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

Compare changes

Choose any two refs to compare.

Changed files
+8492 -3900
.tangled
api
appview
config
db
dns
indexer
issues
knots
labels
middleware
models
notifications
notify
oauth
ogcard
pages
pagination
pipelines
pulls
repo
reporesolver
settings
signup
spindles
state
validator
cmd
genjwks
docs
guard
idresolver
knotserver
lexicons
nix
patchutil
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 15 .env 16 16 *.rdb 17 17 .envrc 18 + **/*.bleve 18 19 # Created if following hacking.md 19 20 genjwks.out 20 21 /nix/vm-data
+1 -1
.tangled/workflows/build.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+3 -1
api/tangled/actorprofile.go
··· 27 27 Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 28 // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 29 PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 - Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 30 + // pronouns: Preferred gender pronouns. 31 + Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` 32 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 31 33 }
+196 -2
api/tangled/cbor_gen.go
··· 26 26 } 27 27 28 28 cw := cbg.NewCborWriter(w) 29 - fieldCount := 7 29 + fieldCount := 8 30 30 31 31 if t.Description == nil { 32 32 fieldCount-- ··· 41 41 } 42 42 43 43 if t.PinnedRepositories == nil { 44 + fieldCount-- 45 + } 46 + 47 + if t.Pronouns == nil { 44 48 fieldCount-- 45 49 } 46 50 ··· 186 190 return err 187 191 } 188 192 if _, err := cw.WriteString(string(*t.Location)); err != nil { 193 + return err 194 + } 195 + } 196 + } 197 + 198 + // t.Pronouns (string) (string) 199 + if t.Pronouns != nil { 200 + 201 + if len("pronouns") > 1000000 { 202 + return xerrors.Errorf("Value in field \"pronouns\" was too long") 203 + } 204 + 205 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil { 206 + return err 207 + } 208 + if _, err := cw.WriteString(string("pronouns")); err != nil { 209 + return err 210 + } 211 + 212 + if t.Pronouns == nil { 213 + if _, err := cw.Write(cbg.CborNull); err != nil { 214 + return err 215 + } 216 + } else { 217 + if len(*t.Pronouns) > 1000000 { 218 + return xerrors.Errorf("Value in field t.Pronouns was too long") 219 + } 220 + 221 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil { 222 + return err 223 + } 224 + if _, err := cw.WriteString(string(*t.Pronouns)); err != nil { 189 225 return err 190 226 } 191 227 } ··· 430 466 } 431 467 432 468 t.Location = (*string)(&sval) 469 + } 470 + } 471 + // t.Pronouns (string) (string) 472 + case "pronouns": 473 + 474 + { 475 + b, err := cr.ReadByte() 476 + if err != nil { 477 + return err 478 + } 479 + if b != cbg.CborNull[0] { 480 + if err := cr.UnreadByte(); err != nil { 481 + return err 482 + } 483 + 484 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 485 + if err != nil { 486 + return err 487 + } 488 + 489 + t.Pronouns = (*string)(&sval) 433 490 } 434 491 } 435 492 // t.Description (string) (string) ··· 5806 5863 } 5807 5864 5808 5865 cw := cbg.NewCborWriter(w) 5809 - fieldCount := 8 5866 + fieldCount := 10 5810 5867 5811 5868 if t.Description == nil { 5812 5869 fieldCount-- ··· 5821 5878 } 5822 5879 5823 5880 if t.Spindle == nil { 5881 + fieldCount-- 5882 + } 5883 + 5884 + if t.Topics == nil { 5885 + fieldCount-- 5886 + } 5887 + 5888 + if t.Website == nil { 5824 5889 fieldCount-- 5825 5890 } 5826 5891 ··· 5961 6026 } 5962 6027 } 5963 6028 6029 + // t.Topics ([]string) (slice) 6030 + if t.Topics != nil { 6031 + 6032 + if len("topics") > 1000000 { 6033 + return xerrors.Errorf("Value in field \"topics\" was too long") 6034 + } 6035 + 6036 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil { 6037 + return err 6038 + } 6039 + if _, err := cw.WriteString(string("topics")); err != nil { 6040 + return err 6041 + } 6042 + 6043 + if len(t.Topics) > 8192 { 6044 + return xerrors.Errorf("Slice value in field t.Topics was too long") 6045 + } 6046 + 6047 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil { 6048 + return err 6049 + } 6050 + for _, v := range t.Topics { 6051 + if len(v) > 1000000 { 6052 + return xerrors.Errorf("Value in field v was too long") 6053 + } 6054 + 6055 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 6056 + return err 6057 + } 6058 + if _, err := cw.WriteString(string(v)); err != nil { 6059 + return err 6060 + } 6061 + 6062 + } 6063 + } 6064 + 5964 6065 // t.Spindle (string) (string) 5965 6066 if t.Spindle != nil { 5966 6067 ··· 5993 6094 } 5994 6095 } 5995 6096 6097 + // t.Website (string) (string) 6098 + if t.Website != nil { 6099 + 6100 + if len("website") > 1000000 { 6101 + return xerrors.Errorf("Value in field \"website\" was too long") 6102 + } 6103 + 6104 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil { 6105 + return err 6106 + } 6107 + if _, err := cw.WriteString(string("website")); err != nil { 6108 + return err 6109 + } 6110 + 6111 + if t.Website == nil { 6112 + if _, err := cw.Write(cbg.CborNull); err != nil { 6113 + return err 6114 + } 6115 + } else { 6116 + if len(*t.Website) > 1000000 { 6117 + return xerrors.Errorf("Value in field t.Website was too long") 6118 + } 6119 + 6120 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil { 6121 + return err 6122 + } 6123 + if _, err := cw.WriteString(string(*t.Website)); err != nil { 6124 + return err 6125 + } 6126 + } 6127 + } 6128 + 5996 6129 // t.CreatedAt (string) (string) 5997 6130 if len("createdAt") > 1000000 { 5998 6131 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6185 6318 t.Source = (*string)(&sval) 6186 6319 } 6187 6320 } 6321 + // t.Topics ([]string) (slice) 6322 + case "topics": 6323 + 6324 + maj, extra, err = cr.ReadHeader() 6325 + if err != nil { 6326 + return err 6327 + } 6328 + 6329 + if extra > 8192 { 6330 + return fmt.Errorf("t.Topics: array too large (%d)", extra) 6331 + } 6332 + 6333 + if maj != cbg.MajArray { 6334 + return fmt.Errorf("expected cbor array") 6335 + } 6336 + 6337 + if extra > 0 { 6338 + t.Topics = make([]string, extra) 6339 + } 6340 + 6341 + for i := 0; i < int(extra); i++ { 6342 + { 6343 + var maj byte 6344 + var extra uint64 6345 + var err error 6346 + _ = maj 6347 + _ = extra 6348 + _ = err 6349 + 6350 + { 6351 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6352 + if err != nil { 6353 + return err 6354 + } 6355 + 6356 + t.Topics[i] = string(sval) 6357 + } 6358 + 6359 + } 6360 + } 6188 6361 // t.Spindle (string) (string) 6189 6362 case "spindle": 6190 6363 ··· 6204 6377 } 6205 6378 6206 6379 t.Spindle = (*string)(&sval) 6380 + } 6381 + } 6382 + // t.Website (string) (string) 6383 + case "website": 6384 + 6385 + { 6386 + b, err := cr.ReadByte() 6387 + if err != nil { 6388 + return err 6389 + } 6390 + if b != cbg.CborNull[0] { 6391 + if err := cr.UnreadByte(); err != nil { 6392 + return err 6393 + } 6394 + 6395 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6396 + if err != nil { 6397 + return err 6398 + } 6399 + 6400 + t.Website = (*string)(&sval) 6207 6401 } 6208 6402 } 6209 6403 // t.CreatedAt (string) (string)
+13 -1
api/tangled/repoblob.go
··· 30 30 // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 31 type RepoBlob_Output struct { 32 32 // content: File content (base64 encoded for binary files) 33 - Content string `json:"content" cborgen:"content"` 33 + Content *string `json:"content,omitempty" cborgen:"content,omitempty"` 34 34 // encoding: Content encoding 35 35 Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 36 // isBinary: Whether the file is binary ··· 44 44 Ref string `json:"ref" cborgen:"ref"` 45 45 // size: File size in bytes 46 46 Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + // submodule: Submodule information if path is a submodule 48 + Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"` 47 49 } 48 50 49 51 // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. ··· 54 56 Name string `json:"name" cborgen:"name"` 55 57 // when: Author timestamp 56 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"` 57 69 } 58 70 59 71 // RepoBlob calls the XRPC method "sh.tangled.repo.blob".
-4
api/tangled/repotree.go
··· 47 47 48 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 49 49 type RepoTree_TreeEntry struct { 50 - // is_file: Whether this entry is a file 51 - Is_file bool `json:"is_file" cborgen:"is_file"` 52 - // is_subtree: Whether this entry is a directory/subtree 53 - Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 54 50 Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 55 51 // mode: File mode 56 52 Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
··· 30 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 31 31 // spindle: CI runner to send jobs to and receive results from 32 32 Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"` 33 + // topics: Topics related to the repo 34 + Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"` 35 + // website: Any URI related to the repo 36 + Website *string `json:"website,omitempty" cborgen:"website,omitempty"` 33 37 }
+15 -2
appview/config/config.go
··· 13 13 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 14 DbPath string `env:"DB_PATH, default=appview.db"` 15 15 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"` 17 + AppviewName string `env:"APPVIEW_Name, default=Tangled"` 17 18 Dev bool `env:"DEV, default=false"` 18 19 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 20 ··· 25 26 } 26 27 27 28 type OAuthConfig struct { 28 - Jwks string `env:"JWKS"` 29 + ClientSecret string `env:"CLIENT_SECRET"` 30 + ClientKid string `env:"CLIENT_KID"` 31 + } 32 + 33 + type PlcConfig struct { 34 + PLCURL string `env:"URL, default=https://plc.directory"` 29 35 } 30 36 31 37 type JetstreamConfig struct { ··· 78 84 TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 85 } 80 86 87 + type LabelConfig struct { 88 + DefaultLabelDefs []string `env:"DEFAULTS, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"` // delimiter=, 89 + GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 90 + } 91 + 81 92 func (cfg RedisConfig) ToURL() string { 82 93 u := &url.URL{ 83 94 Scheme: "redis", ··· 103 114 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 104 115 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 105 116 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 117 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 106 118 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 107 119 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 120 + Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 108 121 } 109 122 110 123 func LoadConfig(ctx context.Context) (*Config, error) {
-1
appview/db/artifact.go
··· 67 67 ) 68 68 69 69 rows, err := e.Query(query, args...) 70 - 71 70 if err != nil { 72 71 return nil, err 73 72 }
+53
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 + "time" 6 7 7 8 "tangled.org/core/appview/models" 8 9 ) ··· 59 60 60 61 return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 61 62 } 63 + 64 + func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 + var collaborators []models.Collaborator 66 + var conditions []string 67 + var args []any 68 + for _, filter := range filters { 69 + conditions = append(conditions, filter.Condition()) 70 + args = append(args, filter.Arg()...) 71 + } 72 + whereClause := "" 73 + if conditions != nil { 74 + whereClause = " where " + strings.Join(conditions, " and ") 75 + } 76 + query := fmt.Sprintf(`select 77 + id, 78 + did, 79 + rkey, 80 + subject_did, 81 + repo_at, 82 + created 83 + from collaborators %s`, 84 + whereClause, 85 + ) 86 + rows, err := e.Query(query, args...) 87 + if err != nil { 88 + return nil, err 89 + } 90 + defer rows.Close() 91 + for rows.Next() { 92 + var collaborator models.Collaborator 93 + var createdAt string 94 + if err := rows.Scan( 95 + &collaborator.Id, 96 + &collaborator.Did, 97 + &collaborator.Rkey, 98 + &collaborator.SubjectDid, 99 + &collaborator.RepoAt, 100 + &createdAt, 101 + ); err != nil { 102 + return nil, err 103 + } 104 + collaborator.Created, err = time.Parse(time.RFC3339, createdAt) 105 + if err != nil { 106 + collaborator.Created = time.Now() 107 + } 108 + collaborators = append(collaborators, collaborator) 109 + } 110 + if err := rows.Err(); err != nil { 111 + return nil, err 112 + } 113 + return collaborators, nil 114 + }
+31
appview/db/db.go
··· 1097 1097 }) 1098 1098 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1099 1099 1100 + // knots may report the combined patch for a comparison, we can store that on the appview side 1101 + // (but not on the pds record), because calculating the combined patch requires a git index 1102 + runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1103 + _, err := tx.Exec(` 1104 + alter table pull_submissions add column combined text; 1105 + `) 1106 + return err 1107 + }) 1108 + 1109 + runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error { 1110 + _, err := tx.Exec(` 1111 + alter table profile add column pronouns text; 1112 + `) 1113 + return err 1114 + }) 1115 + 1116 + runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1117 + _, err := tx.Exec(` 1118 + alter table repos add column website text; 1119 + alter table repos add column topics text; 1120 + `) 1121 + return err 1122 + }) 1123 + 1124 + runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1125 + _, err := tx.Exec(` 1126 + alter table notification_preferences add column user_mentioned integer not null default 1; 1127 + `) 1128 + return err 1129 + }) 1130 + 1100 1131 return &DB{ 1101 1132 db, 1102 1133 logger,
+72 -16
appview/db/issues.go
··· 101 101 pLower := FilterGte("row_num", page.Offset+1) 102 102 pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 103 104 - args = append(args, pLower.Arg()...) 105 - args = append(args, pUpper.Arg()...) 106 - pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 104 + pageClause := "" 105 + if page.Limit > 0 { 106 + args = append(args, pLower.Arg()...) 107 + args = append(args, pUpper.Arg()...) 108 + pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 109 + } 107 110 108 111 query := fmt.Sprintf( 109 112 ` ··· 128 131 %s 129 132 `, 130 133 whereClause, 131 - pagination, 134 + pageClause, 132 135 ) 133 136 134 137 rows, err := e.Query(query, args...) ··· 243 246 return issues, nil 244 247 } 245 248 249 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 250 + issues, err := GetIssuesPaginated( 251 + e, 252 + pagination.Page{}, 253 + FilterEq("repo_at", repoAt), 254 + FilterEq("issue_id", issueId), 255 + ) 256 + if err != nil { 257 + return nil, err 258 + } 259 + if len(issues) != 1 { 260 + return nil, sql.ErrNoRows 261 + } 262 + 263 + return &issues[0], nil 264 + } 265 + 246 266 func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 - return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 267 + return GetIssuesPaginated(e, pagination.Page{}, filters...) 248 268 } 249 269 250 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 - query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 - row := e.QueryRow(query, repoAt, issueId) 270 + // GetIssueIDs gets list of all existing issue's IDs 271 + func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 272 + var ids []int64 273 + 274 + var filters []filter 275 + openValue := 0 276 + if opts.IsOpen { 277 + openValue = 1 278 + } 279 + filters = append(filters, FilterEq("open", openValue)) 280 + if opts.RepoAt != "" { 281 + filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 282 + } 283 + 284 + var conditions []string 285 + var args []any 286 + 287 + for _, filter := range filters { 288 + conditions = append(conditions, filter.Condition()) 289 + args = append(args, filter.Arg()...) 290 + } 253 291 254 - var issue models.Issue 255 - var createdAt string 256 - err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 292 + whereClause := "" 293 + if conditions != nil { 294 + whereClause = " where " + strings.Join(conditions, " and ") 295 + } 296 + query := fmt.Sprintf( 297 + ` 298 + select 299 + id 300 + from 301 + issues 302 + %s 303 + limit ? offset ?`, 304 + whereClause, 305 + ) 306 + args = append(args, opts.Page.Limit, opts.Page.Offset) 307 + rows, err := e.Query(query, args...) 257 308 if err != nil { 258 309 return nil, err 259 310 } 311 + defer rows.Close() 260 312 261 - createdTime, err := time.Parse(time.RFC3339, createdAt) 262 - if err != nil { 263 - return nil, err 313 + for rows.Next() { 314 + var id int64 315 + err := rows.Scan(&id) 316 + if err != nil { 317 + return nil, err 318 + } 319 + 320 + ids = append(ids, id) 264 321 } 265 - issue.Created = createdTime 266 322 267 - return &issue, nil 323 + return ids, nil 268 324 } 269 325 270 326 func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+103 -54
appview/db/notifications.go
··· 8 8 "strings" 9 9 "time" 10 10 11 + "github.com/bluesky-social/indigo/atproto/syntax" 11 12 "tangled.org/core/appview/models" 12 13 "tangled.org/core/appview/pagination" 13 14 ) 14 15 15 - func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 16 + func CreateNotification(e Execer, notification *models.Notification) error { 16 17 query := ` 17 18 INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 19 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 20 ` 20 21 21 - result, err := d.DB.ExecContext(ctx, query, 22 + result, err := e.Exec(query, 22 23 notification.RecipientDid, 23 24 notification.ActorDid, 24 25 string(notification.Type), ··· 58 59 for _, condition := range conditions[1:] { 59 60 whereClause += " AND " + condition 60 61 } 62 + } 63 + pageClause := "" 64 + if page.Limit > 0 { 65 + pageClause = " limit ? offset ? " 66 + args = append(args, page.Limit, page.Offset) 61 67 } 62 68 63 69 query := fmt.Sprintf(` ··· 65 71 from notifications 66 72 %s 67 73 order by created desc 68 - limit ? offset ? 69 - `, whereClause) 70 - 71 - args = append(args, page.Limit, page.Offset) 74 + %s 75 + `, whereClause, pageClause) 72 76 73 77 rows, err := e.QueryContext(context.Background(), query, args...) 74 78 if err != nil { ··· 130 134 select 131 135 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 132 136 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 133 - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 137 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 134 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, 135 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 136 140 from notifications n ··· 159 163 var issue models.Issue 160 164 var pull models.Pull 161 165 var rId, iId, pId sql.NullInt64 162 - var rDid, rName, rDescription sql.NullString 166 + var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 163 167 var iDid sql.NullString 164 168 var iIssueId sql.NullInt64 165 169 var iTitle sql.NullString ··· 172 176 err := rows.Scan( 173 177 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 174 178 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 175 - &rId, &rDid, &rName, &rDescription, 179 + &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 176 180 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 177 181 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 178 182 ) ··· 199 203 } 200 204 if rDescription.Valid { 201 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) 202 212 } 203 213 nwe.Repo = &repo 204 214 } ··· 274 284 return count, nil 275 285 } 276 286 277 - func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 287 + func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 278 288 idFilter := FilterEq("id", notificationID) 279 289 recipientFilter := FilterEq("recipient_did", userDID) 280 290 ··· 286 296 287 297 args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 298 289 - result, err := d.DB.ExecContext(ctx, query, args...) 299 + result, err := e.Exec(query, args...) 290 300 if err != nil { 291 301 return fmt.Errorf("failed to mark notification as read: %w", err) 292 302 } ··· 303 313 return nil 304 314 } 305 315 306 - func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 316 + func MarkAllNotificationsRead(e Execer, userDID string) error { 307 317 recipientFilter := FilterEq("recipient_did", userDID) 308 318 readFilter := FilterEq("read", 0) 309 319 ··· 315 325 316 326 args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 327 318 - _, err := d.DB.ExecContext(ctx, query, args...) 328 + _, err := e.Exec(query, args...) 319 329 if err != nil { 320 330 return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 331 } ··· 323 333 return nil 324 334 } 325 335 326 - func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 336 + func DeleteNotification(e Execer, notificationID int64, userDID string) error { 327 337 idFilter := FilterEq("id", notificationID) 328 338 recipientFilter := FilterEq("recipient_did", userDID) 329 339 ··· 334 344 335 345 args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 346 337 - result, err := d.DB.ExecContext(ctx, query, args...) 347 + result, err := e.Exec(query, args...) 338 348 if err != nil { 339 349 return fmt.Errorf("failed to delete notification: %w", err) 340 350 } ··· 351 361 return nil 352 362 } 353 363 354 - func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 - userFilter := FilterEq("user_did", userDID) 364 + func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 365 + prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 366 + if err != nil { 367 + return nil, err 368 + } 369 + 370 + p, ok := prefs[syntax.DID(userDid)] 371 + if !ok { 372 + return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 373 + } 374 + 375 + return p, nil 376 + } 377 + 378 + func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 379 + prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 380 + 381 + var conditions []string 382 + var args []any 383 + for _, filter := range filters { 384 + conditions = append(conditions, filter.Condition()) 385 + args = append(args, filter.Arg()...) 386 + } 387 + 388 + whereClause := "" 389 + if conditions != nil { 390 + whereClause = " where " + strings.Join(conditions, " and ") 391 + } 356 392 357 393 query := fmt.Sprintf(` 358 - SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 - pull_commented, followed, pull_merged, issue_closed, email_notifications 360 - FROM notification_preferences 361 - WHERE %s 362 - `, userFilter.Condition()) 394 + select 395 + id, 396 + user_did, 397 + repo_starred, 398 + issue_created, 399 + issue_commented, 400 + pull_created, 401 + pull_commented, 402 + followed, 403 + user_mentioned, 404 + pull_merged, 405 + issue_closed, 406 + email_notifications 407 + from 408 + notification_preferences 409 + %s 410 + `, whereClause) 363 411 364 - var prefs models.NotificationPreferences 365 - err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 - &prefs.ID, 367 - &prefs.UserDid, 368 - &prefs.RepoStarred, 369 - &prefs.IssueCreated, 370 - &prefs.IssueCommented, 371 - &prefs.PullCreated, 372 - &prefs.PullCommented, 373 - &prefs.Followed, 374 - &prefs.PullMerged, 375 - &prefs.IssueClosed, 376 - &prefs.EmailNotifications, 377 - ) 378 - 412 + rows, err := e.Query(query, args...) 379 413 if err != nil { 380 - if err == sql.ErrNoRows { 381 - return &models.NotificationPreferences{ 382 - UserDid: userDID, 383 - RepoStarred: true, 384 - IssueCreated: true, 385 - IssueCommented: true, 386 - PullCreated: true, 387 - PullCommented: true, 388 - Followed: true, 389 - PullMerged: true, 390 - IssueClosed: true, 391 - EmailNotifications: false, 392 - }, nil 414 + return nil, err 415 + } 416 + defer rows.Close() 417 + 418 + for rows.Next() { 419 + var prefs models.NotificationPreferences 420 + if err := rows.Scan( 421 + &prefs.ID, 422 + &prefs.UserDid, 423 + &prefs.RepoStarred, 424 + &prefs.IssueCreated, 425 + &prefs.IssueCommented, 426 + &prefs.PullCreated, 427 + &prefs.PullCommented, 428 + &prefs.Followed, 429 + &prefs.UserMentioned, 430 + &prefs.PullMerged, 431 + &prefs.IssueClosed, 432 + &prefs.EmailNotifications, 433 + ); err != nil { 434 + return nil, err 393 435 } 394 - return nil, fmt.Errorf("failed to get notification preferences: %w", err) 436 + 437 + prefsMap[prefs.UserDid] = &prefs 438 + } 439 + 440 + if err := rows.Err(); err != nil { 441 + return nil, err 395 442 } 396 443 397 - return &prefs, nil 444 + return prefsMap, nil 398 445 } 399 446 400 447 func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 401 448 query := ` 402 449 INSERT OR REPLACE INTO notification_preferences 403 450 (user_did, repo_starred, issue_created, issue_commented, pull_created, 404 - pull_commented, followed, pull_merged, issue_closed, email_notifications) 405 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 451 + pull_commented, followed, user_mentioned, pull_merged, issue_closed, 452 + email_notifications) 453 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 406 454 ` 407 455 408 456 result, err := d.DB.ExecContext(ctx, query, ··· 413 461 prefs.PullCreated, 414 462 prefs.PullCommented, 415 463 prefs.Followed, 464 + prefs.UserMentioned, 416 465 prefs.PullMerged, 417 466 prefs.IssueClosed, 418 467 prefs.EmailNotifications,
+26 -6
appview/db/profile.go
··· 129 129 did, 130 130 description, 131 131 include_bluesky, 132 - location 132 + location, 133 + pronouns 133 134 ) 134 - values (?, ?, ?, ?)`, 135 + values (?, ?, ?, ?, ?)`, 135 136 profile.Did, 136 137 profile.Description, 137 138 includeBskyValue, 138 139 profile.Location, 140 + profile.Pronouns, 139 141 ) 140 142 141 143 if err != nil { ··· 216 218 did, 217 219 description, 218 220 include_bluesky, 219 - location 221 + location, 222 + pronouns 220 223 from 221 224 profile 222 225 %s`, ··· 231 234 for rows.Next() { 232 235 var profile models.Profile 233 236 var includeBluesky int 237 + var pronouns sql.Null[string] 234 238 235 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 239 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 236 240 if err != nil { 237 241 return nil, err 238 242 } 239 243 240 244 if includeBluesky != 0 { 241 245 profile.IncludeBluesky = true 246 + } 247 + 248 + if pronouns.Valid { 249 + profile.Pronouns = pronouns.V 242 250 } 243 251 244 252 profileMap[profile.Did] = &profile ··· 302 310 303 311 func GetProfile(e Execer, did string) (*models.Profile, error) { 304 312 var profile models.Profile 313 + var pronouns sql.Null[string] 314 + 305 315 profile.Did = did 306 316 307 317 includeBluesky := 0 318 + 308 319 err := e.QueryRow( 309 - `select description, include_bluesky, location from profile where did = ?`, 320 + `select description, include_bluesky, location, pronouns from profile where did = ?`, 310 321 did, 311 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 322 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 312 323 if err == sql.ErrNoRows { 313 324 profile := models.Profile{} 314 325 profile.Did = did ··· 321 332 322 333 if includeBluesky != 0 { 323 334 profile.IncludeBluesky = true 335 + } 336 + 337 + if pronouns.Valid { 338 + profile.Pronouns = pronouns.V 324 339 } 325 340 326 341 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 412 427 // ensure description is not too long 413 428 if len(profile.Location) > 40 { 414 429 return fmt.Errorf("Entered location is too long.") 430 + } 431 + 432 + // ensure pronouns are not too long 433 + if len(profile.Pronouns) > 40 { 434 + return fmt.Errorf("Entered pronouns are too long.") 415 435 } 416 436 417 437 // ensure links are in order
+86 -24
appview/db/pulls.go
··· 90 90 pull.ID = int(id) 91 91 92 92 _, err = tx.Exec(` 93 - insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 - values (?, ?, ?, ?) 95 - `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 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 96 return err 97 97 } 98 98 ··· 101 101 if err != nil { 102 102 return "", err 103 103 } 104 - return pull.PullAt(), err 104 + return pull.AtUri(), err 105 105 } 106 106 107 107 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 214 214 pull.ParentChangeId = parentChangeId.String 215 215 } 216 216 217 - pulls[pull.PullAt()] = &pull 217 + pulls[pull.AtUri()] = &pull 218 218 } 219 219 220 220 var pullAts []syntax.ATURI 221 221 for _, p := range pulls { 222 - pullAts = append(pullAts, p.PullAt()) 222 + pullAts = append(pullAts, p.AtUri()) 223 223 } 224 224 submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 225 if err != nil { ··· 281 281 return GetPullsWithLimit(e, 0, filters...) 282 282 } 283 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 + 284 345 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 346 pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 286 347 if err != nil { 287 348 return nil, err 288 349 } 289 - if pulls == nil { 350 + if len(pulls) == 0 { 290 351 return nil, sql.ErrNoRows 291 352 } 292 353 ··· 313 374 pull_at, 314 375 round_number, 315 376 patch, 377 + combined, 316 378 created, 317 379 source_rev 318 380 from ··· 332 394 333 395 for rows.Next() { 334 396 var submission models.PullSubmission 335 - var createdAt string 336 - var sourceRev sql.NullString 397 + var submissionCreatedStr string 398 + var submissionSourceRev, submissionCombined sql.NullString 337 399 err := rows.Scan( 338 400 &submission.ID, 339 401 &submission.PullAt, 340 402 &submission.RoundNumber, 341 403 &submission.Patch, 342 - &createdAt, 343 - &sourceRev, 404 + &submissionCombined, 405 + &submissionCreatedStr, 406 + &submissionSourceRev, 344 407 ) 345 408 if err != nil { 346 409 return nil, err 347 410 } 348 411 349 - createdTime, err := time.Parse(time.RFC3339, createdAt) 350 - if err != nil { 351 - return nil, err 412 + if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 413 + submission.Created = t 414 + } 415 + 416 + if submissionSourceRev.Valid { 417 + submission.SourceRev = submissionSourceRev.String 352 418 } 353 - submission.Created = createdTime 354 419 355 - if sourceRev.Valid { 356 - submission.SourceRev = sourceRev.String 420 + if submissionCombined.Valid { 421 + submission.Combined = submissionCombined.String 357 422 } 358 423 359 424 submissionMap[submission.ID] = &submission ··· 590 655 return err 591 656 } 592 657 593 - func ResubmitPull(e Execer, pull *models.Pull) error { 594 - newPatch := pull.LatestPatch() 595 - newSourceRev := pull.LatestSha() 596 - newRoundNumber := len(pull.Submissions) 658 + func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 597 659 _, err := e.Exec(` 598 - insert into pull_submissions (pull_at, round_number, patch, source_rev) 599 - values (?, ?, ?, ?) 600 - `, pull.PullAt(), newRoundNumber, newPatch, newSourceRev) 660 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 661 + values (?, ?, ?, ?, ?) 662 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 601 663 602 664 return err 603 665 }
+50 -12
appview/db/repos.go
··· 70 70 rkey, 71 71 created, 72 72 description, 73 + website, 74 + topics, 73 75 source, 74 76 spindle 75 77 from ··· 89 91 for rows.Next() { 90 92 var repo models.Repo 91 93 var createdAt string 92 - var description, source, spindle sql.NullString 94 + var description, website, topicStr, source, spindle sql.NullString 93 95 94 96 err := rows.Scan( 95 97 &repo.Id, ··· 99 101 &repo.Rkey, 100 102 &createdAt, 101 103 &description, 104 + &website, 105 + &topicStr, 102 106 &source, 103 107 &spindle, 104 108 ) ··· 111 115 } 112 116 if description.Valid { 113 117 repo.Description = description.String 118 + } 119 + if website.Valid { 120 + repo.Website = website.String 121 + } 122 + if topicStr.Valid { 123 + repo.Topics = strings.Fields(topicStr.String) 114 124 } 115 125 if source.Valid { 116 126 repo.Source = source.String ··· 356 366 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 367 var repo models.Repo 358 368 var nullableDescription sql.NullString 369 + var nullableWebsite sql.NullString 370 + var nullableTopicStr sql.NullString 359 371 360 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 372 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 361 373 362 374 var createdAt string 363 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 375 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 364 376 return nil, err 365 377 } 366 378 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 368 380 369 381 if nullableDescription.Valid { 370 382 repo.Description = nullableDescription.String 371 - } else { 372 - repo.Description = "" 383 + } 384 + if nullableWebsite.Valid { 385 + repo.Website = nullableWebsite.String 386 + } 387 + if nullableTopicStr.Valid { 388 + repo.Topics = strings.Fields(nullableTopicStr.String) 373 389 } 374 390 375 391 return &repo, nil 392 + } 393 + 394 + func PutRepo(tx *sql.Tx, repo models.Repo) error { 395 + _, err := tx.Exec( 396 + `update repos 397 + set knot = ?, description = ?, website = ?, topics = ? 398 + where did = ? and rkey = ? 399 + `, 400 + repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey, 401 + ) 402 + return err 376 403 } 377 404 378 405 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 406 _, err := tx.Exec( 380 407 `insert into repos 381 - (did, name, knot, rkey, at_uri, description, source) 382 - values (?, ?, ?, ?, ?, ?, ?)`, 383 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 408 + (did, name, knot, rkey, at_uri, description, website, topics, source) 409 + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 410 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, 384 411 ) 385 412 if err != nil { 386 413 return fmt.Errorf("failed to insert repo: %w", err) ··· 416 443 var repos []models.Repo 417 444 418 445 rows, err := e.Query( 419 - `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 446 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source 420 447 from repos r 421 448 left join collaborators c on r.at_uri = c.repo_at 422 449 where (r.did = ? or c.subject_did = ?) ··· 434 461 var repo models.Repo 435 462 var createdAt string 436 463 var nullableDescription sql.NullString 464 + var nullableWebsite sql.NullString 437 465 var nullableSource sql.NullString 438 466 439 - err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 467 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) 440 468 if err != nil { 441 469 return nil, err 442 470 } ··· 470 498 var repo models.Repo 471 499 var createdAt string 472 500 var nullableDescription sql.NullString 501 + var nullableWebsite sql.NullString 502 + var nullableTopicStr sql.NullString 473 503 var nullableSource sql.NullString 474 504 475 505 row := e.QueryRow( 476 - `select id, did, name, knot, rkey, description, created, source 506 + `select id, did, name, knot, rkey, description, website, topics, created, source 477 507 from repos 478 508 where did = ? and name = ? and source is not null and source != ''`, 479 509 did, name, 480 510 ) 481 511 482 - err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 512 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) 483 513 if err != nil { 484 514 return nil, err 485 515 } 486 516 487 517 if nullableDescription.Valid { 488 518 repo.Description = nullableDescription.String 519 + } 520 + 521 + if nullableWebsite.Valid { 522 + repo.Website = nullableWebsite.String 523 + } 524 + 525 + if nullableTopicStr.Valid { 526 + repo.Topics = strings.Fields(nullableTopicStr.String) 489 527 } 490 528 491 529 if nullableSource.Valid {
+4 -4
appview/dns/cloudflare.go
··· 30 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 31 } 32 32 33 - func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 - _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 + result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 35 Type: record.Type, 36 36 Name: record.Name, 37 37 Content: record.Content, ··· 39 39 Proxied: &record.Proxied, 40 40 }) 41 41 if err != nil { 42 - return fmt.Errorf("failed to create DNS record: %w", err) 42 + return "", fmt.Errorf("failed to create DNS record: %w", err) 43 43 } 44 - return nil 44 + return result.ID, nil 45 45 } 46 46 47 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+20
appview/indexer/base36/base36.go
··· 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 291 292 292 includeBluesky := record.Bluesky 293 293 294 + pronouns := "" 295 + if record.Pronouns != nil { 296 + pronouns = *record.Pronouns 297 + } 298 + 294 299 location := "" 295 300 if record.Location != nil { 296 301 location = *record.Location ··· 325 330 Links: links, 326 331 Stats: stats, 327 332 PinnedRepos: pinned, 333 + Pronouns: pronouns, 328 334 } 329 335 330 336 ddb, ok := i.Db.Execer.(*db.DB)
+69 -15
appview/issues/issues.go
··· 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/config" 21 21 "tangled.org/core/appview/db" 22 + issues_indexer "tangled.org/core/appview/indexer/issues" 22 23 "tangled.org/core/appview/models" 23 24 "tangled.org/core/appview/notify" 24 25 "tangled.org/core/appview/oauth" 25 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pages/markup" 26 28 "tangled.org/core/appview/pagination" 27 29 "tangled.org/core/appview/reporesolver" 28 30 "tangled.org/core/appview/validator" ··· 40 42 notifier notify.Notifier 41 43 logger *slog.Logger 42 44 validator *validator.Validator 45 + indexer *issues_indexer.Indexer 43 46 } 44 47 45 48 func New( ··· 51 54 config *config.Config, 52 55 notifier notify.Notifier, 53 56 validator *validator.Validator, 57 + indexer *issues_indexer.Indexer, 54 58 logger *slog.Logger, 55 59 ) *Issues { 56 60 return &Issues{ ··· 63 67 notifier: notifier, 64 68 logger: logger, 65 69 validator: validator, 70 + indexer: indexer, 66 71 } 67 72 } 68 73 ··· 259 264 return 260 265 } 261 266 267 + rp.notifier.DeleteIssue(r.Context(), issue) 268 + 262 269 // return to all issues page 263 270 rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 264 271 } ··· 299 306 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 300 307 return 301 308 } 309 + // change the issue state (this will pass down to the notifiers) 310 + issue.Open = false 302 311 303 312 // notify about the issue closure 304 - rp.notifier.NewIssueClosed(r.Context(), issue) 313 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 305 314 306 315 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 307 316 return ··· 347 356 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 348 357 return 349 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 + 350 365 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 351 366 return 352 367 } else { ··· 439 454 440 455 // notify about the new comment 441 456 comment.Id = commentId 442 - rp.notifier.NewIssueComment(r.Context(), &comment) 457 + 458 + rawMentions := markup.FindUserMentions(comment.Body) 459 + idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 460 + l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 461 + var mentions []syntax.DID 462 + for _, ident := range idents { 463 + if ident != nil && !ident.Handle.IsInvalidHandle() { 464 + mentions = append(mentions, ident.DID) 465 + } 466 + } 467 + rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 443 468 444 469 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 445 470 } ··· 770 795 isOpen = true 771 796 } 772 797 773 - page, ok := r.Context().Value("page").(pagination.Page) 774 - if !ok { 775 - l.Error("failed to get page") 776 - page = pagination.FirstPage() 777 - } 798 + page := pagination.FromContext(r.Context()) 778 799 779 800 user := rp.oauth.GetUser(r) 780 801 f, err := rp.repoResolver.Resolve(r) ··· 783 804 return 784 805 } 785 806 786 - openVal := 0 787 - if isOpen { 788 - openVal = 1 807 + keyword := params.Get("q") 808 + 809 + var ids []int64 810 + searchOpts := models.IssueSearchOptions{ 811 + Keyword: keyword, 812 + RepoAt: f.RepoAt().String(), 813 + IsOpen: isOpen, 814 + Page: page, 789 815 } 790 - issues, err := db.GetIssuesPaginated( 816 + if keyword != "" { 817 + res, err := rp.indexer.Search(r.Context(), searchOpts) 818 + if err != nil { 819 + l.Error("failed to search for issues", "err", err) 820 + return 821 + } 822 + ids = res.Hits 823 + l.Debug("searched issues with indexer", "count", len(ids)) 824 + } else { 825 + ids, err = db.GetIssueIDs(rp.db, searchOpts) 826 + if err != nil { 827 + l.Error("failed to search for issues", "err", err) 828 + return 829 + } 830 + l.Debug("indexed all issues from the db", "count", len(ids)) 831 + } 832 + 833 + issues, err := db.GetIssues( 791 834 rp.db, 792 - page, 793 - db.FilterEq("repo_at", f.RepoAt()), 794 - db.FilterEq("open", openVal), 835 + db.FilterIn("id", ids), 795 836 ) 796 837 if err != nil { 797 838 l.Error("failed to get issues", "err", err) ··· 821 862 Issues: issues, 822 863 LabelDefs: defs, 823 864 FilteringByOpen: isOpen, 865 + FilterQuery: keyword, 824 866 Page: page, 825 867 }) 826 868 } ··· 847 889 Rkey: tid.TID(), 848 890 Title: r.FormValue("title"), 849 891 Body: r.FormValue("body"), 892 + Open: true, 850 893 Did: user.Did, 851 894 Created: time.Now(), 895 + Repo: &f.Repo, 852 896 } 853 897 854 898 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 915 959 916 960 // everything is successful, do not rollback the atproto record 917 961 atUri = "" 918 - rp.notifier.NewIssue(r.Context(), issue) 962 + 963 + rawMentions := markup.FindUserMentions(issue.Body) 964 + idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 965 + l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 966 + var mentions []syntax.DID 967 + for _, ident := range idents { 968 + if ident != nil && !ident.Handle.IsInvalidHandle() { 969 + mentions = append(mentions, ident.DID) 970 + } 971 + } 972 + rp.notifier.NewIssue(r.Context(), issue, mentions) 919 973 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 920 974 return 921 975 }
+267
appview/issues/opengraph.go
··· 1 + package issues 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/ogcard" 15 + ) 16 + 17 + func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { 18 + width, height := ogcard.DefaultSize() 19 + mainCard, err := ogcard.NewCard(width, height) 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + // Split: content area (75%) and status/stats area (25%) 25 + contentCard, statsArea := mainCard.Split(false, 75) 26 + 27 + // Add padding to content 28 + contentCard.SetMargin(50) 29 + 30 + // Split content horizontally: main content (80%) and avatar area (20%) 31 + mainContent, avatarArea := contentCard.Split(true, 80) 32 + 33 + // Add margin to main content like repo card 34 + mainContent.SetMargin(10) 35 + 36 + // Use full main content area for repo name and title 37 + bounds := mainContent.Img.Bounds() 38 + startX := bounds.Min.X + mainContent.Margin 39 + startY := bounds.Min.Y + mainContent.Margin 40 + 41 + // Draw full repository name at top (owner/repo format) 42 + var repoOwner string 43 + owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 + if err != nil { 45 + repoOwner = repo.Did 46 + } else { 47 + repoOwner = "@" + owner.Handle.String() 48 + } 49 + 50 + fullRepoName := repoOwner + " / " + repo.Name 51 + if len(fullRepoName) > 60 { 52 + fullRepoName = fullRepoName[:60] + "…" 53 + } 54 + 55 + grayColor := color.RGBA{88, 96, 105, 255} 56 + err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 57 + if err != nil { 58 + return nil, err 59 + } 60 + 61 + // Draw issue title below repo name with wrapping 62 + titleY := startY + 60 63 + titleX := startX 64 + 65 + // Truncate title if too long 66 + issueTitle := issue.Title 67 + maxTitleLength := 80 68 + if len(issueTitle) > maxTitleLength { 69 + issueTitle = issueTitle[:maxTitleLength] + "…" 70 + } 71 + 72 + // Create a temporary card for the title area to enable wrapping 73 + titleBounds := mainContent.Img.Bounds() 74 + titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 75 + titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID 76 + 77 + titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 78 + titleCard := &ogcard.Card{ 79 + Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 80 + Font: mainContent.Font, 81 + Margin: 0, 82 + } 83 + 84 + // Draw wrapped title 85 + lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + // Calculate where title ends (number of lines * line height) 91 + lineHeight := 60 // Approximate line height for 54pt font 92 + titleEndY := titleY + (len(lines) * lineHeight) + 10 93 + 94 + // Draw issue ID in gray below the title 95 + issueIdText := fmt.Sprintf("#%d", issue.IssueId) 96 + err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + // Get issue author handle (needed for avatar and metadata) 102 + var authorHandle string 103 + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 104 + if err != nil { 105 + authorHandle = issue.Did 106 + } else { 107 + authorHandle = "@" + author.Handle.String() 108 + } 109 + 110 + // Draw avatar circle on the right side 111 + avatarBounds := avatarArea.Img.Bounds() 112 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 113 + if avatarSize > 220 { 114 + avatarSize = 220 115 + } 116 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 117 + avatarY := avatarBounds.Min.Y + 20 118 + 119 + // Get avatar URL for issue author 120 + avatarURL := rp.pages.AvatarUrl(authorHandle, "256") 121 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 122 + if err != nil { 123 + log.Printf("failed to draw avatar (non-fatal): %v", err) 124 + } 125 + 126 + // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 + statusCommentsArea, dollyArea := statsArea.Split(true, 80) 128 + 129 + // Draw status and comment count in status/comments area 130 + statsBounds := statusCommentsArea.Img.Bounds() 131 + statsX := statsBounds.Min.X + 60 // left padding 132 + statsY := statsBounds.Min.Y 133 + 134 + iconColor := color.RGBA{88, 96, 105, 255} 135 + iconSize := 36 136 + textSize := 36.0 137 + labelSize := 28.0 138 + iconBaselineOffset := int(textSize) / 2 139 + 140 + // Draw status (open/closed) with colored icon and text 141 + var statusIcon string 142 + var statusText string 143 + var statusBgColor color.RGBA 144 + 145 + if issue.Open { 146 + statusIcon = "circle-dot" 147 + statusText = "open" 148 + statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 + } else { 150 + statusIcon = "ban" 151 + statusText = "closed" 152 + statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 + } 154 + 155 + badgeIconSize := 36 156 + 157 + // Draw icon with status color (no background) 158 + err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 + if err != nil { 160 + log.Printf("failed to draw status icon: %v", err) 161 + } 162 + 163 + // Draw text with status color (no background) 164 + textX := statsX + badgeIconSize + 12 165 + badgeTextSize := 32.0 166 + err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left) 167 + if err != nil { 168 + log.Printf("failed to draw status text: %v", err) 169 + } 170 + 171 + statusTextWidth := len(statusText) * 20 172 + currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 + 174 + // Draw comment count 175 + err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 + if err != nil { 177 + log.Printf("failed to draw comment icon: %v", err) 178 + } 179 + 180 + currentX += iconSize + 15 181 + commentText := fmt.Sprintf("%d comments", commentCount) 182 + if commentCount == 1 { 183 + commentText = "1 comment" 184 + } 185 + err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 186 + if err != nil { 187 + log.Printf("failed to draw comment text: %v", err) 188 + } 189 + 190 + // Draw dolly logo on the right side 191 + dollyBounds := dollyArea.Img.Bounds() 192 + dollySize := 90 193 + dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 + dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 + dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 + err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 197 + if err != nil { 198 + log.Printf("dolly silhouette not available (this is ok): %v", err) 199 + } 200 + 201 + // Draw "opened by @author" and date at the bottom with more spacing 202 + labelY := statsY + iconSize + 30 203 + 204 + // Format the opened date 205 + openedDate := issue.Created.Format("Jan 2, 2006") 206 + metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 207 + 208 + err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 209 + if err != nil { 210 + log.Printf("failed to draw metadata: %v", err) 211 + } 212 + 213 + return mainCard, nil 214 + } 215 + 216 + func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 217 + f, err := rp.repoResolver.Resolve(r) 218 + if err != nil { 219 + log.Println("failed to get repo and knot", err) 220 + return 221 + } 222 + 223 + issue, ok := r.Context().Value("issue").(*models.Issue) 224 + if !ok { 225 + log.Println("issue not found in context") 226 + http.Error(w, "issue not found", http.StatusNotFound) 227 + return 228 + } 229 + 230 + // Get comment count 231 + commentCount := len(issue.Comments) 232 + 233 + // Get owner handle for avatar 234 + var ownerHandle string 235 + owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did) 236 + if err != nil { 237 + ownerHandle = f.Repo.Did 238 + } else { 239 + ownerHandle = "@" + owner.Handle.String() 240 + } 241 + 242 + card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle) 243 + if err != nil { 244 + log.Println("failed to draw issue summary card", err) 245 + http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) 246 + return 247 + } 248 + 249 + var imageBuffer bytes.Buffer 250 + err = png.Encode(&imageBuffer, card.Img) 251 + if err != nil { 252 + log.Println("failed to encode issue summary card", err) 253 + http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) 254 + return 255 + } 256 + 257 + imageBytes := imageBuffer.Bytes() 258 + 259 + w.Header().Set("Content-Type", "image/png") 260 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 261 + w.WriteHeader(http.StatusOK) 262 + _, err = w.Write(imageBytes) 263 + if err != nil { 264 + log.Println("failed to write issue summary card", err) 265 + return 266 + } 267 + }
+1
appview/issues/router.go
··· 16 16 r.Route("/{issue}", func(r chi.Router) { 17 17 r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 + r.Get("/opengraph", i.IssueOpenGraphSummary) 19 20 20 21 // authenticated routes 21 22 r.Group(func(r chi.Router) {
+9
appview/knots/knots.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 145 146 } 146 147 147 148 domain := r.FormValue("domain") 149 + // Strip protocol, trailing slashes, and whitespace 150 + // Rkey cannot contain slashes 151 + domain = strings.TrimSpace(domain) 152 + domain = strings.TrimPrefix(domain, "https://") 153 + domain = strings.TrimPrefix(domain, "http://") 154 + domain = strings.TrimSuffix(domain, "/") 148 155 if domain == "" { 149 156 k.Pages.Notice(w, noticeId, "Incomplete form.") 150 157 return ··· 526 533 } 527 534 528 535 member := r.FormValue("member") 536 + member = strings.TrimPrefix(member, "@") 529 537 if member == "" { 530 538 l.Error("empty member") 531 539 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 626 634 } 627 635 628 636 member := r.FormValue("member") 637 + member = strings.TrimPrefix(member, "@") 629 638 if member == "" { 630 639 l.Error("empty member") 631 640 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+1 -1
appview/labels/labels.go
··· 53 53 } 54 54 } 55 55 56 - func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 56 + func (l *Labels) Router() http.Handler { 57 57 r := chi.NewRouter() 58 58 59 59 r.Use(middleware.AuthMiddleware(l.oauth))
+11 -16
appview/middleware/middleware.go
··· 105 105 } 106 106 } 107 107 108 - ctx := context.WithValue(r.Context(), "page", page) 108 + ctx := pagination.IntoContext(r.Context(), page) 109 109 next.ServeHTTP(w, r.WithContext(ctx)) 110 110 }) 111 111 } ··· 180 180 return func(next http.Handler) http.Handler { 181 181 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 182 didOrHandle := chi.URLParam(req, "user") 183 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 184 + 183 185 if slices.Contains(excluded, didOrHandle) { 184 186 next.ServeHTTP(w, req) 185 187 return 186 188 } 187 - 188 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 189 190 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 191 if err != nil { ··· 206 206 return func(next http.Handler) http.Handler { 207 207 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 208 repoName := chi.URLParam(req, "repo") 209 + repoName = strings.TrimSuffix(repoName, ".git") 210 + 209 211 id, ok := req.Context().Value("resolvedId").(identity.Identity) 210 212 if !ok { 211 213 log.Println("malformed middleware") ··· 244 246 prId := chi.URLParam(r, "pull") 245 247 prIdInt, err := strconv.Atoi(prId) 246 248 if err != nil { 247 - http.Error(w, "bad pr id", http.StatusBadRequest) 248 249 log.Println("failed to parse pr id", err) 250 + mw.pages.Error404(w) 249 251 return 250 252 } 251 253 252 254 pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 253 255 if err != nil { 254 256 log.Println("failed to get pull and comments", err) 257 + mw.pages.Error404(w) 255 258 return 256 259 } 257 260 ··· 292 295 issueId, err := strconv.Atoi(issueIdStr) 293 296 if err != nil { 294 297 log.Println("failed to fully resolve issue ID", err) 295 - mw.pages.ErrorKnot404(w) 298 + mw.pages.Error404(w) 296 299 return 297 300 } 298 301 299 - issues, err := db.GetIssues( 300 - mw.db, 301 - db.FilterEq("repo_at", f.RepoAt()), 302 - db.FilterEq("issue_id", issueId), 303 - ) 302 + issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId) 304 303 if err != nil { 305 304 log.Println("failed to get issues", "err", err) 305 + mw.pages.Error404(w) 306 306 return 307 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 308 314 - ctx := context.WithValue(r.Context(), "issue", &issue) 309 + ctx := context.WithValue(r.Context(), "issue", issue) 315 310 next.ServeHTTP(w, r.WithContext(ctx)) 316 311 }) 317 312 }
+24
appview/models/issue.go
··· 54 54 Replies []*IssueComment 55 55 } 56 56 57 + func (it *CommentListItem) Participants() []syntax.DID { 58 + participantSet := make(map[syntax.DID]struct{}) 59 + participants := []syntax.DID{} 60 + 61 + addParticipant := func(did syntax.DID) { 62 + if _, exists := participantSet[did]; !exists { 63 + participantSet[did] = struct{}{} 64 + participants = append(participants, did) 65 + } 66 + } 67 + 68 + addParticipant(syntax.DID(it.Self.Did)) 69 + 70 + for _, c := range it.Replies { 71 + addParticipant(syntax.DID(c.Did)) 72 + } 73 + 74 + return participants 75 + } 76 + 57 77 func (i *Issue) CommentList() []CommentListItem { 58 78 // Create a map to quickly find comments by their aturi 59 79 toplevel := make(map[string]*CommentListItem) ··· 167 187 168 188 func (i *IssueComment) IsTopLevel() bool { 169 189 return i.ReplyTo == nil 190 + } 191 + 192 + func (i *IssueComment) IsReply() bool { 193 + return i.ReplyTo != nil 170 194 } 171 195 172 196 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+25 -43
appview/models/label.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "github.com/bluesky-social/indigo/xrpc" 16 16 "tangled.org/core/api/tangled" 17 - "tangled.org/core/consts" 18 17 "tangled.org/core/idresolver" 19 18 ) 20 19 ··· 461 460 return result 462 461 } 463 462 464 - 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 - ) 463 + func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 464 + var labelDefs []LabelDefinition 465 + ctx := context.Background() 471 466 472 - func DefaultLabelDefs() []string { 473 - return []string{ 474 - LabelWontfix, 475 - LabelDuplicate, 476 - LabelAssignee, 477 - LabelGoodFirstIssue, 478 - LabelDocumentation, 479 - } 480 - } 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 + } 481 475 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 - } 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 + } 494 480 495 - var labelDefs []LabelDefinition 481 + xrpcc := xrpc.Client{ 482 + Host: owner.PDSEndpoint(), 483 + } 496 484 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 485 record, err := atproto.RepoGetRecord( 504 - context.Background(), 505 - client, 486 + ctx, 487 + &xrpcc, 506 488 "", 507 - parsedUri.Collection().String(), 508 - parsedUri.Authority().String(), 509 - parsedUri.RecordKey().String(), 489 + atUri.Collection().String(), 490 + atUri.Authority().String(), 491 + atUri.RecordKey().String(), 510 492 ) 511 493 if err != nil { 512 494 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) ··· 526 508 } 527 509 528 510 labelDef, err := LabelDefinitionFromRecord( 529 - parsedUri.Authority().String(), 530 - parsedUri.RecordKey().String(), 511 + atUri.Authority().String(), 512 + atUri.RecordKey().String(), 531 513 labelRecord, 532 514 ) 533 515 if err != nil {
+60 -1
appview/models/notifications.go
··· 2 2 3 3 import ( 4 4 "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 5 7 ) 6 8 7 9 type NotificationType string ··· 15 17 NotificationTypeFollowed NotificationType = "followed" 16 18 NotificationTypePullMerged NotificationType = "pull_merged" 17 19 NotificationTypeIssueClosed NotificationType = "issue_closed" 20 + NotificationTypeIssueReopen NotificationType = "issue_reopen" 18 21 NotificationTypePullClosed NotificationType = "pull_closed" 22 + NotificationTypePullReopen NotificationType = "pull_reopen" 23 + NotificationTypeUserMentioned NotificationType = "user_mentioned" 19 24 ) 20 25 21 26 type Notification struct { ··· 45 50 return "message-square" 46 51 case NotificationTypeIssueClosed: 47 52 return "ban" 53 + case NotificationTypeIssueReopen: 54 + return "circle-dot" 48 55 case NotificationTypePullCreated: 49 56 return "git-pull-request-create" 50 57 case NotificationTypePullCommented: ··· 53 60 return "git-merge" 54 61 case NotificationTypePullClosed: 55 62 return "git-pull-request-closed" 63 + case NotificationTypePullReopen: 64 + return "git-pull-request-create" 56 65 case NotificationTypeFollowed: 57 66 return "user-plus" 67 + case NotificationTypeUserMentioned: 68 + return "at-sign" 58 69 default: 59 70 return "" 60 71 } ··· 69 80 70 81 type NotificationPreferences struct { 71 82 ID int64 72 - UserDid string 83 + UserDid syntax.DID 73 84 RepoStarred bool 74 85 IssueCreated bool 75 86 IssueCommented bool 76 87 PullCreated bool 77 88 PullCommented bool 78 89 Followed bool 90 + UserMentioned bool 79 91 PullMerged bool 80 92 IssueClosed bool 81 93 EmailNotifications bool 82 94 } 95 + 96 + func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool { 97 + switch t { 98 + case NotificationTypeRepoStarred: 99 + return prefs.RepoStarred 100 + case NotificationTypeIssueCreated: 101 + return prefs.IssueCreated 102 + case NotificationTypeIssueCommented: 103 + return prefs.IssueCommented 104 + case NotificationTypeIssueClosed: 105 + return prefs.IssueClosed 106 + case NotificationTypeIssueReopen: 107 + return prefs.IssueCreated // smae pref for now 108 + case NotificationTypePullCreated: 109 + return prefs.PullCreated 110 + case NotificationTypePullCommented: 111 + return prefs.PullCommented 112 + case NotificationTypePullMerged: 113 + return prefs.PullMerged 114 + case NotificationTypePullClosed: 115 + return prefs.PullMerged // same pref for now 116 + case NotificationTypePullReopen: 117 + return prefs.PullCreated // same pref for now 118 + case NotificationTypeFollowed: 119 + return prefs.Followed 120 + case NotificationTypeUserMentioned: 121 + return prefs.UserMentioned 122 + default: 123 + return false 124 + } 125 + } 126 + 127 + func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences { 128 + return &NotificationPreferences{ 129 + UserDid: user, 130 + RepoStarred: true, 131 + IssueCreated: true, 132 + IssueCommented: true, 133 + PullCreated: true, 134 + PullCommented: true, 135 + Followed: true, 136 + UserMentioned: true, 137 + PullMerged: true, 138 + IssueClosed: true, 139 + EmailNotifications: false, 140 + } 141 + }
+1
appview/models/profile.go
··· 19 19 Links [5]string 20 20 Stats [2]VanityStat 21 21 PinnedRepos [6]syntax.ATURI 22 + Pronouns string 22 23 } 23 24 24 25 func (p Profile) IsLinksEmpty() bool {
+21 -10
appview/models/pull.go
··· 88 88 source.Branch = p.PullSource.Branch 89 89 source.Sha = p.LatestSha() 90 90 if p.PullSource.RepoAt != nil { 91 - s := p.PullSource.Repo.RepoAt().String() 91 + s := p.PullSource.RepoAt.String() 92 92 source.Repo = &s 93 93 } 94 94 } ··· 125 125 // content 126 126 RoundNumber int 127 127 Patch string 128 + Combined string 128 129 Comments []PullComment 129 130 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 130 131 ··· 150 151 Created time.Time 151 152 } 152 153 154 + func (p *Pull) LastRoundNumber() int { 155 + return len(p.Submissions) - 1 156 + } 157 + 158 + func (p *Pull) LatestSubmission() *PullSubmission { 159 + return p.Submissions[p.LastRoundNumber()] 160 + } 161 + 153 162 func (p *Pull) LatestPatch() string { 154 - latestSubmission := p.Submissions[p.LastRoundNumber()] 155 - return latestSubmission.Patch 163 + return p.LatestSubmission().Patch 156 164 } 157 165 158 166 func (p *Pull) LatestSha() string { 159 - latestSubmission := p.Submissions[p.LastRoundNumber()] 160 - return latestSubmission.SourceRev 167 + return p.LatestSubmission().SourceRev 161 168 } 162 169 163 - func (p *Pull) PullAt() syntax.ATURI { 170 + func (p *Pull) AtUri() syntax.ATURI { 164 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 165 - } 166 - 167 - func (p *Pull) LastRoundNumber() int { 168 - return len(p.Submissions) - 1 169 172 } 170 173 171 174 func (p *Pull) IsPatchBased() bool { ··· 252 255 } 253 256 254 257 return participants 258 + } 259 + 260 + func (s PullSubmission) CombinedPatch() string { 261 + if s.Combined == "" { 262 + return s.Patch 263 + } 264 + 265 + return s.Combined 255 266 } 256 267 257 268 type Stack []*Pull
+61 -1
appview/models/repo.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strings" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 17 18 Rkey string 18 19 Created time.Time 19 20 Description string 21 + Website string 22 + Topics []string 20 23 Spindle string 21 24 Labels []string 22 25 ··· 28 31 } 29 32 30 33 func (r *Repo) AsRecord() tangled.Repo { 31 - var source, spindle, description *string 34 + var source, spindle, description, website *string 32 35 33 36 if r.Source != "" { 34 37 source = &r.Source ··· 42 45 description = &r.Description 43 46 } 44 47 48 + if r.Website != "" { 49 + website = &r.Website 50 + } 51 + 45 52 return tangled.Repo{ 46 53 Knot: r.Knot, 47 54 Name: r.Name, 48 55 Description: description, 56 + Website: website, 57 + Topics: r.Topics, 49 58 CreatedAt: r.Created.Format(time.RFC3339), 50 59 Source: source, 51 60 Spindle: spindle, ··· 60 69 func (r Repo) DidSlashRepo() string { 61 70 p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 71 return p 72 + } 73 + 74 + func (r Repo) TopicStr() string { 75 + return strings.Join(r.Topics, " ") 63 76 } 64 77 65 78 type RepoStats struct { ··· 91 104 Repo *Repo 92 105 Issues []Issue 93 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 + // }
+5 -9
appview/notifications/notifications.go
··· 49 49 l := n.logger.With("handler", "notificationsPage") 50 50 user := n.oauth.GetUser(r) 51 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 - } 52 + page := pagination.FromContext(r.Context()) 57 53 58 54 total, err := db.CountNotifications( 59 55 n.db, ··· 76 72 return 77 73 } 78 74 79 - err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 75 + err = db.MarkAllNotificationsRead(n.db, user.Did) 80 76 if err != nil { 81 77 l.Error("failed to mark notifications as read", "err", err) 82 78 } ··· 128 124 return 129 125 } 130 126 131 - err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 127 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 132 128 if err != nil { 133 129 http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 134 130 return ··· 140 136 func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 141 137 userDid := n.oauth.GetDid(r) 142 138 143 - err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 139 + err := db.MarkAllNotificationsRead(n.db, userDid) 144 140 if err != nil { 145 141 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 146 142 return ··· 159 155 return 160 156 } 161 157 162 - err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 158 + err = db.DeleteNotification(n.db, notificationID, userDid) 163 159 if err != nil { 164 160 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 165 161 return
+320 -260
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 + "maps" 7 + "slices" 6 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 7 10 "tangled.org/core/appview/db" 8 11 "tangled.org/core/appview/models" 9 12 "tangled.org/core/appview/notify" 10 13 "tangled.org/core/idresolver" 14 + ) 15 + 16 + const ( 17 + maxMentions = 5 11 18 ) 12 19 13 20 type databaseNotifier struct { ··· 36 43 return 37 44 } 38 45 39 - // don't notify yourself 40 - if repo.Did == star.StarredByDid { 41 - return 42 - } 46 + actorDid := syntax.DID(star.StarredByDid) 47 + recipients := []syntax.DID{syntax.DID(repo.Did)} 48 + eventType := models.NotificationTypeRepoStarred 49 + entityType := "repo" 50 + entityId := star.RepoAt.String() 51 + repoId := &repo.Id 52 + var issueId *int64 53 + var pullId *int64 43 54 44 - // check if user wants these notifications 45 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 - if err != nil { 47 - log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 - return 49 - } 50 - if !prefs.RepoStarred { 51 - return 52 - } 53 - 54 - notification := &models.Notification{ 55 - RecipientDid: repo.Did, 56 - ActorDid: star.StarredByDid, 57 - Type: models.NotificationTypeRepoStarred, 58 - EntityType: "repo", 59 - EntityId: string(star.RepoAt), 60 - RepoId: &repo.Id, 61 - } 62 - err = n.db.CreateNotification(ctx, notification) 63 - if err != nil { 64 - log.Printf("NewStar: failed to create notification: %v", err) 65 - return 66 - } 55 + n.notifyEvent( 56 + actorDid, 57 + recipients, 58 + eventType, 59 + entityType, 60 + entityId, 61 + repoId, 62 + issueId, 63 + pullId, 64 + ) 67 65 } 68 66 69 67 func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 68 // no-op 71 69 } 72 70 73 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 - if err != nil { 76 - log.Printf("NewIssue: failed to get repos: %v", err) 77 - return 78 - } 71 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 79 72 80 - if repo.Did == issue.Did { 81 - return 82 - } 83 - 84 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 73 + // build the recipients list 74 + // - owner of the repo 75 + // - collaborators in the repo 76 + var recipients []syntax.DID 77 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 78 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 85 79 if err != nil { 86 - log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 80 + log.Printf("failed to fetch collaborators: %v", err) 87 81 return 88 82 } 89 - if !prefs.IssueCreated { 90 - return 83 + for _, c := range collaborators { 84 + recipients = append(recipients, c.SubjectDid) 91 85 } 92 86 93 - notification := &models.Notification{ 94 - RecipientDid: repo.Did, 95 - ActorDid: issue.Did, 96 - Type: models.NotificationTypeIssueCreated, 97 - EntityType: "issue", 98 - EntityId: string(issue.AtUri()), 99 - RepoId: &repo.Id, 100 - IssueId: &issue.Id, 101 - } 87 + actorDid := syntax.DID(issue.Did) 88 + entityType := "issue" 89 + entityId := issue.AtUri().String() 90 + repoId := &issue.Repo.Id 91 + issueId := &issue.Id 92 + var pullId *int64 102 93 103 - err = n.db.CreateNotification(ctx, notification) 104 - if err != nil { 105 - log.Printf("NewIssue: failed to create notification: %v", err) 106 - return 107 - } 94 + n.notifyEvent( 95 + actorDid, 96 + recipients, 97 + models.NotificationTypeIssueCreated, 98 + entityType, 99 + entityId, 100 + repoId, 101 + issueId, 102 + pullId, 103 + ) 104 + n.notifyEvent( 105 + actorDid, 106 + mentions, 107 + models.NotificationTypeUserMentioned, 108 + entityType, 109 + entityId, 110 + repoId, 111 + issueId, 112 + pullId, 113 + ) 108 114 } 109 115 110 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 116 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 111 117 issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 112 118 if err != nil { 113 119 log.Printf("NewIssueComment: failed to get issues: %v", err) ··· 119 125 } 120 126 issue := issues[0] 121 127 122 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 - if err != nil { 124 - log.Printf("NewIssueComment: failed to get repos: %v", err) 125 - return 126 - } 128 + var recipients []syntax.DID 129 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 127 130 128 - recipients := make(map[string]bool) 131 + if comment.IsReply() { 132 + // if this comment is a reply, then notify everybody in that thread 133 + parentAtUri := *comment.ReplyTo 134 + allThreads := issue.CommentList() 129 135 130 - // notify issue author (if not the commenter) 131 - if issue.Did != comment.Did { 132 - prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 - if err == nil && prefs.IssueCommented { 134 - recipients[issue.Did] = true 135 - } else if err != nil { 136 - log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 136 + // find the parent thread, and add all DIDs from here to the recipient list 137 + for _, t := range allThreads { 138 + if t.Self.AtUri().String() == parentAtUri { 139 + recipients = append(recipients, t.Participants()...) 140 + } 137 141 } 142 + } else { 143 + // not a reply, notify just the issue author 144 + recipients = append(recipients, syntax.DID(issue.Did)) 138 145 } 139 146 140 - // notify repo owner (if not the commenter and not already added) 141 - if repo.Did != comment.Did && repo.Did != issue.Did { 142 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 - if err == nil && prefs.IssueCommented { 144 - recipients[repo.Did] = true 145 - } else if err != nil { 146 - log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 - } 148 - } 147 + actorDid := syntax.DID(comment.Did) 148 + entityType := "issue" 149 + entityId := issue.AtUri().String() 150 + repoId := &issue.Repo.Id 151 + issueId := &issue.Id 152 + var pullId *int64 149 153 150 - // create notifications for all recipients 151 - for recipientDid := range recipients { 152 - notification := &models.Notification{ 153 - RecipientDid: recipientDid, 154 - ActorDid: comment.Did, 155 - Type: models.NotificationTypeIssueCommented, 156 - EntityType: "issue", 157 - EntityId: string(issue.AtUri()), 158 - RepoId: &repo.Id, 159 - IssueId: &issue.Id, 160 - } 154 + n.notifyEvent( 155 + actorDid, 156 + recipients, 157 + models.NotificationTypeIssueCommented, 158 + entityType, 159 + entityId, 160 + repoId, 161 + issueId, 162 + pullId, 163 + ) 164 + n.notifyEvent( 165 + actorDid, 166 + mentions, 167 + models.NotificationTypeUserMentioned, 168 + entityType, 169 + entityId, 170 + repoId, 171 + issueId, 172 + pullId, 173 + ) 174 + } 161 175 162 - err = n.db.CreateNotification(ctx, notification) 163 - if err != nil { 164 - log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 - } 166 - } 176 + func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 177 + // no-op for now 167 178 } 168 179 169 180 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 - prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 - if err != nil { 172 - log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 - return 174 - } 175 - if !prefs.Followed { 176 - return 177 - } 181 + actorDid := syntax.DID(follow.UserDid) 182 + recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 183 + eventType := models.NotificationTypeFollowed 184 + entityType := "follow" 185 + entityId := follow.UserDid 186 + var repoId, issueId, pullId *int64 178 187 179 - notification := &models.Notification{ 180 - RecipientDid: follow.SubjectDid, 181 - ActorDid: follow.UserDid, 182 - Type: models.NotificationTypeFollowed, 183 - EntityType: "follow", 184 - EntityId: follow.UserDid, 185 - } 186 - 187 - err = n.db.CreateNotification(ctx, notification) 188 - if err != nil { 189 - log.Printf("NewFollow: failed to create notification: %v", err) 190 - return 191 - } 188 + n.notifyEvent( 189 + actorDid, 190 + recipients, 191 + eventType, 192 + entityType, 193 + entityId, 194 + repoId, 195 + issueId, 196 + pullId, 197 + ) 192 198 } 193 199 194 200 func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { ··· 202 208 return 203 209 } 204 210 205 - if repo.Did == pull.OwnerDid { 206 - return 207 - } 208 - 209 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 211 + // build the recipients list 212 + // - owner of the repo 213 + // - collaborators in the repo 214 + var recipients []syntax.DID 215 + recipients = append(recipients, syntax.DID(repo.Did)) 216 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 210 217 if err != nil { 211 - log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 218 + log.Printf("failed to fetch collaborators: %v", err) 212 219 return 213 220 } 214 - if !prefs.PullCreated { 215 - return 221 + for _, c := range collaborators { 222 + recipients = append(recipients, c.SubjectDid) 216 223 } 217 224 218 - notification := &models.Notification{ 219 - RecipientDid: repo.Did, 220 - ActorDid: pull.OwnerDid, 221 - Type: models.NotificationTypePullCreated, 222 - EntityType: "pull", 223 - EntityId: string(pull.RepoAt), 224 - RepoId: &repo.Id, 225 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 - } 225 + actorDid := syntax.DID(pull.OwnerDid) 226 + eventType := models.NotificationTypePullCreated 227 + entityType := "pull" 228 + entityId := pull.AtUri().String() 229 + repoId := &repo.Id 230 + var issueId *int64 231 + p := int64(pull.ID) 232 + pullId := &p 227 233 228 - err = n.db.CreateNotification(ctx, notification) 229 - if err != nil { 230 - log.Printf("NewPull: failed to create notification: %v", err) 231 - return 232 - } 234 + n.notifyEvent( 235 + actorDid, 236 + recipients, 237 + eventType, 238 + entityType, 239 + entityId, 240 + repoId, 241 + issueId, 242 + pullId, 243 + ) 233 244 } 234 245 235 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 - pulls, err := db.GetPulls(n.db, 237 - db.FilterEq("repo_at", comment.RepoAt), 238 - db.FilterEq("pull_id", comment.PullId)) 246 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 247 + pull, err := db.GetPull(n.db, 248 + syntax.ATURI(comment.RepoAt), 249 + comment.PullId, 250 + ) 239 251 if err != nil { 240 252 log.Printf("NewPullComment: failed to get pulls: %v", err) 241 253 return 242 254 } 243 - if len(pulls) == 0 { 244 - log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 - return 246 - } 247 - pull := pulls[0] 248 255 249 256 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 257 if err != nil { ··· 252 259 return 253 260 } 254 261 255 - recipients := make(map[string]bool) 256 - 257 - // notify pull request author (if not the commenter) 258 - if pull.OwnerDid != comment.OwnerDid { 259 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 - if err == nil && prefs.PullCommented { 261 - recipients[pull.OwnerDid] = true 262 - } else if err != nil { 263 - log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 - } 262 + // build up the recipients list: 263 + // - repo owner 264 + // - all pull participants 265 + var recipients []syntax.DID 266 + recipients = append(recipients, syntax.DID(repo.Did)) 267 + for _, p := range pull.Participants() { 268 + recipients = append(recipients, syntax.DID(p)) 265 269 } 266 270 267 - // notify repo owner (if not the commenter and not already added) 268 - if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 - if err == nil && prefs.PullCommented { 271 - recipients[repo.Did] = true 272 - } else if err != nil { 273 - log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 - } 275 - } 271 + actorDid := syntax.DID(comment.OwnerDid) 272 + eventType := models.NotificationTypePullCommented 273 + entityType := "pull" 274 + entityId := pull.AtUri().String() 275 + repoId := &repo.Id 276 + var issueId *int64 277 + p := int64(pull.ID) 278 + pullId := &p 276 279 277 - for recipientDid := range recipients { 278 - notification := &models.Notification{ 279 - RecipientDid: recipientDid, 280 - ActorDid: comment.OwnerDid, 281 - Type: models.NotificationTypePullCommented, 282 - EntityType: "pull", 283 - EntityId: comment.RepoAt, 284 - RepoId: &repo.Id, 285 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 - } 287 - 288 - err = n.db.CreateNotification(ctx, notification) 289 - if err != nil { 290 - log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 - } 292 - } 280 + n.notifyEvent( 281 + actorDid, 282 + recipients, 283 + eventType, 284 + entityType, 285 + entityId, 286 + repoId, 287 + issueId, 288 + pullId, 289 + ) 290 + n.notifyEvent( 291 + actorDid, 292 + mentions, 293 + models.NotificationTypeUserMentioned, 294 + entityType, 295 + entityId, 296 + repoId, 297 + issueId, 298 + pullId, 299 + ) 293 300 } 294 301 295 302 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 308 315 // no-op 309 316 } 310 317 311 - func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 - // Get repo details 313 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 318 + func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 319 + // build up the recipients list: 320 + // - repo owner 321 + // - repo collaborators 322 + // - all issue participants 323 + var recipients []syntax.DID 324 + recipients = append(recipients, syntax.DID(issue.Repo.Did)) 325 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 314 326 if err != nil { 315 - log.Printf("NewIssueClosed: failed to get repos: %v", err) 327 + log.Printf("failed to fetch collaborators: %v", err) 316 328 return 317 329 } 318 - 319 - // Don't notify yourself 320 - if repo.Did == issue.Did { 321 - return 322 - } 323 - 324 - // Check if user wants these notifications 325 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 - if err != nil { 327 - log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 - return 330 + for _, c := range collaborators { 331 + recipients = append(recipients, c.SubjectDid) 329 332 } 330 - if !prefs.IssueClosed { 331 - return 333 + for _, p := range issue.Participants() { 334 + recipients = append(recipients, syntax.DID(p)) 332 335 } 333 336 334 - notification := &models.Notification{ 335 - RecipientDid: repo.Did, 336 - ActorDid: issue.Did, 337 - Type: models.NotificationTypeIssueClosed, 338 - EntityType: "issue", 339 - EntityId: string(issue.AtUri()), 340 - RepoId: &repo.Id, 341 - IssueId: &issue.Id, 342 - } 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 343 344 - err = n.db.CreateNotification(ctx, notification) 345 - if err != nil { 346 - log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 - return 344 + if issue.Open { 345 + eventType = models.NotificationTypeIssueReopen 346 + } else { 347 + eventType = models.NotificationTypeIssueClosed 348 348 } 349 + 350 + n.notifyEvent( 351 + actor, 352 + recipients, 353 + eventType, 354 + entityType, 355 + entityId, 356 + repoId, 357 + issueId, 358 + pullId, 359 + ) 349 360 } 350 361 351 - func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 362 + func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 352 363 // Get repo details 353 364 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 365 if err != nil { 355 - log.Printf("NewPullMerged: failed to get repos: %v", err) 366 + log.Printf("NewPullState: failed to get repos: %v", err) 356 367 return 357 368 } 358 369 359 - // Don't notify yourself 360 - if repo.Did == pull.OwnerDid { 361 - return 362 - } 363 - 364 - // Check if user wants these notifications 365 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 370 + // build up the recipients list: 371 + // - repo owner 372 + // - all pull participants 373 + var recipients []syntax.DID 374 + recipients = append(recipients, syntax.DID(repo.Did)) 375 + collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 366 376 if err != nil { 367 - log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 377 + log.Printf("failed to fetch collaborators: %v", err) 368 378 return 369 379 } 370 - if !prefs.PullMerged { 371 - return 380 + for _, c := range collaborators { 381 + recipients = append(recipients, c.SubjectDid) 372 382 } 373 - 374 - notification := &models.Notification{ 375 - RecipientDid: pull.OwnerDid, 376 - ActorDid: repo.Did, 377 - Type: models.NotificationTypePullMerged, 378 - EntityType: "pull", 379 - EntityId: string(pull.RepoAt), 380 - RepoId: &repo.Id, 381 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 383 + for _, p := range pull.Participants() { 384 + recipients = append(recipients, syntax.DID(p)) 382 385 } 383 386 384 - err = n.db.CreateNotification(ctx, notification) 385 - if err != nil { 386 - log.Printf("NewPullMerged: failed to create notification: %v", err) 387 + entityType := "pull" 388 + entityId := pull.AtUri().String() 389 + repoId := &repo.Id 390 + var issueId *int64 391 + var eventType models.NotificationType 392 + switch pull.State { 393 + case models.PullClosed: 394 + eventType = models.NotificationTypePullClosed 395 + case models.PullOpen: 396 + eventType = models.NotificationTypePullReopen 397 + case models.PullMerged: 398 + eventType = models.NotificationTypePullMerged 399 + default: 400 + log.Println("NewPullState: unexpected new PR state:", pull.State) 387 401 return 388 402 } 403 + p := int64(pull.ID) 404 + pullId := &p 405 + 406 + n.notifyEvent( 407 + actor, 408 + recipients, 409 + eventType, 410 + entityType, 411 + entityId, 412 + repoId, 413 + issueId, 414 + pullId, 415 + ) 389 416 } 390 417 391 - func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 - // Get repo details 393 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 - if err != nil { 395 - log.Printf("NewPullClosed: failed to get repos: %v", err) 396 - return 418 + func (n *databaseNotifier) notifyEvent( 419 + actorDid syntax.DID, 420 + recipients []syntax.DID, 421 + eventType models.NotificationType, 422 + entityType string, 423 + entityId string, 424 + repoId *int64, 425 + issueId *int64, 426 + pullId *int64, 427 + ) { 428 + if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 429 + recipients = recipients[:maxMentions] 397 430 } 398 - 399 - // Don't notify yourself 400 - if repo.Did == pull.OwnerDid { 401 - return 431 + recipientSet := make(map[syntax.DID]struct{}) 432 + for _, did := range recipients { 433 + // everybody except actor themselves 434 + if did != actorDid { 435 + recipientSet[did] = struct{}{} 436 + } 402 437 } 403 438 404 - // Check if user wants these notifications - reuse pull_merged preference for now 405 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 439 + prefMap, err := db.GetNotificationPreferences( 440 + n.db, 441 + db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 442 + ) 406 443 if err != nil { 407 - log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 444 + // failed to get prefs for users 408 445 return 409 446 } 410 - if !prefs.PullMerged { 447 + 448 + // create a transaction for bulk notification storage 449 + tx, err := n.db.Begin() 450 + if err != nil { 451 + // failed to start tx 411 452 return 412 453 } 454 + defer tx.Rollback() 413 455 414 - notification := &models.Notification{ 415 - RecipientDid: pull.OwnerDid, 416 - ActorDid: repo.Did, 417 - Type: models.NotificationTypePullClosed, 418 - EntityType: "pull", 419 - EntityId: string(pull.RepoAt), 420 - RepoId: &repo.Id, 421 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 456 + // filter based on preferences 457 + for recipientDid := range recipientSet { 458 + prefs, ok := prefMap[recipientDid] 459 + if !ok { 460 + prefs = models.DefaultNotificationPreferences(recipientDid) 461 + } 462 + 463 + // skip users who don’t want this type 464 + if !prefs.ShouldNotify(eventType) { 465 + continue 466 + } 467 + 468 + // create notification 469 + notif := &models.Notification{ 470 + RecipientDid: recipientDid.String(), 471 + ActorDid: actorDid.String(), 472 + Type: eventType, 473 + EntityType: entityType, 474 + EntityId: entityId, 475 + RepoId: repoId, 476 + IssueId: issueId, 477 + PullId: pullId, 478 + } 479 + 480 + if err := db.CreateNotification(tx, notif); err != nil { 481 + log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err) 482 + } 422 483 } 423 484 424 - err = n.db.CreateNotification(ctx, notification) 425 - if err != nil { 426 - log.Printf("NewPullClosed: failed to create notification: %v", err) 485 + if err := tx.Commit(); err != nil { 486 + // failed to commit 427 487 return 428 488 } 429 489 }
+57 -59
appview/notify/merged_notifier.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 6 + "reflect" 7 + "sync" 5 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 6 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/log" 7 12 ) 8 13 9 14 type mergedNotifier struct { 10 15 notifiers []Notifier 16 + logger *slog.Logger 11 17 } 12 18 13 - func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 - return &mergedNotifier{notifiers} 19 + func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier { 20 + return &mergedNotifier{notifiers, logger} 15 21 } 16 22 17 23 var _ Notifier = &mergedNotifier{} 18 24 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 25 + // fanout calls the same method on all notifiers concurrently 26 + func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) { 27 + ctx = log.IntoContext(ctx, m.logger.With("method", method)) 28 + var wg sync.WaitGroup 29 + for _, n := range m.notifiers { 30 + wg.Add(1) 31 + go func(notifier Notifier) { 32 + defer wg.Done() 33 + v := reflect.ValueOf(notifier).MethodByName(method) 34 + in := make([]reflect.Value, len(args)+1) 35 + in[0] = reflect.ValueOf(ctx) 36 + for i, arg := range args { 37 + in[i+1] = reflect.ValueOf(arg) 38 + } 39 + v.Call(in) 40 + }(n) 22 41 } 42 + wg.Wait() 43 + } 44 + 45 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 46 + m.fanout("NewRepo", ctx, repo) 23 47 } 24 48 25 49 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 50 + m.fanout("NewStar", ctx, star) 29 51 } 52 + 30 53 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 54 + m.fanout("DeleteStar", ctx, star) 34 55 } 35 56 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 57 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 58 + m.fanout("NewIssue", ctx, issue, mentions) 40 59 } 41 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 - for _, notifier := range m.notifiers { 43 - notifier.NewIssueComment(ctx, comment) 44 - } 60 + 61 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 62 + m.fanout("NewIssueComment", ctx, comment, mentions) 45 63 } 46 64 47 - func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 - for _, notifier := range m.notifiers { 49 - notifier.NewIssueClosed(ctx, issue) 50 - } 65 + func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 66 + m.fanout("NewIssueState", ctx, actor, issue) 67 + } 68 + 69 + func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 70 + m.fanout("DeleteIssue", ctx, issue) 51 71 } 52 72 53 73 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewFollow(ctx, follow) 56 - } 74 + m.fanout("NewFollow", ctx, follow) 57 75 } 76 + 58 77 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 59 - for _, notifier := range m.notifiers { 60 - notifier.DeleteFollow(ctx, follow) 61 - } 78 + m.fanout("DeleteFollow", ctx, follow) 62 79 } 63 80 64 81 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 65 - for _, notifier := range m.notifiers { 66 - notifier.NewPull(ctx, pull) 67 - } 68 - } 69 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 70 - for _, notifier := range m.notifiers { 71 - notifier.NewPullComment(ctx, comment) 72 - } 82 + m.fanout("NewPull", ctx, pull) 73 83 } 74 84 75 - func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 - for _, notifier := range m.notifiers { 77 - notifier.NewPullMerged(ctx, pull) 78 - } 85 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 86 + m.fanout("NewPullComment", ctx, comment, mentions) 79 87 } 80 88 81 - func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 - for _, notifier := range m.notifiers { 83 - notifier.NewPullClosed(ctx, pull) 84 - } 89 + func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 90 + m.fanout("NewPullState", ctx, actor, pull) 85 91 } 86 92 87 93 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 - for _, notifier := range m.notifiers { 89 - notifier.UpdateProfile(ctx, profile) 90 - } 94 + m.fanout("UpdateProfile", ctx, profile) 91 95 } 92 96 93 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 - for _, notifier := range m.notifiers { 95 - notifier.NewString(ctx, string) 96 - } 97 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 98 + m.fanout("NewString", ctx, s) 97 99 } 98 100 99 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 - for _, notifier := range m.notifiers { 101 - notifier.EditString(ctx, string) 102 - } 101 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 102 + m.fanout("EditString", ctx, s) 103 103 } 104 104 105 105 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 - for _, notifier := range m.notifiers { 107 - notifier.DeleteString(ctx, did, rkey) 108 - } 106 + m.fanout("DeleteString", ctx, did, rkey) 109 107 }
+16 -13
appview/notify/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 + "github.com/bluesky-social/indigo/atproto/syntax" 6 7 "tangled.org/core/appview/models" 7 8 ) 8 9 ··· 12 13 NewStar(ctx context.Context, star *models.Star) 13 14 DeleteStar(ctx context.Context, star *models.Star) 14 15 15 - NewIssue(ctx context.Context, issue *models.Issue) 16 - NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 - NewIssueClosed(ctx context.Context, issue *models.Issue) 16 + NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 + NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 + NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 + DeleteIssue(ctx context.Context, issue *models.Issue) 18 20 19 21 NewFollow(ctx context.Context, follow *models.Follow) 20 22 DeleteFollow(ctx context.Context, follow *models.Follow) 21 23 22 24 NewPull(ctx context.Context, pull *models.Pull) 23 - NewPullComment(ctx context.Context, comment *models.PullComment) 24 - NewPullMerged(ctx context.Context, pull *models.Pull) 25 - NewPullClosed(ctx context.Context, pull *models.Pull) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 + NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 26 27 27 28 UpdateProfile(ctx context.Context, profile *models.Profile) 28 29 ··· 41 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 43 44 44 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 - func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 + } 48 + func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 + func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 47 50 48 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 50 53 51 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 - func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 - func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 55 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 56 + } 57 + func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 55 58 56 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 57 60
+33 -9
appview/notify/posthog/notifier.go
··· 4 4 "context" 5 5 "log" 6 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 7 8 "github.com/posthog/posthog-go" 8 9 "tangled.org/core/appview/models" 9 10 "tangled.org/core/appview/notify" ··· 56 57 } 57 58 } 58 59 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 60 61 err := n.client.Enqueue(posthog.Capture{ 61 62 DistinctId: issue.Did, 62 63 Event: "new_issue", 63 64 Properties: posthog.Properties{ 64 65 "repo_at": issue.RepoAt.String(), 65 66 "issue_id": issue.IssueId, 67 + "mentions": mentions, 66 68 }, 67 69 }) 68 70 if err != nil { ··· 84 86 } 85 87 } 86 88 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 88 90 err := n.client.Enqueue(posthog.Capture{ 89 91 DistinctId: comment.OwnerDid, 90 92 Event: "new_pull_comment", 91 93 Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 + "repo_at": comment.RepoAt, 95 + "pull_id": comment.PullId, 96 + "mentions": mentions, 94 97 }, 95 98 }) 96 99 if err != nil { ··· 177 180 } 178 181 } 179 182 180 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 181 184 err := n.client.Enqueue(posthog.Capture{ 182 185 DistinctId: comment.Did, 183 186 Event: "new_issue_comment", 184 187 Properties: posthog.Properties{ 185 188 "issue_at": comment.IssueAt, 189 + "mentions": mentions, 186 190 }, 187 191 }) 188 192 if err != nil { ··· 190 194 } 191 195 } 192 196 193 - func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 197 + func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 198 + var event string 199 + if issue.Open { 200 + event = "issue_reopen" 201 + } else { 202 + event = "issue_closed" 203 + } 194 204 err := n.client.Enqueue(posthog.Capture{ 195 205 DistinctId: issue.Did, 196 - Event: "issue_closed", 206 + Event: event, 197 207 Properties: posthog.Properties{ 198 208 "repo_at": issue.RepoAt.String(), 209 + "actor": actor, 199 210 "issue_id": issue.IssueId, 200 211 }, 201 212 }) ··· 204 215 } 205 216 } 206 217 207 - func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 218 + func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 219 + var event string 220 + switch pull.State { 221 + case models.PullClosed: 222 + event = "pull_closed" 223 + case models.PullOpen: 224 + event = "pull_reopen" 225 + case models.PullMerged: 226 + event = "pull_merged" 227 + default: 228 + log.Println("posthog: unexpected new PR state:", pull.State) 229 + return 230 + } 208 231 err := n.client.Enqueue(posthog.Capture{ 209 232 DistinctId: pull.OwnerDid, 210 - Event: "pull_merged", 233 + Event: event, 211 234 Properties: posthog.Properties{ 212 235 "repo_at": pull.RepoAt, 213 236 "pull_id": pull.PullId, 237 + "actor": actor, 214 238 }, 215 239 }) 216 240 if err != nil {
+18 -15
appview/oauth/handler.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "net/http" 9 10 "slices" 10 11 "time" 11 12 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 14 "github.com/go-chi/chi/v5" 13 - "github.com/lestrrat-go/jwx/v2/jwk" 14 15 "github.com/posthog/posthog-go" 15 16 "tangled.org/core/api/tangled" 16 17 "tangled.org/core/appview/db" ··· 30 31 func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 31 32 doc := o.ClientApp.Config.ClientMetadata() 32 33 doc.JWKSURI = &o.JwksUri 34 + doc.ClientName = &o.ClientName 35 + doc.ClientURI = &o.ClientUri 33 36 34 37 w.Header().Set("Content-Type", "application/json") 35 38 if err := json.NewEncoder(w).Encode(doc); err != nil { ··· 39 42 } 40 43 41 44 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) 45 + w.Header().Set("Content-Type", "application/json") 46 + body := o.ClientApp.Config.PublicJWKS() 47 + if err := json.NewEncoder(w).Encode(body); err != nil { 46 48 http.Error(w, err.Error(), http.StatusInternalServerError) 47 49 return 48 50 } 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 51 } 58 52 59 53 func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 60 54 ctx := r.Context() 55 + l := o.Logger.With("query", r.URL.Query()) 61 56 62 57 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 63 58 if err != nil { 64 - http.Error(w, err.Error(), http.StatusInternalServerError) 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) 65 67 return 66 68 } 67 69 68 70 if err := o.SaveSession(w, r, sessData); err != nil { 69 - http.Error(w, err.Error(), http.StatusInternalServerError) 71 + l.Error("failed to save session", "data", sessData, "err", err) 72 + http.Redirect(w, r, "/login?error=session", http.StatusFound) 70 73 return 71 74 } 72 75
+35 -22
appview/oauth/oauth.go
··· 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 11 "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 12 atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 xrpc "github.com/bluesky-social/indigo/xrpc" 15 16 "github.com/gorilla/sessions" 16 - "github.com/lestrrat-go/jwx/v2/jwk" 17 17 "github.com/posthog/posthog-go" 18 18 "tangled.org/core/appview/config" 19 19 "tangled.org/core/appview/db" ··· 26 26 SessStore *sessions.CookieStore 27 27 Config *config.Config 28 28 JwksUri string 29 + ClientName string 30 + ClientUri string 29 31 Posthog posthog.Client 30 32 Db *db.DB 31 33 Enforcer *rbac.Enforcer ··· 34 36 } 35 37 36 38 func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 37 - 38 39 var oauthConfig oauth.ClientConfig 39 40 var clientUri string 40 - 41 41 if config.Core.Dev { 42 42 clientUri = "http://127.0.0.1:3000" 43 43 callbackUri := clientUri + "/oauth/callback" ··· 47 47 clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 48 48 callbackUri := clientUri + "/oauth/callback" 49 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 50 59 } 51 60 52 61 jwksUri := clientUri + "/oauth/jwks.json" 53 62 54 - authStore, err := NewRedisStore(config.Redis.ToURL()) 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 + }) 55 69 if err != nil { 56 70 return nil, err 57 71 } 58 72 59 73 sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 60 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()) 61 85 return &OAuth{ 62 - ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 86 + ClientApp: clientApp, 63 87 Config: config, 64 88 SessStore: sessStore, 65 89 JwksUri: jwksUri, 90 + ClientName: clientName, 91 + ClientUri: clientUri, 66 92 Posthog: ph, 67 93 Db: db, 68 94 Enforcer: enforcer, ··· 137 163 return errors.Join(err1, err2) 138 164 } 139 165 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 166 type User struct { 153 167 Did string 154 168 Pds string 155 169 } 156 170 157 171 func (o *OAuth) GetUser(r *http.Request) *User { 158 - sess, err := o.SessStore.Get(r, SessionName) 159 - 160 - if err != nil || sess.IsNew { 172 + sess, err := o.ResumeSession(r) 173 + if err != nil { 161 174 return nil 162 175 } 163 176 164 177 return &User{ 165 - Did: sess.Values[SessionDid].(string), 166 - Pds: sess.Values[SessionPds].(string), 178 + Did: sess.Data.AccountDID.String(), 179 + Pds: sess.Data.HostURL, 167 180 } 168 181 } 169 182
+110 -11
appview/oauth/store.go
··· 11 11 "github.com/redis/go-redis/v9" 12 12 ) 13 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 + 14 24 // redis-backed implementation of ClientAuthStore. 15 25 type RedisStore struct { 16 - client *redis.Client 17 - SessionTTL time.Duration 18 - AuthRequestTTL time.Duration 26 + client *redis.Client 27 + cfg *RedisStoreConfig 19 28 } 20 29 21 30 var _ oauth.ClientAuthStore = &RedisStore{} 22 31 23 - func NewRedisStore(redisURL string) (*RedisStore, error) { 24 - opts, err := redis.ParseURL(redisURL) 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) 25 55 if err != nil { 26 56 return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 57 } ··· 37 67 } 38 68 39 69 return &RedisStore{ 40 - client: client, 41 - SessionTTL: 30 * 24 * time.Hour, // 30 days 42 - AuthRequestTTL: 10 * time.Minute, // 10 minutes 70 + client: client, 71 + cfg: cfg, 43 72 }, nil 44 73 } 45 74 ··· 51 80 return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 81 } 53 82 83 + func sessionMetadataKey(did syntax.DID, sessionID string) string { 84 + return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID) 85 + } 86 + 54 87 func authRequestKey(state string) string { 55 88 return fmt.Sprintf("oauth:auth_request:%s", state) 56 89 } 57 90 58 91 func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 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 60 118 data, err := r.client.Get(ctx, key).Bytes() 61 119 if err == redis.Nil { 62 120 return nil, fmt.Errorf("session not found: %s", did) ··· 75 133 76 134 func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 135 key := sessionKey(sess.AccountDID, sess.SessionID) 136 + metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID) 78 137 79 138 data, err := json.Marshal(sess) 80 139 if err != nil { 81 140 return fmt.Errorf("failed to marshal session: %w", err) 82 141 } 83 142 84 - if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 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 { 85 173 return fmt.Errorf("failed to save session: %w", err) 86 174 } 87 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 + 88 185 return nil 89 186 } 90 187 91 188 func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 189 key := sessionKey(did, sessionID) 93 - if err := r.client.Del(ctx, key).Err(); err != nil { 190 + metaKey := sessionMetadataKey(did, sessionID) 191 + 192 + if err := r.client.Del(ctx, key, metaKey).Err(); err != nil { 94 193 return fmt.Errorf("failed to delete session: %w", err) 95 194 } 96 195 return nil ··· 131 230 return fmt.Errorf("failed to marshal auth request: %w", err) 132 231 } 133 232 134 - if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 233 + if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil { 135 234 return fmt.Errorf("failed to save auth request: %w", err) 136 235 } 137 236
+584
appview/ogcard/card.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "html/template" 11 + "image" 12 + "image/color" 13 + "io" 14 + "log" 15 + "math" 16 + "net/http" 17 + "strings" 18 + "sync" 19 + "time" 20 + 21 + "github.com/goki/freetype" 22 + "github.com/goki/freetype/truetype" 23 + "github.com/srwiley/oksvg" 24 + "github.com/srwiley/rasterx" 25 + "golang.org/x/image/draw" 26 + "golang.org/x/image/font" 27 + "tangled.org/core/appview/pages" 28 + 29 + _ "golang.org/x/image/webp" // for processing webp images 30 + ) 31 + 32 + type Card struct { 33 + Img *image.RGBA 34 + Font *truetype.Font 35 + Margin int 36 + Width int 37 + Height int 38 + } 39 + 40 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 41 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 42 + if err != nil { 43 + return nil, err 44 + } 45 + return truetype.Parse(interVar) 46 + }) 47 + 48 + // DefaultSize returns the default size for a card 49 + func DefaultSize() (int, int) { 50 + return 1200, 630 51 + } 52 + 53 + // NewCard creates a new card with the given dimensions in pixels 54 + func NewCard(width, height int) (*Card, error) { 55 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 56 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 57 + 58 + font, err := fontCache() 59 + if err != nil { 60 + return nil, err 61 + } 62 + 63 + return &Card{ 64 + Img: img, 65 + Font: font, 66 + Margin: 0, 67 + Width: width, 68 + Height: height, 69 + }, nil 70 + } 71 + 72 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 73 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 74 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 75 + bounds := c.Img.Bounds() 76 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 77 + if vertical { 78 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 79 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 80 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 81 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 82 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 83 + } 84 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 85 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 86 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 87 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 88 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 89 + } 90 + 91 + // SetMargin sets the margins for the card 92 + func (c *Card) SetMargin(margin int) { 93 + c.Margin = margin 94 + } 95 + 96 + type ( 97 + VAlign int64 98 + HAlign int64 99 + ) 100 + 101 + const ( 102 + Top VAlign = iota 103 + Middle 104 + Bottom 105 + ) 106 + 107 + const ( 108 + Left HAlign = iota 109 + Center 110 + Right 111 + ) 112 + 113 + // DrawText draws text within the card, respecting margins and alignment 114 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 115 + ft := freetype.NewContext() 116 + ft.SetDPI(72) 117 + ft.SetFont(c.Font) 118 + ft.SetFontSize(sizePt) 119 + ft.SetClip(c.Img.Bounds()) 120 + ft.SetDst(c.Img) 121 + ft.SetSrc(image.NewUniform(textColor)) 122 + 123 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 124 + fontHeight := ft.PointToFixed(sizePt).Ceil() 125 + 126 + bounds := c.Img.Bounds() 127 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 128 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 129 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 130 + 131 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 132 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 133 + // knowing the total height, which is related to how many lines we'll have. 134 + lines := make([]string, 0) 135 + textWords := strings.Split(text, " ") 136 + currentLine := "" 137 + heightTotal := 0 138 + 139 + for { 140 + if len(textWords) == 0 { 141 + // Ran out of words. 142 + if currentLine != "" { 143 + heightTotal += fontHeight 144 + lines = append(lines, currentLine) 145 + } 146 + break 147 + } 148 + 149 + nextWord := textWords[0] 150 + proposedLine := currentLine 151 + if proposedLine != "" { 152 + proposedLine += " " 153 + } 154 + proposedLine += nextWord 155 + 156 + proposedLineWidth := font.MeasureString(face, proposedLine) 157 + if proposedLineWidth.Ceil() > boxWidth { 158 + // no, proposed line is too big; we'll use the last "currentLine" 159 + heightTotal += fontHeight 160 + if currentLine != "" { 161 + lines = append(lines, currentLine) 162 + currentLine = "" 163 + // leave nextWord in textWords and keep going 164 + } else { 165 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 166 + // regardless as a line by itself. It will be clipped by the drawing routine. 167 + lines = append(lines, nextWord) 168 + textWords = textWords[1:] 169 + } 170 + } else { 171 + // yes, it will fit 172 + currentLine = proposedLine 173 + textWords = textWords[1:] 174 + } 175 + } 176 + 177 + textY := 0 178 + switch valign { 179 + case Top: 180 + textY = fontHeight 181 + case Bottom: 182 + textY = boxHeight - heightTotal + fontHeight 183 + case Middle: 184 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 185 + } 186 + 187 + for _, line := range lines { 188 + lineWidth := font.MeasureString(face, line) 189 + 190 + textX := 0 191 + switch halign { 192 + case Left: 193 + textX = 0 194 + case Right: 195 + textX = boxWidth - lineWidth.Ceil() 196 + case Center: 197 + textX = (boxWidth - lineWidth.Ceil()) / 2 198 + } 199 + 200 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 201 + _, err := ft.DrawString(line, pt) 202 + if err != nil { 203 + return nil, err 204 + } 205 + 206 + textY += fontHeight 207 + } 208 + 209 + return lines, nil 210 + } 211 + 212 + // DrawTextAt draws text at a specific position with the given alignment 213 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 214 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 215 + return err 216 + } 217 + 218 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 219 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 220 + ft := freetype.NewContext() 221 + ft.SetDPI(72) 222 + ft.SetFont(c.Font) 223 + ft.SetFontSize(sizePt) 224 + ft.SetClip(c.Img.Bounds()) 225 + ft.SetDst(c.Img) 226 + ft.SetSrc(image.NewUniform(textColor)) 227 + 228 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 229 + fontHeight := ft.PointToFixed(sizePt).Ceil() 230 + lineWidth := font.MeasureString(face, text) 231 + textWidth := lineWidth.Ceil() 232 + 233 + // Adjust position based on alignment 234 + adjustedX := x 235 + adjustedY := y 236 + 237 + switch halign { 238 + case Left: 239 + // x is already at the left position 240 + case Right: 241 + adjustedX = x - textWidth 242 + case Center: 243 + adjustedX = x - textWidth/2 244 + } 245 + 246 + switch valign { 247 + case Top: 248 + adjustedY = y + fontHeight 249 + case Bottom: 250 + adjustedY = y 251 + case Middle: 252 + adjustedY = y + fontHeight/2 253 + } 254 + 255 + pt := freetype.Pt(adjustedX, adjustedY) 256 + _, err := ft.DrawString(text, pt) 257 + return textWidth, err 258 + } 259 + 260 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 261 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 262 + // Draw the text multiple times with slight offsets to create bold effect 263 + offsets := []struct{ dx, dy int }{ 264 + {0, 0}, // original 265 + {1, 0}, // right 266 + {0, 1}, // down 267 + {1, 1}, // diagonal 268 + } 269 + 270 + var width int 271 + for _, offset := range offsets { 272 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 273 + if err != nil { 274 + return 0, err 275 + } 276 + if width == 0 { 277 + width = w 278 + } 279 + } 280 + return width, nil 281 + } 282 + 283 + func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { 284 + // Convert color to hex string for SVG 285 + rgba, isRGBA := iconColor.(color.RGBA) 286 + if !isRGBA { 287 + r, g, b, a := iconColor.RGBA() 288 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 289 + } 290 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 291 + 292 + // Replace currentColor with our desired color in the SVG 293 + svgString := string(svgData) 294 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 295 + 296 + // Make the stroke thicker 297 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 298 + 299 + // Parse SVG 300 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 301 + if err != nil { 302 + return nil, fmt.Errorf("failed to parse SVG: %w", err) 303 + } 304 + 305 + return icon, nil 306 + } 307 + 308 + func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) { 309 + svgData, err := pages.Files.ReadFile(svgPath) 310 + if err != nil { 311 + return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 312 + } 313 + 314 + icon, err := BuildSVGIconFromData(svgData, iconColor) 315 + if err != nil { 316 + return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err) 317 + } 318 + 319 + return icon, nil 320 + } 321 + 322 + func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) { 323 + return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 324 + } 325 + 326 + func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error { 327 + icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 328 + if err != nil { 329 + return err 330 + } 331 + 332 + c.DrawSVGIcon(icon, x, y, size) 333 + 334 + return nil 335 + } 336 + 337 + func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error { 338 + tpl, err := template.New("dolly"). 339 + ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html") 340 + if err != nil { 341 + return fmt.Errorf("failed to read dolly silhouette template: %w", err) 342 + } 343 + 344 + var svgData bytes.Buffer 345 + if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil { 346 + return fmt.Errorf("failed to execute dolly silhouette template: %w", err) 347 + } 348 + 349 + icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + c.DrawSVGIcon(icon, x, y, size) 355 + 356 + return nil 357 + } 358 + 359 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 360 + func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) { 361 + // Set the icon size 362 + w, h := float64(size), float64(size) 363 + icon.SetTarget(0, 0, w, h) 364 + 365 + // Create a temporary RGBA image for the icon 366 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 367 + 368 + // Create scanner and rasterizer 369 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 370 + raster := rasterx.NewDasher(size, size, scanner) 371 + 372 + // Draw the icon 373 + icon.Draw(raster, 1.0) 374 + 375 + // Draw the icon onto the card at the specified position 376 + bounds := c.Img.Bounds() 377 + destRect := image.Rect(x, y, x+size, y+size) 378 + 379 + // Make sure we don't draw outside the card bounds 380 + if destRect.Max.X > bounds.Max.X { 381 + destRect.Max.X = bounds.Max.X 382 + } 383 + if destRect.Max.Y > bounds.Max.Y { 384 + destRect.Max.Y = bounds.Max.Y 385 + } 386 + 387 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 388 + } 389 + 390 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 391 + func (c *Card) DrawImage(img image.Image) { 392 + bounds := c.Img.Bounds() 393 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 394 + srcBounds := img.Bounds() 395 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 396 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 397 + 398 + var scale float64 399 + if srcAspect > targetAspect { 400 + // Image is wider than target, scale by width 401 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 402 + } else { 403 + // Image is taller or equal, scale by height 404 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 405 + } 406 + 407 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 408 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 409 + 410 + // Center the image within the target rectangle 411 + offsetX := (targetRect.Dx() - newWidth) / 2 412 + offsetY := (targetRect.Dy() - newHeight) / 2 413 + 414 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 415 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 416 + } 417 + 418 + func fallbackImage() image.Image { 419 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 420 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 421 + img.Set(0, 0, color.White) 422 + return img 423 + } 424 + 425 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 426 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 427 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 428 + // this rendering process to be slowed down 429 + client := &http.Client{ 430 + Timeout: 1 * time.Second, // 1 second timeout 431 + } 432 + 433 + resp, err := client.Get(url) 434 + if err != nil { 435 + log.Printf("error when fetching external image from %s: %v", url, err) 436 + return nil, false 437 + } 438 + defer resp.Body.Close() 439 + 440 + if resp.StatusCode != http.StatusOK { 441 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 442 + return nil, false 443 + } 444 + 445 + contentType := resp.Header.Get("Content-Type") 446 + 447 + body := resp.Body 448 + bodyBytes, err := io.ReadAll(body) 449 + if err != nil { 450 + log.Printf("error when fetching external image from %s: %v", url, err) 451 + return nil, false 452 + } 453 + 454 + // Handle SVG separately 455 + if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 + return c.convertSVGToPNG(bodyBytes) 457 + } 458 + 459 + // Support content types are in-sync with the allowed custom avatar file types 460 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 461 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 462 + return nil, false 463 + } 464 + 465 + bodyBuffer := bytes.NewReader(bodyBytes) 466 + _, imgType, err := image.DecodeConfig(bodyBuffer) 467 + if err != nil { 468 + log.Printf("error when decoding external image from %s: %v", url, err) 469 + return nil, false 470 + } 471 + 472 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 473 + if (contentType == "image/png" && imgType != "png") || 474 + (contentType == "image/jpeg" && imgType != "jpeg") || 475 + (contentType == "image/gif" && imgType != "gif") || 476 + (contentType == "image/webp" && imgType != "webp") { 477 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 478 + return nil, false 479 + } 480 + 481 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 482 + if err != nil { 483 + log.Printf("error w/ bodyBuffer.Seek") 484 + return nil, false 485 + } 486 + img, _, err := image.Decode(bodyBuffer) 487 + if err != nil { 488 + log.Printf("error when decoding external image from %s: %v", url, err) 489 + return nil, false 490 + } 491 + 492 + return img, true 493 + } 494 + 495 + // convertSVGToPNG converts SVG data to a PNG image 496 + func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 + // Parse the SVG 498 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 + if err != nil { 500 + log.Printf("error parsing SVG: %v", err) 501 + return nil, false 502 + } 503 + 504 + // Set a reasonable size for the rasterized image 505 + width := 256 506 + height := 256 507 + icon.SetTarget(0, 0, float64(width), float64(height)) 508 + 509 + // Create an image to draw on 510 + rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 511 + 512 + // Fill with white background 513 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 514 + 515 + // Create a scanner and rasterize the SVG 516 + scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 517 + raster := rasterx.NewDasher(width, height, scanner) 518 + 519 + icon.Draw(raster, 1.0) 520 + 521 + return rgba, true 522 + } 523 + 524 + func (c *Card) DrawExternalImage(url string) { 525 + image, ok := c.fetchExternalImage(url) 526 + if !ok { 527 + image = fallbackImage() 528 + } 529 + c.DrawImage(image) 530 + } 531 + 532 + // DrawCircularExternalImage draws an external image as a circle at the specified position 533 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 534 + img, ok := c.fetchExternalImage(url) 535 + if !ok { 536 + img = fallbackImage() 537 + } 538 + 539 + // Create a circular mask 540 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 541 + center := size / 2 542 + radius := float64(size / 2) 543 + 544 + // Scale the source image to fit the circle 545 + srcBounds := img.Bounds() 546 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 547 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 + 549 + // Draw the image with circular clipping 550 + for cy := 0; cy < size; cy++ { 551 + for cx := 0; cx < size; cx++ { 552 + // Calculate distance from center 553 + dx := float64(cx - center) 554 + dy := float64(cy - center) 555 + distance := math.Sqrt(dx*dx + dy*dy) 556 + 557 + // Only draw pixels within the circle 558 + if distance <= radius { 559 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 560 + } 561 + } 562 + } 563 + 564 + // Draw the circle onto the card 565 + bounds := c.Img.Bounds() 566 + destRect := image.Rect(x, y, x+size, y+size) 567 + 568 + // Make sure we don't draw outside the card bounds 569 + if destRect.Max.X > bounds.Max.X { 570 + destRect.Max.X = bounds.Max.X 571 + } 572 + if destRect.Max.Y > bounds.Max.Y { 573 + destRect.Max.Y = bounds.Max.Y 574 + } 575 + 576 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 577 + 578 + return nil 579 + } 580 + 581 + // DrawRect draws a rect with the given color 582 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 583 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 584 + }
+65 -6
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "crypto/hmac" 6 7 "crypto/sha256" ··· 17 18 "strings" 18 19 "time" 19 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" 20 26 "github.com/dustin/go-humanize" 21 27 "github.com/go-enry/go-enry/v2" 22 28 "tangled.org/core/appview/filetree" ··· 38 44 "contains": func(s string, target string) bool { 39 45 return strings.Contains(s, target) 40 46 }, 47 + "stripPort": func(hostname string) string { 48 + if strings.Contains(hostname, ":") { 49 + return strings.Split(hostname, ":")[0] 50 + } 51 + return hostname 52 + }, 41 53 "mapContains": func(m any, key any) bool { 42 54 mapValue := reflect.ValueOf(m) 43 55 if mapValue.Kind() != reflect.Map { ··· 57 69 return "handle.invalid" 58 70 } 59 71 60 - return "@" + identity.Handle.String() 72 + return identity.Handle.String() 61 73 }, 62 74 "truncateAt30": func(s string) string { 63 75 if len(s) <= 30 { ··· 67 79 }, 68 80 "splitOn": func(s, sep string) []string { 69 81 return strings.Split(s, sep) 82 + }, 83 + "string": func(v any) string { 84 + return fmt.Sprint(v) 70 85 }, 71 86 "int64": func(a int) int64 { 72 87 return int64(a) ··· 117 132 return b 118 133 }, 119 134 "didOrHandle": func(did, handle string) string { 120 - if handle != "" { 121 - return fmt.Sprintf("@%s", handle) 135 + if handle != "" && handle != syntax.HandleInvalid.String() { 136 + return handle 122 137 } else { 123 138 return did 124 139 } ··· 236 251 sanitized := p.rctx.SanitizeDescription(htmlString) 237 252 return template.HTML(sanitized) 238 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 + }, 239 295 "isNil": func(t any) bool { 240 296 // returns false for other "zero" values 241 297 return t == nil ··· 281 337 u, _ := url.PathUnescape(s) 282 338 return u 283 339 }, 284 - 340 + "safeUrl": func(s string) template.URL { 341 + return template.URL(s) 342 + }, 285 343 "tinyAvatar": func(handle string) string { 286 344 return p.AvatarUrl(handle, "tiny") 287 345 }, ··· 297 355 }, 298 356 299 357 "normalizeForHtmlId": func(s string) string { 300 - // TODO: extend this to handle other cases? 301 - return strings.ReplaceAll(s, ":", "_") 358 + normalized := strings.ReplaceAll(s, ":", "_") 359 + normalized = strings.ReplaceAll(normalized, ".", "_") 360 + return normalized 302 361 }, 303 362 "sshFingerprint": func(pubKey string) string { 304 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 25 htmlparse "golang.org/x/net/html" 26 26 27 27 "tangled.org/core/api/tangled" 28 + textension "tangled.org/core/appview/pages/markup/extension" 28 29 "tangled.org/core/appview/pages/repoinfo" 29 30 ) 30 31 ··· 50 51 Files fs.FS 51 52 } 52 53 53 - func (rctx *RenderContext) RenderMarkdown(source string) string { 54 + func NewMarkdown() goldmark.Markdown { 54 55 md := goldmark.New( 55 56 goldmark.WithExtensions( 56 57 extension.GFM, ··· 66 67 ), 67 68 treeblood.MathML(), 68 69 callout.CalloutExtention, 70 + textension.AtExt, 69 71 ), 70 72 goldmark.WithParserOptions( 71 73 parser.WithAutoHeadingID(), 72 74 ), 73 75 goldmark.WithRendererOptions(html.WithUnsafe()), 74 76 ) 77 + return md 78 + } 79 + 80 + func (rctx *RenderContext) RenderMarkdown(source string) string { 81 + md := NewMarkdown() 75 82 76 83 if rctx != nil { 77 84 var transformers []util.PrioritizedValue ··· 293 300 } 294 301 295 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 296 327 } 297 328 298 329 func isAbsoluteUrl(link string) bool {
+3
appview/pages/markup/sanitizer.go
··· 77 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 79 80 + // at-mentions 81 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a") 82 + 80 83 // centering content 81 84 policy.AllowElements("center") 82 85
+46 -130
appview/pages/pages.go
··· 1 1 package pages 2 2 3 3 import ( 4 - "bytes" 5 4 "crypto/sha256" 6 5 "embed" 7 6 "encoding/hex" ··· 15 14 "path/filepath" 16 15 "strings" 17 16 "sync" 17 + "time" 18 18 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/commitverify" ··· 28 28 "tangled.org/core/patchutil" 29 29 "tangled.org/core/types" 30 30 31 - "github.com/alecthomas/chroma/v2" 32 - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 - "github.com/alecthomas/chroma/v2/lexers" 34 - "github.com/alecthomas/chroma/v2/styles" 35 31 "github.com/bluesky-social/indigo/atproto/identity" 36 32 "github.com/bluesky-social/indigo/atproto/syntax" 37 33 "github.com/go-git/go-git/v5/plumbing" ··· 221 217 222 218 type LoginParams struct { 223 219 ReturnUrl string 220 + ErrorCode string 224 221 } 225 222 226 223 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 638 635 return p.executePlain("repo/fragments/repoStar", w, params) 639 636 } 640 637 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 638 type RepoIndexParams struct { 654 639 LoggedInUser *oauth.User 655 640 RepoInfo repoinfo.RepoInfo ··· 659 644 TagsTrunc []*types.TagReference 660 645 BranchesTrunc []types.Branch 661 646 // 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 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 669 654 types.RepoIndexResponse 670 655 } 671 656 ··· 700 685 } 701 686 702 687 type RepoLogParams struct { 703 - LoggedInUser *oauth.User 704 - RepoInfo repoinfo.RepoInfo 705 - TagMap map[string][]string 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 + 706 696 types.RepoLogResponse 707 - Active string 708 - EmailToDidOrHandle map[string]string 709 - VerifiedCommits commitverify.VerifiedCommits 710 - Pipelines map[string]models.Pipeline 711 697 } 712 698 713 699 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 716 702 } 717 703 718 704 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 705 + LoggedInUser *oauth.User 706 + RepoInfo repoinfo.RepoInfo 707 + Active string 708 + EmailToDid map[string]string 709 + Pipeline *models.Pipeline 710 + DiffOpts types.DiffOpts 725 711 726 712 // singular because it's always going to be just one 727 713 VerifiedCommit commitverify.VerifiedCommits ··· 753 739 func (r RepoTreeParams) TreeStats() RepoTreeStats { 754 740 numFolders, numFiles := 0, 0 755 741 for _, f := range r.Files { 756 - if !f.IsFile { 742 + if !f.IsFile() { 757 743 numFolders += 1 758 - } else if f.IsFile { 744 + } else if f.IsFile() { 759 745 numFiles += 1 760 746 } 761 747 } ··· 826 812 } 827 813 828 814 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 815 + LoggedInUser *oauth.User 816 + RepoInfo repoinfo.RepoInfo 817 + Active string 818 + BreadCrumbs [][]string 819 + BlobView models.BlobView 840 820 *tangled.RepoBlob_Output 841 - // Computed fields for template compatibility 842 - Contents string 843 - Lines int 844 - SizeHint uint64 845 - IsBinary bool 846 821 } 847 822 848 823 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 - } 824 + switch params.BlobView.ContentType { 825 + case models.BlobContentTypeMarkup: 826 + p.rctx.RepoInfo = params.RepoInfo 860 827 } 861 828 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 829 params.Active = "overview" 889 830 return p.executeRepo("repo/blob", w, params) 890 831 } ··· 970 911 LabelDefs map[string]*models.LabelDefinition 971 912 Page pagination.Page 972 913 FilteringByOpen bool 914 + FilterQuery string 973 915 } 974 916 975 917 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 1100 1042 Pulls []*models.Pull 1101 1043 Active string 1102 1044 FilteringBy models.PullState 1045 + FilterQuery string 1103 1046 Stacks map[string]models.Stack 1104 1047 Pipelines map[string]models.Pipeline 1105 1048 LabelDefs map[string]*models.LabelDefinition ··· 1357 1300 Name string 1358 1301 Command string 1359 1302 Collapsed bool 1303 + StartTime time.Time 1360 1304 } 1361 1305 1362 1306 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1363 1307 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1364 1308 } 1365 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 + 1366 1320 type LogLineParams struct { 1367 1321 Id int 1368 1322 Content string ··· 1428 1382 } 1429 1383 1430 1384 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 1385 return p.execute("strings/string", w, params) 1470 1386 } 1471 1387
+7 -7
appview/pages/repoinfo/repoinfo.go
··· 1 1 package repoinfo 2 2 3 3 import ( 4 - "fmt" 5 4 "path" 6 5 "slices" 7 - "strings" 8 6 9 7 "github.com/bluesky-social/indigo/atproto/syntax" 10 8 "tangled.org/core/appview/models" 11 9 "tangled.org/core/appview/state/userutil" 12 10 ) 13 11 14 - func (r RepoInfo) OwnerWithAt() string { 12 + func (r RepoInfo) Owner() string { 15 13 if r.OwnerHandle != "" { 16 - return fmt.Sprintf("@%s", r.OwnerHandle) 14 + return r.OwnerHandle 17 15 } else { 18 16 return r.OwnerDid 19 17 } 20 18 } 21 19 22 20 func (r RepoInfo) FullName() string { 23 - return path.Join(r.OwnerWithAt(), r.Name) 21 + return path.Join(r.Owner(), r.Name) 24 22 } 25 23 26 24 func (r RepoInfo) OwnerWithoutAt() string { 27 - if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 - return after 25 + if r.OwnerHandle != "" { 26 + return r.OwnerHandle 29 27 } else { 30 28 return userutil.FlattenDid(r.OwnerDid) 31 29 } ··· 56 54 OwnerDid string 57 55 OwnerHandle string 58 56 Description string 57 + Website string 58 + Topics []string 59 59 Knot string 60 60 Spindle string 61 61 RepoAt syntax.ATURI
+82 -54
appview/pages/templates/fragments/dolly/logo.html
··· 1 1 {{ define "fragments/dolly/logo" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - class="{{.}}" 6 - width="25" 7 - height="25" 8 - viewBox="0 0 25 25" 9 - sodipodi:docname="tangled_dolly_face_only.png" 10 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 - xmlns:xlink="http://www.w3.org/1999/xlink" 13 - xmlns="http://www.w3.org/2000/svg" 14 - xmlns:svg="http://www.w3.org/2000/svg"> 15 - <title>Dolly</title> 16 - <defs 17 - id="defs1" /> 18 - <sodipodi:namedview 19 - id="namedview1" 20 - pagecolor="#ffffff" 21 - bordercolor="#000000" 22 - borderopacity="0.25" 23 - inkscape:showpageshadow="2" 24 - inkscape:pageopacity="0.0" 25 - inkscape:pagecheckerboard="true" 26 - inkscape:deskcolor="#d5d5d5"> 27 - <inkscape:page 28 - x="0" 29 - y="0" 30 - width="25" 31 - height="25" 32 - id="page2" 33 - margin="0" 34 - bleed="0" /> 35 - </sodipodi:namedview> 36 - <g 37 - inkscape:groupmode="layer" 38 - inkscape:label="Image" 39 - id="g1"> 40 - <image 41 - width="252.48" 42 - height="248.96001" 43 - preserveAspectRatio="none" 44 - xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#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> 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{ . }}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 10 + inkscape:export-filename="tangled_logotype_black_on_trans.svg" 11 + inkscape:export-xdpi="96" 12 + inkscape:export-ydpi="96" 13 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 14 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 15 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 16 + xmlns="http://www.w3.org/2000/svg" 17 + xmlns:svg="http://www.w3.org/2000/svg" 18 + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 + xmlns:cc="http://creativecommons.org/ns#"> 20 + <sodipodi:namedview 21 + id="namedview1" 22 + pagecolor="#ffffff" 23 + bordercolor="#000000" 24 + borderopacity="0.25" 25 + inkscape:showpageshadow="2" 26 + inkscape:pageopacity="0.0" 27 + inkscape:pagecheckerboard="true" 28 + inkscape:deskcolor="#d5d5d5" 29 + inkscape:zoom="45.254834" 30 + inkscape:cx="3.1377863" 31 + inkscape:cy="8.9382717" 32 + inkscape:window-width="3840" 33 + inkscape:window-height="2160" 34 + inkscape:window-x="0" 35 + inkscape:window-y="0" 36 + inkscape:window-maximized="0" 37 + inkscape:current-layer="g1" 38 + borderlayer="true"> 39 + <inkscape:page 40 + x="0" 41 + y="0" 42 + width="25" 43 + height="25" 44 + id="page2" 45 + margin="0" 46 + bleed="0" /> 47 + </sodipodi:namedview> 48 + <g 49 + inkscape:groupmode="layer" 50 + inkscape:label="Image" 51 + id="g1" 52 + transform="translate(-0.42924038,-0.87777209)"> 53 + <path 54 + fill="currentColor" 55 + style="stroke-width:0.111183;" 56 + d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 57 + id="path4" 58 + sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" /> 59 + </g> 60 + <metadata 61 + id="metadata1"> 62 + <rdf:RDF> 63 + <cc:Work 64 + rdf:about=""> 65 + <cc:license 66 + rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 67 + </cc:Work> 68 + <cc:License 69 + rdf:about="http://creativecommons.org/licenses/by/4.0/"> 70 + <cc:permits 71 + rdf:resource="http://creativecommons.org/ns#Reproduction" /> 72 + <cc:permits 73 + rdf:resource="http://creativecommons.org/ns#Distribution" /> 74 + <cc:requires 75 + rdf:resource="http://creativecommons.org/ns#Notice" /> 76 + <cc:requires 77 + rdf:resource="http://creativecommons.org/ns#Attribution" /> 78 + <cc:permits 79 + rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 80 + </cc:License> 81 + </rdf:RDF> 82 + </metadata> 83 + </svg> 56 84 {{ end }}
+60 -22
appview/pages/templates/fragments/dolly/silhouette.html
··· 2 2 <svg 3 3 version="1.1" 4 4 id="svg1" 5 - width="32" 6 - height="32" 5 + width="25" 6 + height="25" 7 7 viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_silhouette.png" 8 + sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 9 + inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg" 10 + inkscape:export-xdpi="96" 11 + inkscape:export-ydpi="96" 12 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 9 13 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 10 14 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 11 15 xmlns="http://www.w3.org/2000/svg" 12 - xmlns:svg="http://www.w3.org/2000/svg"> 13 - <style> 14 - .dolly { 15 - color: #000000; 16 - } 16 + xmlns:svg="http://www.w3.org/2000/svg" 17 + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 18 + xmlns:cc="http://creativecommons.org/ns#"> 19 + <style> 20 + .dolly { 21 + color: #000000; 22 + } 17 23 18 - @media (prefers-color-scheme: dark) { 19 - .dolly { 20 - color: #ffffff; 21 - } 22 - } 23 - </style> 24 - <title>Dolly</title> 25 - <defs 26 - id="defs1" /> 24 + @media (prefers-color-scheme: dark) { 25 + .dolly { 26 + color: #ffffff; 27 + } 28 + } 29 + </style> 27 30 <sodipodi:namedview 28 31 id="namedview1" 29 32 pagecolor="#ffffff" ··· 32 35 inkscape:showpageshadow="2" 33 36 inkscape:pageopacity="0.0" 34 37 inkscape:pagecheckerboard="true" 35 - inkscape:deskcolor="#d1d1d1"> 38 + inkscape:deskcolor="#d5d5d5" 39 + inkscape:zoom="64" 40 + inkscape:cx="4.96875" 41 + inkscape:cy="13.429688" 42 + inkscape:window-width="3840" 43 + inkscape:window-height="2160" 44 + inkscape:window-x="0" 45 + inkscape:window-y="0" 46 + inkscape:window-maximized="0" 47 + inkscape:current-layer="g1" 48 + borderlayer="true"> 36 49 <inkscape:page 37 50 x="0" 38 51 y="0" ··· 45 58 <g 46 59 inkscape:groupmode="layer" 47 60 inkscape:label="Image" 48 - id="g1"> 61 + id="g1" 62 + transform="translate(-0.42924038,-0.87777209)"> 49 63 <path 50 64 class="dolly" 51 65 fill="currentColor" 52 - style="stroke-width:1.12248" 53 - d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 54 - id="path1" /> 66 + style="stroke-width:0.111183" 67 + d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z" 68 + id="path7" 69 + sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" /> 55 70 </g> 71 + <metadata 72 + id="metadata1"> 73 + <rdf:RDF> 74 + <cc:Work 75 + rdf:about=""> 76 + <cc:license 77 + rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 78 + </cc:Work> 79 + <cc:License 80 + rdf:about="http://creativecommons.org/licenses/by/4.0/"> 81 + <cc:permits 82 + rdf:resource="http://creativecommons.org/ns#Reproduction" /> 83 + <cc:permits 84 + rdf:resource="http://creativecommons.org/ns#Distribution" /> 85 + <cc:requires 86 + rdf:resource="http://creativecommons.org/ns#Notice" /> 87 + <cc:requires 88 + rdf:resource="http://creativecommons.org/ns#Attribution" /> 89 + <cc:permits 90 + rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 91 + </cc:License> 92 + </rdf:RDF> 93 + </metadata> 56 94 </svg> 57 95 {{ end }}
-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 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 16 + class=" 17 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 17 19 {{ block "addKnotMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 39 47 <div class="flex gap-2 pt-2"> 40 48 <button 41 49 type="button" ··· 54 62 </div> 55 63 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 64 </form> 57 - {{ end }} 65 + {{ end }}
+1
appview/pages/templates/layouts/base.html
··· 9 9 10 10 <script defer src="/static/htmx.min.js"></script> 11 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + <script defer src="/static/actor-typeahead.js" type="module"></script> 12 13 13 14 <!-- preconnect to image cdn --> 14 15 <link rel="preconnect" href="https://avatar.tangled.sh" />
+5 -9
appview/pages/templates/layouts/fragments/topbar.html
··· 15 15 {{ with .LoggedInUser }} 16 16 {{ block "newButton" . }} {{ end }} 17 17 {{ template "notifications/fragments/bell" }} 18 - {{ block "dropDown" . }} {{ end }} 18 + {{ block "profileDropdown" . }} {{ end }} 19 19 {{ else }} 20 20 <a href="/login">login</a> 21 21 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 33 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 34 {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 35 </summary> 36 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 36 + <div class="absolute flex flex-col right-0 mt-3 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 37 37 <a href="/repo/new" class="flex items-center gap-2"> 38 38 {{ i "book-plus" "w-4 h-4" }} 39 39 new repository ··· 46 46 </details> 47 47 {{ end }} 48 48 49 - {{ define "dropDown" }} 49 + {{ define "profileDropdown" }} 50 50 <details class="relative inline-block text-left nav-dropdown"> 51 - <summary 52 - class="cursor-pointer list-none flex items-center gap-1" 53 - > 51 + <summary class="cursor-pointer list-none flex items-center gap-1"> 54 52 {{ $user := .Did }} 55 53 <img 56 54 src="{{ tinyAvatar $user }}" ··· 59 57 /> 60 58 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 61 59 </summary> 62 - <div 63 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 64 - > 60 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 65 61 <a href="/{{ $user }}">profile</a> 66 62 <a href="/{{ $user }}?tab=repos">repositories</a> 67 63 <a href="/{{ $user }}?tab=strings">strings</a>
+9
appview/pages/templates/layouts/profilebase.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 + {{ $avatarUrl := fullAvatar .Card.UserHandle }} 4 5 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 6 <meta property="og:type" content="profile" /> 6 7 <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 8 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 9 + <meta property="og:image" content="{{ $avatarUrl }}" /> 10 + <meta property="og:image:width" content="512" /> 11 + <meta property="og:image:height" content="512" /> 12 + 13 + <meta name="twitter:card" content="summary" /> 14 + <meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 15 + <meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 16 + <meta name="twitter:image" content="{{ $avatarUrl }}" /> 8 17 {{ end }} 9 18 10 19 {{ define "content" }}
+53 -25
appview/pages/templates/layouts/repobase.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 py-2 px-6 dark:text-white"> 5 - {{ if .RepoInfo.Source }} 6 - <p class="text-sm"> 7 - <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 - forked from 10 - {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 - <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> 12 - </div> 13 - </p> 14 - {{ end }} 15 - <div class="text-lg flex items-center justify-between"> 16 - <div> 17 - <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 18 - <span class="select-none">/</span> 19 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 4 + <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 + <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 + <!-- left items --> 7 + <div class="flex flex-col gap-2"> 8 + <!-- repo owner / repo name --> 9 + <div class="flex items-center gap-2 flex-wrap"> 10 + {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 11 + <span class="select-none">/</span> 12 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 13 + </div> 14 + 15 + {{ if .RepoInfo.Source }} 16 + {{ $sourceOwner := resolve .RepoInfo.Source.Did }} 17 + <div class="flex items-center gap-1 text-sm flex-wrap"> 18 + {{ i "git-fork" "w-3 h-3 shrink-0" }} 19 + <span>forked from</span> 20 + <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}"> 21 + {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 22 + </a> 23 + </div> 24 + {{ end }} 25 + 26 + <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 27 + {{ if .RepoInfo.Description }} 28 + {{ .RepoInfo.Description | description }} 29 + {{ else }} 30 + <span class="italic">this repo has no description</span> 31 + {{ end }} 32 + 33 + {{ with .RepoInfo.Website }} 34 + <span class="flex items-center gap-1"> 35 + <span class="flex-shrink-0">{{ i "globe" "size-4" }}</span> 36 + <a href="{{ . }}">{{ . | trimUriScheme }}</a> 37 + </span> 38 + {{ end }} 39 + 40 + {{ if .RepoInfo.Topics }} 41 + <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300"> 42 + {{ range .RepoInfo.Topics }} 43 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span> 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + </span> 20 49 </div> 21 50 22 - <div class="flex items-center gap-2 z-auto"> 23 - <a 24 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 - href="/{{ .RepoInfo.FullName }}/feed.atom" 26 - > 27 - {{ i "rss" "size-4" }} 28 - </a> 51 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 29 52 {{ template "repo/fragments/repoStar" .RepoInfo }} 30 53 <a 31 54 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" ··· 36 59 fork 37 60 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 61 </a> 62 + <a 63 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 64 + href="/{{ .RepoInfo.FullName }}/feed.atom"> 65 + {{ i "rss" "size-4" }} 66 + <span class="md:hidden">atom</span> 67 + </a> 39 68 </div> 40 69 </div> 41 - {{ template "repo/fragments/repoDescription" . }} 42 70 </section> 43 71 44 72 <section class="w-full flex flex-col" > ··· 79 107 </div> 80 108 </nav> 81 109 {{ block "repoContentLayout" . }} 82 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 110 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white"> 83 111 {{ block "repoContent" . }}{{ end }} 84 112 </section> 85 113 {{ block "repoAfter" . }}{{ end }}
+11 -2
appview/pages/templates/notifications/fragments/item.html
··· 8 8 "> 9 9 {{ template "notificationIcon" . }} 10 10 <div class="flex-1 w-full flex flex-col gap-1"> 11 - <span>{{ template "notificationHeader" . }}</span> 11 + <div class="flex items-center gap-1"> 12 + <span>{{ template "notificationHeader" . }}</span> 13 + <span class="text-sm text-gray-500 dark:text-gray-400 before:content-['·'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span> 14 + </div> 12 15 <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 16 </div> 14 17 ··· 19 22 {{ define "notificationIcon" }} 20 23 <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 24 <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 22 - <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 25 + <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10"> 23 26 {{ i .Icon "size-3 text-black dark:text-white" }} 24 27 </div> 25 28 </div> ··· 37 40 commented on an issue 38 41 {{ else if eq .Type "issue_closed" }} 39 42 closed an issue 43 + {{ else if eq .Type "issue_reopen" }} 44 + reopened an issue 40 45 {{ else if eq .Type "pull_created" }} 41 46 created a pull request 42 47 {{ else if eq .Type "pull_commented" }} ··· 45 50 merged a pull request 46 51 {{ else if eq .Type "pull_closed" }} 47 52 closed a pull request 53 + {{ else if eq .Type "pull_reopen" }} 54 + reopened a pull request 48 55 {{ else if eq .Type "followed" }} 49 56 followed you 57 + {{ else if eq .Type "user_mentioned" }} 58 + mentioned you 50 59 {{ else }} 51 60 {{ end }} 52 61 {{ end }}
+62 -39
appview/pages/templates/repo/blob.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} 14 - {{ $lines := split .Contents }} 15 - {{ $tot_lines := len $lines }} 16 - {{ $tot_chars := len (printf "%d" $tot_lines) }} 17 - {{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }} 18 14 {{ $linkstyle := "no-underline hover:underline" }} 19 15 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 20 16 <div class="flex flex-col md:flex-row md:justify-between gap-2"> ··· 36 32 </div> 37 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"> 38 34 <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> 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> 51 56 {{ end }} 52 57 </div> 53 58 </div> 54 59 </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> 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> 76 89 {{ else }} 77 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div> 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> 78 99 {{ end }} 79 - </div> 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> 80 103 {{ end }} 81 104 {{ template "fragments/multiline-select" }} 82 105 {{ end }}
+11 -11
appview/pages/templates/repo/commit.html
··· 24 24 </div> 25 25 </div> 26 26 27 - <div class="flex items-center space-x-2"> 28 - <p class="text-sm text-gray-500 dark:text-gray-300"> 29 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 27 + <div class="flex flex-wrap items-center space-x-2"> 28 + <p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300"> 29 + {{ $did := index $.EmailToDid $commit.Author.Email }} 30 30 31 - {{ if $didOrHandle }} 32 - <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 31 + {{ if $did }} 32 + {{ template "user/fragments/picHandleLink" $did }} 33 33 {{ else }} 34 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 35 {{ end }} 36 + 36 37 <span class="px-1 select-none before:content-['\00B7']"></span> 37 38 {{ template "repo/fragments/time" $commit.Author.When }} 38 39 <span class="px-1 select-none before:content-['\00B7']"></span> 39 - </p> 40 40 41 - <p class="flex items-center text-sm text-gray-500 dark:text-gray-300"> 42 41 <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> 42 + 43 43 {{ if $commit.Parent }} 44 - {{ i "arrow-left" "w-3 h-3 mx-1" }} 45 - <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 44 + {{ i "arrow-left" "w-3 h-3 mx-1" }} 45 + <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 46 46 {{ end }} 47 47 </p> 48 48 ··· 58 58 <div class="mb-1">This commit was signed with the committer's <span class="text-green-600 font-semibold">known signature</span>.</div> 59 59 <div class="flex items-center gap-2 my-2"> 60 60 {{ i "user" "w-4 h-4" }} 61 - {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 61 + {{ $committerDid := index $.EmailToDid $commit.Committer.Email }} 62 + {{ template "user/fragments/picHandleLink" $committerDid }} 63 63 </div> 64 64 <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
+1 -1
appview/pages/templates/repo/empty.html
··· 35 35 36 36 <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 37 <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 - <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 40 </div> 41 41 </div>
+4 -4
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" ··· 48 48 <code 49 49 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 50 onclick="window.getSelection().selectAllChildren(this)" 51 - data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 51 + data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 53 <button 54 54 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 55 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+20 -18
appview/pages/templates/repo/fragments/diffOpts.html
··· 5 5 {{ if .Split }} 6 6 {{ $active = "split" }} 7 7 {{ end }} 8 - {{ $values := list "unified" "split" }} 9 - {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 8 + 9 + {{ $unified := 10 + (dict 11 + "Key" "unified" 12 + "Value" "unified" 13 + "Icon" "square-split-vertical" 14 + "Meta" "") }} 15 + {{ $split := 16 + (dict 17 + "Key" "split" 18 + "Value" "split" 19 + "Icon" "square-split-horizontal" 20 + "Meta" "") }} 21 + {{ $values := list $unified $split }} 22 + 23 + {{ template "fragments/tabSelector" 24 + (dict 25 + "Name" "diff" 26 + "Values" $values 27 + "Active" $active) }} 10 28 </section> 11 29 {{ end }} 12 30 13 - {{ define "tabSelector" }} 14 - {{ $name := .Name }} 15 - {{ $all := .Values }} 16 - {{ $active := .Active }} 17 - <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 - {{ range $index, $value := $all }} 21 - {{ $isActive := eq $value $active }} 22 - <a href="?{{ $name }}={{ $value }}" 23 - class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 - {{ $value }} 25 - </a> 26 - {{ end }} 27 - </div> 28 - {{ end }}
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
··· 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 +
+1 -1
appview/pages/templates/repo/fragments/og.html
··· 11 11 <meta property="og:image" content="{{ $imageUrl }}" /> 12 12 <meta property="og:image:width" content="1200" /> 13 13 <meta property="og:image:height" content="600" /> 14 - 14 + 15 15 <meta name="twitter:card" content="summary_large_image" /> 16 16 <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 17 <meta name="twitter:description" content="{{ $description }}" />
-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 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <details class="group -m-6 mb-4"> 38 + <details class="group -my-4 -m-6 mb-4"> 39 39 <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 40 {{ range $value := .Languages }} 41 41 <div ··· 129 129 {{ $icon := "folder" }} 130 130 {{ $iconStyle := "size-4 fill-current" }} 131 131 132 + {{ if .IsSubmodule }} 133 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 + {{ $icon = "folder-input" }} 135 + {{ $iconStyle = "size-4" }} 136 + {{ end }} 137 + 132 138 {{ if .IsFile }} 133 139 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 140 {{ $icon = "file" }} 135 141 {{ $iconStyle = "size-4" }} 136 142 {{ end }} 143 + 137 144 <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 145 <div class="flex items-center gap-2"> 139 146 {{ i $icon $iconStyle "flex-shrink-0" }} ··· 222 229 class="mx-1 before:content-['·'] before:select-none" 223 230 ></span> 224 231 <span> 225 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 226 - <a 227 - href="{{ if $didOrHandle }} 228 - /{{ $didOrHandle }} 229 - {{ else }} 230 - mailto:{{ .Author.Email }} 231 - {{ end }}" 232 + {{ $did := index $.EmailToDid .Author.Email }} 233 + <a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 232 234 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 - > 235 + >{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a> 239 236 </span> 240 237 <div class="inline-block px-1 select-none after:content-['·']"></div> 241 238 {{ template "repo/fragments/time" .Committer.When }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 34 34 35 35 {{ define "editIssueComment" }} 36 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 38 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 39 hx-swap="outerHTML" 40 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 44 45 45 {{ define "deleteIssueComment" }} 46 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 48 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 49 hx-confirm="Are you sure you want to delete your comment?" 50 50 hx-swap="outerHTML"
+1 -1
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 8 8 class="no-underline hover:underline" 9 9 > 10 10 {{ .Title | description }} 11 - <span class="text-gray-500">#{{ .IssueId }}</span> 11 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 12 12 </a> 13 13 </div> 14 14 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+19
appview/pages/templates/repo/issues/fragments/og.html
··· 1 + {{ define "repo/issues/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }} 3 + {{ $description := or .Issue.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+4 -6
appview/pages/templates/repo/issues/issue.html
··· 2 2 3 3 4 4 {{ define "extrameta" }} 5 - {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 - {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 - 8 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 5 + {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 9 6 {{ end }} 10 7 11 8 {{ define "repoContentLayout" }} ··· 23 20 "Subject" $.Issue.AtUri 24 21 "State" $.Issue.Labels) }} 25 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 26 24 </div> 27 25 </div> 28 26 {{ end }} ··· 87 85 88 86 {{ define "editIssue" }} 89 87 <a 90 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 88 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 91 89 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 90 hx-swap="innerHTML" 93 91 hx-target="#issue-{{.Issue.IssueId}}"> ··· 97 95 98 96 {{ define "deleteIssue" }} 99 97 <a 100 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 98 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 101 99 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 100 hx-confirm="Are you sure you want to delete your issue?" 103 101 hx-swap="none">
+43 -24
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 12 - <div class="flex gap-4"> 11 + {{ $active := "closed" }} 12 + {{ if .FilteringByOpen }} 13 + {{ $active = "open" }} 14 + {{ end }} 15 + 16 + {{ $open := 17 + (dict 18 + "Key" "open" 19 + "Value" "open" 20 + "Icon" "circle-dot" 21 + "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 + {{ $closed := 23 + (dict 24 + "Key" "closed" 25 + "Value" "closed" 26 + "Icon" "ban" 27 + "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 28 + {{ $values := list $open $closed }} 29 + 30 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 + <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 + <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 34 + {{ i "search" "w-4 h-4" }} 35 + </div> 36 + <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 37 + <a 38 + href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 39 + class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 40 + > 41 + {{ i "x" "w-4 h-4" }} 42 + </a> 43 + </form> 44 + <div class="sm:row-start-1"> 45 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 46 + </div> 13 47 <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 - > 17 - {{ i "circle-dot" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 19 - </a> 20 - <a 21 - href="?state=closed" 22 - class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 - > 24 - {{ i "ban" "w-4 h-4" }} 25 - <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 26 - </a> 27 - </div> 28 - <a 29 48 href="/{{ .RepoInfo.FullName }}/issues/new" 30 - class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 31 - > 49 + class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 50 + > 32 51 {{ i "circle-plus" "w-4 h-4" }} 33 52 <span>new</span> 34 - </a> 35 - </div> 36 - <div class="error" id="issues"></div> 53 + </a> 54 + </div> 55 + <div class="error" id="issues"></div> 37 56 {{ end }} 38 57 39 58 {{ define "repoAfter" }} ··· 55 74 <a 56 75 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 76 hx-boost="true" 58 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 77 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 59 78 > 60 79 {{ i "chevron-left" "w-4 h-4" }} 61 80 previous ··· 69 88 <a 70 89 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 71 90 hx-boost="true" 72 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 91 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 73 92 > 74 93 next 75 94 {{ i "chevron-right" "w-4 h-4" }}
+6 -6
appview/pages/templates/repo/log.html
··· 27 27 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 28 <div class="{{ $grid }} py-3"> 29 29 <div class="align-top truncate col-span-2"> 30 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 - {{ if $didOrHandle }} 32 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 30 + {{ $did := index $.EmailToDid $commit.Author.Email }} 31 + {{ if $did }} 32 + {{ template "user/fragments/picHandleLink" $did }} 33 33 {{ else }} 34 34 <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 35 {{ end }} ··· 153 153 </span> 154 154 <span class="mx-2 before:content-['·'] before:select-none"></span> 155 155 <span> 156 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 157 - <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 156 + {{ $did := index $.EmailToDid $commit.Author.Email }} 157 + <a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 158 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 - {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 159 + {{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 160 </a> 161 161 </span> 162 162 <div class="inline-block px-1 select-none after:content-['·']"></div>
+7 -6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 2 2 <div id="lines" hx-swap-oob="beforeend"> 3 3 <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 4 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 - <div class="group-open:hidden flex items-center gap-1"> 6 - {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 - </div> 8 - <div class="hidden group-open:flex items-center gap-1"> 9 - {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 - </div> 5 + <div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div> 6 + <div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div> 11 7 </summary> 12 8 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 9 </details> 14 10 </div> 15 11 {{ end }} 12 + 13 + {{ define "stepHeader" }} 14 + {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 15 + <span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span> 16 + {{ end }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
··· 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 12 {{ range .Pipelines }} 13 13 {{ block "pipeline" (list $ .) }} {{ end }} 14 14 {{ else }} 15 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 16 - No pipelines run for this repository. 17 - </p> 15 + <div class="py-6 w-fit flex flex-col gap-4 mx-auto"> 16 + <p> 17 + No pipelines have been run for this repository yet. To get started: 18 + </p> 19 + {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 20 + <p> 21 + <span class="{{ $bullet }}">1</span>First, choose a spindle in your 22 + <a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>. 23 + </p> 24 + <p> 25 + <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 27 + </p> 28 + <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 + </div> 18 30 {{ end }} 19 31 </div> 20 32 </div>
+6
appview/pages/templates/repo/pipelines/workflow.html
··· 15 15 {{ block "logs" . }} {{ end }} 16 16 </div> 17 17 </section> 18 + {{ template "fragments/workflow-timers" }} 18 19 {{ end }} 19 20 20 21 {{ define "sidebar" }} ··· 58 59 hx-ext="ws" 59 60 ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 60 61 <div id="lines" class="flex flex-col gap-2"> 62 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 only:flex hidden border border-gray-200 dark:border-gray-700 rounded"> 63 + <span class="flex items-center gap-2"> 64 + {{ i "triangle-alert" "size-4" }} No logs for this workflow 65 + </span> 66 + </div> 61 67 </div> 62 68 </div> 63 69 {{ end }}
+19
appview/pages/templates/repo/pulls/fragments/og.html
··· 1 + {{ define "repo/pulls/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }} 3 + {{ $description := or .Pull.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+81 -83
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div class="relative w-fit"> 26 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 27 - <button 28 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 29 - hx-target="#actions-{{$roundNumber}}" 30 - hx-swap="outerHtml" 31 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 32 - {{ i "message-square-plus" "w-4 h-4" }} 33 - <span>comment</span> 34 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 - </button> 36 - {{ if .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 }} 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 }} 62 61 63 - {{ if and $isPullAuthor $isOpen $isLastRound }} 64 - {{ $disabled := "" }} 65 - {{ if $isUpToDate }} 66 - {{ $disabled = "disabled" }} 62 + {{ if and $isPullAuthor $isOpen $isLastRound }} 63 + {{ $disabled := "" }} 64 + {{ if $isUpToDate }} 65 + {{ $disabled = "disabled" }} 66 + {{ end }} 67 + <button id="resubmitBtn" 68 + {{ if not .Pull.IsPatchBased }} 69 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 70 + {{ else }} 71 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 72 + hx-target="#actions-{{$roundNumber}}" 73 + hx-swap="outerHtml" 67 74 {{ 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 75 77 - hx-disabled-elt="#resubmitBtn" 78 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 76 + hx-disabled-elt="#resubmitBtn" 77 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 79 78 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 }} 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 }} 91 90 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 }} 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 }} 102 101 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> 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 }} 114 112 </div> 115 113 {{ end }} 116 114
+12 -10
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 42 {{ if not .Pull.IsPatchBased }} 43 43 from 44 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 - {{ if .Pull.IsForkBased }} 46 - {{ if .Pull.PullSource.Repo }} 47 - {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 - <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 - {{- else -}} 50 - <span class="italic">[deleted fork]</span> 51 - {{- end -}} 52 - {{- end -}} 53 - {{- .Pull.PullSource.Branch -}} 45 + {{ if not .Pull.IsForkBased }} 46 + {{ $repoPath := .RepoInfo.FullName }} 47 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 48 + {{ else if .Pull.PullSource.Repo }} 49 + {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }} 50 + <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 51 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 52 + {{ else }} 53 + <span class="italic">[deleted fork]</span>: 54 + {{ .Pull.PullSource.Branch }} 55 + {{ end }} 54 56 </span> 55 57 {{ end }} 56 58 </span> ··· 73 75 "Kind" $kind 74 76 "Count" $reactionData.Count 75 77 "IsReacted" (index $.UserReacted $kind) 76 - "ThreadAt" $.Pull.PullAt 78 + "ThreadAt" $.Pull.AtUri 77 79 "Users" $reactionData.Users) 78 80 }} 79 81 {{ end }}
+3 -5
appview/pages/templates/repo/pulls/pull.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "extrameta" }} 6 - {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 9 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 6 + {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 10 7 {{ end }} 11 8 12 9 {{ define "repoContentLayout" }} ··· 21 18 {{ template "repo/fragments/labelPanel" 22 19 (dict "RepoInfo" $.RepoInfo 23 20 "Defs" $.LabelDefs 24 - "Subject" $.Pull.PullAt 21 + "Subject" $.Pull.AtUri 25 22 "State" $.Pull.Labels) }} 26 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 27 25 </div> 28 26 </div> 29 27 {{ end }}
+52 -34
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center"> 12 - <div class="flex gap-4"> 13 - <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 - > 17 - {{ i "git-pull-request" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.PullCount.Open }} open</span> 19 - </a> 20 - <a 21 - href="?state=merged" 22 - class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 - > 24 - {{ i "git-merge" "w-4 h-4" }} 25 - <span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span> 26 - </a> 27 - <a 28 - href="?state=closed" 29 - class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 30 - > 31 - {{ i "ban" "w-4 h-4" }} 32 - <span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span> 33 - </a> 34 - </div> 35 - <a 36 - href="/{{ .RepoInfo.FullName }}/pulls/new" 37 - class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 38 - > 39 - {{ i "git-pull-request-create" "w-4 h-4" }} 40 - <span>new</span> 41 - </a> 11 + {{ $active := "closed" }} 12 + {{ if .FilteringBy.IsOpen }} 13 + {{ $active = "open" }} 14 + {{ else if .FilteringBy.IsMerged }} 15 + {{ $active = "merged" }} 16 + {{ end }} 17 + {{ $open := 18 + (dict 19 + "Key" "open" 20 + "Value" "open" 21 + "Icon" "git-pull-request" 22 + "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 + {{ $merged := 24 + (dict 25 + "Key" "merged" 26 + "Value" "merged" 27 + "Icon" "git-merge" 28 + "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 + {{ $closed := 30 + (dict 31 + "Key" "closed" 32 + "Value" "closed" 33 + "Icon" "ban" 34 + "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 35 + {{ $values := list $open $merged $closed }} 36 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 + <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 + <div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> 40 + {{ i "search" "w-4 h-4" }} 41 + </div> 42 + <input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" "> 43 + <a 44 + href="?state={{ .FilteringBy.String }}" 45 + class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 46 + > 47 + {{ i "x" "w-4 h-4" }} 48 + </a> 49 + </form> 50 + <div class="sm:row-start-1"> 51 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 42 52 </div> 43 - <div class="error" id="pulls"></div> 53 + <a 54 + href="/{{ .RepoInfo.FullName }}/pulls/new" 55 + class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 56 + > 57 + {{ i "git-pull-request-create" "w-4 h-4" }} 58 + <span>new</span> 59 + </a> 60 + </div> 61 + <div class="error" id="pulls"></div> 44 62 {{ end }} 45 63 46 64 {{ define "repoAfter" }} ··· 133 151 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 134 152 </div> 135 153 </summary> 136 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 154 + {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 137 155 </details> 138 156 {{ end }} 139 157 {{ end }} ··· 142 160 </div> 143 161 {{ end }} 144 162 145 - {{ define "pullList" }} 163 + {{ define "stackedPullList" }} 146 164 {{ $list := index . 0 }} 147 165 {{ $root := index . 1 }} 148 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 66 <div 67 67 id="add-collaborator-modal" 68 68 popover 69 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 69 + class=" 70 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 71 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 72 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 70 73 {{ template "addCollaboratorModal" . }} 71 74 </div> 72 75 {{ end }} ··· 82 85 ADD COLLABORATOR 83 86 </label> 84 87 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 - <input 86 - type="text" 87 - id="add-collaborator" 88 - name="collaborator" 89 - required 90 - placeholder="@foo.bsky.social" 91 - /> 88 + <actor-typeahead> 89 + <input 90 + autocapitalize="none" 91 + autocorrect="off" 92 + autocomplete="off" 93 + type="text" 94 + id="add-collaborator" 95 + name="collaborator" 96 + required 97 + placeholder="user.tngl.sh" 98 + class="w-full" 99 + /> 100 + </actor-typeahead> 92 101 <div class="flex gap-2 pt-2"> 93 102 <button 94 103 type="button"
+47
appview/pages/templates/repo/settings/general.html
··· 6 6 {{ template "repo/settings/fragments/sidebar" . }} 7 7 </div> 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "baseSettings" . }} 9 10 {{ template "branchSettings" . }} 10 11 {{ template "defaultLabelSettings" . }} 11 12 {{ template "customLabelSettings" . }} ··· 13 14 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 15 </div> 15 16 </section> 17 + {{ end }} 18 + 19 + {{ define "baseSettings" }} 20 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none"> 21 + <fieldset 22 + class="" 23 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 24 + > 25 + <h2 class="text-sm pb-2 uppercase font-bold">Description</h2> 26 + <textarea 27 + rows="3" 28 + class="w-full mb-2" 29 + id="base-form-description" 30 + name="description" 31 + >{{ .RepoInfo.Description }}</textarea> 32 + <h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2> 33 + <input 34 + type="text" 35 + class="w-full mb-2" 36 + id="base-form-website" 37 + name="website" 38 + value="{{ .RepoInfo.Website }}" 39 + > 40 + <h2 class="text-sm pb-2 uppercase font-bold">Topics</h2> 41 + <p class="text-gray-500 dark:text-gray-400"> 42 + List of topics separated by spaces. 43 + </p> 44 + <textarea 45 + rows="2" 46 + class="w-full my-2" 47 + id="base-form-topics" 48 + name="topics" 49 + >{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea> 50 + <div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div> 51 + <div class="flex justify-end pt-2"> 52 + <button 53 + type="submit" 54 + class="btn-create flex items-center gap-2 group" 55 + > 56 + {{ i "save" "w-4 h-4" }} 57 + save 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + </button> 60 + </div> 61 + <fieldset> 62 + </form> 16 63 {{ end }} 17 64 18 65 {{ define "branchSettings" }}
+8
appview/pages/templates/repo/tree.html
··· 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} 61 61 62 + {{ if .IsSubmodule }} 63 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 64 + {{ $icon = "folder-input" }} 65 + {{ $iconStyle = "size-4" }} 66 + {{ end }} 67 + 62 68 {{ if .IsFile }} 69 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 63 70 {{ $icon = "file" }} 64 71 {{ $iconStyle = "size-4" }} 65 72 {{ end }} 73 + 66 74 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 75 <div class="flex items-center gap-2"> 68 76 {{ i $icon $iconStyle "flex-shrink-0" }}
+16 -8
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 16 + class=" 17 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 17 19 {{ block "addSpindleMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 39 47 <div class="flex gap-2 pt-2"> 40 48 <button 41 49 type="button"
+3 -3
appview/pages/templates/strings/string.html
··· 47 47 </span> 48 48 </section> 49 49 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 - <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 50 + <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 51 <span> 52 52 {{ .String.Filename }} 53 53 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> ··· 75 75 </div> 76 76 <div class="overflow-x-auto overflow-y-hidden relative"> 77 77 {{ if .ShowRendered }} 78 - <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 78 + <div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div> 79 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> 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 81 {{ end }} 82 82 </div> 83 83 {{ template "fragments/multiline-select" }}
+2 -2
appview/pages/templates/timeline/fragments/hero.html
··· 4 4 <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 5 6 6 <p class="text-lg"> 7 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 7 + Tangled is a decentralized Git hosting and collaboration platform. 8 8 </p> 9 9 <p class="text-lg"> 10 - we envision a place where developers have complete ownership of their 10 + We envision a place where developers have complete ownership of their 11 11 code, open source communities can freely self-govern and most 12 12 importantly, coding can be social and fun again. 13 13 </p>
+11
appview/pages/templates/user/fragments/editBio.html
··· 20 20 </div> 21 21 22 22 <div class="flex flex-col gap-1"> 23 + <label class="m-0 p-0" for="pronouns">pronouns</label> 24 + <div class="flex items-center gap-2 w-full"> 25 + {{ $pronouns := "" }} 26 + {{ if and .Profile .Profile.Pronouns }} 27 + {{ $pronouns = .Profile.Pronouns }} 28 + {{ end }} 29 + <input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}"> 30 + </div> 31 + </div> 32 + 33 + <div class="flex flex-col gap-1"> 23 34 <label class="m-0 p-0" for="location">location</label> 24 35 <div class="flex items-center gap-2 w-full"> 25 36 {{ $location := "" }}
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 3 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+19 -6
appview/pages/templates/user/fragments/profileCard.html
··· 12 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 13 {{ $userIdent }} 14 14 </p> 15 - <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 16 20 </div> 17 21 18 22 <div class="md:hidden"> ··· 67 71 {{ end }} 68 72 </div> 69 73 {{ end }} 70 - {{ if ne .FollowStatus.String "IsSelf" }} 71 - {{ template "user/fragments/follow" . }} 72 - {{ else }} 74 + 75 + <div class="flex mt-2 items-center gap-2"> 76 + {{ if ne .FollowStatus.String "IsSelf" }} 77 + {{ template "user/fragments/follow" . }} 78 + {{ else }} 73 79 <button id="editBtn" 74 - class="btn mt-2 w-full flex items-center gap-2 group" 80 + class="btn w-full flex items-center gap-2 group" 75 81 hx-target="#profile-bio" 76 82 hx-get="/profile/edit-bio" 77 83 hx-swap="innerHTML"> ··· 79 85 edit 80 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 81 87 </button> 82 - {{ end }} 88 + {{ end }} 89 + 90 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 + href="/{{ $userIdent }}/feed.atom"> 92 + {{ i "rss" "size-4" }} 93 + </a> 94 + </div> 95 + 83 96 </div> 84 97 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 98 </div>
+23 -2
appview/pages/templates/user/login.html
··· 13 13 <title>login &middot; tangled</title> 14 14 </head> 15 15 <body class="flex items-center justify-center min-h-screen"> 16 - <main class="max-w-md px-6 -mt-4"> 16 + <main class="max-w-md px-7 mt-4"> 17 17 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 18 {{ template "fragments/logotype" }} 19 19 </h1> ··· 21 21 tightly-knit social coding. 22 22 </h2> 23 23 <form 24 - class="mt-4 max-w-sm mx-auto" 24 + class="mt-4" 25 25 hx-post="/login" 26 26 hx-swap="none" 27 27 hx-disabled-elt="#login-button" ··· 29 29 <div class="flex flex-col"> 30 30 <label for="handle">handle</label> 31 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 32 35 type="text" 33 36 id="handle" 34 37 name="handle" ··· 53 56 <span>login</span> 54 57 </button> 55 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 }} 56 77 <p class="text-sm text-gray-500"> 57 78 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 58 79 </p>
+14
appview/pages/templates/user/settings/notifications.html
··· 144 144 <div class="flex items-center justify-between p-2"> 145 145 <div class="flex items-center gap-2"> 146 146 <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Mentions</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>When someone mentions you.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 155 + </label> 156 + </div> 157 + 158 + <div class="flex items-center justify-between p-2"> 159 + <div class="flex items-center gap-2"> 160 + <div class="flex flex-col gap-1"> 147 161 <span class="font-bold">Email notifications</span> 148 162 <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 163 <span>Receive notifications via email in addition to in-app notifications.</span>
+46
appview/pagination/page.go
··· 1 1 package pagination 2 2 3 + import "context" 4 + 3 5 type Page struct { 4 6 Offset int // where to start from 5 7 Limit int // number of items in a page ··· 10 12 Offset: 0, 11 13 Limit: 30, 12 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 13 36 } 14 37 15 38 func (p Page) Previous() Page { ··· 29 52 Limit: p.Limit, 30 53 } 31 54 } 55 + 56 + func IterateAll[T any]( 57 + fetch func(page Page) ([]T, error), 58 + handle func(items []T) error, 59 + ) error { 60 + page := FirstPage() 61 + for { 62 + items, err := fetch(page) 63 + if err != nil { 64 + return err 65 + } 66 + 67 + err = handle(items) 68 + if err != nil { 69 + return err 70 + } 71 + if len(items) < page.Limit { 72 + break 73 + } 74 + page = page.Next() 75 + } 76 + return nil 77 + }
+34 -13
appview/pipelines/pipelines.go
··· 35 35 logger *slog.Logger 36 36 } 37 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 + 38 47 func New( 39 48 oauth *oauth.OAuth, 40 49 repoResolver *reporesolver.RepoResolver, ··· 227 236 // start a goroutine to read from spindle 228 237 go readLogs(spindleConn, evChan) 229 238 230 - stepIdx := 0 239 + stepStartTimes := make(map[int]time.Time) 231 240 var fragment bytes.Buffer 232 241 for { 233 242 select { ··· 259 268 260 269 switch logLine.Kind { 261 270 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 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 + }) 267 293 } 268 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 269 - Id: stepIdx, 270 - Name: logLine.Content, 271 - Command: logLine.StepCommand, 272 - Collapsed: collapsed, 273 - }) 294 + 274 295 case spindlemodel.LogKindData: 275 296 // data messages simply insert new log lines into current step 276 297 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 277 - Id: stepIdx, 298 + Id: logLine.StepId, 278 299 Content: logLine.Content, 279 300 }) 280 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 - }
+321
appview/pulls/opengraph.go
··· 1 + package pulls 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/appview/ogcard" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 18 + ) 19 + 20 + func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) { 21 + width, height := ogcard.DefaultSize() 22 + mainCard, err := ogcard.NewCard(width, height) 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + // Split: content area (75%) and status/stats area (25%) 28 + contentCard, statsArea := mainCard.Split(false, 75) 29 + 30 + // Add padding to content 31 + contentCard.SetMargin(50) 32 + 33 + // Split content horizontally: main content (80%) and avatar area (20%) 34 + mainContent, avatarArea := contentCard.Split(true, 80) 35 + 36 + // Add margin to main content 37 + mainContent.SetMargin(10) 38 + 39 + // Use full main content area for repo name and title 40 + bounds := mainContent.Img.Bounds() 41 + startX := bounds.Min.X + mainContent.Margin 42 + startY := bounds.Min.Y + mainContent.Margin 43 + 44 + // Draw full repository name at top (owner/repo format) 45 + var repoOwner string 46 + owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did) 47 + if err != nil { 48 + repoOwner = repo.Did 49 + } else { 50 + repoOwner = "@" + owner.Handle.String() 51 + } 52 + 53 + fullRepoName := repoOwner + " / " + repo.Name 54 + if len(fullRepoName) > 60 { 55 + fullRepoName = fullRepoName[:60] + "…" 56 + } 57 + 58 + grayColor := color.RGBA{88, 96, 105, 255} 59 + err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 60 + if err != nil { 61 + return nil, err 62 + } 63 + 64 + // Draw pull request title below repo name with wrapping 65 + titleY := startY + 60 66 + titleX := startX 67 + 68 + // Truncate title if too long 69 + pullTitle := pull.Title 70 + maxTitleLength := 80 71 + if len(pullTitle) > maxTitleLength { 72 + pullTitle = pullTitle[:maxTitleLength] + "…" 73 + } 74 + 75 + // Create a temporary card for the title area to enable wrapping 76 + titleBounds := mainContent.Img.Bounds() 77 + titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 78 + titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID 79 + 80 + titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 81 + titleCard := &ogcard.Card{ 82 + Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 83 + Font: mainContent.Font, 84 + Margin: 0, 85 + } 86 + 87 + // Draw wrapped title 88 + lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) 89 + if err != nil { 90 + return nil, err 91 + } 92 + 93 + // Calculate where title ends (number of lines * line height) 94 + lineHeight := 60 // Approximate line height for 54pt font 95 + titleEndY := titleY + (len(lines) * lineHeight) + 10 96 + 97 + // Draw pull ID in gray below the title 98 + pullIdText := fmt.Sprintf("#%d", pull.PullId) 99 + err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 100 + if err != nil { 101 + return nil, err 102 + } 103 + 104 + // Get pull author handle (needed for avatar and metadata) 105 + var authorHandle string 106 + author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) 107 + if err != nil { 108 + authorHandle = pull.OwnerDid 109 + } else { 110 + authorHandle = "@" + author.Handle.String() 111 + } 112 + 113 + // Draw avatar circle on the right side 114 + avatarBounds := avatarArea.Img.Bounds() 115 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 116 + if avatarSize > 220 { 117 + avatarSize = 220 118 + } 119 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 120 + avatarY := avatarBounds.Min.Y + 20 121 + 122 + // Get avatar URL for pull author 123 + avatarURL := s.pages.AvatarUrl(authorHandle, "256") 124 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 125 + if err != nil { 126 + log.Printf("failed to draw avatar (non-fatal): %v", err) 127 + } 128 + 129 + // Split stats area: left side for status/stats (80%), right side for dolly (20%) 130 + statusStatsArea, dollyArea := statsArea.Split(true, 80) 131 + 132 + // Draw status and stats 133 + statsBounds := statusStatsArea.Img.Bounds() 134 + statsX := statsBounds.Min.X + 60 // left padding 135 + statsY := statsBounds.Min.Y 136 + 137 + iconColor := color.RGBA{88, 96, 105, 255} 138 + iconSize := 36 139 + textSize := 36.0 140 + labelSize := 28.0 141 + iconBaselineOffset := int(textSize) / 2 142 + 143 + // Draw status (open/merged/closed) with colored icon and text 144 + var statusIcon string 145 + var statusText string 146 + var statusColor color.RGBA 147 + 148 + if pull.State.IsOpen() { 149 + statusIcon = "git-pull-request" 150 + statusText = "open" 151 + statusColor = color.RGBA{34, 139, 34, 255} // green 152 + } else if pull.State.IsMerged() { 153 + statusIcon = "git-merge" 154 + statusText = "merged" 155 + statusColor = color.RGBA{138, 43, 226, 255} // purple 156 + } else { 157 + statusIcon = "git-pull-request-closed" 158 + statusText = "closed" 159 + statusColor = color.RGBA{128, 128, 128, 255} // gray 160 + } 161 + 162 + statusIconSize := 36 163 + 164 + // Draw icon with status color 165 + err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 166 + if err != nil { 167 + log.Printf("failed to draw status icon: %v", err) 168 + } 169 + 170 + // Draw text with status color 171 + textX := statsX + statusIconSize + 12 172 + statusTextSize := 32.0 173 + err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left) 174 + if err != nil { 175 + log.Printf("failed to draw status text: %v", err) 176 + } 177 + 178 + statusTextWidth := len(statusText) * 20 179 + currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 180 + 181 + // Draw comment count 182 + err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 183 + if err != nil { 184 + log.Printf("failed to draw comment icon: %v", err) 185 + } 186 + 187 + currentX += iconSize + 15 188 + commentText := fmt.Sprintf("%d comments", commentCount) 189 + if commentCount == 1 { 190 + commentText = "1 comment" 191 + } 192 + err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 193 + if err != nil { 194 + log.Printf("failed to draw comment text: %v", err) 195 + } 196 + 197 + commentTextWidth := len(commentText) * 20 198 + currentX += commentTextWidth + 40 199 + 200 + // Draw files changed 201 + err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 202 + if err != nil { 203 + log.Printf("failed to draw file diff icon: %v", err) 204 + } 205 + 206 + currentX += iconSize + 15 207 + filesText := fmt.Sprintf("%d files", filesChanged) 208 + if filesChanged == 1 { 209 + filesText = "1 file" 210 + } 211 + err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 212 + if err != nil { 213 + log.Printf("failed to draw files text: %v", err) 214 + } 215 + 216 + filesTextWidth := len(filesText) * 20 217 + currentX += filesTextWidth 218 + 219 + // Draw additions (green +) 220 + greenColor := color.RGBA{34, 139, 34, 255} 221 + additionsText := fmt.Sprintf("+%d", diffStats.Insertions) 222 + err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left) 223 + if err != nil { 224 + log.Printf("failed to draw additions text: %v", err) 225 + } 226 + 227 + additionsTextWidth := len(additionsText) * 20 228 + currentX += additionsTextWidth + 30 229 + 230 + // Draw deletions (red -) right next to additions 231 + redColor := color.RGBA{220, 20, 60, 255} 232 + deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) 233 + err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left) 234 + if err != nil { 235 + log.Printf("failed to draw deletions text: %v", err) 236 + } 237 + 238 + // Draw dolly logo on the right side 239 + dollyBounds := dollyArea.Img.Bounds() 240 + dollySize := 90 241 + dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 242 + dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 243 + dollyColor := color.RGBA{180, 180, 180, 255} // light gray 244 + err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 245 + if err != nil { 246 + log.Printf("dolly silhouette not available (this is ok): %v", err) 247 + } 248 + 249 + // Draw "opened by @author" and date at the bottom with more spacing 250 + labelY := statsY + iconSize + 30 251 + 252 + // Format the opened date 253 + openedDate := pull.Created.Format("Jan 2, 2006") 254 + metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 255 + 256 + err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 257 + if err != nil { 258 + log.Printf("failed to draw metadata: %v", err) 259 + } 260 + 261 + return mainCard, nil 262 + } 263 + 264 + func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 265 + f, err := s.repoResolver.Resolve(r) 266 + if err != nil { 267 + log.Println("failed to get repo and knot", err) 268 + return 269 + } 270 + 271 + pull, ok := r.Context().Value("pull").(*models.Pull) 272 + if !ok { 273 + log.Println("pull not found in context") 274 + http.Error(w, "pull not found", http.StatusNotFound) 275 + return 276 + } 277 + 278 + // Get comment count from database 279 + comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID)) 280 + if err != nil { 281 + log.Printf("failed to get pull comments: %v", err) 282 + } 283 + commentCount := len(comments) 284 + 285 + // Calculate diff stats from latest submission using patchutil 286 + var diffStats types.DiffStat 287 + filesChanged := 0 288 + if len(pull.Submissions) > 0 { 289 + latestSubmission := pull.Submissions[len(pull.Submissions)-1] 290 + niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 291 + diffStats.Insertions = int64(niceDiff.Stat.Insertions) 292 + diffStats.Deletions = int64(niceDiff.Stat.Deletions) 293 + filesChanged = niceDiff.Stat.FilesChanged 294 + } 295 + 296 + card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged) 297 + if err != nil { 298 + log.Println("failed to draw pull summary card", err) 299 + http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError) 300 + return 301 + } 302 + 303 + var imageBuffer bytes.Buffer 304 + err = png.Encode(&imageBuffer, card.Img) 305 + if err != nil { 306 + log.Println("failed to encode pull summary card", err) 307 + http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) 308 + return 309 + } 310 + 311 + imageBytes := imageBuffer.Bytes() 312 + 313 + w.Header().Set("Content-Type", "image/png") 314 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 315 + w.WriteHeader(http.StatusOK) 316 + _, err = w.Write(imageBytes) 317 + if err != nil { 318 + log.Println("failed to write pull summary card", err) 319 + return 320 + } 321 + }
+139 -101
appview/pulls/pulls.go
··· 17 17 "tangled.org/core/api/tangled" 18 18 "tangled.org/core/appview/config" 19 19 "tangled.org/core/appview/db" 20 + pulls_indexer "tangled.org/core/appview/indexer/pulls" 20 21 "tangled.org/core/appview/models" 21 22 "tangled.org/core/appview/notify" 22 23 "tangled.org/core/appview/oauth" 23 24 "tangled.org/core/appview/pages" 24 25 "tangled.org/core/appview/pages/markup" 25 26 "tangled.org/core/appview/reporesolver" 27 + "tangled.org/core/appview/validator" 26 28 "tangled.org/core/appview/xrpcclient" 27 29 "tangled.org/core/idresolver" 28 30 "tangled.org/core/patchutil" ··· 31 33 "tangled.org/core/types" 32 34 33 35 comatproto "github.com/bluesky-social/indigo/api/atproto" 36 + "github.com/bluesky-social/indigo/atproto/syntax" 34 37 lexutil "github.com/bluesky-social/indigo/lex/util" 35 38 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 36 39 "github.com/go-chi/chi/v5" ··· 47 50 notifier notify.Notifier 48 51 enforcer *rbac.Enforcer 49 52 logger *slog.Logger 53 + validator *validator.Validator 54 + indexer *pulls_indexer.Indexer 50 55 } 51 56 52 57 func New( ··· 58 63 config *config.Config, 59 64 notifier notify.Notifier, 60 65 enforcer *rbac.Enforcer, 66 + validator *validator.Validator, 67 + indexer *pulls_indexer.Indexer, 61 68 logger *slog.Logger, 62 69 ) *Pulls { 63 70 return &Pulls{ ··· 70 77 notifier: notifier, 71 78 enforcer: enforcer, 72 79 logger: logger, 80 + validator: validator, 81 + indexer: indexer, 73 82 } 74 83 } 75 84 ··· 145 154 stack, _ := r.Context().Value("stack").(models.Stack) 146 155 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 147 156 148 - totalIdents := 1 149 - for _, submission := range pull.Submissions { 150 - totalIdents += len(submission.Comments) 151 - } 152 - 153 - identsToResolve := make([]string, totalIdents) 154 - 155 - // populate idents 156 - identsToResolve[0] = pull.OwnerDid 157 - idx := 1 158 - for _, submission := range pull.Submissions { 159 - for _, comment := range submission.Comments { 160 - identsToResolve[idx] = comment.OwnerDid 161 - idx += 1 162 - } 163 - } 164 - 165 157 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 166 158 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 167 159 resubmitResult := pages.Unknown ··· 200 192 m[p.Sha] = p 201 193 } 202 194 203 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 195 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 204 196 if err != nil { 205 197 log.Println("failed to get pull reactions") 206 198 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 208 200 209 201 userReactions := map[models.ReactionKind]bool{} 210 202 if user != nil { 211 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 203 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 212 204 } 213 205 214 206 labelDefs, err := db.GetLabelDefinitions( ··· 459 451 return 460 452 } 461 453 462 - patch := pull.Submissions[roundIdInt].Patch 454 + patch := pull.Submissions[roundIdInt].CombinedPatch() 463 455 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 464 456 465 457 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ ··· 510 502 return 511 503 } 512 504 513 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 505 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 514 506 if err != nil { 515 507 log.Println("failed to interdiff; current patch malformed") 516 508 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 517 509 return 518 510 } 519 511 520 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 512 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 521 513 if err != nil { 522 514 log.Println("failed to interdiff; previous patch malformed") 523 515 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 557 549 } 558 550 559 551 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 552 + l := s.logger.With("handler", "RepoPulls") 553 + 560 554 user := s.oauth.GetUser(r) 561 555 params := r.URL.Query() 562 556 ··· 574 568 return 575 569 } 576 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 + 577 598 pulls, err := db.GetPulls( 578 599 s.db, 579 - db.FilterEq("repo_at", f.RepoAt()), 580 - db.FilterEq("state", state), 600 + db.FilterIn("id", ids), 581 601 ) 582 602 if err != nil { 583 603 log.Println("failed to get pulls", err) ··· 664 684 Pulls: pulls, 665 685 LabelDefs: defs, 666 686 FilteringBy: state, 687 + FilterQuery: keyword, 667 688 Stacks: stacks, 668 689 Pipelines: m, 669 690 }) 670 691 } 671 692 672 693 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 694 + l := s.logger.With("handler", "PullComment") 673 695 user := s.oauth.GetUser(r) 674 696 f, err := s.repoResolver.Resolve(r) 675 697 if err != nil { ··· 719 741 720 742 createdAt := time.Now().Format(time.RFC3339) 721 743 722 - pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 723 - if err != nil { 724 - log.Println("failed to get pull at", err) 725 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 726 - return 727 - } 728 - 729 744 client, err := s.oauth.AuthorizedClient(r) 730 745 if err != nil { 731 746 log.Println("failed to get authorized client", err) ··· 738 753 Rkey: tid.TID(), 739 754 Record: &lexutil.LexiconTypeDecoder{ 740 755 Val: &tangled.RepoPullComment{ 741 - Pull: string(pullAt), 756 + Pull: pull.AtUri().String(), 742 757 Body: body, 743 758 CreatedAt: createdAt, 744 759 }, ··· 774 789 return 775 790 } 776 791 777 - s.notifier.NewPullComment(r.Context(), comment) 792 + rawMentions := markup.FindUserMentions(comment.Body) 793 + idents := s.idResolver.ResolveIdents(r.Context(), rawMentions) 794 + l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 795 + var mentions []syntax.DID 796 + for _, ident := range idents { 797 + if ident != nil && !ident.Handle.IsInvalidHandle() { 798 + mentions = append(mentions, ident.DID) 799 + } 800 + } 801 + s.notifier.NewPullComment(r.Context(), comment, mentions) 778 802 779 803 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 780 804 return ··· 986 1010 } 987 1011 988 1012 sourceRev := comparison.Rev2 989 - patch := comparison.Patch 1013 + patch := comparison.FormatPatchRaw 1014 + combined := comparison.CombinedPatchRaw 990 1015 991 - if !patchutil.IsPatchValid(patch) { 1016 + if err := s.validator.ValidatePatch(&patch); err != nil { 1017 + s.logger.Error("failed to validate patch", "err", err) 992 1018 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 993 1019 return 994 1020 } ··· 1001 1027 Sha: comparison.Rev2, 1002 1028 } 1003 1029 1004 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1030 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1005 1031 } 1006 1032 1007 1033 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1008 - if !patchutil.IsPatchValid(patch) { 1034 + if err := s.validator.ValidatePatch(&patch); err != nil { 1035 + s.logger.Error("patch validation failed", "err", err) 1009 1036 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1010 1037 return 1011 1038 } 1012 1039 1013 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 1040 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1014 1041 } 1015 1042 1016 1043 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { ··· 1093 1120 } 1094 1121 1095 1122 sourceRev := comparison.Rev2 1096 - patch := comparison.Patch 1123 + patch := comparison.FormatPatchRaw 1124 + combined := comparison.CombinedPatchRaw 1097 1125 1098 - if !patchutil.IsPatchValid(patch) { 1126 + if err := s.validator.ValidatePatch(&patch); err != nil { 1127 + s.logger.Error("failed to validate patch", "err", err) 1099 1128 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1100 1129 return 1101 1130 } ··· 1113 1142 Sha: sourceRev, 1114 1143 } 1115 1144 1116 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1145 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1117 1146 } 1118 1147 1119 1148 func (s *Pulls) createPullRequest( ··· 1123 1152 user *oauth.User, 1124 1153 title, body, targetBranch string, 1125 1154 patch string, 1155 + combined string, 1126 1156 sourceRev string, 1127 1157 pullSource *models.PullSource, 1128 1158 recordPullSource *tangled.RepoPull_Source, ··· 1182 1212 rkey := tid.TID() 1183 1213 initialSubmission := models.PullSubmission{ 1184 1214 Patch: patch, 1215 + Combined: combined, 1185 1216 SourceRev: sourceRev, 1186 1217 } 1187 1218 pull := &models.Pull{ ··· 1357 1388 return 1358 1389 } 1359 1390 1360 - if patch == "" || !patchutil.IsPatchValid(patch) { 1391 + if err := s.validator.ValidatePatch(&patch); err != nil { 1392 + s.logger.Error("faield to validate patch", "err", err) 1361 1393 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1362 1394 return 1363 1395 } ··· 1611 1643 1612 1644 patch := r.FormValue("patch") 1613 1645 1614 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1646 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1615 1647 } 1616 1648 1617 1649 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1672 1704 } 1673 1705 1674 1706 sourceRev := comparison.Rev2 1675 - patch := comparison.Patch 1707 + patch := comparison.FormatPatchRaw 1708 + combined := comparison.CombinedPatchRaw 1676 1709 1677 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1710 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1678 1711 } 1679 1712 1680 1713 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1706 1739 return 1707 1740 } 1708 1741 1709 - // extract patch by performing compare 1710 - forkScheme := "http" 1711 - if !s.config.Core.Dev { 1712 - forkScheme = "https" 1713 - } 1714 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1715 - forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1716 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1717 - if err != nil { 1718 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1719 - log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1720 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1721 - return 1722 - } 1723 - log.Printf("failed to compare branches: %s", err) 1724 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1725 - return 1726 - } 1727 - 1728 - var forkComparison types.RepoFormatPatchResponse 1729 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1730 - log.Println("failed to decode XRPC compare response for fork", err) 1731 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1732 - return 1733 - } 1734 - 1735 1742 // update the hidden tracking branch to latest 1736 1743 client, err := s.oauth.ServiceClient( 1737 1744 r, ··· 1763 1770 return 1764 1771 } 1765 1772 1766 - // Use the fork comparison we already made 1767 - comparison := forkComparison 1768 - 1769 - sourceRev := comparison.Rev2 1770 - patch := comparison.Patch 1771 - 1772 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1773 - } 1774 - 1775 - // validate a resubmission against a pull request 1776 - func validateResubmittedPatch(pull *models.Pull, patch string) error { 1777 - if patch == "" { 1778 - return fmt.Errorf("Patch is empty.") 1773 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1774 + // extract patch by performing compare 1775 + forkScheme := "http" 1776 + if !s.config.Core.Dev { 1777 + forkScheme = "https" 1779 1778 } 1780 - 1781 - if patch == pull.LatestPatch() { 1782 - return fmt.Errorf("Patch is identical to previous submission.") 1779 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1780 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1781 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1782 + if err != nil { 1783 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1784 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1785 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1786 + return 1787 + } 1788 + log.Printf("failed to compare branches: %s", err) 1789 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1790 + return 1783 1791 } 1784 1792 1785 - if !patchutil.IsPatchValid(patch) { 1786 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1793 + var forkComparison types.RepoFormatPatchResponse 1794 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1795 + log.Println("failed to decode XRPC compare response for fork", err) 1796 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1797 + return 1787 1798 } 1788 1799 1789 - return nil 1800 + // Use the fork comparison we already made 1801 + comparison := forkComparison 1802 + 1803 + sourceRev := comparison.Rev2 1804 + patch := comparison.FormatPatchRaw 1805 + combined := comparison.CombinedPatchRaw 1806 + 1807 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1790 1808 } 1791 1809 1792 1810 func (s *Pulls) resubmitPullHelper( ··· 1796 1814 user *oauth.User, 1797 1815 pull *models.Pull, 1798 1816 patch string, 1817 + combined string, 1799 1818 sourceRev string, 1800 1819 ) { 1801 1820 if pull.IsStacked() { ··· 1804 1823 return 1805 1824 } 1806 1825 1807 - if err := validateResubmittedPatch(pull, patch); err != nil { 1826 + if err := s.validator.ValidatePatch(&patch); err != nil { 1808 1827 s.pages.Notice(w, "resubmit-error", err.Error()) 1809 1828 return 1810 1829 } 1811 1830 1831 + if patch == pull.LatestPatch() { 1832 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1833 + return 1834 + } 1835 + 1812 1836 // validate sourceRev if branch/fork based 1813 1837 if pull.IsBranchBased() || pull.IsForkBased() { 1814 1838 if sourceRev == pull.LatestSha() { ··· 1825 1849 } 1826 1850 defer tx.Rollback() 1827 1851 1828 - pull.Submissions = append(pull.Submissions, &models.PullSubmission{ 1829 - Patch: patch, 1830 - SourceRev: sourceRev, 1831 - }) 1832 - err = db.ResubmitPull(tx, pull) 1852 + pullAt := pull.AtUri() 1853 + newRoundNumber := len(pull.Submissions) 1854 + newPatch := patch 1855 + newSourceRev := sourceRev 1856 + combinedPatch := combined 1857 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1833 1858 if err != nil { 1834 1859 log.Println("failed to create pull request", err) 1835 1860 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 2020 2045 continue 2021 2046 } 2022 2047 2023 - // resubmit the old pull 2024 - err := db.ResubmitPull(tx, np) 2025 - 2048 + // resubmit the new pull 2049 + pullAt := op.AtUri() 2050 + newRoundNumber := len(op.Submissions) 2051 + newPatch := np.LatestPatch() 2052 + combinedPatch := np.LatestSubmission().Combined 2053 + newSourceRev := np.LatestSha() 2054 + err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 2026 2055 if err != nil { 2027 2056 log.Println("failed to update pull", err, op.PullId) 2028 2057 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") ··· 2088 2117 } 2089 2118 2090 2119 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2120 + user := s.oauth.GetUser(r) 2091 2121 f, err := s.repoResolver.Resolve(r) 2092 2122 if err != nil { 2093 2123 log.Println("failed to resolve repo:", err) ··· 2185 2215 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2186 2216 return 2187 2217 } 2218 + p.State = models.PullMerged 2188 2219 } 2189 2220 2190 2221 err = tx.Commit() ··· 2197 2228 2198 2229 // notify about the pull merge 2199 2230 for _, p := range pullsToMerge { 2200 - s.notifier.NewPullMerged(r.Context(), p) 2231 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2201 2232 } 2202 2233 2203 2234 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) ··· 2258 2289 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2259 2290 return 2260 2291 } 2292 + p.State = models.PullClosed 2261 2293 } 2262 2294 2263 2295 // Commit the transaction ··· 2268 2300 } 2269 2301 2270 2302 for _, p := range pullsToClose { 2271 - s.notifier.NewPullClosed(r.Context(), p) 2303 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2272 2304 } 2273 2305 2274 2306 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2330 2362 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2331 2363 return 2332 2364 } 2365 + p.State = models.PullOpen 2333 2366 } 2334 2367 2335 2368 // Commit the transaction ··· 2337 2370 log.Println("failed to commit transaction", err) 2338 2371 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2339 2372 return 2373 + } 2374 + 2375 + for _, p := range pullsToReopen { 2376 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2340 2377 } 2341 2378 2342 2379 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2370 2407 initialSubmission := models.PullSubmission{ 2371 2408 Patch: fp.Raw, 2372 2409 SourceRev: fp.SHA, 2410 + Combined: fp.Raw, 2373 2411 } 2374 2412 pull := models.Pull{ 2375 2413 Title: title,
+1
appview/pulls/router.go
··· 23 23 r.Route("/{pull}", func(r chi.Router) { 24 24 r.Use(mw.ResolvePull()) 25 25 r.Get("/", s.RepoSinglePull) 26 + r.Get("/opengraph", s.PullOpenGraphSummary) 26 27 27 28 r.Route("/round/{round}", func(r chi.Router) { 28 29 r.Get("/", s.RepoPullPatch)
+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 146 return fmt.Sprintf("%s in %s", base, repoName) 147 147 } 148 148 149 - func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 149 + func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 150 150 f, err := rp.repoResolver.Resolve(r) 151 151 if err != nil { 152 152 log.Println("failed to fully resolve repo:", err)
+10 -11
appview/repo/index.go
··· 30 30 "github.com/go-enry/go-enry/v2" 31 31 ) 32 32 33 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 33 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 34 l := rp.logger.With("handler", "RepoIndex") 35 35 36 36 ref := chi.URLParam(r, "ref") ··· 154 154 CommitsTrunc: commitsTrunc, 155 155 TagsTrunc: tagsTrunc, 156 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, 157 + BranchesTrunc: branchesTrunc, 158 + EmailToDid: emailToDidMap, 159 + VerifiedCommits: vc, 160 + Languages: languageInfo, 161 + Pipelines: pipelines, 162 162 }) 163 163 } 164 164 ··· 351 351 if treeResp != nil && treeResp.Files != nil { 352 352 for _, file := range treeResp.Files { 353 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, 354 + Name: file.Name, 355 + Mode: file.Mode, 356 + Size: file.Size, 359 357 } 358 + 360 359 if file.Last_commit != nil { 361 360 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 362 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 + }
-500
appview/repo/ogcard/card.go
··· 1 - // Copyright 2024 The Forgejo Authors. All rights reserved. 2 - // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 - // SPDX-License-Identifier: MIT 4 - 5 - package ogcard 6 - 7 - import ( 8 - "bytes" 9 - "fmt" 10 - "image" 11 - "image/color" 12 - "io" 13 - "log" 14 - "math" 15 - "net/http" 16 - "strings" 17 - "sync" 18 - "time" 19 - 20 - "github.com/goki/freetype" 21 - "github.com/goki/freetype/truetype" 22 - "github.com/srwiley/oksvg" 23 - "github.com/srwiley/rasterx" 24 - "golang.org/x/image/draw" 25 - "golang.org/x/image/font" 26 - "tangled.org/core/appview/pages" 27 - 28 - _ "golang.org/x/image/webp" // for processing webp images 29 - ) 30 - 31 - type Card struct { 32 - Img *image.RGBA 33 - Font *truetype.Font 34 - Margin int 35 - Width int 36 - Height int 37 - } 38 - 39 - var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 - if err != nil { 42 - return nil, err 43 - } 44 - return truetype.Parse(interVar) 45 - }) 46 - 47 - // DefaultSize returns the default size for a card 48 - func DefaultSize() (int, int) { 49 - return 1200, 630 50 - } 51 - 52 - // NewCard creates a new card with the given dimensions in pixels 53 - func NewCard(width, height int) (*Card, error) { 54 - img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 - 57 - font, err := fontCache() 58 - if err != nil { 59 - return nil, err 60 - } 61 - 62 - return &Card{ 63 - Img: img, 64 - Font: font, 65 - Margin: 0, 66 - Width: width, 67 - Height: height, 68 - }, nil 69 - } 70 - 71 - // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 - // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 - func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 - bounds := c.Img.Bounds() 75 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 - if vertical { 77 - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 - } 83 - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 - } 89 - 90 - // SetMargin sets the margins for the card 91 - func (c *Card) SetMargin(margin int) { 92 - c.Margin = margin 93 - } 94 - 95 - type ( 96 - VAlign int64 97 - HAlign int64 98 - ) 99 - 100 - const ( 101 - Top VAlign = iota 102 - Middle 103 - Bottom 104 - ) 105 - 106 - const ( 107 - Left HAlign = iota 108 - Center 109 - Right 110 - ) 111 - 112 - // DrawText draws text within the card, respecting margins and alignment 113 - func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 - ft := freetype.NewContext() 115 - ft.SetDPI(72) 116 - ft.SetFont(c.Font) 117 - ft.SetFontSize(sizePt) 118 - ft.SetClip(c.Img.Bounds()) 119 - ft.SetDst(c.Img) 120 - ft.SetSrc(image.NewUniform(textColor)) 121 - 122 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 - fontHeight := ft.PointToFixed(sizePt).Ceil() 124 - 125 - bounds := c.Img.Bounds() 126 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 - 130 - // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 - // knowing the total height, which is related to how many lines we'll have. 133 - lines := make([]string, 0) 134 - textWords := strings.Split(text, " ") 135 - currentLine := "" 136 - heightTotal := 0 137 - 138 - for { 139 - if len(textWords) == 0 { 140 - // Ran out of words. 141 - if currentLine != "" { 142 - heightTotal += fontHeight 143 - lines = append(lines, currentLine) 144 - } 145 - break 146 - } 147 - 148 - nextWord := textWords[0] 149 - proposedLine := currentLine 150 - if proposedLine != "" { 151 - proposedLine += " " 152 - } 153 - proposedLine += nextWord 154 - 155 - proposedLineWidth := font.MeasureString(face, proposedLine) 156 - if proposedLineWidth.Ceil() > boxWidth { 157 - // no, proposed line is too big; we'll use the last "currentLine" 158 - heightTotal += fontHeight 159 - if currentLine != "" { 160 - lines = append(lines, currentLine) 161 - currentLine = "" 162 - // leave nextWord in textWords and keep going 163 - } else { 164 - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 - // regardless as a line by itself. It will be clipped by the drawing routine. 166 - lines = append(lines, nextWord) 167 - textWords = textWords[1:] 168 - } 169 - } else { 170 - // yes, it will fit 171 - currentLine = proposedLine 172 - textWords = textWords[1:] 173 - } 174 - } 175 - 176 - textY := 0 177 - switch valign { 178 - case Top: 179 - textY = fontHeight 180 - case Bottom: 181 - textY = boxHeight - heightTotal + fontHeight 182 - case Middle: 183 - textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 - } 185 - 186 - for _, line := range lines { 187 - lineWidth := font.MeasureString(face, line) 188 - 189 - textX := 0 190 - switch halign { 191 - case Left: 192 - textX = 0 193 - case Right: 194 - textX = boxWidth - lineWidth.Ceil() 195 - case Center: 196 - textX = (boxWidth - lineWidth.Ceil()) / 2 197 - } 198 - 199 - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 - _, err := ft.DrawString(line, pt) 201 - if err != nil { 202 - return nil, err 203 - } 204 - 205 - textY += fontHeight 206 - } 207 - 208 - return lines, nil 209 - } 210 - 211 - // DrawTextAt draws text at a specific position with the given alignment 212 - func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 - return err 215 - } 216 - 217 - // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 - func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 - ft := freetype.NewContext() 220 - ft.SetDPI(72) 221 - ft.SetFont(c.Font) 222 - ft.SetFontSize(sizePt) 223 - ft.SetClip(c.Img.Bounds()) 224 - ft.SetDst(c.Img) 225 - ft.SetSrc(image.NewUniform(textColor)) 226 - 227 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 - fontHeight := ft.PointToFixed(sizePt).Ceil() 229 - lineWidth := font.MeasureString(face, text) 230 - textWidth := lineWidth.Ceil() 231 - 232 - // Adjust position based on alignment 233 - adjustedX := x 234 - adjustedY := y 235 - 236 - switch halign { 237 - case Left: 238 - // x is already at the left position 239 - case Right: 240 - adjustedX = x - textWidth 241 - case Center: 242 - adjustedX = x - textWidth/2 243 - } 244 - 245 - switch valign { 246 - case Top: 247 - adjustedY = y + fontHeight 248 - case Bottom: 249 - adjustedY = y 250 - case Middle: 251 - adjustedY = y + fontHeight/2 252 - } 253 - 254 - pt := freetype.Pt(adjustedX, adjustedY) 255 - _, err := ft.DrawString(text, pt) 256 - return textWidth, err 257 - } 258 - 259 - // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 - func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 - // Draw the text multiple times with slight offsets to create bold effect 262 - offsets := []struct{ dx, dy int }{ 263 - {0, 0}, // original 264 - {1, 0}, // right 265 - {0, 1}, // down 266 - {1, 1}, // diagonal 267 - } 268 - 269 - var width int 270 - for _, offset := range offsets { 271 - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 - if err != nil { 273 - return 0, err 274 - } 275 - if width == 0 { 276 - width = w 277 - } 278 - } 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 { 292 - r, g, b, a := iconColor.RGBA() 293 - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 - } 295 - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 - 297 - // Replace currentColor with our desired color in the SVG 298 - svgString := string(svgData) 299 - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 - 301 - // Make the stroke thicker 302 - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 - 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) 313 - 314 - // Create a temporary RGBA image for the icon 315 - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 - 317 - // Create scanner and rasterizer 318 - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 - raster := rasterx.NewDasher(size, size, scanner) 320 - 321 - // Draw the icon 322 - icon.Draw(raster, 1.0) 323 - 324 - // Draw the icon onto the card at the specified position 325 - bounds := c.Img.Bounds() 326 - destRect := image.Rect(x, y, x+size, y+size) 327 - 328 - // Make sure we don't draw outside the card bounds 329 - if destRect.Max.X > bounds.Max.X { 330 - destRect.Max.X = bounds.Max.X 331 - } 332 - if destRect.Max.Y > bounds.Max.Y { 333 - destRect.Max.Y = bounds.Max.Y 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 342 - func (c *Card) DrawImage(img image.Image) { 343 - bounds := c.Img.Bounds() 344 - targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 - srcBounds := img.Bounds() 346 - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 - 349 - var scale float64 350 - if srcAspect > targetAspect { 351 - // Image is wider than target, scale by width 352 - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 - } else { 354 - // Image is taller or equal, scale by height 355 - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 - } 357 - 358 - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 - 361 - // Center the image within the target rectangle 362 - offsetX := (targetRect.Dx() - newWidth) / 2 363 - offsetY := (targetRect.Dy() - newHeight) / 2 364 - 365 - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 - } 368 - 369 - func fallbackImage() image.Image { 370 - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 - img.Set(0, 0, color.White) 373 - return img 374 - } 375 - 376 - // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 - func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 - // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 - // this rendering process to be slowed down 380 - client := &http.Client{ 381 - Timeout: 1 * time.Second, // 1 second timeout 382 - } 383 - 384 - resp, err := client.Get(url) 385 - if err != nil { 386 - log.Printf("error when fetching external image from %s: %v", url, err) 387 - return nil, false 388 - } 389 - defer resp.Body.Close() 390 - 391 - if resp.StatusCode != http.StatusOK { 392 - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 - return nil, false 394 - } 395 - 396 - contentType := resp.Header.Get("Content-Type") 397 - // Support content types are in-sync with the allowed custom avatar file types 398 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 - return nil, false 401 - } 402 - 403 - body := resp.Body 404 - bodyBytes, err := io.ReadAll(body) 405 - if err != nil { 406 - log.Printf("error when fetching external image from %s: %v", url, err) 407 - return nil, false 408 - } 409 - 410 - bodyBuffer := bytes.NewReader(bodyBytes) 411 - _, imgType, err := image.DecodeConfig(bodyBuffer) 412 - if err != nil { 413 - log.Printf("error when decoding external image from %s: %v", url, err) 414 - return nil, false 415 - } 416 - 417 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 - if (contentType == "image/png" && imgType != "png") || 419 - (contentType == "image/jpeg" && imgType != "jpeg") || 420 - (contentType == "image/gif" && imgType != "gif") || 421 - (contentType == "image/webp" && imgType != "webp") { 422 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 - return nil, false 424 - } 425 - 426 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 - if err != nil { 428 - log.Printf("error w/ bodyBuffer.Seek") 429 - return nil, false 430 - } 431 - img, _, err := image.Decode(bodyBuffer) 432 - if err != nil { 433 - log.Printf("error when decoding external image from %s: %v", url, err) 434 - return nil, false 435 - } 436 - 437 - return img, true 438 - } 439 - 440 - func (c *Card) DrawExternalImage(url string) { 441 - image, ok := c.fetchExternalImage(url) 442 - if !ok { 443 - image = fallbackImage() 444 - } 445 - c.DrawImage(image) 446 - } 447 - 448 - // DrawCircularExternalImage draws an external image as a circle at the specified position 449 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 - img, ok := c.fetchExternalImage(url) 451 - if !ok { 452 - img = fallbackImage() 453 - } 454 - 455 - // Create a circular mask 456 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 - center := size / 2 458 - radius := float64(size / 2) 459 - 460 - // Scale the source image to fit the circle 461 - srcBounds := img.Bounds() 462 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 - 465 - // Draw the image with circular clipping 466 - for cy := 0; cy < size; cy++ { 467 - for cx := 0; cx < size; cx++ { 468 - // Calculate distance from center 469 - dx := float64(cx - center) 470 - dy := float64(cy - center) 471 - distance := math.Sqrt(dx*dx + dy*dy) 472 - 473 - // Only draw pixels within the circle 474 - if distance <= radius { 475 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 - } 477 - } 478 - } 479 - 480 - // Draw the circle onto the card 481 - bounds := c.Img.Bounds() 482 - destRect := image.Rect(x, y, x+size, y+size) 483 - 484 - // Make sure we don't draw outside the card bounds 485 - if destRect.Max.X > bounds.Max.X { 486 - destRect.Max.X = bounds.Max.X 487 - } 488 - if destRect.Max.Y > bounds.Max.Y { 489 - destRect.Max.Y = bounds.Max.Y 490 - } 491 - 492 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 - 494 - return nil 495 - } 496 - 497 - // DrawRect draws a rect with the given color 498 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 - }
+6 -6
appview/repo/opengraph.go
··· 15 15 "github.com/go-enry/go-enry/v2" 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/repo/ogcard" 18 + "tangled.org/core/appview/ogcard" 19 19 "tangled.org/core/types" 20 20 ) 21 21 ··· 158 158 // Draw star icon, count, and label 159 159 // Align icon baseline with text baseline 160 160 iconBaselineOffset := int(textSize) / 2 161 - err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 161 + err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 162 162 if err != nil { 163 163 log.Printf("failed to draw star icon: %v", err) 164 164 } ··· 185 185 186 186 // Draw issues icon, count, and label 187 187 issueStartX := currentX 188 - err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 188 + err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 189 if err != nil { 190 190 log.Printf("failed to draw circle-dot icon: %v", err) 191 191 } ··· 210 210 211 211 // Draw pull request icon, count, and label 212 212 prStartX := currentX 213 - err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 213 + err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 214 214 if err != nil { 215 215 log.Printf("failed to draw git-pull-request icon: %v", err) 216 216 } ··· 236 236 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 237 237 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 238 238 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 239 - err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 239 + err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 240 240 if err != nil { 241 241 log.Printf("dolly silhouette not available (this is ok): %v", err) 242 242 } ··· 327 327 return nil 328 328 } 329 329 330 - func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 330 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 331 331 f, err := rp.repoResolver.Resolve(r) 332 332 if err != nil { 333 333 log.Println("failed to get repo and knot", err)
+2 -1369
appview/repo/repo.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "encoding/json" 7 6 "errors" 8 7 "fmt" 9 - "io" 10 8 "log/slog" 11 9 "net/http" 12 10 "net/url" 13 - "path/filepath" 14 11 "slices" 15 - "strconv" 16 12 "strings" 17 13 "time" 18 14 19 15 "tangled.org/core/api/tangled" 20 - "tangled.org/core/appview/commitverify" 21 16 "tangled.org/core/appview/config" 22 17 "tangled.org/core/appview/db" 23 18 "tangled.org/core/appview/models" 24 19 "tangled.org/core/appview/notify" 25 20 "tangled.org/core/appview/oauth" 26 21 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 22 "tangled.org/core/appview/reporesolver" 29 23 "tangled.org/core/appview/validator" 30 24 xrpcclient "tangled.org/core/appview/xrpcclient" 31 25 "tangled.org/core/eventconsumer" 32 26 "tangled.org/core/idresolver" 33 - "tangled.org/core/patchutil" 34 27 "tangled.org/core/rbac" 35 28 "tangled.org/core/tid" 36 - "tangled.org/core/types" 37 29 "tangled.org/core/xrpc/serviceauth" 38 30 39 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 32 atpclient "github.com/bluesky-social/indigo/atproto/client" 41 33 "github.com/bluesky-social/indigo/atproto/syntax" 42 34 lexutil "github.com/bluesky-social/indigo/lex/util" 43 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 35 securejoin "github.com/cyphar/filepath-securejoin" 45 36 "github.com/go-chi/chi/v5" 46 - "github.com/go-git/go-git/v5/plumbing" 47 37 ) 48 38 49 39 type Repo struct { ··· 88 78 } 89 79 } 90 80 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 - tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 196 - } 197 - } 198 - } 199 - 200 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 201 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 202 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 203 - rp.pages.Error503(w) 204 - return 205 - } 206 - 207 - if branchBytes != nil { 208 - var branchResp types.RepoBranchesResponse 209 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 210 - for _, branch := range branchResp.Branches { 211 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 212 - } 213 - } 214 - } 215 - 216 - user := rp.oauth.GetUser(r) 217 - 218 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 219 - if err != nil { 220 - l.Error("failed to fetch email to did mapping", "err", err) 221 - } 222 - 223 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 224 - if err != nil { 225 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 226 - } 227 - 228 - repoInfo := f.RepoInfo(user) 229 - 230 - var shas []string 231 - for _, c := range xrpcResp.Commits { 232 - shas = append(shas, c.Hash.String()) 233 - } 234 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 235 - if err != nil { 236 - l.Error("failed to getPipelineStatuses", "err", err) 237 - // non-fatal 238 - } 239 - 240 - rp.pages.RepoLog(w, pages.RepoLogParams{ 241 - LoggedInUser: user, 242 - TagMap: tagMap, 243 - RepoInfo: repoInfo, 244 - RepoLogResponse: xrpcResp, 245 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 246 - VerifiedCommits: vc, 247 - Pipelines: pipelines, 248 - }) 249 - } 250 - 251 - func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 252 - l := rp.logger.With("handler", "RepoDescriptionEdit") 253 - 254 - f, err := rp.repoResolver.Resolve(r) 255 - if err != nil { 256 - l.Error("failed to get repo and knot", "err", err) 257 - w.WriteHeader(http.StatusBadRequest) 258 - return 259 - } 260 - 261 - user := rp.oauth.GetUser(r) 262 - rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 263 - RepoInfo: f.RepoInfo(user), 264 - }) 265 - } 266 - 267 - func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 268 - l := rp.logger.With("handler", "RepoDescription") 269 - 270 - f, err := rp.repoResolver.Resolve(r) 271 - if err != nil { 272 - l.Error("failed to get repo and knot", "err", err) 273 - w.WriteHeader(http.StatusBadRequest) 274 - return 275 - } 276 - 277 - repoAt := f.RepoAt() 278 - rkey := repoAt.RecordKey().String() 279 - if rkey == "" { 280 - l.Error("invalid aturi for repo", "err", err) 281 - w.WriteHeader(http.StatusInternalServerError) 282 - return 283 - } 284 - 285 - user := rp.oauth.GetUser(r) 286 - 287 - switch r.Method { 288 - case http.MethodGet: 289 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 290 - RepoInfo: f.RepoInfo(user), 291 - }) 292 - return 293 - case http.MethodPut: 294 - newDescription := r.FormValue("description") 295 - client, err := rp.oauth.AuthorizedClient(r) 296 - if err != nil { 297 - l.Error("failed to get client") 298 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 299 - return 300 - } 301 - 302 - // optimistic update 303 - err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 304 - if err != nil { 305 - l.Error("failed to perform update-description query", "err", err) 306 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 307 - return 308 - } 309 - 310 - newRepo := f.Repo 311 - newRepo.Description = newDescription 312 - record := newRepo.AsRecord() 313 - 314 - // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 315 - // 316 - // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 317 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 318 - if err != nil { 319 - // failed to get record 320 - rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 321 - return 322 - } 323 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 324 - Collection: tangled.RepoNSID, 325 - Repo: newRepo.Did, 326 - Rkey: newRepo.Rkey, 327 - SwapRecord: ex.Cid, 328 - Record: &lexutil.LexiconTypeDecoder{ 329 - Val: &record, 330 - }, 331 - }) 332 - 333 - if err != nil { 334 - l.Error("failed to perferom update-description query", "err", err) 335 - // failed to get record 336 - rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 337 - return 338 - } 339 - 340 - newRepoInfo := f.RepoInfo(user) 341 - newRepoInfo.Description = newDescription 342 - 343 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 344 - RepoInfo: newRepoInfo, 345 - }) 346 - return 347 - } 348 - } 349 - 350 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 351 - l := rp.logger.With("handler", "RepoCommit") 352 - 353 - f, err := rp.repoResolver.Resolve(r) 354 - if err != nil { 355 - l.Error("failed to fully resolve repo", "err", err) 356 - return 357 - } 358 - ref := chi.URLParam(r, "ref") 359 - ref, _ = url.PathUnescape(ref) 360 - 361 - var diffOpts types.DiffOpts 362 - if d := r.URL.Query().Get("diff"); d == "split" { 363 - diffOpts.Split = true 364 - } 365 - 366 - if !plumbing.IsHash(ref) { 367 - rp.pages.Error404(w) 368 - return 369 - } 370 - 371 - scheme := "http" 372 - if !rp.config.Core.Dev { 373 - scheme = "https" 374 - } 375 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 376 - xrpcc := &indigoxrpc.Client{ 377 - Host: host, 378 - } 379 - 380 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 381 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 382 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 383 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 384 - rp.pages.Error503(w) 385 - return 386 - } 387 - 388 - var result types.RepoCommitResponse 389 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 390 - l.Error("failed to decode XRPC response", "err", err) 391 - rp.pages.Error503(w) 392 - return 393 - } 394 - 395 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 396 - if err != nil { 397 - l.Error("failed to get email to did mapping", "err", err) 398 - } 399 - 400 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 401 - if err != nil { 402 - l.Error("failed to GetVerifiedCommits", "err", err) 403 - } 404 - 405 - user := rp.oauth.GetUser(r) 406 - repoInfo := f.RepoInfo(user) 407 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 408 - if err != nil { 409 - l.Error("failed to getPipelineStatuses", "err", err) 410 - // non-fatal 411 - } 412 - var pipeline *models.Pipeline 413 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 414 - pipeline = &p 415 - } 416 - 417 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 418 - LoggedInUser: user, 419 - RepoInfo: f.RepoInfo(user), 420 - RepoCommitResponse: result, 421 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 422 - VerifiedCommit: vc, 423 - Pipeline: pipeline, 424 - DiffOpts: diffOpts, 425 - }) 426 - } 427 - 428 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 429 - l := rp.logger.With("handler", "RepoTree") 430 - 431 - f, err := rp.repoResolver.Resolve(r) 432 - if err != nil { 433 - l.Error("failed to fully resolve repo", "err", err) 434 - return 435 - } 436 - 437 - ref := chi.URLParam(r, "ref") 438 - ref, _ = url.PathUnescape(ref) 439 - 440 - // if the tree path has a trailing slash, let's strip it 441 - // so we don't 404 442 - treePath := chi.URLParam(r, "*") 443 - treePath, _ = url.PathUnescape(treePath) 444 - treePath = strings.TrimSuffix(treePath, "/") 445 - 446 - scheme := "http" 447 - if !rp.config.Core.Dev { 448 - scheme = "https" 449 - } 450 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 451 - xrpcc := &indigoxrpc.Client{ 452 - Host: host, 453 - } 454 - 455 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 456 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 457 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 458 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 459 - rp.pages.Error503(w) 460 - return 461 - } 462 - 463 - // Convert XRPC response to internal types.RepoTreeResponse 464 - files := make([]types.NiceTree, len(xrpcResp.Files)) 465 - for i, xrpcFile := range xrpcResp.Files { 466 - file := types.NiceTree{ 467 - Name: xrpcFile.Name, 468 - Mode: xrpcFile.Mode, 469 - Size: int64(xrpcFile.Size), 470 - IsFile: xrpcFile.Is_file, 471 - IsSubtree: xrpcFile.Is_subtree, 472 - } 473 - 474 - // Convert last commit info if present 475 - if xrpcFile.Last_commit != nil { 476 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 477 - file.LastCommit = &types.LastCommitInfo{ 478 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 479 - Message: xrpcFile.Last_commit.Message, 480 - When: commitWhen, 481 - } 482 - } 483 - 484 - files[i] = file 485 - } 486 - 487 - result := types.RepoTreeResponse{ 488 - Ref: xrpcResp.Ref, 489 - Files: files, 490 - } 491 - 492 - if xrpcResp.Parent != nil { 493 - result.Parent = *xrpcResp.Parent 494 - } 495 - if xrpcResp.Dotdot != nil { 496 - result.DotDot = *xrpcResp.Dotdot 497 - } 498 - if xrpcResp.Readme != nil { 499 - result.ReadmeFileName = xrpcResp.Readme.Filename 500 - result.Readme = xrpcResp.Readme.Contents 501 - } 502 - 503 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 504 - // so we can safely redirect to the "parent" (which is the same file). 505 - if len(result.Files) == 0 && result.Parent == treePath { 506 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 507 - http.Redirect(w, r, redirectTo, http.StatusFound) 508 - return 509 - } 510 - 511 - user := rp.oauth.GetUser(r) 512 - 513 - var breadcrumbs [][]string 514 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 515 - if treePath != "" { 516 - for idx, elem := range strings.Split(treePath, "/") { 517 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 518 - } 519 - } 520 - 521 - sortFiles(result.Files) 522 - 523 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 524 - LoggedInUser: user, 525 - BreadCrumbs: breadcrumbs, 526 - TreePath: treePath, 527 - RepoInfo: f.RepoInfo(user), 528 - RepoTreeResponse: result, 529 - }) 530 - } 531 - 532 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 533 - l := rp.logger.With("handler", "RepoTags") 534 - 535 - f, err := rp.repoResolver.Resolve(r) 536 - if err != nil { 537 - l.Error("failed to get repo and knot", "err", err) 538 - return 539 - } 540 - 541 - scheme := "http" 542 - if !rp.config.Core.Dev { 543 - scheme = "https" 544 - } 545 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 546 - xrpcc := &indigoxrpc.Client{ 547 - Host: host, 548 - } 549 - 550 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 551 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 552 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 553 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 554 - rp.pages.Error503(w) 555 - return 556 - } 557 - 558 - var result types.RepoTagsResponse 559 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 560 - l.Error("failed to decode XRPC response", "err", err) 561 - rp.pages.Error503(w) 562 - return 563 - } 564 - 565 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 566 - if err != nil { 567 - l.Error("failed grab artifacts", "err", err) 568 - return 569 - } 570 - 571 - // convert artifacts to map for easy UI building 572 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 573 - for _, a := range artifacts { 574 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 575 - } 576 - 577 - var danglingArtifacts []models.Artifact 578 - for _, a := range artifacts { 579 - found := false 580 - for _, t := range result.Tags { 581 - if t.Tag != nil { 582 - if t.Tag.Hash == a.Tag { 583 - found = true 584 - } 585 - } 586 - } 587 - 588 - if !found { 589 - danglingArtifacts = append(danglingArtifacts, a) 590 - } 591 - } 592 - 593 - user := rp.oauth.GetUser(r) 594 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 595 - LoggedInUser: user, 596 - RepoInfo: f.RepoInfo(user), 597 - RepoTagsResponse: result, 598 - ArtifactMap: artifactMap, 599 - DanglingArtifacts: danglingArtifacts, 600 - }) 601 - } 602 - 603 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 604 - l := rp.logger.With("handler", "RepoBranches") 605 - 606 - f, err := rp.repoResolver.Resolve(r) 607 - if err != nil { 608 - l.Error("failed to get repo and knot", "err", err) 609 - return 610 - } 611 - 612 - scheme := "http" 613 - if !rp.config.Core.Dev { 614 - scheme = "https" 615 - } 616 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 617 - xrpcc := &indigoxrpc.Client{ 618 - Host: host, 619 - } 620 - 621 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 622 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 623 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 624 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 625 - rp.pages.Error503(w) 626 - return 627 - } 628 - 629 - var result types.RepoBranchesResponse 630 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 631 - l.Error("failed to decode XRPC response", "err", err) 632 - rp.pages.Error503(w) 633 - return 634 - } 635 - 636 - sortBranches(result.Branches) 637 - 638 - user := rp.oauth.GetUser(r) 639 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 640 - LoggedInUser: user, 641 - RepoInfo: f.RepoInfo(user), 642 - RepoBranchesResponse: result, 643 - }) 644 - } 645 - 646 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 647 - l := rp.logger.With("handler", "DeleteBranch") 648 - 649 - f, err := rp.repoResolver.Resolve(r) 650 - if err != nil { 651 - l.Error("failed to get repo and knot", "err", err) 652 - return 653 - } 654 - 655 - noticeId := "delete-branch-error" 656 - fail := func(msg string, err error) { 657 - l.Error(msg, "err", err) 658 - rp.pages.Notice(w, noticeId, msg) 659 - } 660 - 661 - branch := r.FormValue("branch") 662 - if branch == "" { 663 - fail("No branch provided.", nil) 664 - return 665 - } 666 - 667 - client, err := rp.oauth.ServiceClient( 668 - r, 669 - oauth.WithService(f.Knot), 670 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 671 - oauth.WithDev(rp.config.Core.Dev), 672 - ) 673 - if err != nil { 674 - fail("Failed to connect to knotserver", nil) 675 - return 676 - } 677 - 678 - err = tangled.RepoDeleteBranch( 679 - r.Context(), 680 - client, 681 - &tangled.RepoDeleteBranch_Input{ 682 - Branch: branch, 683 - Repo: f.RepoAt().String(), 684 - }, 685 - ) 686 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 687 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 688 - return 689 - } 690 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 691 - 692 - rp.pages.HxRefresh(w) 693 - } 694 - 695 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 696 - l := rp.logger.With("handler", "RepoBlob") 697 - 698 - f, err := rp.repoResolver.Resolve(r) 699 - if err != nil { 700 - l.Error("failed to get repo and knot", "err", err) 701 - return 702 - } 703 - 704 - ref := chi.URLParam(r, "ref") 705 - ref, _ = url.PathUnescape(ref) 706 - 707 - filePath := chi.URLParam(r, "*") 708 - filePath, _ = url.PathUnescape(filePath) 709 - 710 - scheme := "http" 711 - if !rp.config.Core.Dev { 712 - scheme = "https" 713 - } 714 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 715 - xrpcc := &indigoxrpc.Client{ 716 - Host: host, 717 - } 718 - 719 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 720 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 721 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 722 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 723 - rp.pages.Error503(w) 724 - return 725 - } 726 - 727 - // Use XRPC response directly instead of converting to internal types 728 - 729 - var breadcrumbs [][]string 730 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 731 - if filePath != "" { 732 - for idx, elem := range strings.Split(filePath, "/") { 733 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 734 - } 735 - } 736 - 737 - showRendered := false 738 - renderToggle := false 739 - 740 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 741 - renderToggle = true 742 - showRendered = r.URL.Query().Get("code") != "true" 743 - } 744 - 745 - var unsupported bool 746 - var isImage bool 747 - var isVideo bool 748 - var contentSrc string 749 - 750 - if resp.IsBinary != nil && *resp.IsBinary { 751 - ext := strings.ToLower(filepath.Ext(resp.Path)) 752 - switch ext { 753 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 754 - isImage = true 755 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 756 - isVideo = true 757 - default: 758 - unsupported = true 759 - } 760 - 761 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 762 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 763 - 764 - baseURL := &url.URL{ 765 - Scheme: scheme, 766 - Host: f.Knot, 767 - Path: "/xrpc/sh.tangled.repo.blob", 768 - } 769 - query := baseURL.Query() 770 - query.Set("repo", repoName) 771 - query.Set("ref", ref) 772 - query.Set("path", filePath) 773 - query.Set("raw", "true") 774 - baseURL.RawQuery = query.Encode() 775 - blobURL := baseURL.String() 776 - 777 - contentSrc = blobURL 778 - if !rp.config.Core.Dev { 779 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 780 - } 781 - } 782 - 783 - lines := 0 784 - if resp.IsBinary == nil || !*resp.IsBinary { 785 - lines = strings.Count(resp.Content, "\n") + 1 786 - } 787 - 788 - var sizeHint uint64 789 - if resp.Size != nil { 790 - sizeHint = uint64(*resp.Size) 791 - } else { 792 - sizeHint = uint64(len(resp.Content)) 793 - } 794 - 795 - user := rp.oauth.GetUser(r) 796 - 797 - // Determine if content is binary (dereference pointer) 798 - isBinary := false 799 - if resp.IsBinary != nil { 800 - isBinary = *resp.IsBinary 801 - } 802 - 803 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 804 - LoggedInUser: user, 805 - RepoInfo: f.RepoInfo(user), 806 - BreadCrumbs: breadcrumbs, 807 - ShowRendered: showRendered, 808 - RenderToggle: renderToggle, 809 - Unsupported: unsupported, 810 - IsImage: isImage, 811 - IsVideo: isVideo, 812 - ContentSrc: contentSrc, 813 - RepoBlob_Output: resp, 814 - Contents: resp.Content, 815 - Lines: lines, 816 - SizeHint: sizeHint, 817 - IsBinary: isBinary, 818 - }) 819 - } 820 - 821 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 822 - l := rp.logger.With("handler", "RepoBlobRaw") 823 - 824 - f, err := rp.repoResolver.Resolve(r) 825 - if err != nil { 826 - l.Error("failed to get repo and knot", "err", err) 827 - w.WriteHeader(http.StatusBadRequest) 828 - return 829 - } 830 - 831 - ref := chi.URLParam(r, "ref") 832 - ref, _ = url.PathUnescape(ref) 833 - 834 - filePath := chi.URLParam(r, "*") 835 - filePath, _ = url.PathUnescape(filePath) 836 - 837 - scheme := "http" 838 - if !rp.config.Core.Dev { 839 - scheme = "https" 840 - } 841 - 842 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 843 - baseURL := &url.URL{ 844 - Scheme: scheme, 845 - Host: f.Knot, 846 - Path: "/xrpc/sh.tangled.repo.blob", 847 - } 848 - query := baseURL.Query() 849 - query.Set("repo", repo) 850 - query.Set("ref", ref) 851 - query.Set("path", filePath) 852 - query.Set("raw", "true") 853 - baseURL.RawQuery = query.Encode() 854 - blobURL := baseURL.String() 855 - 856 - req, err := http.NewRequest("GET", blobURL, nil) 857 - if err != nil { 858 - l.Error("failed to create request", "err", err) 859 - return 860 - } 861 - 862 - // forward the If-None-Match header 863 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 864 - req.Header.Set("If-None-Match", clientETag) 865 - } 866 - 867 - client := &http.Client{} 868 - resp, err := client.Do(req) 869 - if err != nil { 870 - l.Error("failed to reach knotserver", "err", err) 871 - rp.pages.Error503(w) 872 - return 873 - } 874 - defer resp.Body.Close() 875 - 876 - // forward 304 not modified 877 - if resp.StatusCode == http.StatusNotModified { 878 - w.WriteHeader(http.StatusNotModified) 879 - return 880 - } 881 - 882 - if resp.StatusCode != http.StatusOK { 883 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 884 - w.WriteHeader(resp.StatusCode) 885 - _, _ = io.Copy(w, resp.Body) 886 - return 887 - } 888 - 889 - contentType := resp.Header.Get("Content-Type") 890 - body, err := io.ReadAll(resp.Body) 891 - if err != nil { 892 - l.Error("error reading response body from knotserver", "err", err) 893 - w.WriteHeader(http.StatusInternalServerError) 894 - return 895 - } 896 - 897 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 898 - // serve all textual content as text/plain 899 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 900 - w.Write(body) 901 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 902 - // serve images and videos with their original content type 903 - w.Header().Set("Content-Type", contentType) 904 - w.Write(body) 905 - } else { 906 - w.WriteHeader(http.StatusUnsupportedMediaType) 907 - w.Write([]byte("unsupported content type")) 908 - return 909 - } 910 - } 911 - 912 - // isTextualMimeType returns true if the MIME type represents textual content 913 - // that should be served as text/plain 914 - func isTextualMimeType(mimeType string) bool { 915 - textualTypes := []string{ 916 - "application/json", 917 - "application/xml", 918 - "application/yaml", 919 - "application/x-yaml", 920 - "application/toml", 921 - "application/javascript", 922 - "application/ecmascript", 923 - "message/", 924 - } 925 - 926 - return slices.Contains(textualTypes, mimeType) 927 - } 928 - 929 81 // modify the spindle configured for this repo 930 82 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 931 83 user := rp.oauth.GetUser(r) ··· 1781 933 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1782 934 } 1783 935 1784 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1785 - l := rp.logger.With("handler", "SetDefaultBranch") 1786 - 1787 - f, err := rp.repoResolver.Resolve(r) 1788 - if err != nil { 1789 - l.Error("failed to get repo and knot", "err", err) 1790 - return 1791 - } 1792 - 1793 - noticeId := "operation-error" 1794 - branch := r.FormValue("branch") 1795 - if branch == "" { 1796 - http.Error(w, "malformed form", http.StatusBadRequest) 1797 - return 1798 - } 1799 - 1800 - client, err := rp.oauth.ServiceClient( 1801 - r, 1802 - oauth.WithService(f.Knot), 1803 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1804 - oauth.WithDev(rp.config.Core.Dev), 1805 - ) 1806 - if err != nil { 1807 - l.Error("failed to connect to knot server", "err", err) 1808 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1809 - return 1810 - } 1811 - 1812 - xe := tangled.RepoSetDefaultBranch( 1813 - r.Context(), 1814 - client, 1815 - &tangled.RepoSetDefaultBranch_Input{ 1816 - Repo: f.RepoAt().String(), 1817 - DefaultBranch: branch, 1818 - }, 1819 - ) 1820 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1821 - l.Error("xrpc failed", "err", xe) 1822 - rp.pages.Notice(w, noticeId, err.Error()) 1823 - return 1824 - } 1825 - 1826 - rp.pages.HxRefresh(w) 1827 - } 1828 - 1829 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1830 - user := rp.oauth.GetUser(r) 1831 - l := rp.logger.With("handler", "Secrets") 1832 - l = l.With("did", user.Did) 1833 - 1834 - f, err := rp.repoResolver.Resolve(r) 1835 - if err != nil { 1836 - l.Error("failed to get repo and knot", "err", err) 1837 - return 1838 - } 1839 - 1840 - if f.Spindle == "" { 1841 - l.Error("empty spindle cannot add/rm secret", "err", err) 1842 - return 1843 - } 1844 - 1845 - lxm := tangled.RepoAddSecretNSID 1846 - if r.Method == http.MethodDelete { 1847 - lxm = tangled.RepoRemoveSecretNSID 1848 - } 1849 - 1850 - spindleClient, err := rp.oauth.ServiceClient( 1851 - r, 1852 - oauth.WithService(f.Spindle), 1853 - oauth.WithLxm(lxm), 1854 - oauth.WithExp(60), 1855 - oauth.WithDev(rp.config.Core.Dev), 1856 - ) 1857 - if err != nil { 1858 - l.Error("failed to create spindle client", "err", err) 1859 - return 1860 - } 1861 - 1862 - key := r.FormValue("key") 1863 - if key == "" { 1864 - w.WriteHeader(http.StatusBadRequest) 1865 - return 1866 - } 1867 - 1868 - switch r.Method { 1869 - case http.MethodPut: 1870 - errorId := "add-secret-error" 1871 - 1872 - value := r.FormValue("value") 1873 - if value == "" { 1874 - w.WriteHeader(http.StatusBadRequest) 1875 - return 1876 - } 1877 - 1878 - err = tangled.RepoAddSecret( 1879 - r.Context(), 1880 - spindleClient, 1881 - &tangled.RepoAddSecret_Input{ 1882 - Repo: f.RepoAt().String(), 1883 - Key: key, 1884 - Value: value, 1885 - }, 1886 - ) 1887 - if err != nil { 1888 - l.Error("Failed to add secret.", "err", err) 1889 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1890 - return 1891 - } 1892 - 1893 - case http.MethodDelete: 1894 - errorId := "operation-error" 1895 - 1896 - err = tangled.RepoRemoveSecret( 1897 - r.Context(), 1898 - spindleClient, 1899 - &tangled.RepoRemoveSecret_Input{ 1900 - Repo: f.RepoAt().String(), 1901 - Key: key, 1902 - }, 1903 - ) 1904 - if err != nil { 1905 - l.Error("Failed to delete secret.", "err", err) 1906 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1907 - return 1908 - } 1909 - } 1910 - 1911 - rp.pages.HxRefresh(w) 1912 - } 1913 - 1914 - type tab = map[string]any 1915 - 1916 - var ( 1917 - // would be great to have ordered maps right about now 1918 - settingsTabs []tab = []tab{ 1919 - {"Name": "general", "Icon": "sliders-horizontal"}, 1920 - {"Name": "access", "Icon": "users"}, 1921 - {"Name": "pipelines", "Icon": "layers-2"}, 1922 - } 1923 - ) 1924 - 1925 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1926 - tabVal := r.URL.Query().Get("tab") 1927 - if tabVal == "" { 1928 - tabVal = "general" 1929 - } 1930 - 1931 - switch tabVal { 1932 - case "general": 1933 - rp.generalSettings(w, r) 1934 - 1935 - case "access": 1936 - rp.accessSettings(w, r) 1937 - 1938 - case "pipelines": 1939 - rp.pipelineSettings(w, r) 1940 - } 1941 - } 1942 - 1943 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1944 - l := rp.logger.With("handler", "generalSettings") 1945 - 1946 - f, err := rp.repoResolver.Resolve(r) 1947 - user := rp.oauth.GetUser(r) 1948 - 1949 - scheme := "http" 1950 - if !rp.config.Core.Dev { 1951 - scheme = "https" 1952 - } 1953 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1954 - xrpcc := &indigoxrpc.Client{ 1955 - Host: host, 1956 - } 1957 - 1958 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1959 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1960 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1961 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1962 - rp.pages.Error503(w) 1963 - return 1964 - } 1965 - 1966 - var result types.RepoBranchesResponse 1967 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1968 - l.Error("failed to decode XRPC response", "err", err) 1969 - rp.pages.Error503(w) 1970 - return 1971 - } 1972 - 1973 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1974 - if err != nil { 1975 - l.Error("failed to fetch labels", "err", err) 1976 - rp.pages.Error503(w) 1977 - return 1978 - } 1979 - 1980 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1981 - if err != nil { 1982 - l.Error("failed to fetch labels", "err", err) 1983 - rp.pages.Error503(w) 1984 - return 1985 - } 1986 - // remove default labels from the labels list, if present 1987 - defaultLabelMap := make(map[string]bool) 1988 - for _, dl := range defaultLabels { 1989 - defaultLabelMap[dl.AtUri().String()] = true 1990 - } 1991 - n := 0 1992 - for _, l := range labels { 1993 - if !defaultLabelMap[l.AtUri().String()] { 1994 - labels[n] = l 1995 - n++ 1996 - } 1997 - } 1998 - labels = labels[:n] 1999 - 2000 - subscribedLabels := make(map[string]struct{}) 2001 - for _, l := range f.Repo.Labels { 2002 - subscribedLabels[l] = struct{}{} 2003 - } 2004 - 2005 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 2006 - // if all default labels are subbed, show the "unsubscribe all" button 2007 - shouldSubscribeAll := false 2008 - for _, dl := range defaultLabels { 2009 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 2010 - // one of the default labels is not subscribed to 2011 - shouldSubscribeAll = true 2012 - break 2013 - } 2014 - } 2015 - 2016 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 2017 - LoggedInUser: user, 2018 - RepoInfo: f.RepoInfo(user), 2019 - Branches: result.Branches, 2020 - Labels: labels, 2021 - DefaultLabels: defaultLabels, 2022 - SubscribedLabels: subscribedLabels, 2023 - ShouldSubscribeAll: shouldSubscribeAll, 2024 - Tabs: settingsTabs, 2025 - Tab: "general", 2026 - }) 2027 - } 2028 - 2029 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2030 - l := rp.logger.With("handler", "accessSettings") 2031 - 2032 - f, err := rp.repoResolver.Resolve(r) 2033 - user := rp.oauth.GetUser(r) 2034 - 2035 - repoCollaborators, err := f.Collaborators(r.Context()) 2036 - if err != nil { 2037 - l.Error("failed to get collaborators", "err", err) 2038 - } 2039 - 2040 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 2041 - LoggedInUser: user, 2042 - RepoInfo: f.RepoInfo(user), 2043 - Tabs: settingsTabs, 2044 - Tab: "access", 2045 - Collaborators: repoCollaborators, 2046 - }) 2047 - } 2048 - 2049 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2050 - l := rp.logger.With("handler", "pipelineSettings") 2051 - 2052 - f, err := rp.repoResolver.Resolve(r) 2053 - user := rp.oauth.GetUser(r) 2054 - 2055 - // all spindles that the repo owner is a member of 2056 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2057 - if err != nil { 2058 - l.Error("failed to fetch spindles", "err", err) 2059 - return 2060 - } 2061 - 2062 - var secrets []*tangled.RepoListSecrets_Secret 2063 - if f.Spindle != "" { 2064 - if spindleClient, err := rp.oauth.ServiceClient( 2065 - r, 2066 - oauth.WithService(f.Spindle), 2067 - oauth.WithLxm(tangled.RepoListSecretsNSID), 2068 - oauth.WithExp(60), 2069 - oauth.WithDev(rp.config.Core.Dev), 2070 - ); err != nil { 2071 - l.Error("failed to create spindle client", "err", err) 2072 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2073 - l.Error("failed to fetch secrets", "err", err) 2074 - } else { 2075 - secrets = resp.Secrets 2076 - } 2077 - } 2078 - 2079 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2080 - return strings.Compare(a.Key, b.Key) 2081 - }) 2082 - 2083 - var dids []string 2084 - for _, s := range secrets { 2085 - dids = append(dids, s.CreatedBy) 2086 - } 2087 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2088 - 2089 - // convert to a more manageable form 2090 - var niceSecret []map[string]any 2091 - for id, s := range secrets { 2092 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2093 - niceSecret = append(niceSecret, map[string]any{ 2094 - "Id": id, 2095 - "Key": s.Key, 2096 - "CreatedAt": when, 2097 - "CreatedBy": resolvedIdents[id].Handle.String(), 2098 - }) 2099 - } 2100 - 2101 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2102 - LoggedInUser: user, 2103 - RepoInfo: f.RepoInfo(user), 2104 - Tabs: settingsTabs, 2105 - Tab: "pipelines", 2106 - Spindles: spindles, 2107 - CurrentSpindle: f.Spindle, 2108 - Secrets: niceSecret, 2109 - }) 2110 - } 2111 - 2112 936 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2113 937 l := rp.logger.With("handler", "SyncRepoFork") 2114 938 ··· 2249 1073 Source: sourceAt, 2250 1074 Description: f.Repo.Description, 2251 1075 Created: time.Now(), 2252 - Labels: models.DefaultLabelDefs(), 1076 + Labels: rp.config.Label.DefaultLabelDefs, 2253 1077 } 2254 1078 record := repo.AsRecord() 2255 1079 ··· 2365 1189 aturi = "" 2366 1190 2367 1191 rp.notifier.NewRepo(r.Context(), repo) 2368 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 1192 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 2369 1193 } 2370 1194 } 2371 1195 ··· 2390 1214 }) 2391 1215 return err 2392 1216 } 2393 - 2394 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2395 - l := rp.logger.With("handler", "RepoCompareNew") 2396 - 2397 - user := rp.oauth.GetUser(r) 2398 - f, err := rp.repoResolver.Resolve(r) 2399 - if err != nil { 2400 - l.Error("failed to get repo and knot", "err", err) 2401 - return 2402 - } 2403 - 2404 - scheme := "http" 2405 - if !rp.config.Core.Dev { 2406 - scheme = "https" 2407 - } 2408 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2409 - xrpcc := &indigoxrpc.Client{ 2410 - Host: host, 2411 - } 2412 - 2413 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2414 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2415 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2416 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2417 - rp.pages.Error503(w) 2418 - return 2419 - } 2420 - 2421 - var branchResult types.RepoBranchesResponse 2422 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2423 - l.Error("failed to decode XRPC branches response", "err", err) 2424 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2425 - return 2426 - } 2427 - branches := branchResult.Branches 2428 - 2429 - sortBranches(branches) 2430 - 2431 - var defaultBranch string 2432 - for _, b := range branches { 2433 - if b.IsDefault { 2434 - defaultBranch = b.Name 2435 - } 2436 - } 2437 - 2438 - base := defaultBranch 2439 - head := defaultBranch 2440 - 2441 - params := r.URL.Query() 2442 - queryBase := params.Get("base") 2443 - queryHead := params.Get("head") 2444 - if queryBase != "" { 2445 - base = queryBase 2446 - } 2447 - if queryHead != "" { 2448 - head = queryHead 2449 - } 2450 - 2451 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2452 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2453 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2454 - rp.pages.Error503(w) 2455 - return 2456 - } 2457 - 2458 - var tags types.RepoTagsResponse 2459 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2460 - l.Error("failed to decode XRPC tags response", "err", err) 2461 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2462 - return 2463 - } 2464 - 2465 - repoinfo := f.RepoInfo(user) 2466 - 2467 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2468 - LoggedInUser: user, 2469 - RepoInfo: repoinfo, 2470 - Branches: branches, 2471 - Tags: tags.Tags, 2472 - Base: base, 2473 - Head: head, 2474 - }) 2475 - } 2476 - 2477 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2478 - l := rp.logger.With("handler", "RepoCompare") 2479 - 2480 - user := rp.oauth.GetUser(r) 2481 - f, err := rp.repoResolver.Resolve(r) 2482 - if err != nil { 2483 - l.Error("failed to get repo and knot", "err", err) 2484 - return 2485 - } 2486 - 2487 - var diffOpts types.DiffOpts 2488 - if d := r.URL.Query().Get("diff"); d == "split" { 2489 - diffOpts.Split = true 2490 - } 2491 - 2492 - // if user is navigating to one of 2493 - // /compare/{base}/{head} 2494 - // /compare/{base}...{head} 2495 - base := chi.URLParam(r, "base") 2496 - head := chi.URLParam(r, "head") 2497 - if base == "" && head == "" { 2498 - rest := chi.URLParam(r, "*") // master...feature/xyz 2499 - parts := strings.SplitN(rest, "...", 2) 2500 - if len(parts) == 2 { 2501 - base = parts[0] 2502 - head = parts[1] 2503 - } 2504 - } 2505 - 2506 - base, _ = url.PathUnescape(base) 2507 - head, _ = url.PathUnescape(head) 2508 - 2509 - if base == "" || head == "" { 2510 - l.Error("invalid comparison") 2511 - rp.pages.Error404(w) 2512 - return 2513 - } 2514 - 2515 - scheme := "http" 2516 - if !rp.config.Core.Dev { 2517 - scheme = "https" 2518 - } 2519 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2520 - xrpcc := &indigoxrpc.Client{ 2521 - Host: host, 2522 - } 2523 - 2524 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2525 - 2526 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2527 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2528 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2529 - rp.pages.Error503(w) 2530 - return 2531 - } 2532 - 2533 - var branches types.RepoBranchesResponse 2534 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2535 - l.Error("failed to decode XRPC branches response", "err", err) 2536 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2537 - return 2538 - } 2539 - 2540 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2541 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2542 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2543 - rp.pages.Error503(w) 2544 - return 2545 - } 2546 - 2547 - var tags types.RepoTagsResponse 2548 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2549 - l.Error("failed to decode XRPC tags response", "err", err) 2550 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2551 - return 2552 - } 2553 - 2554 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2555 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2556 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2557 - rp.pages.Error503(w) 2558 - return 2559 - } 2560 - 2561 - var formatPatch types.RepoFormatPatchResponse 2562 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2563 - l.Error("failed to decode XRPC compare response", "err", err) 2564 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2565 - return 2566 - } 2567 - 2568 - diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2569 - 2570 - repoinfo := f.RepoInfo(user) 2571 - 2572 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2573 - LoggedInUser: user, 2574 - RepoInfo: repoinfo, 2575 - Branches: branches.Branches, 2576 - Tags: tags.Tags, 2577 - Base: base, 2578 - Head: head, 2579 - Diff: &diff, 2580 - DiffOpts: diffOpts, 2581 - }) 2582 - 2583 - }
+2 -37
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "context" 5 4 "crypto/rand" 6 - "fmt" 7 5 "math/big" 8 6 "slices" 9 7 "sort" ··· 19 17 20 18 func sortFiles(files []types.NiceTree) { 21 19 sort.Slice(files, func(i, j int) bool { 22 - iIsFile := files[i].IsFile 23 - jIsFile := files[j].IsFile 20 + iIsFile := files[i].IsFile() 21 + jIsFile := files[j].IsFile() 24 22 if iIsFile != jIsFile { 25 23 return !iIsFile 26 24 } ··· 90 88 } 91 89 92 90 return 93 - } 94 - 95 - // emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid 96 - // and resolves all dids to handles and returns a new map[string]string 97 - func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string { 98 - if emailToDidMap == nil { 99 - return nil 100 - } 101 - 102 - var dids []string 103 - for _, v := range emailToDidMap { 104 - dids = append(dids, v) 105 - } 106 - resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids) 107 - 108 - didHandleMap := make(map[string]string) 109 - for _, identity := range resolvedIdents { 110 - if !identity.Handle.IsInvalidHandle() { 111 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 - } else { 113 - didHandleMap[identity.DID.String()] = identity.DID.String() 114 - } 115 - } 116 - 117 - // Create map of email to didOrHandle for commit display 118 - emailToDidOrHandle := make(map[string]string) 119 - for email, did := range emailToDidMap { 120 - if didOrHandle, ok := didHandleMap[did]; ok { 121 - emailToDidOrHandle[email] = didOrHandle 122 - } 123 - } 124 - 125 - return emailToDidOrHandle 126 91 } 127 92 128 93 func randomString(n int) string {
+15 -20
appview/repo/router.go
··· 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 - r.Get("/", rp.RepoIndex) 13 - r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 - r.Get("/feed.atom", rp.RepoAtomFeed) 15 - r.Get("/commits/{ref}", rp.RepoLog) 12 + r.Get("/", rp.Index) 13 + r.Get("/opengraph", rp.Opengraph) 14 + r.Get("/feed.atom", rp.AtomFeed) 15 + r.Get("/commits/{ref}", rp.Log) 16 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 - r.Get("/", rp.RepoIndex) 18 - r.Get("/*", rp.RepoTree) 17 + r.Get("/", rp.Index) 18 + r.Get("/*", rp.Tree) 19 19 }) 20 - r.Get("/commit/{ref}", rp.RepoCommit) 21 - r.Get("/branches", rp.RepoBranches) 20 + r.Get("/commit/{ref}", rp.Commit) 21 + r.Get("/branches", rp.Branches) 22 22 r.Delete("/branches", rp.DeleteBranch) 23 23 r.Route("/tags", func(r chi.Router) { 24 - r.Get("/", rp.RepoTags) 24 + r.Get("/", rp.Tags) 25 25 r.Route("/{tag}", func(r chi.Router) { 26 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 27 ··· 37 37 }) 38 38 }) 39 39 }) 40 - r.Get("/blob/{ref}/*", rp.RepoBlob) 40 + r.Get("/blob/{ref}/*", rp.Blob) 41 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 42 43 43 // intentionally doesn't use /* as this isn't ··· 54 54 }) 55 55 56 56 r.Route("/compare", func(r chi.Router) { 57 - r.Get("/", rp.RepoCompareNew) // start an new comparison 57 + r.Get("/", rp.CompareNew) // start an new comparison 58 58 59 59 // we have to wildcard here since we want to support GitHub's compare syntax 60 60 // /compare/{ref1}...{ref2} 61 61 // for example: 62 62 // /compare/master...some/feature 63 63 // /compare/master...example.com:another/feature <- this is a fork 64 - r.Get("/{base}/{head}", rp.RepoCompare) 65 - r.Get("/*", rp.RepoCompare) 64 + r.Get("/{base}/{head}", rp.Compare) 65 + r.Get("/*", rp.Compare) 66 66 }) 67 67 68 68 // label panel in issues/pulls/discussions/tasks ··· 74 74 // settings routes, needs auth 75 75 r.Group(func(r chi.Router) { 76 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 77 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 84 - r.Get("/", rp.RepoSettings) 78 + r.Get("/", rp.Settings) 79 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 85 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 86 81 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 87 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 188 Rkey: f.Repo.Rkey, 189 189 RepoAt: repoAt, 190 190 Description: f.Description, 191 + Website: f.Website, 192 + Topics: f.Topics, 191 193 IsStarred: isStarred, 192 194 Knot: knot, 193 195 Spindle: f.Spindle,
+4 -2
appview/settings/settings.go
··· 22 22 "tangled.org/core/tid" 23 23 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 25 26 lexutil "github.com/bluesky-social/indigo/lex/util" 26 27 "github.com/gliderlabs/ssh" 27 28 "github.com/google/uuid" ··· 91 92 user := s.OAuth.GetUser(r) 92 93 did := s.OAuth.GetDid(r) 93 94 94 - prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 95 + prefs, err := db.GetNotificationPreference(s.Db, did) 95 96 if err != nil { 96 97 log.Printf("failed to get notification preferences: %s", err) 97 98 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") ··· 110 111 did := s.OAuth.GetDid(r) 111 112 112 113 prefs := &models.NotificationPreferences{ 113 - UserDid: did, 114 + UserDid: syntax.DID(did), 114 115 RepoStarred: r.FormValue("repo_starred") == "on", 115 116 IssueCreated: r.FormValue("issue_created") == "on", 116 117 IssueCommented: r.FormValue("issue_commented") == "on", ··· 119 120 PullCommented: r.FormValue("pull_commented") == "on", 120 121 PullMerged: r.FormValue("pull_merged") == "on", 121 122 Followed: r.FormValue("followed") == "on", 123 + UserMentioned: r.FormValue("user_mentioned") == "on", 122 124 EmailNotifications: r.FormValue("email_notifications") == "on", 123 125 } 124 126
+18
appview/signup/requests.go
··· 102 102 103 103 return result.DID, nil 104 104 } 105 + 106 + func (s *Signup) deleteAccountRequest(did string) error { 107 + body := map[string]string{ 108 + "did": did, 109 + } 110 + 111 + resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true) 112 + if err != nil { 113 + return err 114 + } 115 + defer resp.Body.Close() 116 + 117 + if resp.StatusCode != http.StatusOK { 118 + return s.handlePdsError(resp, "delete account") 119 + } 120 + 121 + return nil 122 + }
+93 -36
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 5 6 "encoding/json" 6 7 "errors" 7 8 "fmt" ··· 216 217 return 217 218 } 218 219 219 - did, err := s.createAccountRequest(username, password, email, code) 220 - if err != nil { 221 - s.l.Error("failed to create account", "error", err) 222 - s.pages.Notice(w, "signup-error", err.Error()) 223 - return 224 - } 225 - 226 220 if s.cf == nil { 227 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 228 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 229 223 return 230 224 } 231 225 232 - err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 233 - Type: "TXT", 234 - Name: "_atproto." + username, 235 - Content: fmt.Sprintf(`"did=%s"`, did), 236 - TTL: 6400, 237 - Proxied: false, 238 - }) 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 239 228 if err != nil { 240 - s.l.Error("failed to create DNS record", "error", err) 241 - s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + // Error already logged and notice already sent 242 230 return 243 231 } 232 + } 233 + } 244 234 245 - err = db.AddEmail(s.db, models.Email{ 246 - Did: did, 247 - Address: email, 248 - Verified: true, 249 - Primary: true, 250 - }) 251 - if err != nil { 252 - s.l.Error("failed to add email", "error", err) 253 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 254 - return 255 - } 235 + // executeSignupTransaction performs the signup process transactionally with rollback 236 + func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 237 + var recordID string 238 + var did string 239 + var emailAdded bool 256 240 257 - s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 258 - <a class="underline text-black dark:text-white" href="/login">login</a> 259 - with <code>%s.tngl.sh</code>.`, username)) 241 + success := false 242 + defer func() { 243 + if !success { 244 + s.l.Info("rolling back signup transaction", "username", username, "did", did) 260 245 261 - go func() { 262 - err := db.DeleteInflightSignup(s.db, email) 263 - if err != nil { 264 - s.l.Error("failed to delete inflight signup", "error", err) 246 + // Rollback DNS record 247 + if recordID != "" { 248 + if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil { 249 + s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID) 250 + } else { 251 + s.l.Info("successfully rolled back DNS record", "recordID", recordID) 252 + } 265 253 } 266 - }() 267 - return 254 + 255 + // Rollback PDS account 256 + if did != "" { 257 + if err := s.deleteAccountRequest(did); err != nil { 258 + s.l.Error("failed to rollback PDS account", "error", err, "did", did) 259 + } else { 260 + s.l.Info("successfully rolled back PDS account", "did", did) 261 + } 262 + } 263 + 264 + // Rollback email from database 265 + if emailAdded { 266 + if err := db.DeleteEmail(s.db, did, email); err != nil { 267 + s.l.Error("failed to rollback email from database", "error", err, "email", email) 268 + } else { 269 + s.l.Info("successfully rolled back email from database", "email", email) 270 + } 271 + } 272 + } 273 + }() 274 + 275 + // step 1: create account in PDS 276 + did, err := s.createAccountRequest(username, password, email, code) 277 + if err != nil { 278 + s.l.Error("failed to create account", "error", err) 279 + s.pages.Notice(w, "signup-error", err.Error()) 280 + return err 268 281 } 282 + 283 + // step 2: create DNS record with actual DID 284 + recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{ 285 + Type: "TXT", 286 + Name: "_atproto." + username, 287 + Content: fmt.Sprintf(`"did=%s"`, did), 288 + TTL: 6400, 289 + Proxied: false, 290 + }) 291 + if err != nil { 292 + s.l.Error("failed to create DNS record", "error", err) 293 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 294 + return err 295 + } 296 + 297 + // step 3: add email to database 298 + err = db.AddEmail(s.db, models.Email{ 299 + Did: did, 300 + Address: email, 301 + Verified: true, 302 + Primary: true, 303 + }) 304 + if err != nil { 305 + s.l.Error("failed to add email", "error", err) 306 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 307 + return err 308 + } 309 + emailAdded = true 310 + 311 + // if we get here, we've successfully created the account and added the email 312 + success = true 313 + 314 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 315 + <a class="underline text-black dark:text-white" href="/login">login</a> 316 + with <code>%s.tngl.sh</code>.`, username)) 317 + 318 + // clean up inflight signup asynchronously 319 + go func() { 320 + if err := db.DeleteInflightSignup(s.db, email); err != nil { 321 + s.l.Error("failed to delete inflight signup", "error", err) 322 + } 323 + }() 324 + 325 + return nil 269 326 } 270 327 271 328 type turnstileResponse struct {
+9
appview/spindles/spindles.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 146 147 } 147 148 148 149 instance := r.FormValue("instance") 150 + // Strip protocol, trailing slashes, and whitespace 151 + // Rkey cannot contain slashes 152 + instance = strings.TrimSpace(instance) 153 + instance = strings.TrimPrefix(instance, "https://") 154 + instance = strings.TrimPrefix(instance, "http://") 155 + instance = strings.TrimSuffix(instance, "/") 149 156 if instance == "" { 150 157 s.Pages.Notice(w, noticeId, "Incomplete form.") 151 158 return ··· 484 491 } 485 492 486 493 member := r.FormValue("member") 494 + member = strings.TrimPrefix(member, "@") 487 495 if member == "" { 488 496 l.Error("empty member") 489 497 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 613 621 } 614 622 615 623 member := r.FormValue("member") 624 + member = strings.TrimPrefix(member, "@") 616 625 if member == "" { 617 626 l.Error("empty member") 618 627 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+1
appview/state/follow.go
··· 26 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 27 if err != nil { 28 28 log.Println("failed to follow, invalid did") 29 + return 29 30 } 30 31 31 32 if currentUser.Did == subjectIdent.DID.String() {
+11 -8
appview/state/gfi.go
··· 1 1 package state 2 2 3 3 import ( 4 - "fmt" 5 4 "log" 6 5 "net/http" 7 6 "sort" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/api/tangled" 11 9 "tangled.org/core/appview/db" 12 10 "tangled.org/core/appview/models" 13 11 "tangled.org/core/appview/pages" ··· 18 16 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 17 user := s.oauth.GetUser(r) 20 18 21 - page, ok := r.Context().Value("page").(pagination.Page) 22 - if !ok { 23 - page = pagination.FirstPage() 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 24 28 } 25 - 26 - goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 29 28 30 repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 31 if err != nil { ··· 38 40 RepoGroups: []*models.RepoGroup{}, 39 41 LabelDefs: make(map[string]*models.LabelDefinition), 40 42 Page: page, 43 + GfiLabel: gfiLabelDef, 41 44 }) 42 45 return 43 46 } ··· 146 149 RepoGroups: paginatedGroups, 147 150 LabelDefs: labelDefsMap, 148 151 Page: page, 149 - GfiLabel: labelDefsMap[goodFirstIssueLabel], 152 + GfiLabel: gfiLabelDef, 150 153 }) 151 154 }
+3
appview/state/login.go
··· 14 14 switch r.Method { 15 15 case http.MethodGet: 16 16 returnURL := r.URL.Query().Get("return_url") 17 + errorCode := r.URL.Query().Get("error") 17 18 s.pages.Login(w, pages.LoginParams{ 18 19 ReturnUrl: returnURL, 20 + ErrorCode: errorCode, 19 21 }) 20 22 case http.MethodPost: 21 23 handle := r.FormValue("handle") ··· 44 46 45 47 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 46 48 if err != nil { 49 + l.Error("failed to start auth", "err", err) 47 50 http.Error(w, err.Error(), http.StatusInternalServerError) 48 51 return 49 52 }
+2
appview/state/profile.go
··· 538 538 profile.Description = r.FormValue("description") 539 539 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 540 540 profile.Location = r.FormValue("location") 541 + profile.Pronouns = r.FormValue("pronouns") 541 542 542 543 var links [5]string 543 544 for i := range 5 { ··· 652 653 Location: &profile.Location, 653 654 PinnedRepositories: pinnedRepoStrings, 654 655 Stats: vanityStats[:], 656 + Pronouns: &profile.Pronouns, 655 657 }}, 656 658 SwapRecord: cid, 657 659 })
+44 -36
appview/state/router.go
··· 42 42 43 43 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 44 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 - } 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 68 77 } 69 - standardRouter.ServeHTTP(w, r) 78 + 70 79 } 80 + 81 + standardRouter.ServeHTTP(w, r) 71 82 }) 72 83 73 84 return router ··· 80 91 r.Get("/", s.Profile) 81 92 r.Get("/feed.atom", s.AtomFeedPage) 82 93 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 94 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 90 95 r.Use(mw.GoImport()) 91 96 r.Mount("/", s.RepoRouter(mw)) 92 97 r.Mount("/issues", s.IssuesRouter(mw)) 93 98 r.Mount("/pulls", s.PullsRouter(mw)) 94 - r.Mount("/pipelines", s.PipelinesRouter(mw)) 95 - r.Mount("/labels", s.LabelsRouter(mw)) 99 + r.Mount("/pipelines", s.PipelinesRouter()) 100 + r.Mount("/labels", s.LabelsRouter()) 96 101 97 102 // These routes get proxied to the knot 98 103 r.Get("/info/refs", s.InfoRefs) ··· 262 267 s.config, 263 268 s.notifier, 264 269 s.validator, 270 + s.indexer.Issues, 265 271 log.SubLogger(s.logger, "issues"), 266 272 ) 267 273 return issues.Router(mw) ··· 277 283 s.config, 278 284 s.notifier, 279 285 s.enforcer, 286 + s.validator, 287 + s.indexer.Pulls, 280 288 log.SubLogger(s.logger, "pulls"), 281 289 ) 282 290 return pulls.Router(mw) ··· 299 307 return repo.Router(mw) 300 308 } 301 309 302 - func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 310 + func (s *State) PipelinesRouter() http.Handler { 303 311 pipes := pipelines.New( 304 312 s.oauth, 305 313 s.repoResolver, ··· 311 319 s.enforcer, 312 320 log.SubLogger(s.logger, "pipelines"), 313 321 ) 314 - return pipes.Router(mw) 322 + return pipes.Router() 315 323 } 316 324 317 - func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 325 + func (s *State) LabelsRouter() http.Handler { 318 326 ls := labels.New( 319 327 s.oauth, 320 328 s.pages, ··· 323 331 s.enforcer, 324 332 log.SubLogger(s.logger, "labels"), 325 333 ) 326 - return ls.Router(mw) 334 + return ls.Router() 327 335 } 328 336 329 337 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
+22 -12
appview/state/state.go
··· 14 14 "tangled.org/core/appview" 15 15 "tangled.org/core/appview/config" 16 16 "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/indexer" 17 18 "tangled.org/core/appview/models" 18 19 "tangled.org/core/appview/notify" 19 20 dbnotify "tangled.org/core/appview/notify/db" ··· 43 44 type State struct { 44 45 db *db.DB 45 46 notifier notify.Notifier 47 + indexer *indexer.Indexer 46 48 oauth *oauth.OAuth 47 49 enforcer *rbac.Enforcer 48 50 pages *pages.Pages ··· 65 67 return nil, fmt.Errorf("failed to create db: %w", err) 66 68 } 67 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 + 68 76 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 69 77 if err != nil { 70 78 return nil, fmt.Errorf("failed to create enforcer: %w", err) 71 79 } 72 80 73 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 81 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 74 82 if err != nil { 75 83 logger.Error("failed to create redis resolver", "err", err) 76 - res = idresolver.DefaultResolver() 84 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 77 85 } 78 86 79 87 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 121 129 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 122 130 } 123 131 124 - if err := BackfillDefaultDefs(d, res); err != nil { 132 + if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 125 133 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 126 134 } 127 135 ··· 159 167 if !config.Core.Dev { 160 168 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 161 169 } 162 - notifier := notify.NewMergedNotifier(notifiers...) 170 + notifiers = append(notifiers, indexer) 171 + notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 163 172 164 173 state := &State{ 165 174 d, 166 175 notifier, 176 + indexer, 167 177 oauth, 168 178 enforcer, 169 179 pages, ··· 284 294 return 285 295 } 286 296 287 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 297 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 288 298 if err != nil { 289 299 // non-fatal 290 300 } ··· 376 386 377 387 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 378 388 if err != nil { 379 - w.WriteHeader(http.StatusNotFound) 389 + s.logger.Error("failed to get public keys", "err", err) 390 + http.Error(w, "failed to get public keys", http.StatusInternalServerError) 380 391 return 381 392 } 382 393 383 394 if len(pubKeys) == 0 { 384 - w.WriteHeader(http.StatusNotFound) 395 + w.WriteHeader(http.StatusNoContent) 385 396 return 386 397 } 387 398 ··· 506 517 Rkey: rkey, 507 518 Description: description, 508 519 Created: time.Now(), 509 - Labels: models.DefaultLabelDefs(), 520 + Labels: s.config.Label.DefaultLabelDefs, 510 521 } 511 522 record := repo.AsRecord() 512 523 ··· 622 633 aturi = "" 623 634 624 635 s.notifier.NewRepo(r.Context(), repo) 625 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 636 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 626 637 } 627 638 } 628 639 ··· 648 659 return err 649 660 } 650 661 651 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 652 - defaults := models.DefaultLabelDefs() 662 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 653 663 defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 654 664 if err != nil { 655 665 return err ··· 659 669 return nil 660 670 } 661 671 662 - labelDefs, err := models.FetchDefaultDefs(r) 672 + labelDefs, err := models.FetchLabelDefs(r, defaults) 663 673 if err != nil { 664 674 return err 665 675 }
+6 -6
appview/state/userutil/userutil.go
··· 10 10 didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 11 ) 12 12 13 - func IsHandleNoAt(s string) bool { 13 + func IsHandle(s string) bool { 14 14 // ref: https://atproto.com/specs/handle 15 15 return handleRegex.MatchString(s) 16 + } 17 + 18 + // IsDid checks if the given string is a standard DID. 19 + func IsDid(s string) bool { 20 + return didRegex.MatchString(s) 16 21 } 17 22 18 23 func UnflattenDid(s string) string { ··· 45 50 return strings.Replace(s, ":", "-", 2) 46 51 } 47 52 return s 48 - } 49 - 50 - // IsDid checks if the given string is a standard DID. 51 - func IsDid(s string) bool { 52 - return didRegex.MatchString(s) 53 53 } 54 54 55 55 var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+25
appview/validator/patch.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/patchutil" 8 + ) 9 + 10 + func (v *Validator) ValidatePatch(patch *string) error { 11 + if patch == nil || *patch == "" { 12 + return fmt.Errorf("patch is empty") 13 + } 14 + 15 + // add newline if not present to diff style patches 16 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 17 + *patch = *patch + "\n" 18 + } 19 + 20 + if err := patchutil.IsPatchValid(*patch); err != nil { 21 + return err 22 + } 23 + 24 + return nil 25 + }
+53
appview/validator/repo_topics.go
··· 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 37 38 38 ``` 39 39 # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_JWKS 41 - {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 40 + echo $TANGLED_OAUTH_CLIENT_SECRET 41 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 + 43 + echo $TANGLED_OAUTH_CLIENT_KID 44 + 1761667908 42 45 43 46 # if not, you can set it up yourself: 44 - go build -o genjwks.out ./cmd/genjwks 45 - export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 47 + goat key generate -t P-256 48 + Key Type: P-256 / secp256r1 / ES256 private key 49 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 + 54 + # the secret key from above 55 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 46 56 47 57 # run redis in at a new shell to store oauth sessions 48 58 redis-server ··· 158 168 159 169 If for any reason you wish to disable either one of the 160 170 services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 - `services.tangled-spindle.enable` (or 162 - `services.tangled-knot.enable`) to `false`. 171 + `services.tangled.spindle.enable` (or 172 + `services.tangled.knot.enable`) to `false`.
+2 -1
docs/knot-hosting.md
··· 39 39 ``` 40 40 41 41 Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/knot` is a good choice: 42 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 43 44 44 ``` 45 45 sudo mv knot /usr/local/bin/knot 46 + sudo chown root:root /usr/local/bin/knot 46 47 ``` 47 48 48 49 This is necessary because SSH `AuthorizedKeysCommand` requires [really
+1 -1
docs/migrations.md
··· 49 49 latest revision, and change your config block like so: 50 50 51 51 ```diff 52 - services.tangled-knot = { 52 + services.tangled.knot = { 53 53 enable = true; 54 54 server = { 55 55 - secretFile = /path/to/secret;
+19 -1
docs/spindle/pipeline.md
··· 19 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 21 21 - `manual`: The workflow can be triggered manually. 22 - - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 22 + - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 + - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 24 24 25 For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ··· 29 30 branch: ["main", "develop"] 30 31 - event: ["pull_request"] 31 32 branch: ["main"] 33 + ``` 34 + 35 + You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: 36 + 37 + ```yaml 38 + when: 39 + - event: ["push"] 40 + tag: ["v*"] 41 + ``` 42 + 43 + You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): 44 + 45 + ```yaml 46 + when: 47 + - event: ["push"] 48 + branch: ["main", "release-*"] 49 + tag: ["v*", "stable"] 32 50 ``` 33 51 34 52 ## Engine
+17
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "actor-typeahead-src": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1762835797, 7 + "narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=", 8 + "ref": "refs/heads/main", 9 + "rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b", 10 + "revCount": 6, 11 + "type": "git", 12 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 13 + }, 14 + "original": { 15 + "type": "git", 16 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 17 + } 18 + }, 3 19 "flake-compat": { 4 20 "flake": false, 5 21 "locked": { ··· 150 166 }, 151 167 "root": { 152 168 "inputs": { 169 + "actor-typeahead-src": "actor-typeahead-src", 153 170 "flake-compat": "flake-compat", 154 171 "gomod2nix": "gomod2nix", 155 172 "htmx-src": "htmx-src",
+18 -8
flake.nix
··· 33 33 url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"; 34 34 flake = false; 35 35 }; 36 + actor-typeahead-src = { 37 + url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 38 + flake = false; 39 + }; 36 40 ibm-plex-mono-src = { 37 41 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 38 42 flake = false; ··· 54 58 inter-fonts-src, 55 59 sqlite-lib-src, 56 60 ibm-plex-mono-src, 61 + actor-typeahead-src, 57 62 ... 58 63 }: let 59 64 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 78 83 inherit (pkgs) gcc; 79 84 inherit sqlite-lib-src; 80 85 }; 81 - genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 82 86 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 87 + goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 83 88 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 84 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 89 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 85 90 }; 86 91 appview = self.callPackage ./nix/pkgs/appview.nix {}; 87 92 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; ··· 90 95 }); 91 96 in { 92 97 overlays.default = final: prev: { 93 - inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 98 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 94 99 }; 95 100 96 101 packages = forAllSystems (system: let ··· 99 104 staticPackages = mkPackageSet pkgs.pkgsStatic; 100 105 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 101 106 in { 102 - inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 107 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 103 108 104 109 pkgsStatic-appview = staticPackages.appview; 105 110 pkgsStatic-knot = staticPackages.knot; ··· 167 172 mkdir -p appview/pages/static 168 173 # no preserve is needed because watch-tailwind will want to be able to overwrite 169 174 cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 170 - export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 175 + export TANGLED_OAUTH_CLIENT_KID="$(date +%s)" 176 + export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')" 171 177 ''; 172 178 env.CGO_ENABLED = 1; 173 179 }; ··· 206 212 watch-knot = { 207 213 type = "app"; 208 214 program = ''${air-watcher "knot" "server"}/bin/run''; 215 + }; 216 + watch-spindle = { 217 + type = "app"; 218 + program = ''${air-watcher "spindle" ""}/bin/run''; 209 219 }; 210 220 watch-tailwind = { 211 221 type = "app"; ··· 278 288 }: { 279 289 imports = [./nix/modules/appview.nix]; 280 290 281 - services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 291 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 282 292 }; 283 293 nixosModules.knot = { 284 294 lib, ··· 287 297 }: { 288 298 imports = [./nix/modules/knot.nix]; 289 299 290 - services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 300 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 291 301 }; 292 302 nixosModules.spindle = { 293 303 lib, ··· 296 306 }: { 297 307 imports = [./nix/modules/spindle.nix]; 298 308 299 - services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 309 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 300 310 }; 301 311 }; 302 312 }
+32 -13
go.mod
··· 7 7 github.com/alecthomas/assert/v2 v2.11.0 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 + github.com/blevesearch/bleve/v2 v2.5.3 10 11 github.com/bluekeyes/go-gitdiff v0.8.1 11 12 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 13 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 14 + github.com/bmatcuk/doublestar/v4 v4.9.1 13 15 github.com/carlmjohnson/versioninfo v0.22.5 14 16 github.com/casbin/casbin/v2 v2.103.0 17 + github.com/charmbracelet/log v0.4.2 15 18 github.com/cloudflare/cloudflare-go v0.115.0 16 19 github.com/cyphar/filepath-securejoin v0.4.1 17 20 github.com/dgraph-io/ristretto v0.2.0 ··· 29 32 github.com/hiddeco/sshsig v0.2.0 30 33 github.com/hpcloud/tail v1.0.0 31 34 github.com/ipfs/go-cid v0.5.0 32 - github.com/lestrrat-go/jwx/v2 v2.1.6 33 35 github.com/mattn/go-sqlite3 v1.14.24 34 36 github.com/microcosm-cc/bluemonday v1.0.27 35 37 github.com/openbao/openbao/api/v2 v2.3.0 ··· 42 44 github.com/stretchr/testify v1.10.0 43 45 github.com/urfave/cli/v3 v3.3.3 44 46 github.com/whyrusleeping/cbor-gen v0.3.1 45 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 47 + github.com/wyatt915/goldmark-treeblood v0.0.1 46 48 github.com/yuin/goldmark v1.7.13 47 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 48 51 golang.org/x/crypto v0.40.0 49 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 53 golang.org/x/image v0.31.0 ··· 58 61 dario.cat/mergo v1.0.1 // indirect 59 62 github.com/Microsoft/go-winio v0.6.2 // indirect 60 63 github.com/ProtonMail/go-crypto v1.3.0 // indirect 64 + github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 61 65 github.com/alecthomas/repr v0.4.0 // indirect 62 66 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 63 67 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 64 68 github.com/aymerick/douceur v0.2.0 // indirect 65 69 github.com/beorn7/perks v1.0.1 // indirect 66 - github.com/bmatcuk/doublestar/v4 v4.7.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 67 88 github.com/casbin/govaluate v1.3.0 // indirect 68 89 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 69 90 github.com/cespare/xxhash/v2 v2.3.0 // indirect 70 91 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 71 92 github.com/charmbracelet/lipgloss v1.1.0 // indirect 72 - github.com/charmbracelet/log v0.4.2 // indirect 73 93 github.com/charmbracelet/x/ansi v0.8.0 // indirect 74 94 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 75 95 github.com/charmbracelet/x/term v0.2.1 // indirect ··· 78 98 github.com/containerd/errdefs/pkg v0.3.0 // indirect 79 99 github.com/containerd/log v0.1.0 // indirect 80 100 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 101 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 83 102 github.com/distribution/reference v0.6.0 // indirect 84 103 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 101 120 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 102 121 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 103 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 104 125 github.com/google/go-querystring v1.1.0 // indirect 105 126 github.com/gorilla/css v1.0.1 // indirect 106 127 github.com/gorilla/securecookie v1.1.2 // indirect ··· 126 147 github.com/ipfs/go-log v1.0.5 // indirect 127 148 github.com/ipfs/go-log/v2 v2.6.0 // indirect 128 149 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 150 + github.com/json-iterator/go v1.1.12 // indirect 129 151 github.com/kevinburke/ssh_config v1.2.0 // indirect 130 152 github.com/klauspost/compress v1.18.0 // indirect 131 153 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 154 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 138 155 github.com/mattn/go-isatty v0.0.20 // indirect 139 156 github.com/mattn/go-runewidth v0.0.16 // indirect ··· 142 159 github.com/moby/docker-image-spec v1.3.1 // indirect 143 160 github.com/moby/sys/atomicwriter v0.1.0 // indirect 144 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 145 164 github.com/morikuni/aec v1.0.0 // indirect 146 165 github.com/mr-tron/base58 v1.2.0 // indirect 166 + github.com/mschoch/smat v0.2.0 // indirect 147 167 github.com/muesli/termenv v0.16.0 // indirect 148 168 github.com/multiformats/go-base32 v0.1.0 // indirect 149 169 github.com/multiformats/go-base36 v0.2.0 // indirect ··· 165 185 github.com/prometheus/procfs v0.16.1 // indirect 166 186 github.com/rivo/uniseg v0.4.7 // indirect 167 187 github.com/ryanuber/go-glob v1.0.0 // indirect 168 - github.com/segmentio/asm v1.2.0 // indirect 169 188 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 170 189 github.com/spaolacci/murmur3 v1.1.0 // indirect 171 190 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 172 191 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 173 192 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 174 - github.com/wyatt915/treeblood v0.1.15 // indirect 193 + github.com/wyatt915/treeblood v0.1.16 // indirect 175 194 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 176 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 177 195 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 178 196 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 197 + go.etcd.io/bbolt v1.4.0 // indirect 179 198 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 180 199 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 181 200 go.opentelemetry.io/otel v1.37.0 // indirect
+64 -21
go.sum
··· 9 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 10 github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 + github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 13 + github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 12 14 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 15 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 16 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 25 27 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 26 28 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 27 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= 28 69 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 70 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 30 71 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 31 72 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 32 73 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 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= 35 77 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 36 78 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 37 79 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 83 125 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 84 126 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 85 127 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 128 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 89 129 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 90 130 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 170 210 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 171 211 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 172 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= 173 217 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 174 218 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 175 219 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 181 225 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 182 226 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 183 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= 184 229 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 185 230 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 186 231 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 261 306 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 262 307 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 263 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= 264 311 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 265 312 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 266 313 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 280 327 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 281 328 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 282 329 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 330 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 296 331 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 297 332 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 316 351 github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 317 352 github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 318 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= 319 359 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 320 360 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 321 361 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 322 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= 323 365 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 324 366 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 325 367 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= ··· 409 451 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 410 452 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 411 453 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 454 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 415 455 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 416 456 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 455 495 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 456 496 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 457 497 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= 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= 462 502 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 463 503 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 464 504 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 477 517 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 478 518 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 479 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= 480 522 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 481 523 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 482 524 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= ··· 680 722 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 681 723 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 682 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= 683 726 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 684 727 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 685 728 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+36 -61
guard/guard.go
··· 12 12 "os/exec" 13 13 "strings" 14 14 15 - "github.com/bluesky-social/indigo/atproto/identity" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/urfave/cli/v3" 18 - "tangled.org/core/idresolver" 19 17 "tangled.org/core/log" 20 18 ) 21 19 ··· 93 91 "command", sshCommand, 94 92 "client", clientIP) 95 93 94 + // TODO: greet user with their resolved handle instead of did 96 95 if sshCommand == "" { 97 96 l.Info("access denied: no interactive shells", "user", incomingUser) 98 97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 107 106 } 108 107 109 108 gitCommand := cmdParts[0] 110 - 111 - // did:foo/repo-name or 112 - // handle/repo-name or 113 - // any of the above with a leading slash (/) 114 - 115 - components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 116 - l.Info("command components", "components", components) 117 - 118 - if len(components) != 2 { 119 - l.Error("invalid repo format", "components", components) 120 - fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 121 - os.Exit(-1) 122 - } 123 - 124 - didOrHandle := components[0] 125 - identity := resolveIdentity(ctx, l, didOrHandle) 126 - did := identity.DID.String() 127 - repoName := components[1] 128 - qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 109 + repoPath := cmdParts[1] 129 110 130 111 validCommands := map[string]bool{ 131 112 "git-receive-pack": true, ··· 138 119 return fmt.Errorf("access denied: invalid git command") 139 120 } 140 121 141 - if gitCommand != "git-upload-pack" { 142 - if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 143 - l.Error("access denied: user not allowed", 144 - "did", incomingUser, 145 - "reponame", qualifiedRepoName) 146 - fmt.Fprintln(os.Stderr, "access denied: user not allowed") 147 - os.Exit(-1) 148 - } 122 + // qualify repo path from internal server which holds the knot config 123 + qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand) 124 + if err != nil { 125 + l.Error("failed to run guard", "err", err) 126 + fmt.Fprintln(os.Stderr, err) 127 + os.Exit(1) 149 128 } 150 129 151 - fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 130 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 152 131 153 132 l.Info("processing command", 154 133 "user", incomingUser, 155 134 "command", gitCommand, 156 - "repo", repoName, 135 + "repo", repoPath, 157 136 "fullPath", fullPath, 158 137 "client", clientIP) 159 138 ··· 177 156 gitCmd.Stdin = os.Stdin 178 157 gitCmd.Env = append(os.Environ(), 179 158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 180 - fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 181 159 ) 182 160 183 161 if err := gitCmd.Run(); err != nil { ··· 189 167 l.Info("command completed", 190 168 "user", incomingUser, 191 169 "command", gitCommand, 192 - "repo", repoName, 170 + "repo", repoPath, 193 171 "success", true) 194 172 195 173 return nil 196 174 } 197 175 198 - func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 199 - resolver := idresolver.DefaultResolver() 200 - ident, err := resolver.ResolveIdent(ctx, didOrHandle) 176 + // runs guardAndQualifyRepo logic 177 + func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 178 + u, _ := url.Parse(endpoint + "/guard") 179 + q := u.Query() 180 + q.Add("user", incomingUser) 181 + q.Add("repo", repo) 182 + q.Add("gitCmd", gitCommand) 183 + u.RawQuery = q.Encode() 184 + 185 + resp, err := http.Get(u.String()) 201 186 if err != nil { 202 - l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 203 - fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 204 - os.Exit(1) 205 - } 206 - if ident.Handle.IsInvalidHandle() { 207 - l.Error("Error resolving handle", "invalid handle", didOrHandle) 208 - fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n") 209 - os.Exit(1) 187 + return "", err 210 188 } 211 - return ident 212 - } 189 + defer resp.Body.Close() 213 190 214 - func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 215 - u, _ := url.Parse(endpoint + "/push-allowed") 216 - q := u.Query() 217 - q.Add("user", user) 218 - q.Add("repo", qualifiedRepoName) 219 - u.RawQuery = q.Encode() 191 + l.Info("Running guard", "url", u.String(), "status", resp.Status) 220 192 221 - req, err := http.Get(u.String()) 193 + body, err := io.ReadAll(resp.Body) 222 194 if err != nil { 223 - l.Error("Error verifying permissions", "error", err) 224 - fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 225 - os.Exit(1) 195 + return "", err 226 196 } 227 - 228 - l.Info("Checking push permission", 229 - "url", u.String(), 230 - "status", req.Status) 197 + text := string(body) 231 198 232 - return req.StatusCode == http.StatusNoContent 199 + switch resp.StatusCode { 200 + case http.StatusOK: 201 + return text, nil 202 + case http.StatusForbidden: 203 + l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text) 204 + return text, errors.New("access denied: user not allowed") 205 + default: 206 + return "", errors.New(text) 207 + } 233 208 }
+17 -8
idresolver/resolver.go
··· 17 17 directory identity.Directory 18 18 } 19 19 20 - func BaseDirectory() identity.Directory { 20 + func BaseDirectory(plcUrl string) identity.Directory { 21 21 base := identity.BaseDirectory{ 22 - PLCURL: identity.DefaultPLCURL, 22 + PLCURL: plcUrl, 23 23 HTTPClient: http.Client{ 24 24 Timeout: time.Second * 10, 25 25 Transport: &http.Transport{ ··· 42 42 return &base 43 43 } 44 44 45 - func RedisDirectory(url string) (identity.Directory, error) { 45 + func RedisDirectory(url, plcUrl string) (identity.Directory, error) { 46 46 hitTTL := time.Hour * 24 47 47 errTTL := time.Second * 30 48 48 invalidHandleTTL := time.Minute * 5 49 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 49 + return redisdir.NewRedisDirectory( 50 + BaseDirectory(plcUrl), 51 + url, 52 + hitTTL, 53 + errTTL, 54 + invalidHandleTTL, 55 + 10000, 56 + ) 50 57 } 51 58 52 - func DefaultResolver() *Resolver { 59 + func DefaultResolver(plcUrl string) *Resolver { 60 + base := BaseDirectory(plcUrl) 61 + cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 53 62 return &Resolver{ 54 - directory: identity.DefaultDirectory(), 63 + directory: &cached, 55 64 } 56 65 } 57 66 58 - func RedisResolver(redisUrl string) (*Resolver, error) { 59 - directory, err := RedisDirectory(redisUrl) 67 + func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 + directory, err := RedisDirectory(redisUrl, plcUrl) 60 69 if err != nil { 61 70 return nil, err 62 71 }
+38
input.css
··· 161 161 @apply no-underline; 162 162 } 163 163 164 + .prose a.mention { 165 + @apply no-underline hover:underline; 166 + } 167 + 164 168 .prose li { 165 169 @apply my-0 py-0; 166 170 } ··· 241 245 details[data-callout] > summary::-webkit-details-marker { 242 246 display: none; 243 247 } 248 + 244 249 } 245 250 @layer utilities { 246 251 .error { ··· 924 929 text-decoration: underline; 925 930 } 926 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 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 21 Hostname string `env:"HOSTNAME, required"` 22 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 22 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 24 Owner string `env:"OWNER, required"` 24 25 LogDids bool `env:"LOG_DIDS, default=true"`
+60 -2
knotserver/git/git.go
··· 3 3 import ( 4 4 "archive/tar" 5 5 "bytes" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "io/fs" ··· 12 13 "time" 13 14 14 15 "github.com/go-git/go-git/v5" 16 + "github.com/go-git/go-git/v5/config" 15 17 "github.com/go-git/go-git/v5/plumbing" 16 18 "github.com/go-git/go-git/v5/plumbing/object" 17 19 ) 18 20 19 21 var ( 20 - ErrBinaryFile = fmt.Errorf("binary file") 21 - ErrNotBinaryFile = fmt.Errorf("not binary file") 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") 22 27 ) 23 28 24 29 type GitRepo struct { ··· 188 193 defer reader.Close() 189 194 190 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 191 249 } 192 250 193 251 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4 -13
knotserver/git/tree.go
··· 7 7 "path" 8 8 "time" 9 9 10 + "github.com/go-git/go-git/v5/plumbing/filemode" 10 11 "github.com/go-git/go-git/v5/plumbing/object" 11 12 "tangled.org/core/types" 12 13 ) ··· 53 54 } 54 55 55 56 for _, e := range subtree.Entries { 56 - mode, _ := e.Mode.ToOSFileMode() 57 57 sz, _ := subtree.Size(e.Name) 58 - 59 58 fpath := path.Join(parent, e.Name) 60 59 61 60 var lastCommit *types.LastCommitInfo ··· 69 68 70 69 nts = append(nts, types.NiceTree{ 71 70 Name: e.Name, 72 - Mode: mode.String(), 73 - IsFile: e.Mode.IsFile(), 71 + Mode: e.Mode.String(), 74 72 Size: sz, 75 73 LastCommit: lastCommit, 76 74 }) ··· 126 124 default: 127 125 } 128 126 129 - mode, err := e.Mode.ToOSFileMode() 130 - if err != nil { 131 - // TODO: log this 132 - continue 133 - } 134 - 135 127 if e.Mode.IsFile() { 136 - err = cb(e, currentTree, root) 137 - if errors.Is(err, TerminateWalk) { 128 + if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) { 138 129 return err 139 130 } 140 131 } 141 132 142 133 // e is a directory 143 - if mode.IsDir() { 134 + if e.Mode == filemode.Dir { 144 135 subtree, err := currentTree.Tree(e.Name) 145 136 if err != nil { 146 137 return fmt.Errorf("sub tree %s: %w", e.Name, err)
+4 -8
knotserver/ingester.go
··· 16 16 "github.com/bluesky-social/jetstream/pkg/models" 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 18 "tangled.org/core/api/tangled" 19 - "tangled.org/core/idresolver" 20 19 "tangled.org/core/knotserver/db" 21 20 "tangled.org/core/knotserver/git" 22 21 "tangled.org/core/log" ··· 120 119 } 121 120 122 121 // resolve this aturi to extract the repo record 123 - resolver := idresolver.DefaultResolver() 124 - ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 125 123 if err != nil || ident.Handle.IsInvalidHandle() { 126 124 return fmt.Errorf("failed to resolve handle: %w", err) 127 125 } ··· 163 161 164 162 var pipeline workflow.RawPipeline 165 163 for _, e := range workflowDir { 166 - if !e.IsFile { 164 + if !e.IsFile() { 167 165 continue 168 166 } 169 167 ··· 233 231 return err 234 232 } 235 233 236 - resolver := idresolver.DefaultResolver() 237 - 238 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 234 + subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject) 239 235 if err != nil || subjectId.Handle.IsInvalidHandle() { 240 236 return err 241 237 } 242 238 243 239 // TODO: fix this for good, we need to fetch the record here unfortunately 244 240 // resolve this aturi to extract the repo record 245 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 241 + owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 242 if err != nil || owner.Handle.IsInvalidHandle() { 247 243 return fmt.Errorf("failed to resolve handle: %w", err) 248 244 }
+146 -49
knotserver/internal.go
··· 27 27 ) 28 28 29 29 type InternalHandle struct { 30 - db *db.DB 31 - c *config.Config 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 30 + db *db.DB 31 + c *config.Config 32 + e *rbac.Enforcer 33 + l *slog.Logger 34 + n *notifier.Notifier 35 + res *idresolver.Resolver 35 36 } 36 37 37 38 func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { ··· 67 68 writeJSON(w, data) 68 69 } 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 + 70 131 type PushOptions struct { 71 132 skipCi bool 72 133 verboseCi bool ··· 121 182 // non-fatal 122 183 } 123 184 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 - } 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 134 189 } 135 190 136 191 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) ··· 143 198 writeJSON(w, resp) 144 199 } 145 200 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 201 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 179 202 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 180 203 if err != nil { ··· 220 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 221 244 } 222 245 223 - func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 246 + func (h *InternalHandle) triggerPipeline( 247 + clientMsgs *[]string, 248 + line git.PostReceiveLine, 249 + gitUserDid string, 250 + repoDid string, 251 + repoName string, 252 + pushOptions PushOptions, 253 + ) error { 224 254 if pushOptions.skipCi { 225 255 return nil 226 256 } ··· 247 277 248 278 var pipeline workflow.RawPipeline 249 279 for _, e := range workflowDir { 250 - if !e.IsFile { 280 + if !e.IsFile() { 251 281 continue 252 282 } 253 283 ··· 315 345 return h.db.InsertEvent(event, h.n) 316 346 } 317 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 + 318 412 func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 319 413 r := chi.NewRouter() 320 414 l := log.FromContext(ctx) 321 415 l = log.SubLogger(l, "internal") 416 + res := idresolver.DefaultResolver(c.Server.PlcUrl) 322 417 323 418 h := InternalHandle{ 324 419 db, ··· 326 421 e, 327 422 l, 328 423 n, 424 + res, 329 425 } 330 426 331 427 r.Get("/push-allowed", h.PushAllowed) 332 428 r.Get("/keys", h.InternalKeys) 429 + r.Get("/guard", h.Guard) 333 430 r.Post("/hooks/post-receive", h.PostReceiveHook) 334 431 r.Mount("/debug", middleware.Profiler()) 335 432
+18
knotserver/middleware.go
··· 33 33 ) 34 34 }) 35 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 36 l: log.FromContext(ctx), 37 37 jc: jc, 38 38 n: n, 39 - resolver: idresolver.DefaultResolver(), 39 + resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 40 40 } 41 41 42 42 err := e.AddKnot(rbac.ThisServer) ··· 71 71 func (h *Knot) Router() http.Handler { 72 72 r := chi.NewRouter() 73 73 74 + r.Use(h.CORS) 74 75 r.Use(h.RequestLogger) 75 76 76 77 r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+2
knotserver/xrpc/merge_check.go
··· 81 81 } 82 82 } 83 83 84 + l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 85 + 84 86 w.Header().Set("Content-Type", "application/json") 85 87 w.WriteHeader(http.StatusOK) 86 88 json.NewEncoder(w).Encode(response)
+21 -2
knotserver/xrpc/repo_blob.go
··· 42 42 return 43 43 } 44 44 45 + // first check if this path is a submodule 46 + submodule, err := gr.Submodule(treePath) 47 + if err != nil { 48 + // this is okay, continue and try to treat it as a regular file 49 + } else { 50 + response := tangled.RepoBlob_Output{ 51 + Ref: ref, 52 + Path: treePath, 53 + Submodule: &tangled.RepoBlob_Submodule{ 54 + Name: submodule.Name, 55 + Url: submodule.URL, 56 + Branch: &submodule.Branch, 57 + }, 58 + } 59 + writeJson(w, response) 60 + return 61 + } 62 + 45 63 contents, err := gr.RawContent(treePath) 46 64 if err != nil { 47 65 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) ··· 101 119 var encoding string 102 120 103 121 isBinary := !isTextual(mimeType) 122 + size := int64(len(contents)) 104 123 105 124 if isBinary { 106 125 content = base64.StdEncoding.EncodeToString(contents) ··· 113 132 response := tangled.RepoBlob_Output{ 114 133 Ref: ref, 115 134 Path: treePath, 116 - Content: content, 135 + Content: &content, 117 136 Encoding: &encoding, 118 - Size: &[]int64{int64(len(contents))}[0], 137 + Size: &size, 119 138 IsBinary: &isBinary, 120 139 } 121 140
+20 -4
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 7 8 "tangled.org/core/knotserver/git" 8 9 "tangled.org/core/types" 9 10 xrpcerr "tangled.org/core/xrpc/errors" ··· 71 72 return 72 73 } 73 74 75 + var combinedPatch []*gitdiff.File 76 + var combinedPatchRaw string 77 + // we need the combined patch 78 + if len(formatPatch) >= 2 { 79 + diffTree, err := gr.DiffTree(commit1, commit2) 80 + if err != nil { 81 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 82 + } else { 83 + combinedPatch = diffTree.Diff 84 + combinedPatchRaw = diffTree.Patch 85 + } 86 + } 87 + 74 88 response := types.RepoFormatPatchResponse{ 75 - Rev1: commit1.Hash.String(), 76 - Rev2: commit2.Hash.String(), 77 - FormatPatch: formatPatch, 78 - Patch: rawPatch, 89 + Rev1: commit1.Hash.String(), 90 + Rev2: commit2.Hash.String(), 91 + FormatPatch: formatPatch, 92 + FormatPatchRaw: rawPatch, 93 + CombinedPatch: combinedPatch, 94 + CombinedPatchRaw: combinedPatchRaw, 79 95 } 80 96 81 97 writeJson(w, response)
+3 -5
knotserver/xrpc/repo_tree.go
··· 67 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 68 for i, file := range files { 69 69 entry := &tangled.RepoTree_TreeEntry{ 70 - Name: file.Name, 71 - Mode: file.Mode, 72 - Size: file.Size, 73 - Is_file: file.IsFile, 74 - Is_subtree: file.IsSubtree, 70 + Name: file.Name, 71 + Mode: file.Mode, 72 + Size: file.Size, 75 73 } 76 74 77 75 if file.LastCommit != nil {
+5
lexicons/actor/profile.json
··· 64 64 "type": "string", 65 65 "format": "at-uri" 66 66 } 67 + }, 68 + "pronouns": { 69 + "type": "string", 70 + "description": "Preferred gender pronouns.", 71 + "maxLength": 40 67 72 } 68 73 } 69 74 }
+49 -5
lexicons/repo/blob.json
··· 6 6 "type": "query", 7 7 "parameters": { 8 8 "type": "params", 9 - "required": ["repo", "ref", "path"], 9 + "required": [ 10 + "repo", 11 + "ref", 12 + "path" 13 + ], 10 14 "properties": { 11 15 "repo": { 12 16 "type": "string", ··· 31 35 "encoding": "application/json", 32 36 "schema": { 33 37 "type": "object", 34 - "required": ["ref", "path", "content"], 38 + "required": [ 39 + "ref", 40 + "path" 41 + ], 35 42 "properties": { 36 43 "ref": { 37 44 "type": "string", ··· 48 55 "encoding": { 49 56 "type": "string", 50 57 "description": "Content encoding", 51 - "enum": ["utf-8", "base64"] 58 + "enum": [ 59 + "utf-8", 60 + "base64" 61 + ] 52 62 }, 53 63 "size": { 54 64 "type": "integer", ··· 61 71 "mimeType": { 62 72 "type": "string", 63 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" 64 79 }, 65 80 "lastCommit": { 66 81 "type": "ref", ··· 90 105 }, 91 106 "lastCommit": { 92 107 "type": "object", 93 - "required": ["hash", "message", "when"], 108 + "required": [ 109 + "hash", 110 + "message", 111 + "when" 112 + ], 94 113 "properties": { 95 114 "hash": { 96 115 "type": "string", ··· 117 136 }, 118 137 "signature": { 119 138 "type": "object", 120 - "required": ["name", "email", "when"], 139 + "required": [ 140 + "name", 141 + "email", 142 + "when" 143 + ], 121 144 "properties": { 122 145 "name": { 123 146 "type": "string", ··· 131 154 "type": "string", 132 155 "format": "datetime", 133 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" 134 178 } 135 179 } 136 180 }
+15
lexicons/repo/repo.json
··· 32 32 "minGraphemes": 1, 33 33 "maxGraphemes": 140 34 34 }, 35 + "website": { 36 + "type": "string", 37 + "format": "uri", 38 + "description": "Any URI related to the repo" 39 + }, 40 + "topics": { 41 + "type": "array", 42 + "description": "Topics related to the repo", 43 + "items": { 44 + "type": "string", 45 + "minLength": 1, 46 + "maxLength": 50 47 + }, 48 + "maxLength": 50 49 + }, 35 50 "source": { 36 51 "type": "string", 37 52 "format": "uri",
+1 -9
lexicons/repo/tree.json
··· 91 91 }, 92 92 "treeEntry": { 93 93 "type": "object", 94 - "required": ["name", "mode", "size", "is_file", "is_subtree"], 94 + "required": ["name", "mode", "size"], 95 95 "properties": { 96 96 "name": { 97 97 "type": "string", ··· 104 104 "size": { 105 105 "type": "integer", 106 106 "description": "File size in bytes" 107 - }, 108 - "is_file": { 109 - "type": "boolean", 110 - "description": "Whether this entry is a file" 111 - }, 112 - "is_subtree": { 113 - "type": "boolean", 114 - "description": "Whether this entry is a directory/subtree" 115 107 }, 116 108 "last_commit": { 117 109 "type": "ref",
+87 -6
nix/gomod2nix.toml
··· 13 13 [mod."github.com/ProtonMail/go-crypto"] 14 14 version = "v1.3.0" 15 15 hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/RoaringBitmap/roaring/v2"] 17 + version = "v2.4.5" 18 + hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA=" 16 19 [mod."github.com/alecthomas/assert/v2"] 17 20 version = "v2.11.0" 18 21 hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" ··· 38 41 [mod."github.com/beorn7/perks"] 39 42 version = "v1.0.1" 40 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=" 41 101 [mod."github.com/bluekeyes/go-gitdiff"] 42 102 version = "v0.8.2" 43 103 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" ··· 49 109 version = "v0.0.0-20241210005130-ea96859b93d1" 50 110 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 51 111 [mod."github.com/bmatcuk/doublestar/v4"] 52 - version = "v4.7.1" 53 - hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 112 + version = "v4.9.1" 113 + hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" 54 114 [mod."github.com/carlmjohnson/versioninfo"] 55 115 version = "v0.22.5" 56 116 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" ··· 199 259 [mod."github.com/golang/mock"] 200 260 version = "v1.6.0" 201 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=" 202 268 [mod."github.com/google/go-querystring"] 203 269 version = "v1.1.0" 204 270 hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" ··· 295 361 [mod."github.com/ipfs/go-metrics-interface"] 296 362 version = "v0.3.0" 297 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=" 298 367 [mod."github.com/kevinburke/ssh_config"] 299 368 version = "v1.2.0" 300 369 hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" ··· 352 421 [mod."github.com/moby/term"] 353 422 version = "v0.5.2" 354 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=" 355 430 [mod."github.com/morikuni/aec"] 356 431 version = "v1.0.0" 357 432 hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 358 433 [mod."github.com/mr-tron/base58"] 359 434 version = "v1.2.0" 360 435 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 436 + [mod."github.com/mschoch/smat"] 437 + version = "v0.2.0" 438 + hash = "sha256-DZvUJXjIcta3U+zxzgU3wpoGn/V4lpBY7Xme8aQUi+E=" 361 439 [mod."github.com/muesli/termenv"] 362 440 version = "v0.16.0" 363 441 hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" ··· 471 549 version = "v0.3.1" 472 550 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 473 551 [mod."github.com/wyatt915/goldmark-treeblood"] 474 - version = "v0.0.0-20250825231212-5dcbdb2f4b57" 475 - hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 552 + version = "v0.0.1" 553 + hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g=" 476 554 [mod."github.com/wyatt915/treeblood"] 477 - version = "v0.1.15" 478 - hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 555 + version = "v0.1.16" 556 + hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw=" 479 557 [mod."github.com/xo/terminfo"] 480 558 version = "v0.0.0-20220910002029-abceb7e1c41e" 481 559 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" ··· 494 572 [mod."gitlab.com/yawning/tuplehash"] 495 573 version = "v0.0.0-20230713102510-df83abbf9a02" 496 574 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 575 + [mod."go.etcd.io/bbolt"] 576 + version = "v1.4.0" 577 + hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps=" 497 578 [mod."go.opentelemetry.io/auto/sdk"] 498 579 version = "v1.1.0" 499 580 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
+285 -18
nix/modules/appview.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled-appview; 6 + cfg = config.services.tangled.appview; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled-appview = { 10 + services.tangled.appview = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; 14 14 description = "Enable tangled appview"; 15 15 }; 16 + 16 17 package = mkOption { 17 18 type = types.package; 18 19 description = "Package to use for the appview"; 19 20 }; 21 + 22 + # core configuration 20 23 port = mkOption { 21 - type = types.int; 24 + type = types.port; 22 25 default = 3000; 23 26 description = "Port to run the appview on"; 24 27 }; 25 - cookie_secret = mkOption { 28 + 29 + listenAddr = mkOption { 30 + type = types.str; 31 + default = "0.0.0.0:${toString cfg.port}"; 32 + description = "Listen address for the appview service"; 33 + }; 34 + 35 + dbPath = mkOption { 26 36 type = types.str; 27 - default = "00000000000000000000000000000000"; 28 - description = "Cookie secret"; 37 + default = "/var/lib/appview/appview.db"; 38 + description = "Path to the SQLite database file"; 39 + }; 40 + 41 + appviewHost = mkOption { 42 + type = types.str; 43 + default = "https://tangled.org"; 44 + example = "https://example.com"; 45 + description = "Public host URL for the appview instance"; 46 + }; 47 + 48 + appviewName = mkOption { 49 + type = types.str; 50 + default = "Tangled"; 51 + description = "Display name for the appview instance"; 52 + }; 53 + 54 + dev = mkOption { 55 + type = types.bool; 56 + default = false; 57 + description = "Enable development mode"; 58 + }; 59 + 60 + disallowedNicknamesFile = mkOption { 61 + type = types.nullOr types.path; 62 + default = null; 63 + description = "Path to file containing disallowed nicknames"; 64 + }; 65 + 66 + # redis configuration 67 + redis = { 68 + addr = mkOption { 69 + type = types.str; 70 + default = "localhost:6379"; 71 + description = "Redis server address"; 72 + }; 73 + 74 + db = mkOption { 75 + type = types.int; 76 + default = 0; 77 + description = "Redis database number"; 78 + }; 79 + }; 80 + 81 + # jetstream configuration 82 + jetstream = { 83 + endpoint = mkOption { 84 + type = types.str; 85 + default = "wss://jetstream1.us-east.bsky.network/subscribe"; 86 + description = "Jetstream WebSocket endpoint"; 87 + }; 88 + }; 89 + 90 + # knotstream consumer configuration 91 + knotstream = { 92 + retryInterval = mkOption { 93 + type = types.str; 94 + default = "60s"; 95 + description = "Initial retry interval for knotstream consumer"; 96 + }; 97 + 98 + maxRetryInterval = mkOption { 99 + type = types.str; 100 + default = "120m"; 101 + description = "Maximum retry interval for knotstream consumer"; 102 + }; 103 + 104 + connectionTimeout = mkOption { 105 + type = types.str; 106 + default = "5s"; 107 + description = "Connection timeout for knotstream consumer"; 108 + }; 109 + 110 + workerCount = mkOption { 111 + type = types.int; 112 + default = 64; 113 + description = "Number of workers for knotstream consumer"; 114 + }; 115 + 116 + queueSize = mkOption { 117 + type = types.int; 118 + default = 100; 119 + description = "Queue size for knotstream consumer"; 120 + }; 121 + }; 122 + 123 + # spindlestream consumer configuration 124 + spindlestream = { 125 + retryInterval = mkOption { 126 + type = types.str; 127 + default = "60s"; 128 + description = "Initial retry interval for spindlestream consumer"; 129 + }; 130 + 131 + maxRetryInterval = mkOption { 132 + type = types.str; 133 + default = "120m"; 134 + description = "Maximum retry interval for spindlestream consumer"; 135 + }; 136 + 137 + connectionTimeout = mkOption { 138 + type = types.str; 139 + default = "5s"; 140 + description = "Connection timeout for spindlestream consumer"; 141 + }; 142 + 143 + workerCount = mkOption { 144 + type = types.int; 145 + default = 64; 146 + description = "Number of workers for spindlestream consumer"; 147 + }; 148 + 149 + queueSize = mkOption { 150 + type = types.int; 151 + default = 100; 152 + description = "Queue size for spindlestream consumer"; 153 + }; 154 + }; 155 + 156 + # resend configuration 157 + resend = { 158 + sentFrom = mkOption { 159 + type = types.str; 160 + default = "noreply@notifs.tangled.sh"; 161 + description = "Email address to send notifications from"; 162 + }; 163 + }; 164 + 165 + # posthog configuration 166 + posthog = { 167 + endpoint = mkOption { 168 + type = types.str; 169 + default = "https://eu.i.posthog.com"; 170 + description = "PostHog API endpoint"; 171 + }; 172 + }; 173 + 174 + # camo configuration 175 + camo = { 176 + host = mkOption { 177 + type = types.str; 178 + default = "https://camo.tangled.sh"; 179 + description = "Camo proxy host URL"; 180 + }; 29 181 }; 182 + 183 + # avatar configuration 184 + avatar = { 185 + host = mkOption { 186 + type = types.str; 187 + default = "https://avatar.tangled.sh"; 188 + description = "Avatar service host URL"; 189 + }; 190 + }; 191 + 192 + plc = { 193 + url = mkOption { 194 + type = types.str; 195 + default = "https://plc.directory"; 196 + description = "PLC directory URL"; 197 + }; 198 + }; 199 + 200 + pds = { 201 + host = mkOption { 202 + type = types.str; 203 + default = "https://tngl.sh"; 204 + description = "PDS host URL"; 205 + }; 206 + }; 207 + 208 + label = { 209 + defaults = mkOption { 210 + type = types.listOf types.str; 211 + default = [ 212 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix" 213 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" 214 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate" 215 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation" 216 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee" 217 + ]; 218 + description = "Default label definitions"; 219 + }; 220 + 221 + goodFirstIssue = mkOption { 222 + type = types.str; 223 + default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"; 224 + description = "Good first issue label definition"; 225 + }; 226 + }; 227 + 30 228 environmentFile = mkOption { 31 229 type = with types; nullOr path; 32 230 default = null; 33 - example = "/etc/tangled-appview.env"; 231 + example = "/etc/appview.env"; 34 232 description = '' 35 233 Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 234 37 - Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 - passed to the service without makeing them world readable in the 39 - nix store. 40 - 235 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`, 236 + {env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`, 237 + {env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`, 238 + {env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`, 239 + {env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`, 240 + {env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`, 241 + {env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`, 242 + {env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`, 243 + and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service 244 + without making them world readable in the nix store. 41 245 ''; 42 246 }; 43 247 }; 44 248 }; 45 249 46 250 config = mkIf cfg.enable { 47 - systemd.services.tangled-appview = { 251 + services.redis.servers.appview = { 252 + enable = true; 253 + port = 6379; 254 + }; 255 + 256 + systemd.services.appview = { 48 257 description = "tangled appview service"; 49 258 wantedBy = ["multi-user.target"]; 259 + after = ["redis-appview.service" "network-online.target"]; 260 + requires = ["redis-appview.service"]; 261 + wants = ["network-online.target"]; 50 262 51 263 serviceConfig = { 52 - ListenStream = "0.0.0.0:${toString cfg.port}"; 264 + Type = "simple"; 53 265 ExecStart = "${cfg.package}/bin/appview"; 54 266 Restart = "always"; 55 - EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 267 + RestartSec = "10s"; 268 + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; 269 + 270 + # state directory 271 + StateDirectory = "appview"; 272 + WorkingDirectory = "/var/lib/appview"; 273 + 274 + # security hardening 275 + NoNewPrivileges = true; 276 + PrivateTmp = true; 277 + ProtectSystem = "strict"; 278 + ProtectHome = true; 279 + ReadWritePaths = ["/var/lib/appview"]; 56 280 }; 57 281 58 - environment = { 59 - TANGLED_DB_PATH = "appview.db"; 60 - TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 - }; 282 + environment = 283 + { 284 + TANGLED_DB_PATH = cfg.dbPath; 285 + TANGLED_LISTEN_ADDR = cfg.listenAddr; 286 + TANGLED_APPVIEW_HOST = cfg.appviewHost; 287 + TANGLED_APPVIEW_NAME = cfg.appviewName; 288 + TANGLED_DEV = 289 + if cfg.dev 290 + then "true" 291 + else "false"; 292 + } 293 + // optionalAttrs (cfg.disallowedNicknamesFile != null) { 294 + TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile; 295 + } 296 + // { 297 + TANGLED_REDIS_ADDR = cfg.redis.addr; 298 + TANGLED_REDIS_DB = toString cfg.redis.db; 299 + 300 + TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint; 301 + 302 + TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval; 303 + TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval; 304 + TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout; 305 + TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount; 306 + TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize; 307 + 308 + TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval; 309 + TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval; 310 + TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout; 311 + TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount; 312 + TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize; 313 + 314 + TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom; 315 + 316 + TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint; 317 + 318 + TANGLED_CAMO_HOST = cfg.camo.host; 319 + 320 + TANGLED_AVATAR_HOST = cfg.avatar.host; 321 + 322 + TANGLED_PLC_URL = cfg.plc.url; 323 + 324 + TANGLED_PDS_HOST = cfg.pds.host; 325 + 326 + TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults; 327 + TANGLED_LABEL_GFI = cfg.label.goodFirstIssue; 328 + }; 62 329 }; 63 330 }; 64 331 }
+76 -6
nix/modules/knot.nix
··· 4 4 lib, 5 5 ... 6 6 }: let 7 - cfg = config.services.tangled-knot; 7 + cfg = config.services.tangled.knot; 8 8 in 9 9 with lib; { 10 10 options = { 11 - services.tangled-knot = { 11 + services.tangled.knot = { 12 12 enable = mkOption { 13 13 type = types.bool; 14 14 default = false; ··· 22 22 23 23 appviewEndpoint = mkOption { 24 24 type = types.str; 25 - default = "https://tangled.sh"; 25 + default = "https://tangled.org"; 26 26 description = "Appview endpoint"; 27 27 }; 28 28 ··· 51 51 description = "Path where repositories are scanned from"; 52 52 }; 53 53 54 + readme = mkOption { 55 + type = types.listOf types.str; 56 + default = [ 57 + "README.md" 58 + "readme.md" 59 + "README" 60 + "readme" 61 + "README.markdown" 62 + "readme.markdown" 63 + "README.txt" 64 + "readme.txt" 65 + "README.rst" 66 + "readme.rst" 67 + "README.org" 68 + "readme.org" 69 + "README.asciidoc" 70 + "readme.asciidoc" 71 + ]; 72 + description = "List of README filenames to look for (in priority order)"; 73 + }; 74 + 54 75 mainBranch = mkOption { 55 76 type = types.str; 56 77 default = "main"; 57 78 description = "Default branch name for repositories"; 79 + }; 80 + }; 81 + 82 + git = { 83 + userName = mkOption { 84 + type = types.str; 85 + default = "Tangled"; 86 + description = "Git user name used as committer"; 87 + }; 88 + 89 + userEmail = mkOption { 90 + type = types.str; 91 + default = "noreply@tangled.org"; 92 + description = "Git user email used as committer"; 58 93 }; 59 94 }; 60 95 ··· 107 142 108 143 hostname = mkOption { 109 144 type = types.str; 110 - example = "knot.tangled.sh"; 145 + example = "my.knot.com"; 111 146 description = "Hostname for the server (required)"; 147 + }; 148 + 149 + plcUrl = mkOption { 150 + type = types.str; 151 + default = "https://plc.directory"; 152 + description = "atproto PLC directory"; 153 + }; 154 + 155 + jetstreamEndpoint = mkOption { 156 + type = types.str; 157 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 158 + description = "Jetstream endpoint to subscribe to"; 159 + }; 160 + 161 + logDids = mkOption { 162 + type = types.bool; 163 + default = true; 164 + description = "Enable logging of DIDs"; 112 165 }; 113 166 114 167 dev = mkOption { ··· 178 231 mkdir -p "${cfg.stateDir}/.config/git" 179 232 cat > "${cfg.stateDir}/.config/git/config" << EOF 180 233 [user] 181 - name = Git User 182 - email = git@example.com 234 + name = ${cfg.git.userName} 235 + email = ${cfg.git.userEmail} 183 236 [receive] 184 237 advertisePushOptions = true 238 + [uploadpack] 239 + allowFilter = true 185 240 EOF 186 241 ${setMotd} 187 242 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" ··· 193 248 WorkingDirectory = cfg.stateDir; 194 249 Environment = [ 195 250 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 251 + "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}" 196 252 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 253 + "KNOT_GIT_USER_NAME=${cfg.git.userName}" 254 + "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}" 197 255 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 198 256 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 199 257 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 258 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 259 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 260 + "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 261 + "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 202 262 "KNOT_SERVER_OWNER=${cfg.server.owner}" 263 + "KNOT_SERVER_LOG_DIDS=${ 264 + if cfg.server.logDids 265 + then "true" 266 + else "false" 267 + }" 268 + "KNOT_SERVER_DEV=${ 269 + if cfg.server.dev 270 + then "true" 271 + else "false" 272 + }" 203 273 ]; 204 274 ExecStart = "${cfg.package}/bin/knot server"; 205 275 Restart = "always";
+12 -5
nix/modules/spindle.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled-spindle; 6 + cfg = config.services.tangled.spindle; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled-spindle = { 10 + services.tangled.spindle = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; ··· 33 33 34 34 hostname = mkOption { 35 35 type = types.str; 36 - example = "spindle.tangled.sh"; 36 + example = "my.spindle.com"; 37 37 description = "Hostname for the server (required)"; 38 38 }; 39 39 40 + plcUrl = mkOption { 41 + type = types.str; 42 + default = "https://plc.directory"; 43 + description = "atproto PLC directory"; 44 + }; 45 + 40 46 jetstreamEndpoint = mkOption { 41 47 type = types.str; 42 48 default = "wss://jetstream1.us-west.bsky.network/subscribe"; ··· 92 98 pipelines = { 93 99 nixery = mkOption { 94 100 type = types.str; 95 - default = "nixery.tangled.sh"; 101 + default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 96 102 description = "Nixery instance to use"; 97 103 }; 98 104 ··· 119 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 120 126 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 121 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 122 - "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 128 + "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 123 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 124 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 132 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+2
nix/pkgs/appview-static-files.nix
··· 5 5 lucide-src, 6 6 inter-fonts-src, 7 7 ibm-plex-mono-src, 8 + actor-typeahead-src, 8 9 sqlite-lib, 9 10 tailwindcss, 10 11 src, ··· 24 25 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 26 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 27 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 + cp -f ${actor-typeahead-src}/actor-typeahead.js . 27 29 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 30 # for whatever reason (produces broken css), so we are doing this instead 29 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 10 if var == "" 11 11 then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 12 else var; 13 + envVarOr = name: default: let 14 + var = builtins.getEnv name; 15 + in 16 + if var != "" 17 + then var 18 + else default; 19 + 20 + plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 + jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 13 22 in 14 23 nixpkgs.lib.nixosSystem { 15 24 inherit system; ··· 73 82 time.timeZone = "Europe/London"; 74 83 services.getty.autologinUser = "root"; 75 84 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 - services.tangled-knot = { 85 + services.tangled.knot = { 77 86 enable = true; 78 87 motd = "Welcome to the development knot!\n"; 79 88 server = { 80 89 owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 - hostname = "localhost:6000"; 90 + hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000"; 91 + plcUrl = plcUrl; 92 + jetstreamEndpoint = jetstream; 82 93 listenAddr = "0.0.0.0:6000"; 83 94 }; 84 95 }; 85 - services.tangled-spindle = { 96 + services.tangled.spindle = { 86 97 enable = true; 87 98 server = { 88 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 - hostname = "localhost:6555"; 100 + hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 + plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 90 103 listenAddr = "0.0.0.0:6555"; 91 104 dev = true; 92 105 queueSize = 100; ··· 99 112 users = { 100 113 # So we don't have to deal with permission clashing between 101 114 # blank disk VMs and existing state 102 - users.${config.services.tangled-knot.gitUser}.uid = 666; 103 - groups.${config.services.tangled-knot.gitUser}.gid = 666; 115 + users.${config.services.tangled.knot.gitUser}.uid = 666; 116 + groups.${config.services.tangled.knot.gitUser}.gid = 666; 104 117 105 118 # TODO: separate spindle user 106 119 }; ··· 120 133 serviceConfig.PermissionsStartOnly = true; 121 134 }; 122 135 in { 123 - knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 124 - spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 136 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 137 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 125 138 }; 126 139 }) 127 140 ];
+18 -7
patchutil/patchutil.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log" 6 7 "os" ··· 42 43 // IsPatchValid checks if the given patch string is valid. 43 44 // It performs very basic sniffing for either git-diff or git-format-patch 44 45 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 46 + var ( 47 + EmptyPatchError error = errors.New("patch is empty") 48 + GenericPatchError error = errors.New("patch is invalid") 49 + FormatPatchError error = errors.New("patch is not a valid format-patch") 50 + ) 51 + 52 + func IsPatchValid(patch string) error { 46 53 if len(patch) == 0 { 47 - return false 54 + return EmptyPatchError 48 55 } 49 56 50 57 lines := strings.Split(patch, "\n") 51 58 if len(lines) < 2 { 52 - return false 59 + return EmptyPatchError 53 60 } 54 61 55 62 firstLine := strings.TrimSpace(lines[0]) ··· 60 67 strings.HasPrefix(firstLine, "Index: ") || 61 68 strings.HasPrefix(firstLine, "+++ ") || 62 69 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 70 + return nil 64 71 } 65 72 66 73 // check if it's format-patch ··· 70 77 // it's safe to say it's broken. 71 78 patches, err := ExtractPatches(patch) 72 79 if err != nil { 73 - return false 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 74 81 } 75 - return len(patches) > 0 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 76 87 } 77 88 78 - return false 89 + return GenericPatchError 79 90 } 80 91 81 92 func IsFormatPatch(patch string) bool {
+13 -12
patchutil/patchutil_test.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "reflect" 5 6 "testing" 6 7 ) ··· 9 10 tests := []struct { 10 11 name string 11 12 patch string 12 - expected bool 13 + expected error 13 14 }{ 14 15 { 15 16 name: `empty patch`, 16 17 patch: ``, 17 - expected: false, 18 + expected: EmptyPatchError, 18 19 }, 19 20 { 20 21 name: `single line patch`, 21 22 patch: `single line`, 22 - expected: false, 23 + expected: EmptyPatchError, 23 24 }, 24 25 { 25 26 name: `valid diff patch`, ··· 31 32 -old line 32 33 +new line 33 34 context`, 34 - expected: true, 35 + expected: nil, 35 36 }, 36 37 { 37 38 name: `valid patch starting with ---`, ··· 41 42 -old line 42 43 +new line 43 44 context`, 44 - expected: true, 45 + expected: nil, 45 46 }, 46 47 { 47 48 name: `valid patch starting with Index`, ··· 53 54 -old line 54 55 +new line 55 56 context`, 56 - expected: true, 57 + expected: nil, 57 58 }, 58 59 { 59 60 name: `valid patch starting with +++`, ··· 63 64 -old line 64 65 +new line 65 66 context`, 66 - expected: true, 67 + expected: nil, 67 68 }, 68 69 { 69 70 name: `valid patch starting with @@`, ··· 72 73 +new line 73 74 context 74 75 `, 75 - expected: true, 76 + expected: nil, 76 77 }, 77 78 { 78 79 name: `valid format patch`, ··· 90 91 +new content 91 92 -- 92 93 2.48.1`, 93 - expected: true, 94 + expected: nil, 94 95 }, 95 96 { 96 97 name: `invalid format patch`, 97 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 99 From: Author <author@example.com> 99 100 This is not a valid patch format`, 100 - expected: false, 101 + expected: FormatPatchError, 101 102 }, 102 103 { 103 104 name: `not a patch at all`, ··· 105 106 just some 106 107 random text 107 108 that isn't a patch`, 108 - expected: false, 109 + expected: GenericPatchError, 109 110 }, 110 111 } 111 112 112 113 for _, tt := range tests { 113 114 t.Run(tt.name, func(t *testing.T) { 114 115 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 + if !errors.Is(result, tt.expected) { 116 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 118 } 118 119 })
-26
scripts/appview.sh
··· 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 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 14 Hostname string `env:"HOSTNAME, required"` 15 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 16 17 Dev bool `env:"DEV, default=false"` 17 18 Owner string `env:"OWNER, required"` 18 19 Secrets Secrets `env:",prefix=SECRETS_"`
+13 -3
spindle/engine/engine.go
··· 79 79 defer cancel() 80 80 81 81 for stepIdx, step := range w.Steps { 82 + // log start of step 82 83 if wfLogger != nil { 83 - ctl := wfLogger.ControlWriter(stepIdx, step) 84 - ctl.Write([]byte(step.Name())) 84 + wfLogger. 85 + ControlWriter(stepIdx, step, models.StepStatusStart). 86 + Write([]byte{0}) 85 87 } 86 88 87 89 err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 90 + 91 + // log end of step 92 + if wfLogger != nil { 93 + wfLogger. 94 + ControlWriter(stepIdx, step, models.StepStatusEnd). 95 + Write([]byte{0}) 96 + } 97 + 88 98 if err != nil { 89 99 if errors.Is(err, ErrTimedOut) { 90 100 dbErr := db.StatusTimeout(wid, n) ··· 115 125 if err := eg.Wait(); err != nil { 116 126 l.Error("failed to run one or more workflows", "err", err) 117 127 } else { 118 - l.Error("successfully ran full pipeline") 128 + l.Info("successfully ran full pipeline") 119 129 } 120 130 }
+3 -3
spindle/engines/nixery/engine.go
··· 222 222 }, 223 223 ReadonlyRootfs: false, 224 224 CapDrop: []string{"ALL"}, 225 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 226 226 SecurityOpt: []string{"no-new-privileges"}, 227 227 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 228 }, nil, nil, "") ··· 381 381 defer logs.Close() 382 382 383 383 _, err = stdcopy.StdCopy( 384 - wfLogger.DataWriter("stdout"), 385 - wfLogger.DataWriter("stderr"), 384 + wfLogger.DataWriter(stepIdx, "stdout"), 385 + wfLogger.DataWriter(stepIdx, "stderr"), 386 386 logs.Reader, 387 387 ) 388 388 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+3 -7
spindle/ingester.go
··· 9 9 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/eventconsumer" 12 - "tangled.org/core/idresolver" 13 12 "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/db" 15 14 ··· 142 141 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 143 142 var err error 144 143 did := e.Did 145 - resolver := idresolver.DefaultResolver() 146 144 147 145 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 146 ··· 190 188 } 191 189 192 190 // add collaborators to rbac 193 - owner, err := resolver.ResolveIdent(ctx, did) 191 + owner, err := s.res.ResolveIdent(ctx, did) 194 192 if err != nil || owner.Handle.IsInvalidHandle() { 195 193 return err 196 194 } ··· 225 223 return err 226 224 } 227 225 228 - resolver := idresolver.DefaultResolver() 229 - 230 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 231 227 if err != nil || subjectId.Handle.IsInvalidHandle() { 232 228 return err 233 229 } ··· 240 236 241 237 // TODO: get rid of this entirely 242 238 // resolve this aturi to extract the repo record 243 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 244 240 if err != nil || owner.Handle.IsInvalidHandle() { 245 241 return fmt.Errorf("failed to resolve handle: %w", err) 246 242 }
+14 -11
spindle/models/logger.go
··· 37 37 return l.file.Close() 38 38 } 39 39 40 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 - // TODO: emit stream 40 + func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 42 41 return &dataWriter{ 43 42 logger: l, 43 + idx: idx, 44 44 stream: stream, 45 45 } 46 46 } 47 47 48 - func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 49 49 return &controlWriter{ 50 - logger: l, 51 - idx: idx, 52 - step: step, 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + stepStatus: stepStatus, 53 54 } 54 55 } 55 56 56 57 type dataWriter struct { 57 58 logger *WorkflowLogger 59 + idx int 58 60 stream string 59 61 } 60 62 61 63 func (w *dataWriter) Write(p []byte) (int, error) { 62 64 line := strings.TrimRight(string(p), "\r\n") 63 - entry := NewDataLogLine(line, w.stream) 65 + entry := NewDataLogLine(w.idx, line, w.stream) 64 66 if err := w.logger.encoder.Encode(entry); err != nil { 65 67 return 0, err 66 68 } ··· 68 70 } 69 71 70 72 type controlWriter struct { 71 - logger *WorkflowLogger 72 - idx int 73 - step Step 73 + logger *WorkflowLogger 74 + idx int 75 + step Step 76 + stepStatus StepStatus 74 77 } 75 78 76 79 func (w *controlWriter) Write(_ []byte) (int, error) { 77 - entry := NewControlLogLine(w.idx, w.step) 80 + entry := NewControlLogLine(w.idx, w.step, w.stepStatus) 78 81 if err := w.logger.encoder.Encode(entry); err != nil { 79 82 return 0, err 80 83 }
+23 -8
spindle/models/models.go
··· 4 4 "fmt" 5 5 "regexp" 6 6 "slices" 7 + "time" 7 8 8 9 "tangled.org/core/api/tangled" 9 10 ··· 76 77 var ( 77 78 // step log data 78 79 LogKindData LogKind = "data" 79 - // indicates start/end of a step 80 + // indicates status of a step 80 81 LogKindControl LogKind = "control" 81 82 ) 82 83 84 + // step status indicator in control log lines 85 + type StepStatus string 86 + 87 + var ( 88 + StepStatusStart StepStatus = "start" 89 + StepStatusEnd StepStatus = "end" 90 + ) 91 + 83 92 type LogLine struct { 84 - Kind LogKind `json:"kind"` 85 - Content string `json:"content"` 93 + Kind LogKind `json:"kind"` 94 + Content string `json:"content"` 95 + Time time.Time `json:"time"` 96 + StepId int `json:"step_id"` 86 97 87 98 // fields if kind is "data" 88 99 Stream string `json:"stream,omitempty"` 89 100 90 101 // fields if kind is "control" 91 - StepId int `json:"step_id,omitempty"` 92 - StepKind StepKind `json:"step_kind,omitempty"` 93 - StepCommand string `json:"step_command,omitempty"` 102 + StepStatus StepStatus `json:"step_status,omitempty"` 103 + StepKind StepKind `json:"step_kind,omitempty"` 104 + StepCommand string `json:"step_command,omitempty"` 94 105 } 95 106 96 - func NewDataLogLine(content, stream string) LogLine { 107 + func NewDataLogLine(idx int, content, stream string) LogLine { 97 108 return LogLine{ 98 109 Kind: LogKindData, 110 + Time: time.Now(), 99 111 Content: content, 112 + StepId: idx, 100 113 Stream: stream, 101 114 } 102 115 } 103 116 104 - func NewControlLogLine(idx int, step Step) LogLine { 117 + func NewControlLogLine(idx int, step Step, status StepStatus) LogLine { 105 118 return LogLine{ 106 119 Kind: LogKindControl, 120 + Time: time.Now(), 107 121 Content: step.Name(), 108 122 StepId: idx, 123 + StepStatus: status, 109 124 StepKind: step.Kind(), 110 125 StepCommand: step.Command(), 111 126 }
+86 -41
spindle/server.go
··· 49 49 vault secrets.Manager 50 50 } 51 51 52 - func Run(ctx context.Context) error { 52 + // New creates a new Spindle server with the provided configuration and engines. 53 + func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 53 54 logger := log.FromContext(ctx) 54 - 55 - cfg, err := config.Load(ctx) 56 - if err != nil { 57 - return fmt.Errorf("failed to load config: %w", err) 58 - } 59 55 60 56 d, err := db.Make(cfg.Server.DBPath) 61 57 if err != nil { 62 - return fmt.Errorf("failed to setup db: %w", err) 58 + return nil, fmt.Errorf("failed to setup db: %w", err) 63 59 } 64 60 65 61 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 66 62 if err != nil { 67 - return fmt.Errorf("failed to setup rbac enforcer: %w", err) 63 + return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 68 64 } 69 65 e.E.EnableAutoSave(true) 70 66 ··· 74 70 switch cfg.Server.Secrets.Provider { 75 71 case "openbao": 76 72 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 - return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 73 + return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 74 } 79 75 vault, err = secrets.NewOpenBaoManager( 80 76 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 82 78 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 79 ) 84 80 if err != nil { 85 - return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 81 + return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 82 } 87 83 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 84 case "sqlite", "": 89 85 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 86 if err != nil { 91 - return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 87 + return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 88 } 93 89 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 90 default: 95 - return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 - } 97 - 98 - nixeryEng, err := nixery.New(ctx, cfg) 99 - if err != nil { 100 - return err 91 + return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 92 } 102 93 103 94 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 110 101 } 111 102 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 103 if err != nil { 113 - return fmt.Errorf("failed to setup jetstream client: %w", err) 104 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 114 105 } 115 106 jc.AddDid(cfg.Server.Owner) 116 107 117 108 // Check if the spindle knows about any Dids; 118 109 dids, err := d.GetAllDids() 119 110 if err != nil { 120 - return fmt.Errorf("failed to get all dids: %w", err) 111 + return nil, fmt.Errorf("failed to get all dids: %w", err) 121 112 } 122 113 for _, d := range dids { 123 114 jc.AddDid(d) 124 115 } 125 116 126 - resolver := idresolver.DefaultResolver() 117 + resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 127 118 128 - spindle := Spindle{ 119 + spindle := &Spindle{ 129 120 jc: jc, 130 121 e: e, 131 122 db: d, 132 123 l: logger, 133 124 n: &n, 134 - engs: map[string]models.Engine{"nixery": nixeryEng}, 125 + engs: engines, 135 126 jq: jq, 136 127 cfg: cfg, 137 128 res: resolver, ··· 140 131 141 132 err = e.AddSpindle(rbacDomain) 142 133 if err != nil { 143 - return fmt.Errorf("failed to set rbac domain: %w", err) 134 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 144 135 } 145 136 err = spindle.configureOwner() 146 137 if err != nil { 147 - return err 138 + return nil, err 148 139 } 149 140 logger.Info("owner set", "did", cfg.Server.Owner) 150 - 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 141 160 142 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 161 143 if err != nil { 162 - return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 144 + return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 163 145 } 164 146 165 147 err = jc.StartJetstream(ctx, spindle.ingest()) 166 148 if err != nil { 167 - return fmt.Errorf("failed to start jetstream consumer: %w", err) 149 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 168 150 } 169 151 170 152 // for each incoming sh.tangled.pipeline, we execute ··· 177 159 ccfg.CursorStore = cursorStore 178 160 knownKnots, err := d.Knots() 179 161 if err != nil { 180 - return err 162 + return nil, err 181 163 } 182 164 for _, knot := range knownKnots { 183 165 logger.Info("adding source start", "knot", knot) ··· 185 167 } 186 168 spindle.ks = eventconsumer.NewConsumer(*ccfg) 187 169 170 + return spindle, nil 171 + } 172 + 173 + // DB returns the database instance. 174 + func (s *Spindle) DB() *db.DB { 175 + return s.db 176 + } 177 + 178 + // Queue returns the job queue instance. 179 + func (s *Spindle) Queue() *queue.Queue { 180 + return s.jq 181 + } 182 + 183 + // Engines returns the map of available engines. 184 + func (s *Spindle) Engines() map[string]models.Engine { 185 + return s.engs 186 + } 187 + 188 + // Vault returns the secrets manager instance. 189 + func (s *Spindle) Vault() secrets.Manager { 190 + return s.vault 191 + } 192 + 193 + // Notifier returns the notifier instance. 194 + func (s *Spindle) Notifier() *notifier.Notifier { 195 + return s.n 196 + } 197 + 198 + // Enforcer returns the RBAC enforcer instance. 199 + func (s *Spindle) Enforcer() *rbac.Enforcer { 200 + return s.e 201 + } 202 + 203 + // Start starts the Spindle server (blocking). 204 + func (s *Spindle) Start(ctx context.Context) error { 205 + // starts a job queue runner in the background 206 + s.jq.Start() 207 + defer s.jq.Stop() 208 + 209 + // Stop vault token renewal if it implements Stopper 210 + if stopper, ok := s.vault.(secrets.Stopper); ok { 211 + defer stopper.Stop() 212 + } 213 + 188 214 go func() { 189 - logger.Info("starting knot event consumer") 190 - spindle.ks.Start(ctx) 215 + s.l.Info("starting knot event consumer") 216 + s.ks.Start(ctx) 191 217 }() 192 218 193 - logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 194 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 219 + s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 220 + return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 221 + } 222 + 223 + func Run(ctx context.Context) error { 224 + cfg, err := config.Load(ctx) 225 + if err != nil { 226 + return fmt.Errorf("failed to load config: %w", err) 227 + } 228 + 229 + nixeryEng, err := nixery.New(ctx, cfg) 230 + if err != nil { 231 + return err 232 + } 233 + 234 + s, err := New(ctx, cfg, map[string]models.Engine{ 235 + "nixery": nixeryEng, 236 + }) 237 + if err != nil { 238 + return err 239 + } 195 240 196 - return nil 241 + return s.Start(ctx) 197 242 } 198 243 199 244 func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
··· 213 213 if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 214 214 return fmt.Errorf("failed to write to websocket: %w", err) 215 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 + } 216 221 } 217 222 } 218 223 }
+8 -6
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 4 5 "github.com/go-git/go-git/v5/plumbing/object" 5 6 ) 6 7 ··· 33 34 } 34 35 35 36 type RepoFormatPatchResponse struct { 36 - Rev1 string `json:"rev1,omitempty"` 37 - Rev2 string `json:"rev2,omitempty"` 38 - FormatPatch []FormatPatch `json:"format_patch,omitempty"` 39 - MergeBase string `json:"merge_base,omitempty"` // deprecated 40 - Patch string `json:"patch,omitempty"` 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 40 + FormatPatchRaw string `json:"patch,omitempty"` 41 + CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 42 + CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 41 43 } 42 44 43 45 type RepoTreeResponse struct { ··· 64 66 type Branch struct { 65 67 Reference `json:"reference"` 66 68 Commit *object.Commit `json:"commit,omitempty"` 67 - IsDefault bool `json:"is_deafult,omitempty"` 69 + IsDefault bool `json:"is_default,omitempty"` 68 70 } 69 71 70 72 type RepoTagsResponse struct {
+28 -5
types/tree.go
··· 4 4 "time" 5 5 6 6 "github.com/go-git/go-git/v5/plumbing" 7 + "github.com/go-git/go-git/v5/plumbing/filemode" 7 8 ) 8 9 9 10 // A nicer git tree representation. 10 11 type NiceTree struct { 11 12 // 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"` 13 + Name string `json:"name"` 14 + Mode string `json:"mode"` 15 + Size int64 `json:"size"` 17 16 18 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 19 42 } 20 43 21 44 type LastCommitInfo struct {
+9 -1
workflow/compile.go
··· 113 113 func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 114 cw := &tangled.Pipeline_Workflow{} 115 115 116 - if !w.Match(compiler.Trigger) { 116 + matched, err := w.Match(compiler.Trigger) 117 + if err != nil { 118 + compiler.Diagnostics.AddError( 119 + w.Name, 120 + fmt.Errorf("failed to execute workflow: %w", err), 121 + ) 122 + return nil 123 + } 124 + if !matched { 117 125 compiler.Diagnostics.AddWarning( 118 126 w.Name, 119 127 WorkflowSkipped,
+125
workflow/compile_test.go
··· 95 95 assert.Len(t, c.Diagnostics.Errors, 1) 96 96 assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 97 } 98 + 99 + func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) { 100 + wf := Workflow{ 101 + Name: ".tangled/workflows/branch_and_tag.yml", 102 + When: []Constraint{ 103 + { 104 + Event: []string{"push"}, 105 + Branch: []string{"main", "develop"}, 106 + Tag: []string{"v*"}, 107 + }, 108 + }, 109 + Engine: "nixery", 110 + } 111 + 112 + tests := []struct { 113 + name string 114 + trigger tangled.Pipeline_TriggerMetadata 115 + shouldMatch bool 116 + expectedCount int 117 + }{ 118 + { 119 + name: "matches main branch", 120 + trigger: tangled.Pipeline_TriggerMetadata{ 121 + Kind: string(TriggerKindPush), 122 + Push: &tangled.Pipeline_PushTriggerData{ 123 + Ref: "refs/heads/main", 124 + OldSha: strings.Repeat("0", 40), 125 + NewSha: strings.Repeat("f", 40), 126 + }, 127 + }, 128 + shouldMatch: true, 129 + expectedCount: 1, 130 + }, 131 + { 132 + name: "matches develop branch", 133 + trigger: tangled.Pipeline_TriggerMetadata{ 134 + Kind: string(TriggerKindPush), 135 + Push: &tangled.Pipeline_PushTriggerData{ 136 + Ref: "refs/heads/develop", 137 + OldSha: strings.Repeat("0", 40), 138 + NewSha: strings.Repeat("f", 40), 139 + }, 140 + }, 141 + shouldMatch: true, 142 + expectedCount: 1, 143 + }, 144 + { 145 + name: "matches v* tag pattern", 146 + trigger: tangled.Pipeline_TriggerMetadata{ 147 + Kind: string(TriggerKindPush), 148 + Push: &tangled.Pipeline_PushTriggerData{ 149 + Ref: "refs/tags/v1.0.0", 150 + OldSha: strings.Repeat("0", 40), 151 + NewSha: strings.Repeat("f", 40), 152 + }, 153 + }, 154 + shouldMatch: true, 155 + expectedCount: 1, 156 + }, 157 + { 158 + name: "matches v* tag pattern with different version", 159 + trigger: tangled.Pipeline_TriggerMetadata{ 160 + Kind: string(TriggerKindPush), 161 + Push: &tangled.Pipeline_PushTriggerData{ 162 + Ref: "refs/tags/v2.5.3", 163 + OldSha: strings.Repeat("0", 40), 164 + NewSha: strings.Repeat("f", 40), 165 + }, 166 + }, 167 + shouldMatch: true, 168 + expectedCount: 1, 169 + }, 170 + { 171 + name: "does not match master branch", 172 + trigger: tangled.Pipeline_TriggerMetadata{ 173 + Kind: string(TriggerKindPush), 174 + Push: &tangled.Pipeline_PushTriggerData{ 175 + Ref: "refs/heads/master", 176 + OldSha: strings.Repeat("0", 40), 177 + NewSha: strings.Repeat("f", 40), 178 + }, 179 + }, 180 + shouldMatch: false, 181 + expectedCount: 0, 182 + }, 183 + { 184 + name: "does not match non-v tag", 185 + trigger: tangled.Pipeline_TriggerMetadata{ 186 + Kind: string(TriggerKindPush), 187 + Push: &tangled.Pipeline_PushTriggerData{ 188 + Ref: "refs/tags/release-1.0", 189 + OldSha: strings.Repeat("0", 40), 190 + NewSha: strings.Repeat("f", 40), 191 + }, 192 + }, 193 + shouldMatch: false, 194 + expectedCount: 0, 195 + }, 196 + { 197 + name: "does not match feature branch", 198 + trigger: tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + Ref: "refs/heads/feature/new-feature", 202 + OldSha: strings.Repeat("0", 40), 203 + NewSha: strings.Repeat("f", 40), 204 + }, 205 + }, 206 + shouldMatch: false, 207 + expectedCount: 0, 208 + }, 209 + } 210 + 211 + for _, tt := range tests { 212 + t.Run(tt.name, func(t *testing.T) { 213 + c := Compiler{Trigger: tt.trigger} 214 + cp := c.Compile([]Workflow{wf}) 215 + 216 + assert.Len(t, cp.Workflows, tt.expectedCount) 217 + if tt.shouldMatch { 218 + assert.Equal(t, wf.Name, cp.Workflows[0].Name) 219 + } 220 + }) 221 + } 222 + }
+61 -19
workflow/def.go
··· 8 8 9 9 "tangled.org/core/api/tangled" 10 10 11 + "github.com/bmatcuk/doublestar/v4" 11 12 "github.com/go-git/go-git/v5/plumbing" 12 13 "gopkg.in/yaml.v3" 13 14 ) ··· 33 34 34 35 Constraint struct { 35 36 Event StringList `yaml:"event"` 36 - Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 + Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 38 + Tag StringList `yaml:"tag"` // optional; only applies to push events 37 39 } 38 40 39 41 CloneOpts struct { ··· 59 61 return strings.ReplaceAll(string(t), "_", " ") 60 62 } 61 63 64 + // matchesPattern checks if a name matches any of the given patterns. 65 + // Patterns can be exact matches or glob patterns using * and **. 66 + // * matches any sequence of non-separator characters 67 + // ** matches any sequence of characters including separators 68 + func matchesPattern(name string, patterns []string) (bool, error) { 69 + for _, pattern := range patterns { 70 + matched, err := doublestar.Match(pattern, name) 71 + if err != nil { 72 + return false, err 73 + } 74 + if matched { 75 + return true, nil 76 + } 77 + } 78 + return false, nil 79 + } 80 + 62 81 func FromFile(name string, contents []byte) (Workflow, error) { 63 82 var wf Workflow 64 83 ··· 74 93 } 75 94 76 95 // if any of the constraints on a workflow is true, return true 77 - func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 96 + func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 78 97 // manual triggers always run the workflow 79 98 if trigger.Manual != nil { 80 - return true 99 + return true, nil 81 100 } 82 101 83 102 // if not manual, run through the constraint list and see if any one matches 84 103 for _, c := range w.When { 85 - if c.Match(trigger) { 86 - return true 104 + matched, err := c.Match(trigger) 105 + if err != nil { 106 + return false, err 107 + } 108 + if matched { 109 + return true, nil 87 110 } 88 111 } 89 112 90 113 // no constraints, always run this workflow 91 114 if len(w.When) == 0 { 92 - return true 115 + return true, nil 93 116 } 94 117 95 - return false 118 + return false, nil 96 119 } 97 120 98 - func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 121 + func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 99 122 match := true 100 123 101 124 // manual triggers always pass this constraint 102 125 if trigger.Manual != nil { 103 - return true 126 + return true, nil 104 127 } 105 128 106 129 // apply event constraints ··· 108 131 109 132 // apply branch constraints for PRs 110 133 if trigger.PullRequest != nil { 111 - match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) 134 + matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch) 135 + if err != nil { 136 + return false, err 137 + } 138 + match = match && matched 112 139 } 113 140 114 141 // apply ref constraints for pushes 115 142 if trigger.Push != nil { 116 - match = match && c.MatchRef(trigger.Push.Ref) 143 + matched, err := c.MatchRef(trigger.Push.Ref) 144 + if err != nil { 145 + return false, err 146 + } 147 + match = match && matched 117 148 } 118 149 119 - return match 120 - } 121 - 122 - func (c *Constraint) MatchBranch(branch string) bool { 123 - return slices.Contains(c.Branch, branch) 150 + return match, nil 124 151 } 125 152 126 - func (c *Constraint) MatchRef(ref string) bool { 153 + func (c *Constraint) MatchRef(ref string) (bool, error) { 127 154 refName := plumbing.ReferenceName(ref) 155 + shortName := refName.Short() 156 + 128 157 if refName.IsBranch() { 129 - return slices.Contains(c.Branch, refName.Short()) 158 + return c.MatchBranch(shortName) 130 159 } 131 - return false 160 + 161 + if refName.IsTag() { 162 + return c.MatchTag(shortName) 163 + } 164 + 165 + return false, nil 166 + } 167 + 168 + func (c *Constraint) MatchBranch(branch string) (bool, error) { 169 + return matchesPattern(branch, c.Branch) 170 + } 171 + 172 + func (c *Constraint) MatchTag(tag string) (bool, error) { 173 + return matchesPattern(tag, c.Tag) 132 174 } 133 175 134 176 func (c *Constraint) MatchEvent(event string) bool {
+284 -1
workflow/def_test.go
··· 6 6 "github.com/stretchr/testify/assert" 7 7 ) 8 8 9 - func TestUnmarshalWorkflow(t *testing.T) { 9 + func TestUnmarshalWorkflowWithBranch(t *testing.T) { 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] ··· 38 38 39 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 40 } 41 + 42 + func TestUnmarshalWorkflowWithTags(t *testing.T) { 43 + yamlData := ` 44 + when: 45 + - event: ["push"] 46 + tag: ["v*", "release-*"]` 47 + 48 + wf, err := FromFile("test.yml", []byte(yamlData)) 49 + assert.NoError(t, err, "YAML should unmarshal without error") 50 + 51 + assert.Len(t, wf.When, 1, "Should have one constraint") 52 + assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag) 53 + assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 54 + } 55 + 56 + func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) { 57 + yamlData := ` 58 + when: 59 + - event: ["push"] 60 + branch: ["main", "develop"] 61 + tag: ["v*"]` 62 + 63 + wf, err := FromFile("test.yml", []byte(yamlData)) 64 + assert.NoError(t, err, "YAML should unmarshal without error") 65 + 66 + assert.Len(t, wf.When, 1, "Should have one constraint") 67 + assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 68 + assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) 69 + } 70 + 71 + func TestMatchesPattern(t *testing.T) { 72 + tests := []struct { 73 + name string 74 + input string 75 + patterns []string 76 + expected bool 77 + }{ 78 + {"exact match", "main", []string{"main"}, true}, 79 + {"exact match in list", "develop", []string{"main", "develop"}, true}, 80 + {"no match", "feature", []string{"main", "develop"}, false}, 81 + {"wildcard prefix", "v1.0.0", []string{"v*"}, true}, 82 + {"wildcard suffix", "release-1.0", []string{"*-1.0"}, true}, 83 + {"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true}, 84 + {"double star prefix", "release-1.0.0", []string{"release-**"}, true}, 85 + {"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true}, 86 + {"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true}, 87 + {"double star no match", "feature/test", []string{"release/**"}, false}, 88 + {"no patterns matches nothing", "anything", []string{}, false}, 89 + {"pattern doesn't match", "v1.0.0", []string{"release-*"}, false}, 90 + {"complex pattern", "release/v1.2.3", []string{"release/*"}, true}, 91 + {"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false}, 92 + } 93 + 94 + for _, tt := range tests { 95 + t.Run(tt.name, func(t *testing.T) { 96 + result, _ := matchesPattern(tt.input, tt.patterns) 97 + assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected) 98 + }) 99 + } 100 + } 101 + 102 + func TestConstraintMatchRef_Branches(t *testing.T) { 103 + tests := []struct { 104 + name string 105 + constraint Constraint 106 + ref string 107 + expected bool 108 + }{ 109 + { 110 + name: "exact branch match", 111 + constraint: Constraint{Branch: []string{"main"}}, 112 + ref: "refs/heads/main", 113 + expected: true, 114 + }, 115 + { 116 + name: "branch glob match", 117 + constraint: Constraint{Branch: []string{"feature-*"}}, 118 + ref: "refs/heads/feature-123", 119 + expected: true, 120 + }, 121 + { 122 + name: "branch no match", 123 + constraint: Constraint{Branch: []string{"main"}}, 124 + ref: "refs/heads/develop", 125 + expected: false, 126 + }, 127 + { 128 + name: "no constraints matches nothing", 129 + constraint: Constraint{}, 130 + ref: "refs/heads/anything", 131 + expected: false, 132 + }, 133 + } 134 + 135 + for _, tt := range tests { 136 + t.Run(tt.name, func(t *testing.T) { 137 + result, _ := tt.constraint.MatchRef(tt.ref) 138 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 139 + }) 140 + } 141 + } 142 + 143 + func TestConstraintMatchRef_Tags(t *testing.T) { 144 + tests := []struct { 145 + name string 146 + constraint Constraint 147 + ref string 148 + expected bool 149 + }{ 150 + { 151 + name: "exact tag match", 152 + constraint: Constraint{Tag: []string{"v1.0.0"}}, 153 + ref: "refs/tags/v1.0.0", 154 + expected: true, 155 + }, 156 + { 157 + name: "tag glob match", 158 + constraint: Constraint{Tag: []string{"v*"}}, 159 + ref: "refs/tags/v1.2.3", 160 + expected: true, 161 + }, 162 + { 163 + name: "tag glob with pattern", 164 + constraint: Constraint{Tag: []string{"release-*"}}, 165 + ref: "refs/tags/release-2024", 166 + expected: true, 167 + }, 168 + { 169 + name: "tag no match", 170 + constraint: Constraint{Tag: []string{"v*"}}, 171 + ref: "refs/tags/release-1.0", 172 + expected: false, 173 + }, 174 + { 175 + name: "tag not matched when only branch constraint", 176 + constraint: Constraint{Branch: []string{"main"}}, 177 + ref: "refs/tags/v1.0.0", 178 + expected: false, 179 + }, 180 + } 181 + 182 + for _, tt := range tests { 183 + t.Run(tt.name, func(t *testing.T) { 184 + result, _ := tt.constraint.MatchRef(tt.ref) 185 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 186 + }) 187 + } 188 + } 189 + 190 + func TestConstraintMatchRef_Combined(t *testing.T) { 191 + tests := []struct { 192 + name string 193 + constraint Constraint 194 + ref string 195 + expected bool 196 + }{ 197 + { 198 + name: "matches branch in combined constraint", 199 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 200 + ref: "refs/heads/main", 201 + expected: true, 202 + }, 203 + { 204 + name: "matches tag in combined constraint", 205 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 206 + ref: "refs/tags/v1.0.0", 207 + expected: true, 208 + }, 209 + { 210 + name: "no match in combined constraint", 211 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 212 + ref: "refs/heads/develop", 213 + expected: false, 214 + }, 215 + { 216 + name: "glob patterns in combined constraint - branch", 217 + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 218 + ref: "refs/heads/release-2024", 219 + expected: true, 220 + }, 221 + { 222 + name: "glob patterns in combined constraint - tag", 223 + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 224 + ref: "refs/tags/v2.0.0", 225 + expected: true, 226 + }, 227 + } 228 + 229 + for _, tt := range tests { 230 + t.Run(tt.name, func(t *testing.T) { 231 + result, _ := tt.constraint.MatchRef(tt.ref) 232 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 233 + }) 234 + } 235 + } 236 + 237 + func TestConstraintMatchBranch_GlobPatterns(t *testing.T) { 238 + tests := []struct { 239 + name string 240 + constraint Constraint 241 + branch string 242 + expected bool 243 + }{ 244 + { 245 + name: "exact match", 246 + constraint: Constraint{Branch: []string{"main"}}, 247 + branch: "main", 248 + expected: true, 249 + }, 250 + { 251 + name: "glob match", 252 + constraint: Constraint{Branch: []string{"feature-*"}}, 253 + branch: "feature-123", 254 + expected: true, 255 + }, 256 + { 257 + name: "no match", 258 + constraint: Constraint{Branch: []string{"main"}}, 259 + branch: "develop", 260 + expected: false, 261 + }, 262 + { 263 + name: "multiple patterns with match", 264 + constraint: Constraint{Branch: []string{"main", "release-*"}}, 265 + branch: "release-1.0", 266 + expected: true, 267 + }, 268 + } 269 + 270 + for _, tt := range tests { 271 + t.Run(tt.name, func(t *testing.T) { 272 + result, _ := tt.constraint.MatchBranch(tt.branch) 273 + assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch) 274 + }) 275 + } 276 + } 277 + 278 + func TestConstraintMatchTag_GlobPatterns(t *testing.T) { 279 + tests := []struct { 280 + name string 281 + constraint Constraint 282 + tag string 283 + expected bool 284 + }{ 285 + { 286 + name: "exact match", 287 + constraint: Constraint{Tag: []string{"v1.0.0"}}, 288 + tag: "v1.0.0", 289 + expected: true, 290 + }, 291 + { 292 + name: "glob match", 293 + constraint: Constraint{Tag: []string{"v*"}}, 294 + tag: "v2.3.4", 295 + expected: true, 296 + }, 297 + { 298 + name: "no match", 299 + constraint: Constraint{Tag: []string{"v*"}}, 300 + tag: "release-1.0", 301 + expected: false, 302 + }, 303 + { 304 + name: "multiple patterns with match", 305 + constraint: Constraint{Tag: []string{"v*", "release-*"}}, 306 + tag: "release-2024", 307 + expected: true, 308 + }, 309 + { 310 + name: "empty tag list matches nothing", 311 + constraint: Constraint{Tag: []string{}}, 312 + tag: "v1.0.0", 313 + expected: false, 314 + }, 315 + } 316 + 317 + for _, tt := range tests { 318 + t.Run(tt.name, func(t *testing.T) { 319 + result, _ := tt.constraint.MatchTag(tt.tag) 320 + assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag) 321 + }) 322 + } 323 + }