forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+6537 -2954
api
appview
config
db
indexer
issues
knots
labels
middleware
models
notifications
notify
oauth
ogcard
pages
pagination
pipelines
pulls
repo
reporesolver
settings
spindles
state
validator
cmd
genjwks
docs
guard
idresolver
knotserver
lexicons
nix
scripts
spindle
types
workflow
+13
.editorconfig
···
··· 1 + root = true 2 + 3 + [*.html] 4 + indent_size = 2 5 + 6 + [*.json] 7 + indent_size = 2 8 + 9 + [*.nix] 10 + indent_size = 2 11 + 12 + [*.yml] 13 + indent_size = 2
+1
.gitignore
··· 15 .env 16 *.rdb 17 .envrc 18 # Created if following hacking.md 19 genjwks.out 20 /nix/vm-data
··· 15 .env 16 *.rdb 17 .envrc 18 + **/*.bleve 19 # Created if following hacking.md 20 genjwks.out 21 /nix/vm-data
+3 -1
api/tangled/actorprofile.go
··· 27 Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 - Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 31 }
··· 27 Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 + // pronouns: Preferred gender pronouns. 31 + Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` 32 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 33 }
+196 -2
api/tangled/cbor_gen.go
··· 26 } 27 28 cw := cbg.NewCborWriter(w) 29 - fieldCount := 7 30 31 if t.Description == nil { 32 fieldCount-- ··· 41 } 42 43 if t.PinnedRepositories == nil { 44 fieldCount-- 45 } 46 ··· 186 return err 187 } 188 if _, err := cw.WriteString(string(*t.Location)); err != nil { 189 return err 190 } 191 } ··· 430 } 431 432 t.Location = (*string)(&sval) 433 } 434 } 435 // t.Description (string) (string) ··· 5806 } 5807 5808 cw := cbg.NewCborWriter(w) 5809 - fieldCount := 8 5810 5811 if t.Description == nil { 5812 fieldCount-- ··· 5821 } 5822 5823 if t.Spindle == nil { 5824 fieldCount-- 5825 } 5826 ··· 5961 } 5962 } 5963 5964 // t.Spindle (string) (string) 5965 if t.Spindle != nil { 5966 ··· 5993 } 5994 } 5995 5996 // t.CreatedAt (string) (string) 5997 if len("createdAt") > 1000000 { 5998 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6185 t.Source = (*string)(&sval) 6186 } 6187 } 6188 // t.Spindle (string) (string) 6189 case "spindle": 6190 ··· 6204 } 6205 6206 t.Spindle = (*string)(&sval) 6207 } 6208 } 6209 // t.CreatedAt (string) (string)
··· 26 } 27 28 cw := cbg.NewCborWriter(w) 29 + fieldCount := 8 30 31 if t.Description == nil { 32 fieldCount-- ··· 41 } 42 43 if t.PinnedRepositories == nil { 44 + fieldCount-- 45 + } 46 + 47 + if t.Pronouns == nil { 48 fieldCount-- 49 } 50 ··· 190 return err 191 } 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 { 225 return err 226 } 227 } ··· 466 } 467 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) 490 } 491 } 492 // t.Description (string) (string) ··· 5863 } 5864 5865 cw := cbg.NewCborWriter(w) 5866 + fieldCount := 10 5867 5868 if t.Description == nil { 5869 fieldCount-- ··· 5878 } 5879 5880 if t.Spindle == nil { 5881 + fieldCount-- 5882 + } 5883 + 5884 + if t.Topics == nil { 5885 + fieldCount-- 5886 + } 5887 + 5888 + if t.Website == nil { 5889 fieldCount-- 5890 } 5891 ··· 6026 } 6027 } 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 + 6065 // t.Spindle (string) (string) 6066 if t.Spindle != nil { 6067 ··· 6094 } 6095 } 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 + 6129 // t.CreatedAt (string) (string) 6130 if len("createdAt") > 1000000 { 6131 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6318 t.Source = (*string)(&sval) 6319 } 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 + } 6361 // t.Spindle (string) (string) 6362 case "spindle": 6363 ··· 6377 } 6378 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) 6401 } 6402 } 6403 // t.CreatedAt (string) (string)
+13 -1
api/tangled/repoblob.go
··· 30 // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 type RepoBlob_Output struct { 32 // content: File content (base64 encoded for binary files) 33 - Content string `json:"content" cborgen:"content"` 34 // encoding: Content encoding 35 Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 // isBinary: Whether the file is binary ··· 44 Ref string `json:"ref" cborgen:"ref"` 45 // size: File size in bytes 46 Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 } 48 49 // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. ··· 54 Name string `json:"name" cborgen:"name"` 55 // when: Author timestamp 56 When string `json:"when" cborgen:"when"` 57 } 58 59 // RepoBlob calls the XRPC method "sh.tangled.repo.blob".
··· 30 // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 type RepoBlob_Output struct { 32 // content: File content (base64 encoded for binary files) 33 + Content *string `json:"content,omitempty" cborgen:"content,omitempty"` 34 // encoding: Content encoding 35 Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 // isBinary: Whether the file is binary ··· 44 Ref string `json:"ref" cborgen:"ref"` 45 // size: File size in bytes 46 Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + // submodule: Submodule information if path is a submodule 48 + Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"` 49 } 50 51 // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. ··· 56 Name string `json:"name" cborgen:"name"` 57 // when: Author timestamp 58 When string `json:"when" cborgen:"when"` 59 + } 60 + 61 + // RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema. 62 + type RepoBlob_Submodule struct { 63 + // branch: Branch to track in the submodule 64 + Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"` 65 + // name: Submodule name 66 + Name string `json:"name" cborgen:"name"` 67 + // url: Submodule repository URL 68 + Url string `json:"url" cborgen:"url"` 69 } 70 71 // RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-4
api/tangled/repotree.go
··· 47 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 49 type RepoTree_TreeEntry struct { 50 - // is_file: Whether this entry is a file 51 - Is_file bool `json:"is_file" cborgen:"is_file"` 52 - // is_subtree: Whether this entry is a directory/subtree 53 - Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 54 Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 55 // mode: File mode 56 Mode string `json:"mode" cborgen:"mode"`
··· 47 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 49 type RepoTree_TreeEntry struct { 50 Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 51 // mode: File mode 52 Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
··· 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 31 // spindle: CI runner to send jobs to and receive results from 32 Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"` 33 }
··· 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 31 // spindle: CI runner to send jobs to and receive results from 32 Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"` 33 + // topics: Topics related to the repo 34 + Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"` 35 + // website: Any URI related to the repo 36 + Website *string `json:"website,omitempty" cborgen:"website,omitempty"` 37 }
+15 -2
appview/config/config.go
··· 13 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 DbPath string `env:"DB_PATH, default=appview.db"` 15 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 Dev bool `env:"DEV, default=false"` 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 ··· 25 } 26 27 type OAuthConfig struct { 28 - Jwks string `env:"JWKS"` 29 } 30 31 type JetstreamConfig struct { ··· 78 TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 } 80 81 func (cfg RedisConfig) ToURL() string { 82 u := &url.URL{ 83 Scheme: "redis", ··· 103 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 104 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 105 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 106 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 107 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 108 } 109 110 func LoadConfig(ctx context.Context) (*Config, error) {
··· 13 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 DbPath string `env:"DB_PATH, default=appview.db"` 15 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"` 17 + AppviewName string `env:"APPVIEW_Name, default=Tangled"` 18 Dev bool `env:"DEV, default=false"` 19 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 20 ··· 26 } 27 28 type OAuthConfig struct { 29 + ClientSecret string `env:"CLIENT_SECRET"` 30 + ClientKid string `env:"CLIENT_KID"` 31 + } 32 + 33 + type PlcConfig struct { 34 + PLCURL string `env:"URL, default=https://plc.directory"` 35 } 36 37 type JetstreamConfig struct { ··· 84 TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 85 } 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 + 92 func (cfg RedisConfig) ToURL() string { 93 u := &url.URL{ 94 Scheme: "redis", ··· 114 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 115 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 116 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 117 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 118 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 119 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 120 + Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 121 } 122 123 func LoadConfig(ctx context.Context) (*Config, error) {
+22
appview/db/db.go
··· 1106 return err 1107 }) 1108 1109 return &DB{ 1110 db, 1111 logger,
··· 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,
+81 -5
appview/db/issues.go
··· 101 pLower := FilterGte("row_num", page.Offset+1) 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 104 - args = append(args, pLower.Arg()...) 105 - args = append(args, pUpper.Arg()...) 106 - pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 107 108 query := fmt.Sprintf( 109 ` ··· 128 %s 129 `, 130 whereClause, 131 - pagination, 132 ) 133 134 rows, err := e.Query(query, args...) ··· 243 return issues, nil 244 } 245 246 func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 - return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 } 249 250 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
··· 101 pLower := FilterGte("row_num", page.Offset+1) 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 104 + pageClause := "" 105 + if page.Limit > 0 { 106 + args = append(args, pLower.Arg()...) 107 + args = append(args, pUpper.Arg()...) 108 + pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 109 + } 110 111 query := fmt.Sprintf( 112 ` ··· 131 %s 132 `, 133 whereClause, 134 + pageClause, 135 ) 136 137 rows, err := e.Query(query, args...) ··· 246 return issues, nil 247 } 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 + 266 func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 267 + return GetIssuesPaginated(e, pagination.Page{}, filters...) 268 + } 269 + 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 + } 291 + 292 + whereClause := "" 293 + if conditions != nil { 294 + whereClause = " where " + strings.Join(conditions, " and ") 295 + } 296 + query := fmt.Sprintf( 297 + ` 298 + select 299 + id 300 + from 301 + issues 302 + %s 303 + limit ? offset ?`, 304 + whereClause, 305 + ) 306 + args = append(args, opts.Page.Limit, opts.Page.Offset) 307 + rows, err := e.Query(query, args...) 308 + if err != nil { 309 + return nil, err 310 + } 311 + defer rows.Close() 312 + 313 + for rows.Next() { 314 + var id int64 315 + err := rows.Scan(&id) 316 + if err != nil { 317 + return nil, err 318 + } 319 + 320 + ids = append(ids, id) 321 + } 322 + 323 + return ids, nil 324 } 325 326 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+22 -9
appview/db/notifications.go
··· 60 whereClause += " AND " + condition 61 } 62 } 63 64 query := fmt.Sprintf(` 65 select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 66 from notifications 67 %s 68 order by created desc 69 - limit ? offset ? 70 - `, whereClause) 71 - 72 - args = append(args, page.Limit, page.Offset) 73 74 rows, err := e.QueryContext(context.Background(), query, args...) 75 if err != nil { ··· 131 select 132 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 133 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 134 - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 135 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, 136 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 137 from notifications n ··· 160 var issue models.Issue 161 var pull models.Pull 162 var rId, iId, pId sql.NullInt64 163 - var rDid, rName, rDescription sql.NullString 164 var iDid sql.NullString 165 var iIssueId sql.NullInt64 166 var iTitle sql.NullString ··· 173 err := rows.Scan( 174 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 175 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 176 - &rId, &rDid, &rName, &rDescription, 177 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 178 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 179 ) ··· 200 } 201 if rDescription.Valid { 202 repo.Description = rDescription.String 203 } 204 nwe.Repo = &repo 205 } ··· 391 pull_created, 392 pull_commented, 393 followed, 394 pull_merged, 395 issue_closed, 396 email_notifications ··· 416 &prefs.PullCreated, 417 &prefs.PullCommented, 418 &prefs.Followed, 419 &prefs.PullMerged, 420 &prefs.IssueClosed, 421 &prefs.EmailNotifications, ··· 437 query := ` 438 INSERT OR REPLACE INTO notification_preferences 439 (user_did, repo_starred, issue_created, issue_commented, pull_created, 440 - pull_commented, followed, pull_merged, issue_closed, email_notifications) 441 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 442 ` 443 444 result, err := d.DB.ExecContext(ctx, query, ··· 449 prefs.PullCreated, 450 prefs.PullCommented, 451 prefs.Followed, 452 prefs.PullMerged, 453 prefs.IssueClosed, 454 prefs.EmailNotifications,
··· 60 whereClause += " AND " + condition 61 } 62 } 63 + pageClause := "" 64 + if page.Limit > 0 { 65 + pageClause = " limit ? offset ? " 66 + args = append(args, page.Limit, page.Offset) 67 + } 68 69 query := fmt.Sprintf(` 70 select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 71 from notifications 72 %s 73 order by created desc 74 + %s 75 + `, whereClause, pageClause) 76 77 rows, err := e.QueryContext(context.Background(), query, args...) 78 if err != nil { ··· 134 select 135 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 136 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 137 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 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, 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 140 from notifications n ··· 163 var issue models.Issue 164 var pull models.Pull 165 var rId, iId, pId sql.NullInt64 166 + var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 167 var iDid sql.NullString 168 var iIssueId sql.NullInt64 169 var iTitle sql.NullString ··· 176 err := rows.Scan( 177 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 178 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 179 + &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 180 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 181 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 182 ) ··· 203 } 204 if rDescription.Valid { 205 repo.Description = rDescription.String 206 + } 207 + if rWebsite.Valid { 208 + repo.Website = rWebsite.String 209 + } 210 + if rTopicStr.Valid { 211 + repo.Topics = strings.Fields(rTopicStr.String) 212 } 213 nwe.Repo = &repo 214 } ··· 400 pull_created, 401 pull_commented, 402 followed, 403 + user_mentioned, 404 pull_merged, 405 issue_closed, 406 email_notifications ··· 426 &prefs.PullCreated, 427 &prefs.PullCommented, 428 &prefs.Followed, 429 + &prefs.UserMentioned, 430 &prefs.PullMerged, 431 &prefs.IssueClosed, 432 &prefs.EmailNotifications, ··· 448 query := ` 449 INSERT OR REPLACE INTO notification_preferences 450 (user_did, repo_starred, issue_created, issue_commented, pull_created, 451 + pull_commented, followed, user_mentioned, pull_merged, issue_closed, 452 + email_notifications) 453 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 454 ` 455 456 result, err := d.DB.ExecContext(ctx, query, ··· 461 prefs.PullCreated, 462 prefs.PullCommented, 463 prefs.Followed, 464 + prefs.UserMentioned, 465 prefs.PullMerged, 466 prefs.IssueClosed, 467 prefs.EmailNotifications,
+26 -6
appview/db/profile.go
··· 129 did, 130 description, 131 include_bluesky, 132 - location 133 ) 134 - values (?, ?, ?, ?)`, 135 profile.Did, 136 profile.Description, 137 includeBskyValue, 138 profile.Location, 139 ) 140 141 if err != nil { ··· 216 did, 217 description, 218 include_bluesky, 219 - location 220 from 221 profile 222 %s`, ··· 231 for rows.Next() { 232 var profile models.Profile 233 var includeBluesky int 234 235 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 236 if err != nil { 237 return nil, err 238 } 239 240 if includeBluesky != 0 { 241 profile.IncludeBluesky = true 242 } 243 244 profileMap[profile.Did] = &profile ··· 302 303 func GetProfile(e Execer, did string) (*models.Profile, error) { 304 var profile models.Profile 305 profile.Did = did 306 307 includeBluesky := 0 308 err := e.QueryRow( 309 - `select description, include_bluesky, location from profile where did = ?`, 310 did, 311 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 312 if err == sql.ErrNoRows { 313 profile := models.Profile{} 314 profile.Did = did ··· 321 322 if includeBluesky != 0 { 323 profile.IncludeBluesky = true 324 } 325 326 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 412 // ensure description is not too long 413 if len(profile.Location) > 40 { 414 return fmt.Errorf("Entered location is too long.") 415 } 416 417 // ensure links are in order
··· 129 did, 130 description, 131 include_bluesky, 132 + location, 133 + pronouns 134 ) 135 + values (?, ?, ?, ?, ?)`, 136 profile.Did, 137 profile.Description, 138 includeBskyValue, 139 profile.Location, 140 + profile.Pronouns, 141 ) 142 143 if err != nil { ··· 218 did, 219 description, 220 include_bluesky, 221 + location, 222 + pronouns 223 from 224 profile 225 %s`, ··· 234 for rows.Next() { 235 var profile models.Profile 236 var includeBluesky int 237 + var pronouns sql.Null[string] 238 239 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 240 if err != nil { 241 return nil, err 242 } 243 244 if includeBluesky != 0 { 245 profile.IncludeBluesky = true 246 + } 247 + 248 + if pronouns.Valid { 249 + profile.Pronouns = pronouns.V 250 } 251 252 profileMap[profile.Did] = &profile ··· 310 311 func GetProfile(e Execer, did string) (*models.Profile, error) { 312 var profile models.Profile 313 + var pronouns sql.Null[string] 314 + 315 profile.Did = did 316 317 includeBluesky := 0 318 + 319 err := e.QueryRow( 320 + `select description, include_bluesky, location, pronouns from profile where did = ?`, 321 did, 322 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 323 if err == sql.ErrNoRows { 324 profile := models.Profile{} 325 profile.Did = did ··· 332 333 if includeBluesky != 0 { 334 profile.IncludeBluesky = true 335 + } 336 + 337 + if pronouns.Valid { 338 + profile.Pronouns = pronouns.V 339 } 340 341 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 427 // ensure description is not too long 428 if len(profile.Location) > 40 { 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.") 435 } 436 437 // ensure links are in order
+66 -5
appview/db/pulls.go
··· 92 _, err = tx.Exec(` 93 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 values (?, ?, ?, ?, ?) 95 - `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 return err 97 } 98 ··· 101 if err != nil { 102 return "", err 103 } 104 - return pull.PullAt(), err 105 } 106 107 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 214 pull.ParentChangeId = parentChangeId.String 215 } 216 217 - pulls[pull.PullAt()] = &pull 218 } 219 220 var pullAts []syntax.ATURI 221 for _, p := range pulls { 222 - pullAts = append(pullAts, p.PullAt()) 223 } 224 submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 if err != nil { ··· 281 return GetPullsWithLimit(e, 0, filters...) 282 } 283 284 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 286 if err != nil { 287 return nil, err 288 } 289 - if pulls == nil { 290 return nil, sql.ErrNoRows 291 } 292
··· 92 _, err = tx.Exec(` 93 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 values (?, ?, ?, ?, ?) 95 + `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 return err 97 } 98 ··· 101 if err != nil { 102 return "", err 103 } 104 + return pull.AtUri(), err 105 } 106 107 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 214 pull.ParentChangeId = parentChangeId.String 215 } 216 217 + pulls[pull.AtUri()] = &pull 218 } 219 220 var pullAts []syntax.ATURI 221 for _, p := range pulls { 222 + pullAts = append(pullAts, p.AtUri()) 223 } 224 submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 if err != nil { ··· 281 return GetPullsWithLimit(e, 0, filters...) 282 } 283 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 + ` 316 + select 317 + id 318 + from 319 + pulls 320 + %s 321 + %s`, 322 + whereClause, 323 + pageClause, 324 + ) 325 + args = append(args, opts.Page.Limit, opts.Page.Offset) 326 + rows, err := e.Query(query, args...) 327 + if err != nil { 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) 340 + } 341 + 342 + return ids, nil 343 + } 344 + 345 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 346 pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 347 if err != nil { 348 return nil, err 349 } 350 + if len(pulls) == 0 { 351 return nil, sql.ErrNoRows 352 } 353
+50 -12
appview/db/repos.go
··· 70 rkey, 71 created, 72 description, 73 source, 74 spindle 75 from ··· 89 for rows.Next() { 90 var repo models.Repo 91 var createdAt string 92 - var description, source, spindle sql.NullString 93 94 err := rows.Scan( 95 &repo.Id, ··· 99 &repo.Rkey, 100 &createdAt, 101 &description, 102 &source, 103 &spindle, 104 ) ··· 111 } 112 if description.Valid { 113 repo.Description = description.String 114 } 115 if source.Valid { 116 repo.Source = source.String ··· 356 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 var repo models.Repo 358 var nullableDescription sql.NullString 359 360 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 361 362 var createdAt string 363 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 364 return nil, err 365 } 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 368 369 if nullableDescription.Valid { 370 repo.Description = nullableDescription.String 371 - } else { 372 - repo.Description = "" 373 } 374 375 return &repo, nil 376 } 377 378 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 _, err := tx.Exec( 380 `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, 384 ) 385 if err != nil { 386 return fmt.Errorf("failed to insert repo: %w", err) ··· 416 var repos []models.Repo 417 418 rows, err := e.Query( 419 - `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 420 from repos r 421 left join collaborators c on r.at_uri = c.repo_at 422 where (r.did = ? or c.subject_did = ?) ··· 434 var repo models.Repo 435 var createdAt string 436 var nullableDescription sql.NullString 437 var nullableSource sql.NullString 438 439 - err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 440 if err != nil { 441 return nil, err 442 } ··· 470 var repo models.Repo 471 var createdAt string 472 var nullableDescription sql.NullString 473 var nullableSource sql.NullString 474 475 row := e.QueryRow( 476 - `select id, did, name, knot, rkey, description, created, source 477 from repos 478 where did = ? and name = ? and source is not null and source != ''`, 479 did, name, 480 ) 481 482 - err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 483 if err != nil { 484 return nil, err 485 } 486 487 if nullableDescription.Valid { 488 repo.Description = nullableDescription.String 489 } 490 491 if nullableSource.Valid {
··· 70 rkey, 71 created, 72 description, 73 + website, 74 + topics, 75 source, 76 spindle 77 from ··· 91 for rows.Next() { 92 var repo models.Repo 93 var createdAt string 94 + var description, website, topicStr, source, spindle sql.NullString 95 96 err := rows.Scan( 97 &repo.Id, ··· 101 &repo.Rkey, 102 &createdAt, 103 &description, 104 + &website, 105 + &topicStr, 106 &source, 107 &spindle, 108 ) ··· 115 } 116 if description.Valid { 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) 124 } 125 if source.Valid { 126 repo.Source = source.String ··· 366 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 367 var repo models.Repo 368 var nullableDescription sql.NullString 369 + var nullableWebsite sql.NullString 370 + var nullableTopicStr sql.NullString 371 372 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 373 374 var createdAt string 375 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 376 return nil, err 377 } 378 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 380 381 if nullableDescription.Valid { 382 repo.Description = nullableDescription.String 383 + } 384 + if nullableWebsite.Valid { 385 + repo.Website = nullableWebsite.String 386 + } 387 + if nullableTopicStr.Valid { 388 + repo.Topics = strings.Fields(nullableTopicStr.String) 389 } 390 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 403 } 404 405 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 406 _, err := tx.Exec( 407 `insert into repos 408 + (did, name, knot, rkey, at_uri, description, website, topics, source) 409 + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 410 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, 411 ) 412 if err != nil { 413 return fmt.Errorf("failed to insert repo: %w", err) ··· 443 var repos []models.Repo 444 445 rows, err := e.Query( 446 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source 447 from repos r 448 left join collaborators c on r.at_uri = c.repo_at 449 where (r.did = ? or c.subject_did = ?) ··· 461 var repo models.Repo 462 var createdAt string 463 var nullableDescription sql.NullString 464 + var nullableWebsite sql.NullString 465 var nullableSource sql.NullString 466 467 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) 468 if err != nil { 469 return nil, err 470 } ··· 498 var repo models.Repo 499 var createdAt string 500 var nullableDescription sql.NullString 501 + var nullableWebsite sql.NullString 502 + var nullableTopicStr sql.NullString 503 var nullableSource sql.NullString 504 505 row := e.QueryRow( 506 + `select id, did, name, knot, rkey, description, website, topics, created, source 507 from repos 508 where did = ? and name = ? and source is not null and source != ''`, 509 did, name, 510 ) 511 512 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) 513 if err != nil { 514 return nil, err 515 } 516 517 if nullableDescription.Valid { 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) 527 } 528 529 if nullableSource.Valid {
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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 + }
+6
appview/ingester.go
··· 291 292 includeBluesky := record.Bluesky 293 294 location := "" 295 if record.Location != nil { 296 location = *record.Location ··· 325 Links: links, 326 Stats: stats, 327 PinnedRepos: pinned, 328 } 329 330 ddb, ok := i.Db.Execer.(*db.DB)
··· 291 292 includeBluesky := record.Bluesky 293 294 + pronouns := "" 295 + if record.Pronouns != nil { 296 + pronouns = *record.Pronouns 297 + } 298 + 299 location := "" 300 if record.Location != nil { 301 location = *record.Location ··· 330 Links: links, 331 Stats: stats, 332 PinnedRepos: pinned, 333 + Pronouns: pronouns, 334 } 335 336 ddb, ok := i.Db.Execer.(*db.DB)
+68 -15
appview/issues/issues.go
··· 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/config" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" 23 "tangled.org/core/appview/notify" 24 "tangled.org/core/appview/oauth" 25 "tangled.org/core/appview/pages" 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" ··· 40 notifier notify.Notifier 41 logger *slog.Logger 42 validator *validator.Validator 43 } 44 45 func New( ··· 51 config *config.Config, 52 notifier notify.Notifier, 53 validator *validator.Validator, 54 logger *slog.Logger, 55 ) *Issues { 56 return &Issues{ ··· 63 notifier: notifier, 64 logger: logger, 65 validator: validator, 66 } 67 } 68 ··· 259 return 260 } 261 262 // return to all issues page 263 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 264 } ··· 299 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 300 return 301 } 302 303 // notify about the issue closure 304 - rp.notifier.NewIssueClosed(r.Context(), issue) 305 306 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 307 return ··· 347 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 348 return 349 } 350 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 351 return 352 } else { ··· 439 440 // notify about the new comment 441 comment.Id = commentId 442 - rp.notifier.NewIssueComment(r.Context(), &comment) 443 444 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 445 } ··· 770 isOpen = true 771 } 772 773 - page, ok := r.Context().Value("page").(pagination.Page) 774 - if !ok { 775 - l.Error("failed to get page") 776 - page = pagination.FirstPage() 777 - } 778 779 user := rp.oauth.GetUser(r) 780 f, err := rp.repoResolver.Resolve(r) ··· 783 return 784 } 785 786 - openVal := 0 787 - if isOpen { 788 - openVal = 1 789 } 790 - issues, err := db.GetIssuesPaginated( 791 rp.db, 792 - page, 793 - db.FilterEq("repo_at", f.RepoAt()), 794 - db.FilterEq("open", openVal), 795 ) 796 if err != nil { 797 l.Error("failed to get issues", "err", err) ··· 821 Issues: issues, 822 LabelDefs: defs, 823 FilteringByOpen: isOpen, 824 Page: page, 825 }) 826 } ··· 847 Rkey: tid.TID(), 848 Title: r.FormValue("title"), 849 Body: r.FormValue("body"), 850 Did: user.Did, 851 Created: time.Now(), 852 Repo: &f.Repo, ··· 916 917 // everything is successful, do not rollback the atproto record 918 atUri = "" 919 - rp.notifier.NewIssue(r.Context(), issue) 920 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 921 return 922 }
··· 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/config" 21 "tangled.org/core/appview/db" 22 + issues_indexer "tangled.org/core/appview/indexer/issues" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pages/markup" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/appview/validator" ··· 42 notifier notify.Notifier 43 logger *slog.Logger 44 validator *validator.Validator 45 + indexer *issues_indexer.Indexer 46 } 47 48 func New( ··· 54 config *config.Config, 55 notifier notify.Notifier, 56 validator *validator.Validator, 57 + indexer *issues_indexer.Indexer, 58 logger *slog.Logger, 59 ) *Issues { 60 return &Issues{ ··· 67 notifier: notifier, 68 logger: logger, 69 validator: validator, 70 + indexer: indexer, 71 } 72 } 73 ··· 264 return 265 } 266 267 + rp.notifier.DeleteIssue(r.Context(), issue) 268 + 269 // return to all issues page 270 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 271 } ··· 306 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 307 return 308 } 309 + // change the issue state (this will pass down to the notifiers) 310 + issue.Open = false 311 312 // notify about the issue closure 313 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 314 315 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 316 return ··· 356 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 357 return 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 + 365 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 366 return 367 } else { ··· 454 455 // notify about the new comment 456 comment.Id = commentId 457 + 458 + rawMentions := markup.FindUserMentions(comment.Body) 459 + idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 460 + l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 461 + var mentions []syntax.DID 462 + for _, ident := range idents { 463 + if ident != nil && !ident.Handle.IsInvalidHandle() { 464 + mentions = append(mentions, ident.DID) 465 + } 466 + } 467 + rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 468 469 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 470 } ··· 795 isOpen = true 796 } 797 798 + page := pagination.FromContext(r.Context()) 799 800 user := rp.oauth.GetUser(r) 801 f, err := rp.repoResolver.Resolve(r) ··· 804 return 805 } 806 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, 815 + } 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( 834 rp.db, 835 + db.FilterIn("id", ids), 836 ) 837 if err != nil { 838 l.Error("failed to get issues", "err", err) ··· 862 Issues: issues, 863 LabelDefs: defs, 864 FilteringByOpen: isOpen, 865 + FilterQuery: keyword, 866 Page: page, 867 }) 868 } ··· 889 Rkey: tid.TID(), 890 Title: r.FormValue("title"), 891 Body: r.FormValue("body"), 892 + Open: true, 893 Did: user.Did, 894 Created: time.Now(), 895 Repo: &f.Repo, ··· 959 960 // everything is successful, do not rollback the atproto record 961 atUri = "" 962 + 963 + rawMentions := markup.FindUserMentions(issue.Body) 964 + idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 965 + l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 966 + var mentions []syntax.DID 967 + for _, ident := range idents { 968 + if ident != nil && !ident.Handle.IsInvalidHandle() { 969 + mentions = append(mentions, ident.DID) 970 + } 971 + } 972 + rp.notifier.NewIssue(r.Context(), issue, mentions) 973 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 974 return 975 }
+5 -5
appview/issues/opengraph.go
··· 143 var statusBgColor color.RGBA 144 145 if issue.Open { 146 - statusIcon = "static/icons/circle-dot.svg" 147 statusText = "open" 148 statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 } else { 150 - statusIcon = "static/icons/circle-dot.svg" 151 statusText = "closed" 152 statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 } ··· 155 badgeIconSize := 36 156 157 // Draw icon with status color (no background) 158 - err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 if err != nil { 160 log.Printf("failed to draw status icon: %v", err) 161 } ··· 172 currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 174 // Draw comment count 175 - err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 if err != nil { 177 log.Printf("failed to draw comment icon: %v", err) 178 } ··· 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.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 197 if err != nil { 198 log.Printf("dolly silhouette not available (this is ok): %v", err) 199 }
··· 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 } ··· 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 } ··· 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 } ··· 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 }
+9
appview/knots/knots.go
··· 6 "log/slog" 7 "net/http" 8 "slices" 9 "time" 10 11 "github.com/go-chi/chi/v5" ··· 145 } 146 147 domain := r.FormValue("domain") 148 if domain == "" { 149 k.Pages.Notice(w, noticeId, "Incomplete form.") 150 return ··· 526 } 527 528 member := r.FormValue("member") 529 if member == "" { 530 l.Error("empty member") 531 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 626 } 627 628 member := r.FormValue("member") 629 if member == "" { 630 l.Error("empty member") 631 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
··· 6 "log/slog" 7 "net/http" 8 "slices" 9 + "strings" 10 "time" 11 12 "github.com/go-chi/chi/v5" ··· 146 } 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, "/") 155 if domain == "" { 156 k.Pages.Notice(w, noticeId, "Incomplete form.") 157 return ··· 533 } 534 535 member := r.FormValue("member") 536 + member = strings.TrimPrefix(member, "@") 537 if member == "" { 538 l.Error("empty member") 539 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 634 } 635 636 member := r.FormValue("member") 637 + member = strings.TrimPrefix(member, "@") 638 if member == "" { 639 l.Error("empty member") 640 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+1 -1
appview/labels/labels.go
··· 53 } 54 } 55 56 - func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 57 r := chi.NewRouter() 58 59 r.Use(middleware.AuthMiddleware(l.oauth))
··· 53 } 54 } 55 56 + func (l *Labels) Router() http.Handler { 57 r := chi.NewRouter() 58 59 r.Use(middleware.AuthMiddleware(l.oauth))
+11 -16
appview/middleware/middleware.go
··· 105 } 106 } 107 108 - ctx := context.WithValue(r.Context(), "page", page) 109 next.ServeHTTP(w, r.WithContext(ctx)) 110 }) 111 } ··· 180 return func(next http.Handler) http.Handler { 181 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 didOrHandle := chi.URLParam(req, "user") 183 if slices.Contains(excluded, didOrHandle) { 184 next.ServeHTTP(w, req) 185 return 186 } 187 - 188 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 if err != nil { ··· 206 return func(next http.Handler) http.Handler { 207 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 repoName := chi.URLParam(req, "repo") 209 id, ok := req.Context().Value("resolvedId").(identity.Identity) 210 if !ok { 211 log.Println("malformed middleware") ··· 244 prId := chi.URLParam(r, "pull") 245 prIdInt, err := strconv.Atoi(prId) 246 if err != nil { 247 - http.Error(w, "bad pr id", http.StatusBadRequest) 248 log.Println("failed to parse pr id", err) 249 return 250 } 251 252 pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 253 if err != nil { 254 log.Println("failed to get pull and comments", err) 255 return 256 } 257 ··· 292 issueId, err := strconv.Atoi(issueIdStr) 293 if err != nil { 294 log.Println("failed to fully resolve issue ID", err) 295 - mw.pages.ErrorKnot404(w) 296 return 297 } 298 299 - issues, err := db.GetIssues( 300 - mw.db, 301 - db.FilterEq("repo_at", f.RepoAt()), 302 - db.FilterEq("issue_id", issueId), 303 - ) 304 if err != nil { 305 log.Println("failed to get issues", "err", err) 306 return 307 } 308 - if len(issues) != 1 { 309 - log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 310 - return 311 - } 312 - issue := issues[0] 313 314 - ctx := context.WithValue(r.Context(), "issue", &issue) 315 next.ServeHTTP(w, r.WithContext(ctx)) 316 }) 317 }
··· 105 } 106 } 107 108 + ctx := pagination.IntoContext(r.Context(), page) 109 next.ServeHTTP(w, r.WithContext(ctx)) 110 }) 111 } ··· 180 return func(next http.Handler) http.Handler { 181 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 didOrHandle := chi.URLParam(req, "user") 183 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 184 + 185 if slices.Contains(excluded, didOrHandle) { 186 next.ServeHTTP(w, req) 187 return 188 } 189 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 if err != nil { ··· 206 return func(next http.Handler) http.Handler { 207 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 repoName := chi.URLParam(req, "repo") 209 + repoName = strings.TrimSuffix(repoName, ".git") 210 + 211 id, ok := req.Context().Value("resolvedId").(identity.Identity) 212 if !ok { 213 log.Println("malformed middleware") ··· 246 prId := chi.URLParam(r, "pull") 247 prIdInt, err := strconv.Atoi(prId) 248 if err != nil { 249 log.Println("failed to parse pr id", err) 250 + mw.pages.Error404(w) 251 return 252 } 253 254 pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 255 if err != nil { 256 log.Println("failed to get pull and comments", err) 257 + mw.pages.Error404(w) 258 return 259 } 260 ··· 295 issueId, err := strconv.Atoi(issueIdStr) 296 if err != nil { 297 log.Println("failed to fully resolve issue ID", err) 298 + mw.pages.Error404(w) 299 return 300 } 301 302 + issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId) 303 if err != nil { 304 log.Println("failed to get issues", "err", err) 305 + mw.pages.Error404(w) 306 return 307 } 308 309 + ctx := context.WithValue(r.Context(), "issue", issue) 310 next.ServeHTTP(w, r.WithContext(ctx)) 311 }) 312 }
+25 -43
appview/models/label.go
··· 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/bluesky-social/indigo/xrpc" 16 "tangled.org/core/api/tangled" 17 - "tangled.org/core/consts" 18 "tangled.org/core/idresolver" 19 ) 20 ··· 461 return result 462 } 463 464 - var ( 465 - LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 - LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 - LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 - LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 - LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 - ) 471 472 - func DefaultLabelDefs() []string { 473 - return []string{ 474 - LabelWontfix, 475 - LabelDuplicate, 476 - LabelAssignee, 477 - LabelGoodFirstIssue, 478 - LabelDocumentation, 479 - } 480 - } 481 482 - func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 - resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 - if err != nil { 485 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 - } 487 - pdsEndpoint := resolved.PDSEndpoint() 488 - if pdsEndpoint == "" { 489 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 - } 491 - client := &xrpc.Client{ 492 - Host: pdsEndpoint, 493 - } 494 495 - var labelDefs []LabelDefinition 496 497 - for _, dl := range DefaultLabelDefs() { 498 - atUri := syntax.ATURI(dl) 499 - parsedUri, err := syntax.ParseATURI(string(atUri)) 500 - if err != nil { 501 - return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 - } 503 record, err := atproto.RepoGetRecord( 504 - context.Background(), 505 - client, 506 "", 507 - parsedUri.Collection().String(), 508 - parsedUri.Authority().String(), 509 - parsedUri.RecordKey().String(), 510 ) 511 if err != nil { 512 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) ··· 526 } 527 528 labelDef, err := LabelDefinitionFromRecord( 529 - parsedUri.Authority().String(), 530 - parsedUri.RecordKey().String(), 531 labelRecord, 532 ) 533 if err != nil {
··· 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/bluesky-social/indigo/xrpc" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/idresolver" 18 ) 19 ··· 460 return result 461 } 462 463 + func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 464 + var labelDefs []LabelDefinition 465 + ctx := context.Background() 466 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()) 477 + if err != nil { 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(), 483 + } 484 485 record, err := atproto.RepoGetRecord( 486 + ctx, 487 + &xrpcc, 488 "", 489 + atUri.Collection().String(), 490 + atUri.Authority().String(), 491 + atUri.RecordKey().String(), 492 ) 493 if err != nil { 494 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) ··· 508 } 509 510 labelDef, err := LabelDefinitionFromRecord( 511 + atUri.Authority().String(), 512 + atUri.RecordKey().String(), 513 labelRecord, 514 ) 515 if err != nil {
+17
appview/models/notifications.go
··· 17 NotificationTypeFollowed NotificationType = "followed" 18 NotificationTypePullMerged NotificationType = "pull_merged" 19 NotificationTypeIssueClosed NotificationType = "issue_closed" 20 NotificationTypePullClosed NotificationType = "pull_closed" 21 ) 22 23 type Notification struct { ··· 47 return "message-square" 48 case NotificationTypeIssueClosed: 49 return "ban" 50 case NotificationTypePullCreated: 51 return "git-pull-request-create" 52 case NotificationTypePullCommented: ··· 55 return "git-merge" 56 case NotificationTypePullClosed: 57 return "git-pull-request-closed" 58 case NotificationTypeFollowed: 59 return "user-plus" 60 default: 61 return "" 62 } ··· 78 PullCreated bool 79 PullCommented bool 80 Followed bool 81 PullMerged bool 82 IssueClosed bool 83 EmailNotifications bool ··· 93 return prefs.IssueCommented 94 case NotificationTypeIssueClosed: 95 return prefs.IssueClosed 96 case NotificationTypePullCreated: 97 return prefs.PullCreated 98 case NotificationTypePullCommented: ··· 101 return prefs.PullMerged 102 case NotificationTypePullClosed: 103 return prefs.PullMerged // same pref for now 104 case NotificationTypeFollowed: 105 return prefs.Followed 106 default: 107 return false 108 } ··· 117 PullCreated: true, 118 PullCommented: true, 119 Followed: true, 120 PullMerged: true, 121 IssueClosed: true, 122 EmailNotifications: false,
··· 17 NotificationTypeFollowed NotificationType = "followed" 18 NotificationTypePullMerged NotificationType = "pull_merged" 19 NotificationTypeIssueClosed NotificationType = "issue_closed" 20 + NotificationTypeIssueReopen NotificationType = "issue_reopen" 21 NotificationTypePullClosed NotificationType = "pull_closed" 22 + NotificationTypePullReopen NotificationType = "pull_reopen" 23 + NotificationTypeUserMentioned NotificationType = "user_mentioned" 24 ) 25 26 type Notification struct { ··· 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: ··· 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 } ··· 87 PullCreated bool 88 PullCommented bool 89 Followed bool 90 + UserMentioned bool 91 PullMerged bool 92 IssueClosed bool 93 EmailNotifications bool ··· 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: ··· 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 } ··· 133 PullCreated: true, 134 PullCommented: true, 135 Followed: true, 136 + UserMentioned: true, 137 PullMerged: true, 138 IssueClosed: true, 139 EmailNotifications: false,
+1
appview/models/profile.go
··· 19 Links [5]string 20 Stats [2]VanityStat 21 PinnedRepos [6]syntax.ATURI 22 } 23 24 func (p Profile) IsLinksEmpty() bool {
··· 19 Links [5]string 20 Stats [2]VanityStat 21 PinnedRepos [6]syntax.ATURI 22 + Pronouns string 23 } 24 25 func (p Profile) IsLinksEmpty() bool {
+1 -1
appview/models/pull.go
··· 167 return p.LatestSubmission().SourceRev 168 } 169 170 - func (p *Pull) PullAt() syntax.ATURI { 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 172 } 173
··· 167 return p.LatestSubmission().SourceRev 168 } 169 170 + func (p *Pull) AtUri() syntax.ATURI { 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 172 } 173
+61 -1
appview/models/repo.go
··· 2 3 import ( 4 "fmt" 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" ··· 17 Rkey string 18 Created time.Time 19 Description string 20 Spindle string 21 Labels []string 22 ··· 28 } 29 30 func (r *Repo) AsRecord() tangled.Repo { 31 - var source, spindle, description *string 32 33 if r.Source != "" { 34 source = &r.Source ··· 42 description = &r.Description 43 } 44 45 return tangled.Repo{ 46 Knot: r.Knot, 47 Name: r.Name, 48 Description: description, 49 CreatedAt: r.Created.Format(time.RFC3339), 50 Source: source, 51 Spindle: spindle, ··· 60 func (r Repo) DidSlashRepo() string { 61 p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 return p 63 } 64 65 type RepoStats struct { ··· 91 Repo *Repo 92 Issues []Issue 93 }
··· 2 3 import ( 4 "fmt" 5 + "strings" 6 "time" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 18 Rkey string 19 Created time.Time 20 Description string 21 + Website string 22 + Topics []string 23 Spindle string 24 Labels []string 25 ··· 31 } 32 33 func (r *Repo) AsRecord() tangled.Repo { 34 + var source, spindle, description, website *string 35 36 if r.Source != "" { 37 source = &r.Source ··· 45 description = &r.Description 46 } 47 48 + if r.Website != "" { 49 + website = &r.Website 50 + } 51 + 52 return tangled.Repo{ 53 Knot: r.Knot, 54 Name: r.Name, 55 Description: description, 56 + Website: website, 57 + Topics: r.Topics, 58 CreatedAt: r.Created.Format(time.RFC3339), 59 Source: source, 60 Spindle: spindle, ··· 69 func (r Repo) DidSlashRepo() string { 70 p, _ := securejoin.SecureJoin(r.Did, r.Name) 71 return p 72 + } 73 + 74 + func (r Repo) TopicStr() string { 75 + return strings.Join(r.Topics, " ") 76 } 77 78 type RepoStats struct { ··· 104 Repo *Repo 105 Issues []Issue 106 } 107 + 108 + type BlobContentType int 109 + 110 + const ( 111 + BlobContentTypeCode BlobContentType = iota 112 + BlobContentTypeMarkup 113 + BlobContentTypeImage 114 + BlobContentTypeSvg 115 + BlobContentTypeVideo 116 + BlobContentTypeSubmodule 117 + ) 118 + 119 + func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode } 120 + func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup } 121 + func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage } 122 + func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg } 123 + func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo } 124 + func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule } 125 + 126 + type BlobView struct { 127 + HasTextView bool // can show as code/text 128 + HasRenderedView bool // can show rendered (markup/image/video/submodule) 129 + HasRawView bool // can download raw (everything except submodule) 130 + 131 + // current display mode 132 + ShowingRendered bool // currently in rendered mode 133 + ShowingText bool // currently in text/code mode 134 + 135 + // content type flags 136 + ContentType BlobContentType 137 + 138 + // Content data 139 + Contents string 140 + ContentSrc string // URL for media files 141 + Lines int 142 + SizeHint uint64 143 + } 144 + 145 + // if both views are available, then show a toggle between them 146 + func (b BlobView) ShowToggle() bool { 147 + return b.HasTextView && b.HasRenderedView 148 + } 149 + 150 + func (b BlobView) IsUnsupported() bool { 151 + // no view available, only raw 152 + return !(b.HasRenderedView || b.HasTextView) 153 + }
+31
appview/models/search.go
···
··· 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 + // }
+1 -5
appview/notifications/notifications.go
··· 49 l := n.logger.With("handler", "notificationsPage") 50 user := n.oauth.GetUser(r) 51 52 - page, ok := r.Context().Value("page").(pagination.Page) 53 - if !ok { 54 - l.Error("failed to get page") 55 - page = pagination.FirstPage() 56 - } 57 58 total, err := db.CountNotifications( 59 n.db,
··· 49 l := n.logger.With("handler", "notificationsPage") 50 user := n.oauth.GetUser(r) 51 52 + page := pagination.FromContext(r.Context()) 53 54 total, err := db.CountNotifications( 55 n.db,
+71 -63
appview/notify/db/db.go
··· 13 "tangled.org/core/idresolver" 14 ) 15 16 type databaseNotifier struct { 17 db *db.DB 18 res *idresolver.Resolver ··· 64 // no-op 65 } 66 67 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 68 69 // build the recipients list 70 // - owner of the repo ··· 81 } 82 83 actorDid := syntax.DID(issue.Did) 84 - eventType := models.NotificationTypeIssueCreated 85 entityType := "issue" 86 entityId := issue.AtUri().String() 87 repoId := &issue.Repo.Id ··· 91 n.notifyEvent( 92 actorDid, 93 recipients, 94 - eventType, 95 entityType, 96 entityId, 97 repoId, ··· 100 ) 101 } 102 103 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 104 issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 105 if err != nil { 106 log.Printf("NewIssueComment: failed to get issues: %v", err) ··· 132 } 133 134 actorDid := syntax.DID(comment.Did) 135 - eventType := models.NotificationTypeIssueCommented 136 entityType := "issue" 137 entityId := issue.AtUri().String() 138 repoId := &issue.Repo.Id ··· 142 n.notifyEvent( 143 actorDid, 144 recipients, 145 - eventType, 146 entityType, 147 entityId, 148 repoId, ··· 151 ) 152 } 153 154 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 155 actorDid := syntax.DID(follow.UserDid) 156 recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} ··· 199 actorDid := syntax.DID(pull.OwnerDid) 200 eventType := models.NotificationTypePullCreated 201 entityType := "pull" 202 - entityId := pull.PullAt().String() 203 repoId := &repo.Id 204 var issueId *int64 205 p := int64(pull.ID) ··· 217 ) 218 } 219 220 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 221 pull, err := db.GetPull(n.db, 222 syntax.ATURI(comment.RepoAt), 223 comment.PullId, ··· 245 actorDid := syntax.DID(comment.OwnerDid) 246 eventType := models.NotificationTypePullCommented 247 entityType := "pull" 248 - entityId := pull.PullAt().String() 249 repoId := &repo.Id 250 var issueId *int64 251 p := int64(pull.ID) ··· 261 issueId, 262 pullId, 263 ) 264 } 265 266 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 279 // no-op 280 } 281 282 - func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 283 // build up the recipients list: 284 // - repo owner 285 // - repo collaborators ··· 298 recipients = append(recipients, syntax.DID(p)) 299 } 300 301 - actorDid := syntax.DID(issue.Repo.Did) 302 - eventType := models.NotificationTypeIssueClosed 303 entityType := "pull" 304 entityId := issue.AtUri().String() 305 repoId := &issue.Repo.Id 306 issueId := &issue.Id 307 var pullId *int64 308 - 309 - n.notifyEvent( 310 - actorDid, 311 - recipients, 312 - eventType, 313 - entityType, 314 - entityId, 315 - repoId, 316 - issueId, 317 - pullId, 318 - ) 319 - } 320 321 - func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 322 - // Get repo details 323 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 324 - if err != nil { 325 - log.Printf("NewPullMerged: failed to get repos: %v", err) 326 - return 327 - } 328 - 329 - // build up the recipients list: 330 - // - repo owner 331 - // - all pull participants 332 - var recipients []syntax.DID 333 - recipients = append(recipients, syntax.DID(repo.Did)) 334 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 335 - if err != nil { 336 - log.Printf("failed to fetch collaborators: %v", err) 337 - return 338 - } 339 - for _, c := range collaborators { 340 - recipients = append(recipients, c.SubjectDid) 341 - } 342 - for _, p := range pull.Participants() { 343 - recipients = append(recipients, syntax.DID(p)) 344 } 345 346 - actorDid := syntax.DID(repo.Did) 347 - eventType := models.NotificationTypePullMerged 348 - entityType := "pull" 349 - entityId := pull.PullAt().String() 350 - repoId := &repo.Id 351 - var issueId *int64 352 - p := int64(pull.ID) 353 - pullId := &p 354 - 355 n.notifyEvent( 356 - actorDid, 357 recipients, 358 eventType, 359 entityType, ··· 364 ) 365 } 366 367 - func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 368 // Get repo details 369 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 370 if err != nil { 371 - log.Printf("NewPullMerged: failed to get repos: %v", err) 372 return 373 } 374 ··· 389 recipients = append(recipients, syntax.DID(p)) 390 } 391 392 - actorDid := syntax.DID(repo.Did) 393 - eventType := models.NotificationTypePullClosed 394 entityType := "pull" 395 - entityId := pull.PullAt().String() 396 repoId := &repo.Id 397 var issueId *int64 398 p := int64(pull.ID) 399 pullId := &p 400 401 n.notifyEvent( 402 - actorDid, 403 recipients, 404 eventType, 405 entityType, ··· 420 issueId *int64, 421 pullId *int64, 422 ) { 423 recipientSet := make(map[syntax.DID]struct{}) 424 for _, did := range recipients { 425 // everybody except actor themselves
··· 13 "tangled.org/core/idresolver" 14 ) 15 16 + const ( 17 + maxMentions = 5 18 + ) 19 + 20 type databaseNotifier struct { 21 db *db.DB 22 res *idresolver.Resolver ··· 68 // no-op 69 } 70 71 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 72 73 // build the recipients list 74 // - owner of the repo ··· 85 } 86 87 actorDid := syntax.DID(issue.Did) 88 entityType := "issue" 89 entityId := issue.AtUri().String() 90 repoId := &issue.Repo.Id ··· 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, ··· 113 ) 114 } 115 116 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 117 issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 118 if err != nil { 119 log.Printf("NewIssueComment: failed to get issues: %v", err) ··· 145 } 146 147 actorDid := syntax.DID(comment.Did) 148 entityType := "issue" 149 entityId := issue.AtUri().String() 150 repoId := &issue.Repo.Id ··· 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, ··· 173 ) 174 } 175 176 + func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 177 + // no-op for now 178 + } 179 + 180 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 181 actorDid := syntax.DID(follow.UserDid) 182 recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} ··· 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) ··· 243 ) 244 } 245 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, ··· 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) ··· 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 + ) 300 } 301 302 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 315 // no-op 316 } 317 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 ··· 334 recipients = append(recipients, syntax.DID(p)) 335 } 336 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 343 344 + if issue.Open { 345 + eventType = models.NotificationTypeIssueReopen 346 + } else { 347 + eventType = models.NotificationTypeIssueClosed 348 } 349 350 n.notifyEvent( 351 + actor, 352 recipients, 353 eventType, 354 entityType, ··· 359 ) 360 } 361 362 + func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 363 // Get repo details 364 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 365 if err != nil { 366 + log.Printf("NewPullState: failed to get repos: %v", err) 367 return 368 } 369 ··· 384 recipients = append(recipients, syntax.DID(p)) 385 } 386 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) 401 + return 402 + } 403 p := int64(pull.ID) 404 pullId := &p 405 406 n.notifyEvent( 407 + actor, 408 recipients, 409 eventType, 410 entityType, ··· 425 issueId *int64, 426 pullId *int64, 427 ) { 428 + if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 429 + recipients = recipients[:maxMentions] 430 + } 431 recipientSet := make(map[syntax.DID]struct{}) 432 for _, did := range recipients { 433 // everybody except actor themselves
+57 -59
appview/notify/merged_notifier.go
··· 2 3 import ( 4 "context" 5 6 "tangled.org/core/appview/models" 7 ) 8 9 type mergedNotifier struct { 10 notifiers []Notifier 11 } 12 13 - func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 - return &mergedNotifier{notifiers} 15 } 16 17 var _ Notifier = &mergedNotifier{} 18 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 22 } 23 } 24 25 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 29 } 30 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 34 } 35 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 40 } 41 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 - for _, notifier := range m.notifiers { 43 - notifier.NewIssueComment(ctx, comment) 44 - } 45 } 46 47 - func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 - for _, notifier := range m.notifiers { 49 - notifier.NewIssueClosed(ctx, issue) 50 - } 51 } 52 53 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewFollow(ctx, follow) 56 - } 57 } 58 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 59 - for _, notifier := range m.notifiers { 60 - notifier.DeleteFollow(ctx, follow) 61 - } 62 } 63 64 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 - } 73 } 74 75 - func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 - for _, notifier := range m.notifiers { 77 - notifier.NewPullMerged(ctx, pull) 78 - } 79 } 80 81 - func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 - for _, notifier := range m.notifiers { 83 - notifier.NewPullClosed(ctx, pull) 84 - } 85 } 86 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 - for _, notifier := range m.notifiers { 89 - notifier.UpdateProfile(ctx, profile) 90 - } 91 } 92 93 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 - for _, notifier := range m.notifiers { 95 - notifier.NewString(ctx, string) 96 - } 97 } 98 99 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 - for _, notifier := range m.notifiers { 101 - notifier.EditString(ctx, string) 102 - } 103 } 104 105 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 - for _, notifier := range m.notifiers { 107 - notifier.DeleteString(ctx, did, rkey) 108 - } 109 }
··· 2 3 import ( 4 "context" 5 + "log/slog" 6 + "reflect" 7 + "sync" 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/log" 12 ) 13 14 type mergedNotifier struct { 15 notifiers []Notifier 16 + logger *slog.Logger 17 } 18 19 + func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier { 20 + return &mergedNotifier{notifiers, logger} 21 } 22 23 var _ Notifier = &mergedNotifier{} 24 25 + // fanout calls the same method on all notifiers concurrently 26 + func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) { 27 + ctx = log.IntoContext(ctx, m.logger.With("method", method)) 28 + var wg sync.WaitGroup 29 + for _, n := range m.notifiers { 30 + wg.Add(1) 31 + go func(notifier Notifier) { 32 + defer wg.Done() 33 + v := reflect.ValueOf(notifier).MethodByName(method) 34 + in := make([]reflect.Value, len(args)+1) 35 + in[0] = reflect.ValueOf(ctx) 36 + for i, arg := range args { 37 + in[i+1] = reflect.ValueOf(arg) 38 + } 39 + v.Call(in) 40 + }(n) 41 } 42 + wg.Wait() 43 + } 44 + 45 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 46 + m.fanout("NewRepo", ctx, repo) 47 } 48 49 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 50 + m.fanout("NewStar", ctx, star) 51 } 52 + 53 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 54 + m.fanout("DeleteStar", ctx, star) 55 } 56 57 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 58 + m.fanout("NewIssue", ctx, issue, mentions) 59 } 60 + 61 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 62 + m.fanout("NewIssueComment", ctx, comment, mentions) 63 } 64 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) 71 } 72 73 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 74 + m.fanout("NewFollow", ctx, follow) 75 } 76 + 77 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 78 + m.fanout("DeleteFollow", ctx, follow) 79 } 80 81 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 82 + m.fanout("NewPull", ctx, pull) 83 } 84 85 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 86 + m.fanout("NewPullComment", ctx, comment, mentions) 87 } 88 89 + func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 90 + m.fanout("NewPullState", ctx, actor, pull) 91 } 92 93 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 94 + m.fanout("UpdateProfile", ctx, profile) 95 } 96 97 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 98 + m.fanout("NewString", ctx, s) 99 } 100 101 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 102 + m.fanout("EditString", ctx, s) 103 } 104 105 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 + m.fanout("DeleteString", ctx, did, rkey) 107 }
+16 -13
appview/notify/notifier.go
··· 3 import ( 4 "context" 5 6 "tangled.org/core/appview/models" 7 ) 8 ··· 12 NewStar(ctx context.Context, star *models.Star) 13 DeleteStar(ctx context.Context, star *models.Star) 14 15 - NewIssue(ctx context.Context, issue *models.Issue) 16 - NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 - NewIssueClosed(ctx context.Context, issue *models.Issue) 18 19 NewFollow(ctx context.Context, follow *models.Follow) 20 DeleteFollow(ctx context.Context, follow *models.Follow) 21 22 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) 26 27 UpdateProfile(ctx context.Context, profile *models.Profile) 28 ··· 41 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 43 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) {} 47 48 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 50 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) {} 55 56 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 57
··· 3 import ( 4 "context" 5 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 "tangled.org/core/appview/models" 8 ) 9 ··· 13 NewStar(ctx context.Context, star *models.Star) 14 DeleteStar(ctx context.Context, star *models.Star) 15 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) 20 21 NewFollow(ctx context.Context, follow *models.Follow) 22 DeleteFollow(ctx context.Context, follow *models.Follow) 23 24 NewPull(ctx context.Context, pull *models.Pull) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 + NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 27 28 UpdateProfile(ctx context.Context, profile *models.Profile) 29 ··· 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 44 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) {} 50 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 53 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) {} 58 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 60
+33 -9
appview/notify/posthog/notifier.go
··· 4 "context" 5 "log" 6 7 "github.com/posthog/posthog-go" 8 "tangled.org/core/appview/models" 9 "tangled.org/core/appview/notify" ··· 56 } 57 } 58 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 err := n.client.Enqueue(posthog.Capture{ 61 DistinctId: issue.Did, 62 Event: "new_issue", 63 Properties: posthog.Properties{ 64 "repo_at": issue.RepoAt.String(), 65 "issue_id": issue.IssueId, 66 }, 67 }) 68 if err != nil { ··· 84 } 85 } 86 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 err := n.client.Enqueue(posthog.Capture{ 89 DistinctId: comment.OwnerDid, 90 Event: "new_pull_comment", 91 Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 }, 95 }) 96 if err != nil { ··· 177 } 178 } 179 180 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 err := n.client.Enqueue(posthog.Capture{ 182 DistinctId: comment.Did, 183 Event: "new_issue_comment", 184 Properties: posthog.Properties{ 185 "issue_at": comment.IssueAt, 186 }, 187 }) 188 if err != nil { ··· 190 } 191 } 192 193 - func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 err := n.client.Enqueue(posthog.Capture{ 195 DistinctId: issue.Did, 196 - Event: "issue_closed", 197 Properties: posthog.Properties{ 198 "repo_at": issue.RepoAt.String(), 199 "issue_id": issue.IssueId, 200 }, 201 }) ··· 204 } 205 } 206 207 - func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 err := n.client.Enqueue(posthog.Capture{ 209 DistinctId: pull.OwnerDid, 210 - Event: "pull_merged", 211 Properties: posthog.Properties{ 212 "repo_at": pull.RepoAt, 213 "pull_id": pull.PullId, 214 }, 215 }) 216 if err != nil {
··· 4 "context" 5 "log" 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 "github.com/posthog/posthog-go" 9 "tangled.org/core/appview/models" 10 "tangled.org/core/appview/notify" ··· 57 } 58 } 59 60 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 61 err := n.client.Enqueue(posthog.Capture{ 62 DistinctId: issue.Did, 63 Event: "new_issue", 64 Properties: posthog.Properties{ 65 "repo_at": issue.RepoAt.String(), 66 "issue_id": issue.IssueId, 67 + "mentions": mentions, 68 }, 69 }) 70 if err != nil { ··· 86 } 87 } 88 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 90 err := n.client.Enqueue(posthog.Capture{ 91 DistinctId: comment.OwnerDid, 92 Event: "new_pull_comment", 93 Properties: posthog.Properties{ 94 + "repo_at": comment.RepoAt, 95 + "pull_id": comment.PullId, 96 + "mentions": mentions, 97 }, 98 }) 99 if err != nil { ··· 180 } 181 } 182 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 184 err := n.client.Enqueue(posthog.Capture{ 185 DistinctId: comment.Did, 186 Event: "new_issue_comment", 187 Properties: posthog.Properties{ 188 "issue_at": comment.IssueAt, 189 + "mentions": mentions, 190 }, 191 }) 192 if err != nil { ··· 194 } 195 } 196 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 + } 204 err := n.client.Enqueue(posthog.Capture{ 205 DistinctId: issue.Did, 206 + Event: event, 207 Properties: posthog.Properties{ 208 "repo_at": issue.RepoAt.String(), 209 + "actor": actor, 210 "issue_id": issue.IssueId, 211 }, 212 }) ··· 215 } 216 } 217 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 + } 231 err := n.client.Enqueue(posthog.Capture{ 232 DistinctId: pull.OwnerDid, 233 + Event: event, 234 Properties: posthog.Properties{ 235 "repo_at": pull.RepoAt, 236 "pull_id": pull.PullId, 237 + "actor": actor, 238 }, 239 }) 240 if err != nil {
+18 -15
appview/oauth/handler.go
··· 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "net/http" 9 "slices" 10 "time" 11 12 "github.com/go-chi/chi/v5" 13 - "github.com/lestrrat-go/jwx/v2/jwk" 14 "github.com/posthog/posthog-go" 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/appview/db" ··· 30 func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 31 doc := o.ClientApp.Config.ClientMetadata() 32 doc.JWKSURI = &o.JwksUri 33 34 w.Header().Set("Content-Type", "application/json") 35 if err := json.NewEncoder(w).Encode(doc); err != nil { ··· 39 } 40 41 func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 42 - jwks := o.Config.OAuth.Jwks 43 - pubKey, err := pubKeyFromJwk(jwks) 44 - if err != nil { 45 - o.Logger.Error("error parsing public key", "err", err) 46 http.Error(w, err.Error(), http.StatusInternalServerError) 47 return 48 } 49 - 50 - response := map[string]any{ 51 - "keys": []jwk.Key{pubKey}, 52 - } 53 - 54 - w.Header().Set("Content-Type", "application/json") 55 - w.WriteHeader(http.StatusOK) 56 - json.NewEncoder(w).Encode(response) 57 } 58 59 func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 60 ctx := r.Context() 61 62 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 63 if err != nil { 64 - http.Error(w, err.Error(), http.StatusInternalServerError) 65 return 66 } 67 68 if err := o.SaveSession(w, r, sessData); err != nil { 69 - http.Error(w, err.Error(), http.StatusInternalServerError) 70 return 71 } 72
··· 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" ··· 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 { ··· 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
+35 -22
appview/oauth/oauth.go
··· 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 "github.com/bluesky-social/indigo/atproto/syntax" 14 xrpc "github.com/bluesky-social/indigo/xrpc" 15 "github.com/gorilla/sessions" 16 - "github.com/lestrrat-go/jwx/v2/jwk" 17 "github.com/posthog/posthog-go" 18 "tangled.org/core/appview/config" 19 "tangled.org/core/appview/db" ··· 26 SessStore *sessions.CookieStore 27 Config *config.Config 28 JwksUri string 29 Posthog posthog.Client 30 Db *db.DB 31 Enforcer *rbac.Enforcer ··· 34 } 35 36 func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 37 - 38 var oauthConfig oauth.ClientConfig 39 var clientUri string 40 - 41 if config.Core.Dev { 42 clientUri = "http://127.0.0.1:3000" 43 callbackUri := clientUri + "/oauth/callback" ··· 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"}) 50 } 51 52 jwksUri := clientUri + "/oauth/jwks.json" 53 54 - authStore, err := NewRedisStore(config.Redis.ToURL()) 55 if err != nil { 56 return nil, err 57 } 58 59 sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 60 61 return &OAuth{ 62 - ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 63 Config: config, 64 SessStore: sessStore, 65 JwksUri: jwksUri, 66 Posthog: ph, 67 Db: db, 68 Enforcer: enforcer, ··· 137 return errors.Join(err1, err2) 138 } 139 140 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 141 - k, err := jwk.ParseKey([]byte(jwks)) 142 - if err != nil { 143 - return nil, err 144 - } 145 - pubKey, err := k.PublicKey() 146 - if err != nil { 147 - return nil, err 148 - } 149 - return pubKey, nil 150 - } 151 - 152 type User struct { 153 Did string 154 Pds string 155 } 156 157 func (o *OAuth) GetUser(r *http.Request) *User { 158 - sess, err := o.SessStore.Get(r, SessionName) 159 - 160 - if err != nil || sess.IsNew { 161 return nil 162 } 163 164 return &User{ 165 - Did: sess.Values[SessionDid].(string), 166 - Pds: sess.Values[SessionPds].(string), 167 } 168 } 169
··· 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" 16 "github.com/gorilla/sessions" 17 "github.com/posthog/posthog-go" 18 "tangled.org/core/appview/config" 19 "tangled.org/core/appview/db" ··· 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 ··· 36 } 37 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" ··· 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"}) 50 + } 51 + 52 + // configure client secret 53 + priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret) 54 + if err != nil { 55 + return nil, err 56 + } 57 + if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil { 58 + return nil, err 59 } 60 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 + }) 69 if err != nil { 70 return nil, err 71 } 72 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 80 + } 81 + 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, ··· 163 return errors.Join(err1, err2) 164 } 165 166 type User struct { 167 Did string 168 Pds string 169 } 170 171 func (o *OAuth) GetUser(r *http.Request) *User { 172 + sess, err := o.ResumeSession(r) 173 + if err != nil { 174 return nil 175 } 176 177 return &User{ 178 + Did: sess.Data.AccountDID.String(), 179 + Pds: sess.Data.HostURL, 180 } 181 } 182
+110 -11
appview/oauth/store.go
··· 11 "github.com/redis/go-redis/v9" 12 ) 13 14 // redis-backed implementation of ClientAuthStore. 15 type RedisStore struct { 16 - client *redis.Client 17 - SessionTTL time.Duration 18 - AuthRequestTTL time.Duration 19 } 20 21 var _ oauth.ClientAuthStore = &RedisStore{} 22 23 - func NewRedisStore(redisURL string) (*RedisStore, error) { 24 - opts, err := redis.ParseURL(redisURL) 25 if err != nil { 26 return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 } ··· 37 } 38 39 return &RedisStore{ 40 - client: client, 41 - SessionTTL: 30 * 24 * time.Hour, // 30 days 42 - AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 }, nil 44 } 45 ··· 51 return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 } 53 54 func authRequestKey(state string) string { 55 return fmt.Sprintf("oauth:auth_request:%s", state) 56 } 57 58 func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 key := sessionKey(did, sessionID) 60 data, err := r.client.Get(ctx, key).Bytes() 61 if err == redis.Nil { 62 return nil, fmt.Errorf("session not found: %s", did) ··· 75 76 func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 key := sessionKey(sess.AccountDID, sess.SessionID) 78 79 data, err := json.Marshal(sess) 80 if err != nil { 81 return fmt.Errorf("failed to marshal session: %w", err) 82 } 83 84 - if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 return fmt.Errorf("failed to save session: %w", err) 86 } 87 88 return nil 89 } 90 91 func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 key := sessionKey(did, sessionID) 93 - if err := r.client.Del(ctx, key).Err(); err != nil { 94 return fmt.Errorf("failed to delete session: %w", err) 95 } 96 return nil ··· 131 return fmt.Errorf("failed to marshal auth request: %w", err) 132 } 133 134 - if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 return fmt.Errorf("failed to save auth request: %w", err) 136 } 137
··· 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 } ··· 67 } 68 69 return &RedisStore{ 70 + client: client, 71 + cfg: cfg, 72 }, nil 73 } 74 ··· 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) ··· 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 ··· 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
+59 -10
appview/ogcard/card.go
··· 7 import ( 8 "bytes" 9 "fmt" 10 "image" 11 "image/color" 12 "io" ··· 279 return width, nil 280 } 281 282 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 - func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 - svgData, err := pages.Files.ReadFile(svgPath) 285 - if err != nil { 286 - return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 - } 288 - 289 // Convert color to hex string for SVG 290 rgba, isRGBA := iconColor.(color.RGBA) 291 if !isRGBA { ··· 304 // Parse SVG 305 icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 if err != nil { 307 - return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 } 309 310 // Set the icon size 311 w, h := float64(size), float64(size) 312 icon.SetTarget(0, 0, w, h) ··· 334 } 335 336 draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 - 338 - return nil 339 } 340 341 // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
··· 7 import ( 8 "bytes" 9 "fmt" 10 + "html/template" 11 "image" 12 "image/color" 13 "io" ··· 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 { ··· 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) ··· 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
+65 -6
appview/pages/funcmap.go
··· 1 package pages 2 3 import ( 4 "context" 5 "crypto/hmac" 6 "crypto/sha256" ··· 17 "strings" 18 "time" 19 20 "github.com/dustin/go-humanize" 21 "github.com/go-enry/go-enry/v2" 22 "tangled.org/core/appview/filetree" ··· 38 "contains": func(s string, target string) bool { 39 return strings.Contains(s, target) 40 }, 41 "mapContains": func(m any, key any) bool { 42 mapValue := reflect.ValueOf(m) 43 if mapValue.Kind() != reflect.Map { ··· 57 return "handle.invalid" 58 } 59 60 - return "@" + identity.Handle.String() 61 }, 62 "truncateAt30": func(s string) string { 63 if len(s) <= 30 { ··· 67 }, 68 "splitOn": func(s, sep string) []string { 69 return strings.Split(s, sep) 70 }, 71 "int64": func(a int) int64 { 72 return int64(a) ··· 117 return b 118 }, 119 "didOrHandle": func(did, handle string) string { 120 - if handle != "" { 121 - return fmt.Sprintf("@%s", handle) 122 } else { 123 return did 124 } ··· 236 sanitized := p.rctx.SanitizeDescription(htmlString) 237 return template.HTML(sanitized) 238 }, 239 "isNil": func(t any) bool { 240 // returns false for other "zero" values 241 return t == nil ··· 281 u, _ := url.PathUnescape(s) 282 return u 283 }, 284 - 285 "tinyAvatar": func(handle string) string { 286 return p.AvatarUrl(handle, "tiny") 287 }, ··· 297 }, 298 299 "normalizeForHtmlId": func(s string) string { 300 - // TODO: extend this to handle other cases? 301 - return strings.ReplaceAll(s, ":", "_") 302 }, 303 "sshFingerprint": func(pubKey string) string { 304 fp, err := crypto.SSHFingerprint(pubKey)
··· 1 package pages 2 3 import ( 4 + "bytes" 5 "context" 6 "crypto/hmac" 7 "crypto/sha256" ··· 18 "strings" 19 "time" 20 21 + "github.com/alecthomas/chroma/v2" 22 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 + "github.com/alecthomas/chroma/v2/lexers" 24 + "github.com/alecthomas/chroma/v2/styles" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 "github.com/dustin/go-humanize" 27 "github.com/go-enry/go-enry/v2" 28 "tangled.org/core/appview/filetree" ··· 44 "contains": func(s string, target string) bool { 45 return strings.Contains(s, target) 46 }, 47 + "stripPort": func(hostname string) string { 48 + if strings.Contains(hostname, ":") { 49 + return strings.Split(hostname, ":")[0] 50 + } 51 + return hostname 52 + }, 53 "mapContains": func(m any, key any) bool { 54 mapValue := reflect.ValueOf(m) 55 if mapValue.Kind() != reflect.Map { ··· 69 return "handle.invalid" 70 } 71 72 + return identity.Handle.String() 73 }, 74 "truncateAt30": func(s string) string { 75 if len(s) <= 30 { ··· 79 }, 80 "splitOn": func(s, sep string) []string { 81 return strings.Split(s, sep) 82 + }, 83 + "string": func(v any) string { 84 + return fmt.Sprint(v) 85 }, 86 "int64": func(a int) int64 { 87 return int64(a) ··· 132 return b 133 }, 134 "didOrHandle": func(did, handle string) string { 135 + if handle != "" && handle != syntax.HandleInvalid.String() { 136 + return handle 137 } else { 138 return did 139 } ··· 251 sanitized := p.rctx.SanitizeDescription(htmlString) 252 return template.HTML(sanitized) 253 }, 254 + "readme": func(text string) template.HTML { 255 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 256 + htmlString := p.rctx.RenderMarkdown(text) 257 + sanitized := p.rctx.SanitizeDefault(htmlString) 258 + return template.HTML(sanitized) 259 + }, 260 + "code": func(content, path string) string { 261 + var style *chroma.Style = styles.Get("catpuccin-latte") 262 + formatter := chromahtml.New( 263 + chromahtml.InlineCode(false), 264 + chromahtml.WithLineNumbers(true), 265 + chromahtml.WithLinkableLineNumbers(true, "L"), 266 + chromahtml.Standalone(false), 267 + chromahtml.WithClasses(true), 268 + ) 269 + 270 + lexer := lexers.Get(filepath.Base(path)) 271 + if lexer == nil { 272 + lexer = lexers.Fallback 273 + } 274 + 275 + iterator, err := lexer.Tokenise(nil, content) 276 + if err != nil { 277 + p.logger.Error("chroma tokenize", "err", "err") 278 + return "" 279 + } 280 + 281 + var code bytes.Buffer 282 + err = formatter.Format(&code, style, iterator) 283 + if err != nil { 284 + p.logger.Error("chroma format", "err", "err") 285 + return "" 286 + } 287 + 288 + return code.String() 289 + }, 290 + "trimUriScheme": func(text string) string { 291 + text = strings.TrimPrefix(text, "https://") 292 + text = strings.TrimPrefix(text, "http://") 293 + return text 294 + }, 295 "isNil": func(t any) bool { 296 // returns false for other "zero" values 297 return t == nil ··· 337 u, _ := url.PathUnescape(s) 338 return u 339 }, 340 + "safeUrl": func(s string) template.URL { 341 + return template.URL(s) 342 + }, 343 "tinyAvatar": func(handle string) string { 344 return p.AvatarUrl(handle, "tiny") 345 }, ··· 355 }, 356 357 "normalizeForHtmlId": func(s string) string { 358 + normalized := strings.ReplaceAll(s, ":", "_") 359 + normalized = strings.ReplaceAll(normalized, ".", "_") 360 + return normalized 361 }, 362 "sshFingerprint": func(pubKey string) string { 363 fp, err := crypto.SSHFingerprint(pubKey)
+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 + }
+32 -1
appview/pages/markup/markdown.go
··· 25 htmlparse "golang.org/x/net/html" 26 27 "tangled.org/core/api/tangled" 28 "tangled.org/core/appview/pages/repoinfo" 29 ) 30 ··· 50 Files fs.FS 51 } 52 53 - func (rctx *RenderContext) RenderMarkdown(source string) string { 54 md := goldmark.New( 55 goldmark.WithExtensions( 56 extension.GFM, ··· 66 ), 67 treeblood.MathML(), 68 callout.CalloutExtention, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(), 72 ), 73 goldmark.WithRendererOptions(html.WithUnsafe()), 74 ) 75 76 if rctx != nil { 77 var transformers []util.PrioritizedValue ··· 293 } 294 295 return path.Join(rctx.CurrentDir, dst) 296 } 297 298 func isAbsoluteUrl(link string) bool {
··· 25 htmlparse "golang.org/x/net/html" 26 27 "tangled.org/core/api/tangled" 28 + textension "tangled.org/core/appview/pages/markup/extension" 29 "tangled.org/core/appview/pages/repoinfo" 30 ) 31 ··· 51 Files fs.FS 52 } 53 54 + func NewMarkdown() goldmark.Markdown { 55 md := goldmark.New( 56 goldmark.WithExtensions( 57 extension.GFM, ··· 67 ), 68 treeblood.MathML(), 69 callout.CalloutExtention, 70 + textension.AtExt, 71 ), 72 goldmark.WithParserOptions( 73 parser.WithAutoHeadingID(), 74 ), 75 goldmark.WithRendererOptions(html.WithUnsafe()), 76 ) 77 + return md 78 + } 79 + 80 + func (rctx *RenderContext) RenderMarkdown(source string) string { 81 + md := NewMarkdown() 82 83 if rctx != nil { 84 var transformers []util.PrioritizedValue ··· 300 } 301 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 327 } 328 329 func isAbsoluteUrl(link string) bool {
+3
appview/pages/markup/sanitizer.go
··· 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 80 // centering content 81 policy.AllowElements("center") 82
··· 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 80 + // at-mentions 81 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a") 82 + 83 // centering content 84 policy.AllowElements("center") 85
+46 -130
appview/pages/pages.go
··· 1 package pages 2 3 import ( 4 - "bytes" 5 "crypto/sha256" 6 "embed" 7 "encoding/hex" ··· 15 "path/filepath" 16 "strings" 17 "sync" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" ··· 28 "tangled.org/core/patchutil" 29 "tangled.org/core/types" 30 31 - "github.com/alecthomas/chroma/v2" 32 - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 - "github.com/alecthomas/chroma/v2/lexers" 34 - "github.com/alecthomas/chroma/v2/styles" 35 "github.com/bluesky-social/indigo/atproto/identity" 36 "github.com/bluesky-social/indigo/atproto/syntax" 37 "github.com/go-git/go-git/v5/plumbing" ··· 221 222 type LoginParams struct { 223 ReturnUrl string 224 } 225 226 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 638 return p.executePlain("repo/fragments/repoStar", w, params) 639 } 640 641 - type RepoDescriptionParams struct { 642 - RepoInfo repoinfo.RepoInfo 643 - } 644 - 645 - func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 646 - return p.executePlain("repo/fragments/editRepoDescription", w, params) 647 - } 648 - 649 - func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 650 - return p.executePlain("repo/fragments/repoDescription", w, params) 651 - } 652 - 653 type RepoIndexParams struct { 654 LoggedInUser *oauth.User 655 RepoInfo repoinfo.RepoInfo ··· 659 TagsTrunc []*types.TagReference 660 BranchesTrunc []types.Branch 661 // ForkInfo *types.ForkInfo 662 - HTMLReadme template.HTML 663 - Raw bool 664 - EmailToDidOrHandle map[string]string 665 - VerifiedCommits commitverify.VerifiedCommits 666 - Languages []types.RepoLanguageDetails 667 - Pipelines map[string]models.Pipeline 668 - NeedsKnotUpgrade bool 669 types.RepoIndexResponse 670 } 671 ··· 700 } 701 702 type RepoLogParams struct { 703 - LoggedInUser *oauth.User 704 - RepoInfo repoinfo.RepoInfo 705 - TagMap map[string][]string 706 types.RepoLogResponse 707 - Active string 708 - EmailToDidOrHandle map[string]string 709 - VerifiedCommits commitverify.VerifiedCommits 710 - Pipelines map[string]models.Pipeline 711 } 712 713 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 716 } 717 718 type RepoCommitParams struct { 719 - LoggedInUser *oauth.User 720 - RepoInfo repoinfo.RepoInfo 721 - Active string 722 - EmailToDidOrHandle map[string]string 723 - Pipeline *models.Pipeline 724 - DiffOpts types.DiffOpts 725 726 // singular because it's always going to be just one 727 VerifiedCommit commitverify.VerifiedCommits ··· 753 func (r RepoTreeParams) TreeStats() RepoTreeStats { 754 numFolders, numFiles := 0, 0 755 for _, f := range r.Files { 756 - if !f.IsFile { 757 numFolders += 1 758 - } else if f.IsFile { 759 numFiles += 1 760 } 761 } ··· 826 } 827 828 type RepoBlobParams struct { 829 - LoggedInUser *oauth.User 830 - RepoInfo repoinfo.RepoInfo 831 - Active string 832 - Unsupported bool 833 - IsImage bool 834 - IsVideo bool 835 - ContentSrc string 836 - BreadCrumbs [][]string 837 - ShowRendered bool 838 - RenderToggle bool 839 - RenderedContents template.HTML 840 *tangled.RepoBlob_Output 841 - // Computed fields for template compatibility 842 - Contents string 843 - Lines int 844 - SizeHint uint64 845 - IsBinary bool 846 } 847 848 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 849 - var style *chroma.Style = styles.Get("catpuccin-latte") 850 - 851 - if params.ShowRendered { 852 - switch markup.GetFormat(params.Path) { 853 - case markup.FormatMarkdown: 854 - p.rctx.RepoInfo = params.RepoInfo 855 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 856 - htmlString := p.rctx.RenderMarkdown(params.Contents) 857 - sanitized := p.rctx.SanitizeDefault(htmlString) 858 - params.RenderedContents = template.HTML(sanitized) 859 - } 860 } 861 862 - c := params.Contents 863 - formatter := chromahtml.New( 864 - chromahtml.InlineCode(false), 865 - chromahtml.WithLineNumbers(true), 866 - chromahtml.WithLinkableLineNumbers(true, "L"), 867 - chromahtml.Standalone(false), 868 - chromahtml.WithClasses(true), 869 - ) 870 - 871 - lexer := lexers.Get(filepath.Base(params.Path)) 872 - if lexer == nil { 873 - lexer = lexers.Fallback 874 - } 875 - 876 - iterator, err := lexer.Tokenise(nil, c) 877 - if err != nil { 878 - return fmt.Errorf("chroma tokenize: %w", err) 879 - } 880 - 881 - var code bytes.Buffer 882 - err = formatter.Format(&code, style, iterator) 883 - if err != nil { 884 - return fmt.Errorf("chroma format: %w", err) 885 - } 886 - 887 - params.Contents = code.String() 888 params.Active = "overview" 889 return p.executeRepo("repo/blob", w, params) 890 } ··· 970 LabelDefs map[string]*models.LabelDefinition 971 Page pagination.Page 972 FilteringByOpen bool 973 } 974 975 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 1100 Pulls []*models.Pull 1101 Active string 1102 FilteringBy models.PullState 1103 Stacks map[string]models.Stack 1104 Pipelines map[string]models.Pipeline 1105 LabelDefs map[string]*models.LabelDefinition ··· 1357 Name string 1358 Command string 1359 Collapsed bool 1360 } 1361 1362 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1363 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1364 } 1365 1366 type LogLineParams struct { 1367 Id int 1368 Content string ··· 1428 } 1429 1430 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1431 - var style *chroma.Style = styles.Get("catpuccin-latte") 1432 - 1433 - if params.ShowRendered { 1434 - switch markup.GetFormat(params.String.Filename) { 1435 - case markup.FormatMarkdown: 1436 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1437 - htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1438 - sanitized := p.rctx.SanitizeDefault(htmlString) 1439 - params.RenderedContents = template.HTML(sanitized) 1440 - } 1441 - } 1442 - 1443 - c := params.String.Contents 1444 - formatter := chromahtml.New( 1445 - chromahtml.InlineCode(false), 1446 - chromahtml.WithLineNumbers(true), 1447 - chromahtml.WithLinkableLineNumbers(true, "L"), 1448 - chromahtml.Standalone(false), 1449 - chromahtml.WithClasses(true), 1450 - ) 1451 - 1452 - lexer := lexers.Get(filepath.Base(params.String.Filename)) 1453 - if lexer == nil { 1454 - lexer = lexers.Fallback 1455 - } 1456 - 1457 - iterator, err := lexer.Tokenise(nil, c) 1458 - if err != nil { 1459 - return fmt.Errorf("chroma tokenize: %w", err) 1460 - } 1461 - 1462 - var code bytes.Buffer 1463 - err = formatter.Format(&code, style, iterator) 1464 - if err != nil { 1465 - return fmt.Errorf("chroma format: %w", err) 1466 - } 1467 - 1468 - params.String.Contents = code.String() 1469 return p.execute("strings/string", w, params) 1470 } 1471
··· 1 package pages 2 3 import ( 4 "crypto/sha256" 5 "embed" 6 "encoding/hex" ··· 14 "path/filepath" 15 "strings" 16 "sync" 17 + "time" 18 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/commitverify" ··· 28 "tangled.org/core/patchutil" 29 "tangled.org/core/types" 30 31 "github.com/bluesky-social/indigo/atproto/identity" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 "github.com/go-git/go-git/v5/plumbing" ··· 217 218 type LoginParams struct { 219 ReturnUrl string 220 + ErrorCode string 221 } 222 223 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 635 return p.executePlain("repo/fragments/repoStar", w, params) 636 } 637 638 type RepoIndexParams struct { 639 LoggedInUser *oauth.User 640 RepoInfo repoinfo.RepoInfo ··· 644 TagsTrunc []*types.TagReference 645 BranchesTrunc []types.Branch 646 // ForkInfo *types.ForkInfo 647 + HTMLReadme template.HTML 648 + Raw bool 649 + EmailToDid map[string]string 650 + VerifiedCommits commitverify.VerifiedCommits 651 + Languages []types.RepoLanguageDetails 652 + Pipelines map[string]models.Pipeline 653 + NeedsKnotUpgrade bool 654 types.RepoIndexResponse 655 } 656 ··· 685 } 686 687 type RepoLogParams struct { 688 + LoggedInUser *oauth.User 689 + RepoInfo repoinfo.RepoInfo 690 + TagMap map[string][]string 691 + Active string 692 + EmailToDid map[string]string 693 + VerifiedCommits commitverify.VerifiedCommits 694 + Pipelines map[string]models.Pipeline 695 + 696 types.RepoLogResponse 697 } 698 699 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 702 } 703 704 type RepoCommitParams struct { 705 + LoggedInUser *oauth.User 706 + RepoInfo repoinfo.RepoInfo 707 + Active string 708 + EmailToDid map[string]string 709 + Pipeline *models.Pipeline 710 + DiffOpts types.DiffOpts 711 712 // singular because it's always going to be just one 713 VerifiedCommit commitverify.VerifiedCommits ··· 739 func (r RepoTreeParams) TreeStats() RepoTreeStats { 740 numFolders, numFiles := 0, 0 741 for _, f := range r.Files { 742 + if !f.IsFile() { 743 numFolders += 1 744 + } else if f.IsFile() { 745 numFiles += 1 746 } 747 } ··· 812 } 813 814 type RepoBlobParams struct { 815 + LoggedInUser *oauth.User 816 + RepoInfo repoinfo.RepoInfo 817 + Active string 818 + BreadCrumbs [][]string 819 + BlobView models.BlobView 820 *tangled.RepoBlob_Output 821 } 822 823 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 824 + switch params.BlobView.ContentType { 825 + case models.BlobContentTypeMarkup: 826 + p.rctx.RepoInfo = params.RepoInfo 827 } 828 829 params.Active = "overview" 830 return p.executeRepo("repo/blob", w, params) 831 } ··· 911 LabelDefs map[string]*models.LabelDefinition 912 Page pagination.Page 913 FilteringByOpen bool 914 + FilterQuery string 915 } 916 917 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 1042 Pulls []*models.Pull 1043 Active string 1044 FilteringBy models.PullState 1045 + FilterQuery string 1046 Stacks map[string]models.Stack 1047 Pipelines map[string]models.Pipeline 1048 LabelDefs map[string]*models.LabelDefinition ··· 1300 Name string 1301 Command string 1302 Collapsed bool 1303 + StartTime time.Time 1304 } 1305 1306 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1307 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1308 } 1309 1310 + type LogBlockEndParams struct { 1311 + Id int 1312 + StartTime time.Time 1313 + EndTime time.Time 1314 + } 1315 + 1316 + func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1317 + return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1318 + } 1319 + 1320 type LogLineParams struct { 1321 Id int 1322 Content string ··· 1382 } 1383 1384 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1385 return p.execute("strings/string", w, params) 1386 } 1387
+7 -7
appview/pages/repoinfo/repoinfo.go
··· 1 package repoinfo 2 3 import ( 4 - "fmt" 5 "path" 6 "slices" 7 - "strings" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "tangled.org/core/appview/models" 11 "tangled.org/core/appview/state/userutil" 12 ) 13 14 - func (r RepoInfo) OwnerWithAt() string { 15 if r.OwnerHandle != "" { 16 - return fmt.Sprintf("@%s", r.OwnerHandle) 17 } else { 18 return r.OwnerDid 19 } 20 } 21 22 func (r RepoInfo) FullName() string { 23 - return path.Join(r.OwnerWithAt(), r.Name) 24 } 25 26 func (r RepoInfo) OwnerWithoutAt() string { 27 - if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 - return after 29 } else { 30 return userutil.FlattenDid(r.OwnerDid) 31 } ··· 56 OwnerDid string 57 OwnerHandle string 58 Description string 59 Knot string 60 Spindle string 61 RepoAt syntax.ATURI
··· 1 package repoinfo 2 3 import ( 4 "path" 5 "slices" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 "tangled.org/core/appview/models" 9 "tangled.org/core/appview/state/userutil" 10 ) 11 12 + func (r RepoInfo) Owner() string { 13 if r.OwnerHandle != "" { 14 + return r.OwnerHandle 15 } else { 16 return r.OwnerDid 17 } 18 } 19 20 func (r RepoInfo) FullName() string { 21 + return path.Join(r.Owner(), r.Name) 22 } 23 24 func (r RepoInfo) OwnerWithoutAt() string { 25 + if r.OwnerHandle != "" { 26 + return r.OwnerHandle 27 } else { 28 return userutil.FlattenDid(r.OwnerDid) 29 } ··· 54 OwnerDid string 55 OwnerHandle string 56 Description string 57 + Website string 58 + Topics []string 59 Knot string 60 Spindle string 61 RepoAt syntax.ATURI
+82 -54
appview/pages/templates/fragments/dolly/logo.html
··· 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&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 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> 56 {{ end }}
··· 1 {{ define "fragments/dolly/logo" }} 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{ . }}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 10 + inkscape:export-filename="tangled_logotype_black_on_trans.svg" 11 + inkscape:export-xdpi="96" 12 + inkscape:export-ydpi="96" 13 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 14 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 15 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 16 + xmlns="http://www.w3.org/2000/svg" 17 + xmlns:svg="http://www.w3.org/2000/svg" 18 + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 + xmlns:cc="http://creativecommons.org/ns#"> 20 + <sodipodi:namedview 21 + id="namedview1" 22 + pagecolor="#ffffff" 23 + bordercolor="#000000" 24 + borderopacity="0.25" 25 + inkscape:showpageshadow="2" 26 + inkscape:pageopacity="0.0" 27 + inkscape:pagecheckerboard="true" 28 + inkscape:deskcolor="#d5d5d5" 29 + inkscape:zoom="45.254834" 30 + inkscape:cx="3.1377863" 31 + inkscape:cy="8.9382717" 32 + inkscape:window-width="3840" 33 + inkscape:window-height="2160" 34 + inkscape:window-x="0" 35 + inkscape:window-y="0" 36 + inkscape:window-maximized="0" 37 + inkscape:current-layer="g1" 38 + borderlayer="true"> 39 + <inkscape:page 40 + x="0" 41 + y="0" 42 + width="25" 43 + height="25" 44 + id="page2" 45 + margin="0" 46 + bleed="0" /> 47 + </sodipodi:namedview> 48 + <g 49 + inkscape:groupmode="layer" 50 + inkscape:label="Image" 51 + id="g1" 52 + transform="translate(-0.42924038,-0.87777209)"> 53 + <path 54 + fill="currentColor" 55 + style="stroke-width:0.111183;" 56 + d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 57 + id="path4" 58 + sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" /> 59 + </g> 60 + <metadata 61 + id="metadata1"> 62 + <rdf:RDF> 63 + <cc:Work 64 + rdf:about=""> 65 + <cc:license 66 + rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 67 + </cc:Work> 68 + <cc:License 69 + rdf:about="http://creativecommons.org/licenses/by/4.0/"> 70 + <cc:permits 71 + rdf:resource="http://creativecommons.org/ns#Reproduction" /> 72 + <cc:permits 73 + rdf:resource="http://creativecommons.org/ns#Distribution" /> 74 + <cc:requires 75 + rdf:resource="http://creativecommons.org/ns#Notice" /> 76 + <cc:requires 77 + rdf:resource="http://creativecommons.org/ns#Attribution" /> 78 + <cc:permits 79 + rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 80 + </cc:License> 81 + </rdf:RDF> 82 + </metadata> 83 + </svg> 84 {{ end }}
+60 -22
appview/pages/templates/fragments/dolly/silhouette.html
··· 2 <svg 3 version="1.1" 4 id="svg1" 5 - width="32" 6 - height="32" 7 viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_silhouette.png" 9 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 10 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 11 xmlns="http://www.w3.org/2000/svg" 12 - xmlns:svg="http://www.w3.org/2000/svg"> 13 - <style> 14 - .dolly { 15 - color: #000000; 16 - } 17 18 - @media (prefers-color-scheme: dark) { 19 - .dolly { 20 - color: #ffffff; 21 - } 22 - } 23 - </style> 24 - <title>Dolly</title> 25 - <defs 26 - id="defs1" /> 27 <sodipodi:namedview 28 id="namedview1" 29 pagecolor="#ffffff" ··· 32 inkscape:showpageshadow="2" 33 inkscape:pageopacity="0.0" 34 inkscape:pagecheckerboard="true" 35 - inkscape:deskcolor="#d1d1d1"> 36 <inkscape:page 37 x="0" 38 y="0" ··· 45 <g 46 inkscape:groupmode="layer" 47 inkscape:label="Image" 48 - id="g1"> 49 <path 50 class="dolly" 51 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" /> 55 </g> 56 </svg> 57 {{ end }}
··· 2 <svg 3 version="1.1" 4 id="svg1" 5 + width="25" 6 + height="25" 7 viewBox="0 0 25 25" 8 + sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 9 + inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg" 10 + inkscape:export-xdpi="96" 11 + inkscape:export-ydpi="96" 12 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 13 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 14 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 15 xmlns="http://www.w3.org/2000/svg" 16 + xmlns:svg="http://www.w3.org/2000/svg" 17 + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 18 + xmlns:cc="http://creativecommons.org/ns#"> 19 + <style> 20 + .dolly { 21 + color: #000000; 22 + } 23 24 + @media (prefers-color-scheme: dark) { 25 + .dolly { 26 + color: #ffffff; 27 + } 28 + } 29 + </style> 30 <sodipodi:namedview 31 id="namedview1" 32 pagecolor="#ffffff" ··· 35 inkscape:showpageshadow="2" 36 inkscape:pageopacity="0.0" 37 inkscape:pagecheckerboard="true" 38 + inkscape:deskcolor="#d5d5d5" 39 + inkscape:zoom="64" 40 + inkscape:cx="4.96875" 41 + inkscape:cy="13.429688" 42 + inkscape:window-width="3840" 43 + inkscape:window-height="2160" 44 + inkscape:window-x="0" 45 + inkscape:window-y="0" 46 + inkscape:window-maximized="0" 47 + inkscape:current-layer="g1" 48 + borderlayer="true"> 49 <inkscape:page 50 x="0" 51 y="0" ··· 58 <g 59 inkscape:groupmode="layer" 60 inkscape:label="Image" 61 + id="g1" 62 + transform="translate(-0.42924038,-0.87777209)"> 63 <path 64 class="dolly" 65 fill="currentColor" 66 + style="stroke-width:0.111183" 67 + d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z" 68 + id="path7" 69 + sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" /> 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> 94 </svg> 95 {{ end }}
-44
appview/pages/templates/fragments/dolly/silhouette.svg
··· 1 - <svg 2 - version="1.1" 3 - id="svg1" 4 - width="32" 5 - height="32" 6 - viewBox="0 0 25 25" 7 - sodipodi:docname="tangled_dolly_silhouette.png" 8 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 - xmlns="http://www.w3.org/2000/svg" 11 - xmlns:svg="http://www.w3.org/2000/svg"> 12 - <title>Dolly</title> 13 - <defs 14 - id="defs1" /> 15 - <sodipodi:namedview 16 - id="namedview1" 17 - pagecolor="#ffffff" 18 - bordercolor="#000000" 19 - borderopacity="0.25" 20 - inkscape:showpageshadow="2" 21 - inkscape:pageopacity="0.0" 22 - inkscape:pagecheckerboard="true" 23 - inkscape:deskcolor="#d1d1d1"> 24 - <inkscape:page 25 - x="0" 26 - y="0" 27 - width="25" 28 - height="25" 29 - id="page2" 30 - margin="0" 31 - bleed="0" /> 32 - </sodipodi:namedview> 33 - <g 34 - inkscape:groupmode="layer" 35 - inkscape:label="Image" 36 - id="g1"> 37 - <path 38 - class="dolly" 39 - fill="currentColor" 40 - style="stroke-width:1.12248" 41 - 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" 42 - id="path1" /> 43 - </g> 44 - </svg>
···
+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
···
··· 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 }}
+17 -9
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 <div 14 id="add-member-{{ .Id }}" 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"> 17 {{ block "addKnotMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }} ··· 29 ADD MEMBER 30 </label> 31 <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 39 <div class="flex gap-2 pt-2"> 40 <button 41 type="button" ··· 54 </div> 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 </form> 57 - {{ end }}
··· 13 <div 14 id="add-member-{{ .Id }}" 15 popover 16 + class=" 17 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 19 {{ block "addKnotMemberPopover" . }} {{ end }} 20 </div> 21 {{ end }} ··· 31 ADD MEMBER 32 </label> 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 47 <div class="flex gap-2 pt-2"> 48 <button 49 type="button" ··· 62 </div> 63 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 64 </form> 65 + {{ end }}
+1
appview/pages/templates/layouts/base.html
··· 9 10 <script defer src="/static/htmx.min.js"></script> 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 13 <!-- preconnect to image cdn --> 14 <link rel="preconnect" href="https://avatar.tangled.sh" />
··· 9 10 <script defer src="/static/htmx.min.js"></script> 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + <script defer src="/static/actor-typeahead.js" type="module"></script> 13 14 <!-- preconnect to image cdn --> 15 <link rel="preconnect" href="https://avatar.tangled.sh" />
+5 -9
appview/pages/templates/layouts/fragments/topbar.html
··· 15 {{ with .LoggedInUser }} 16 {{ block "newButton" . }} {{ end }} 17 {{ template "notifications/fragments/bell" }} 18 - {{ block "dropDown" . }} {{ end }} 19 {{ else }} 20 <a href="/login">login</a> 21 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 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"> 37 <a href="/repo/new" class="flex items-center gap-2"> 38 {{ i "book-plus" "w-4 h-4" }} 39 new repository ··· 46 </details> 47 {{ end }} 48 49 - {{ define "dropDown" }} 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 := .Did }} 55 <img 56 src="{{ tinyAvatar $user }}" ··· 59 /> 60 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 61 </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 - > 65 <a href="/{{ $user }}">profile</a> 66 <a href="/{{ $user }}?tab=repos">repositories</a> 67 <a href="/{{ $user }}?tab=strings">strings</a>
··· 15 {{ with .LoggedInUser }} 16 {{ block "newButton" . }} {{ end }} 17 {{ template "notifications/fragments/bell" }} 18 + {{ block "profileDropdown" . }} {{ end }} 19 {{ else }} 20 <a href="/login">login</a> 21 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 </summary> 36 + <div class="absolute flex flex-col right-0 mt-3 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 37 <a href="/repo/new" class="flex items-center gap-2"> 38 {{ i "book-plus" "w-4 h-4" }} 39 new repository ··· 46 </details> 47 {{ end }} 48 49 + {{ define "profileDropdown" }} 50 <details class="relative inline-block text-left nav-dropdown"> 51 + <summary class="cursor-pointer list-none flex items-center gap-1"> 52 {{ $user := .Did }} 53 <img 54 src="{{ tinyAvatar $user }}" ··· 57 /> 58 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 59 </summary> 60 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 61 <a href="/{{ $user }}">profile</a> 62 <a href="/{{ $user }}?tab=repos">repositories</a> 63 <a href="/{{ $user }}?tab=strings">strings</a>
+53 -25
appview/pages/templates/layouts/repobase.html
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 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> 20 </div> 21 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> 29 {{ template "repo/fragments/repoStar" .RepoInfo }} 30 <a 31 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" ··· 36 fork 37 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 </a> 39 </div> 40 </div> 41 - {{ template "repo/fragments/repoDescription" . }} 42 </section> 43 44 <section class="w-full flex flex-col" > ··· 79 </div> 80 </nav> 81 {{ block "repoContentLayout" . }} 82 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 83 {{ block "repoContent" . }}{{ end }} 84 </section> 85 {{ block "repoAfter" . }}{{ end }}
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "content" }} 4 + <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 + <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 + <!-- left items --> 7 + <div class="flex flex-col gap-2"> 8 + <!-- repo owner / repo name --> 9 + <div class="flex items-center gap-2 flex-wrap"> 10 + {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 11 + <span class="select-none">/</span> 12 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 13 + </div> 14 + 15 + {{ if .RepoInfo.Source }} 16 + {{ $sourceOwner := resolve .RepoInfo.Source.Did }} 17 + <div class="flex items-center gap-1 text-sm flex-wrap"> 18 + {{ i "git-fork" "w-3 h-3 shrink-0" }} 19 + <span>forked from</span> 20 + <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}"> 21 + {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 22 + </a> 23 + </div> 24 + {{ end }} 25 + 26 + <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 27 + {{ if .RepoInfo.Description }} 28 + {{ .RepoInfo.Description | description }} 29 + {{ else }} 30 + <span class="italic">this repo has no description</span> 31 + {{ end }} 32 + 33 + {{ with .RepoInfo.Website }} 34 + <span class="flex items-center gap-1"> 35 + <span class="flex-shrink-0">{{ i "globe" "size-4" }}</span> 36 + <a href="{{ . }}">{{ . | trimUriScheme }}</a> 37 + </span> 38 + {{ end }} 39 + 40 + {{ if .RepoInfo.Topics }} 41 + <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300"> 42 + {{ range .RepoInfo.Topics }} 43 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span> 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + </span> 49 </div> 50 51 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 {{ template "repo/fragments/repoStar" .RepoInfo }} 53 <a 54 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" ··· 59 fork 60 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 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> 68 </div> 69 </div> 70 </section> 71 72 <section class="w-full flex flex-col" > ··· 107 </div> 108 </nav> 109 {{ block "repoContentLayout" . }} 110 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white"> 111 {{ block "repoContent" . }}{{ end }} 112 </section> 113 {{ block "repoAfter" . }}{{ end }}
+6
appview/pages/templates/notifications/fragments/item.html
··· 40 commented on an issue 41 {{ else if eq .Type "issue_closed" }} 42 closed an issue 43 {{ else if eq .Type "pull_created" }} 44 created a pull request 45 {{ else if eq .Type "pull_commented" }} ··· 48 merged a pull request 49 {{ else if eq .Type "pull_closed" }} 50 closed a pull request 51 {{ else if eq .Type "followed" }} 52 followed you 53 {{ else }} 54 {{ end }} 55 {{ end }}
··· 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" }} ··· 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 }}
+62 -39
appview/pages/templates/repo/blob.html
··· 11 {{ end }} 12 13 {{ define "repoContent" }} 14 - {{ $lines := split .Contents }} 15 - {{ $tot_lines := len $lines }} 16 - {{ $tot_chars := len (printf "%d" $tot_lines) }} 17 - {{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }} 18 {{ $linkstyle := "no-underline hover:underline" }} 19 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 20 <div class="flex flex-col md:flex-row md:justify-between gap-2"> ··· 36 </div> 37 <div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 38 <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span> 39 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 40 - <span>{{ .Lines }} lines</span> 41 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 42 - <span>{{ byteFmt .SizeHint }}</span> 43 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 44 - <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 - {{ if .RenderToggle }} 46 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 - hx-boost="true" 50 - >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 {{ end }} 52 </div> 53 </div> 54 </div> 55 - {{ if and .IsBinary .Unsupported }} 56 - <p class="text-center text-gray-400 dark:text-gray-500"> 57 - Previews are not supported for this file type. 58 - </p> 59 - {{ else if .IsBinary }} 60 - <div class="text-center"> 61 - {{ if .IsImage }} 62 - <img src="{{ .ContentSrc }}" 63 - alt="{{ .Path }}" 64 - class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 - {{ else if .IsVideo }} 66 - <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 - <source src="{{ .ContentSrc }}"> 68 - Your browser does not support the video tag. 69 - </video> 70 - {{ end }} 71 - </div> 72 - {{ else }} 73 - <div class="overflow-auto relative"> 74 - {{ if .ShowRendered }} 75 - <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 76 {{ else }} 77 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div> 78 {{ end }} 79 - </div> 80 {{ end }} 81 {{ template "fragments/multiline-select" }} 82 {{ end }}
··· 11 {{ end }} 12 13 {{ define "repoContent" }} 14 {{ $linkstyle := "no-underline hover:underline" }} 15 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 16 <div class="flex flex-col md:flex-row md:justify-between gap-2"> ··· 32 </div> 33 <div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 34 <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span> 35 + 36 + {{ if .BlobView.ShowingText }} 37 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 38 + <span>{{ .Lines }} lines</span> 39 + {{ end }} 40 + 41 + {{ if .BlobView.SizeHint }} 42 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 43 + <span>{{ byteFmt .BlobView.SizeHint }}</span> 44 + {{ end }} 45 + 46 + {{ if .BlobView.HasRawView }} 47 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 48 + <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 49 + {{ end }} 50 + 51 + {{ if .BlobView.ShowToggle }} 52 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 53 + <a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true"> 54 + view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} 55 + </a> 56 {{ end }} 57 </div> 58 </div> 59 </div> 60 + {{ if .BlobView.IsUnsupported }} 61 + <p class="text-center text-gray-400 dark:text-gray-500"> 62 + Previews are not supported for this file type. 63 + </p> 64 + {{ else if .BlobView.ContentType.IsSubmodule }} 65 + <p class="text-center text-gray-400 dark:text-gray-500"> 66 + This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>. 67 + </p> 68 + {{ else if .BlobView.ContentType.IsImage }} 69 + <div class="text-center"> 70 + <img src="{{ .BlobView.ContentSrc }}" 71 + alt="{{ .Path }}" 72 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 73 + </div> 74 + {{ else if .BlobView.ContentType.IsVideo }} 75 + <div class="text-center"> 76 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 77 + <source src="{{ .BlobView.ContentSrc }}"> 78 + Your browser does not support the video tag. 79 + </video> 80 + </div> 81 + {{ else if .BlobView.ContentType.IsSvg }} 82 + <div class="overflow-auto relative"> 83 + {{ if .BlobView.ShowingRendered }} 84 + <div class="text-center"> 85 + <img src="{{ .BlobView.ContentSrc }}" 86 + alt="{{ .Path }}" 87 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 88 + </div> 89 {{ else }} 90 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 91 + {{ end }} 92 + </div> 93 + {{ else if .BlobView.ContentType.IsMarkup }} 94 + <div class="overflow-auto relative"> 95 + {{ if .BlobView.ShowingRendered }} 96 + <div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div> 97 + {{ else }} 98 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 99 {{ end }} 100 + </div> 101 + {{ else if .BlobView.ContentType.IsCode }} 102 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 103 {{ end }} 104 {{ template "fragments/multiline-select" }} 105 {{ end }}
+11 -11
appview/pages/templates/repo/commit.html
··· 24 </div> 25 </div> 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 }} 30 31 - {{ if $didOrHandle }} 32 - <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 33 {{ else }} 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 {{ end }} 36 <span class="px-1 select-none before:content-['\00B7']"></span> 37 {{ template "repo/fragments/time" $commit.Author.When }} 38 <span class="px-1 select-none before:content-['\00B7']"></span> 39 - </p> 40 41 - <p class="flex items-center text-sm text-gray-500 dark:text-gray-300"> 42 <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> 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> 46 {{ end }} 47 </p> 48 ··· 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 <div class="flex items-center gap-2 my-2"> 60 {{ i "user" "w-4 h-4" }} 61 - {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 </div> 64 <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
··· 24 </div> 25 </div> 26 27 + <div class="flex flex-wrap items-center space-x-2"> 28 + <p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300"> 29 + {{ $did := index $.EmailToDid $commit.Author.Email }} 30 31 + {{ if $did }} 32 + {{ template "user/fragments/picHandleLink" $did }} 33 {{ else }} 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 {{ end }} 36 + 37 <span class="px-1 select-none before:content-['\00B7']"></span> 38 {{ template "repo/fragments/time" $commit.Author.When }} 39 <span class="px-1 select-none before:content-['\00B7']"></span> 40 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 {{ 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> 46 {{ end }} 47 </p> 48 ··· 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 <div class="flex items-center gap-2 my-2"> 60 {{ i "user" "w-4 h-4" }} 61 + {{ $committerDid := index $.EmailToDid $commit.Committer.Email }} 62 + {{ template "user/fragments/picHandleLink" $committerDid }} 63 </div> 64 <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
+1 -1
appview/pages/templates/repo/empty.html
··· 35 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 <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> 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 </div> 41 </div>
··· 35 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 <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 </div> 41 </div>
+4 -4
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 29 <code 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 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 <button 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 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 <code 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 onclick="window.getSelection().selectAllChildren(this)" 51 - data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 <button 54 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 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"
··· 29 <code 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 onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 34 <button 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 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 <code 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 onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 <button 54 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 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
··· 5 {{ if .Split }} 6 {{ $active = "split" }} 7 {{ end }} 8 - {{ $values := list "unified" "split" }} 9 - {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 10 </section> 11 {{ end }} 12 13 - {{ define "tabSelector" }} 14 - {{ $name := .Name }} 15 - {{ $all := .Values }} 16 - {{ $active := .Active }} 17 - <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 - {{ range $index, $value := $all }} 21 - {{ $isActive := eq $value $active }} 22 - <a href="?{{ $name }}={{ $value }}" 23 - class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 - {{ $value }} 25 - </a> 26 - {{ end }} 27 - </div> 28 - {{ end }}
··· 5 {{ if .Split }} 6 {{ $active = "split" }} 7 {{ end }} 8 + 9 + {{ $unified := 10 + (dict 11 + "Key" "unified" 12 + "Value" "unified" 13 + "Icon" "square-split-vertical" 14 + "Meta" "") }} 15 + {{ $split := 16 + (dict 17 + "Key" "split" 18 + "Value" "split" 19 + "Icon" "square-split-horizontal" 20 + "Meta" "") }} 21 + {{ $values := list $unified $split }} 22 + 23 + {{ template "fragments/tabSelector" 24 + (dict 25 + "Name" "diff" 26 + "Values" $values 27 + "Active" $active) }} 28 </section> 29 {{ end }} 30
-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
···
··· 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 +
-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 }}
···
+11 -14
appview/pages/templates/repo/index.html
··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 38 - <details class="group -m-6 mb-4"> 39 <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 {{ range $value := .Languages }} 41 <div ··· 129 {{ $icon := "folder" }} 130 {{ $iconStyle := "size-4 fill-current" }} 131 132 {{ if .IsFile }} 133 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 {{ $icon = "file" }} 135 {{ $iconStyle = "size-4" }} 136 {{ end }} 137 <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 <div class="flex items-center gap-2"> 139 {{ i $icon $iconStyle "flex-shrink-0" }} ··· 222 class="mx-1 before:content-['·'] before:select-none" 223 ></span> 224 <span> 225 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 226 - <a 227 - href="{{ if $didOrHandle }} 228 - /{{ $didOrHandle }} 229 - {{ else }} 230 - mailto:{{ .Author.Email }} 231 - {{ end }}" 232 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 - > 239 </span> 240 <div class="inline-block px-1 select-none after:content-['·']"></div> 241 {{ template "repo/fragments/time" .Committer.When }}
··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 38 + <details class="group -my-4 -m-6 mb-4"> 39 <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 {{ range $value := .Languages }} 41 <div ··· 129 {{ $icon := "folder" }} 130 {{ $iconStyle := "size-4 fill-current" }} 131 132 + {{ if .IsSubmodule }} 133 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 + {{ $icon = "folder-input" }} 135 + {{ $iconStyle = "size-4" }} 136 + {{ end }} 137 + 138 {{ if .IsFile }} 139 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 140 {{ $icon = "file" }} 141 {{ $iconStyle = "size-4" }} 142 {{ end }} 143 + 144 <a href="{{ $link }}" class="{{ $linkstyle }}"> 145 <div class="flex items-center gap-2"> 146 {{ i $icon $iconStyle "flex-shrink-0" }} ··· 229 class="mx-1 before:content-['·'] before:select-none" 230 ></span> 231 <span> 232 + {{ $did := index $.EmailToDid .Author.Email }} 233 + <a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 234 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 235 + >{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a> 236 </span> 237 <div class="inline-block px-1 select-none after:content-['·']"></div> 238 {{ template "repo/fragments/time" .Committer.When }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 34 35 {{ define "editIssueComment" }} 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 hx-swap="outerHTML" 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 45 {{ define "deleteIssueComment" }} 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 hx-confirm="Are you sure you want to delete your comment?" 50 hx-swap="outerHTML"
··· 34 35 {{ define "editIssueComment" }} 36 <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 hx-swap="outerHTML" 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 45 {{ define "deleteIssueComment" }} 46 <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 hx-confirm="Are you sure you want to delete your comment?" 50 hx-swap="outerHTML"
+1 -1
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 8 class="no-underline hover:underline" 9 > 10 {{ .Title | description }} 11 - <span class="text-gray-500">#{{ .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">
··· 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">
+3 -2
appview/pages/templates/repo/issues/issue.html
··· 20 "Subject" $.Issue.AtUri 21 "State" $.Issue.Labels) }} 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 </div> 24 </div> 25 {{ end }} ··· 84 85 {{ define "editIssue" }} 86 <a 87 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 88 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 89 hx-swap="innerHTML" 90 hx-target="#issue-{{.Issue.IssueId}}"> ··· 94 95 {{ define "deleteIssue" }} 96 <a 97 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 98 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 99 hx-confirm="Are you sure you want to delete your issue?" 100 hx-swap="none">
··· 20 "Subject" $.Issue.AtUri 21 "State" $.Issue.Labels) }} 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 24 </div> 25 </div> 26 {{ end }} ··· 85 86 {{ define "editIssue" }} 87 <a 88 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 89 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 90 hx-swap="innerHTML" 91 hx-target="#issue-{{.Issue.IssueId}}"> ··· 95 96 {{ define "deleteIssue" }} 97 <a 98 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 99 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 100 hx-confirm="Are you sure you want to delete your issue?" 101 hx-swap="none">
+43 -24
appview/pages/templates/repo/issues/issues.html
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 12 - <div class="flex gap-4"> 13 <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 - > 17 - {{ i "circle-dot" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 19 - </a> 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 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 - > 32 {{ i "circle-plus" "w-4 h-4" }} 33 <span>new</span> 34 - </a> 35 - </div> 36 - <div class="error" id="issues"></div> 37 {{ end }} 38 39 {{ define "repoAfter" }} ··· 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 = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 59 > 60 {{ i "chevron-left" "w-4 h-4" }} 61 previous ··· 69 <a 70 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 71 hx-boost="true" 72 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 73 > 74 next 75 {{ i "chevron-right" "w-4 h-4" }}
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 + {{ $active := "closed" }} 12 + {{ if .FilteringByOpen }} 13 + {{ $active = "open" }} 14 + {{ end }} 15 + 16 + {{ $open := 17 + (dict 18 + "Key" "open" 19 + "Value" "open" 20 + "Icon" "circle-dot" 21 + "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 + {{ $closed := 23 + (dict 24 + "Key" "closed" 25 + "Value" "closed" 26 + "Icon" "ban" 27 + "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 28 + {{ $values := list $open $closed }} 29 + 30 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 + <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 + <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 34 + {{ i "search" "w-4 h-4" }} 35 + </div> 36 + <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 37 + <a 38 + href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 39 + class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 40 + > 41 + {{ i "x" "w-4 h-4" }} 42 + </a> 43 + </form> 44 + <div class="sm:row-start-1"> 45 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 46 + </div> 47 <a 48 href="/{{ .RepoInfo.FullName }}/issues/new" 49 + class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 50 + > 51 {{ i "circle-plus" "w-4 h-4" }} 52 <span>new</span> 53 + </a> 54 + </div> 55 + <div class="error" id="issues"></div> 56 {{ end }} 57 58 {{ define "repoAfter" }} ··· 74 <a 75 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 76 hx-boost="true" 77 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 78 > 79 {{ i "chevron-left" "w-4 h-4" }} 80 previous ··· 88 <a 89 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 90 hx-boost="true" 91 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 92 > 93 next 94 {{ i "chevron-right" "w-4 h-4" }}
+6 -6
appview/pages/templates/repo/log.html
··· 27 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 <div class="{{ $grid }} py-3"> 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 }} 33 {{ else }} 34 <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 {{ end }} ··· 153 </span> 154 <span class="mx-2 before:content-['·'] before:select-none"></span> 155 <span> 156 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 157 - <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 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 }} 160 </a> 161 </span> 162 <div class="inline-block px-1 select-none after:content-['·']"></div>
··· 27 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 <div class="{{ $grid }} py-3"> 29 <div class="align-top truncate col-span-2"> 30 + {{ $did := index $.EmailToDid $commit.Author.Email }} 31 + {{ if $did }} 32 + {{ template "user/fragments/picHandleLink" $did }} 33 {{ else }} 34 <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 {{ end }} ··· 153 </span> 154 <span class="mx-2 before:content-['·'] before:select-none"></span> 155 <span> 156 + {{ $did := index $.EmailToDid $commit.Author.Email }} 157 + <a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 + {{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 </a> 161 </span> 162 <div class="inline-block px-1 select-none after:content-['·']"></div>
+7 -6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 2 <div id="lines" hx-swap-oob="beforeend"> 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 <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> 11 </summary> 12 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 </details> 14 </div> 15 {{ end }}
··· 2 <div id="lines" hx-swap-oob="beforeend"> 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 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 + <div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div> 6 + <div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div> 7 </summary> 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> 9 </details> 10 </div> 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
···
··· 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
··· 12 {{ range .Pipelines }} 13 {{ block "pipeline" (list $ .) }} {{ end }} 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> 18 {{ end }} 19 </div> 20 </div>
··· 12 {{ range .Pipelines }} 13 {{ block "pipeline" (list $ .) }} {{ end }} 14 {{ else }} 15 + <div class="py-6 w-fit flex flex-col gap-4 mx-auto"> 16 + <p> 17 + No pipelines have been run for this repository yet. To get started: 18 + </p> 19 + {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 20 + <p> 21 + <span class="{{ $bullet }}">1</span>First, choose a spindle in your 22 + <a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>. 23 + </p> 24 + <p> 25 + <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 27 + </p> 28 + <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 + </div> 30 {{ end }} 31 </div> 32 </div>
+6
appview/pages/templates/repo/pipelines/workflow.html
··· 15 {{ block "logs" . }} {{ end }} 16 </div> 17 </section> 18 {{ end }} 19 20 {{ define "sidebar" }} ··· 58 hx-ext="ws" 59 ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 60 <div id="lines" class="flex flex-col gap-2"> 61 </div> 62 </div> 63 {{ end }}
··· 15 {{ block "logs" . }} {{ end }} 16 </div> 17 </section> 18 + {{ template "fragments/workflow-timers" }} 19 {{ end }} 20 21 {{ define "sidebar" }} ··· 59 hx-ext="ws" 60 ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 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> 67 </div> 68 </div> 69 {{ end }}
+81 -83
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 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 .BranchDeleteStatus }} 37 - <button 38 - hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 - hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 - hx-swap="none" 41 - 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"> 42 - {{ i "git-branch" "w-4 h-4" }} 43 - <span>delete branch</span> 44 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </button> 46 - {{ end }} 47 - {{ if and $isPushAllowed $isOpen $isLastRound }} 48 - {{ $disabled := "" }} 49 - {{ if $isConflicted }} 50 - {{ $disabled = "disabled" }} 51 - {{ end }} 52 - <button 53 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 54 - hx-swap="none" 55 - hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 56 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 57 - {{ i "git-merge" "w-4 h-4" }} 58 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 59 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 60 - </button> 61 - {{ end }} 62 63 - {{ if and $isPullAuthor $isOpen $isLastRound }} 64 - {{ $disabled := "" }} 65 - {{ if $isUpToDate }} 66 - {{ $disabled = "disabled" }} 67 {{ end }} 68 - <button id="resubmitBtn" 69 - {{ if not .Pull.IsPatchBased }} 70 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 71 - {{ else }} 72 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 73 - hx-target="#actions-{{$roundNumber}}" 74 - hx-swap="outerHtml" 75 - {{ end }} 76 77 - hx-disabled-elt="#resubmitBtn" 78 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 79 80 - {{ if $disabled }} 81 - title="Update this branch to resubmit this pull request" 82 - {{ else }} 83 - title="Resubmit this pull request" 84 - {{ end }} 85 - > 86 - {{ i "rotate-ccw" "w-4 h-4" }} 87 - <span>resubmit</span> 88 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 89 - </button> 90 - {{ end }} 91 92 - {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 93 - <button 94 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 95 - hx-swap="none" 96 - class="btn p-2 flex items-center gap-2 group"> 97 - {{ i "ban" "w-4 h-4" }} 98 - <span>close</span> 99 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 - </button> 101 - {{ end }} 102 103 - {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 104 - <button 105 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 106 - hx-swap="none" 107 - class="btn p-2 flex items-center gap-2 group"> 108 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 109 - <span>reopen</span> 110 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 111 - </button> 112 - {{ end }} 113 - </div> 114 </div> 115 {{ end }} 116
··· 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 26 + <button 27 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 + hx-target="#actions-{{$roundNumber}}" 29 + hx-swap="outerHtml" 30 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 + {{ i "message-square-plus" "w-4 h-4" }} 32 + <span>comment</span> 33 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 + </button> 35 + {{ if .BranchDeleteStatus }} 36 + <button 37 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 + hx-swap="none" 40 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 + {{ i "git-branch" "w-4 h-4" }} 42 + <span>delete branch</span> 43 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 44 + </button> 45 + {{ end }} 46 + {{ if and $isPushAllowed $isOpen $isLastRound }} 47 + {{ $disabled := "" }} 48 + {{ if $isConflicted }} 49 + {{ $disabled = "disabled" }} 50 + {{ end }} 51 + <button 52 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 + hx-swap="none" 54 + hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 + class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 + {{ i "git-merge" "w-4 h-4" }} 57 + <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + </button> 60 + {{ end }} 61 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" 74 {{ end }} 75 76 + hx-disabled-elt="#resubmitBtn" 77 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 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 }} 90 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 }} 101 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 }} 112 </div> 113 {{ end }} 114
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 75 "Kind" $kind 76 "Count" $reactionData.Count 77 "IsReacted" (index $.UserReacted $kind) 78 - "ThreadAt" $.Pull.PullAt 79 "Users" $reactionData.Users) 80 }} 81 {{ end }}
··· 75 "Kind" $kind 76 "Count" $reactionData.Count 77 "IsReacted" (index $.UserReacted $kind) 78 + "ThreadAt" $.Pull.AtUri 79 "Users" $reactionData.Users) 80 }} 81 {{ end }}
+2 -1
appview/pages/templates/repo/pulls/pull.html
··· 18 {{ template "repo/fragments/labelPanel" 19 (dict "RepoInfo" $.RepoInfo 20 "Defs" $.LabelDefs 21 - "Subject" $.Pull.PullAt 22 "State" $.Pull.Labels) }} 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 </div> 25 </div> 26 {{ end }}
··· 18 {{ template "repo/fragments/labelPanel" 19 (dict "RepoInfo" $.RepoInfo 20 "Defs" $.LabelDefs 21 + "Subject" $.Pull.AtUri 22 "State" $.Pull.Labels) }} 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 25 </div> 26 </div> 27 {{ end }}
+52 -34
appview/pages/templates/repo/pulls/pulls.html
··· 8 {{ end }} 9 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> 42 </div> 43 - <div class="error" id="pulls"></div> 44 {{ end }} 45 46 {{ define "repoAfter" }} ··· 133 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 134 </div> 135 </summary> 136 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 137 </details> 138 {{ end }} 139 {{ end }} ··· 142 </div> 143 {{ end }} 144 145 - {{ define "pullList" }} 146 {{ $list := index . 0 }} 147 {{ $root := index . 1 }} 148 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 + {{ $active := "closed" }} 12 + {{ if .FilteringBy.IsOpen }} 13 + {{ $active = "open" }} 14 + {{ else if .FilteringBy.IsMerged }} 15 + {{ $active = "merged" }} 16 + {{ end }} 17 + {{ $open := 18 + (dict 19 + "Key" "open" 20 + "Value" "open" 21 + "Icon" "git-pull-request" 22 + "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 + {{ $merged := 24 + (dict 25 + "Key" "merged" 26 + "Value" "merged" 27 + "Icon" "git-merge" 28 + "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 + {{ $closed := 30 + (dict 31 + "Key" "closed" 32 + "Value" "closed" 33 + "Icon" "ban" 34 + "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 35 + {{ $values := list $open $merged $closed }} 36 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 + <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 + <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 40 + {{ i "search" "w-4 h-4" }} 41 + </div> 42 + <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 43 + <a 44 + href="?state={{ .FilteringBy.String }}" 45 + class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 46 + > 47 + {{ i "x" "w-4 h-4" }} 48 + </a> 49 + </form> 50 + <div class="sm:row-start-1"> 51 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 52 </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> 62 {{ end }} 63 64 {{ define "repoAfter" }} ··· 151 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 152 </div> 153 </summary> 154 + {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 155 </details> 156 {{ end }} 157 {{ end }} ··· 160 </div> 161 {{ end }} 162 163 + {{ define "stackedPullList" }} 164 {{ $list := index . 0 }} 165 {{ $root := index . 1 }} 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
··· 66 <div 67 id="add-collaborator-modal" 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"> 70 {{ template "addCollaboratorModal" . }} 71 </div> 72 {{ end }} ··· 82 ADD COLLABORATOR 83 </label> 84 <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 - /> 92 <div class="flex gap-2 pt-2"> 93 <button 94 type="button"
··· 66 <div 67 id="add-collaborator-modal" 68 popover 69 + class=" 70 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 71 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 72 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 73 {{ template "addCollaboratorModal" . }} 74 </div> 75 {{ end }} ··· 85 ADD COLLABORATOR 86 </label> 87 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 88 + <actor-typeahead> 89 + <input 90 + autocapitalize="none" 91 + autocorrect="off" 92 + autocomplete="off" 93 + type="text" 94 + id="add-collaborator" 95 + name="collaborator" 96 + required 97 + placeholder="user.tngl.sh" 98 + class="w-full" 99 + /> 100 + </actor-typeahead> 101 <div class="flex gap-2 pt-2"> 102 <button 103 type="button"
+47
appview/pages/templates/repo/settings/general.html
··· 6 {{ template "repo/settings/fragments/sidebar" . }} 7 </div> 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 {{ template "branchSettings" . }} 10 {{ template "defaultLabelSettings" . }} 11 {{ template "customLabelSettings" . }} ··· 13 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 </div> 15 </section> 16 {{ end }} 17 18 {{ define "branchSettings" }}
··· 6 {{ template "repo/settings/fragments/sidebar" . }} 7 </div> 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "baseSettings" . }} 10 {{ template "branchSettings" . }} 11 {{ template "defaultLabelSettings" . }} 12 {{ template "customLabelSettings" . }} ··· 14 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 15 </div> 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> 63 {{ end }} 64 65 {{ define "branchSettings" }}
+8
appview/pages/templates/repo/tree.html
··· 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 61 62 {{ if .IsFile }} 63 {{ $icon = "file" }} 64 {{ $iconStyle = "size-4" }} 65 {{ end }} 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 <div class="flex items-center gap-2"> 68 {{ i $icon $iconStyle "flex-shrink-0" }}
··· 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 61 62 + {{ if .IsSubmodule }} 63 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 64 + {{ $icon = "folder-input" }} 65 + {{ $iconStyle = "size-4" }} 66 + {{ end }} 67 + 68 {{ if .IsFile }} 69 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 70 {{ $icon = "file" }} 71 {{ $iconStyle = "size-4" }} 72 {{ end }} 73 + 74 <a href="{{ $link }}" class="{{ $linkstyle }}"> 75 <div class="flex items-center gap-2"> 76 {{ i $icon $iconStyle "flex-shrink-0" }}
+16 -8
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 <div 14 id="add-member-{{ .Instance }}" 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"> 17 {{ block "addSpindleMemberPopover" . }} {{ end }} 18 </div> 19 {{ end }} ··· 29 ADD MEMBER 30 </label> 31 <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 - /> 39 <div class="flex gap-2 pt-2"> 40 <button 41 type="button"
··· 13 <div 14 id="add-member-{{ .Instance }}" 15 popover 16 + class=" 17 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 19 {{ block "addSpindleMemberPopover" . }} {{ end }} 20 </div> 21 {{ end }} ··· 31 ADD MEMBER 32 </label> 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 47 <div class="flex gap-2 pt-2"> 48 <button 49 type="button"
+3 -3
appview/pages/templates/strings/string.html
··· 47 </span> 48 </section> 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"> 51 <span> 52 {{ .String.Filename }} 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> ··· 75 </div> 76 <div class="overflow-x-auto overflow-y-hidden relative"> 77 {{ if .ShowRendered }} 78 - <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 79 {{ else }} 80 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 81 {{ end }} 82 </div> 83 {{ template "fragments/multiline-select" }}
··· 47 </span> 48 </section> 49 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 + <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 <span> 52 {{ .String.Filename }} 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> ··· 75 </div> 76 <div class="overflow-x-auto overflow-y-hidden relative"> 77 {{ if .ShowRendered }} 78 + <div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div> 79 {{ else }} 80 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div> 81 {{ end }} 82 </div> 83 {{ template "fragments/multiline-select" }}
+2 -2
appview/pages/templates/timeline/fragments/hero.html
··· 4 <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 6 <p class="text-lg"> 7 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 8 </p> 9 <p class="text-lg"> 10 - we envision a place where developers have complete ownership of their 11 code, open source communities can freely self-govern and most 12 importantly, coding can be social and fun again. 13 </p>
··· 4 <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 6 <p class="text-lg"> 7 + Tangled is a decentralized Git hosting and collaboration platform. 8 </p> 9 <p class="text-lg"> 10 + We envision a place where developers have complete ownership of their 11 code, open source communities can freely self-govern and most 12 importantly, coding can be social and fun again. 13 </p>
+11
appview/pages/templates/user/fragments/editBio.html
··· 20 </div> 21 22 <div class="flex flex-col gap-1"> 23 <label class="m-0 p-0" for="location">location</label> 24 <div class="flex items-center gap-2 w-full"> 25 {{ $location := "" }}
··· 20 </div> 21 22 <div class="flex flex-col gap-1"> 23 + <label class="m-0 p-0" for="pronouns">pronouns</label> 24 + <div class="flex items-center gap-2 w-full"> 25 + {{ $pronouns := "" }} 26 + {{ if and .Profile .Profile.Pronouns }} 27 + {{ $pronouns = .Profile.Pronouns }} 28 + {{ end }} 29 + <input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}"> 30 + </div> 31 + </div> 32 + 33 + <div class="flex flex-col gap-1"> 34 <label class="m-0 p-0" for="location">location</label> 35 <div class="flex items-center gap-2 w-full"> 36 {{ $location := "" }}
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 </div> 8 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
··· 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 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
··· 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 {{ $userIdent }} 14 </p> 15 - <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 </div> 17 18 <div class="md:hidden"> ··· 67 {{ end }} 68 </div> 69 {{ end }} 70 - {{ if ne .FollowStatus.String "IsSelf" }} 71 - {{ template "user/fragments/follow" . }} 72 - {{ else }} 73 <button id="editBtn" 74 - class="btn mt-2 w-full flex items-center gap-2 group" 75 hx-target="#profile-bio" 76 hx-get="/profile/edit-bio" 77 hx-swap="innerHTML"> ··· 79 edit 80 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 81 </button> 82 - {{ end }} 83 </div> 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 </div>
··· 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 {{ $userIdent }} 14 </p> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 20 </div> 21 22 <div class="md:hidden"> ··· 71 {{ end }} 72 </div> 73 {{ end }} 74 + 75 + <div class="flex mt-2 items-center gap-2"> 76 + {{ if ne .FollowStatus.String "IsSelf" }} 77 + {{ template "user/fragments/follow" . }} 78 + {{ else }} 79 <button id="editBtn" 80 + class="btn w-full flex items-center gap-2 group" 81 hx-target="#profile-bio" 82 hx-get="/profile/edit-bio" 83 hx-swap="innerHTML"> ··· 85 edit 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 </button> 88 + {{ end }} 89 + 90 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 + href="/{{ $userIdent }}/feed.atom"> 92 + {{ i "rss" "size-4" }} 93 + </a> 94 + </div> 95 + 96 </div> 97 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 98 </div>
+23 -2
appview/pages/templates/user/login.html
··· 13 <title>login &middot; tangled</title> 14 </head> 15 <body class="flex items-center justify-center min-h-screen"> 16 - <main class="max-w-md px-6 -mt-4"> 17 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 {{ template "fragments/logotype" }} 19 </h1> ··· 21 tightly-knit social coding. 22 </h2> 23 <form 24 - class="mt-4 max-w-sm mx-auto" 25 hx-post="/login" 26 hx-swap="none" 27 hx-disabled-elt="#login-button" ··· 29 <div class="flex flex-col"> 30 <label for="handle">handle</label> 31 <input 32 type="text" 33 id="handle" 34 name="handle" ··· 53 <span>login</span> 54 </button> 55 </form> 56 <p class="text-sm text-gray-500"> 57 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 58 </p>
··· 13 <title>login &middot; tangled</title> 14 </head> 15 <body class="flex items-center justify-center min-h-screen"> 16 + <main class="max-w-md px-7 mt-4"> 17 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 {{ template "fragments/logotype" }} 19 </h1> ··· 21 tightly-knit social coding. 22 </h2> 23 <form 24 + class="mt-4" 25 hx-post="/login" 26 hx-swap="none" 27 hx-disabled-elt="#login-button" ··· 29 <div class="flex flex-col"> 30 <label for="handle">handle</label> 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 35 type="text" 36 id="handle" 37 name="handle" ··· 56 <span>login</span> 57 </button> 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 }} 77 <p class="text-sm text-gray-500"> 78 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 79 </p>
+14
appview/pages/templates/user/settings/notifications.html
··· 144 <div class="flex items-center justify-between p-2"> 145 <div class="flex items-center gap-2"> 146 <div class="flex flex-col gap-1"> 147 <span class="font-bold">Email notifications</span> 148 <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 <span>Receive notifications via email in addition to in-app notifications.</span>
··· 144 <div class="flex items-center justify-between p-2"> 145 <div class="flex items-center gap-2"> 146 <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Mentions</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>When someone mentions you.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 155 + </label> 156 + </div> 157 + 158 + <div class="flex items-center justify-between p-2"> 159 + <div class="flex items-center gap-2"> 160 + <div class="flex flex-col gap-1"> 161 <span class="font-bold">Email notifications</span> 162 <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 163 <span>Receive notifications via email in addition to in-app notifications.</span>
+46
appview/pagination/page.go
··· 1 package pagination 2 3 type Page struct { 4 Offset int // where to start from 5 Limit int // number of items in a page ··· 10 Offset: 0, 11 Limit: 30, 12 } 13 } 14 15 func (p Page) Previous() Page { ··· 29 Limit: p.Limit, 30 } 31 }
··· 1 package pagination 2 3 + import "context" 4 + 5 type Page struct { 6 Offset int // where to start from 7 Limit int // number of items in a page ··· 12 Offset: 0, 13 Limit: 30, 14 } 15 + } 16 + 17 + type ctxKey struct{} 18 + 19 + func IntoContext(ctx context.Context, page Page) context.Context { 20 + return context.WithValue(ctx, ctxKey{}, page) 21 + } 22 + 23 + func FromContext(ctx context.Context) Page { 24 + if ctx == nil { 25 + return FirstPage() 26 + } 27 + v := ctx.Value(ctxKey{}) 28 + if v == nil { 29 + return FirstPage() 30 + } 31 + page, ok := v.(Page) 32 + if !ok { 33 + return FirstPage() 34 + } 35 + return page 36 } 37 38 func (p Page) Previous() Page { ··· 52 Limit: p.Limit, 53 } 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 + }
+34 -13
appview/pipelines/pipelines.go
··· 35 logger *slog.Logger 36 } 37 38 func New( 39 oauth *oauth.OAuth, 40 repoResolver *reporesolver.RepoResolver, ··· 227 // start a goroutine to read from spindle 228 go readLogs(spindleConn, evChan) 229 230 - stepIdx := 0 231 var fragment bytes.Buffer 232 for { 233 select { ··· 259 260 switch logLine.Kind { 261 case spindlemodel.LogKindControl: 262 - // control messages create a new step block 263 - stepIdx++ 264 - collapsed := false 265 - if logLine.StepKind == spindlemodel.StepKindSystem { 266 - collapsed = true 267 } 268 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 269 - Id: stepIdx, 270 - Name: logLine.Content, 271 - Command: logLine.StepCommand, 272 - Collapsed: collapsed, 273 - }) 274 case spindlemodel.LogKindData: 275 // data messages simply insert new log lines into current step 276 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 277 - Id: stepIdx, 278 Content: logLine.Content, 279 }) 280 }
··· 35 logger *slog.Logger 36 } 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 + 47 func New( 48 oauth *oauth.OAuth, 49 repoResolver *reporesolver.RepoResolver, ··· 236 // start a goroutine to read from spindle 237 go readLogs(spindleConn, evChan) 238 239 + stepStartTimes := make(map[int]time.Time) 240 var fragment bytes.Buffer 241 for { 242 select { ··· 268 269 switch logLine.Kind { 270 case spindlemodel.LogKindControl: 271 + switch logLine.StepStatus { 272 + case spindlemodel.StepStatusStart: 273 + stepStartTimes[logLine.StepId] = logLine.Time 274 + collapsed := false 275 + if logLine.StepKind == spindlemodel.StepKindSystem { 276 + collapsed = true 277 + } 278 + err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 279 + Id: logLine.StepId, 280 + Name: logLine.Content, 281 + Command: logLine.StepCommand, 282 + Collapsed: collapsed, 283 + StartTime: logLine.Time, 284 + }) 285 + case spindlemodel.StepStatusEnd: 286 + startTime := stepStartTimes[logLine.StepId] 287 + endTime := logLine.Time 288 + err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{ 289 + Id: logLine.StepId, 290 + StartTime: startTime, 291 + EndTime: endTime, 292 + }) 293 } 294 + 295 case spindlemodel.LogKindData: 296 // data messages simply insert new log lines into current step 297 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 298 + Id: logLine.StepId, 299 Content: logLine.Content, 300 }) 301 }
-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 - }
···
+7 -7
appview/pulls/opengraph.go
··· 146 var statusColor color.RGBA 147 148 if pull.State.IsOpen() { 149 - statusIcon = "static/icons/git-pull-request.svg" 150 statusText = "open" 151 statusColor = color.RGBA{34, 139, 34, 255} // green 152 } else if pull.State.IsMerged() { 153 - statusIcon = "static/icons/git-merge.svg" 154 statusText = "merged" 155 statusColor = color.RGBA{138, 43, 226, 255} // purple 156 } else { 157 - statusIcon = "static/icons/git-pull-request-closed.svg" 158 statusText = "closed" 159 statusColor = color.RGBA{128, 128, 128, 255} // gray 160 } ··· 162 statusIconSize := 36 163 164 // Draw icon with status color 165 - err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 166 if err != nil { 167 log.Printf("failed to draw status icon: %v", err) 168 } ··· 179 currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 180 181 // Draw comment count 182 - err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 183 if err != nil { 184 log.Printf("failed to draw comment icon: %v", err) 185 } ··· 198 currentX += commentTextWidth + 40 199 200 // Draw files changed 201 - err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 202 if err != nil { 203 log.Printf("failed to draw file diff icon: %v", err) 204 } ··· 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.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 245 if err != nil { 246 log.Printf("dolly silhouette not available (this is ok): %v", err) 247 }
··· 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 } ··· 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 } ··· 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 } ··· 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 } ··· 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 }
+62 -10
appview/pulls/pulls.go
··· 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/config" 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/notify" 22 "tangled.org/core/appview/oauth" ··· 32 "tangled.org/core/types" 33 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 35 lexutil "github.com/bluesky-social/indigo/lex/util" 36 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 37 "github.com/go-chi/chi/v5" ··· 49 enforcer *rbac.Enforcer 50 logger *slog.Logger 51 validator *validator.Validator 52 } 53 54 func New( ··· 61 notifier notify.Notifier, 62 enforcer *rbac.Enforcer, 63 validator *validator.Validator, 64 logger *slog.Logger, 65 ) *Pulls { 66 return &Pulls{ ··· 74 enforcer: enforcer, 75 logger: logger, 76 validator: validator, 77 } 78 } 79 ··· 187 m[p.Sha] = p 188 } 189 190 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 191 if err != nil { 192 log.Println("failed to get pull reactions") 193 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 195 196 userReactions := map[models.ReactionKind]bool{} 197 if user != nil { 198 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 199 } 200 201 labelDefs, err := db.GetLabelDefinitions( ··· 544 } 545 546 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 547 user := s.oauth.GetUser(r) 548 params := r.URL.Query() 549 ··· 561 return 562 } 563 564 pulls, err := db.GetPulls( 565 s.db, 566 - db.FilterEq("repo_at", f.RepoAt()), 567 - db.FilterEq("state", state), 568 ) 569 if err != nil { 570 log.Println("failed to get pulls", err) ··· 651 Pulls: pulls, 652 LabelDefs: defs, 653 FilteringBy: state, 654 Stacks: stacks, 655 Pipelines: m, 656 }) 657 } 658 659 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 660 user := s.oauth.GetUser(r) 661 f, err := s.repoResolver.Resolve(r) 662 if err != nil { ··· 718 Rkey: tid.TID(), 719 Record: &lexutil.LexiconTypeDecoder{ 720 Val: &tangled.RepoPullComment{ 721 - Pull: pull.PullAt().String(), 722 Body: body, 723 CreatedAt: createdAt, 724 }, ··· 754 return 755 } 756 757 - s.notifier.NewPullComment(r.Context(), comment) 758 759 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 760 return ··· 1805 } 1806 defer tx.Rollback() 1807 1808 - pullAt := pull.PullAt() 1809 newRoundNumber := len(pull.Submissions) 1810 newPatch := patch 1811 newSourceRev := sourceRev ··· 2002 } 2003 2004 // resubmit the new pull 2005 - pullAt := op.PullAt() 2006 newRoundNumber := len(op.Submissions) 2007 newPatch := np.LatestPatch() 2008 combinedPatch := np.LatestSubmission().Combined ··· 2073 } 2074 2075 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2076 f, err := s.repoResolver.Resolve(r) 2077 if err != nil { 2078 log.Println("failed to resolve repo:", err) ··· 2170 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2171 return 2172 } 2173 } 2174 2175 err = tx.Commit() ··· 2182 2183 // notify about the pull merge 2184 for _, p := range pullsToMerge { 2185 - s.notifier.NewPullMerged(r.Context(), p) 2186 } 2187 2188 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) ··· 2243 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2244 return 2245 } 2246 } 2247 2248 // Commit the transaction ··· 2253 } 2254 2255 for _, p := range pullsToClose { 2256 - s.notifier.NewPullClosed(r.Context(), p) 2257 } 2258 2259 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2315 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2316 return 2317 } 2318 } 2319 2320 // Commit the transaction ··· 2322 log.Println("failed to commit transaction", err) 2323 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2324 return 2325 } 2326 2327 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
··· 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/config" 19 "tangled.org/core/appview/db" 20 + pulls_indexer "tangled.org/core/appview/indexer/pulls" 21 "tangled.org/core/appview/models" 22 "tangled.org/core/appview/notify" 23 "tangled.org/core/appview/oauth" ··· 33 "tangled.org/core/types" 34 35 comatproto "github.com/bluesky-social/indigo/api/atproto" 36 + "github.com/bluesky-social/indigo/atproto/syntax" 37 lexutil "github.com/bluesky-social/indigo/lex/util" 38 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 39 "github.com/go-chi/chi/v5" ··· 51 enforcer *rbac.Enforcer 52 logger *slog.Logger 53 validator *validator.Validator 54 + indexer *pulls_indexer.Indexer 55 } 56 57 func New( ··· 64 notifier notify.Notifier, 65 enforcer *rbac.Enforcer, 66 validator *validator.Validator, 67 + indexer *pulls_indexer.Indexer, 68 logger *slog.Logger, 69 ) *Pulls { 70 return &Pulls{ ··· 78 enforcer: enforcer, 79 logger: logger, 80 validator: validator, 81 + indexer: indexer, 82 } 83 } 84 ··· 192 m[p.Sha] = p 193 } 194 195 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 196 if err != nil { 197 log.Println("failed to get pull reactions") 198 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 200 201 userReactions := map[models.ReactionKind]bool{} 202 if user != nil { 203 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 204 } 205 206 labelDefs, err := db.GetLabelDefinitions( ··· 549 } 550 551 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 552 + l := s.logger.With("handler", "RepoPulls") 553 + 554 user := s.oauth.GetUser(r) 555 params := r.URL.Query() 556 ··· 568 return 569 } 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 + 598 pulls, err := db.GetPulls( 599 s.db, 600 + db.FilterIn("id", ids), 601 ) 602 if err != nil { 603 log.Println("failed to get pulls", err) ··· 684 Pulls: pulls, 685 LabelDefs: defs, 686 FilteringBy: state, 687 + FilterQuery: keyword, 688 Stacks: stacks, 689 Pipelines: m, 690 }) 691 } 692 693 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 694 + l := s.logger.With("handler", "PullComment") 695 user := s.oauth.GetUser(r) 696 f, err := s.repoResolver.Resolve(r) 697 if err != nil { ··· 753 Rkey: tid.TID(), 754 Record: &lexutil.LexiconTypeDecoder{ 755 Val: &tangled.RepoPullComment{ 756 + Pull: pull.AtUri().String(), 757 Body: body, 758 CreatedAt: createdAt, 759 }, ··· 789 return 790 } 791 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) 802 803 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 804 return ··· 1849 } 1850 defer tx.Rollback() 1851 1852 + pullAt := pull.AtUri() 1853 newRoundNumber := len(pull.Submissions) 1854 newPatch := patch 1855 newSourceRev := sourceRev ··· 2046 } 2047 2048 // resubmit the new pull 2049 + pullAt := op.AtUri() 2050 newRoundNumber := len(op.Submissions) 2051 newPatch := np.LatestPatch() 2052 combinedPatch := np.LatestSubmission().Combined ··· 2117 } 2118 2119 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2120 + user := s.oauth.GetUser(r) 2121 f, err := s.repoResolver.Resolve(r) 2122 if err != nil { 2123 log.Println("failed to resolve repo:", err) ··· 2215 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2216 return 2217 } 2218 + p.State = models.PullMerged 2219 } 2220 2221 err = tx.Commit() ··· 2228 2229 // notify about the pull merge 2230 for _, p := range pullsToMerge { 2231 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2232 } 2233 2234 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) ··· 2289 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2290 return 2291 } 2292 + p.State = models.PullClosed 2293 } 2294 2295 // Commit the transaction ··· 2300 } 2301 2302 for _, p := range pullsToClose { 2303 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2304 } 2305 2306 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2362 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2363 return 2364 } 2365 + p.State = models.PullOpen 2366 } 2367 2368 // Commit the transaction ··· 2370 log.Println("failed to commit transaction", err) 2371 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2372 return 2373 + } 2374 + 2375 + for _, p := range pullsToReopen { 2376 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2377 } 2378 2379 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+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 + }
+291
appview/repo/blob.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/base64" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/pages/markup" 18 + "tangled.org/core/appview/reporesolver" 19 + xrpcclient "tangled.org/core/appview/xrpcclient" 20 + 21 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 + "github.com/go-chi/chi/v5" 23 + ) 24 + 25 + // the content can be one of the following: 26 + // 27 + // - code : text | | raw 28 + // - markup : text | rendered | raw 29 + // - svg : text | rendered | raw 30 + // - png : | rendered | raw 31 + // - video : | rendered | raw 32 + // - submodule : | rendered | 33 + // - rest : | | 34 + func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 35 + l := rp.logger.With("handler", "RepoBlob") 36 + 37 + f, err := rp.repoResolver.Resolve(r) 38 + if err != nil { 39 + l.Error("failed to get repo and knot", "err", err) 40 + return 41 + } 42 + 43 + ref := chi.URLParam(r, "ref") 44 + ref, _ = url.PathUnescape(ref) 45 + 46 + filePath := chi.URLParam(r, "*") 47 + filePath, _ = url.PathUnescape(filePath) 48 + 49 + scheme := "http" 50 + if !rp.config.Core.Dev { 51 + scheme = "https" 52 + } 53 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 54 + xrpcc := &indigoxrpc.Client{ 55 + Host: host, 56 + } 57 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 58 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 59 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 61 + rp.pages.Error503(w) 62 + return 63 + } 64 + 65 + // Use XRPC response directly instead of converting to internal types 66 + var breadcrumbs [][]string 67 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 68 + if filePath != "" { 69 + for idx, elem := range strings.Split(filePath, "/") { 70 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 71 + } 72 + } 73 + 74 + // Create the blob view 75 + blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 76 + 77 + user := rp.oauth.GetUser(r) 78 + 79 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 80 + LoggedInUser: user, 81 + RepoInfo: f.RepoInfo(user), 82 + BreadCrumbs: breadcrumbs, 83 + BlobView: blobView, 84 + RepoBlob_Output: resp, 85 + }) 86 + } 87 + 88 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 89 + l := rp.logger.With("handler", "RepoBlobRaw") 90 + 91 + f, err := rp.repoResolver.Resolve(r) 92 + if err != nil { 93 + l.Error("failed to get repo and knot", "err", err) 94 + w.WriteHeader(http.StatusBadRequest) 95 + return 96 + } 97 + 98 + ref := chi.URLParam(r, "ref") 99 + ref, _ = url.PathUnescape(ref) 100 + 101 + filePath := chi.URLParam(r, "*") 102 + filePath, _ = url.PathUnescape(filePath) 103 + 104 + scheme := "http" 105 + if !rp.config.Core.Dev { 106 + scheme = "https" 107 + } 108 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 109 + baseURL := &url.URL{ 110 + Scheme: scheme, 111 + Host: f.Knot, 112 + Path: "/xrpc/sh.tangled.repo.blob", 113 + } 114 + query := baseURL.Query() 115 + query.Set("repo", repo) 116 + query.Set("ref", ref) 117 + query.Set("path", filePath) 118 + query.Set("raw", "true") 119 + baseURL.RawQuery = query.Encode() 120 + blobURL := baseURL.String() 121 + req, err := http.NewRequest("GET", blobURL, nil) 122 + if err != nil { 123 + l.Error("failed to create request", "err", err) 124 + return 125 + } 126 + 127 + // forward the If-None-Match header 128 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 129 + req.Header.Set("If-None-Match", clientETag) 130 + } 131 + client := &http.Client{} 132 + 133 + resp, err := client.Do(req) 134 + if err != nil { 135 + l.Error("failed to reach knotserver", "err", err) 136 + rp.pages.Error503(w) 137 + return 138 + } 139 + 140 + defer resp.Body.Close() 141 + 142 + // forward 304 not modified 143 + if resp.StatusCode == http.StatusNotModified { 144 + w.WriteHeader(http.StatusNotModified) 145 + return 146 + } 147 + 148 + if resp.StatusCode != http.StatusOK { 149 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 150 + w.WriteHeader(resp.StatusCode) 151 + _, _ = io.Copy(w, resp.Body) 152 + return 153 + } 154 + 155 + contentType := resp.Header.Get("Content-Type") 156 + body, err := io.ReadAll(resp.Body) 157 + if err != nil { 158 + l.Error("error reading response body from knotserver", "err", err) 159 + w.WriteHeader(http.StatusInternalServerError) 160 + return 161 + } 162 + 163 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 164 + // serve all textual content as text/plain 165 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 166 + w.Write(body) 167 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 168 + // serve images and videos with their original content type 169 + w.Header().Set("Content-Type", contentType) 170 + w.Write(body) 171 + } else { 172 + w.WriteHeader(http.StatusUnsupportedMediaType) 173 + w.Write([]byte("unsupported content type")) 174 + return 175 + } 176 + } 177 + 178 + // NewBlobView creates a BlobView from the XRPC response 179 + func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView { 180 + view := models.BlobView{ 181 + Contents: "", 182 + Lines: 0, 183 + } 184 + 185 + // Set size 186 + if resp.Size != nil { 187 + view.SizeHint = uint64(*resp.Size) 188 + } else if resp.Content != nil { 189 + view.SizeHint = uint64(len(*resp.Content)) 190 + } 191 + 192 + if resp.Submodule != nil { 193 + view.ContentType = models.BlobContentTypeSubmodule 194 + view.HasRenderedView = true 195 + view.ContentSrc = resp.Submodule.Url 196 + return view 197 + } 198 + 199 + // Determine if binary 200 + if resp.IsBinary != nil && *resp.IsBinary { 201 + view.ContentSrc = generateBlobURL(config, f, ref, filePath) 202 + ext := strings.ToLower(filepath.Ext(resp.Path)) 203 + 204 + switch ext { 205 + case ".jpg", ".jpeg", ".png", ".gif", ".webp": 206 + view.ContentType = models.BlobContentTypeImage 207 + view.HasRawView = true 208 + view.HasRenderedView = true 209 + view.ShowingRendered = true 210 + 211 + case ".svg": 212 + view.ContentType = models.BlobContentTypeSvg 213 + view.HasRawView = true 214 + view.HasTextView = true 215 + view.HasRenderedView = true 216 + view.ShowingRendered = queryParams.Get("code") != "true" 217 + if resp.Content != nil { 218 + bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 219 + view.Contents = string(bytes) 220 + view.Lines = strings.Count(view.Contents, "\n") + 1 221 + } 222 + 223 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 224 + view.ContentType = models.BlobContentTypeVideo 225 + view.HasRawView = true 226 + view.HasRenderedView = true 227 + view.ShowingRendered = true 228 + } 229 + 230 + return view 231 + } 232 + 233 + // otherwise, we are dealing with text content 234 + view.HasRawView = true 235 + view.HasTextView = true 236 + 237 + if resp.Content != nil { 238 + view.Contents = *resp.Content 239 + view.Lines = strings.Count(view.Contents, "\n") + 1 240 + } 241 + 242 + // with text, we may be dealing with markdown 243 + format := markup.GetFormat(resp.Path) 244 + if format == markup.FormatMarkdown { 245 + view.ContentType = models.BlobContentTypeMarkup 246 + view.HasRenderedView = true 247 + view.ShowingRendered = queryParams.Get("code") != "true" 248 + } 249 + 250 + return view 251 + } 252 + 253 + func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string { 254 + scheme := "http" 255 + if !config.Core.Dev { 256 + scheme = "https" 257 + } 258 + 259 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 260 + baseURL := &url.URL{ 261 + Scheme: scheme, 262 + Host: f.Knot, 263 + Path: "/xrpc/sh.tangled.repo.blob", 264 + } 265 + query := baseURL.Query() 266 + query.Set("repo", repoName) 267 + query.Set("ref", ref) 268 + query.Set("path", filePath) 269 + query.Set("raw", "true") 270 + baseURL.RawQuery = query.Encode() 271 + blobURL := baseURL.String() 272 + 273 + if !config.Core.Dev { 274 + return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 275 + } 276 + return blobURL 277 + } 278 + 279 + func isTextualMimeType(mimeType string) bool { 280 + textualTypes := []string{ 281 + "application/json", 282 + "application/xml", 283 + "application/yaml", 284 + "application/x-yaml", 285 + "application/toml", 286 + "application/javascript", 287 + "application/ecmascript", 288 + "message/", 289 + } 290 + return slices.Contains(textualTypes, mimeType) 291 + }
+95
appview/repo/branches.go
···
··· 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
···
··· 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
··· 146 return fmt.Sprintf("%s in %s", base, repoName) 147 } 148 149 - func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 150 f, err := rp.repoResolver.Resolve(r) 151 if err != nil { 152 log.Println("failed to fully resolve repo:", err)
··· 146 return fmt.Sprintf("%s in %s", base, repoName) 147 } 148 149 + func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 150 f, err := rp.repoResolver.Resolve(r) 151 if err != nil { 152 log.Println("failed to fully resolve repo:", err)
+10 -11
appview/repo/index.go
··· 30 "github.com/go-enry/go-enry/v2" 31 ) 32 33 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 l := rp.logger.With("handler", "RepoIndex") 35 36 ref := chi.URLParam(r, "ref") ··· 154 CommitsTrunc: commitsTrunc, 155 TagsTrunc: tagsTrunc, 156 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 157 - BranchesTrunc: branchesTrunc, 158 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 159 - VerifiedCommits: vc, 160 - Languages: languageInfo, 161 - Pipelines: pipelines, 162 }) 163 } 164 ··· 351 if treeResp != nil && treeResp.Files != nil { 352 for _, file := range treeResp.Files { 353 niceFile := types.NiceTree{ 354 - IsFile: file.Is_file, 355 - IsSubtree: file.Is_subtree, 356 - Name: file.Name, 357 - Mode: file.Mode, 358 - Size: file.Size, 359 } 360 if file.Last_commit != nil { 361 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 362 niceFile.LastCommit = &types.LastCommitInfo{
··· 30 "github.com/go-enry/go-enry/v2" 31 ) 32 33 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 l := rp.logger.With("handler", "RepoIndex") 35 36 ref := chi.URLParam(r, "ref") ··· 154 CommitsTrunc: commitsTrunc, 155 TagsTrunc: tagsTrunc, 156 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 157 + BranchesTrunc: branchesTrunc, 158 + EmailToDid: emailToDidMap, 159 + VerifiedCommits: vc, 160 + Languages: languageInfo, 161 + Pipelines: pipelines, 162 }) 163 } 164 ··· 351 if treeResp != nil && treeResp.Files != nil { 352 for _, file := range treeResp.Files { 353 niceFile := types.NiceTree{ 354 + Name: file.Name, 355 + Mode: file.Mode, 356 + Size: file.Size, 357 } 358 + 359 if file.Last_commit != nil { 360 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 361 niceFile.LastCommit = &types.LastCommitInfo{
+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 + }
+5 -5
appview/repo/opengraph.go
··· 158 // Draw star icon, count, and label 159 // Align icon baseline with text baseline 160 iconBaselineOffset := int(textSize) / 2 161 - err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 162 if err != nil { 163 log.Printf("failed to draw star icon: %v", err) 164 } ··· 185 186 // Draw issues icon, count, and label 187 issueStartX := currentX 188 - err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 if err != nil { 190 log.Printf("failed to draw circle-dot icon: %v", err) 191 } ··· 210 211 // Draw pull request icon, count, and label 212 prStartX := currentX 213 - err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", 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 } ··· 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.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 240 if err != nil { 241 log.Printf("dolly silhouette not available (this is ok): %v", err) 242 } ··· 327 return nil 328 } 329 330 - func (rp *Repo) RepoOpenGraphSummary(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)
··· 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 } ··· 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 } ··· 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 } ··· 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 } ··· 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)
+2 -1378
appview/repo/repo.go
··· 3 import ( 4 "context" 5 "database/sql" 6 - "encoding/json" 7 "errors" 8 "fmt" 9 - "io" 10 "log/slog" 11 "net/http" 12 "net/url" 13 - "path/filepath" 14 "slices" 15 - "strconv" 16 "strings" 17 "time" 18 19 "tangled.org/core/api/tangled" 20 - "tangled.org/core/appview/commitverify" 21 "tangled.org/core/appview/config" 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 "tangled.org/core/appview/reporesolver" 29 "tangled.org/core/appview/validator" 30 xrpcclient "tangled.org/core/appview/xrpcclient" 31 "tangled.org/core/eventconsumer" 32 "tangled.org/core/idresolver" 33 - "tangled.org/core/patchutil" 34 "tangled.org/core/rbac" 35 "tangled.org/core/tid" 36 - "tangled.org/core/types" 37 "tangled.org/core/xrpc/serviceauth" 38 39 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 atpclient "github.com/bluesky-social/indigo/atproto/client" 41 "github.com/bluesky-social/indigo/atproto/syntax" 42 lexutil "github.com/bluesky-social/indigo/lex/util" 43 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 securejoin "github.com/cyphar/filepath-securejoin" 45 "github.com/go-chi/chi/v5" 46 - "github.com/go-git/go-git/v5/plumbing" 47 ) 48 49 type Repo struct { ··· 88 } 89 } 90 91 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 92 - l := rp.logger.With("handler", "DownloadArchive") 93 - 94 - ref := chi.URLParam(r, "ref") 95 - ref, _ = url.PathUnescape(ref) 96 - 97 - f, err := rp.repoResolver.Resolve(r) 98 - if err != nil { 99 - l.Error("failed to get repo and knot", "err", err) 100 - return 101 - } 102 - 103 - scheme := "http" 104 - if !rp.config.Core.Dev { 105 - scheme = "https" 106 - } 107 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 108 - xrpcc := &indigoxrpc.Client{ 109 - Host: host, 110 - } 111 - 112 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 113 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 114 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 115 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 116 - rp.pages.Error503(w) 117 - return 118 - } 119 - 120 - // Set headers for file download, just pass along whatever the knot specifies 121 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 122 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 123 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 124 - w.Header().Set("Content-Type", "application/gzip") 125 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 126 - 127 - // Write the archive data directly 128 - w.Write(archiveBytes) 129 - } 130 - 131 - func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 132 - l := rp.logger.With("handler", "RepoLog") 133 - 134 - f, err := rp.repoResolver.Resolve(r) 135 - if err != nil { 136 - l.Error("failed to fully resolve repo", "err", err) 137 - return 138 - } 139 - 140 - page := 1 141 - if r.URL.Query().Get("page") != "" { 142 - page, err = strconv.Atoi(r.URL.Query().Get("page")) 143 - if err != nil { 144 - page = 1 145 - } 146 - } 147 - 148 - ref := chi.URLParam(r, "ref") 149 - ref, _ = url.PathUnescape(ref) 150 - 151 - scheme := "http" 152 - if !rp.config.Core.Dev { 153 - scheme = "https" 154 - } 155 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 156 - xrpcc := &indigoxrpc.Client{ 157 - Host: host, 158 - } 159 - 160 - limit := int64(60) 161 - cursor := "" 162 - if page > 1 { 163 - // Convert page number to cursor (offset) 164 - offset := (page - 1) * int(limit) 165 - cursor = strconv.Itoa(offset) 166 - } 167 - 168 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 169 - xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 170 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 171 - l.Error("failed to call XRPC repo.log", "err", xrpcerr) 172 - rp.pages.Error503(w) 173 - return 174 - } 175 - 176 - var xrpcResp types.RepoLogResponse 177 - if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 178 - l.Error("failed to decode XRPC response", "err", err) 179 - rp.pages.Error503(w) 180 - return 181 - } 182 - 183 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 184 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 185 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 186 - rp.pages.Error503(w) 187 - return 188 - } 189 - 190 - tagMap := make(map[string][]string) 191 - if tagBytes != nil { 192 - var tagResp types.RepoTagsResponse 193 - if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 194 - for _, tag := range tagResp.Tags { 195 - hash := tag.Hash 196 - if tag.Tag != nil { 197 - hash = tag.Tag.Target.String() 198 - } 199 - tagMap[hash] = append(tagMap[hash], tag.Name) 200 - } 201 - } 202 - } 203 - 204 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 205 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 206 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 207 - rp.pages.Error503(w) 208 - return 209 - } 210 - 211 - if branchBytes != nil { 212 - var branchResp types.RepoBranchesResponse 213 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 214 - for _, branch := range branchResp.Branches { 215 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 216 - } 217 - } 218 - } 219 - 220 - user := rp.oauth.GetUser(r) 221 - 222 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 223 - if err != nil { 224 - l.Error("failed to fetch email to did mapping", "err", err) 225 - } 226 - 227 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 228 - if err != nil { 229 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 230 - } 231 - 232 - repoInfo := f.RepoInfo(user) 233 - 234 - var shas []string 235 - for _, c := range xrpcResp.Commits { 236 - shas = append(shas, c.Hash.String()) 237 - } 238 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 239 - if err != nil { 240 - l.Error("failed to getPipelineStatuses", "err", err) 241 - // non-fatal 242 - } 243 - 244 - rp.pages.RepoLog(w, pages.RepoLogParams{ 245 - LoggedInUser: user, 246 - TagMap: tagMap, 247 - RepoInfo: repoInfo, 248 - RepoLogResponse: xrpcResp, 249 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 250 - VerifiedCommits: vc, 251 - Pipelines: pipelines, 252 - }) 253 - } 254 - 255 - func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 256 - l := rp.logger.With("handler", "RepoDescriptionEdit") 257 - 258 - f, err := rp.repoResolver.Resolve(r) 259 - if err != nil { 260 - l.Error("failed to get repo and knot", "err", err) 261 - w.WriteHeader(http.StatusBadRequest) 262 - return 263 - } 264 - 265 - user := rp.oauth.GetUser(r) 266 - rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 267 - RepoInfo: f.RepoInfo(user), 268 - }) 269 - } 270 - 271 - func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 272 - l := rp.logger.With("handler", "RepoDescription") 273 - 274 - f, err := rp.repoResolver.Resolve(r) 275 - if err != nil { 276 - l.Error("failed to get repo and knot", "err", err) 277 - w.WriteHeader(http.StatusBadRequest) 278 - return 279 - } 280 - 281 - repoAt := f.RepoAt() 282 - rkey := repoAt.RecordKey().String() 283 - if rkey == "" { 284 - l.Error("invalid aturi for repo", "err", err) 285 - w.WriteHeader(http.StatusInternalServerError) 286 - return 287 - } 288 - 289 - user := rp.oauth.GetUser(r) 290 - 291 - switch r.Method { 292 - case http.MethodGet: 293 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 294 - RepoInfo: f.RepoInfo(user), 295 - }) 296 - return 297 - case http.MethodPut: 298 - newDescription := r.FormValue("description") 299 - client, err := rp.oauth.AuthorizedClient(r) 300 - if err != nil { 301 - l.Error("failed to get client") 302 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 303 - return 304 - } 305 - 306 - // optimistic update 307 - err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 308 - if err != nil { 309 - l.Error("failed to perform update-description query", "err", err) 310 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 311 - return 312 - } 313 - 314 - newRepo := f.Repo 315 - newRepo.Description = newDescription 316 - record := newRepo.AsRecord() 317 - 318 - // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 319 - // 320 - // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 321 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 322 - if err != nil { 323 - // failed to get record 324 - rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 325 - return 326 - } 327 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 328 - Collection: tangled.RepoNSID, 329 - Repo: newRepo.Did, 330 - Rkey: newRepo.Rkey, 331 - SwapRecord: ex.Cid, 332 - Record: &lexutil.LexiconTypeDecoder{ 333 - Val: &record, 334 - }, 335 - }) 336 - 337 - if err != nil { 338 - l.Error("failed to perferom update-description query", "err", err) 339 - // failed to get record 340 - rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 341 - return 342 - } 343 - 344 - newRepoInfo := f.RepoInfo(user) 345 - newRepoInfo.Description = newDescription 346 - 347 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 348 - RepoInfo: newRepoInfo, 349 - }) 350 - return 351 - } 352 - } 353 - 354 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 355 - l := rp.logger.With("handler", "RepoCommit") 356 - 357 - f, err := rp.repoResolver.Resolve(r) 358 - if err != nil { 359 - l.Error("failed to fully resolve repo", "err", err) 360 - return 361 - } 362 - ref := chi.URLParam(r, "ref") 363 - ref, _ = url.PathUnescape(ref) 364 - 365 - var diffOpts types.DiffOpts 366 - if d := r.URL.Query().Get("diff"); d == "split" { 367 - diffOpts.Split = true 368 - } 369 - 370 - if !plumbing.IsHash(ref) { 371 - rp.pages.Error404(w) 372 - return 373 - } 374 - 375 - scheme := "http" 376 - if !rp.config.Core.Dev { 377 - scheme = "https" 378 - } 379 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 380 - xrpcc := &indigoxrpc.Client{ 381 - Host: host, 382 - } 383 - 384 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 385 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 386 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 387 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 388 - rp.pages.Error503(w) 389 - return 390 - } 391 - 392 - var result types.RepoCommitResponse 393 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 394 - l.Error("failed to decode XRPC response", "err", err) 395 - rp.pages.Error503(w) 396 - return 397 - } 398 - 399 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 400 - if err != nil { 401 - l.Error("failed to get email to did mapping", "err", err) 402 - } 403 - 404 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 405 - if err != nil { 406 - l.Error("failed to GetVerifiedCommits", "err", err) 407 - } 408 - 409 - user := rp.oauth.GetUser(r) 410 - repoInfo := f.RepoInfo(user) 411 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 412 - if err != nil { 413 - l.Error("failed to getPipelineStatuses", "err", err) 414 - // non-fatal 415 - } 416 - var pipeline *models.Pipeline 417 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 418 - pipeline = &p 419 - } 420 - 421 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 422 - LoggedInUser: user, 423 - RepoInfo: f.RepoInfo(user), 424 - RepoCommitResponse: result, 425 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 426 - VerifiedCommit: vc, 427 - Pipeline: pipeline, 428 - DiffOpts: diffOpts, 429 - }) 430 - } 431 - 432 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 433 - l := rp.logger.With("handler", "RepoTree") 434 - 435 - f, err := rp.repoResolver.Resolve(r) 436 - if err != nil { 437 - l.Error("failed to fully resolve repo", "err", err) 438 - return 439 - } 440 - 441 - ref := chi.URLParam(r, "ref") 442 - ref, _ = url.PathUnescape(ref) 443 - 444 - // if the tree path has a trailing slash, let's strip it 445 - // so we don't 404 446 - treePath := chi.URLParam(r, "*") 447 - treePath, _ = url.PathUnescape(treePath) 448 - treePath = strings.TrimSuffix(treePath, "/") 449 - 450 - scheme := "http" 451 - if !rp.config.Core.Dev { 452 - scheme = "https" 453 - } 454 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 455 - xrpcc := &indigoxrpc.Client{ 456 - Host: host, 457 - } 458 - 459 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 460 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 461 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 462 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 463 - rp.pages.Error503(w) 464 - return 465 - } 466 - 467 - // Convert XRPC response to internal types.RepoTreeResponse 468 - files := make([]types.NiceTree, len(xrpcResp.Files)) 469 - for i, xrpcFile := range xrpcResp.Files { 470 - file := types.NiceTree{ 471 - Name: xrpcFile.Name, 472 - Mode: xrpcFile.Mode, 473 - Size: int64(xrpcFile.Size), 474 - IsFile: xrpcFile.Is_file, 475 - IsSubtree: xrpcFile.Is_subtree, 476 - } 477 - 478 - // Convert last commit info if present 479 - if xrpcFile.Last_commit != nil { 480 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 481 - file.LastCommit = &types.LastCommitInfo{ 482 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 483 - Message: xrpcFile.Last_commit.Message, 484 - When: commitWhen, 485 - } 486 - } 487 - 488 - files[i] = file 489 - } 490 - 491 - result := types.RepoTreeResponse{ 492 - Ref: xrpcResp.Ref, 493 - Files: files, 494 - } 495 - 496 - if xrpcResp.Parent != nil { 497 - result.Parent = *xrpcResp.Parent 498 - } 499 - if xrpcResp.Dotdot != nil { 500 - result.DotDot = *xrpcResp.Dotdot 501 - } 502 - if xrpcResp.Readme != nil { 503 - result.ReadmeFileName = xrpcResp.Readme.Filename 504 - result.Readme = xrpcResp.Readme.Contents 505 - } 506 - 507 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 508 - // so we can safely redirect to the "parent" (which is the same file). 509 - if len(result.Files) == 0 && result.Parent == treePath { 510 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 511 - http.Redirect(w, r, redirectTo, http.StatusFound) 512 - return 513 - } 514 - 515 - user := rp.oauth.GetUser(r) 516 - 517 - var breadcrumbs [][]string 518 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 519 - if treePath != "" { 520 - for idx, elem := range strings.Split(treePath, "/") { 521 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 522 - } 523 - } 524 - 525 - sortFiles(result.Files) 526 - 527 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 528 - LoggedInUser: user, 529 - BreadCrumbs: breadcrumbs, 530 - TreePath: treePath, 531 - RepoInfo: f.RepoInfo(user), 532 - RepoTreeResponse: result, 533 - }) 534 - } 535 - 536 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 537 - l := rp.logger.With("handler", "RepoTags") 538 - 539 - f, err := rp.repoResolver.Resolve(r) 540 - if err != nil { 541 - l.Error("failed to get repo and knot", "err", err) 542 - return 543 - } 544 - 545 - scheme := "http" 546 - if !rp.config.Core.Dev { 547 - scheme = "https" 548 - } 549 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 550 - xrpcc := &indigoxrpc.Client{ 551 - Host: host, 552 - } 553 - 554 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 555 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 556 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 557 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 558 - rp.pages.Error503(w) 559 - return 560 - } 561 - 562 - var result types.RepoTagsResponse 563 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 564 - l.Error("failed to decode XRPC response", "err", err) 565 - rp.pages.Error503(w) 566 - return 567 - } 568 - 569 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 570 - if err != nil { 571 - l.Error("failed grab artifacts", "err", err) 572 - return 573 - } 574 - 575 - // convert artifacts to map for easy UI building 576 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 577 - for _, a := range artifacts { 578 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 579 - } 580 - 581 - var danglingArtifacts []models.Artifact 582 - for _, a := range artifacts { 583 - found := false 584 - for _, t := range result.Tags { 585 - if t.Tag != nil { 586 - if t.Tag.Hash == a.Tag { 587 - found = true 588 - } 589 - } 590 - } 591 - 592 - if !found { 593 - danglingArtifacts = append(danglingArtifacts, a) 594 - } 595 - } 596 - 597 - user := rp.oauth.GetUser(r) 598 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 599 - LoggedInUser: user, 600 - RepoInfo: f.RepoInfo(user), 601 - RepoTagsResponse: result, 602 - ArtifactMap: artifactMap, 603 - DanglingArtifacts: danglingArtifacts, 604 - }) 605 - } 606 - 607 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 608 - l := rp.logger.With("handler", "RepoBranches") 609 - 610 - f, err := rp.repoResolver.Resolve(r) 611 - if err != nil { 612 - l.Error("failed to get repo and knot", "err", err) 613 - return 614 - } 615 - 616 - scheme := "http" 617 - if !rp.config.Core.Dev { 618 - scheme = "https" 619 - } 620 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 621 - xrpcc := &indigoxrpc.Client{ 622 - Host: host, 623 - } 624 - 625 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 626 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 627 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 628 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 629 - rp.pages.Error503(w) 630 - return 631 - } 632 - 633 - var result types.RepoBranchesResponse 634 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 635 - l.Error("failed to decode XRPC response", "err", err) 636 - rp.pages.Error503(w) 637 - return 638 - } 639 - 640 - sortBranches(result.Branches) 641 - 642 - user := rp.oauth.GetUser(r) 643 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 644 - LoggedInUser: user, 645 - RepoInfo: f.RepoInfo(user), 646 - RepoBranchesResponse: result, 647 - }) 648 - } 649 - 650 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 651 - l := rp.logger.With("handler", "DeleteBranch") 652 - 653 - f, err := rp.repoResolver.Resolve(r) 654 - if err != nil { 655 - l.Error("failed to get repo and knot", "err", err) 656 - return 657 - } 658 - 659 - noticeId := "delete-branch-error" 660 - fail := func(msg string, err error) { 661 - l.Error(msg, "err", err) 662 - rp.pages.Notice(w, noticeId, msg) 663 - } 664 - 665 - branch := r.FormValue("branch") 666 - if branch == "" { 667 - fail("No branch provided.", nil) 668 - return 669 - } 670 - 671 - client, err := rp.oauth.ServiceClient( 672 - r, 673 - oauth.WithService(f.Knot), 674 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 675 - oauth.WithDev(rp.config.Core.Dev), 676 - ) 677 - if err != nil { 678 - fail("Failed to connect to knotserver", nil) 679 - return 680 - } 681 - 682 - err = tangled.RepoDeleteBranch( 683 - r.Context(), 684 - client, 685 - &tangled.RepoDeleteBranch_Input{ 686 - Branch: branch, 687 - Repo: f.RepoAt().String(), 688 - }, 689 - ) 690 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 691 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 692 - return 693 - } 694 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 695 - 696 - rp.pages.HxRefresh(w) 697 - } 698 - 699 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 - l := rp.logger.With("handler", "RepoBlob") 701 - 702 - f, err := rp.repoResolver.Resolve(r) 703 - if err != nil { 704 - l.Error("failed to get repo and knot", "err", err) 705 - return 706 - } 707 - 708 - ref := chi.URLParam(r, "ref") 709 - ref, _ = url.PathUnescape(ref) 710 - 711 - filePath := chi.URLParam(r, "*") 712 - filePath, _ = url.PathUnescape(filePath) 713 - 714 - scheme := "http" 715 - if !rp.config.Core.Dev { 716 - scheme = "https" 717 - } 718 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 719 - xrpcc := &indigoxrpc.Client{ 720 - Host: host, 721 - } 722 - 723 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 724 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 725 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 726 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 727 - rp.pages.Error503(w) 728 - return 729 - } 730 - 731 - // Use XRPC response directly instead of converting to internal types 732 - 733 - var breadcrumbs [][]string 734 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 735 - if filePath != "" { 736 - for idx, elem := range strings.Split(filePath, "/") { 737 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 738 - } 739 - } 740 - 741 - showRendered := false 742 - renderToggle := false 743 - 744 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 745 - renderToggle = true 746 - showRendered = r.URL.Query().Get("code") != "true" 747 - } 748 - 749 - var unsupported bool 750 - var isImage bool 751 - var isVideo bool 752 - var contentSrc string 753 - 754 - if resp.IsBinary != nil && *resp.IsBinary { 755 - ext := strings.ToLower(filepath.Ext(resp.Path)) 756 - switch ext { 757 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 758 - isImage = true 759 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 760 - isVideo = true 761 - default: 762 - unsupported = true 763 - } 764 - 765 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 766 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 767 - 768 - baseURL := &url.URL{ 769 - Scheme: scheme, 770 - Host: f.Knot, 771 - Path: "/xrpc/sh.tangled.repo.blob", 772 - } 773 - query := baseURL.Query() 774 - query.Set("repo", repoName) 775 - query.Set("ref", ref) 776 - query.Set("path", filePath) 777 - query.Set("raw", "true") 778 - baseURL.RawQuery = query.Encode() 779 - blobURL := baseURL.String() 780 - 781 - contentSrc = blobURL 782 - if !rp.config.Core.Dev { 783 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 784 - } 785 - } 786 - 787 - lines := 0 788 - if resp.IsBinary == nil || !*resp.IsBinary { 789 - lines = strings.Count(resp.Content, "\n") + 1 790 - } 791 - 792 - var sizeHint uint64 793 - if resp.Size != nil { 794 - sizeHint = uint64(*resp.Size) 795 - } else { 796 - sizeHint = uint64(len(resp.Content)) 797 - } 798 - 799 - user := rp.oauth.GetUser(r) 800 - 801 - // Determine if content is binary (dereference pointer) 802 - isBinary := false 803 - if resp.IsBinary != nil { 804 - isBinary = *resp.IsBinary 805 - } 806 - 807 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 808 - LoggedInUser: user, 809 - RepoInfo: f.RepoInfo(user), 810 - BreadCrumbs: breadcrumbs, 811 - ShowRendered: showRendered, 812 - RenderToggle: renderToggle, 813 - Unsupported: unsupported, 814 - IsImage: isImage, 815 - IsVideo: isVideo, 816 - ContentSrc: contentSrc, 817 - RepoBlob_Output: resp, 818 - Contents: resp.Content, 819 - Lines: lines, 820 - SizeHint: sizeHint, 821 - IsBinary: isBinary, 822 - }) 823 - } 824 - 825 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 - l := rp.logger.With("handler", "RepoBlobRaw") 827 - 828 - f, err := rp.repoResolver.Resolve(r) 829 - if err != nil { 830 - l.Error("failed to get repo and knot", "err", err) 831 - w.WriteHeader(http.StatusBadRequest) 832 - return 833 - } 834 - 835 - ref := chi.URLParam(r, "ref") 836 - ref, _ = url.PathUnescape(ref) 837 - 838 - filePath := chi.URLParam(r, "*") 839 - filePath, _ = url.PathUnescape(filePath) 840 - 841 - scheme := "http" 842 - if !rp.config.Core.Dev { 843 - scheme = "https" 844 - } 845 - 846 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 847 - baseURL := &url.URL{ 848 - Scheme: scheme, 849 - Host: f.Knot, 850 - Path: "/xrpc/sh.tangled.repo.blob", 851 - } 852 - query := baseURL.Query() 853 - query.Set("repo", repo) 854 - query.Set("ref", ref) 855 - query.Set("path", filePath) 856 - query.Set("raw", "true") 857 - baseURL.RawQuery = query.Encode() 858 - blobURL := baseURL.String() 859 - 860 - req, err := http.NewRequest("GET", blobURL, nil) 861 - if err != nil { 862 - l.Error("failed to create request", "err", err) 863 - return 864 - } 865 - 866 - // forward the If-None-Match header 867 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 868 - req.Header.Set("If-None-Match", clientETag) 869 - } 870 - 871 - client := &http.Client{} 872 - resp, err := client.Do(req) 873 - if err != nil { 874 - l.Error("failed to reach knotserver", "err", err) 875 - rp.pages.Error503(w) 876 - return 877 - } 878 - defer resp.Body.Close() 879 - 880 - // forward 304 not modified 881 - if resp.StatusCode == http.StatusNotModified { 882 - w.WriteHeader(http.StatusNotModified) 883 - return 884 - } 885 - 886 - if resp.StatusCode != http.StatusOK { 887 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 888 - w.WriteHeader(resp.StatusCode) 889 - _, _ = io.Copy(w, resp.Body) 890 - return 891 - } 892 - 893 - contentType := resp.Header.Get("Content-Type") 894 - body, err := io.ReadAll(resp.Body) 895 - if err != nil { 896 - l.Error("error reading response body from knotserver", "err", err) 897 - w.WriteHeader(http.StatusInternalServerError) 898 - return 899 - } 900 - 901 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 902 - // serve all textual content as text/plain 903 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 904 - w.Write(body) 905 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 906 - // serve images and videos with their original content type 907 - w.Header().Set("Content-Type", contentType) 908 - w.Write(body) 909 - } else { 910 - w.WriteHeader(http.StatusUnsupportedMediaType) 911 - w.Write([]byte("unsupported content type")) 912 - return 913 - } 914 - } 915 - 916 - // isTextualMimeType returns true if the MIME type represents textual content 917 - // that should be served as text/plain 918 - func isTextualMimeType(mimeType string) bool { 919 - textualTypes := []string{ 920 - "application/json", 921 - "application/xml", 922 - "application/yaml", 923 - "application/x-yaml", 924 - "application/toml", 925 - "application/javascript", 926 - "application/ecmascript", 927 - "message/", 928 - } 929 - 930 - return slices.Contains(textualTypes, mimeType) 931 - } 932 - 933 // modify the spindle configured for this repo 934 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 935 user := rp.oauth.GetUser(r) ··· 1785 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1786 } 1787 1788 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 - l := rp.logger.With("handler", "SetDefaultBranch") 1790 - 1791 - f, err := rp.repoResolver.Resolve(r) 1792 - if err != nil { 1793 - l.Error("failed to get repo and knot", "err", err) 1794 - return 1795 - } 1796 - 1797 - noticeId := "operation-error" 1798 - branch := r.FormValue("branch") 1799 - if branch == "" { 1800 - http.Error(w, "malformed form", http.StatusBadRequest) 1801 - return 1802 - } 1803 - 1804 - client, err := rp.oauth.ServiceClient( 1805 - r, 1806 - oauth.WithService(f.Knot), 1807 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1808 - oauth.WithDev(rp.config.Core.Dev), 1809 - ) 1810 - if err != nil { 1811 - l.Error("failed to connect to knot server", "err", err) 1812 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1813 - return 1814 - } 1815 - 1816 - xe := tangled.RepoSetDefaultBranch( 1817 - r.Context(), 1818 - client, 1819 - &tangled.RepoSetDefaultBranch_Input{ 1820 - Repo: f.RepoAt().String(), 1821 - DefaultBranch: branch, 1822 - }, 1823 - ) 1824 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1825 - l.Error("xrpc failed", "err", xe) 1826 - rp.pages.Notice(w, noticeId, err.Error()) 1827 - return 1828 - } 1829 - 1830 - rp.pages.HxRefresh(w) 1831 - } 1832 - 1833 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1834 - user := rp.oauth.GetUser(r) 1835 - l := rp.logger.With("handler", "Secrets") 1836 - l = l.With("did", user.Did) 1837 - 1838 - f, err := rp.repoResolver.Resolve(r) 1839 - if err != nil { 1840 - l.Error("failed to get repo and knot", "err", err) 1841 - return 1842 - } 1843 - 1844 - if f.Spindle == "" { 1845 - l.Error("empty spindle cannot add/rm secret", "err", err) 1846 - return 1847 - } 1848 - 1849 - lxm := tangled.RepoAddSecretNSID 1850 - if r.Method == http.MethodDelete { 1851 - lxm = tangled.RepoRemoveSecretNSID 1852 - } 1853 - 1854 - spindleClient, err := rp.oauth.ServiceClient( 1855 - r, 1856 - oauth.WithService(f.Spindle), 1857 - oauth.WithLxm(lxm), 1858 - oauth.WithExp(60), 1859 - oauth.WithDev(rp.config.Core.Dev), 1860 - ) 1861 - if err != nil { 1862 - l.Error("failed to create spindle client", "err", err) 1863 - return 1864 - } 1865 - 1866 - key := r.FormValue("key") 1867 - if key == "" { 1868 - w.WriteHeader(http.StatusBadRequest) 1869 - return 1870 - } 1871 - 1872 - switch r.Method { 1873 - case http.MethodPut: 1874 - errorId := "add-secret-error" 1875 - 1876 - value := r.FormValue("value") 1877 - if value == "" { 1878 - w.WriteHeader(http.StatusBadRequest) 1879 - return 1880 - } 1881 - 1882 - err = tangled.RepoAddSecret( 1883 - r.Context(), 1884 - spindleClient, 1885 - &tangled.RepoAddSecret_Input{ 1886 - Repo: f.RepoAt().String(), 1887 - Key: key, 1888 - Value: value, 1889 - }, 1890 - ) 1891 - if err != nil { 1892 - l.Error("Failed to add secret.", "err", err) 1893 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1894 - return 1895 - } 1896 - 1897 - case http.MethodDelete: 1898 - errorId := "operation-error" 1899 - 1900 - err = tangled.RepoRemoveSecret( 1901 - r.Context(), 1902 - spindleClient, 1903 - &tangled.RepoRemoveSecret_Input{ 1904 - Repo: f.RepoAt().String(), 1905 - Key: key, 1906 - }, 1907 - ) 1908 - if err != nil { 1909 - l.Error("Failed to delete secret.", "err", err) 1910 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1911 - return 1912 - } 1913 - } 1914 - 1915 - rp.pages.HxRefresh(w) 1916 - } 1917 - 1918 - type tab = map[string]any 1919 - 1920 - var ( 1921 - // would be great to have ordered maps right about now 1922 - settingsTabs []tab = []tab{ 1923 - {"Name": "general", "Icon": "sliders-horizontal"}, 1924 - {"Name": "access", "Icon": "users"}, 1925 - {"Name": "pipelines", "Icon": "layers-2"}, 1926 - } 1927 - ) 1928 - 1929 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1930 - tabVal := r.URL.Query().Get("tab") 1931 - if tabVal == "" { 1932 - tabVal = "general" 1933 - } 1934 - 1935 - switch tabVal { 1936 - case "general": 1937 - rp.generalSettings(w, r) 1938 - 1939 - case "access": 1940 - rp.accessSettings(w, r) 1941 - 1942 - case "pipelines": 1943 - rp.pipelineSettings(w, r) 1944 - } 1945 - } 1946 - 1947 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 - l := rp.logger.With("handler", "generalSettings") 1949 - 1950 - f, err := rp.repoResolver.Resolve(r) 1951 - user := rp.oauth.GetUser(r) 1952 - 1953 - scheme := "http" 1954 - if !rp.config.Core.Dev { 1955 - scheme = "https" 1956 - } 1957 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1958 - xrpcc := &indigoxrpc.Client{ 1959 - Host: host, 1960 - } 1961 - 1962 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1963 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1964 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1965 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1966 - rp.pages.Error503(w) 1967 - return 1968 - } 1969 - 1970 - var result types.RepoBranchesResponse 1971 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1972 - l.Error("failed to decode XRPC response", "err", err) 1973 - rp.pages.Error503(w) 1974 - return 1975 - } 1976 - 1977 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1978 - if err != nil { 1979 - l.Error("failed to fetch labels", "err", err) 1980 - rp.pages.Error503(w) 1981 - return 1982 - } 1983 - 1984 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1985 - if err != nil { 1986 - l.Error("failed to fetch labels", "err", err) 1987 - rp.pages.Error503(w) 1988 - return 1989 - } 1990 - // remove default labels from the labels list, if present 1991 - defaultLabelMap := make(map[string]bool) 1992 - for _, dl := range defaultLabels { 1993 - defaultLabelMap[dl.AtUri().String()] = true 1994 - } 1995 - n := 0 1996 - for _, l := range labels { 1997 - if !defaultLabelMap[l.AtUri().String()] { 1998 - labels[n] = l 1999 - n++ 2000 - } 2001 - } 2002 - labels = labels[:n] 2003 - 2004 - subscribedLabels := make(map[string]struct{}) 2005 - for _, l := range f.Repo.Labels { 2006 - subscribedLabels[l] = struct{}{} 2007 - } 2008 - 2009 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 2010 - // if all default labels are subbed, show the "unsubscribe all" button 2011 - shouldSubscribeAll := false 2012 - for _, dl := range defaultLabels { 2013 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 2014 - // one of the default labels is not subscribed to 2015 - shouldSubscribeAll = true 2016 - break 2017 - } 2018 - } 2019 - 2020 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 2021 - LoggedInUser: user, 2022 - RepoInfo: f.RepoInfo(user), 2023 - Branches: result.Branches, 2024 - Labels: labels, 2025 - DefaultLabels: defaultLabels, 2026 - SubscribedLabels: subscribedLabels, 2027 - ShouldSubscribeAll: shouldSubscribeAll, 2028 - Tabs: settingsTabs, 2029 - Tab: "general", 2030 - }) 2031 - } 2032 - 2033 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 - l := rp.logger.With("handler", "accessSettings") 2035 - 2036 - f, err := rp.repoResolver.Resolve(r) 2037 - user := rp.oauth.GetUser(r) 2038 - 2039 - repoCollaborators, err := f.Collaborators(r.Context()) 2040 - if err != nil { 2041 - l.Error("failed to get collaborators", "err", err) 2042 - } 2043 - 2044 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 2045 - LoggedInUser: user, 2046 - RepoInfo: f.RepoInfo(user), 2047 - Tabs: settingsTabs, 2048 - Tab: "access", 2049 - Collaborators: repoCollaborators, 2050 - }) 2051 - } 2052 - 2053 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 - l := rp.logger.With("handler", "pipelineSettings") 2055 - 2056 - f, err := rp.repoResolver.Resolve(r) 2057 - user := rp.oauth.GetUser(r) 2058 - 2059 - // all spindles that the repo owner is a member of 2060 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2061 - if err != nil { 2062 - l.Error("failed to fetch spindles", "err", err) 2063 - return 2064 - } 2065 - 2066 - var secrets []*tangled.RepoListSecrets_Secret 2067 - if f.Spindle != "" { 2068 - if spindleClient, err := rp.oauth.ServiceClient( 2069 - r, 2070 - oauth.WithService(f.Spindle), 2071 - oauth.WithLxm(tangled.RepoListSecretsNSID), 2072 - oauth.WithExp(60), 2073 - oauth.WithDev(rp.config.Core.Dev), 2074 - ); err != nil { 2075 - l.Error("failed to create spindle client", "err", err) 2076 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2077 - l.Error("failed to fetch secrets", "err", err) 2078 - } else { 2079 - secrets = resp.Secrets 2080 - } 2081 - } 2082 - 2083 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2084 - return strings.Compare(a.Key, b.Key) 2085 - }) 2086 - 2087 - var dids []string 2088 - for _, s := range secrets { 2089 - dids = append(dids, s.CreatedBy) 2090 - } 2091 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2092 - 2093 - // convert to a more manageable form 2094 - var niceSecret []map[string]any 2095 - for id, s := range secrets { 2096 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2097 - niceSecret = append(niceSecret, map[string]any{ 2098 - "Id": id, 2099 - "Key": s.Key, 2100 - "CreatedAt": when, 2101 - "CreatedBy": resolvedIdents[id].Handle.String(), 2102 - }) 2103 - } 2104 - 2105 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2106 - LoggedInUser: user, 2107 - RepoInfo: f.RepoInfo(user), 2108 - Tabs: settingsTabs, 2109 - Tab: "pipelines", 2110 - Spindles: spindles, 2111 - CurrentSpindle: f.Spindle, 2112 - Secrets: niceSecret, 2113 - }) 2114 - } 2115 - 2116 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2117 l := rp.logger.With("handler", "SyncRepoFork") 2118 ··· 2253 Source: sourceAt, 2254 Description: f.Repo.Description, 2255 Created: time.Now(), 2256 - Labels: models.DefaultLabelDefs(), 2257 } 2258 record := repo.AsRecord() 2259 ··· 2369 aturi = "" 2370 2371 rp.notifier.NewRepo(r.Context(), repo) 2372 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2373 } 2374 } 2375 ··· 2394 }) 2395 return err 2396 } 2397 - 2398 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 - l := rp.logger.With("handler", "RepoCompareNew") 2400 - 2401 - user := rp.oauth.GetUser(r) 2402 - f, err := rp.repoResolver.Resolve(r) 2403 - if err != nil { 2404 - l.Error("failed to get repo and knot", "err", err) 2405 - return 2406 - } 2407 - 2408 - scheme := "http" 2409 - if !rp.config.Core.Dev { 2410 - scheme = "https" 2411 - } 2412 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2413 - xrpcc := &indigoxrpc.Client{ 2414 - Host: host, 2415 - } 2416 - 2417 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2418 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2419 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2420 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2421 - rp.pages.Error503(w) 2422 - return 2423 - } 2424 - 2425 - var branchResult types.RepoBranchesResponse 2426 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2427 - l.Error("failed to decode XRPC branches response", "err", err) 2428 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2429 - return 2430 - } 2431 - branches := branchResult.Branches 2432 - 2433 - sortBranches(branches) 2434 - 2435 - var defaultBranch string 2436 - for _, b := range branches { 2437 - if b.IsDefault { 2438 - defaultBranch = b.Name 2439 - } 2440 - } 2441 - 2442 - base := defaultBranch 2443 - head := defaultBranch 2444 - 2445 - params := r.URL.Query() 2446 - queryBase := params.Get("base") 2447 - queryHead := params.Get("head") 2448 - if queryBase != "" { 2449 - base = queryBase 2450 - } 2451 - if queryHead != "" { 2452 - head = queryHead 2453 - } 2454 - 2455 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2456 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2457 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2458 - rp.pages.Error503(w) 2459 - return 2460 - } 2461 - 2462 - var tags types.RepoTagsResponse 2463 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2464 - l.Error("failed to decode XRPC tags response", "err", err) 2465 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2466 - return 2467 - } 2468 - 2469 - repoinfo := f.RepoInfo(user) 2470 - 2471 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2472 - LoggedInUser: user, 2473 - RepoInfo: repoinfo, 2474 - Branches: branches, 2475 - Tags: tags.Tags, 2476 - Base: base, 2477 - Head: head, 2478 - }) 2479 - } 2480 - 2481 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 - l := rp.logger.With("handler", "RepoCompare") 2483 - 2484 - user := rp.oauth.GetUser(r) 2485 - f, err := rp.repoResolver.Resolve(r) 2486 - if err != nil { 2487 - l.Error("failed to get repo and knot", "err", err) 2488 - return 2489 - } 2490 - 2491 - var diffOpts types.DiffOpts 2492 - if d := r.URL.Query().Get("diff"); d == "split" { 2493 - diffOpts.Split = true 2494 - } 2495 - 2496 - // if user is navigating to one of 2497 - // /compare/{base}/{head} 2498 - // /compare/{base}...{head} 2499 - base := chi.URLParam(r, "base") 2500 - head := chi.URLParam(r, "head") 2501 - if base == "" && head == "" { 2502 - rest := chi.URLParam(r, "*") // master...feature/xyz 2503 - parts := strings.SplitN(rest, "...", 2) 2504 - if len(parts) == 2 { 2505 - base = parts[0] 2506 - head = parts[1] 2507 - } 2508 - } 2509 - 2510 - base, _ = url.PathUnescape(base) 2511 - head, _ = url.PathUnescape(head) 2512 - 2513 - if base == "" || head == "" { 2514 - l.Error("invalid comparison") 2515 - rp.pages.Error404(w) 2516 - return 2517 - } 2518 - 2519 - scheme := "http" 2520 - if !rp.config.Core.Dev { 2521 - scheme = "https" 2522 - } 2523 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2524 - xrpcc := &indigoxrpc.Client{ 2525 - Host: host, 2526 - } 2527 - 2528 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2529 - 2530 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2531 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2532 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2533 - rp.pages.Error503(w) 2534 - return 2535 - } 2536 - 2537 - var branches types.RepoBranchesResponse 2538 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2539 - l.Error("failed to decode XRPC branches response", "err", err) 2540 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2541 - return 2542 - } 2543 - 2544 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2545 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2546 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2547 - rp.pages.Error503(w) 2548 - return 2549 - } 2550 - 2551 - var tags types.RepoTagsResponse 2552 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2553 - l.Error("failed to decode XRPC tags response", "err", err) 2554 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2555 - return 2556 - } 2557 - 2558 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2559 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2560 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2561 - rp.pages.Error503(w) 2562 - return 2563 - } 2564 - 2565 - var formatPatch types.RepoFormatPatchResponse 2566 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2567 - l.Error("failed to decode XRPC compare response", "err", err) 2568 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2569 - return 2570 - } 2571 - 2572 - var diff types.NiceDiff 2573 - if formatPatch.CombinedPatchRaw != "" { 2574 - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2575 - } else { 2576 - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2577 - } 2578 - 2579 - repoinfo := f.RepoInfo(user) 2580 - 2581 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2582 - LoggedInUser: user, 2583 - RepoInfo: repoinfo, 2584 - Branches: branches.Branches, 2585 - Tags: tags.Tags, 2586 - Base: base, 2587 - Head: head, 2588 - Diff: &diff, 2589 - DiffOpts: diffOpts, 2590 - }) 2591 - 2592 - }
··· 3 import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "net/url" 11 "slices" 12 "strings" 13 "time" 14 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/appview/config" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/appview/models" 19 "tangled.org/core/appview/notify" 20 "tangled.org/core/appview/oauth" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/reporesolver" 23 "tangled.org/core/appview/validator" 24 xrpcclient "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/eventconsumer" 26 "tangled.org/core/idresolver" 27 "tangled.org/core/rbac" 28 "tangled.org/core/tid" 29 "tangled.org/core/xrpc/serviceauth" 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" 35 securejoin "github.com/cyphar/filepath-securejoin" 36 "github.com/go-chi/chi/v5" 37 ) 38 39 type Repo struct { ··· 78 } 79 } 80 81 // modify the spindle configured for this repo 82 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 83 user := rp.oauth.GetUser(r) ··· 933 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 934 } 935 936 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 937 l := rp.logger.With("handler", "SyncRepoFork") 938 ··· 1073 Source: sourceAt, 1074 Description: f.Repo.Description, 1075 Created: time.Now(), 1076 + Labels: rp.config.Label.DefaultLabelDefs, 1077 } 1078 record := repo.AsRecord() 1079 ··· 1189 aturi = "" 1190 1191 rp.notifier.NewRepo(r.Context(), repo) 1192 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 1193 } 1194 } 1195 ··· 1214 }) 1215 return err 1216 }
+2 -37
appview/repo/repo_util.go
··· 1 package repo 2 3 import ( 4 - "context" 5 "crypto/rand" 6 - "fmt" 7 "math/big" 8 "slices" 9 "sort" ··· 19 20 func sortFiles(files []types.NiceTree) { 21 sort.Slice(files, func(i, j int) bool { 22 - iIsFile := files[i].IsFile 23 - jIsFile := files[j].IsFile 24 if iIsFile != jIsFile { 25 return !iIsFile 26 } ··· 90 } 91 92 return 93 - } 94 - 95 - // emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid 96 - // and resolves all dids to handles and returns a new map[string]string 97 - func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string { 98 - if emailToDidMap == nil { 99 - return nil 100 - } 101 - 102 - var dids []string 103 - for _, v := range emailToDidMap { 104 - dids = append(dids, v) 105 - } 106 - resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids) 107 - 108 - didHandleMap := make(map[string]string) 109 - for _, identity := range resolvedIdents { 110 - if !identity.Handle.IsInvalidHandle() { 111 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 - } else { 113 - didHandleMap[identity.DID.String()] = identity.DID.String() 114 - } 115 - } 116 - 117 - // Create map of email to didOrHandle for commit display 118 - emailToDidOrHandle := make(map[string]string) 119 - for email, did := range emailToDidMap { 120 - if didOrHandle, ok := didHandleMap[did]; ok { 121 - emailToDidOrHandle[email] = didOrHandle 122 - } 123 - } 124 - 125 - return emailToDidOrHandle 126 } 127 128 func randomString(n int) string {
··· 1 package repo 2 3 import ( 4 "crypto/rand" 5 "math/big" 6 "slices" 7 "sort" ··· 17 18 func sortFiles(files []types.NiceTree) { 19 sort.Slice(files, func(i, j int) bool { 20 + iIsFile := files[i].IsFile() 21 + jIsFile := files[j].IsFile() 22 if iIsFile != jIsFile { 23 return !iIsFile 24 } ··· 88 } 89 90 return 91 } 92 93 func randomString(n int) string {
+15 -20
appview/repo/router.go
··· 9 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 - r.Get("/", rp.RepoIndex) 13 - r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 - r.Get("/feed.atom", rp.RepoAtomFeed) 15 - r.Get("/commits/{ref}", rp.RepoLog) 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 - r.Get("/", rp.RepoIndex) 18 - r.Get("/*", rp.RepoTree) 19 }) 20 - r.Get("/commit/{ref}", rp.RepoCommit) 21 - r.Get("/branches", rp.RepoBranches) 22 r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 - r.Get("/", rp.RepoTags) 25 r.Route("/{tag}", func(r chi.Router) { 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 ··· 37 }) 38 }) 39 }) 40 - r.Get("/blob/{ref}/*", rp.RepoBlob) 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 43 // intentionally doesn't use /* as this isn't ··· 54 }) 55 56 r.Route("/compare", func(r chi.Router) { 57 - r.Get("/", rp.RepoCompareNew) // start an new comparison 58 59 // we have to wildcard here since we want to support GitHub's compare syntax 60 // /compare/{ref1}...{ref2} 61 // for example: 62 // /compare/master...some/feature 63 // /compare/master...example.com:another/feature <- this is a fork 64 - r.Get("/{base}/{head}", rp.RepoCompare) 65 - r.Get("/*", rp.RepoCompare) 66 }) 67 68 // label panel in issues/pulls/discussions/tasks ··· 74 // settings routes, needs auth 75 r.Group(func(r chi.Router) { 76 r.Use(middleware.AuthMiddleware(rp.oauth)) 77 - // repo description can only be edited by owner 78 - r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { 79 - r.Put("/", rp.RepoDescription) 80 - r.Get("/", rp.RepoDescription) 81 - r.Get("/edit", rp.RepoDescriptionEdit) 82 - }) 83 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 84 - r.Get("/", rp.RepoSettings) 85 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 86 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 87 r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
··· 9 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 + r.Get("/", rp.Index) 13 + r.Get("/opengraph", rp.Opengraph) 14 + r.Get("/feed.atom", rp.AtomFeed) 15 + r.Get("/commits/{ref}", rp.Log) 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 + r.Get("/", rp.Index) 18 + r.Get("/*", rp.Tree) 19 }) 20 + r.Get("/commit/{ref}", rp.Commit) 21 + r.Get("/branches", rp.Branches) 22 r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 + r.Get("/", rp.Tags) 25 r.Route("/{tag}", func(r chi.Router) { 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 ··· 37 }) 38 }) 39 }) 40 + r.Get("/blob/{ref}/*", rp.Blob) 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 43 // intentionally doesn't use /* as this isn't ··· 54 }) 55 56 r.Route("/compare", func(r chi.Router) { 57 + r.Get("/", rp.CompareNew) // start an new comparison 58 59 // we have to wildcard here since we want to support GitHub's compare syntax 60 // /compare/{ref1}...{ref2} 61 // for example: 62 // /compare/master...some/feature 63 // /compare/master...example.com:another/feature <- this is a fork 64 + r.Get("/{base}/{head}", rp.Compare) 65 + r.Get("/*", rp.Compare) 66 }) 67 68 // label panel in issues/pulls/discussions/tasks ··· 74 // settings routes, needs auth 75 r.Group(func(r chi.Router) { 76 r.Use(middleware.AuthMiddleware(rp.oauth)) 77 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 78 + r.Get("/", rp.Settings) 79 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 81 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 82 r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
+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 + }
+79
appview/repo/tags.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/db" 10 + "tangled.org/core/appview/models" 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-git/go-git/v5/plumbing" 17 + ) 18 + 19 + func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) { 20 + l := rp.logger.With("handler", "RepoTags") 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 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 36 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 38 + rp.pages.Error503(w) 39 + return 40 + } 41 + var result types.RepoTagsResponse 42 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 43 + l.Error("failed to decode XRPC response", "err", err) 44 + rp.pages.Error503(w) 45 + return 46 + } 47 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 48 + if err != nil { 49 + l.Error("failed grab artifacts", "err", err) 50 + return 51 + } 52 + // convert artifacts to map for easy UI building 53 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 54 + for _, a := range artifacts { 55 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 56 + } 57 + var danglingArtifacts []models.Artifact 58 + for _, a := range artifacts { 59 + found := false 60 + for _, t := range result.Tags { 61 + if t.Tag != nil { 62 + if t.Tag.Hash == a.Tag { 63 + found = true 64 + } 65 + } 66 + } 67 + if !found { 68 + danglingArtifacts = append(danglingArtifacts, a) 69 + } 70 + } 71 + user := rp.oauth.GetUser(r) 72 + rp.pages.RepoTags(w, pages.RepoTagsParams{ 73 + LoggedInUser: user, 74 + RepoInfo: f.RepoInfo(user), 75 + RepoTagsResponse: result, 76 + ArtifactMap: artifactMap, 77 + DanglingArtifacts: danglingArtifacts, 78 + }) 79 + }
+106
appview/repo/tree.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + "time" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/types" 14 + 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/go-chi/chi/v5" 17 + "github.com/go-git/go-git/v5/plumbing" 18 + ) 19 + 20 + func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoTree") 22 + f, err := rp.repoResolver.Resolve(r) 23 + if err != nil { 24 + l.Error("failed to fully resolve repo", "err", err) 25 + return 26 + } 27 + ref := chi.URLParam(r, "ref") 28 + ref, _ = url.PathUnescape(ref) 29 + // if the tree path has a trailing slash, let's strip it 30 + // so we don't 404 31 + treePath := chi.URLParam(r, "*") 32 + treePath, _ = url.PathUnescape(treePath) 33 + treePath = strings.TrimSuffix(treePath, "/") 34 + scheme := "http" 35 + if !rp.config.Core.Dev { 36 + scheme = "https" 37 + } 38 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 39 + xrpcc := &indigoxrpc.Client{ 40 + Host: host, 41 + } 42 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 43 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 44 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 45 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 46 + rp.pages.Error503(w) 47 + return 48 + } 49 + // Convert XRPC response to internal types.RepoTreeResponse 50 + files := make([]types.NiceTree, len(xrpcResp.Files)) 51 + for i, xrpcFile := range xrpcResp.Files { 52 + file := types.NiceTree{ 53 + Name: xrpcFile.Name, 54 + Mode: xrpcFile.Mode, 55 + Size: int64(xrpcFile.Size), 56 + } 57 + // Convert last commit info if present 58 + if xrpcFile.Last_commit != nil { 59 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 60 + file.LastCommit = &types.LastCommitInfo{ 61 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 62 + Message: xrpcFile.Last_commit.Message, 63 + When: commitWhen, 64 + } 65 + } 66 + files[i] = file 67 + } 68 + result := types.RepoTreeResponse{ 69 + Ref: xrpcResp.Ref, 70 + Files: files, 71 + } 72 + if xrpcResp.Parent != nil { 73 + result.Parent = *xrpcResp.Parent 74 + } 75 + if xrpcResp.Dotdot != nil { 76 + result.DotDot = *xrpcResp.Dotdot 77 + } 78 + if xrpcResp.Readme != nil { 79 + result.ReadmeFileName = xrpcResp.Readme.Filename 80 + result.Readme = xrpcResp.Readme.Contents 81 + } 82 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 83 + // so we can safely redirect to the "parent" (which is the same file). 84 + if len(result.Files) == 0 && result.Parent == treePath { 85 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 86 + http.Redirect(w, r, redirectTo, http.StatusFound) 87 + return 88 + } 89 + user := rp.oauth.GetUser(r) 90 + var breadcrumbs [][]string 91 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 92 + if treePath != "" { 93 + for idx, elem := range strings.Split(treePath, "/") { 94 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 95 + } 96 + } 97 + sortFiles(result.Files) 98 + 99 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 100 + LoggedInUser: user, 101 + BreadCrumbs: breadcrumbs, 102 + TreePath: treePath, 103 + RepoInfo: f.RepoInfo(user), 104 + RepoTreeResponse: result, 105 + }) 106 + }
+2
appview/reporesolver/resolver.go
··· 188 Rkey: f.Repo.Rkey, 189 RepoAt: repoAt, 190 Description: f.Description, 191 IsStarred: isStarred, 192 Knot: knot, 193 Spindle: f.Spindle,
··· 188 Rkey: f.Repo.Rkey, 189 RepoAt: repoAt, 190 Description: f.Description, 191 + Website: f.Website, 192 + Topics: f.Topics, 193 IsStarred: isStarred, 194 Knot: knot, 195 Spindle: f.Spindle,
+1
appview/settings/settings.go
··· 120 PullCommented: r.FormValue("pull_commented") == "on", 121 PullMerged: r.FormValue("pull_merged") == "on", 122 Followed: r.FormValue("followed") == "on", 123 EmailNotifications: r.FormValue("email_notifications") == "on", 124 } 125
··· 120 PullCommented: r.FormValue("pull_commented") == "on", 121 PullMerged: r.FormValue("pull_merged") == "on", 122 Followed: r.FormValue("followed") == "on", 123 + UserMentioned: r.FormValue("user_mentioned") == "on", 124 EmailNotifications: r.FormValue("email_notifications") == "on", 125 } 126
+9
appview/spindles/spindles.go
··· 6 "log/slog" 7 "net/http" 8 "slices" 9 "time" 10 11 "github.com/go-chi/chi/v5" ··· 146 } 147 148 instance := r.FormValue("instance") 149 if instance == "" { 150 s.Pages.Notice(w, noticeId, "Incomplete form.") 151 return ··· 484 } 485 486 member := r.FormValue("member") 487 if member == "" { 488 l.Error("empty member") 489 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 613 } 614 615 member := r.FormValue("member") 616 if member == "" { 617 l.Error("empty member") 618 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
··· 6 "log/slog" 7 "net/http" 8 "slices" 9 + "strings" 10 "time" 11 12 "github.com/go-chi/chi/v5" ··· 147 } 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, "/") 156 if instance == "" { 157 s.Pages.Notice(w, noticeId, "Incomplete form.") 158 return ··· 491 } 492 493 member := r.FormValue("member") 494 + member = strings.TrimPrefix(member, "@") 495 if member == "" { 496 l.Error("empty member") 497 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 621 } 622 623 member := r.FormValue("member") 624 + member = strings.TrimPrefix(member, "@") 625 if member == "" { 626 l.Error("empty member") 627 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+1
appview/state/follow.go
··· 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 if err != nil { 28 log.Println("failed to follow, invalid did") 29 } 30 31 if currentUser.Did == subjectIdent.DID.String() {
··· 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 if err != nil { 28 log.Println("failed to follow, invalid did") 29 + return 30 } 31 32 if currentUser.Did == subjectIdent.DID.String() {
+11 -8
appview/state/gfi.go
··· 1 package state 2 3 import ( 4 - "fmt" 5 "log" 6 "net/http" 7 "sort" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/api/tangled" 11 "tangled.org/core/appview/db" 12 "tangled.org/core/appview/models" 13 "tangled.org/core/appview/pages" ··· 18 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 user := s.oauth.GetUser(r) 20 21 - page, ok := r.Context().Value("page").(pagination.Page) 22 - if !ok { 23 - page = pagination.FirstPage() 24 } 25 - 26 - goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 28 repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 if err != nil { ··· 38 RepoGroups: []*models.RepoGroup{}, 39 LabelDefs: make(map[string]*models.LabelDefinition), 40 Page: page, 41 }) 42 return 43 } ··· 146 RepoGroups: paginatedGroups, 147 LabelDefs: labelDefsMap, 148 Page: page, 149 - GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 }) 151 }
··· 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" ··· 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 { ··· 40 RepoGroups: []*models.RepoGroup{}, 41 LabelDefs: make(map[string]*models.LabelDefinition), 42 Page: page, 43 + GfiLabel: gfiLabelDef, 44 }) 45 return 46 } ··· 149 RepoGroups: paginatedGroups, 150 LabelDefs: labelDefsMap, 151 Page: page, 152 + GfiLabel: gfiLabelDef, 153 }) 154 }
+3
appview/state/login.go
··· 14 switch r.Method { 15 case http.MethodGet: 16 returnURL := r.URL.Query().Get("return_url") 17 s.pages.Login(w, pages.LoginParams{ 18 ReturnUrl: returnURL, 19 }) 20 case http.MethodPost: 21 handle := r.FormValue("handle") ··· 44 45 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 46 if err != nil { 47 http.Error(w, err.Error(), http.StatusInternalServerError) 48 return 49 }
··· 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") ··· 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 }
+2
appview/state/profile.go
··· 538 profile.Description = r.FormValue("description") 539 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 540 profile.Location = r.FormValue("location") 541 542 var links [5]string 543 for i := range 5 { ··· 652 Location: &profile.Location, 653 PinnedRepositories: pinnedRepoStrings, 654 Stats: vanityStats[:], 655 }}, 656 SwapRecord: cid, 657 })
··· 538 profile.Description = r.FormValue("description") 539 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 540 profile.Location = r.FormValue("location") 541 + profile.Pronouns = r.FormValue("pronouns") 542 543 var links [5]string 544 for i := range 5 { ··· 653 Location: &profile.Location, 654 PinnedRepositories: pinnedRepoStrings, 655 Stats: vanityStats[:], 656 + Pronouns: &profile.Pronouns, 657 }}, 658 SwapRecord: cid, 659 })
+43 -36
appview/state/router.go
··· 42 43 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 44 pat := chi.URLParam(r, "*") 45 - if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 46 - userRouter.ServeHTTP(w, r) 47 - } else { 48 - // Check if the first path element is a valid handle without '@' or a flattened DID 49 - pathParts := strings.SplitN(pat, "/", 2) 50 - if len(pathParts) > 0 { 51 - if userutil.IsHandleNoAt(pathParts[0]) { 52 - // Redirect to the same path but with '@' prefixed to the handle 53 - redirectPath := "@" + pat 54 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 55 - return 56 - } else if userutil.IsFlattenedDid(pathParts[0]) { 57 - // Redirect to the unflattened DID version 58 - unflattenedDid := userutil.UnflattenDid(pathParts[0]) 59 - var redirectPath string 60 - if len(pathParts) > 1 { 61 - redirectPath = unflattenedDid + "/" + pathParts[1] 62 - } else { 63 - redirectPath = unflattenedDid 64 - } 65 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 66 - return 67 - } 68 } 69 - standardRouter.ServeHTTP(w, r) 70 } 71 }) 72 73 return router ··· 80 r.Get("/", s.Profile) 81 r.Get("/feed.atom", s.AtomFeedPage) 82 83 - // redirect /@handle/repo.git -> /@handle/repo 84 - r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 85 - nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 86 - http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 87 - }) 88 - 89 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 90 r.Use(mw.GoImport()) 91 r.Mount("/", s.RepoRouter(mw)) 92 r.Mount("/issues", s.IssuesRouter(mw)) 93 r.Mount("/pulls", s.PullsRouter(mw)) 94 - r.Mount("/pipelines", s.PipelinesRouter(mw)) 95 - r.Mount("/labels", s.LabelsRouter(mw)) 96 97 // These routes get proxied to the knot 98 r.Get("/info/refs", s.InfoRefs) ··· 262 s.config, 263 s.notifier, 264 s.validator, 265 log.SubLogger(s.logger, "issues"), 266 ) 267 return issues.Router(mw) ··· 278 s.notifier, 279 s.enforcer, 280 s.validator, 281 log.SubLogger(s.logger, "pulls"), 282 ) 283 return pulls.Router(mw) ··· 300 return repo.Router(mw) 301 } 302 303 - func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 304 pipes := pipelines.New( 305 s.oauth, 306 s.repoResolver, ··· 312 s.enforcer, 313 log.SubLogger(s.logger, "pipelines"), 314 ) 315 - return pipes.Router(mw) 316 } 317 318 - func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 319 ls := labels.New( 320 s.oauth, 321 s.pages, ··· 324 s.enforcer, 325 log.SubLogger(s.logger, "labels"), 326 ) 327 - return ls.Router(mw) 328 } 329 330 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
··· 42 43 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 44 pat := chi.URLParam(r, "*") 45 + pathParts := strings.SplitN(pat, "/", 2) 46 + 47 + if len(pathParts) > 0 { 48 + firstPart := pathParts[0] 49 + 50 + // if using a DID or handle, just continue as per usual 51 + if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) { 52 + userRouter.ServeHTTP(w, r) 53 + return 54 + } 55 + 56 + // if using a flattened DID (like you would in go modules), unflatten 57 + if userutil.IsFlattenedDid(firstPart) { 58 + unflattenedDid := userutil.UnflattenDid(firstPart) 59 + redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 60 + 61 + redirectURL := *r.URL 62 + redirectURL.Path = "/" + redirectPath 63 + 64 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 65 + return 66 + } 67 + 68 + // if using a handle with @, rewrite to work without @ 69 + if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 70 + redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 71 + 72 + redirectURL := *r.URL 73 + redirectURL.Path = "/" + redirectPath 74 + 75 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 76 + return 77 } 78 + 79 } 80 + 81 + standardRouter.ServeHTTP(w, r) 82 }) 83 84 return router ··· 91 r.Get("/", s.Profile) 92 r.Get("/feed.atom", s.AtomFeedPage) 93 94 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 95 r.Use(mw.GoImport()) 96 r.Mount("/", s.RepoRouter(mw)) 97 r.Mount("/issues", s.IssuesRouter(mw)) 98 r.Mount("/pulls", s.PullsRouter(mw)) 99 + r.Mount("/pipelines", s.PipelinesRouter()) 100 + r.Mount("/labels", s.LabelsRouter()) 101 102 // These routes get proxied to the knot 103 r.Get("/info/refs", s.InfoRefs) ··· 267 s.config, 268 s.notifier, 269 s.validator, 270 + s.indexer.Issues, 271 log.SubLogger(s.logger, "issues"), 272 ) 273 return issues.Router(mw) ··· 284 s.notifier, 285 s.enforcer, 286 s.validator, 287 + s.indexer.Pulls, 288 log.SubLogger(s.logger, "pulls"), 289 ) 290 return pulls.Router(mw) ··· 307 return repo.Router(mw) 308 } 309 310 + func (s *State) PipelinesRouter() http.Handler { 311 pipes := pipelines.New( 312 s.oauth, 313 s.repoResolver, ··· 319 s.enforcer, 320 log.SubLogger(s.logger, "pipelines"), 321 ) 322 + return pipes.Router() 323 } 324 325 + func (s *State) LabelsRouter() http.Handler { 326 ls := labels.New( 327 s.oauth, 328 s.pages, ··· 331 s.enforcer, 332 log.SubLogger(s.logger, "labels"), 333 ) 334 + return ls.Router() 335 } 336 337 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
+22 -12
appview/state/state.go
··· 14 "tangled.org/core/appview" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/notify" 19 dbnotify "tangled.org/core/appview/notify/db" ··· 43 type State struct { 44 db *db.DB 45 notifier notify.Notifier 46 oauth *oauth.OAuth 47 enforcer *rbac.Enforcer 48 pages *pages.Pages ··· 65 return nil, fmt.Errorf("failed to create db: %w", err) 66 } 67 68 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 69 if err != nil { 70 return nil, fmt.Errorf("failed to create enforcer: %w", err) 71 } 72 73 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 74 if err != nil { 75 logger.Error("failed to create redis resolver", "err", err) 76 - res = idresolver.DefaultResolver() 77 } 78 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 121 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 122 } 123 124 - if err := BackfillDefaultDefs(d, res); err != nil { 125 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 126 } 127 ··· 159 if !config.Core.Dev { 160 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 161 } 162 - notifier := notify.NewMergedNotifier(notifiers...) 163 164 state := &State{ 165 d, 166 notifier, 167 oauth, 168 enforcer, 169 pages, ··· 284 return 285 } 286 287 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 288 if err != nil { 289 // non-fatal 290 } ··· 376 377 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 378 if err != nil { 379 - w.WriteHeader(http.StatusNotFound) 380 return 381 } 382 383 if len(pubKeys) == 0 { 384 - w.WriteHeader(http.StatusNotFound) 385 return 386 } 387 ··· 506 Rkey: rkey, 507 Description: description, 508 Created: time.Now(), 509 - Labels: models.DefaultLabelDefs(), 510 } 511 record := repo.AsRecord() 512 ··· 622 aturi = "" 623 624 s.notifier.NewRepo(r.Context(), repo) 625 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 626 } 627 } 628 ··· 648 return err 649 } 650 651 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 652 - defaults := models.DefaultLabelDefs() 653 defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 654 if err != nil { 655 return err ··· 659 return nil 660 } 661 662 - labelDefs, err := models.FetchDefaultDefs(r) 663 if err != nil { 664 return err 665 }
··· 14 "tangled.org/core/appview" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/indexer" 18 "tangled.org/core/appview/models" 19 "tangled.org/core/appview/notify" 20 dbnotify "tangled.org/core/appview/notify/db" ··· 44 type State struct { 45 db *db.DB 46 notifier notify.Notifier 47 + indexer *indexer.Indexer 48 oauth *oauth.OAuth 49 enforcer *rbac.Enforcer 50 pages *pages.Pages ··· 67 return nil, fmt.Errorf("failed to create db: %w", err) 68 } 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 + 76 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 77 if err != nil { 78 return nil, fmt.Errorf("failed to create enforcer: %w", err) 79 } 80 81 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 82 if err != nil { 83 logger.Error("failed to create redis resolver", "err", err) 84 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 85 } 86 87 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 129 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 130 } 131 132 + if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 133 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 134 } 135 ··· 167 if !config.Core.Dev { 168 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 169 } 170 + notifiers = append(notifiers, indexer) 171 + notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 172 173 state := &State{ 174 d, 175 notifier, 176 + indexer, 177 oauth, 178 enforcer, 179 pages, ··· 294 return 295 } 296 297 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 298 if err != nil { 299 // non-fatal 300 } ··· 386 387 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 388 if err != nil { 389 + s.logger.Error("failed to get public keys", "err", err) 390 + http.Error(w, "failed to get public keys", http.StatusInternalServerError) 391 return 392 } 393 394 if len(pubKeys) == 0 { 395 + w.WriteHeader(http.StatusNoContent) 396 return 397 } 398 ··· 517 Rkey: rkey, 518 Description: description, 519 Created: time.Now(), 520 + Labels: s.config.Label.DefaultLabelDefs, 521 } 522 record := repo.AsRecord() 523 ··· 633 aturi = "" 634 635 s.notifier.NewRepo(r.Context(), repo) 636 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 637 } 638 } 639 ··· 659 return err 660 } 661 662 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 663 defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 664 if err != nil { 665 return err ··· 669 return nil 670 } 671 672 + labelDefs, err := models.FetchLabelDefs(r, defaults) 673 if err != nil { 674 return err 675 }
+6 -6
appview/state/userutil/userutil.go
··· 10 didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 ) 12 13 - func IsHandleNoAt(s string) bool { 14 // ref: https://atproto.com/specs/handle 15 return handleRegex.MatchString(s) 16 } 17 18 func UnflattenDid(s string) string { ··· 45 return strings.Replace(s, ":", "-", 2) 46 } 47 return s 48 - } 49 - 50 - // IsDid checks if the given string is a standard DID. 51 - func IsDid(s string) bool { 52 - return didRegex.MatchString(s) 53 } 54 55 var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
··· 10 didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 ) 12 13 + func IsHandle(s string) bool { 14 // ref: https://atproto.com/specs/handle 15 return handleRegex.MatchString(s) 16 + } 17 + 18 + // IsDid checks if the given string is a standard DID. 19 + func IsDid(s string) bool { 20 + return didRegex.MatchString(s) 21 } 22 23 func UnflattenDid(s string) string { ··· 50 return strings.Replace(s, ":", "-", 2) 51 } 52 return s 53 } 54 55 var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+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
···
··· 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 + }
-43
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.org/anirudh.fi/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 - }
···
+16 -6
docs/hacking.md
··· 37 38 ``` 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"} 42 43 # if not, you can set it up yourself: 44 - go build -o genjwks.out ./cmd/genjwks 45 - export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 46 47 # run redis in at a new shell to store oauth sessions 48 redis-server ··· 158 159 If for any reason you wish to disable either one of the 160 services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 - `services.tangled-spindle.enable` (or 162 - `services.tangled-knot.enable`) to `false`.
··· 37 38 ``` 39 # oauth jwks should already be setup by the nix devshell: 40 + echo $TANGLED_OAUTH_CLIENT_SECRET 41 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 + 43 + echo $TANGLED_OAUTH_CLIENT_KID 44 + 1761667908 45 46 # if not, you can set it up yourself: 47 + goat key generate -t P-256 48 + Key Type: P-256 / secp256r1 / ES256 private key 49 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 + 54 + # the secret key from above 55 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 57 # run redis in at a new shell to store oauth sessions 58 redis-server ··· 168 169 If for any reason you wish to disable either one of the 170 services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 + `services.tangled.spindle.enable` (or 172 + `services.tangled.knot.enable`) to `false`.
+1 -1
docs/migrations.md
··· 49 latest revision, and change your config block like so: 50 51 ```diff 52 - services.tangled-knot = { 53 enable = true; 54 server = { 55 - secretFile = /path/to/secret;
··· 49 latest revision, and change your config block like so: 50 51 ```diff 52 + services.tangled.knot = { 53 enable = true; 54 server = { 55 - secretFile = /path/to/secret;
+19 -1
docs/spindle/pipeline.md
··· 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 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. 23 24 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 ··· 29 branch: ["main", "develop"] 30 - event: ["pull_request"] 31 branch: ["main"] 32 ``` 33 34 ## Engine
··· 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - `manual`: The workflow can be triggered manually. 22 + - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 + - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 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: 26 ··· 30 branch: ["main", "develop"] 31 - event: ["pull_request"] 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"] 50 ``` 51 52 ## Engine
+17
flake.lock
··· 1 { 2 "nodes": { 3 "flake-compat": { 4 "flake": false, 5 "locked": { ··· 150 }, 151 "root": { 152 "inputs": { 153 "flake-compat": "flake-compat", 154 "gomod2nix": "gomod2nix", 155 "htmx-src": "htmx-src",
··· 1 { 2 "nodes": { 3 + "actor-typeahead-src": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1762835797, 7 + "narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=", 8 + "ref": "refs/heads/main", 9 + "rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b", 10 + "revCount": 6, 11 + "type": "git", 12 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 13 + }, 14 + "original": { 15 + "type": "git", 16 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 17 + } 18 + }, 19 "flake-compat": { 20 "flake": false, 21 "locked": { ··· 166 }, 167 "root": { 168 "inputs": { 169 + "actor-typeahead-src": "actor-typeahead-src", 170 "flake-compat": "flake-compat", 171 "gomod2nix": "gomod2nix", 172 "htmx-src": "htmx-src",
+18 -8
flake.nix
··· 33 url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"; 34 flake = false; 35 }; 36 ibm-plex-mono-src = { 37 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 38 flake = false; ··· 54 inter-fonts-src, 55 sqlite-lib-src, 56 ibm-plex-mono-src, 57 ... 58 }: let 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 78 inherit (pkgs) gcc; 79 inherit sqlite-lib-src; 80 }; 81 - genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 82 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 83 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; 85 }; 86 appview = self.callPackage ./nix/pkgs/appview.nix {}; 87 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; ··· 90 }); 91 in { 92 overlays.default = final: prev: { 93 - inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 94 }; 95 96 packages = forAllSystems (system: let ··· 99 staticPackages = mkPackageSet pkgs.pkgsStatic; 100 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 101 in { 102 - inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 103 104 pkgsStatic-appview = staticPackages.appview; 105 pkgsStatic-knot = staticPackages.knot; ··· 167 mkdir -p appview/pages/static 168 # no preserve is needed because watch-tailwind will want to be able to overwrite 169 cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 170 - export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 171 ''; 172 env.CGO_ENABLED = 1; 173 }; ··· 206 watch-knot = { 207 type = "app"; 208 program = ''${air-watcher "knot" "server"}/bin/run''; 209 }; 210 watch-tailwind = { 211 type = "app"; ··· 278 }: { 279 imports = [./nix/modules/appview.nix]; 280 281 - services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 282 }; 283 nixosModules.knot = { 284 lib, ··· 287 }: { 288 imports = [./nix/modules/knot.nix]; 289 290 - services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 291 }; 292 nixosModules.spindle = { 293 lib, ··· 296 }: { 297 imports = [./nix/modules/spindle.nix]; 298 299 - services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 300 }; 301 }; 302 }
··· 33 url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"; 34 flake = false; 35 }; 36 + actor-typeahead-src = { 37 + url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 38 + flake = false; 39 + }; 40 ibm-plex-mono-src = { 41 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 42 flake = false; ··· 58 inter-fonts-src, 59 sqlite-lib-src, 60 ibm-plex-mono-src, 61 + actor-typeahead-src, 62 ... 63 }: let 64 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 83 inherit (pkgs) gcc; 84 inherit sqlite-lib-src; 85 }; 86 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 87 + goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 88 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 89 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 90 }; 91 appview = self.callPackage ./nix/pkgs/appview.nix {}; 92 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; ··· 95 }); 96 in { 97 overlays.default = final: prev: { 98 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 99 }; 100 101 packages = forAllSystems (system: let ··· 104 staticPackages = mkPackageSet pkgs.pkgsStatic; 105 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 106 in { 107 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 108 109 pkgsStatic-appview = staticPackages.appview; 110 pkgsStatic-knot = staticPackages.knot; ··· 172 mkdir -p appview/pages/static 173 # no preserve is needed because watch-tailwind will want to be able to overwrite 174 cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 175 + export TANGLED_OAUTH_CLIENT_KID="$(date +%s)" 176 + export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')" 177 ''; 178 env.CGO_ENABLED = 1; 179 }; ··· 212 watch-knot = { 213 type = "app"; 214 program = ''${air-watcher "knot" "server"}/bin/run''; 215 + }; 216 + watch-spindle = { 217 + type = "app"; 218 + program = ''${air-watcher "spindle" ""}/bin/run''; 219 }; 220 watch-tailwind = { 221 type = "app"; ··· 288 }: { 289 imports = [./nix/modules/appview.nix]; 290 291 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 292 }; 293 nixosModules.knot = { 294 lib, ··· 297 }: { 298 imports = [./nix/modules/knot.nix]; 299 300 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 301 }; 302 nixosModules.spindle = { 303 lib, ··· 306 }: { 307 imports = [./nix/modules/spindle.nix]; 308 309 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 310 }; 311 }; 312 }
+32 -13
go.mod
··· 7 github.com/alecthomas/assert/v2 v2.11.0 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 15 github.com/cloudflare/cloudflare-go v0.115.0 16 github.com/cyphar/filepath-securejoin v0.4.1 17 github.com/dgraph-io/ristretto v0.2.0 ··· 29 github.com/hiddeco/sshsig v0.2.0 30 github.com/hpcloud/tail v1.0.0 31 github.com/ipfs/go-cid v0.5.0 32 - github.com/lestrrat-go/jwx/v2 v2.1.6 33 github.com/mattn/go-sqlite3 v1.14.24 34 github.com/microcosm-cc/bluemonday v1.0.27 35 github.com/openbao/openbao/api/v2 v2.3.0 ··· 42 github.com/stretchr/testify v1.10.0 43 github.com/urfave/cli/v3 v3.3.3 44 github.com/whyrusleeping/cbor-gen v0.3.1 45 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 46 github.com/yuin/goldmark v1.7.13 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 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 ··· 58 dario.cat/mergo v1.0.1 // indirect 59 github.com/Microsoft/go-winio v0.6.2 // indirect 60 github.com/ProtonMail/go-crypto v1.3.0 // indirect 61 github.com/alecthomas/repr v0.4.0 // indirect 62 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 63 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 64 github.com/aymerick/douceur v0.2.0 // indirect 65 github.com/beorn7/perks v1.0.1 // indirect 66 - github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 67 github.com/casbin/govaluate v1.3.0 // indirect 68 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 69 github.com/cespare/xxhash/v2 v2.3.0 // indirect 70 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 71 github.com/charmbracelet/lipgloss v1.1.0 // indirect 72 - github.com/charmbracelet/log v0.4.2 // indirect 73 github.com/charmbracelet/x/ansi v0.8.0 // indirect 74 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 75 github.com/charmbracelet/x/term v0.2.1 // indirect ··· 78 github.com/containerd/errdefs/pkg v0.3.0 // indirect 79 github.com/containerd/log v0.1.0 // indirect 80 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 81 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 82 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 83 github.com/distribution/reference v0.6.0 // indirect 84 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 101 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 102 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 103 github.com/golang/mock v1.6.0 // indirect 104 github.com/google/go-querystring v1.1.0 // indirect 105 github.com/gorilla/css v1.0.1 // indirect 106 github.com/gorilla/securecookie v1.1.2 // indirect ··· 126 github.com/ipfs/go-log v1.0.5 // indirect 127 github.com/ipfs/go-log/v2 v2.6.0 // indirect 128 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 129 github.com/kevinburke/ssh_config v1.2.0 // indirect 130 github.com/klauspost/compress v1.18.0 // indirect 131 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 132 - github.com/lestrrat-go/blackmagic v1.0.4 // indirect 133 - github.com/lestrrat-go/httpcc v1.0.1 // indirect 134 - github.com/lestrrat-go/httprc v1.0.6 // indirect 135 - github.com/lestrrat-go/iter v1.0.2 // indirect 136 - github.com/lestrrat-go/option v1.0.1 // indirect 137 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 138 github.com/mattn/go-isatty v0.0.20 // indirect 139 github.com/mattn/go-runewidth v0.0.16 // indirect ··· 142 github.com/moby/docker-image-spec v1.3.1 // indirect 143 github.com/moby/sys/atomicwriter v0.1.0 // indirect 144 github.com/moby/term v0.5.2 // indirect 145 github.com/morikuni/aec v1.0.0 // indirect 146 github.com/mr-tron/base58 v1.2.0 // indirect 147 github.com/muesli/termenv v0.16.0 // indirect 148 github.com/multiformats/go-base32 v0.1.0 // indirect 149 github.com/multiformats/go-base36 v0.2.0 // indirect ··· 165 github.com/prometheus/procfs v0.16.1 // indirect 166 github.com/rivo/uniseg v0.4.7 // indirect 167 github.com/ryanuber/go-glob v1.0.0 // indirect 168 - github.com/segmentio/asm v1.2.0 // indirect 169 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 170 github.com/spaolacci/murmur3 v1.1.0 // indirect 171 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 172 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 173 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 174 - github.com/wyatt915/treeblood v0.1.15 // indirect 175 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 176 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 177 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 178 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 179 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 180 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 181 go.opentelemetry.io/otel v1.37.0 // indirect
··· 7 github.com/alecthomas/assert/v2 v2.11.0 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 + github.com/blevesearch/bleve/v2 v2.5.3 11 github.com/bluekeyes/go-gitdiff v0.8.1 12 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 13 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 14 + github.com/bmatcuk/doublestar/v4 v4.9.1 15 github.com/carlmjohnson/versioninfo v0.22.5 16 github.com/casbin/casbin/v2 v2.103.0 17 + github.com/charmbracelet/log v0.4.2 18 github.com/cloudflare/cloudflare-go v0.115.0 19 github.com/cyphar/filepath-securejoin v0.4.1 20 github.com/dgraph-io/ristretto v0.2.0 ··· 32 github.com/hiddeco/sshsig v0.2.0 33 github.com/hpcloud/tail v1.0.0 34 github.com/ipfs/go-cid v0.5.0 35 github.com/mattn/go-sqlite3 v1.14.24 36 github.com/microcosm-cc/bluemonday v1.0.27 37 github.com/openbao/openbao/api/v2 v2.3.0 ··· 44 github.com/stretchr/testify v1.10.0 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 + github.com/wyatt915/goldmark-treeblood v0.0.1 48 github.com/yuin/goldmark v1.7.13 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 golang.org/x/crypto v0.40.0 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 golang.org/x/image v0.31.0 ··· 61 dario.cat/mergo v1.0.1 // indirect 62 github.com/Microsoft/go-winio v0.6.2 // indirect 63 github.com/ProtonMail/go-crypto v1.3.0 // indirect 64 + github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 65 github.com/alecthomas/repr v0.4.0 // indirect 66 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 67 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 68 github.com/aymerick/douceur v0.2.0 // indirect 69 github.com/beorn7/perks v1.0.1 // indirect 70 + github.com/bits-and-blooms/bitset v1.22.0 // indirect 71 + github.com/blevesearch/bleve_index_api v1.2.8 // indirect 72 + github.com/blevesearch/geo v0.2.4 // indirect 73 + github.com/blevesearch/go-faiss v1.0.25 // indirect 74 + github.com/blevesearch/go-porterstemmer v1.0.3 // indirect 75 + github.com/blevesearch/gtreap v0.1.1 // indirect 76 + github.com/blevesearch/mmap-go v1.0.4 // indirect 77 + github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect 78 + github.com/blevesearch/segment v0.9.1 // indirect 79 + github.com/blevesearch/snowballstem v0.9.0 // indirect 80 + github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect 81 + github.com/blevesearch/vellum v1.1.0 // indirect 82 + github.com/blevesearch/zapx/v11 v11.4.2 // indirect 83 + github.com/blevesearch/zapx/v12 v12.4.2 // indirect 84 + github.com/blevesearch/zapx/v13 v13.4.2 // indirect 85 + github.com/blevesearch/zapx/v14 v14.4.2 // indirect 86 + github.com/blevesearch/zapx/v15 v15.4.2 // indirect 87 + github.com/blevesearch/zapx/v16 v16.2.4 // indirect 88 github.com/casbin/govaluate v1.3.0 // indirect 89 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 90 github.com/cespare/xxhash/v2 v2.3.0 // indirect 91 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 92 github.com/charmbracelet/lipgloss v1.1.0 // indirect 93 github.com/charmbracelet/x/ansi v0.8.0 // indirect 94 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 95 github.com/charmbracelet/x/term v0.2.1 // indirect ··· 98 github.com/containerd/errdefs/pkg v0.3.0 // indirect 99 github.com/containerd/log v0.1.0 // indirect 100 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 101 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 102 github.com/distribution/reference v0.6.0 // indirect 103 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 120 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 121 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 122 github.com/golang/mock v1.6.0 // indirect 123 + github.com/golang/protobuf v1.5.4 // indirect 124 + github.com/golang/snappy v0.0.4 // indirect 125 github.com/google/go-querystring v1.1.0 // indirect 126 github.com/gorilla/css v1.0.1 // indirect 127 github.com/gorilla/securecookie v1.1.2 // indirect ··· 147 github.com/ipfs/go-log v1.0.5 // indirect 148 github.com/ipfs/go-log/v2 v2.6.0 // indirect 149 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 150 + github.com/json-iterator/go v1.1.12 // indirect 151 github.com/kevinburke/ssh_config v1.2.0 // indirect 152 github.com/klauspost/compress v1.18.0 // indirect 153 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 154 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 155 github.com/mattn/go-isatty v0.0.20 // indirect 156 github.com/mattn/go-runewidth v0.0.16 // indirect ··· 159 github.com/moby/docker-image-spec v1.3.1 // indirect 160 github.com/moby/sys/atomicwriter v0.1.0 // indirect 161 github.com/moby/term v0.5.2 // indirect 162 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 163 + github.com/modern-go/reflect2 v1.0.2 // indirect 164 github.com/morikuni/aec v1.0.0 // indirect 165 github.com/mr-tron/base58 v1.2.0 // indirect 166 + github.com/mschoch/smat v0.2.0 // indirect 167 github.com/muesli/termenv v0.16.0 // indirect 168 github.com/multiformats/go-base32 v0.1.0 // indirect 169 github.com/multiformats/go-base36 v0.2.0 // indirect ··· 185 github.com/prometheus/procfs v0.16.1 // indirect 186 github.com/rivo/uniseg v0.4.7 // indirect 187 github.com/ryanuber/go-glob v1.0.0 // indirect 188 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 189 github.com/spaolacci/murmur3 v1.1.0 // indirect 190 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 191 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 192 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 193 + github.com/wyatt915/treeblood v0.1.16 // indirect 194 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 195 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 196 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 197 + go.etcd.io/bbolt v1.4.0 // indirect 198 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 199 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 200 go.opentelemetry.io/otel v1.37.0 // indirect
+64 -21
go.sum
··· 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 25 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 26 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 27 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 28 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 33 - github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= 34 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 35 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 36 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 37 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 83 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 84 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 85 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 86 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 87 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 88 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 89 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 90 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 170 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 171 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 172 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 173 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 174 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 175 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 181 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 182 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 183 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 184 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 185 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 186 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 261 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 262 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 263 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 264 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 265 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 266 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 280 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 281 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 282 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 283 - github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 284 - github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 285 - github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 286 - github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 287 - github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 288 - github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 289 - github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 290 - github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 291 - github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 292 - github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 293 - github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 294 - github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 295 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 296 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 297 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 316 github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 317 github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 318 github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 319 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 320 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 321 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 322 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 323 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 324 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 325 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= ··· 409 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 410 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 411 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 412 - github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 413 - github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 414 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 415 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 416 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 455 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 456 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 457 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 458 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew= 459 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 460 - github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 461 - github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 462 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 463 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 464 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 477 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 478 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 479 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 480 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 481 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 482 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= ··· 680 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 681 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 682 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 683 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 684 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 685 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
··· 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 + github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 13 + github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 14 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 15 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 16 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 27 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 28 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 29 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 30 + github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 31 + github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= 32 + github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 33 + github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= 34 + github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= 35 + github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y= 36 + github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= 37 + github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= 38 + github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= 39 + github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= 40 + github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= 41 + github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= 42 + github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= 43 + github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= 44 + github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= 45 + github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= 46 + github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= 47 + github.com/blevesearch/scorch_segment_api/v2 v2.3.10 h1:Yqk0XD1mE0fDZAJXTjawJ8If/85JxnLd8v5vG/jWE/s= 48 + github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8= 49 + github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= 50 + github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= 51 + github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= 52 + github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= 53 + github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= 54 + github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= 55 + github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= 56 + github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= 57 + github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= 58 + github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= 59 + github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= 60 + github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= 61 + github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= 62 + github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= 63 + github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= 64 + github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= 65 + github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= 66 + github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= 67 + github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= 68 + github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= 69 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 70 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 71 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 72 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 73 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 74 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 75 + github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 76 + github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 77 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 78 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 79 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 125 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 126 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 127 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 128 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 129 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 130 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 210 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 211 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 212 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 213 + github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 214 + github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 215 + github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 216 + github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 217 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 218 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 219 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 225 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 226 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 227 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 228 + github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 229 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 230 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 231 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 306 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 307 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 308 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 309 + github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 310 + github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 311 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 312 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 313 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 327 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 328 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 329 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 330 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 331 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 332 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 351 github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 352 github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 353 github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 354 + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 355 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 356 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 357 + github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 358 + github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 359 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 360 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 361 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 362 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 363 + github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= 364 + github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= 365 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 366 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 367 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= ··· 451 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 452 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 453 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 454 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 455 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 456 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 495 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 496 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 497 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 498 + github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs= 499 + github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208= 500 + github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y= 501 + github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 502 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 503 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 504 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 517 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 518 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 519 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 520 + go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 521 + go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 522 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 523 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 524 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= ··· 722 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 723 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 724 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 725 + gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 726 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 727 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 728 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+36 -61
guard/guard.go
··· 12 "os/exec" 13 "strings" 14 15 - "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 - "tangled.org/core/idresolver" 19 "tangled.org/core/log" 20 ) 21 ··· 93 "command", sshCommand, 94 "client", clientIP) 95 96 if sshCommand == "" { 97 l.Info("access denied: no interactive shells", "user", incomingUser) 98 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 107 } 108 109 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) 129 130 validCommands := map[string]bool{ 131 "git-receive-pack": true, ··· 138 return fmt.Errorf("access denied: invalid git command") 139 } 140 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 - } 149 } 150 151 - fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 152 153 l.Info("processing command", 154 "user", incomingUser, 155 "command", gitCommand, 156 - "repo", repoName, 157 "fullPath", fullPath, 158 "client", clientIP) 159 ··· 177 gitCmd.Stdin = os.Stdin 178 gitCmd.Env = append(os.Environ(), 179 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 180 - fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 181 ) 182 183 if err := gitCmd.Run(); err != nil { ··· 189 l.Info("command completed", 190 "user", incomingUser, 191 "command", gitCommand, 192 - "repo", repoName, 193 "success", true) 194 195 return nil 196 } 197 198 - func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 199 - resolver := idresolver.DefaultResolver() 200 - ident, err := resolver.ResolveIdent(ctx, didOrHandle) 201 if err != nil { 202 - l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 203 - fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 204 - os.Exit(1) 205 - } 206 - if ident.Handle.IsInvalidHandle() { 207 - l.Error("Error resolving handle", "invalid handle", didOrHandle) 208 - fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n") 209 - os.Exit(1) 210 } 211 - return ident 212 - } 213 214 - func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 215 - u, _ := url.Parse(endpoint + "/push-allowed") 216 - q := u.Query() 217 - q.Add("user", user) 218 - q.Add("repo", qualifiedRepoName) 219 - u.RawQuery = q.Encode() 220 221 - req, err := http.Get(u.String()) 222 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) 226 } 227 - 228 - l.Info("Checking push permission", 229 - "url", u.String(), 230 - "status", req.Status) 231 232 - return req.StatusCode == http.StatusNoContent 233 }
··· 12 "os/exec" 13 "strings" 14 15 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/urfave/cli/v3" 17 "tangled.org/core/log" 18 ) 19 ··· 91 "command", sshCommand, 92 "client", clientIP) 93 94 + // TODO: greet user with their resolved handle instead of did 95 if sshCommand == "" { 96 l.Info("access denied: no interactive shells", "user", incomingUser) 97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 106 } 107 108 gitCommand := cmdParts[0] 109 + repoPath := cmdParts[1] 110 111 validCommands := map[string]bool{ 112 "git-receive-pack": true, ··· 119 return fmt.Errorf("access denied: invalid git command") 120 } 121 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) 128 } 129 130 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 131 132 l.Info("processing command", 133 "user", incomingUser, 134 "command", gitCommand, 135 + "repo", repoPath, 136 "fullPath", fullPath, 137 "client", clientIP) 138 ··· 156 gitCmd.Stdin = os.Stdin 157 gitCmd.Env = append(os.Environ(), 158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 159 ) 160 161 if err := gitCmd.Run(); err != nil { ··· 167 l.Info("command completed", 168 "user", incomingUser, 169 "command", gitCommand, 170 + "repo", repoPath, 171 "success", true) 172 173 return nil 174 } 175 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()) 186 if err != nil { 187 + return "", err 188 } 189 + defer resp.Body.Close() 190 191 + l.Info("Running guard", "url", u.String(), "status", resp.Status) 192 193 + body, err := io.ReadAll(resp.Body) 194 if err != nil { 195 + return "", err 196 } 197 + text := string(body) 198 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 + } 208 }
+17 -8
idresolver/resolver.go
··· 17 directory identity.Directory 18 } 19 20 - func BaseDirectory() identity.Directory { 21 base := identity.BaseDirectory{ 22 - PLCURL: identity.DefaultPLCURL, 23 HTTPClient: http.Client{ 24 Timeout: time.Second * 10, 25 Transport: &http.Transport{ ··· 42 return &base 43 } 44 45 - func RedisDirectory(url string) (identity.Directory, error) { 46 hitTTL := time.Hour * 24 47 errTTL := time.Second * 30 48 invalidHandleTTL := time.Minute * 5 49 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 50 } 51 52 - func DefaultResolver() *Resolver { 53 return &Resolver{ 54 - directory: identity.DefaultDirectory(), 55 } 56 } 57 58 - func RedisResolver(redisUrl string) (*Resolver, error) { 59 - directory, err := RedisDirectory(redisUrl) 60 if err != nil { 61 return nil, err 62 }
··· 17 directory identity.Directory 18 } 19 20 + func BaseDirectory(plcUrl string) identity.Directory { 21 base := identity.BaseDirectory{ 22 + PLCURL: plcUrl, 23 HTTPClient: http.Client{ 24 Timeout: time.Second * 10, 25 Transport: &http.Transport{ ··· 42 return &base 43 } 44 45 + func RedisDirectory(url, plcUrl string) (identity.Directory, error) { 46 hitTTL := time.Hour * 24 47 errTTL := time.Second * 30 48 invalidHandleTTL := time.Minute * 5 49 + return redisdir.NewRedisDirectory( 50 + BaseDirectory(plcUrl), 51 + url, 52 + hitTTL, 53 + errTTL, 54 + invalidHandleTTL, 55 + 10000, 56 + ) 57 } 58 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) 62 return &Resolver{ 63 + directory: &cached, 64 } 65 } 66 67 + func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 + directory, err := RedisDirectory(redisUrl, plcUrl) 69 if err != nil { 70 return nil, err 71 }
+38
input.css
··· 161 @apply no-underline; 162 } 163 164 .prose li { 165 @apply my-0 py-0; 166 } ··· 241 details[data-callout] > summary::-webkit-details-marker { 242 display: none; 243 } 244 } 245 @layer utilities { 246 .error { ··· 924 text-decoration: underline; 925 } 926 }
··· 161 @apply no-underline; 162 } 163 164 + .prose a.mention { 165 + @apply no-underline hover:underline; 166 + } 167 + 168 .prose li { 169 @apply my-0 py-0; 170 } ··· 245 details[data-callout] > summary::-webkit-details-marker { 246 display: none; 247 } 248 + 249 } 250 @layer utilities { 251 .error { ··· 929 text-decoration: underline; 930 } 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
knotserver/config/config.go
··· 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 Hostname string `env:"HOSTNAME, required"` 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 Owner string `env:"OWNER, required"` 24 LogDids bool `env:"LOG_DIDS, default=true"`
··· 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 Hostname string `env:"HOSTNAME, required"` 22 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 24 Owner string `env:"OWNER, required"` 25 LogDids bool `env:"LOG_DIDS, default=true"`
+60 -2
knotserver/git/git.go
··· 3 import ( 4 "archive/tar" 5 "bytes" 6 "fmt" 7 "io" 8 "io/fs" ··· 12 "time" 13 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/plumbing" 16 "github.com/go-git/go-git/v5/plumbing/object" 17 ) 18 19 var ( 20 - ErrBinaryFile = fmt.Errorf("binary file") 21 - ErrNotBinaryFile = fmt.Errorf("not binary file") 22 ) 23 24 type GitRepo struct { ··· 188 defer reader.Close() 189 190 return io.ReadAll(reader) 191 } 192 193 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
··· 3 import ( 4 "archive/tar" 5 "bytes" 6 + "errors" 7 "fmt" 8 "io" 9 "io/fs" ··· 13 "time" 14 15 "github.com/go-git/go-git/v5" 16 + "github.com/go-git/go-git/v5/config" 17 "github.com/go-git/go-git/v5/plumbing" 18 "github.com/go-git/go-git/v5/plumbing/object" 19 ) 20 21 var ( 22 + ErrBinaryFile = errors.New("binary file") 23 + ErrNotBinaryFile = errors.New("not binary file") 24 + ErrMissingGitModules = errors.New("no .gitmodules file found") 25 + ErrInvalidGitModules = errors.New("invalid .gitmodules file") 26 + ErrNotSubmodule = errors.New("path is not a submodule") 27 ) 28 29 type GitRepo struct { ··· 193 defer reader.Close() 194 195 return io.ReadAll(reader) 196 + } 197 + 198 + // read and parse .gitmodules 199 + func (g *GitRepo) Submodules() (*config.Modules, error) { 200 + c, err := g.r.CommitObject(g.h) 201 + if err != nil { 202 + return nil, fmt.Errorf("commit object: %w", err) 203 + } 204 + 205 + tree, err := c.Tree() 206 + if err != nil { 207 + return nil, fmt.Errorf("tree: %w", err) 208 + } 209 + 210 + // read .gitmodules file 211 + modulesEntry, err := tree.FindEntry(".gitmodules") 212 + if err != nil { 213 + return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err) 214 + } 215 + 216 + modulesFile, err := tree.TreeEntryFile(modulesEntry) 217 + if err != nil { 218 + return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err) 219 + } 220 + 221 + content, err := modulesFile.Contents() 222 + if err != nil { 223 + return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err) 224 + } 225 + 226 + // parse .gitmodules 227 + modules := config.NewModules() 228 + if err = modules.Unmarshal([]byte(content)); err != nil { 229 + return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err) 230 + } 231 + 232 + return modules, nil 233 + } 234 + 235 + func (g *GitRepo) Submodule(path string) (*config.Submodule, error) { 236 + modules, err := g.Submodules() 237 + if err != nil { 238 + return nil, err 239 + } 240 + 241 + for _, submodule := range modules.Submodules { 242 + if submodule.Path == path { 243 + return submodule, nil 244 + } 245 + } 246 + 247 + // path is not a submodule 248 + return nil, ErrNotSubmodule 249 } 250 251 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4 -13
knotserver/git/tree.go
··· 7 "path" 8 "time" 9 10 "github.com/go-git/go-git/v5/plumbing/object" 11 "tangled.org/core/types" 12 ) ··· 53 } 54 55 for _, e := range subtree.Entries { 56 - mode, _ := e.Mode.ToOSFileMode() 57 sz, _ := subtree.Size(e.Name) 58 - 59 fpath := path.Join(parent, e.Name) 60 61 var lastCommit *types.LastCommitInfo ··· 69 70 nts = append(nts, types.NiceTree{ 71 Name: e.Name, 72 - Mode: mode.String(), 73 - IsFile: e.Mode.IsFile(), 74 Size: sz, 75 LastCommit: lastCommit, 76 }) ··· 126 default: 127 } 128 129 - mode, err := e.Mode.ToOSFileMode() 130 - if err != nil { 131 - // TODO: log this 132 - continue 133 - } 134 - 135 if e.Mode.IsFile() { 136 - err = cb(e, currentTree, root) 137 - if errors.Is(err, TerminateWalk) { 138 return err 139 } 140 } 141 142 // e is a directory 143 - if mode.IsDir() { 144 subtree, err := currentTree.Tree(e.Name) 145 if err != nil { 146 return fmt.Errorf("sub tree %s: %w", e.Name, err)
··· 7 "path" 8 "time" 9 10 + "github.com/go-git/go-git/v5/plumbing/filemode" 11 "github.com/go-git/go-git/v5/plumbing/object" 12 "tangled.org/core/types" 13 ) ··· 54 } 55 56 for _, e := range subtree.Entries { 57 sz, _ := subtree.Size(e.Name) 58 fpath := path.Join(parent, e.Name) 59 60 var lastCommit *types.LastCommitInfo ··· 68 69 nts = append(nts, types.NiceTree{ 70 Name: e.Name, 71 + Mode: e.Mode.String(), 72 Size: sz, 73 LastCommit: lastCommit, 74 }) ··· 124 default: 125 } 126 127 if e.Mode.IsFile() { 128 + if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) { 129 return err 130 } 131 } 132 133 // e is a directory 134 + if e.Mode == filemode.Dir { 135 subtree, err := currentTree.Tree(e.Name) 136 if err != nil { 137 return fmt.Errorf("sub tree %s: %w", e.Name, err)
+4 -8
knotserver/ingester.go
··· 16 "github.com/bluesky-social/jetstream/pkg/models" 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "tangled.org/core/api/tangled" 19 - "tangled.org/core/idresolver" 20 "tangled.org/core/knotserver/db" 21 "tangled.org/core/knotserver/git" 22 "tangled.org/core/log" ··· 120 } 121 122 // resolve this aturi to extract the repo record 123 - resolver := idresolver.DefaultResolver() 124 - ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 125 if err != nil || ident.Handle.IsInvalidHandle() { 126 return fmt.Errorf("failed to resolve handle: %w", err) 127 } ··· 163 164 var pipeline workflow.RawPipeline 165 for _, e := range workflowDir { 166 - if !e.IsFile { 167 continue 168 } 169 ··· 233 return err 234 } 235 236 - resolver := idresolver.DefaultResolver() 237 - 238 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 239 if err != nil || subjectId.Handle.IsInvalidHandle() { 240 return err 241 } 242 243 // TODO: fix this for good, we need to fetch the record here unfortunately 244 // resolve this aturi to extract the repo record 245 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 if err != nil || owner.Handle.IsInvalidHandle() { 247 return fmt.Errorf("failed to resolve handle: %w", err) 248 }
··· 16 "github.com/bluesky-social/jetstream/pkg/models" 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "tangled.org/core/api/tangled" 19 "tangled.org/core/knotserver/db" 20 "tangled.org/core/knotserver/git" 21 "tangled.org/core/log" ··· 119 } 120 121 // resolve this aturi to extract the repo record 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 if err != nil || ident.Handle.IsInvalidHandle() { 124 return fmt.Errorf("failed to resolve handle: %w", err) 125 } ··· 161 162 var pipeline workflow.RawPipeline 163 for _, e := range workflowDir { 164 + if !e.IsFile() { 165 continue 166 } 167 ··· 231 return err 232 } 233 234 + subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject) 235 if err != nil || subjectId.Handle.IsInvalidHandle() { 236 return err 237 } 238 239 // TODO: fix this for good, we need to fetch the record here unfortunately 240 // resolve this aturi to extract the repo record 241 + owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 242 if err != nil || owner.Handle.IsInvalidHandle() { 243 return fmt.Errorf("failed to resolve handle: %w", err) 244 }
+146 -49
knotserver/internal.go
··· 27 ) 28 29 type InternalHandle struct { 30 - db *db.DB 31 - c *config.Config 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 35 } 36 37 func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { ··· 67 writeJSON(w, data) 68 } 69 70 type PushOptions struct { 71 skipCi bool 72 verboseCi bool ··· 121 // non-fatal 122 } 123 124 - if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 125 - msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context()) 126 - if err != nil { 127 - l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 128 - // non-fatal 129 - } else { 130 - for msgLine := range msg { 131 - resp.Messages = append(resp.Messages, msg[msgLine]) 132 - } 133 - } 134 } 135 136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) ··· 143 writeJSON(w, resp) 144 } 145 146 - func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 - l := h.l.With("handler", "replyCompare") 148 - userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 - user := repoOwner 150 - if err != nil { 151 - l.Error("Failed to fetch user identity", "err", err) 152 - // non-fatal 153 - } else { 154 - user = userIdent.Handle.String() 155 - } 156 - gr, err := git.PlainOpen(gitRelativeDir) 157 - if err != nil { 158 - l.Error("Failed to open git repository", "err", err) 159 - return []string{}, err 160 - } 161 - defaultBranch, err := gr.FindMainBranch() 162 - if err != nil { 163 - l.Error("Failed to fetch default branch", "err", err) 164 - return []string{}, err 165 - } 166 - if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 167 - return []string{}, nil 168 - } 169 - ZWS := "\u200B" 170 - var msg []string 171 - msg = append(msg, ZWS) 172 - msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 173 - msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 174 - msg = append(msg, ZWS) 175 - return msg, nil 176 - } 177 - 178 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 180 if err != nil { ··· 220 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 221 } 222 223 - func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 224 if pushOptions.skipCi { 225 return nil 226 } ··· 247 248 var pipeline workflow.RawPipeline 249 for _, e := range workflowDir { 250 - if !e.IsFile { 251 continue 252 } 253 ··· 315 return h.db.InsertEvent(event, h.n) 316 } 317 318 func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 319 r := chi.NewRouter() 320 l := log.FromContext(ctx) 321 l = log.SubLogger(l, "internal") 322 323 h := InternalHandle{ 324 db, ··· 326 e, 327 l, 328 n, 329 } 330 331 r.Get("/push-allowed", h.PushAllowed) 332 r.Get("/keys", h.InternalKeys) 333 r.Post("/hooks/post-receive", h.PostReceiveHook) 334 r.Mount("/debug", middleware.Profiler()) 335
··· 27 ) 28 29 type InternalHandle struct { 30 + db *db.DB 31 + c *config.Config 32 + e *rbac.Enforcer 33 + l *slog.Logger 34 + n *notifier.Notifier 35 + res *idresolver.Resolver 36 } 37 38 func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { ··· 68 writeJSON(w, data) 69 } 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 + 131 type PushOptions struct { 132 skipCi bool 133 verboseCi bool ··· 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) 188 + // non-fatal 189 } 190 191 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) ··· 198 writeJSON(w, resp) 199 } 200 201 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 202 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 203 if err != nil { ··· 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 244 } 245 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 { 254 if pushOptions.skipCi { 255 return nil 256 } ··· 277 278 var pipeline workflow.RawPipeline 279 for _, e := range workflowDir { 280 + if !e.IsFile() { 281 continue 282 } 283 ··· 345 return h.db.InsertEvent(event, h.n) 346 } 347 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 { 413 r := chi.NewRouter() 414 l := log.FromContext(ctx) 415 l = log.SubLogger(l, "internal") 416 + res := idresolver.DefaultResolver(c.Server.PlcUrl) 417 418 h := InternalHandle{ 419 db, ··· 421 e, 422 l, 423 n, 424 + res, 425 } 426 427 r.Get("/push-allowed", h.PushAllowed) 428 r.Get("/keys", h.InternalKeys) 429 + r.Get("/guard", h.Guard) 430 r.Post("/hooks/post-receive", h.PostReceiveHook) 431 r.Mount("/debug", middleware.Profiler()) 432
+18
knotserver/middleware.go
··· 33 ) 34 }) 35 }
··· 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 + }
+2 -1
knotserver/router.go
··· 36 l: log.FromContext(ctx), 37 jc: jc, 38 n: n, 39 - resolver: idresolver.DefaultResolver(), 40 } 41 42 err := e.AddKnot(rbac.ThisServer) ··· 71 func (h *Knot) Router() http.Handler { 72 r := chi.NewRouter() 73 74 r.Use(h.RequestLogger) 75 76 r.Get("/", func(w http.ResponseWriter, r *http.Request) {
··· 36 l: log.FromContext(ctx), 37 jc: jc, 38 n: n, 39 + resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 40 } 41 42 err := e.AddKnot(rbac.ThisServer) ··· 71 func (h *Knot) Router() http.Handler { 72 r := chi.NewRouter() 73 74 + r.Use(h.CORS) 75 r.Use(h.RequestLogger) 76 77 r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+21 -2
knotserver/xrpc/repo_blob.go
··· 42 return 43 } 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) ··· 101 var encoding string 102 103 isBinary := !isTextual(mimeType) 104 105 if isBinary { 106 content = base64.StdEncoding.EncodeToString(contents) ··· 113 response := tangled.RepoBlob_Output{ 114 Ref: ref, 115 Path: treePath, 116 - Content: content, 117 Encoding: &encoding, 118 - Size: &[]int64{int64(len(contents))}[0], 119 IsBinary: &isBinary, 120 } 121
··· 42 return 43 } 44 45 + // first check if this path is a submodule 46 + submodule, err := gr.Submodule(treePath) 47 + if err != nil { 48 + // this is okay, continue and try to treat it as a regular file 49 + } else { 50 + response := tangled.RepoBlob_Output{ 51 + Ref: ref, 52 + Path: treePath, 53 + Submodule: &tangled.RepoBlob_Submodule{ 54 + Name: submodule.Name, 55 + Url: submodule.URL, 56 + Branch: &submodule.Branch, 57 + }, 58 + } 59 + writeJson(w, response) 60 + return 61 + } 62 + 63 contents, err := gr.RawContent(treePath) 64 if err != nil { 65 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) ··· 119 var encoding string 120 121 isBinary := !isTextual(mimeType) 122 + size := int64(len(contents)) 123 124 if isBinary { 125 content = base64.StdEncoding.EncodeToString(contents) ··· 132 response := tangled.RepoBlob_Output{ 133 Ref: ref, 134 Path: treePath, 135 + Content: &content, 136 Encoding: &encoding, 137 + Size: &size, 138 IsBinary: &isBinary, 139 } 140
+3 -5
knotserver/xrpc/repo_tree.go
··· 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 for i, file := range files { 69 entry := &tangled.RepoTree_TreeEntry{ 70 - Name: file.Name, 71 - Mode: file.Mode, 72 - Size: file.Size, 73 - Is_file: file.IsFile, 74 - Is_subtree: file.IsSubtree, 75 } 76 77 if file.LastCommit != nil {
··· 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 for i, file := range files { 69 entry := &tangled.RepoTree_TreeEntry{ 70 + Name: file.Name, 71 + Mode: file.Mode, 72 + Size: file.Size, 73 } 74 75 if file.LastCommit != nil {
+5
lexicons/actor/profile.json
··· 64 "type": "string", 65 "format": "at-uri" 66 } 67 } 68 } 69 }
··· 64 "type": "string", 65 "format": "at-uri" 66 } 67 + }, 68 + "pronouns": { 69 + "type": "string", 70 + "description": "Preferred gender pronouns.", 71 + "maxLength": 40 72 } 73 } 74 }
+49 -5
lexicons/repo/blob.json
··· 6 "type": "query", 7 "parameters": { 8 "type": "params", 9 - "required": ["repo", "ref", "path"], 10 "properties": { 11 "repo": { 12 "type": "string", ··· 31 "encoding": "application/json", 32 "schema": { 33 "type": "object", 34 - "required": ["ref", "path", "content"], 35 "properties": { 36 "ref": { 37 "type": "string", ··· 48 "encoding": { 49 "type": "string", 50 "description": "Content encoding", 51 - "enum": ["utf-8", "base64"] 52 }, 53 "size": { 54 "type": "integer", ··· 61 "mimeType": { 62 "type": "string", 63 "description": "MIME type of the file" 64 }, 65 "lastCommit": { 66 "type": "ref", ··· 90 }, 91 "lastCommit": { 92 "type": "object", 93 - "required": ["hash", "message", "when"], 94 "properties": { 95 "hash": { 96 "type": "string", ··· 117 }, 118 "signature": { 119 "type": "object", 120 - "required": ["name", "email", "when"], 121 "properties": { 122 "name": { 123 "type": "string", ··· 131 "type": "string", 132 "format": "datetime", 133 "description": "Author timestamp" 134 } 135 } 136 }
··· 6 "type": "query", 7 "parameters": { 8 "type": "params", 9 + "required": [ 10 + "repo", 11 + "ref", 12 + "path" 13 + ], 14 "properties": { 15 "repo": { 16 "type": "string", ··· 35 "encoding": "application/json", 36 "schema": { 37 "type": "object", 38 + "required": [ 39 + "ref", 40 + "path" 41 + ], 42 "properties": { 43 "ref": { 44 "type": "string", ··· 55 "encoding": { 56 "type": "string", 57 "description": "Content encoding", 58 + "enum": [ 59 + "utf-8", 60 + "base64" 61 + ] 62 }, 63 "size": { 64 "type": "integer", ··· 71 "mimeType": { 72 "type": "string", 73 "description": "MIME type of the file" 74 + }, 75 + "submodule": { 76 + "type": "ref", 77 + "ref": "#submodule", 78 + "description": "Submodule information if path is a submodule" 79 }, 80 "lastCommit": { 81 "type": "ref", ··· 105 }, 106 "lastCommit": { 107 "type": "object", 108 + "required": [ 109 + "hash", 110 + "message", 111 + "when" 112 + ], 113 "properties": { 114 "hash": { 115 "type": "string", ··· 136 }, 137 "signature": { 138 "type": "object", 139 + "required": [ 140 + "name", 141 + "email", 142 + "when" 143 + ], 144 "properties": { 145 "name": { 146 "type": "string", ··· 154 "type": "string", 155 "format": "datetime", 156 "description": "Author timestamp" 157 + } 158 + } 159 + }, 160 + "submodule": { 161 + "type": "object", 162 + "required": [ 163 + "name", 164 + "url" 165 + ], 166 + "properties": { 167 + "name": { 168 + "type": "string", 169 + "description": "Submodule name" 170 + }, 171 + "url": { 172 + "type": "string", 173 + "description": "Submodule repository URL" 174 + }, 175 + "branch": { 176 + "type": "string", 177 + "description": "Branch to track in the submodule" 178 } 179 } 180 }
+15
lexicons/repo/repo.json
··· 32 "minGraphemes": 1, 33 "maxGraphemes": 140 34 }, 35 "source": { 36 "type": "string", 37 "format": "uri",
··· 32 "minGraphemes": 1, 33 "maxGraphemes": 140 34 }, 35 + "website": { 36 + "type": "string", 37 + "format": "uri", 38 + "description": "Any URI related to the repo" 39 + }, 40 + "topics": { 41 + "type": "array", 42 + "description": "Topics related to the repo", 43 + "items": { 44 + "type": "string", 45 + "minLength": 1, 46 + "maxLength": 50 47 + }, 48 + "maxLength": 50 49 + }, 50 "source": { 51 "type": "string", 52 "format": "uri",
+1 -9
lexicons/repo/tree.json
··· 91 }, 92 "treeEntry": { 93 "type": "object", 94 - "required": ["name", "mode", "size", "is_file", "is_subtree"], 95 "properties": { 96 "name": { 97 "type": "string", ··· 104 "size": { 105 "type": "integer", 106 "description": "File size in bytes" 107 - }, 108 - "is_file": { 109 - "type": "boolean", 110 - "description": "Whether this entry is a file" 111 - }, 112 - "is_subtree": { 113 - "type": "boolean", 114 - "description": "Whether this entry is a directory/subtree" 115 }, 116 "last_commit": { 117 "type": "ref",
··· 91 }, 92 "treeEntry": { 93 "type": "object", 94 + "required": ["name", "mode", "size"], 95 "properties": { 96 "name": { 97 "type": "string", ··· 104 "size": { 105 "type": "integer", 106 "description": "File size in bytes" 107 }, 108 "last_commit": { 109 "type": "ref",
+87 -6
nix/gomod2nix.toml
··· 13 [mod."github.com/ProtonMail/go-crypto"] 14 version = "v1.3.0" 15 hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 [mod."github.com/alecthomas/assert/v2"] 17 version = "v2.11.0" 18 hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" ··· 38 [mod."github.com/beorn7/perks"] 39 version = "v1.0.1" 40 hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" 41 [mod."github.com/bluekeyes/go-gitdiff"] 42 version = "v0.8.2" 43 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" ··· 49 version = "v0.0.0-20241210005130-ea96859b93d1" 50 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 51 [mod."github.com/bmatcuk/doublestar/v4"] 52 - version = "v4.7.1" 53 - hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 54 [mod."github.com/carlmjohnson/versioninfo"] 55 version = "v0.22.5" 56 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" ··· 199 [mod."github.com/golang/mock"] 200 version = "v1.6.0" 201 hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 202 [mod."github.com/google/go-querystring"] 203 version = "v1.1.0" 204 hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" ··· 295 [mod."github.com/ipfs/go-metrics-interface"] 296 version = "v0.3.0" 297 hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 298 [mod."github.com/kevinburke/ssh_config"] 299 version = "v1.2.0" 300 hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" ··· 352 [mod."github.com/moby/term"] 353 version = "v0.5.2" 354 hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" 355 [mod."github.com/morikuni/aec"] 356 version = "v1.0.0" 357 hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 358 [mod."github.com/mr-tron/base58"] 359 version = "v1.2.0" 360 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 361 [mod."github.com/muesli/termenv"] 362 version = "v0.16.0" 363 hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" ··· 471 version = "v0.3.1" 472 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 473 [mod."github.com/wyatt915/goldmark-treeblood"] 474 - version = "v0.0.0-20250825231212-5dcbdb2f4b57" 475 - hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 476 [mod."github.com/wyatt915/treeblood"] 477 - version = "v0.1.15" 478 - hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 479 [mod."github.com/xo/terminfo"] 480 version = "v0.0.0-20220910002029-abceb7e1c41e" 481 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" ··· 494 [mod."gitlab.com/yawning/tuplehash"] 495 version = "v0.0.0-20230713102510-df83abbf9a02" 496 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 497 [mod."go.opentelemetry.io/auto/sdk"] 498 version = "v1.1.0" 499 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
··· 13 [mod."github.com/ProtonMail/go-crypto"] 14 version = "v1.3.0" 15 hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/RoaringBitmap/roaring/v2"] 17 + version = "v2.4.5" 18 + hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA=" 19 [mod."github.com/alecthomas/assert/v2"] 20 version = "v2.11.0" 21 hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" ··· 41 [mod."github.com/beorn7/perks"] 42 version = "v1.0.1" 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=" 101 [mod."github.com/bluekeyes/go-gitdiff"] 102 version = "v0.8.2" 103 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" ··· 109 version = "v0.0.0-20241210005130-ea96859b93d1" 110 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 111 [mod."github.com/bmatcuk/doublestar/v4"] 112 + version = "v4.9.1" 113 + hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" 114 [mod."github.com/carlmjohnson/versioninfo"] 115 version = "v0.22.5" 116 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" ··· 259 [mod."github.com/golang/mock"] 260 version = "v1.6.0" 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=" 268 [mod."github.com/google/go-querystring"] 269 version = "v1.1.0" 270 hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" ··· 361 [mod."github.com/ipfs/go-metrics-interface"] 362 version = "v0.3.0" 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=" 367 [mod."github.com/kevinburke/ssh_config"] 368 version = "v1.2.0" 369 hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" ··· 421 [mod."github.com/moby/term"] 422 version = "v0.5.2" 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=" 430 [mod."github.com/morikuni/aec"] 431 version = "v1.0.0" 432 hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 433 [mod."github.com/mr-tron/base58"] 434 version = "v1.2.0" 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=" ··· 549 version = "v0.3.1" 550 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 551 [mod."github.com/wyatt915/goldmark-treeblood"] 552 + version = "v0.0.1" 553 + hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g=" 554 [mod."github.com/wyatt915/treeblood"] 555 + version = "v0.1.16" 556 + hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw=" 557 [mod."github.com/xo/terminfo"] 558 version = "v0.0.0-20220910002029-abceb7e1c41e" 559 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" ··· 572 [mod."gitlab.com/yawning/tuplehash"] 573 version = "v0.0.0-20230713102510-df83abbf9a02" 574 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 575 + [mod."go.etcd.io/bbolt"] 576 + version = "v1.4.0" 577 + hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps=" 578 [mod."go.opentelemetry.io/auto/sdk"] 579 version = "v1.1.0" 580 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
+285 -18
nix/modules/appview.nix
··· 3 lib, 4 ... 5 }: let 6 - cfg = config.services.tangled-appview; 7 in 8 with lib; { 9 options = { 10 - services.tangled-appview = { 11 enable = mkOption { 12 type = types.bool; 13 default = false; 14 description = "Enable tangled appview"; 15 }; 16 package = mkOption { 17 type = types.package; 18 description = "Package to use for the appview"; 19 }; 20 port = mkOption { 21 - type = types.int; 22 default = 3000; 23 description = "Port to run the appview on"; 24 }; 25 - cookie_secret = mkOption { 26 type = types.str; 27 - default = "00000000000000000000000000000000"; 28 - description = "Cookie secret"; 29 }; 30 environmentFile = mkOption { 31 type = with types; nullOr path; 32 default = null; 33 - example = "/etc/tangled-appview.env"; 34 description = '' 35 Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 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 - 41 ''; 42 }; 43 }; 44 }; 45 46 config = mkIf cfg.enable { 47 - systemd.services.tangled-appview = { 48 description = "tangled appview service"; 49 wantedBy = ["multi-user.target"]; 50 51 serviceConfig = { 52 - ListenStream = "0.0.0.0:${toString cfg.port}"; 53 ExecStart = "${cfg.package}/bin/appview"; 54 Restart = "always"; 55 - EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 56 }; 57 58 - environment = { 59 - TANGLED_DB_PATH = "appview.db"; 60 - TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 - }; 62 }; 63 }; 64 }
··· 3 lib, 4 ... 5 }: let 6 + cfg = config.services.tangled.appview; 7 in 8 with lib; { 9 options = { 10 + services.tangled.appview = { 11 enable = mkOption { 12 type = types.bool; 13 default = false; 14 description = "Enable tangled appview"; 15 }; 16 + 17 package = mkOption { 18 type = types.package; 19 description = "Package to use for the appview"; 20 }; 21 + 22 + # core configuration 23 port = mkOption { 24 + type = types.port; 25 default = 3000; 26 description = "Port to run the appview on"; 27 }; 28 + 29 + listenAddr = mkOption { 30 + type = types.str; 31 + default = "0.0.0.0:${toString cfg.port}"; 32 + description = "Listen address for the appview service"; 33 + }; 34 + 35 + dbPath = mkOption { 36 type = types.str; 37 + default = "/var/lib/appview/appview.db"; 38 + description = "Path to the SQLite database file"; 39 + }; 40 + 41 + appviewHost = mkOption { 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 + }; 181 }; 182 + 183 + # avatar configuration 184 + avatar = { 185 + host = mkOption { 186 + type = types.str; 187 + default = "https://avatar.tangled.sh"; 188 + description = "Avatar service host URL"; 189 + }; 190 + }; 191 + 192 + plc = { 193 + url = mkOption { 194 + type = types.str; 195 + default = "https://plc.directory"; 196 + description = "PLC directory URL"; 197 + }; 198 + }; 199 + 200 + pds = { 201 + host = mkOption { 202 + type = types.str; 203 + default = "https://tngl.sh"; 204 + description = "PDS host URL"; 205 + }; 206 + }; 207 + 208 + label = { 209 + defaults = mkOption { 210 + type = types.listOf types.str; 211 + default = [ 212 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix" 213 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" 214 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate" 215 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation" 216 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee" 217 + ]; 218 + description = "Default label definitions"; 219 + }; 220 + 221 + goodFirstIssue = mkOption { 222 + type = types.str; 223 + default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"; 224 + description = "Good first issue label definition"; 225 + }; 226 + }; 227 + 228 environmentFile = mkOption { 229 type = with types; nullOr path; 230 default = null; 231 + example = "/etc/appview.env"; 232 description = '' 233 Additional environment file as defined in {manpage}`systemd.exec(5)`. 234 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. 245 ''; 246 }; 247 }; 248 }; 249 250 config = mkIf cfg.enable { 251 + services.redis.servers.appview = { 252 + enable = true; 253 + port = 6379; 254 + }; 255 + 256 + systemd.services.appview = { 257 description = "tangled appview service"; 258 wantedBy = ["multi-user.target"]; 259 + after = ["redis-appview.service" "network-online.target"]; 260 + requires = ["redis-appview.service"]; 261 + wants = ["network-online.target"]; 262 263 serviceConfig = { 264 + Type = "simple"; 265 ExecStart = "${cfg.package}/bin/appview"; 266 Restart = "always"; 267 + RestartSec = "10s"; 268 + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; 269 + 270 + # state directory 271 + StateDirectory = "appview"; 272 + WorkingDirectory = "/var/lib/appview"; 273 + 274 + # security hardening 275 + NoNewPrivileges = true; 276 + PrivateTmp = true; 277 + ProtectSystem = "strict"; 278 + ProtectHome = true; 279 + ReadWritePaths = ["/var/lib/appview"]; 280 }; 281 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 + }; 329 }; 330 }; 331 }
+74 -4
nix/modules/knot.nix
··· 4 lib, 5 ... 6 }: let 7 - cfg = config.services.tangled-knot; 8 in 9 with lib; { 10 options = { 11 - services.tangled-knot = { 12 enable = mkOption { 13 type = types.bool; 14 default = false; ··· 51 description = "Path where repositories are scanned from"; 52 }; 53 54 mainBranch = mkOption { 55 type = types.str; 56 default = "main"; 57 description = "Default branch name for repositories"; 58 }; 59 }; 60 ··· 111 description = "Hostname for the server (required)"; 112 }; 113 114 dev = mkOption { 115 type = types.bool; 116 default = false; ··· 178 mkdir -p "${cfg.stateDir}/.config/git" 179 cat > "${cfg.stateDir}/.config/git/config" << EOF 180 [user] 181 - name = Git User 182 - email = git@example.com 183 [receive] 184 advertisePushOptions = true 185 EOF 186 ${setMotd} 187 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" ··· 193 WorkingDirectory = cfg.stateDir; 194 Environment = [ 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 196 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 197 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 198 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 "KNOT_SERVER_OWNER=${cfg.server.owner}" 203 ]; 204 ExecStart = "${cfg.package}/bin/knot server"; 205 Restart = "always";
··· 4 lib, 5 ... 6 }: let 7 + cfg = config.services.tangled.knot; 8 in 9 with lib; { 10 options = { 11 + services.tangled.knot = { 12 enable = mkOption { 13 type = types.bool; 14 default = false; ··· 51 description = "Path where repositories are scanned from"; 52 }; 53 54 + readme = mkOption { 55 + type = types.listOf types.str; 56 + default = [ 57 + "README.md" 58 + "readme.md" 59 + "README" 60 + "readme" 61 + "README.markdown" 62 + "readme.markdown" 63 + "README.txt" 64 + "readme.txt" 65 + "README.rst" 66 + "readme.rst" 67 + "README.org" 68 + "readme.org" 69 + "README.asciidoc" 70 + "readme.asciidoc" 71 + ]; 72 + description = "List of README filenames to look for (in priority order)"; 73 + }; 74 + 75 mainBranch = mkOption { 76 type = types.str; 77 default = "main"; 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"; 93 }; 94 }; 95 ··· 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"; 165 + }; 166 + 167 dev = mkOption { 168 type = types.bool; 169 default = false; ··· 231 mkdir -p "${cfg.stateDir}/.config/git" 232 cat > "${cfg.stateDir}/.config/git/config" << EOF 233 [user] 234 + name = ${cfg.git.userName} 235 + email = ${cfg.git.userEmail} 236 [receive] 237 advertisePushOptions = true 238 + [uploadpack] 239 + allowFilter = true 240 EOF 241 ${setMotd} 242 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" ··· 248 WorkingDirectory = cfg.stateDir; 249 Environment = [ 250 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 251 + "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}" 252 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 253 + "KNOT_GIT_USER_NAME=${cfg.git.userName}" 254 + "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}" 255 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 256 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 257 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 258 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 259 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 260 + "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 261 + "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 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 + }" 273 ]; 274 ExecStart = "${cfg.package}/bin/knot server"; 275 Restart = "always";
+10 -3
nix/modules/spindle.nix
··· 3 lib, 4 ... 5 }: let 6 - cfg = config.services.tangled-spindle; 7 in 8 with lib; { 9 options = { 10 - services.tangled-spindle = { 11 enable = mkOption { 12 type = types.bool; 13 default = false; ··· 35 type = types.str; 36 example = "my.spindle.com"; 37 description = "Hostname for the server (required)"; 38 }; 39 40 jetstreamEndpoint = mkOption { ··· 119 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 120 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 121 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 122 - "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 123 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 124 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
··· 3 lib, 4 ... 5 }: let 6 + cfg = config.services.tangled.spindle; 7 in 8 with lib; { 9 options = { 10 + services.tangled.spindle = { 11 enable = mkOption { 12 type = types.bool; 13 default = false; ··· 35 type = types.str; 36 example = "my.spindle.com"; 37 description = "Hostname for the server (required)"; 38 + }; 39 + 40 + plcUrl = mkOption { 41 + type = types.str; 42 + default = "https://plc.directory"; 43 + description = "atproto PLC directory"; 44 }; 45 46 jetstreamEndpoint = mkOption { ··· 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 126 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 128 + "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 132 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+2
nix/pkgs/appview-static-files.nix
··· 5 lucide-src, 6 inter-fonts-src, 7 ibm-plex-mono-src, 8 sqlite-lib, 9 tailwindcss, 10 src, ··· 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 # for whatever reason (produces broken css), so we are doing this instead 29 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
··· 5 lucide-src, 6 inter-fonts-src, 7 ibm-plex-mono-src, 8 + actor-typeahead-src, 9 sqlite-lib, 10 tailwindcss, 11 src, ··· 25 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 26 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 27 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 + cp -f ${actor-typeahead-src}/actor-typeahead.js . 29 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 30 # for whatever reason (produces broken css), so we are doing this instead 31 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
-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
···
··· 1 + { 2 + buildGoModule, 3 + indigo, 4 + }: 5 + buildGoModule { 6 + pname = "goat"; 7 + version = "0.1.0"; 8 + src = indigo; 9 + subPackages = ["cmd/goat"]; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 + doCheck = false; 12 + }
+21 -8
nix/vm.nix
··· 10 if var == "" 11 then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 else var; 13 in 14 nixpkgs.lib.nixosSystem { 15 inherit system; ··· 73 time.timeZone = "Europe/London"; 74 services.getty.autologinUser = "root"; 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 - services.tangled-knot = { 77 enable = true; 78 motd = "Welcome to the development knot!\n"; 79 server = { 80 owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 - hostname = "localhost:6000"; 82 listenAddr = "0.0.0.0:6000"; 83 }; 84 }; 85 - services.tangled-spindle = { 86 enable = true; 87 server = { 88 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 - hostname = "localhost:6555"; 90 listenAddr = "0.0.0.0:6555"; 91 dev = true; 92 queueSize = 100; ··· 99 users = { 100 # So we don't have to deal with permission clashing between 101 # blank disk VMs and existing state 102 - users.${config.services.tangled-knot.gitUser}.uid = 666; 103 - groups.${config.services.tangled-knot.gitUser}.gid = 666; 104 105 # TODO: separate spindle user 106 }; ··· 120 serviceConfig.PermissionsStartOnly = true; 121 }; 122 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); 125 }; 126 }) 127 ];
··· 10 if var == "" 11 then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 else var; 13 + envVarOr = name: default: let 14 + var = builtins.getEnv name; 15 + in 16 + if var != "" 17 + then var 18 + else default; 19 + 20 + plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 + jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 in 23 nixpkgs.lib.nixosSystem { 24 inherit system; ··· 82 time.timeZone = "Europe/London"; 83 services.getty.autologinUser = "root"; 84 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 85 + services.tangled.knot = { 86 enable = true; 87 motd = "Welcome to the development knot!\n"; 88 server = { 89 owner = envVar "TANGLED_VM_KNOT_OWNER"; 90 + hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000"; 91 + plcUrl = plcUrl; 92 + jetstreamEndpoint = jetstream; 93 listenAddr = "0.0.0.0:6000"; 94 }; 95 }; 96 + services.tangled.spindle = { 97 enable = true; 98 server = { 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 100 + hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 + plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 103 listenAddr = "0.0.0.0:6555"; 104 dev = true; 105 queueSize = 100; ··· 112 users = { 113 # So we don't have to deal with permission clashing between 114 # blank disk VMs and existing state 115 + users.${config.services.tangled.knot.gitUser}.uid = 666; 116 + groups.${config.services.tangled.knot.gitUser}.gid = 666; 117 118 # TODO: separate spindle user 119 }; ··· 133 serviceConfig.PermissionsStartOnly = true; 134 }; 135 in { 136 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 137 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 138 }; 139 }) 140 ];
-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
··· 1 - #! /usr/bin/env bash 2 - 3 - set -e 4 - 5 - go run ./cmd/genjwks/
···
+1
spindle/config/config.go
··· 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 Hostname string `env:"HOSTNAME, required"` 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 Dev bool `env:"DEV, default=false"` 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"`
··· 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 Hostname string `env:"HOSTNAME, required"` 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 Dev bool `env:"DEV, default=false"` 18 Owner string `env:"OWNER, required"` 19 Secrets Secrets `env:",prefix=SECRETS_"`
+13 -3
spindle/engine/engine.go
··· 79 defer cancel() 80 81 for stepIdx, step := range w.Steps { 82 if wfLogger != nil { 83 - ctl := wfLogger.ControlWriter(stepIdx, step) 84 - ctl.Write([]byte(step.Name())) 85 } 86 87 err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 88 if err != nil { 89 if errors.Is(err, ErrTimedOut) { 90 dbErr := db.StatusTimeout(wid, n) ··· 115 if err := eg.Wait(); err != nil { 116 l.Error("failed to run one or more workflows", "err", err) 117 } else { 118 - l.Error("successfully ran full pipeline") 119 } 120 }
··· 79 defer cancel() 80 81 for stepIdx, step := range w.Steps { 82 + // log start of step 83 if wfLogger != nil { 84 + wfLogger. 85 + ControlWriter(stepIdx, step, models.StepStatusStart). 86 + Write([]byte{0}) 87 } 88 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 + 98 if err != nil { 99 if errors.Is(err, ErrTimedOut) { 100 dbErr := db.StatusTimeout(wid, n) ··· 125 if err := eg.Wait(); err != nil { 126 l.Error("failed to run one or more workflows", "err", err) 127 } else { 128 + l.Info("successfully ran full pipeline") 129 } 130 }
+3 -3
spindle/engines/nixery/engine.go
··· 222 }, 223 ReadonlyRootfs: false, 224 CapDrop: []string{"ALL"}, 225 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 SecurityOpt: []string{"no-new-privileges"}, 227 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 }, nil, nil, "") ··· 381 defer logs.Close() 382 383 _, err = stdcopy.StdCopy( 384 - wfLogger.DataWriter("stdout"), 385 - wfLogger.DataWriter("stderr"), 386 logs.Reader, 387 ) 388 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
··· 222 }, 223 ReadonlyRootfs: false, 224 CapDrop: []string{"ALL"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 226 SecurityOpt: []string{"no-new-privileges"}, 227 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 }, nil, nil, "") ··· 381 defer logs.Close() 382 383 _, err = stdcopy.StdCopy( 384 + wfLogger.DataWriter(stepIdx, "stdout"), 385 + wfLogger.DataWriter(stepIdx, "stderr"), 386 logs.Reader, 387 ) 388 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+3 -7
spindle/ingester.go
··· 9 10 "tangled.org/core/api/tangled" 11 "tangled.org/core/eventconsumer" 12 - "tangled.org/core/idresolver" 13 "tangled.org/core/rbac" 14 "tangled.org/core/spindle/db" 15 ··· 142 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 143 var err error 144 did := e.Did 145 - resolver := idresolver.DefaultResolver() 146 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 ··· 190 } 191 192 // add collaborators to rbac 193 - owner, err := resolver.ResolveIdent(ctx, did) 194 if err != nil || owner.Handle.IsInvalidHandle() { 195 return err 196 } ··· 225 return err 226 } 227 228 - resolver := idresolver.DefaultResolver() 229 - 230 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 231 if err != nil || subjectId.Handle.IsInvalidHandle() { 232 return err 233 } ··· 240 241 // TODO: get rid of this entirely 242 // resolve this aturi to extract the repo record 243 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 244 if err != nil || owner.Handle.IsInvalidHandle() { 245 return fmt.Errorf("failed to resolve handle: %w", err) 246 }
··· 9 10 "tangled.org/core/api/tangled" 11 "tangled.org/core/eventconsumer" 12 "tangled.org/core/rbac" 13 "tangled.org/core/spindle/db" 14 ··· 141 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 var err error 143 did := e.Did 144 145 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 ··· 188 } 189 190 // add collaborators to rbac 191 + owner, err := s.res.ResolveIdent(ctx, did) 192 if err != nil || owner.Handle.IsInvalidHandle() { 193 return err 194 } ··· 223 return err 224 } 225 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 if err != nil || subjectId.Handle.IsInvalidHandle() { 228 return err 229 } ··· 236 237 // TODO: get rid of this entirely 238 // resolve this aturi to extract the repo record 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 if err != nil || owner.Handle.IsInvalidHandle() { 241 return fmt.Errorf("failed to resolve handle: %w", err) 242 }
+14 -11
spindle/models/logger.go
··· 37 return l.file.Close() 38 } 39 40 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 - // TODO: emit stream 42 return &dataWriter{ 43 logger: l, 44 stream: stream, 45 } 46 } 47 48 - func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 return &controlWriter{ 50 - logger: l, 51 - idx: idx, 52 - step: step, 53 } 54 } 55 56 type dataWriter struct { 57 logger *WorkflowLogger 58 stream string 59 } 60 61 func (w *dataWriter) Write(p []byte) (int, error) { 62 line := strings.TrimRight(string(p), "\r\n") 63 - entry := NewDataLogLine(line, w.stream) 64 if err := w.logger.encoder.Encode(entry); err != nil { 65 return 0, err 66 } ··· 68 } 69 70 type controlWriter struct { 71 - logger *WorkflowLogger 72 - idx int 73 - step Step 74 } 75 76 func (w *controlWriter) Write(_ []byte) (int, error) { 77 - entry := NewControlLogLine(w.idx, w.step) 78 if err := w.logger.encoder.Encode(entry); err != nil { 79 return 0, err 80 }
··· 37 return l.file.Close() 38 } 39 40 + func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 41 return &dataWriter{ 42 logger: l, 43 + idx: idx, 44 stream: stream, 45 } 46 } 47 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 49 return &controlWriter{ 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + stepStatus: stepStatus, 54 } 55 } 56 57 type dataWriter struct { 58 logger *WorkflowLogger 59 + idx int 60 stream string 61 } 62 63 func (w *dataWriter) Write(p []byte) (int, error) { 64 line := strings.TrimRight(string(p), "\r\n") 65 + entry := NewDataLogLine(w.idx, line, w.stream) 66 if err := w.logger.encoder.Encode(entry); err != nil { 67 return 0, err 68 } ··· 70 } 71 72 type controlWriter struct { 73 + logger *WorkflowLogger 74 + idx int 75 + step Step 76 + stepStatus StepStatus 77 } 78 79 func (w *controlWriter) Write(_ []byte) (int, error) { 80 + entry := NewControlLogLine(w.idx, w.step, w.stepStatus) 81 if err := w.logger.encoder.Encode(entry); err != nil { 82 return 0, err 83 }
+23 -8
spindle/models/models.go
··· 4 "fmt" 5 "regexp" 6 "slices" 7 8 "tangled.org/core/api/tangled" 9 ··· 76 var ( 77 // step log data 78 LogKindData LogKind = "data" 79 - // indicates start/end of a step 80 LogKindControl LogKind = "control" 81 ) 82 83 type LogLine struct { 84 - Kind LogKind `json:"kind"` 85 - Content string `json:"content"` 86 87 // fields if kind is "data" 88 Stream string `json:"stream,omitempty"` 89 90 // 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"` 94 } 95 96 - func NewDataLogLine(content, stream string) LogLine { 97 return LogLine{ 98 Kind: LogKindData, 99 Content: content, 100 Stream: stream, 101 } 102 } 103 104 - func NewControlLogLine(idx int, step Step) LogLine { 105 return LogLine{ 106 Kind: LogKindControl, 107 Content: step.Name(), 108 StepId: idx, 109 StepKind: step.Kind(), 110 StepCommand: step.Command(), 111 }
··· 4 "fmt" 5 "regexp" 6 "slices" 7 + "time" 8 9 "tangled.org/core/api/tangled" 10 ··· 77 var ( 78 // step log data 79 LogKindData LogKind = "data" 80 + // indicates status of a step 81 LogKindControl LogKind = "control" 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 + 92 type LogLine struct { 93 + Kind LogKind `json:"kind"` 94 + Content string `json:"content"` 95 + Time time.Time `json:"time"` 96 + StepId int `json:"step_id"` 97 98 // fields if kind is "data" 99 Stream string `json:"stream,omitempty"` 100 101 // fields if kind is "control" 102 + StepStatus StepStatus `json:"step_status,omitempty"` 103 + StepKind StepKind `json:"step_kind,omitempty"` 104 + StepCommand string `json:"step_command,omitempty"` 105 } 106 107 + func NewDataLogLine(idx int, content, stream string) LogLine { 108 return LogLine{ 109 Kind: LogKindData, 110 + Time: time.Now(), 111 Content: content, 112 + StepId: idx, 113 Stream: stream, 114 } 115 } 116 117 + func NewControlLogLine(idx int, step Step, status StepStatus) LogLine { 118 return LogLine{ 119 Kind: LogKindControl, 120 + Time: time.Now(), 121 Content: step.Name(), 122 StepId: idx, 123 + StepStatus: status, 124 StepKind: step.Kind(), 125 StepCommand: step.Command(), 126 }
+86 -41
spindle/server.go
··· 49 vault secrets.Manager 50 } 51 52 - func Run(ctx context.Context) error { 53 logger := log.FromContext(ctx) 54 - 55 - cfg, err := config.Load(ctx) 56 - if err != nil { 57 - return fmt.Errorf("failed to load config: %w", err) 58 - } 59 60 d, err := db.Make(cfg.Server.DBPath) 61 if err != nil { 62 - return fmt.Errorf("failed to setup db: %w", err) 63 } 64 65 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 66 if err != nil { 67 - return fmt.Errorf("failed to setup rbac enforcer: %w", err) 68 } 69 e.E.EnableAutoSave(true) 70 ··· 74 switch cfg.Server.Secrets.Provider { 75 case "openbao": 76 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 - return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 } 79 vault, err = secrets.NewOpenBaoManager( 80 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 82 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 ) 84 if err != nil { 85 - return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 } 87 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 case "sqlite", "": 89 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 if err != nil { 91 - return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 } 93 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 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 101 } 102 103 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 110 } 111 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 if err != nil { 113 - return fmt.Errorf("failed to setup jetstream client: %w", err) 114 } 115 jc.AddDid(cfg.Server.Owner) 116 117 // Check if the spindle knows about any Dids; 118 dids, err := d.GetAllDids() 119 if err != nil { 120 - return fmt.Errorf("failed to get all dids: %w", err) 121 } 122 for _, d := range dids { 123 jc.AddDid(d) 124 } 125 126 - resolver := idresolver.DefaultResolver() 127 128 - spindle := Spindle{ 129 jc: jc, 130 e: e, 131 db: d, 132 l: logger, 133 n: &n, 134 - engs: map[string]models.Engine{"nixery": nixeryEng}, 135 jq: jq, 136 cfg: cfg, 137 res: resolver, ··· 140 141 err = e.AddSpindle(rbacDomain) 142 if err != nil { 143 - return fmt.Errorf("failed to set rbac domain: %w", err) 144 } 145 err = spindle.configureOwner() 146 if err != nil { 147 - return err 148 } 149 logger.Info("owner set", "did", cfg.Server.Owner) 150 - 151 - // starts a job queue runner in the background 152 - jq.Start() 153 - defer jq.Stop() 154 - 155 - // Stop vault token renewal if it implements Stopper 156 - if stopper, ok := vault.(secrets.Stopper); ok { 157 - defer stopper.Stop() 158 - } 159 160 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 161 if err != nil { 162 - return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 163 } 164 165 err = jc.StartJetstream(ctx, spindle.ingest()) 166 if err != nil { 167 - return fmt.Errorf("failed to start jetstream consumer: %w", err) 168 } 169 170 // for each incoming sh.tangled.pipeline, we execute ··· 177 ccfg.CursorStore = cursorStore 178 knownKnots, err := d.Knots() 179 if err != nil { 180 - return err 181 } 182 for _, knot := range knownKnots { 183 logger.Info("adding source start", "knot", knot) ··· 185 } 186 spindle.ks = eventconsumer.NewConsumer(*ccfg) 187 188 go func() { 189 - logger.Info("starting knot event consumer") 190 - spindle.ks.Start(ctx) 191 }() 192 193 - logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 194 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 195 196 - return nil 197 } 198 199 func (s *Spindle) Router() http.Handler {
··· 49 vault secrets.Manager 50 } 51 52 + // New creates a new Spindle server with the provided configuration and engines. 53 + func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 54 logger := log.FromContext(ctx) 55 56 d, err := db.Make(cfg.Server.DBPath) 57 if err != nil { 58 + return nil, fmt.Errorf("failed to setup db: %w", err) 59 } 60 61 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 62 if err != nil { 63 + return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 64 } 65 e.E.EnableAutoSave(true) 66 ··· 70 switch cfg.Server.Secrets.Provider { 71 case "openbao": 72 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 73 + return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 74 } 75 vault, err = secrets.NewOpenBaoManager( 76 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 78 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 79 ) 80 if err != nil { 81 + return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 82 } 83 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 84 case "sqlite", "": 85 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 86 if err != nil { 87 + return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 88 } 89 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 90 default: 91 + return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 92 } 93 94 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 101 } 102 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 103 if err != nil { 104 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 105 } 106 jc.AddDid(cfg.Server.Owner) 107 108 // Check if the spindle knows about any Dids; 109 dids, err := d.GetAllDids() 110 if err != nil { 111 + return nil, fmt.Errorf("failed to get all dids: %w", err) 112 } 113 for _, d := range dids { 114 jc.AddDid(d) 115 } 116 117 + resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 118 119 + spindle := &Spindle{ 120 jc: jc, 121 e: e, 122 db: d, 123 l: logger, 124 n: &n, 125 + engs: engines, 126 jq: jq, 127 cfg: cfg, 128 res: resolver, ··· 131 132 err = e.AddSpindle(rbacDomain) 133 if err != nil { 134 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 135 } 136 err = spindle.configureOwner() 137 if err != nil { 138 + return nil, err 139 } 140 logger.Info("owner set", "did", cfg.Server.Owner) 141 142 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 143 if err != nil { 144 + return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 145 } 146 147 err = jc.StartJetstream(ctx, spindle.ingest()) 148 if err != nil { 149 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 150 } 151 152 // for each incoming sh.tangled.pipeline, we execute ··· 159 ccfg.CursorStore = cursorStore 160 knownKnots, err := d.Knots() 161 if err != nil { 162 + return nil, err 163 } 164 for _, knot := range knownKnots { 165 logger.Info("adding source start", "knot", knot) ··· 167 } 168 spindle.ks = eventconsumer.NewConsumer(*ccfg) 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 + 214 go func() { 215 + s.l.Info("starting knot event consumer") 216 + s.ks.Start(ctx) 217 }() 218 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 + } 240 241 + return s.Start(ctx) 242 } 243 244 func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
··· 213 if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 214 return fmt.Errorf("failed to write to websocket: %w", err) 215 } 216 } 217 } 218 }
··· 213 if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 214 return fmt.Errorf("failed to write to websocket: %w", err) 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 + } 221 } 222 } 223 }
+1 -1
types/repo.go
··· 66 type Branch struct { 67 Reference `json:"reference"` 68 Commit *object.Commit `json:"commit,omitempty"` 69 - IsDefault bool `json:"is_deafult,omitempty"` 70 } 71 72 type RepoTagsResponse struct {
··· 66 type Branch struct { 67 Reference `json:"reference"` 68 Commit *object.Commit `json:"commit,omitempty"` 69 + IsDefault bool `json:"is_default,omitempty"` 70 } 71 72 type RepoTagsResponse struct {
+28 -5
types/tree.go
··· 4 "time" 5 6 "github.com/go-git/go-git/v5/plumbing" 7 ) 8 9 // A nicer git tree representation. 10 type NiceTree struct { 11 // Relative path 12 - Name string `json:"name"` 13 - Mode string `json:"mode"` 14 - Size int64 `json:"size"` 15 - IsFile bool `json:"is_file"` 16 - IsSubtree bool `json:"is_subtree"` 17 18 LastCommit *LastCommitInfo `json:"last_commit,omitempty"` 19 } 20 21 type LastCommitInfo struct {
··· 4 "time" 5 6 "github.com/go-git/go-git/v5/plumbing" 7 + "github.com/go-git/go-git/v5/plumbing/filemode" 8 ) 9 10 // A nicer git tree representation. 11 type NiceTree struct { 12 // Relative path 13 + Name string `json:"name"` 14 + Mode string `json:"mode"` 15 + Size int64 `json:"size"` 16 17 LastCommit *LastCommitInfo `json:"last_commit,omitempty"` 18 + } 19 + 20 + func (t *NiceTree) FileMode() (filemode.FileMode, error) { 21 + return filemode.New(t.Mode) 22 + } 23 + 24 + func (t *NiceTree) IsFile() bool { 25 + m, err := t.FileMode() 26 + 27 + if err != nil { 28 + return false 29 + } 30 + 31 + return m.IsFile() 32 + } 33 + 34 + func (t *NiceTree) IsSubmodule() bool { 35 + m, err := t.FileMode() 36 + 37 + if err != nil { 38 + return false 39 + } 40 + 41 + return m == filemode.Submodule 42 } 43 44 type LastCommitInfo struct {
+9 -1
workflow/compile.go
··· 113 func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 cw := &tangled.Pipeline_Workflow{} 115 116 - if !w.Match(compiler.Trigger) { 117 compiler.Diagnostics.AddWarning( 118 w.Name, 119 WorkflowSkipped,
··· 113 func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 cw := &tangled.Pipeline_Workflow{} 115 116 + matched, err := w.Match(compiler.Trigger) 117 + if err != nil { 118 + compiler.Diagnostics.AddError( 119 + w.Name, 120 + fmt.Errorf("failed to execute workflow: %w", err), 121 + ) 122 + return nil 123 + } 124 + if !matched { 125 compiler.Diagnostics.AddWarning( 126 w.Name, 127 WorkflowSkipped,
+125
workflow/compile_test.go
··· 95 assert.Len(t, c.Diagnostics.Errors, 1) 96 assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 }
··· 95 assert.Len(t, c.Diagnostics.Errors, 1) 96 assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 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
··· 8 9 "tangled.org/core/api/tangled" 10 11 "github.com/go-git/go-git/v5/plumbing" 12 "gopkg.in/yaml.v3" 13 ) ··· 33 34 Constraint struct { 35 Event StringList `yaml:"event"` 36 - Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 } 38 39 CloneOpts struct { ··· 59 return strings.ReplaceAll(string(t), "_", " ") 60 } 61 62 func FromFile(name string, contents []byte) (Workflow, error) { 63 var wf Workflow 64 ··· 74 } 75 76 // if any of the constraints on a workflow is true, return true 77 - func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 78 // manual triggers always run the workflow 79 if trigger.Manual != nil { 80 - return true 81 } 82 83 // if not manual, run through the constraint list and see if any one matches 84 for _, c := range w.When { 85 - if c.Match(trigger) { 86 - return true 87 } 88 } 89 90 // no constraints, always run this workflow 91 if len(w.When) == 0 { 92 - return true 93 } 94 95 - return false 96 } 97 98 - func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 99 match := true 100 101 // manual triggers always pass this constraint 102 if trigger.Manual != nil { 103 - return true 104 } 105 106 // apply event constraints ··· 108 109 // apply branch constraints for PRs 110 if trigger.PullRequest != nil { 111 - match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) 112 } 113 114 // apply ref constraints for pushes 115 if trigger.Push != nil { 116 - match = match && c.MatchRef(trigger.Push.Ref) 117 } 118 119 - return match 120 - } 121 - 122 - func (c *Constraint) MatchBranch(branch string) bool { 123 - return slices.Contains(c.Branch, branch) 124 } 125 126 - func (c *Constraint) MatchRef(ref string) bool { 127 refName := plumbing.ReferenceName(ref) 128 if refName.IsBranch() { 129 - return slices.Contains(c.Branch, refName.Short()) 130 } 131 - return false 132 } 133 134 func (c *Constraint) MatchEvent(event string) bool {
··· 8 9 "tangled.org/core/api/tangled" 10 11 + "github.com/bmatcuk/doublestar/v4" 12 "github.com/go-git/go-git/v5/plumbing" 13 "gopkg.in/yaml.v3" 14 ) ··· 34 35 Constraint struct { 36 Event StringList `yaml:"event"` 37 + Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 38 + Tag StringList `yaml:"tag"` // optional; only applies to push events 39 } 40 41 CloneOpts struct { ··· 61 return strings.ReplaceAll(string(t), "_", " ") 62 } 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 + 81 func FromFile(name string, contents []byte) (Workflow, error) { 82 var wf Workflow 83 ··· 93 } 94 95 // if any of the constraints on a workflow is true, return true 96 + func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 97 // manual triggers always run the workflow 98 if trigger.Manual != nil { 99 + return true, nil 100 } 101 102 // if not manual, run through the constraint list and see if any one matches 103 for _, c := range w.When { 104 + matched, err := c.Match(trigger) 105 + if err != nil { 106 + return false, err 107 + } 108 + if matched { 109 + return true, nil 110 } 111 } 112 113 // no constraints, always run this workflow 114 if len(w.When) == 0 { 115 + return true, nil 116 } 117 118 + return false, nil 119 } 120 121 + func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 122 match := true 123 124 // manual triggers always pass this constraint 125 if trigger.Manual != nil { 126 + return true, nil 127 } 128 129 // apply event constraints ··· 131 132 // apply branch constraints for PRs 133 if trigger.PullRequest != nil { 134 + matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch) 135 + if err != nil { 136 + return false, err 137 + } 138 + match = match && matched 139 } 140 141 // apply ref constraints for pushes 142 if trigger.Push != nil { 143 + matched, err := c.MatchRef(trigger.Push.Ref) 144 + if err != nil { 145 + return false, err 146 + } 147 + match = match && matched 148 } 149 150 + return match, nil 151 } 152 153 + func (c *Constraint) MatchRef(ref string) (bool, error) { 154 refName := plumbing.ReferenceName(ref) 155 + shortName := refName.Short() 156 + 157 if refName.IsBranch() { 158 + return c.MatchBranch(shortName) 159 } 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) 174 } 175 176 func (c *Constraint) MatchEvent(event string) bool {
+284 -1
workflow/def_test.go
··· 6 "github.com/stretchr/testify/assert" 7 ) 8 9 - func TestUnmarshalWorkflow(t *testing.T) { 10 yamlData := ` 11 when: 12 - event: ["push", "pull_request"] ··· 38 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 }
··· 6 "github.com/stretchr/testify/assert" 7 ) 8 9 + func TestUnmarshalWorkflowWithBranch(t *testing.T) { 10 yamlData := ` 11 when: 12 - event: ["push", "pull_request"] ··· 38 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 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 + }