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

Compare changes

Choose any two refs to compare.

Changed files
+4762 -2492
api
appview
docs
guard
idresolver
knotserver
lexicons
nix
spindle
types
workflow
+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 }
+11
appview/config/config.go
··· 30 30 ClientKid string `env:"CLIENT_KID"` 31 31 } 32 32 33 + type PlcConfig struct { 34 + PLCURL string `env:"URL, default=https://plc.directory"` 35 + } 36 + 33 37 type JetstreamConfig struct { 34 38 Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 35 39 } ··· 80 84 TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 81 85 } 82 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 + 83 92 func (cfg RedisConfig) ToURL() string { 84 93 u := &url.URL{ 85 94 Scheme: "redis", ··· 105 114 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 106 115 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 107 116 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 117 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 108 118 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 109 119 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 120 + Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 110 121 } 111 122 112 123 func LoadConfig(ctx context.Context) (*Config, error) {
+22
appview/db/db.go
··· 1106 1106 return err 1107 1107 }) 1108 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 + 1109 1131 return &DB{ 1110 1132 db, 1111 1133 logger,
+15 -5
appview/db/notifications.go
··· 134 134 select 135 135 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 136 136 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 137 - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 137 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 138 138 i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 139 139 p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 140 140 from notifications n ··· 163 163 var issue models.Issue 164 164 var pull models.Pull 165 165 var rId, iId, pId sql.NullInt64 166 - var rDid, rName, rDescription sql.NullString 166 + var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 167 167 var iDid sql.NullString 168 168 var iIssueId sql.NullInt64 169 169 var iTitle sql.NullString ··· 176 176 err := rows.Scan( 177 177 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 178 178 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 179 - &rId, &rDid, &rName, &rDescription, 179 + &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 180 180 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 181 181 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 182 182 ) ··· 203 203 } 204 204 if rDescription.Valid { 205 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) 206 212 } 207 213 nwe.Repo = &repo 208 214 } ··· 394 400 pull_created, 395 401 pull_commented, 396 402 followed, 403 + user_mentioned, 397 404 pull_merged, 398 405 issue_closed, 399 406 email_notifications ··· 419 426 &prefs.PullCreated, 420 427 &prefs.PullCommented, 421 428 &prefs.Followed, 429 + &prefs.UserMentioned, 422 430 &prefs.PullMerged, 423 431 &prefs.IssueClosed, 424 432 &prefs.EmailNotifications, ··· 440 448 query := ` 441 449 INSERT OR REPLACE INTO notification_preferences 442 450 (user_did, repo_starred, issue_created, issue_commented, pull_created, 443 - pull_commented, followed, pull_merged, issue_closed, email_notifications) 444 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 451 + pull_commented, followed, user_mentioned, pull_merged, issue_closed, 452 + email_notifications) 453 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 445 454 ` 446 455 447 456 result, err := d.DB.ExecContext(ctx, query, ··· 452 461 prefs.PullCreated, 453 462 prefs.PullCommented, 454 463 prefs.Followed, 464 + prefs.UserMentioned, 455 465 prefs.PullMerged, 456 466 prefs.IssueClosed, 457 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
+4 -4
appview/db/pulls.go
··· 92 92 _, err = tx.Exec(` 93 93 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 94 values (?, ?, ?, ?, ?) 95 - `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 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 {
+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 -3
appview/indexer/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 "tangled.org/core/appview/notify" 8 9 "tangled.org/core/log" ··· 10 11 11 12 var _ notify.Notifier = &Indexer{} 12 13 13 - func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) { 14 + func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 14 15 l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 15 16 l.Debug("indexing new issue") 16 17 err := ix.Issues.Index(ctx, *issue) ··· 19 20 } 20 21 } 21 22 22 - func (ix *Indexer) NewIssueState(ctx context.Context, issue *models.Issue) { 23 + func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 23 24 l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 24 25 l.Debug("updating an issue") 25 26 err := ix.Issues.Index(ctx, *issue) ··· 46 47 } 47 48 } 48 49 49 - func (ix *Indexer) NewPullState(ctx context.Context, pull *models.Pull) { 50 + func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 50 51 l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 51 52 l.Debug("updating a pr") 52 53 err := ix.Pulls.Index(ctx, pull)
+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)
+25 -4
appview/issues/issues.go
··· 24 24 "tangled.org/core/appview/notify" 25 25 "tangled.org/core/appview/oauth" 26 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pages/markup" 27 28 "tangled.org/core/appview/pagination" 28 29 "tangled.org/core/appview/reporesolver" 29 30 "tangled.org/core/appview/validator" ··· 309 310 issue.Open = false 310 311 311 312 // notify about the issue closure 312 - rp.notifier.NewIssueState(r.Context(), issue) 313 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 313 314 314 315 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 315 316 return ··· 359 360 issue.Open = true 360 361 361 362 // notify about the issue reopen 362 - rp.notifier.NewIssueState(r.Context(), issue) 363 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 363 364 364 365 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 365 366 return ··· 453 454 454 455 // notify about the new comment 455 456 comment.Id = commentId 456 - 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) 457 468 458 469 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 459 470 } ··· 948 959 949 960 // everything is successful, do not rollback the atproto record 950 961 atUri = "" 951 - 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) 952 973 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 953 974 return 954 975 }
+5 -5
appview/issues/opengraph.go
··· 143 143 var statusBgColor color.RGBA 144 144 145 145 if issue.Open { 146 - statusIcon = "static/icons/circle-dot.svg" 146 + statusIcon = "circle-dot" 147 147 statusText = "open" 148 148 statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 149 } else { 150 - statusIcon = "static/icons/circle-dot.svg" 150 + statusIcon = "ban" 151 151 statusText = "closed" 152 152 statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 153 } ··· 155 155 badgeIconSize := 36 156 156 157 157 // Draw icon with status color (no background) 158 - err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 158 + err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 159 if err != nil { 160 160 log.Printf("failed to draw status icon: %v", err) 161 161 } ··· 172 172 currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 173 174 174 // Draw comment count 175 - err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 175 + err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 176 if err != nil { 177 177 log.Printf("failed to draw comment icon: %v", err) 178 178 } ··· 193 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 - err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 196 + err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 197 197 if err != nil { 198 198 log.Printf("dolly silhouette not available (this is ok): %v", err) 199 199 }
+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.")
+4 -2
appview/middleware/middleware.go
··· 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")
+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 {
+7
appview/models/notifications.go
··· 20 20 NotificationTypeIssueReopen NotificationType = "issue_reopen" 21 21 NotificationTypePullClosed NotificationType = "pull_closed" 22 22 NotificationTypePullReopen NotificationType = "pull_reopen" 23 + NotificationTypeUserMentioned NotificationType = "user_mentioned" 23 24 ) 24 25 25 26 type Notification struct { ··· 63 64 return "git-pull-request-create" 64 65 case NotificationTypeFollowed: 65 66 return "user-plus" 67 + case NotificationTypeUserMentioned: 68 + return "at-sign" 66 69 default: 67 70 return "" 68 71 } ··· 84 87 PullCreated bool 85 88 PullCommented bool 86 89 Followed bool 90 + UserMentioned bool 87 91 PullMerged bool 88 92 IssueClosed bool 89 93 EmailNotifications bool ··· 113 117 return prefs.PullCreated // same pref for now 114 118 case NotificationTypeFollowed: 115 119 return prefs.Followed 120 + case NotificationTypeUserMentioned: 121 + return prefs.UserMentioned 116 122 default: 117 123 return false 118 124 } ··· 127 133 PullCreated: true, 128 134 PullCommented: true, 129 135 Followed: true, 136 + UserMentioned: true, 130 137 PullMerged: true, 131 138 IssueClosed: true, 132 139 EmailNotifications: false,
+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 {
+1 -1
appview/models/pull.go
··· 167 167 return p.LatestSubmission().SourceRev 168 168 } 169 169 170 - func (p *Pull) PullAt() syntax.ATURI { 170 + func (p *Pull) AtUri() syntax.ATURI { 171 171 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 172 172 } 173 173
+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 + }
+49 -16
appview/notify/db/db.go
··· 13 13 "tangled.org/core/idresolver" 14 14 ) 15 15 16 + const ( 17 + maxMentions = 5 18 + ) 19 + 16 20 type databaseNotifier struct { 17 21 db *db.DB 18 22 res *idresolver.Resolver ··· 64 68 // no-op 65 69 } 66 70 67 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 71 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 68 72 69 73 // build the recipients list 70 74 // - owner of the repo ··· 81 85 } 82 86 83 87 actorDid := syntax.DID(issue.Did) 84 - eventType := models.NotificationTypeIssueCreated 85 88 entityType := "issue" 86 89 entityId := issue.AtUri().String() 87 90 repoId := &issue.Repo.Id ··· 91 94 n.notifyEvent( 92 95 actorDid, 93 96 recipients, 94 - eventType, 97 + models.NotificationTypeIssueCreated, 98 + entityType, 99 + entityId, 100 + repoId, 101 + issueId, 102 + pullId, 103 + ) 104 + n.notifyEvent( 105 + actorDid, 106 + mentions, 107 + models.NotificationTypeUserMentioned, 95 108 entityType, 96 109 entityId, 97 110 repoId, ··· 100 113 ) 101 114 } 102 115 103 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 116 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 104 117 issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 105 118 if err != nil { 106 119 log.Printf("NewIssueComment: failed to get issues: %v", err) ··· 132 145 } 133 146 134 147 actorDid := syntax.DID(comment.Did) 135 - eventType := models.NotificationTypeIssueCommented 136 148 entityType := "issue" 137 149 entityId := issue.AtUri().String() 138 150 repoId := &issue.Repo.Id ··· 142 154 n.notifyEvent( 143 155 actorDid, 144 156 recipients, 145 - eventType, 157 + models.NotificationTypeIssueCommented, 158 + entityType, 159 + entityId, 160 + repoId, 161 + issueId, 162 + pullId, 163 + ) 164 + n.notifyEvent( 165 + actorDid, 166 + mentions, 167 + models.NotificationTypeUserMentioned, 146 168 entityType, 147 169 entityId, 148 170 repoId, ··· 203 225 actorDid := syntax.DID(pull.OwnerDid) 204 226 eventType := models.NotificationTypePullCreated 205 227 entityType := "pull" 206 - entityId := pull.PullAt().String() 228 + entityId := pull.AtUri().String() 207 229 repoId := &repo.Id 208 230 var issueId *int64 209 231 p := int64(pull.ID) ··· 221 243 ) 222 244 } 223 245 224 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 246 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 225 247 pull, err := db.GetPull(n.db, 226 248 syntax.ATURI(comment.RepoAt), 227 249 comment.PullId, ··· 249 271 actorDid := syntax.DID(comment.OwnerDid) 250 272 eventType := models.NotificationTypePullCommented 251 273 entityType := "pull" 252 - entityId := pull.PullAt().String() 274 + entityId := pull.AtUri().String() 253 275 repoId := &repo.Id 254 276 var issueId *int64 255 277 p := int64(pull.ID) ··· 265 287 issueId, 266 288 pullId, 267 289 ) 290 + n.notifyEvent( 291 + actorDid, 292 + mentions, 293 + models.NotificationTypeUserMentioned, 294 + entityType, 295 + entityId, 296 + repoId, 297 + issueId, 298 + pullId, 299 + ) 268 300 } 269 301 270 302 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 283 315 // no-op 284 316 } 285 317 286 - func (n *databaseNotifier) NewIssueState(ctx context.Context, issue *models.Issue) { 318 + func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 287 319 // build up the recipients list: 288 320 // - repo owner 289 321 // - repo collaborators ··· 302 334 recipients = append(recipients, syntax.DID(p)) 303 335 } 304 336 305 - actorDid := syntax.DID(issue.Repo.Did) 306 337 entityType := "pull" 307 338 entityId := issue.AtUri().String() 308 339 repoId := &issue.Repo.Id ··· 317 348 } 318 349 319 350 n.notifyEvent( 320 - actorDid, 351 + actor, 321 352 recipients, 322 353 eventType, 323 354 entityType, ··· 328 359 ) 329 360 } 330 361 331 - func (n *databaseNotifier) NewPullState(ctx context.Context, pull *models.Pull) { 362 + func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 332 363 // Get repo details 333 364 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 334 365 if err != nil { ··· 353 384 recipients = append(recipients, syntax.DID(p)) 354 385 } 355 386 356 - actorDid := syntax.DID(repo.Did) 357 387 entityType := "pull" 358 - entityId := pull.PullAt().String() 388 + entityId := pull.AtUri().String() 359 389 repoId := &repo.Id 360 390 var issueId *int64 361 391 var eventType models.NotificationType ··· 374 404 pullId := &p 375 405 376 406 n.notifyEvent( 377 - actorDid, 407 + actor, 378 408 recipients, 379 409 eventType, 380 410 entityType, ··· 395 425 issueId *int64, 396 426 pullId *int64, 397 427 ) { 428 + if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 429 + recipients = recipients[:maxMentions] 430 + } 398 431 recipientSet := make(map[syntax.DID]struct{}) 399 432 for _, did := range recipients { 400 433 // everybody except actor themselves
+11 -10
appview/notify/merged_notifier.go
··· 6 6 "reflect" 7 7 "sync" 8 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/appview/models" 10 11 "tangled.org/core/log" 11 12 ) ··· 53 54 m.fanout("DeleteStar", ctx, star) 54 55 } 55 56 56 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 57 - m.fanout("NewIssue", ctx, issue) 57 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 58 + m.fanout("NewIssue", ctx, issue, mentions) 58 59 } 59 60 60 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 61 - m.fanout("NewIssueComment", ctx, comment) 61 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 62 + m.fanout("NewIssueComment", ctx, comment, mentions) 62 63 } 63 64 64 - func (m *mergedNotifier) NewIssueState(ctx context.Context, issue *models.Issue) { 65 - m.fanout("NewIssueState", ctx, issue) 65 + func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 66 + m.fanout("NewIssueState", ctx, actor, issue) 66 67 } 67 68 68 69 func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { ··· 81 82 m.fanout("NewPull", ctx, pull) 82 83 } 83 84 84 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 85 - m.fanout("NewPullComment", ctx, comment) 85 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 86 + m.fanout("NewPullComment", ctx, comment, mentions) 86 87 } 87 88 88 - func (m *mergedNotifier) NewPullState(ctx context.Context, pull *models.Pull) { 89 - m.fanout("NewPullState", ctx, pull) 89 + func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 90 + m.fanout("NewPullState", ctx, actor, pull) 90 91 } 91 92 92 93 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
+15 -12
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 - NewIssueState(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) 18 19 DeleteIssue(ctx context.Context, issue *models.Issue) 19 20 20 21 NewFollow(ctx context.Context, follow *models.Follow) 21 22 DeleteFollow(ctx context.Context, follow *models.Follow) 22 23 23 24 NewPull(ctx context.Context, pull *models.Pull) 24 - NewPullComment(ctx context.Context, comment *models.PullComment) 25 - NewPullState(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) NewIssueState(ctx context.Context, issue *models.Issue) {} 47 - func (m *BaseNotifier) DeleteIssue(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) {} 48 50 49 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 50 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 51 53 52 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 53 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 54 - func (m *BaseNotifier) NewPullState(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
+13 -7
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) NewIssueState(ctx context.Context, issue *models.Issue) { 197 + func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 194 198 var event string 195 199 if issue.Open { 196 200 event = "issue_reopen" ··· 202 206 Event: event, 203 207 Properties: posthog.Properties{ 204 208 "repo_at": issue.RepoAt.String(), 209 + "actor": actor, 205 210 "issue_id": issue.IssueId, 206 211 }, 207 212 }) ··· 210 215 } 211 216 } 212 217 213 - func (n *posthogNotifier) NewPullState(ctx context.Context, pull *models.Pull) { 218 + func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 214 219 var event string 215 220 switch pull.State { 216 221 case models.PullClosed: ··· 229 234 Properties: posthog.Properties{ 230 235 "repo_at": pull.RepoAt, 231 236 "pull_id": pull.PullId, 237 + "actor": actor, 232 238 }, 233 239 }) 234 240 if err != nil {
+4
appview/oauth/oauth.go
··· 74 74 75 75 clientApp := oauth.NewClientApp(&oauthConfig, authStore) 76 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 + } 77 81 78 82 clientName := config.Core.AppviewName 79 83
+59 -10
appview/ogcard/card.go
··· 7 7 import ( 8 8 "bytes" 9 9 "fmt" 10 + "html/template" 10 11 "image" 11 12 "image/color" 12 13 "io" ··· 279 280 return width, nil 280 281 } 281 282 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 - 283 + func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { 289 284 // Convert color to hex string for SVG 290 285 rgba, isRGBA := iconColor.(color.RGBA) 291 286 if !isRGBA { ··· 304 299 // Parse SVG 305 300 icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 301 if err != nil { 307 - return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 302 + return nil, fmt.Errorf("failed to parse SVG: %w", err) 308 303 } 309 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) { 310 361 // Set the icon size 311 362 w, h := float64(size), float64(size) 312 363 icon.SetTarget(0, 0, w, h) ··· 334 385 } 335 386 336 387 draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 - 338 - return nil 339 388 } 340 389 341 390 // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension
+62 -4
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 },
+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
+10 -110
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" ··· 29 28 "tangled.org/core/patchutil" 30 29 "tangled.org/core/types" 31 30 32 - "github.com/alecthomas/chroma/v2" 33 - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 34 - "github.com/alecthomas/chroma/v2/lexers" 35 - "github.com/alecthomas/chroma/v2/styles" 36 31 "github.com/bluesky-social/indigo/atproto/identity" 37 32 "github.com/bluesky-social/indigo/atproto/syntax" 38 33 "github.com/go-git/go-git/v5/plumbing" ··· 640 635 return p.executePlain("repo/fragments/repoStar", w, params) 641 636 } 642 637 643 - type RepoDescriptionParams struct { 644 - RepoInfo repoinfo.RepoInfo 645 - } 646 - 647 - func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 648 - return p.executePlain("repo/fragments/editRepoDescription", w, params) 649 - } 650 - 651 - func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 652 - return p.executePlain("repo/fragments/repoDescription", w, params) 653 - } 654 - 655 638 type RepoIndexParams struct { 656 639 LoggedInUser *oauth.User 657 640 RepoInfo repoinfo.RepoInfo ··· 756 739 func (r RepoTreeParams) TreeStats() RepoTreeStats { 757 740 numFolders, numFiles := 0, 0 758 741 for _, f := range r.Files { 759 - if !f.IsFile { 742 + if !f.IsFile() { 760 743 numFolders += 1 761 - } else if f.IsFile { 744 + } else if f.IsFile() { 762 745 numFiles += 1 763 746 } 764 747 } ··· 829 812 } 830 813 831 814 type RepoBlobParams struct { 832 - LoggedInUser *oauth.User 833 - RepoInfo repoinfo.RepoInfo 834 - Active string 835 - Unsupported bool 836 - IsImage bool 837 - IsVideo bool 838 - ContentSrc string 839 - BreadCrumbs [][]string 840 - ShowRendered bool 841 - RenderToggle bool 842 - RenderedContents template.HTML 815 + LoggedInUser *oauth.User 816 + RepoInfo repoinfo.RepoInfo 817 + Active string 818 + BreadCrumbs [][]string 819 + BlobView models.BlobView 843 820 *tangled.RepoBlob_Output 844 - // Computed fields for template compatibility 845 - Contents string 846 - Lines int 847 - SizeHint uint64 848 - IsBinary bool 849 821 } 850 822 851 823 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 852 - var style *chroma.Style = styles.Get("catpuccin-latte") 853 - 854 - if params.ShowRendered { 855 - switch markup.GetFormat(params.Path) { 856 - case markup.FormatMarkdown: 857 - p.rctx.RepoInfo = params.RepoInfo 858 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 859 - htmlString := p.rctx.RenderMarkdown(params.Contents) 860 - sanitized := p.rctx.SanitizeDefault(htmlString) 861 - params.RenderedContents = template.HTML(sanitized) 862 - } 863 - } 864 - 865 - c := params.Contents 866 - formatter := chromahtml.New( 867 - chromahtml.InlineCode(false), 868 - chromahtml.WithLineNumbers(true), 869 - chromahtml.WithLinkableLineNumbers(true, "L"), 870 - chromahtml.Standalone(false), 871 - chromahtml.WithClasses(true), 872 - ) 873 - 874 - lexer := lexers.Get(filepath.Base(params.Path)) 875 - if lexer == nil { 876 - lexer = lexers.Fallback 877 - } 878 - 879 - iterator, err := lexer.Tokenise(nil, c) 880 - if err != nil { 881 - return fmt.Errorf("chroma tokenize: %w", err) 824 + switch params.BlobView.ContentType { 825 + case models.BlobContentTypeMarkup: 826 + p.rctx.RepoInfo = params.RepoInfo 882 827 } 883 828 884 - var code bytes.Buffer 885 - err = formatter.Format(&code, style, iterator) 886 - if err != nil { 887 - return fmt.Errorf("chroma format: %w", err) 888 - } 889 - 890 - params.Contents = code.String() 891 829 params.Active = "overview" 892 830 return p.executeRepo("repo/blob", w, params) 893 831 } ··· 1444 1382 } 1445 1383 1446 1384 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1447 - var style *chroma.Style = styles.Get("catpuccin-latte") 1448 - 1449 - if params.ShowRendered { 1450 - switch markup.GetFormat(params.String.Filename) { 1451 - case markup.FormatMarkdown: 1452 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1453 - htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1454 - sanitized := p.rctx.SanitizeDefault(htmlString) 1455 - params.RenderedContents = template.HTML(sanitized) 1456 - } 1457 - } 1458 - 1459 - c := params.String.Contents 1460 - formatter := chromahtml.New( 1461 - chromahtml.InlineCode(false), 1462 - chromahtml.WithLineNumbers(true), 1463 - chromahtml.WithLinkableLineNumbers(true, "L"), 1464 - chromahtml.Standalone(false), 1465 - chromahtml.WithClasses(true), 1466 - ) 1467 - 1468 - lexer := lexers.Get(filepath.Base(params.String.Filename)) 1469 - if lexer == nil { 1470 - lexer = lexers.Fallback 1471 - } 1472 - 1473 - iterator, err := lexer.Tokenise(nil, c) 1474 - if err != nil { 1475 - return fmt.Errorf("chroma tokenize: %w", err) 1476 - } 1477 - 1478 - var code bytes.Buffer 1479 - err = formatter.Format(&code, style, iterator) 1480 - if err != nil { 1481 - return fmt.Errorf("chroma format: %w", err) 1482 - } 1483 - 1484 - params.String.Contents = code.String() 1485 1385 return p.execute("strings/string", w, params) 1486 1386 } 1487 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 +
+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" />
+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 }}
+2
appview/pages/templates/notifications/fragments/item.html
··· 54 54 reopened a pull request 55 55 {{ else if eq .Type "followed" }} 56 56 followed you 57 + {{ else if eq .Type "user_mentioned" }} 58 + mentioned you 57 59 {{ else }} 58 60 {{ end }} 59 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 }}
+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 +
-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 }}
+8 -1
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" }}
+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">
+1
appview/pages/templates/repo/issues/issue.html
··· 20 20 "Subject" $.Issue.AtUri 21 21 "State" $.Issue.Labels) }} 22 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 23 24 </div> 24 25 </div> 25 26 {{ end }}
+41 -29
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"> 13 - <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 - > 17 - {{ i "circle-dot" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 19 - </a> 20 - <a 21 - href="?state=closed" 22 - class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 - > 24 - {{ i "ban" "w-4 h-4" }} 25 - <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 26 - </a> 27 - <form class="flex gap-4" method="GET"> 28 - <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 29 - <input class="" type="text" name="q" value="{{ .FilterQuery }}"> 30 - <button class="btn" type="submit"> 31 - search 32 - </button> 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> 33 43 </form> 34 - </div> 35 - <a 44 + <div class="sm:row-start-1"> 45 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 46 + </div> 47 + <a 36 48 href="/{{ .RepoInfo.FullName }}/issues/new" 37 - class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 38 - > 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 + > 39 51 {{ i "circle-plus" "w-4 h-4" }} 40 52 <span>new</span> 41 - </a> 42 - </div> 43 - <div class="error" id="issues"></div> 53 + </a> 54 + </div> 55 + <div class="error" id="issues"></div> 44 56 {{ end }} 45 57 46 58 {{ define "repoAfter" }}
+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>
+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
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 75 75 "Kind" $kind 76 76 "Count" $reactionData.Count 77 77 "IsReacted" (index $.UserReacted $kind) 78 - "ThreadAt" $.Pull.PullAt 78 + "ThreadAt" $.Pull.AtUri 79 79 "Users" $reactionData.Users) 80 80 }} 81 81 {{ end }}
+2 -1
appview/pages/templates/repo/pulls/pull.html
··· 18 18 {{ template "repo/fragments/labelPanel" 19 19 (dict "RepoInfo" $.RepoInfo 20 20 "Defs" $.LabelDefs 21 - "Subject" $.Pull.PullAt 21 + "Subject" $.Pull.AtUri 22 22 "State" $.Pull.Labels) }} 23 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 24 25 </div> 25 26 </div> 26 27 {{ end }}
+52 -41
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 - <form class="flex gap-4" method="GET"> 35 - <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 36 - <input class="" type="text" name="q" value="{{ .FilterQuery }}"> 37 - <button class="btn" type="submit"> 38 - search 39 - </button> 40 - </form> 41 - </div> 42 - <a 43 - href="/{{ .RepoInfo.FullName }}/pulls/new" 44 - class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 45 - > 46 - {{ i "git-pull-request-create" "w-4 h-4" }} 47 - <span>new</span> 48 - </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) }} 49 52 </div> 50 - <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> 51 62 {{ end }} 52 63 53 64 {{ define "repoAfter" }} ··· 140 151 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 141 152 </div> 142 153 </summary> 143 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 154 + {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 144 155 </details> 145 156 {{ end }} 146 157 {{ end }} ··· 149 160 </div> 150 161 {{ end }} 151 162 152 - {{ define "pullList" }} 163 + {{ define "stackedPullList" }} 153 164 {{ $list := index . 0 }} 154 165 {{ $root := index . 1 }} 155 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 -10
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 - autocapitalize="none" 87 - autocorrect="off" 88 - type="text" 89 - id="add-collaborator" 90 - name="collaborator" 91 - required 92 - placeholder="@foo.bsky.social" 93 - /> 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> 94 101 <div class="flex gap-2 pt-2"> 95 102 <button 96 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 -10
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 - autocapitalize="none" 34 - autocorrect="off" 35 - type="text" 36 - id="member-did-{{ .Id }}" 37 - name="member" 38 - required 39 - placeholder="@foo.bsky.social" 40 - /> 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> 41 47 <div class="flex gap-2 pt-2"> 42 48 <button 43 49 type="button"
+2 -2
appview/pages/templates/strings/string.html
··· 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 := "" }}
+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>
+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>
+7 -7
appview/pulls/opengraph.go
··· 146 146 var statusColor color.RGBA 147 147 148 148 if pull.State.IsOpen() { 149 - statusIcon = "static/icons/git-pull-request.svg" 149 + statusIcon = "git-pull-request" 150 150 statusText = "open" 151 151 statusColor = color.RGBA{34, 139, 34, 255} // green 152 152 } else if pull.State.IsMerged() { 153 - statusIcon = "static/icons/git-merge.svg" 153 + statusIcon = "git-merge" 154 154 statusText = "merged" 155 155 statusColor = color.RGBA{138, 43, 226, 255} // purple 156 156 } else { 157 - statusIcon = "static/icons/git-pull-request-closed.svg" 157 + statusIcon = "git-pull-request-closed" 158 158 statusText = "closed" 159 159 statusColor = color.RGBA{128, 128, 128, 255} // gray 160 160 } ··· 162 162 statusIconSize := 36 163 163 164 164 // Draw icon with status color 165 - err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 165 + err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 166 166 if err != nil { 167 167 log.Printf("failed to draw status icon: %v", err) 168 168 } ··· 179 179 currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 180 180 181 181 // Draw comment count 182 - err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 182 + err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 183 183 if err != nil { 184 184 log.Printf("failed to draw comment icon: %v", err) 185 185 } ··· 198 198 currentX += commentTextWidth + 40 199 199 200 200 // Draw files changed 201 - err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 201 + err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 202 202 if err != nil { 203 203 log.Printf("failed to draw file diff icon: %v", err) 204 204 } ··· 241 241 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 242 242 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 243 243 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 244 - err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 244 + err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 245 245 if err != nil { 246 246 log.Printf("dolly silhouette not available (this is ok): %v", err) 247 247 }
+21 -9
appview/pulls/pulls.go
··· 33 33 "tangled.org/core/types" 34 34 35 35 comatproto "github.com/bluesky-social/indigo/api/atproto" 36 + "github.com/bluesky-social/indigo/atproto/syntax" 36 37 lexutil "github.com/bluesky-social/indigo/lex/util" 37 38 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 38 39 "github.com/go-chi/chi/v5" ··· 191 192 m[p.Sha] = p 192 193 } 193 194 194 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 195 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 195 196 if err != nil { 196 197 log.Println("failed to get pull reactions") 197 198 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 199 200 200 201 userReactions := map[models.ReactionKind]bool{} 201 202 if user != nil { 202 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 203 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 203 204 } 204 205 205 206 labelDefs, err := db.GetLabelDefinitions( ··· 690 691 } 691 692 692 693 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 694 + l := s.logger.With("handler", "PullComment") 693 695 user := s.oauth.GetUser(r) 694 696 f, err := s.repoResolver.Resolve(r) 695 697 if err != nil { ··· 751 753 Rkey: tid.TID(), 752 754 Record: &lexutil.LexiconTypeDecoder{ 753 755 Val: &tangled.RepoPullComment{ 754 - Pull: pull.PullAt().String(), 756 + Pull: pull.AtUri().String(), 755 757 Body: body, 756 758 CreatedAt: createdAt, 757 759 }, ··· 787 789 return 788 790 } 789 791 790 - 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) 791 802 792 803 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 793 804 return ··· 1838 1849 } 1839 1850 defer tx.Rollback() 1840 1851 1841 - pullAt := pull.PullAt() 1852 + pullAt := pull.AtUri() 1842 1853 newRoundNumber := len(pull.Submissions) 1843 1854 newPatch := patch 1844 1855 newSourceRev := sourceRev ··· 2035 2046 } 2036 2047 2037 2048 // resubmit the new pull 2038 - pullAt := op.PullAt() 2049 + pullAt := op.AtUri() 2039 2050 newRoundNumber := len(op.Submissions) 2040 2051 newPatch := np.LatestPatch() 2041 2052 combinedPatch := np.LatestSubmission().Combined ··· 2106 2117 } 2107 2118 2108 2119 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2120 + user := s.oauth.GetUser(r) 2109 2121 f, err := s.repoResolver.Resolve(r) 2110 2122 if err != nil { 2111 2123 log.Println("failed to resolve repo:", err) ··· 2216 2228 2217 2229 // notify about the pull merge 2218 2230 for _, p := range pullsToMerge { 2219 - s.notifier.NewPullState(r.Context(), p) 2231 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2220 2232 } 2221 2233 2222 2234 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) ··· 2288 2300 } 2289 2301 2290 2302 for _, p := range pullsToClose { 2291 - s.notifier.NewPullState(r.Context(), p) 2303 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2292 2304 } 2293 2305 2294 2306 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) ··· 2361 2373 } 2362 2374 2363 2375 for _, p := range pullsToReopen { 2364 - s.notifier.NewPullState(r.Context(), p) 2376 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2365 2377 } 2366 2378 2367 2379 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+49
appview/repo/archive.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + 9 + "tangled.org/core/api/tangled" 10 + xrpcclient "tangled.org/core/appview/xrpcclient" 11 + 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + "github.com/go-chi/chi/v5" 14 + "github.com/go-git/go-git/v5/plumbing" 15 + ) 16 + 17 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "DownloadArchive") 19 + ref := chi.URLParam(r, "ref") 20 + ref, _ = url.PathUnescape(ref) 21 + f, err := rp.repoResolver.Resolve(r) 22 + if err != nil { 23 + l.Error("failed to get repo and knot", "err", err) 24 + return 25 + } 26 + scheme := "http" 27 + if !rp.config.Core.Dev { 28 + scheme = "https" 29 + } 30 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 + xrpcc := &indigoxrpc.Client{ 32 + Host: host, 33 + } 34 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 36 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 + rp.pages.Error503(w) 39 + return 40 + } 41 + // Set headers for file download, just pass along whatever the knot specifies 42 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 43 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 44 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 45 + w.Header().Set("Content-Type", "application/gzip") 46 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 47 + // Write the archive data directly 48 + w.Write(archiveBytes) 49 + }
+291
appview/repo/blob.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/base64" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/pages/markup" 18 + "tangled.org/core/appview/reporesolver" 19 + xrpcclient "tangled.org/core/appview/xrpcclient" 20 + 21 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 + "github.com/go-chi/chi/v5" 23 + ) 24 + 25 + // the content can be one of the following: 26 + // 27 + // - code : text | | raw 28 + // - markup : text | rendered | raw 29 + // - svg : text | rendered | raw 30 + // - png : | rendered | raw 31 + // - video : | rendered | raw 32 + // - submodule : | rendered | 33 + // - rest : | | 34 + func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 35 + l := rp.logger.With("handler", "RepoBlob") 36 + 37 + f, err := rp.repoResolver.Resolve(r) 38 + if err != nil { 39 + l.Error("failed to get repo and knot", "err", err) 40 + return 41 + } 42 + 43 + ref := chi.URLParam(r, "ref") 44 + ref, _ = url.PathUnescape(ref) 45 + 46 + filePath := chi.URLParam(r, "*") 47 + filePath, _ = url.PathUnescape(filePath) 48 + 49 + scheme := "http" 50 + if !rp.config.Core.Dev { 51 + scheme = "https" 52 + } 53 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 54 + xrpcc := &indigoxrpc.Client{ 55 + Host: host, 56 + } 57 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 58 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 59 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 61 + rp.pages.Error503(w) 62 + return 63 + } 64 + 65 + // Use XRPC response directly instead of converting to internal types 66 + var breadcrumbs [][]string 67 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 68 + if filePath != "" { 69 + for idx, elem := range strings.Split(filePath, "/") { 70 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 71 + } 72 + } 73 + 74 + // Create the blob view 75 + blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 76 + 77 + user := rp.oauth.GetUser(r) 78 + 79 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 80 + LoggedInUser: user, 81 + RepoInfo: f.RepoInfo(user), 82 + BreadCrumbs: breadcrumbs, 83 + BlobView: blobView, 84 + RepoBlob_Output: resp, 85 + }) 86 + } 87 + 88 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 89 + l := rp.logger.With("handler", "RepoBlobRaw") 90 + 91 + f, err := rp.repoResolver.Resolve(r) 92 + if err != nil { 93 + l.Error("failed to get repo and knot", "err", err) 94 + w.WriteHeader(http.StatusBadRequest) 95 + return 96 + } 97 + 98 + ref := chi.URLParam(r, "ref") 99 + ref, _ = url.PathUnescape(ref) 100 + 101 + filePath := chi.URLParam(r, "*") 102 + filePath, _ = url.PathUnescape(filePath) 103 + 104 + scheme := "http" 105 + if !rp.config.Core.Dev { 106 + scheme = "https" 107 + } 108 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 109 + baseURL := &url.URL{ 110 + Scheme: scheme, 111 + Host: f.Knot, 112 + Path: "/xrpc/sh.tangled.repo.blob", 113 + } 114 + query := baseURL.Query() 115 + query.Set("repo", repo) 116 + query.Set("ref", ref) 117 + query.Set("path", filePath) 118 + query.Set("raw", "true") 119 + baseURL.RawQuery = query.Encode() 120 + blobURL := baseURL.String() 121 + req, err := http.NewRequest("GET", blobURL, nil) 122 + if err != nil { 123 + l.Error("failed to create request", "err", err) 124 + return 125 + } 126 + 127 + // forward the If-None-Match header 128 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 129 + req.Header.Set("If-None-Match", clientETag) 130 + } 131 + client := &http.Client{} 132 + 133 + resp, err := client.Do(req) 134 + if err != nil { 135 + l.Error("failed to reach knotserver", "err", err) 136 + rp.pages.Error503(w) 137 + return 138 + } 139 + 140 + defer resp.Body.Close() 141 + 142 + // forward 304 not modified 143 + if resp.StatusCode == http.StatusNotModified { 144 + w.WriteHeader(http.StatusNotModified) 145 + return 146 + } 147 + 148 + if resp.StatusCode != http.StatusOK { 149 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 150 + w.WriteHeader(resp.StatusCode) 151 + _, _ = io.Copy(w, resp.Body) 152 + return 153 + } 154 + 155 + contentType := resp.Header.Get("Content-Type") 156 + body, err := io.ReadAll(resp.Body) 157 + if err != nil { 158 + l.Error("error reading response body from knotserver", "err", err) 159 + w.WriteHeader(http.StatusInternalServerError) 160 + return 161 + } 162 + 163 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 164 + // serve all textual content as text/plain 165 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 166 + w.Write(body) 167 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 168 + // serve images and videos with their original content type 169 + w.Header().Set("Content-Type", contentType) 170 + w.Write(body) 171 + } else { 172 + w.WriteHeader(http.StatusUnsupportedMediaType) 173 + w.Write([]byte("unsupported content type")) 174 + return 175 + } 176 + } 177 + 178 + // NewBlobView creates a BlobView from the XRPC response 179 + func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView { 180 + view := models.BlobView{ 181 + Contents: "", 182 + Lines: 0, 183 + } 184 + 185 + // Set size 186 + if resp.Size != nil { 187 + view.SizeHint = uint64(*resp.Size) 188 + } else if resp.Content != nil { 189 + view.SizeHint = uint64(len(*resp.Content)) 190 + } 191 + 192 + if resp.Submodule != nil { 193 + view.ContentType = models.BlobContentTypeSubmodule 194 + view.HasRenderedView = true 195 + view.ContentSrc = resp.Submodule.Url 196 + return view 197 + } 198 + 199 + // Determine if binary 200 + if resp.IsBinary != nil && *resp.IsBinary { 201 + view.ContentSrc = generateBlobURL(config, f, ref, filePath) 202 + ext := strings.ToLower(filepath.Ext(resp.Path)) 203 + 204 + switch ext { 205 + case ".jpg", ".jpeg", ".png", ".gif", ".webp": 206 + view.ContentType = models.BlobContentTypeImage 207 + view.HasRawView = true 208 + view.HasRenderedView = true 209 + view.ShowingRendered = true 210 + 211 + case ".svg": 212 + view.ContentType = models.BlobContentTypeSvg 213 + view.HasRawView = true 214 + view.HasTextView = true 215 + view.HasRenderedView = true 216 + view.ShowingRendered = queryParams.Get("code") != "true" 217 + if resp.Content != nil { 218 + bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 219 + view.Contents = string(bytes) 220 + view.Lines = strings.Count(view.Contents, "\n") + 1 221 + } 222 + 223 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 224 + view.ContentType = models.BlobContentTypeVideo 225 + view.HasRawView = true 226 + view.HasRenderedView = true 227 + view.ShowingRendered = true 228 + } 229 + 230 + return view 231 + } 232 + 233 + // otherwise, we are dealing with text content 234 + view.HasRawView = true 235 + view.HasTextView = true 236 + 237 + if resp.Content != nil { 238 + view.Contents = *resp.Content 239 + view.Lines = strings.Count(view.Contents, "\n") + 1 240 + } 241 + 242 + // with text, we may be dealing with markdown 243 + format := markup.GetFormat(resp.Path) 244 + if format == markup.FormatMarkdown { 245 + view.ContentType = models.BlobContentTypeMarkup 246 + view.HasRenderedView = true 247 + view.ShowingRendered = queryParams.Get("code") != "true" 248 + } 249 + 250 + return view 251 + } 252 + 253 + func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string { 254 + scheme := "http" 255 + if !config.Core.Dev { 256 + scheme = "https" 257 + } 258 + 259 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 260 + baseURL := &url.URL{ 261 + Scheme: scheme, 262 + Host: f.Knot, 263 + Path: "/xrpc/sh.tangled.repo.blob", 264 + } 265 + query := baseURL.Query() 266 + query.Set("repo", repoName) 267 + query.Set("ref", ref) 268 + query.Set("path", filePath) 269 + query.Set("raw", "true") 270 + baseURL.RawQuery = query.Encode() 271 + blobURL := baseURL.String() 272 + 273 + if !config.Core.Dev { 274 + return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 275 + } 276 + return blobURL 277 + } 278 + 279 + func isTextualMimeType(mimeType string) bool { 280 + textualTypes := []string{ 281 + "application/json", 282 + "application/xml", 283 + "application/yaml", 284 + "application/x-yaml", 285 + "application/toml", 286 + "application/javascript", 287 + "application/ecmascript", 288 + "message/", 289 + } 290 + return slices.Contains(textualTypes, mimeType) 291 + }
+95
appview/repo/branches.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/oauth" 10 + "tangled.org/core/appview/pages" 11 + xrpcclient "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/types" 13 + 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 + ) 16 + 17 + func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "RepoBranches") 19 + f, err := rp.repoResolver.Resolve(r) 20 + if err != nil { 21 + l.Error("failed to get repo and knot", "err", err) 22 + return 23 + } 24 + scheme := "http" 25 + if !rp.config.Core.Dev { 26 + scheme = "https" 27 + } 28 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 29 + xrpcc := &indigoxrpc.Client{ 30 + Host: host, 31 + } 32 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 33 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 34 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 35 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 36 + rp.pages.Error503(w) 37 + return 38 + } 39 + var result types.RepoBranchesResponse 40 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 41 + l.Error("failed to decode XRPC response", "err", err) 42 + rp.pages.Error503(w) 43 + return 44 + } 45 + sortBranches(result.Branches) 46 + user := rp.oauth.GetUser(r) 47 + rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 + LoggedInUser: user, 49 + RepoInfo: f.RepoInfo(user), 50 + RepoBranchesResponse: result, 51 + }) 52 + } 53 + 54 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 55 + l := rp.logger.With("handler", "DeleteBranch") 56 + f, err := rp.repoResolver.Resolve(r) 57 + if err != nil { 58 + l.Error("failed to get repo and knot", "err", err) 59 + return 60 + } 61 + noticeId := "delete-branch-error" 62 + fail := func(msg string, err error) { 63 + l.Error(msg, "err", err) 64 + rp.pages.Notice(w, noticeId, msg) 65 + } 66 + branch := r.FormValue("branch") 67 + if branch == "" { 68 + fail("No branch provided.", nil) 69 + return 70 + } 71 + client, err := rp.oauth.ServiceClient( 72 + r, 73 + oauth.WithService(f.Knot), 74 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 75 + oauth.WithDev(rp.config.Core.Dev), 76 + ) 77 + if err != nil { 78 + fail("Failed to connect to knotserver", nil) 79 + return 80 + } 81 + err = tangled.RepoDeleteBranch( 82 + r.Context(), 83 + client, 84 + &tangled.RepoDeleteBranch_Input{ 85 + Branch: branch, 86 + Repo: f.RepoAt().String(), 87 + }, 88 + ) 89 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 90 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 91 + return 92 + } 93 + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 94 + rp.pages.HxRefresh(w) 95 + }
+214
appview/repo/compare.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/patchutil" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 18 + ) 19 + 20 + func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoCompareNew") 22 + 23 + user := rp.oauth.GetUser(r) 24 + f, err := rp.repoResolver.Resolve(r) 25 + if err != nil { 26 + l.Error("failed to get repo and knot", "err", err) 27 + return 28 + } 29 + 30 + scheme := "http" 31 + if !rp.config.Core.Dev { 32 + scheme = "https" 33 + } 34 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 35 + xrpcc := &indigoxrpc.Client{ 36 + Host: host, 37 + } 38 + 39 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 40 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 41 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 42 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 43 + rp.pages.Error503(w) 44 + return 45 + } 46 + 47 + var branchResult types.RepoBranchesResponse 48 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 49 + l.Error("failed to decode XRPC branches response", "err", err) 50 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 51 + return 52 + } 53 + branches := branchResult.Branches 54 + 55 + sortBranches(branches) 56 + 57 + var defaultBranch string 58 + for _, b := range branches { 59 + if b.IsDefault { 60 + defaultBranch = b.Name 61 + } 62 + } 63 + 64 + base := defaultBranch 65 + head := defaultBranch 66 + 67 + params := r.URL.Query() 68 + queryBase := params.Get("base") 69 + queryHead := params.Get("head") 70 + if queryBase != "" { 71 + base = queryBase 72 + } 73 + if queryHead != "" { 74 + head = queryHead 75 + } 76 + 77 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 78 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 79 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 80 + rp.pages.Error503(w) 81 + return 82 + } 83 + 84 + var tags types.RepoTagsResponse 85 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 86 + l.Error("failed to decode XRPC tags response", "err", err) 87 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 88 + return 89 + } 90 + 91 + repoinfo := f.RepoInfo(user) 92 + 93 + rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 94 + LoggedInUser: user, 95 + RepoInfo: repoinfo, 96 + Branches: branches, 97 + Tags: tags.Tags, 98 + Base: base, 99 + Head: head, 100 + }) 101 + } 102 + 103 + func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 104 + l := rp.logger.With("handler", "RepoCompare") 105 + 106 + user := rp.oauth.GetUser(r) 107 + f, err := rp.repoResolver.Resolve(r) 108 + if err != nil { 109 + l.Error("failed to get repo and knot", "err", err) 110 + return 111 + } 112 + 113 + var diffOpts types.DiffOpts 114 + if d := r.URL.Query().Get("diff"); d == "split" { 115 + diffOpts.Split = true 116 + } 117 + 118 + // if user is navigating to one of 119 + // /compare/{base}/{head} 120 + // /compare/{base}...{head} 121 + base := chi.URLParam(r, "base") 122 + head := chi.URLParam(r, "head") 123 + if base == "" && head == "" { 124 + rest := chi.URLParam(r, "*") // master...feature/xyz 125 + parts := strings.SplitN(rest, "...", 2) 126 + if len(parts) == 2 { 127 + base = parts[0] 128 + head = parts[1] 129 + } 130 + } 131 + 132 + base, _ = url.PathUnescape(base) 133 + head, _ = url.PathUnescape(head) 134 + 135 + if base == "" || head == "" { 136 + l.Error("invalid comparison") 137 + rp.pages.Error404(w) 138 + return 139 + } 140 + 141 + scheme := "http" 142 + if !rp.config.Core.Dev { 143 + scheme = "https" 144 + } 145 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 146 + xrpcc := &indigoxrpc.Client{ 147 + Host: host, 148 + } 149 + 150 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 151 + 152 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 153 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 154 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 155 + rp.pages.Error503(w) 156 + return 157 + } 158 + 159 + var branches types.RepoBranchesResponse 160 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 161 + l.Error("failed to decode XRPC branches response", "err", err) 162 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 163 + return 164 + } 165 + 166 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 167 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 169 + rp.pages.Error503(w) 170 + return 171 + } 172 + 173 + var tags types.RepoTagsResponse 174 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 175 + l.Error("failed to decode XRPC tags response", "err", err) 176 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 177 + return 178 + } 179 + 180 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 181 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 183 + rp.pages.Error503(w) 184 + return 185 + } 186 + 187 + var formatPatch types.RepoFormatPatchResponse 188 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 189 + l.Error("failed to decode XRPC compare response", "err", err) 190 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 191 + return 192 + } 193 + 194 + var diff types.NiceDiff 195 + if formatPatch.CombinedPatchRaw != "" { 196 + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 197 + } else { 198 + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 199 + } 200 + 201 + repoinfo := f.RepoInfo(user) 202 + 203 + rp.pages.RepoCompare(w, pages.RepoCompareParams{ 204 + LoggedInUser: user, 205 + RepoInfo: repoinfo, 206 + Branches: branches.Branches, 207 + Tags: tags.Tags, 208 + Base: base, 209 + Head: head, 210 + Diff: &diff, 211 + DiffOpts: diffOpts, 212 + }) 213 + 214 + }
+1 -1
appview/repo/feed.go
··· 146 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)
+5 -6
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") ··· 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 + }
+5 -5
appview/repo/opengraph.go
··· 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+5, 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+5, 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+5, 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 -1378
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 - hash := tag.Hash 196 - if tag.Tag != nil { 197 - hash = tag.Tag.Target.String() 198 - } 199 - tagMap[hash] = append(tagMap[hash], tag.Name) 200 - } 201 - } 202 - } 203 - 204 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 205 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 206 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 207 - rp.pages.Error503(w) 208 - return 209 - } 210 - 211 - if branchBytes != nil { 212 - var branchResp types.RepoBranchesResponse 213 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 214 - for _, branch := range branchResp.Branches { 215 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 216 - } 217 - } 218 - } 219 - 220 - user := rp.oauth.GetUser(r) 221 - 222 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 223 - if err != nil { 224 - l.Error("failed to fetch email to did mapping", "err", err) 225 - } 226 - 227 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 228 - if err != nil { 229 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 230 - } 231 - 232 - repoInfo := f.RepoInfo(user) 233 - 234 - var shas []string 235 - for _, c := range xrpcResp.Commits { 236 - shas = append(shas, c.Hash.String()) 237 - } 238 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 239 - if err != nil { 240 - l.Error("failed to getPipelineStatuses", "err", err) 241 - // non-fatal 242 - } 243 - 244 - rp.pages.RepoLog(w, pages.RepoLogParams{ 245 - LoggedInUser: user, 246 - TagMap: tagMap, 247 - RepoInfo: repoInfo, 248 - RepoLogResponse: xrpcResp, 249 - EmailToDid: emailToDidMap, 250 - VerifiedCommits: vc, 251 - Pipelines: pipelines, 252 - }) 253 - } 254 - 255 - func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 256 - l := rp.logger.With("handler", "RepoDescriptionEdit") 257 - 258 - f, err := rp.repoResolver.Resolve(r) 259 - if err != nil { 260 - l.Error("failed to get repo and knot", "err", err) 261 - w.WriteHeader(http.StatusBadRequest) 262 - return 263 - } 264 - 265 - user := rp.oauth.GetUser(r) 266 - rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 267 - RepoInfo: f.RepoInfo(user), 268 - }) 269 - } 270 - 271 - func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 272 - l := rp.logger.With("handler", "RepoDescription") 273 - 274 - f, err := rp.repoResolver.Resolve(r) 275 - if err != nil { 276 - l.Error("failed to get repo and knot", "err", err) 277 - w.WriteHeader(http.StatusBadRequest) 278 - return 279 - } 280 - 281 - repoAt := f.RepoAt() 282 - rkey := repoAt.RecordKey().String() 283 - if rkey == "" { 284 - l.Error("invalid aturi for repo", "err", err) 285 - w.WriteHeader(http.StatusInternalServerError) 286 - return 287 - } 288 - 289 - user := rp.oauth.GetUser(r) 290 - 291 - switch r.Method { 292 - case http.MethodGet: 293 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 294 - RepoInfo: f.RepoInfo(user), 295 - }) 296 - return 297 - case http.MethodPut: 298 - newDescription := r.FormValue("description") 299 - client, err := rp.oauth.AuthorizedClient(r) 300 - if err != nil { 301 - l.Error("failed to get client") 302 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 303 - return 304 - } 305 - 306 - // optimistic update 307 - err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 308 - if err != nil { 309 - l.Error("failed to perform update-description query", "err", err) 310 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 311 - return 312 - } 313 - 314 - newRepo := f.Repo 315 - newRepo.Description = newDescription 316 - record := newRepo.AsRecord() 317 - 318 - // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 319 - // 320 - // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 321 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 322 - if err != nil { 323 - // failed to get record 324 - rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 325 - return 326 - } 327 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 328 - Collection: tangled.RepoNSID, 329 - Repo: newRepo.Did, 330 - Rkey: newRepo.Rkey, 331 - SwapRecord: ex.Cid, 332 - Record: &lexutil.LexiconTypeDecoder{ 333 - Val: &record, 334 - }, 335 - }) 336 - 337 - if err != nil { 338 - l.Error("failed to perferom update-description query", "err", err) 339 - // failed to get record 340 - rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 341 - return 342 - } 343 - 344 - newRepoInfo := f.RepoInfo(user) 345 - newRepoInfo.Description = newDescription 346 - 347 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 348 - RepoInfo: newRepoInfo, 349 - }) 350 - return 351 - } 352 - } 353 - 354 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 355 - l := rp.logger.With("handler", "RepoCommit") 356 - 357 - f, err := rp.repoResolver.Resolve(r) 358 - if err != nil { 359 - l.Error("failed to fully resolve repo", "err", err) 360 - return 361 - } 362 - ref := chi.URLParam(r, "ref") 363 - ref, _ = url.PathUnescape(ref) 364 - 365 - var diffOpts types.DiffOpts 366 - if d := r.URL.Query().Get("diff"); d == "split" { 367 - diffOpts.Split = true 368 - } 369 - 370 - if !plumbing.IsHash(ref) { 371 - rp.pages.Error404(w) 372 - return 373 - } 374 - 375 - scheme := "http" 376 - if !rp.config.Core.Dev { 377 - scheme = "https" 378 - } 379 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 380 - xrpcc := &indigoxrpc.Client{ 381 - Host: host, 382 - } 383 - 384 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 385 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 386 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 387 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 388 - rp.pages.Error503(w) 389 - return 390 - } 391 - 392 - var result types.RepoCommitResponse 393 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 394 - l.Error("failed to decode XRPC response", "err", err) 395 - rp.pages.Error503(w) 396 - return 397 - } 398 - 399 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 400 - if err != nil { 401 - l.Error("failed to get email to did mapping", "err", err) 402 - } 403 - 404 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 405 - if err != nil { 406 - l.Error("failed to GetVerifiedCommits", "err", err) 407 - } 408 - 409 - user := rp.oauth.GetUser(r) 410 - repoInfo := f.RepoInfo(user) 411 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 412 - if err != nil { 413 - l.Error("failed to getPipelineStatuses", "err", err) 414 - // non-fatal 415 - } 416 - var pipeline *models.Pipeline 417 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 418 - pipeline = &p 419 - } 420 - 421 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 422 - LoggedInUser: user, 423 - RepoInfo: f.RepoInfo(user), 424 - RepoCommitResponse: result, 425 - EmailToDid: emailToDidMap, 426 - VerifiedCommit: vc, 427 - Pipeline: pipeline, 428 - DiffOpts: diffOpts, 429 - }) 430 - } 431 - 432 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 433 - l := rp.logger.With("handler", "RepoTree") 434 - 435 - f, err := rp.repoResolver.Resolve(r) 436 - if err != nil { 437 - l.Error("failed to fully resolve repo", "err", err) 438 - return 439 - } 440 - 441 - ref := chi.URLParam(r, "ref") 442 - ref, _ = url.PathUnescape(ref) 443 - 444 - // if the tree path has a trailing slash, let's strip it 445 - // so we don't 404 446 - treePath := chi.URLParam(r, "*") 447 - treePath, _ = url.PathUnescape(treePath) 448 - treePath = strings.TrimSuffix(treePath, "/") 449 - 450 - scheme := "http" 451 - if !rp.config.Core.Dev { 452 - scheme = "https" 453 - } 454 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 455 - xrpcc := &indigoxrpc.Client{ 456 - Host: host, 457 - } 458 - 459 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 460 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 461 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 462 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 463 - rp.pages.Error503(w) 464 - return 465 - } 466 - 467 - // Convert XRPC response to internal types.RepoTreeResponse 468 - files := make([]types.NiceTree, len(xrpcResp.Files)) 469 - for i, xrpcFile := range xrpcResp.Files { 470 - file := types.NiceTree{ 471 - Name: xrpcFile.Name, 472 - Mode: xrpcFile.Mode, 473 - Size: int64(xrpcFile.Size), 474 - IsFile: xrpcFile.Is_file, 475 - IsSubtree: xrpcFile.Is_subtree, 476 - } 477 - 478 - // Convert last commit info if present 479 - if xrpcFile.Last_commit != nil { 480 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 481 - file.LastCommit = &types.LastCommitInfo{ 482 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 483 - Message: xrpcFile.Last_commit.Message, 484 - When: commitWhen, 485 - } 486 - } 487 - 488 - files[i] = file 489 - } 490 - 491 - result := types.RepoTreeResponse{ 492 - Ref: xrpcResp.Ref, 493 - Files: files, 494 - } 495 - 496 - if xrpcResp.Parent != nil { 497 - result.Parent = *xrpcResp.Parent 498 - } 499 - if xrpcResp.Dotdot != nil { 500 - result.DotDot = *xrpcResp.Dotdot 501 - } 502 - if xrpcResp.Readme != nil { 503 - result.ReadmeFileName = xrpcResp.Readme.Filename 504 - result.Readme = xrpcResp.Readme.Contents 505 - } 506 - 507 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 508 - // so we can safely redirect to the "parent" (which is the same file). 509 - if len(result.Files) == 0 && result.Parent == treePath { 510 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 511 - http.Redirect(w, r, redirectTo, http.StatusFound) 512 - return 513 - } 514 - 515 - user := rp.oauth.GetUser(r) 516 - 517 - var breadcrumbs [][]string 518 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 519 - if treePath != "" { 520 - for idx, elem := range strings.Split(treePath, "/") { 521 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 522 - } 523 - } 524 - 525 - sortFiles(result.Files) 526 - 527 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 528 - LoggedInUser: user, 529 - BreadCrumbs: breadcrumbs, 530 - TreePath: treePath, 531 - RepoInfo: f.RepoInfo(user), 532 - RepoTreeResponse: result, 533 - }) 534 - } 535 - 536 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 537 - l := rp.logger.With("handler", "RepoTags") 538 - 539 - f, err := rp.repoResolver.Resolve(r) 540 - if err != nil { 541 - l.Error("failed to get repo and knot", "err", err) 542 - return 543 - } 544 - 545 - scheme := "http" 546 - if !rp.config.Core.Dev { 547 - scheme = "https" 548 - } 549 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 550 - xrpcc := &indigoxrpc.Client{ 551 - Host: host, 552 - } 553 - 554 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 555 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 556 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 557 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 558 - rp.pages.Error503(w) 559 - return 560 - } 561 - 562 - var result types.RepoTagsResponse 563 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 564 - l.Error("failed to decode XRPC response", "err", err) 565 - rp.pages.Error503(w) 566 - return 567 - } 568 - 569 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 570 - if err != nil { 571 - l.Error("failed grab artifacts", "err", err) 572 - return 573 - } 574 - 575 - // convert artifacts to map for easy UI building 576 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 577 - for _, a := range artifacts { 578 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 579 - } 580 - 581 - var danglingArtifacts []models.Artifact 582 - for _, a := range artifacts { 583 - found := false 584 - for _, t := range result.Tags { 585 - if t.Tag != nil { 586 - if t.Tag.Hash == a.Tag { 587 - found = true 588 - } 589 - } 590 - } 591 - 592 - if !found { 593 - danglingArtifacts = append(danglingArtifacts, a) 594 - } 595 - } 596 - 597 - user := rp.oauth.GetUser(r) 598 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 599 - LoggedInUser: user, 600 - RepoInfo: f.RepoInfo(user), 601 - RepoTagsResponse: result, 602 - ArtifactMap: artifactMap, 603 - DanglingArtifacts: danglingArtifacts, 604 - }) 605 - } 606 - 607 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 608 - l := rp.logger.With("handler", "RepoBranches") 609 - 610 - f, err := rp.repoResolver.Resolve(r) 611 - if err != nil { 612 - l.Error("failed to get repo and knot", "err", err) 613 - return 614 - } 615 - 616 - scheme := "http" 617 - if !rp.config.Core.Dev { 618 - scheme = "https" 619 - } 620 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 621 - xrpcc := &indigoxrpc.Client{ 622 - Host: host, 623 - } 624 - 625 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 626 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 627 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 628 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 629 - rp.pages.Error503(w) 630 - return 631 - } 632 - 633 - var result types.RepoBranchesResponse 634 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 635 - l.Error("failed to decode XRPC response", "err", err) 636 - rp.pages.Error503(w) 637 - return 638 - } 639 - 640 - sortBranches(result.Branches) 641 - 642 - user := rp.oauth.GetUser(r) 643 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 644 - LoggedInUser: user, 645 - RepoInfo: f.RepoInfo(user), 646 - RepoBranchesResponse: result, 647 - }) 648 - } 649 - 650 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 651 - l := rp.logger.With("handler", "DeleteBranch") 652 - 653 - f, err := rp.repoResolver.Resolve(r) 654 - if err != nil { 655 - l.Error("failed to get repo and knot", "err", err) 656 - return 657 - } 658 - 659 - noticeId := "delete-branch-error" 660 - fail := func(msg string, err error) { 661 - l.Error(msg, "err", err) 662 - rp.pages.Notice(w, noticeId, msg) 663 - } 664 - 665 - branch := r.FormValue("branch") 666 - if branch == "" { 667 - fail("No branch provided.", nil) 668 - return 669 - } 670 - 671 - client, err := rp.oauth.ServiceClient( 672 - r, 673 - oauth.WithService(f.Knot), 674 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 675 - oauth.WithDev(rp.config.Core.Dev), 676 - ) 677 - if err != nil { 678 - fail("Failed to connect to knotserver", nil) 679 - return 680 - } 681 - 682 - err = tangled.RepoDeleteBranch( 683 - r.Context(), 684 - client, 685 - &tangled.RepoDeleteBranch_Input{ 686 - Branch: branch, 687 - Repo: f.RepoAt().String(), 688 - }, 689 - ) 690 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 691 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 692 - return 693 - } 694 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 695 - 696 - rp.pages.HxRefresh(w) 697 - } 698 - 699 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 - l := rp.logger.With("handler", "RepoBlob") 701 - 702 - f, err := rp.repoResolver.Resolve(r) 703 - if err != nil { 704 - l.Error("failed to get repo and knot", "err", err) 705 - return 706 - } 707 - 708 - ref := chi.URLParam(r, "ref") 709 - ref, _ = url.PathUnescape(ref) 710 - 711 - filePath := chi.URLParam(r, "*") 712 - filePath, _ = url.PathUnescape(filePath) 713 - 714 - scheme := "http" 715 - if !rp.config.Core.Dev { 716 - scheme = "https" 717 - } 718 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 719 - xrpcc := &indigoxrpc.Client{ 720 - Host: host, 721 - } 722 - 723 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 724 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 725 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 726 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 727 - rp.pages.Error503(w) 728 - return 729 - } 730 - 731 - // Use XRPC response directly instead of converting to internal types 732 - 733 - var breadcrumbs [][]string 734 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 735 - if filePath != "" { 736 - for idx, elem := range strings.Split(filePath, "/") { 737 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 738 - } 739 - } 740 - 741 - showRendered := false 742 - renderToggle := false 743 - 744 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 745 - renderToggle = true 746 - showRendered = r.URL.Query().Get("code") != "true" 747 - } 748 - 749 - var unsupported bool 750 - var isImage bool 751 - var isVideo bool 752 - var contentSrc string 753 - 754 - if resp.IsBinary != nil && *resp.IsBinary { 755 - ext := strings.ToLower(filepath.Ext(resp.Path)) 756 - switch ext { 757 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 758 - isImage = true 759 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 760 - isVideo = true 761 - default: 762 - unsupported = true 763 - } 764 - 765 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 766 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 767 - 768 - baseURL := &url.URL{ 769 - Scheme: scheme, 770 - Host: f.Knot, 771 - Path: "/xrpc/sh.tangled.repo.blob", 772 - } 773 - query := baseURL.Query() 774 - query.Set("repo", repoName) 775 - query.Set("ref", ref) 776 - query.Set("path", filePath) 777 - query.Set("raw", "true") 778 - baseURL.RawQuery = query.Encode() 779 - blobURL := baseURL.String() 780 - 781 - contentSrc = blobURL 782 - if !rp.config.Core.Dev { 783 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 784 - } 785 - } 786 - 787 - lines := 0 788 - if resp.IsBinary == nil || !*resp.IsBinary { 789 - lines = strings.Count(resp.Content, "\n") + 1 790 - } 791 - 792 - var sizeHint uint64 793 - if resp.Size != nil { 794 - sizeHint = uint64(*resp.Size) 795 - } else { 796 - sizeHint = uint64(len(resp.Content)) 797 - } 798 - 799 - user := rp.oauth.GetUser(r) 800 - 801 - // Determine if content is binary (dereference pointer) 802 - isBinary := false 803 - if resp.IsBinary != nil { 804 - isBinary = *resp.IsBinary 805 - } 806 - 807 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 808 - LoggedInUser: user, 809 - RepoInfo: f.RepoInfo(user), 810 - BreadCrumbs: breadcrumbs, 811 - ShowRendered: showRendered, 812 - RenderToggle: renderToggle, 813 - Unsupported: unsupported, 814 - IsImage: isImage, 815 - IsVideo: isVideo, 816 - ContentSrc: contentSrc, 817 - RepoBlob_Output: resp, 818 - Contents: resp.Content, 819 - Lines: lines, 820 - SizeHint: sizeHint, 821 - IsBinary: isBinary, 822 - }) 823 - } 824 - 825 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 - l := rp.logger.With("handler", "RepoBlobRaw") 827 - 828 - f, err := rp.repoResolver.Resolve(r) 829 - if err != nil { 830 - l.Error("failed to get repo and knot", "err", err) 831 - w.WriteHeader(http.StatusBadRequest) 832 - return 833 - } 834 - 835 - ref := chi.URLParam(r, "ref") 836 - ref, _ = url.PathUnescape(ref) 837 - 838 - filePath := chi.URLParam(r, "*") 839 - filePath, _ = url.PathUnescape(filePath) 840 - 841 - scheme := "http" 842 - if !rp.config.Core.Dev { 843 - scheme = "https" 844 - } 845 - 846 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 847 - baseURL := &url.URL{ 848 - Scheme: scheme, 849 - Host: f.Knot, 850 - Path: "/xrpc/sh.tangled.repo.blob", 851 - } 852 - query := baseURL.Query() 853 - query.Set("repo", repo) 854 - query.Set("ref", ref) 855 - query.Set("path", filePath) 856 - query.Set("raw", "true") 857 - baseURL.RawQuery = query.Encode() 858 - blobURL := baseURL.String() 859 - 860 - req, err := http.NewRequest("GET", blobURL, nil) 861 - if err != nil { 862 - l.Error("failed to create request", "err", err) 863 - return 864 - } 865 - 866 - // forward the If-None-Match header 867 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 868 - req.Header.Set("If-None-Match", clientETag) 869 - } 870 - 871 - client := &http.Client{} 872 - resp, err := client.Do(req) 873 - if err != nil { 874 - l.Error("failed to reach knotserver", "err", err) 875 - rp.pages.Error503(w) 876 - return 877 - } 878 - defer resp.Body.Close() 879 - 880 - // forward 304 not modified 881 - if resp.StatusCode == http.StatusNotModified { 882 - w.WriteHeader(http.StatusNotModified) 883 - return 884 - } 885 - 886 - if resp.StatusCode != http.StatusOK { 887 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 888 - w.WriteHeader(resp.StatusCode) 889 - _, _ = io.Copy(w, resp.Body) 890 - return 891 - } 892 - 893 - contentType := resp.Header.Get("Content-Type") 894 - body, err := io.ReadAll(resp.Body) 895 - if err != nil { 896 - l.Error("error reading response body from knotserver", "err", err) 897 - w.WriteHeader(http.StatusInternalServerError) 898 - return 899 - } 900 - 901 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 902 - // serve all textual content as text/plain 903 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 904 - w.Write(body) 905 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 906 - // serve images and videos with their original content type 907 - w.Header().Set("Content-Type", contentType) 908 - w.Write(body) 909 - } else { 910 - w.WriteHeader(http.StatusUnsupportedMediaType) 911 - w.Write([]byte("unsupported content type")) 912 - return 913 - } 914 - } 915 - 916 - // isTextualMimeType returns true if the MIME type represents textual content 917 - // that should be served as text/plain 918 - func isTextualMimeType(mimeType string) bool { 919 - textualTypes := []string{ 920 - "application/json", 921 - "application/xml", 922 - "application/yaml", 923 - "application/x-yaml", 924 - "application/toml", 925 - "application/javascript", 926 - "application/ecmascript", 927 - "message/", 928 - } 929 - 930 - return slices.Contains(textualTypes, mimeType) 931 - } 932 - 933 81 // modify the spindle configured for this repo 934 82 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 935 83 user := rp.oauth.GetUser(r) ··· 1785 933 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1786 934 } 1787 935 1788 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 - l := rp.logger.With("handler", "SetDefaultBranch") 1790 - 1791 - f, err := rp.repoResolver.Resolve(r) 1792 - if err != nil { 1793 - l.Error("failed to get repo and knot", "err", err) 1794 - return 1795 - } 1796 - 1797 - noticeId := "operation-error" 1798 - branch := r.FormValue("branch") 1799 - if branch == "" { 1800 - http.Error(w, "malformed form", http.StatusBadRequest) 1801 - return 1802 - } 1803 - 1804 - client, err := rp.oauth.ServiceClient( 1805 - r, 1806 - oauth.WithService(f.Knot), 1807 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1808 - oauth.WithDev(rp.config.Core.Dev), 1809 - ) 1810 - if err != nil { 1811 - l.Error("failed to connect to knot server", "err", err) 1812 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1813 - return 1814 - } 1815 - 1816 - xe := tangled.RepoSetDefaultBranch( 1817 - r.Context(), 1818 - client, 1819 - &tangled.RepoSetDefaultBranch_Input{ 1820 - Repo: f.RepoAt().String(), 1821 - DefaultBranch: branch, 1822 - }, 1823 - ) 1824 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1825 - l.Error("xrpc failed", "err", xe) 1826 - rp.pages.Notice(w, noticeId, err.Error()) 1827 - return 1828 - } 1829 - 1830 - rp.pages.HxRefresh(w) 1831 - } 1832 - 1833 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1834 - user := rp.oauth.GetUser(r) 1835 - l := rp.logger.With("handler", "Secrets") 1836 - l = l.With("did", user.Did) 1837 - 1838 - f, err := rp.repoResolver.Resolve(r) 1839 - if err != nil { 1840 - l.Error("failed to get repo and knot", "err", err) 1841 - return 1842 - } 1843 - 1844 - if f.Spindle == "" { 1845 - l.Error("empty spindle cannot add/rm secret", "err", err) 1846 - return 1847 - } 1848 - 1849 - lxm := tangled.RepoAddSecretNSID 1850 - if r.Method == http.MethodDelete { 1851 - lxm = tangled.RepoRemoveSecretNSID 1852 - } 1853 - 1854 - spindleClient, err := rp.oauth.ServiceClient( 1855 - r, 1856 - oauth.WithService(f.Spindle), 1857 - oauth.WithLxm(lxm), 1858 - oauth.WithExp(60), 1859 - oauth.WithDev(rp.config.Core.Dev), 1860 - ) 1861 - if err != nil { 1862 - l.Error("failed to create spindle client", "err", err) 1863 - return 1864 - } 1865 - 1866 - key := r.FormValue("key") 1867 - if key == "" { 1868 - w.WriteHeader(http.StatusBadRequest) 1869 - return 1870 - } 1871 - 1872 - switch r.Method { 1873 - case http.MethodPut: 1874 - errorId := "add-secret-error" 1875 - 1876 - value := r.FormValue("value") 1877 - if value == "" { 1878 - w.WriteHeader(http.StatusBadRequest) 1879 - return 1880 - } 1881 - 1882 - err = tangled.RepoAddSecret( 1883 - r.Context(), 1884 - spindleClient, 1885 - &tangled.RepoAddSecret_Input{ 1886 - Repo: f.RepoAt().String(), 1887 - Key: key, 1888 - Value: value, 1889 - }, 1890 - ) 1891 - if err != nil { 1892 - l.Error("Failed to add secret.", "err", err) 1893 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1894 - return 1895 - } 1896 - 1897 - case http.MethodDelete: 1898 - errorId := "operation-error" 1899 - 1900 - err = tangled.RepoRemoveSecret( 1901 - r.Context(), 1902 - spindleClient, 1903 - &tangled.RepoRemoveSecret_Input{ 1904 - Repo: f.RepoAt().String(), 1905 - Key: key, 1906 - }, 1907 - ) 1908 - if err != nil { 1909 - l.Error("Failed to delete secret.", "err", err) 1910 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1911 - return 1912 - } 1913 - } 1914 - 1915 - rp.pages.HxRefresh(w) 1916 - } 1917 - 1918 - type tab = map[string]any 1919 - 1920 - var ( 1921 - // would be great to have ordered maps right about now 1922 - settingsTabs []tab = []tab{ 1923 - {"Name": "general", "Icon": "sliders-horizontal"}, 1924 - {"Name": "access", "Icon": "users"}, 1925 - {"Name": "pipelines", "Icon": "layers-2"}, 1926 - } 1927 - ) 1928 - 1929 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1930 - tabVal := r.URL.Query().Get("tab") 1931 - if tabVal == "" { 1932 - tabVal = "general" 1933 - } 1934 - 1935 - switch tabVal { 1936 - case "general": 1937 - rp.generalSettings(w, r) 1938 - 1939 - case "access": 1940 - rp.accessSettings(w, r) 1941 - 1942 - case "pipelines": 1943 - rp.pipelineSettings(w, r) 1944 - } 1945 - } 1946 - 1947 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 - l := rp.logger.With("handler", "generalSettings") 1949 - 1950 - f, err := rp.repoResolver.Resolve(r) 1951 - user := rp.oauth.GetUser(r) 1952 - 1953 - scheme := "http" 1954 - if !rp.config.Core.Dev { 1955 - scheme = "https" 1956 - } 1957 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1958 - xrpcc := &indigoxrpc.Client{ 1959 - Host: host, 1960 - } 1961 - 1962 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1963 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1964 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1965 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1966 - rp.pages.Error503(w) 1967 - return 1968 - } 1969 - 1970 - var result types.RepoBranchesResponse 1971 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1972 - l.Error("failed to decode XRPC response", "err", err) 1973 - rp.pages.Error503(w) 1974 - return 1975 - } 1976 - 1977 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1978 - if err != nil { 1979 - l.Error("failed to fetch labels", "err", err) 1980 - rp.pages.Error503(w) 1981 - return 1982 - } 1983 - 1984 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1985 - if err != nil { 1986 - l.Error("failed to fetch labels", "err", err) 1987 - rp.pages.Error503(w) 1988 - return 1989 - } 1990 - // remove default labels from the labels list, if present 1991 - defaultLabelMap := make(map[string]bool) 1992 - for _, dl := range defaultLabels { 1993 - defaultLabelMap[dl.AtUri().String()] = true 1994 - } 1995 - n := 0 1996 - for _, l := range labels { 1997 - if !defaultLabelMap[l.AtUri().String()] { 1998 - labels[n] = l 1999 - n++ 2000 - } 2001 - } 2002 - labels = labels[:n] 2003 - 2004 - subscribedLabels := make(map[string]struct{}) 2005 - for _, l := range f.Repo.Labels { 2006 - subscribedLabels[l] = struct{}{} 2007 - } 2008 - 2009 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 2010 - // if all default labels are subbed, show the "unsubscribe all" button 2011 - shouldSubscribeAll := false 2012 - for _, dl := range defaultLabels { 2013 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 2014 - // one of the default labels is not subscribed to 2015 - shouldSubscribeAll = true 2016 - break 2017 - } 2018 - } 2019 - 2020 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 2021 - LoggedInUser: user, 2022 - RepoInfo: f.RepoInfo(user), 2023 - Branches: result.Branches, 2024 - Labels: labels, 2025 - DefaultLabels: defaultLabels, 2026 - SubscribedLabels: subscribedLabels, 2027 - ShouldSubscribeAll: shouldSubscribeAll, 2028 - Tabs: settingsTabs, 2029 - Tab: "general", 2030 - }) 2031 - } 2032 - 2033 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 - l := rp.logger.With("handler", "accessSettings") 2035 - 2036 - f, err := rp.repoResolver.Resolve(r) 2037 - user := rp.oauth.GetUser(r) 2038 - 2039 - repoCollaborators, err := f.Collaborators(r.Context()) 2040 - if err != nil { 2041 - l.Error("failed to get collaborators", "err", err) 2042 - } 2043 - 2044 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 2045 - LoggedInUser: user, 2046 - RepoInfo: f.RepoInfo(user), 2047 - Tabs: settingsTabs, 2048 - Tab: "access", 2049 - Collaborators: repoCollaborators, 2050 - }) 2051 - } 2052 - 2053 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 - l := rp.logger.With("handler", "pipelineSettings") 2055 - 2056 - f, err := rp.repoResolver.Resolve(r) 2057 - user := rp.oauth.GetUser(r) 2058 - 2059 - // all spindles that the repo owner is a member of 2060 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2061 - if err != nil { 2062 - l.Error("failed to fetch spindles", "err", err) 2063 - return 2064 - } 2065 - 2066 - var secrets []*tangled.RepoListSecrets_Secret 2067 - if f.Spindle != "" { 2068 - if spindleClient, err := rp.oauth.ServiceClient( 2069 - r, 2070 - oauth.WithService(f.Spindle), 2071 - oauth.WithLxm(tangled.RepoListSecretsNSID), 2072 - oauth.WithExp(60), 2073 - oauth.WithDev(rp.config.Core.Dev), 2074 - ); err != nil { 2075 - l.Error("failed to create spindle client", "err", err) 2076 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2077 - l.Error("failed to fetch secrets", "err", err) 2078 - } else { 2079 - secrets = resp.Secrets 2080 - } 2081 - } 2082 - 2083 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2084 - return strings.Compare(a.Key, b.Key) 2085 - }) 2086 - 2087 - var dids []string 2088 - for _, s := range secrets { 2089 - dids = append(dids, s.CreatedBy) 2090 - } 2091 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2092 - 2093 - // convert to a more manageable form 2094 - var niceSecret []map[string]any 2095 - for id, s := range secrets { 2096 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2097 - niceSecret = append(niceSecret, map[string]any{ 2098 - "Id": id, 2099 - "Key": s.Key, 2100 - "CreatedAt": when, 2101 - "CreatedBy": resolvedIdents[id].Handle.String(), 2102 - }) 2103 - } 2104 - 2105 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2106 - LoggedInUser: user, 2107 - RepoInfo: f.RepoInfo(user), 2108 - Tabs: settingsTabs, 2109 - Tab: "pipelines", 2110 - Spindles: spindles, 2111 - CurrentSpindle: f.Spindle, 2112 - Secrets: niceSecret, 2113 - }) 2114 - } 2115 - 2116 936 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2117 937 l := rp.logger.With("handler", "SyncRepoFork") 2118 938 ··· 2253 1073 Source: sourceAt, 2254 1074 Description: f.Repo.Description, 2255 1075 Created: time.Now(), 2256 - Labels: models.DefaultLabelDefs(), 1076 + Labels: rp.config.Label.DefaultLabelDefs, 2257 1077 } 2258 1078 record := repo.AsRecord() 2259 1079 ··· 2369 1189 aturi = "" 2370 1190 2371 1191 rp.notifier.NewRepo(r.Context(), repo) 2372 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 1192 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 2373 1193 } 2374 1194 } 2375 1195 ··· 2394 1214 }) 2395 1215 return err 2396 1216 } 2397 - 2398 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 - l := rp.logger.With("handler", "RepoCompareNew") 2400 - 2401 - user := rp.oauth.GetUser(r) 2402 - f, err := rp.repoResolver.Resolve(r) 2403 - if err != nil { 2404 - l.Error("failed to get repo and knot", "err", err) 2405 - return 2406 - } 2407 - 2408 - scheme := "http" 2409 - if !rp.config.Core.Dev { 2410 - scheme = "https" 2411 - } 2412 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2413 - xrpcc := &indigoxrpc.Client{ 2414 - Host: host, 2415 - } 2416 - 2417 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2418 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2419 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2420 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2421 - rp.pages.Error503(w) 2422 - return 2423 - } 2424 - 2425 - var branchResult types.RepoBranchesResponse 2426 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2427 - l.Error("failed to decode XRPC branches response", "err", err) 2428 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2429 - return 2430 - } 2431 - branches := branchResult.Branches 2432 - 2433 - sortBranches(branches) 2434 - 2435 - var defaultBranch string 2436 - for _, b := range branches { 2437 - if b.IsDefault { 2438 - defaultBranch = b.Name 2439 - } 2440 - } 2441 - 2442 - base := defaultBranch 2443 - head := defaultBranch 2444 - 2445 - params := r.URL.Query() 2446 - queryBase := params.Get("base") 2447 - queryHead := params.Get("head") 2448 - if queryBase != "" { 2449 - base = queryBase 2450 - } 2451 - if queryHead != "" { 2452 - head = queryHead 2453 - } 2454 - 2455 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2456 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2457 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2458 - rp.pages.Error503(w) 2459 - return 2460 - } 2461 - 2462 - var tags types.RepoTagsResponse 2463 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2464 - l.Error("failed to decode XRPC tags response", "err", err) 2465 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2466 - return 2467 - } 2468 - 2469 - repoinfo := f.RepoInfo(user) 2470 - 2471 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2472 - LoggedInUser: user, 2473 - RepoInfo: repoinfo, 2474 - Branches: branches, 2475 - Tags: tags.Tags, 2476 - Base: base, 2477 - Head: head, 2478 - }) 2479 - } 2480 - 2481 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 - l := rp.logger.With("handler", "RepoCompare") 2483 - 2484 - user := rp.oauth.GetUser(r) 2485 - f, err := rp.repoResolver.Resolve(r) 2486 - if err != nil { 2487 - l.Error("failed to get repo and knot", "err", err) 2488 - return 2489 - } 2490 - 2491 - var diffOpts types.DiffOpts 2492 - if d := r.URL.Query().Get("diff"); d == "split" { 2493 - diffOpts.Split = true 2494 - } 2495 - 2496 - // if user is navigating to one of 2497 - // /compare/{base}/{head} 2498 - // /compare/{base}...{head} 2499 - base := chi.URLParam(r, "base") 2500 - head := chi.URLParam(r, "head") 2501 - if base == "" && head == "" { 2502 - rest := chi.URLParam(r, "*") // master...feature/xyz 2503 - parts := strings.SplitN(rest, "...", 2) 2504 - if len(parts) == 2 { 2505 - base = parts[0] 2506 - head = parts[1] 2507 - } 2508 - } 2509 - 2510 - base, _ = url.PathUnescape(base) 2511 - head, _ = url.PathUnescape(head) 2512 - 2513 - if base == "" || head == "" { 2514 - l.Error("invalid comparison") 2515 - rp.pages.Error404(w) 2516 - return 2517 - } 2518 - 2519 - scheme := "http" 2520 - if !rp.config.Core.Dev { 2521 - scheme = "https" 2522 - } 2523 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2524 - xrpcc := &indigoxrpc.Client{ 2525 - Host: host, 2526 - } 2527 - 2528 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2529 - 2530 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2531 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2532 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2533 - rp.pages.Error503(w) 2534 - return 2535 - } 2536 - 2537 - var branches types.RepoBranchesResponse 2538 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2539 - l.Error("failed to decode XRPC branches response", "err", err) 2540 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2541 - return 2542 - } 2543 - 2544 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2545 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2546 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2547 - rp.pages.Error503(w) 2548 - return 2549 - } 2550 - 2551 - var tags types.RepoTagsResponse 2552 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2553 - l.Error("failed to decode XRPC tags response", "err", err) 2554 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2555 - return 2556 - } 2557 - 2558 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2559 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2560 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2561 - rp.pages.Error503(w) 2562 - return 2563 - } 2564 - 2565 - var formatPatch types.RepoFormatPatchResponse 2566 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2567 - l.Error("failed to decode XRPC compare response", "err", err) 2568 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2569 - return 2570 - } 2571 - 2572 - var diff types.NiceDiff 2573 - if formatPatch.CombinedPatchRaw != "" { 2574 - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2575 - } else { 2576 - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2577 - } 2578 - 2579 - repoinfo := f.RepoInfo(user) 2580 - 2581 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2582 - LoggedInUser: user, 2583 - RepoInfo: repoinfo, 2584 - Branches: branches.Branches, 2585 - Tags: tags.Tags, 2586 - Base: base, 2587 - Head: head, 2588 - Diff: &diff, 2589 - DiffOpts: diffOpts, 2590 - }) 2591 - 2592 - }
+2 -2
appview/repo/repo_util.go
··· 17 17 18 18 func sortFiles(files []types.NiceTree) { 19 19 sort.Slice(files, func(i, j int) bool { 20 - iIsFile := files[i].IsFile 21 - jIsFile := files[j].IsFile 20 + iIsFile := files[i].IsFile() 21 + jIsFile := files[j].IsFile() 22 22 if iIsFile != jIsFile { 23 23 return !iIsFile 24 24 }
+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,
+1
appview/settings/settings.go
··· 120 120 PullCommented: r.FormValue("pull_commented") == "on", 121 121 PullMerged: r.FormValue("pull_merged") == "on", 122 122 Followed: r.FormValue("followed") == "on", 123 + UserMentioned: r.FormValue("user_mentioned") == "on", 123 124 EmailNotifications: r.FormValue("email_notifications") == "on", 124 125 } 125 126
+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.")
+10 -4
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" ··· 20 18 21 19 page := pagination.FromContext(r.Context()) 22 20 23 - goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 21 + goodFirstIssueLabel := s.config.Label.GoodFirstIssue 22 + 23 + gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel)) 24 + if err != nil { 25 + log.Println("failed to get gfi label def", err) 26 + s.pages.Error500(w) 27 + return 28 + } 24 29 25 30 repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 26 31 if err != nil { ··· 35 40 RepoGroups: []*models.RepoGroup{}, 36 41 LabelDefs: make(map[string]*models.LabelDefinition), 37 42 Page: page, 43 + GfiLabel: gfiLabelDef, 38 44 }) 39 45 return 40 46 } ··· 143 149 RepoGroups: paginatedGroups, 144 150 LabelDefs: labelDefsMap, 145 151 Page: page, 146 - GfiLabel: labelDefsMap[goodFirstIssueLabel], 152 + GfiLabel: gfiLabelDef, 147 153 }) 148 154 }
+1
appview/state/login.go
··· 46 46 47 47 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 48 if err != nil { 49 + l.Error("failed to start auth", "err", err) 49 50 http.Error(w, err.Error(), http.StatusInternalServerError) 50 51 return 51 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 })
+35 -30
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 ··· 79 90 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 80 91 r.Get("/", s.Profile) 81 92 r.Get("/feed.atom", s.AtomFeedPage) 82 - 83 - // redirect /@handle/repo.git -> /@handle/repo 84 - r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 85 - nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 86 - http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 87 - }) 88 93 89 94 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 90 95 r.Use(mw.GoImport())
+11 -11
appview/state/state.go
··· 78 78 return nil, fmt.Errorf("failed to create enforcer: %w", err) 79 79 } 80 80 81 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 81 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 82 82 if err != nil { 83 83 logger.Error("failed to create redis resolver", "err", err) 84 - res = idresolver.DefaultResolver() 84 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 85 85 } 86 86 87 87 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 129 129 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 130 130 } 131 131 132 - if err := BackfillDefaultDefs(d, res); err != nil { 132 + if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 133 133 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 134 134 } 135 135 ··· 294 294 return 295 295 } 296 296 297 - 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)) 298 298 if err != nil { 299 299 // non-fatal 300 300 } ··· 386 386 387 387 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 388 388 if err != nil { 389 - 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) 390 391 return 391 392 } 392 393 393 394 if len(pubKeys) == 0 { 394 - w.WriteHeader(http.StatusNotFound) 395 + w.WriteHeader(http.StatusNoContent) 395 396 return 396 397 } 397 398 ··· 516 517 Rkey: rkey, 517 518 Description: description, 518 519 Created: time.Now(), 519 - Labels: models.DefaultLabelDefs(), 520 + Labels: s.config.Label.DefaultLabelDefs, 520 521 } 521 522 record := repo.AsRecord() 522 523 ··· 632 633 aturi = "" 633 634 634 635 s.notifier.NewRepo(r.Context(), repo) 635 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 636 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 636 637 } 637 638 } 638 639 ··· 658 659 return err 659 660 } 660 661 661 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 662 - defaults := models.DefaultLabelDefs() 662 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 663 663 defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 664 664 if err != nil { 665 665 return err ··· 669 669 return nil 670 670 } 671 671 672 - labelDefs, err := models.FetchDefaultDefs(r) 672 + labelDefs, err := models.FetchLabelDefs(r, defaults) 673 673 if err != nil { 674 674 return err 675 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])?$`)
+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 + }
+3 -3
docs/hacking.md
··· 52 52 did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 53 54 54 # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 55 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 56 57 57 # run redis in at a new shell to store oauth sessions 58 58 redis-server ··· 168 168 169 169 If for any reason you wish to disable either one of the 170 170 services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled-spindle.enable` (or 172 - `services.tangled-knot.enable`) to `false`. 171 + `services.tangled.spindle.enable` (or 172 + `services.tangled.knot.enable`) to `false`.
+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",
+9 -4
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"]; ··· 81 86 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 82 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 {}; ··· 283 288 }: { 284 289 imports = [./nix/modules/appview.nix]; 285 290 286 - services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 291 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 287 292 }; 288 293 nixosModules.knot = { 289 294 lib, ··· 292 297 }: { 293 298 imports = [./nix/modules/knot.nix]; 294 299 295 - services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 300 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 296 301 }; 297 302 nixosModules.spindle = { 298 303 lib, ··· 301 306 }: { 302 307 imports = [./nix/modules/spindle.nix]; 303 308 304 - services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 309 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 305 310 }; 306 311 }; 307 312 }
+4 -12
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 ··· 45 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 ··· 65 68 github.com/aymerick/douceur v0.2.0 // indirect 66 69 github.com/beorn7/perks v1.0.1 // indirect 67 70 github.com/bits-and-blooms/bitset v1.22.0 // indirect 68 - github.com/blevesearch/bleve/v2 v2.5.3 // indirect 69 71 github.com/blevesearch/bleve_index_api v1.2.8 // indirect 70 72 github.com/blevesearch/geo v0.2.4 // indirect 71 73 github.com/blevesearch/go-faiss v1.0.25 // indirect ··· 83 85 github.com/blevesearch/zapx/v14 v14.4.2 // indirect 84 86 github.com/blevesearch/zapx/v15 v15.4.2 // indirect 85 87 github.com/blevesearch/zapx/v16 v16.2.4 // indirect 86 - github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 87 88 github.com/casbin/govaluate v1.3.0 // indirect 88 89 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 89 90 github.com/cespare/xxhash/v2 v2.3.0 // indirect 90 91 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 91 92 github.com/charmbracelet/lipgloss v1.1.0 // indirect 92 - github.com/charmbracelet/log v0.4.2 // indirect 93 93 github.com/charmbracelet/x/ansi v0.8.0 // indirect 94 94 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 95 95 github.com/charmbracelet/x/term v0.2.1 // indirect ··· 98 98 github.com/containerd/errdefs/pkg v0.3.0 // indirect 99 99 github.com/containerd/log v0.1.0 // indirect 100 100 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 101 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 102 101 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 103 102 github.com/distribution/reference v0.6.0 // indirect 104 103 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 152 151 github.com/kevinburke/ssh_config v1.2.0 // indirect 153 152 github.com/klauspost/compress v1.18.0 // indirect 154 153 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 155 - github.com/lestrrat-go/blackmagic v1.0.4 // indirect 156 - github.com/lestrrat-go/httpcc v1.0.1 // indirect 157 - github.com/lestrrat-go/httprc v1.0.6 // indirect 158 - github.com/lestrrat-go/iter v1.0.2 // indirect 159 - github.com/lestrrat-go/option v1.0.1 // indirect 160 154 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 161 155 github.com/mattn/go-isatty v0.0.20 // indirect 162 156 github.com/mattn/go-runewidth v0.0.16 // indirect ··· 191 185 github.com/prometheus/procfs v0.16.1 // indirect 192 186 github.com/rivo/uniseg v0.4.7 // indirect 193 187 github.com/ryanuber/go-glob v1.0.0 // indirect 194 - github.com/segmentio/asm v1.2.0 // indirect 195 188 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 196 189 github.com/spaolacci/murmur3 v1.1.0 // indirect 197 190 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect ··· 199 192 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 200 193 github.com/wyatt915/treeblood v0.1.16 // indirect 201 194 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 202 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 203 195 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 204 196 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 205 197 go.etcd.io/bbolt v1.4.0 // indirect
+2 -17
go.sum
··· 71 71 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 72 72 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 73 73 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 74 - github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= 75 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= 76 77 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 77 78 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 78 79 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 124 125 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 125 126 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 126 127 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 127 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 128 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 129 128 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 130 129 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 131 130 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 328 327 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 329 328 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 330 329 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 331 - github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 332 - github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 333 - github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 334 - github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 335 - github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 336 - github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 337 - github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 338 - github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 339 - github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 340 - github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 341 - github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 342 - github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 343 330 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 344 331 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 345 332 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 464 451 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 465 452 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 466 453 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 467 - github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 468 - github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 469 454 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 470 455 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 471 456 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
+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
+26 -1
knotserver/router.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/http" 8 + "strings" 8 9 9 10 "github.com/go-chi/chi/v5" 10 11 "tangled.org/core/idresolver" ··· 36 37 l: log.FromContext(ctx), 37 38 jc: jc, 38 39 n: n, 39 - resolver: idresolver.DefaultResolver(), 40 + resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 40 41 } 41 42 42 43 err := e.AddKnot(rbac.ThisServer) ··· 79 80 }) 80 81 81 82 r.Route("/{did}", func(r chi.Router) { 83 + r.Use(h.resolveDidRedirect) 82 84 r.Route("/{name}", func(r chi.Router) { 83 85 // routes for git operations 84 86 r.Get("/info/refs", h.InfoRefs) ··· 113 115 } 114 116 115 117 return xrpc.Router() 118 + } 119 + 120 + func (h *Knot) resolveDidRedirect(next http.Handler) http.Handler { 121 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 122 + didOrHandle := chi.URLParam(r, "did") 123 + if strings.HasPrefix(didOrHandle, "did:") { 124 + next.ServeHTTP(w, r) 125 + return 126 + } 127 + 128 + trimmed := strings.TrimPrefix(didOrHandle, "@") 129 + id, err := h.resolver.ResolveIdent(r.Context(), trimmed) 130 + if err != nil { 131 + // invalid did or handle 132 + h.l.Error("failed to resolve did/handle", "handle", trimmed, "err", err) 133 + http.Error(w, fmt.Sprintf("failed to resolve did/handle: %s", trimmed), http.StatusInternalServerError) 134 + return 135 + } 136 + 137 + suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle) 138 + newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery) 139 + http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) 140 + }) 116 141 } 117 142 118 143 func (h *Knot) configureOwner() error {
+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
+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",
+2 -2
nix/gomod2nix.toml
··· 109 109 version = "v0.0.0-20241210005130-ea96859b93d1" 110 110 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 111 111 [mod."github.com/bmatcuk/doublestar/v4"] 112 - version = "v4.7.1" 113 - hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 112 + version = "v4.9.1" 113 + hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" 114 114 [mod."github.com/carlmjohnson/versioninfo"] 115 115 version = "v0.22.5" 116 116 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
+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 }
+74 -4
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; ··· 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 ··· 111 146 description = "Hostname for the server (required)"; 112 147 }; 113 148 149 + plcUrl = mkOption { 150 + type = types.str; 151 + default = "https://plc.directory"; 152 + description = "atproto PLC directory"; 153 + }; 154 + 155 + jetstreamEndpoint = mkOption { 156 + type = types.str; 157 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 158 + description = "Jetstream endpoint to subscribe to"; 159 + }; 160 + 161 + logDids = mkOption { 162 + type = types.bool; 163 + default = true; 164 + description = "Enable logging of DIDs"; 165 + }; 166 + 114 167 dev = mkOption { 115 168 type = types.bool; 116 169 default = false; ··· 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";
+10 -3
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; ··· 35 35 type = types.str; 36 36 example = "my.spindle.com"; 37 37 description = "Hostname for the server (required)"; 38 + }; 39 + 40 + plcUrl = mkOption { 41 + type = types.str; 42 + default = "https://plc.directory"; 43 + description = "atproto PLC directory"; 38 44 }; 39 45 40 46 jetstreamEndpoint = mkOption { ··· 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
+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 ];
+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_"`
+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 }
+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 }
+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 + }