forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+2
.tangled/workflows/build.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 dependencies: 6 nixpkgs: 7 - go
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 + 7 dependencies: 8 nixpkgs: 9 - go
+2
.tangled/workflows/fmt.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 steps: 6 - name: "Check formatting" 7 command: |
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 + 7 steps: 8 - name: "Check formatting" 9 command: |
+2
.tangled/workflows/test.yml
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 dependencies: 6 nixpkgs: 7 - go
··· 2 - event: ["push", "pull_request"] 3 branch: ["master"] 4 5 + engine: nixery 6 + 7 dependencies: 8 nixpkgs: 9 - go
+2 -122
api/tangled/cbor_gen.go
··· 5512 } 5513 5514 cw := cbg.NewCborWriter(w) 5515 - fieldCount := 7 5516 5517 if t.Body == nil { 5518 fieldCount-- ··· 5642 return err 5643 } 5644 5645 - // t.IssueId (int64) (int64) 5646 - if len("issueId") > 1000000 { 5647 - return xerrors.Errorf("Value in field \"issueId\" was too long") 5648 - } 5649 - 5650 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 5651 - return err 5652 - } 5653 - if _, err := cw.WriteString(string("issueId")); err != nil { 5654 - return err 5655 - } 5656 - 5657 - if t.IssueId >= 0 { 5658 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 5659 - return err 5660 - } 5661 - } else { 5662 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 5663 - return err 5664 - } 5665 - } 5666 - 5667 // t.CreatedAt (string) (string) 5668 if len("createdAt") > 1000000 { 5669 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5795 5796 t.Title = string(sval) 5797 } 5798 - // t.IssueId (int64) (int64) 5799 - case "issueId": 5800 - { 5801 - maj, extra, err := cr.ReadHeader() 5802 - if err != nil { 5803 - return err 5804 - } 5805 - var extraI int64 5806 - switch maj { 5807 - case cbg.MajUnsignedInt: 5808 - extraI = int64(extra) 5809 - if extraI < 0 { 5810 - return fmt.Errorf("int64 positive overflow") 5811 - } 5812 - case cbg.MajNegativeInt: 5813 - extraI = int64(extra) 5814 - if extraI < 0 { 5815 - return fmt.Errorf("int64 negative overflow") 5816 - } 5817 - extraI = -1 - extraI 5818 - default: 5819 - return fmt.Errorf("wrong type for int64 field: %d", maj) 5820 - } 5821 - 5822 - t.IssueId = int64(extraI) 5823 - } 5824 // t.CreatedAt (string) (string) 5825 case "createdAt": 5826 ··· 5850 } 5851 5852 cw := cbg.NewCborWriter(w) 5853 - fieldCount := 7 5854 - 5855 - if t.CommentId == nil { 5856 - fieldCount-- 5857 - } 5858 5859 if t.Owner == nil { 5860 fieldCount-- ··· 5997 } 5998 } 5999 6000 - // t.CommentId (int64) (int64) 6001 - if t.CommentId != nil { 6002 - 6003 - if len("commentId") > 1000000 { 6004 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6005 - } 6006 - 6007 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6008 - return err 6009 - } 6010 - if _, err := cw.WriteString(string("commentId")); err != nil { 6011 - return err 6012 - } 6013 - 6014 - if t.CommentId == nil { 6015 - if _, err := cw.Write(cbg.CborNull); err != nil { 6016 - return err 6017 - } 6018 - } else { 6019 - if *t.CommentId >= 0 { 6020 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6021 - return err 6022 - } 6023 - } else { 6024 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6025 - return err 6026 - } 6027 - } 6028 - } 6029 - 6030 - } 6031 - 6032 // t.CreatedAt (string) (string) 6033 if len("createdAt") > 1000000 { 6034 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6168 } 6169 6170 t.Owner = (*string)(&sval) 6171 - } 6172 - } 6173 - // t.CommentId (int64) (int64) 6174 - case "commentId": 6175 - { 6176 - 6177 - b, err := cr.ReadByte() 6178 - if err != nil { 6179 - return err 6180 - } 6181 - if b != cbg.CborNull[0] { 6182 - if err := cr.UnreadByte(); err != nil { 6183 - return err 6184 - } 6185 - maj, extra, err := cr.ReadHeader() 6186 - if err != nil { 6187 - return err 6188 - } 6189 - var extraI int64 6190 - switch maj { 6191 - case cbg.MajUnsignedInt: 6192 - extraI = int64(extra) 6193 - if extraI < 0 { 6194 - return fmt.Errorf("int64 positive overflow") 6195 - } 6196 - case cbg.MajNegativeInt: 6197 - extraI = int64(extra) 6198 - if extraI < 0 { 6199 - return fmt.Errorf("int64 negative overflow") 6200 - } 6201 - extraI = -1 - extraI 6202 - default: 6203 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6204 - } 6205 - 6206 - t.CommentId = (*int64)(&extraI) 6207 } 6208 } 6209 // t.CreatedAt (string) (string)
··· 5512 } 5513 5514 cw := cbg.NewCborWriter(w) 5515 + fieldCount := 6 5516 5517 if t.Body == nil { 5518 fieldCount-- ··· 5642 return err 5643 } 5644 5645 // t.CreatedAt (string) (string) 5646 if len("createdAt") > 1000000 { 5647 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5773 5774 t.Title = string(sval) 5775 } 5776 // t.CreatedAt (string) (string) 5777 case "createdAt": 5778 ··· 5802 } 5803 5804 cw := cbg.NewCborWriter(w) 5805 + fieldCount := 6 5806 5807 if t.Owner == nil { 5808 fieldCount-- ··· 5945 } 5946 } 5947 5948 // t.CreatedAt (string) (string) 5949 if len("createdAt") > 1000000 { 5950 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6084 } 6085 6086 t.Owner = (*string)(&sval) 6087 } 6088 } 6089 // t.CreatedAt (string) (string)
-1
api/tangled/issuecomment.go
··· 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 Issue string `json:"issue" cborgen:"issue"` 25 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
··· 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 Body string `json:"body" cborgen:"body"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Issue string `json:"issue" cborgen:"issue"` 24 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-1
api/tangled/repoissue.go
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 Owner string `json:"owner" cborgen:"owner"` 25 Repo string `json:"repo" cborgen:"repo"` 26 Title string `json:"title" cborgen:"title"`
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Owner string `json:"owner" cborgen:"owner"` 24 Repo string `json:"repo" cborgen:"repo"` 25 Title string `json:"title" cborgen:"title"`
+4
appview/db/db.go
··· 470 id integer primary key autoincrement, 471 name text unique 472 ); 473 `) 474 if err != nil { 475 return nil, err
··· 470 id integer primary key autoincrement, 471 name text unique 472 ); 473 + 474 + -- indexes for better star query performance 475 + create index if not exists idx_stars_created on stars(created); 476 + create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 477 `) 478 if err != nil { 479 return nil, err
+189 -3
appview/db/issues.go
··· 3 import ( 4 "database/sql" 5 "fmt" 6 "time" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 48 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 49 } 50 51 func NewIssue(tx *sql.Tx, issue *Issue) error { 52 defer tx.Rollback() 53 ··· 105 return ownerDid, err 106 } 107 108 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 109 var issues []Issue 110 openValue := 0 111 if isOpen { ··· 145 body, 146 open, 147 comment_count 148 - from 149 numbered_issue 150 - where 151 row_num between ? and ?`, 152 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 153 if err != nil { ··· 181 return issues, nil 182 } 183 184 // timeframe here is directly passed into the sql query filter, and any 185 // timeframe in the past should be negative; e.g.: "-3 months" 186 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 469 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 470 where repo_at = ? and issue_id = ? and comment_id = ? 471 `, repoAt, issueId, commentId) 472 return err 473 } 474
··· 3 import ( 4 "database/sql" 5 "fmt" 6 + mathrand "math/rand/v2" 7 + "strings" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 50 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 } 52 53 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 54 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 55 + if err != nil { 56 + created = time.Now() 57 + } 58 + 59 + body := "" 60 + if record.Body != nil { 61 + body = *record.Body 62 + } 63 + 64 + return Issue{ 65 + RepoAt: syntax.ATURI(record.Repo), 66 + OwnerDid: record.Owner, 67 + Rkey: rkey, 68 + Created: created, 69 + Title: record.Title, 70 + Body: body, 71 + Open: true, // new issues are open by default 72 + } 73 + } 74 + 75 + func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 + ownerDid := issueUri.Authority().String() 77 + issueRkey := issueUri.RecordKey().String() 78 + 79 + var repoAt string 80 + var issueId int 81 + 82 + query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 + err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 + if err != nil { 85 + return "", 0, err 86 + } 87 + 88 + return syntax.ATURI(repoAt), issueId, nil 89 + } 90 + 91 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 + if err != nil { 94 + created = time.Now() 95 + } 96 + 97 + ownerDid := did 98 + if record.Owner != nil { 99 + ownerDid = *record.Owner 100 + } 101 + 102 + issueUri, err := syntax.ParseATURI(record.Issue) 103 + if err != nil { 104 + return Comment{}, err 105 + } 106 + 107 + repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 + if err != nil { 109 + return Comment{}, err 110 + } 111 + 112 + comment := Comment{ 113 + OwnerDid: ownerDid, 114 + RepoAt: repoAt, 115 + Rkey: rkey, 116 + Body: record.Body, 117 + Issue: issueId, 118 + CommentId: mathrand.IntN(1000000), 119 + Created: &created, 120 + } 121 + 122 + return comment, nil 123 + } 124 + 125 func NewIssue(tx *sql.Tx, issue *Issue) error { 126 defer tx.Rollback() 127 ··· 179 return ownerDid, err 180 } 181 182 + func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 183 var issues []Issue 184 openValue := 0 185 if isOpen { ··· 219 body, 220 open, 221 comment_count 222 + from 223 numbered_issue 224 + where 225 row_num between ? and ?`, 226 repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 227 if err != nil { ··· 255 return issues, nil 256 } 257 258 + func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 + issues := make([]Issue, 0, limit) 260 + 261 + var conditions []string 262 + var args []any 263 + for _, filter := range filters { 264 + conditions = append(conditions, filter.Condition()) 265 + args = append(args, filter.Arg()...) 266 + } 267 + 268 + whereClause := "" 269 + if conditions != nil { 270 + whereClause = " where " + strings.Join(conditions, " and ") 271 + } 272 + limitClause := "" 273 + if limit != 0 { 274 + limitClause = fmt.Sprintf(" limit %d ", limit) 275 + } 276 + 277 + query := fmt.Sprintf( 278 + `select 279 + i.id, 280 + i.owner_did, 281 + i.repo_at, 282 + i.issue_id, 283 + i.created, 284 + i.title, 285 + i.body, 286 + i.open 287 + from 288 + issues i 289 + %s 290 + order by 291 + i.created desc 292 + %s`, 293 + whereClause, limitClause) 294 + 295 + rows, err := e.Query(query, args...) 296 + if err != nil { 297 + return nil, err 298 + } 299 + defer rows.Close() 300 + 301 + for rows.Next() { 302 + var issue Issue 303 + var issueCreatedAt string 304 + err := rows.Scan( 305 + &issue.ID, 306 + &issue.OwnerDid, 307 + &issue.RepoAt, 308 + &issue.IssueId, 309 + &issueCreatedAt, 310 + &issue.Title, 311 + &issue.Body, 312 + &issue.Open, 313 + ) 314 + if err != nil { 315 + return nil, err 316 + } 317 + 318 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 + if err != nil { 320 + return nil, err 321 + } 322 + issue.Created = issueCreatedTime 323 + 324 + issues = append(issues, issue) 325 + } 326 + 327 + if err := rows.Err(); err != nil { 328 + return nil, err 329 + } 330 + 331 + return issues, nil 332 + } 333 + 334 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 335 + return GetIssuesWithLimit(e, 0, filters...) 336 + } 337 + 338 // timeframe here is directly passed into the sql query filter, and any 339 // timeframe in the past should be negative; e.g.: "-3 months" 340 func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { ··· 623 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 624 where repo_at = ? and issue_id = ? and comment_id = ? 625 `, repoAt, issueId, commentId) 626 + return err 627 + } 628 + 629 + func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 + _, err := e.Exec( 631 + ` 632 + update comments 633 + set body = ?, 634 + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 + where owner_did = ? and rkey = ? 636 + `, newBody, ownerDid, rkey) 637 + return err 638 + } 639 + 640 + func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 + _, err := e.Exec( 642 + ` 643 + update comments 644 + set body = "", 645 + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 + where owner_did = ? and rkey = ? 647 + `, ownerDid, rkey) 648 + return err 649 + } 650 + 651 + func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 + _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 + return err 654 + } 655 + 656 + func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 + _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 658 return err 659 } 660
+22 -3
appview/db/pulls.go
··· 310 return pullId - 1, err 311 } 312 313 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 314 pulls := make(map[int]*Pull) 315 316 var conditions []string ··· 323 whereClause := "" 324 if conditions != nil { 325 whereClause = " where " + strings.Join(conditions, " and ") 326 } 327 328 query := fmt.Sprintf(` ··· 344 from 345 pulls 346 %s 347 - `, whereClause) 348 349 rows, err := e.Query(query, args...) 350 if err != nil { ··· 412 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 413 submissionsQuery := fmt.Sprintf(` 414 select 415 - id, pull_id, round_number, patch, source_rev 416 from 417 pull_submissions 418 where ··· 438 for submissionsRows.Next() { 439 var s PullSubmission 440 var sourceRev sql.NullString 441 err := submissionsRows.Scan( 442 &s.ID, 443 &s.PullId, 444 &s.RoundNumber, 445 &s.Patch, 446 &sourceRev, 447 ) 448 if err != nil { 449 return nil, err 450 } 451 452 if sourceRev.Valid { 453 s.SourceRev = sourceRev.String ··· 511 }) 512 513 return orderedByPullId, nil 514 } 515 516 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
··· 310 return pullId - 1, err 311 } 312 313 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 314 pulls := make(map[int]*Pull) 315 316 var conditions []string ··· 323 whereClause := "" 324 if conditions != nil { 325 whereClause = " where " + strings.Join(conditions, " and ") 326 + } 327 + limitClause := "" 328 + if limit != 0 { 329 + limitClause = fmt.Sprintf(" limit %d ", limit) 330 } 331 332 query := fmt.Sprintf(` ··· 348 from 349 pulls 350 %s 351 + order by 352 + created desc 353 + %s 354 + `, whereClause, limitClause) 355 356 rows, err := e.Query(query, args...) 357 if err != nil { ··· 419 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 420 submissionsQuery := fmt.Sprintf(` 421 select 422 + id, pull_id, round_number, patch, created, source_rev 423 from 424 pull_submissions 425 where ··· 445 for submissionsRows.Next() { 446 var s PullSubmission 447 var sourceRev sql.NullString 448 + var createdAt string 449 err := submissionsRows.Scan( 450 &s.ID, 451 &s.PullId, 452 &s.RoundNumber, 453 &s.Patch, 454 + &createdAt, 455 &sourceRev, 456 ) 457 if err != nil { 458 return nil, err 459 } 460 + 461 + createdTime, err := time.Parse(time.RFC3339, createdAt) 462 + if err != nil { 463 + return nil, err 464 + } 465 + s.Created = createdTime 466 467 if sourceRev.Valid { 468 s.SourceRev = sourceRev.String ··· 526 }) 527 528 return orderedByPullId, nil 529 + } 530 + 531 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 532 + return GetPullsWithLimit(e, 0, filters...) 533 } 534 535 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+72 -3
appview/db/star.go
··· 47 // Get a star record 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 query := ` 50 - select starred_by_did, repo_at, created, rkey 51 from stars 52 where starred_by_did = ? and repo_at = ?` 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 } 120 121 repoQuery := fmt.Sprintf( 122 - `select starred_by_did, repo_at, created, rkey 123 from stars 124 %s 125 order by created desc ··· 187 var stars []Star 188 189 rows, err := e.Query(` 190 - select 191 s.starred_by_did, 192 s.repo_at, 193 s.rkey, ··· 244 245 return stars, nil 246 }
··· 47 // Get a star record 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 query := ` 50 + select starred_by_did, repo_at, created, rkey 51 from stars 52 where starred_by_did = ? and repo_at = ?` 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 } 120 121 repoQuery := fmt.Sprintf( 122 + `select starred_by_did, repo_at, created, rkey 123 from stars 124 %s 125 order by created desc ··· 187 var stars []Star 188 189 rows, err := e.Query(` 190 + select 191 s.starred_by_did, 192 s.repo_at, 193 s.rkey, ··· 244 245 return stars, nil 246 } 247 + 248 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 249 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 250 + // first, get the top repo URIs by star count from the last week 251 + query := ` 252 + with recent_starred_repos as ( 253 + select distinct repo_at 254 + from stars 255 + where created >= datetime('now', '-7 days') 256 + ), 257 + repo_star_counts as ( 258 + select 259 + s.repo_at, 260 + count(*) as star_count 261 + from stars s 262 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 263 + group by s.repo_at 264 + ) 265 + select rsc.repo_at 266 + from repo_star_counts rsc 267 + order by rsc.star_count desc 268 + limit 8 269 + ` 270 + 271 + rows, err := e.Query(query) 272 + if err != nil { 273 + return nil, err 274 + } 275 + defer rows.Close() 276 + 277 + var repoUris []string 278 + for rows.Next() { 279 + var repoUri string 280 + err := rows.Scan(&repoUri) 281 + if err != nil { 282 + return nil, err 283 + } 284 + repoUris = append(repoUris, repoUri) 285 + } 286 + 287 + if err := rows.Err(); err != nil { 288 + return nil, err 289 + } 290 + 291 + if len(repoUris) == 0 { 292 + return []Repo{}, nil 293 + } 294 + 295 + // get full repo data 296 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 297 + if err != nil { 298 + return nil, err 299 + } 300 + 301 + // sort repos by the original trending order 302 + repoMap := make(map[string]Repo) 303 + for _, repo := range repos { 304 + repoMap[repo.RepoAt().String()] = repo 305 + } 306 + 307 + orderedRepos := make([]Repo, 0, len(repoUris)) 308 + for _, uri := range repoUris { 309 + if repo, exists := repoMap[uri]; exists { 310 + orderedRepos = append(orderedRepos, repo) 311 + } 312 + } 313 + 314 + return orderedRepos, nil 315 + }
+12 -11
appview/db/strings.go
··· 50 func (s String) Validate() error { 51 var err error 52 53 - if !strings.Contains(s.Filename, ".") { 54 - err = errors.Join(err, fmt.Errorf("missing filename extension")) 55 - } 56 - 57 - if strings.HasSuffix(s.Filename, ".") { 58 - err = errors.Join(err, fmt.Errorf("filename ends with `.`")) 59 - } 60 - 61 if utf8.RuneCountInString(s.Filename) > 140 { 62 err = errors.Join(err, fmt.Errorf("filename too long")) 63 } ··· 113 filename = excluded.filename, 114 description = excluded.description, 115 content = excluded.content, 116 - edited = case 117 when 118 strings.content != excluded.content 119 or strings.filename != excluded.filename ··· 131 return err 132 } 133 134 - func GetStrings(e Execer, filters ...filter) ([]String, error) { 135 var all []String 136 137 var conditions []string ··· 146 whereClause = " where " + strings.Join(conditions, " and ") 147 } 148 149 query := fmt.Sprintf(`select 150 did, 151 rkey, ··· 154 content, 155 created, 156 edited 157 - from strings %s`, 158 whereClause, 159 ) 160 161 rows, err := e.Query(query, args...)
··· 50 func (s String) Validate() error { 51 var err error 52 53 if utf8.RuneCountInString(s.Filename) > 140 { 54 err = errors.Join(err, fmt.Errorf("filename too long")) 55 } ··· 105 filename = excluded.filename, 106 description = excluded.description, 107 content = excluded.content, 108 + edited = case 109 when 110 strings.content != excluded.content 111 or strings.filename != excluded.filename ··· 123 return err 124 } 125 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 var all []String 128 129 var conditions []string ··· 138 whereClause = " where " + strings.Join(conditions, " and ") 139 } 140 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 146 query := fmt.Sprintf(`select 147 did, 148 rkey, ··· 151 content, 152 created, 153 edited 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 whereClause, 159 + limitClause, 160 ) 161 162 rows, err := e.Query(query, args...)
+179 -6
appview/ingester.go
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/rbac" ··· 61 case tangled.ActorProfileNSID: 62 err = i.ingestProfile(e) 63 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 65 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 67 case tangled.StringNSID: 68 err = i.ingestString(e) 69 } 70 l = i.Logger.With("nsid", e.Commit.Collection) 71 } ··· 336 return nil 337 } 338 339 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 340 did := e.Did 341 var err error 342 ··· 359 return fmt.Errorf("failed to enforce permissions: %w", err) 360 } 361 362 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 363 if err != nil { 364 return err 365 } ··· 442 return nil 443 } 444 445 - func (i *Ingester) ingestSpindle(e *models.Event) error { 446 did := e.Did 447 var err error 448 ··· 475 return err 476 } 477 478 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 479 if err != nil { 480 l.Error("failed to add spindle to db", "err", err, "instance", instance) 481 return err ··· 609 610 return nil 611 }
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 + "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 "tangled.sh/tangled.sh/core/api/tangled" 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 "tangled.sh/tangled.sh/core/appview/spindleverify" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" ··· 63 case tangled.ActorProfileNSID: 64 err = i.ingestProfile(e) 65 case tangled.SpindleMemberNSID: 66 + err = i.ingestSpindleMember(ctx, e) 67 case tangled.SpindleNSID: 68 + err = i.ingestSpindle(ctx, e) 69 case tangled.StringNSID: 70 err = i.ingestString(e) 71 + case tangled.RepoIssueNSID: 72 + err = i.ingestIssue(ctx, e) 73 + case tangled.RepoIssueCommentNSID: 74 + err = i.ingestIssueComment(e) 75 } 76 l = i.Logger.With("nsid", e.Commit.Collection) 77 } ··· 342 return nil 343 } 344 345 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 346 did := e.Did 347 var err error 348 ··· 365 return fmt.Errorf("failed to enforce permissions: %w", err) 366 } 367 368 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 369 if err != nil { 370 return err 371 } ··· 448 return nil 449 } 450 451 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 452 did := e.Did 453 var err error 454 ··· 481 return err 482 } 483 484 + err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 485 if err != nil { 486 l.Error("failed to add spindle to db", "err", err, "instance", instance) 487 return err ··· 615 616 return nil 617 } 618 + 619 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 620 + did := e.Did 621 + rkey := e.Commit.RKey 622 + 623 + var err error 624 + 625 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 626 + l.Info("ingesting record") 627 + 628 + ddb, ok := i.Db.Execer.(*db.DB) 629 + if !ok { 630 + return fmt.Errorf("failed to index issue record, invalid db cast") 631 + } 632 + 633 + switch e.Commit.Operation { 634 + case models.CommitOperationCreate: 635 + raw := json.RawMessage(e.Commit.Record) 636 + record := tangled.RepoIssue{} 637 + err = json.Unmarshal(raw, &record) 638 + if err != nil { 639 + l.Error("invalid record", "err", err) 640 + return err 641 + } 642 + 643 + issue := db.IssueFromRecord(did, rkey, record) 644 + 645 + sanitizer := markup.NewSanitizer() 646 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 647 + return fmt.Errorf("title is empty after HTML sanitization") 648 + } 649 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 650 + return fmt.Errorf("body is empty after HTML sanitization") 651 + } 652 + 653 + tx, err := ddb.BeginTx(ctx, nil) 654 + if err != nil { 655 + l.Error("failed to begin transaction", "err", err) 656 + return err 657 + } 658 + 659 + err = db.NewIssue(tx, &issue) 660 + if err != nil { 661 + l.Error("failed to create issue", "err", err) 662 + return err 663 + } 664 + 665 + return nil 666 + 667 + case models.CommitOperationUpdate: 668 + raw := json.RawMessage(e.Commit.Record) 669 + record := tangled.RepoIssue{} 670 + err = json.Unmarshal(raw, &record) 671 + if err != nil { 672 + l.Error("invalid record", "err", err) 673 + return err 674 + } 675 + 676 + body := "" 677 + if record.Body != nil { 678 + body = *record.Body 679 + } 680 + 681 + sanitizer := markup.NewSanitizer() 682 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 683 + return fmt.Errorf("title is empty after HTML sanitization") 684 + } 685 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 686 + return fmt.Errorf("body is empty after HTML sanitization") 687 + } 688 + 689 + err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 690 + if err != nil { 691 + l.Error("failed to update issue", "err", err) 692 + return err 693 + } 694 + 695 + return nil 696 + 697 + case models.CommitOperationDelete: 698 + if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 699 + l.Error("failed to delete", "err", err) 700 + return fmt.Errorf("failed to delete issue record: %w", err) 701 + } 702 + 703 + return nil 704 + } 705 + 706 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 707 + } 708 + 709 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 710 + did := e.Did 711 + rkey := e.Commit.RKey 712 + 713 + var err error 714 + 715 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 716 + l.Info("ingesting record") 717 + 718 + ddb, ok := i.Db.Execer.(*db.DB) 719 + if !ok { 720 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 721 + } 722 + 723 + switch e.Commit.Operation { 724 + case models.CommitOperationCreate: 725 + raw := json.RawMessage(e.Commit.Record) 726 + record := tangled.RepoIssueComment{} 727 + err = json.Unmarshal(raw, &record) 728 + if err != nil { 729 + l.Error("invalid record", "err", err) 730 + return err 731 + } 732 + 733 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 734 + if err != nil { 735 + l.Error("failed to parse comment from record", "err", err) 736 + return err 737 + } 738 + 739 + sanitizer := markup.NewSanitizer() 740 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 741 + return fmt.Errorf("body is empty after HTML sanitization") 742 + } 743 + 744 + err = db.NewIssueComment(ddb, &comment) 745 + if err != nil { 746 + l.Error("failed to create issue comment", "err", err) 747 + return err 748 + } 749 + 750 + return nil 751 + 752 + case models.CommitOperationUpdate: 753 + raw := json.RawMessage(e.Commit.Record) 754 + record := tangled.RepoIssueComment{} 755 + err = json.Unmarshal(raw, &record) 756 + if err != nil { 757 + l.Error("invalid record", "err", err) 758 + return err 759 + } 760 + 761 + sanitizer := markup.NewSanitizer() 762 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 763 + return fmt.Errorf("body is empty after HTML sanitization") 764 + } 765 + 766 + err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 767 + if err != nil { 768 + l.Error("failed to update issue comment", "err", err) 769 + return err 770 + } 771 + 772 + return nil 773 + 774 + case models.CommitOperationDelete: 775 + if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 776 + l.Error("failed to delete", "err", err) 777 + return fmt.Errorf("failed to delete issue comment record: %w", err) 778 + } 779 + 780 + return nil 781 + } 782 + 783 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 784 + }
+17 -10
appview/issues/issues.go
··· 7 "net/http" 8 "slices" 9 "strconv" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 20 "tangled.sh/tangled.sh/core/appview/notify" 21 "tangled.sh/tangled.sh/core/appview/oauth" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 "tangled.sh/tangled.sh/core/appview/pagination" 24 "tangled.sh/tangled.sh/core/appview/reporesolver" 25 "tangled.sh/tangled.sh/core/idresolver" ··· 276 } 277 278 createdAt := time.Now().Format(time.RFC3339) 279 - commentIdInt64 := int64(commentId) 280 ownerDid := user.Did 281 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 282 if err != nil { ··· 300 Val: &tangled.RepoIssueComment{ 301 Repo: &atUri, 302 Issue: issueAt, 303 - CommentId: &commentIdInt64, 304 Owner: &ownerDid, 305 Body: body, 306 CreatedAt: createdAt, ··· 449 repoAt := record["repo"].(string) 450 issueAt := record["issue"].(string) 451 createdAt := record["createdAt"].(string) 452 - commentIdInt64 := int64(commentIdInt) 453 454 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 455 Collection: tangled.RepoIssueCommentNSID, ··· 460 Val: &tangled.RepoIssueComment{ 461 Repo: &repoAt, 462 Issue: issueAt, 463 - CommentId: &commentIdInt64, 464 Owner: &comment.OwnerDid, 465 Body: newBody, 466 CreatedAt: createdAt, ··· 602 return 603 } 604 605 - issues, err := db.GetIssues(rp.db, f.RepoAt(), isOpen, page) 606 if err != nil { 607 log.Println("failed to get issues", err) 608 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 642 return 643 } 644 645 tx, err := rp.db.BeginTx(r.Context(), nil) 646 if err != nil { 647 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") ··· 675 Rkey: issue.Rkey, 676 Record: &lexutil.LexiconTypeDecoder{ 677 Val: &tangled.RepoIssue{ 678 - Repo: atUri, 679 - Title: title, 680 - Body: &body, 681 - Owner: user.Did, 682 - IssueId: int64(issue.IssueId), 683 }, 684 }, 685 })
··· 7 "net/http" 8 "slices" 9 "strconv" 10 + "strings" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 21 "tangled.sh/tangled.sh/core/appview/notify" 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 "tangled.sh/tangled.sh/core/idresolver" ··· 278 } 279 280 createdAt := time.Now().Format(time.RFC3339) 281 ownerDid := user.Did 282 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 283 if err != nil { ··· 301 Val: &tangled.RepoIssueComment{ 302 Repo: &atUri, 303 Issue: issueAt, 304 Owner: &ownerDid, 305 Body: body, 306 CreatedAt: createdAt, ··· 449 repoAt := record["repo"].(string) 450 issueAt := record["issue"].(string) 451 createdAt := record["createdAt"].(string) 452 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 Collection: tangled.RepoIssueCommentNSID, ··· 459 Val: &tangled.RepoIssueComment{ 460 Repo: &repoAt, 461 Issue: issueAt, 462 Owner: &comment.OwnerDid, 463 Body: newBody, 464 CreatedAt: createdAt, ··· 600 return 601 } 602 603 + issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 604 if err != nil { 605 log.Println("failed to get issues", err) 606 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 640 return 641 } 642 643 + sanitizer := markup.NewSanitizer() 644 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 + rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 646 + return 647 + } 648 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 + rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 650 + return 651 + } 652 + 653 tx, err := rp.db.BeginTx(r.Context(), nil) 654 if err != nil { 655 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") ··· 683 Rkey: issue.Rkey, 684 Record: &lexutil.LexiconTypeDecoder{ 685 Val: &tangled.RepoIssue{ 686 + Repo: atUri, 687 + Title: title, 688 + Body: &body, 689 + Owner: user.Did, 690 }, 691 }, 692 })
+6 -1
appview/pages/funcmap.go
··· 206 } 207 return v.Slice(0, min(n, v.Len())).Interface() 208 }, 209 - 210 "markdown": func(text string) template.HTML { 211 p.rctx.RendererType = markup.RendererTypeDefault 212 htmlString := p.rctx.RenderMarkdown(text) 213 sanitized := p.rctx.SanitizeDefault(htmlString) 214 return template.HTML(sanitized) 215 }, 216 "isNil": func(t any) bool {
··· 206 } 207 return v.Slice(0, min(n, v.Len())).Interface() 208 }, 209 "markdown": func(text string) template.HTML { 210 p.rctx.RendererType = markup.RendererTypeDefault 211 htmlString := p.rctx.RenderMarkdown(text) 212 sanitized := p.rctx.SanitizeDefault(htmlString) 213 + return template.HTML(sanitized) 214 + }, 215 + "description": func(text string) template.HTML { 216 + p.rctx.RendererType = markup.RendererTypeDefault 217 + htmlString := p.rctx.RenderMarkdown(text) 218 + sanitized := p.rctx.SanitizeDescription(htmlString) 219 return template.HTML(sanitized) 220 }, 221 "isNil": func(t any) bool {
+5 -1
appview/pages/markup/markdown.go
··· 161 } 162 163 func (rctx *RenderContext) SanitizeDefault(html string) string { 164 - return rctx.Sanitizer.defaultPolicy.Sanitize(html) 165 } 166 167 type MarkdownTransformer struct {
··· 161 } 162 163 func (rctx *RenderContext) SanitizeDefault(html string) string { 164 + return rctx.Sanitizer.SanitizeDefault(html) 165 + } 166 + 167 + func (rctx *RenderContext) SanitizeDescription(html string) string { 168 + return rctx.Sanitizer.SanitizeDescription(html) 169 } 170 171 type MarkdownTransformer struct {
+27 -2
appview/pages/markup/sanitizer.go
··· 11 ) 12 13 type Sanitizer struct { 14 - defaultPolicy *bluemonday.Policy 15 } 16 17 func NewSanitizer() Sanitizer { 18 return Sanitizer{ 19 - defaultPolicy: defaultPolicy(), 20 } 21 } 22 23 func defaultPolicy() *bluemonday.Policy { ··· 90 91 return policy 92 }
··· 11 ) 12 13 type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 } 17 18 func NewSanitizer() Sanitizer { 19 return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 } 23 + } 24 + 25 + func (s *Sanitizer) SanitizeDefault(html string) string { 26 + return s.defaultPolicy.Sanitize(html) 27 + } 28 + func (s *Sanitizer) SanitizeDescription(html string) string { 29 + return s.descriptionPolicy.Sanitize(html) 30 } 31 32 func defaultPolicy() *bluemonday.Policy { ··· 99 100 return policy 101 } 102 + 103 + func descriptionPolicy() *bluemonday.Policy { 104 + policy := bluemonday.NewPolicy() 105 + policy.AllowStandardURLs() 106 + 107 + // allow italics and bold. 108 + policy.AllowElements("i", "b", "em", "strong") 109 + 110 + // allow code. 111 + policy.AllowElements("code") 112 + 113 + // allow links 114 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 115 + 116 + return policy 117 + }
+33 -25
appview/pages/pages.go
··· 299 type TimelineParams struct { 300 LoggedInUser *oauth.User 301 Timeline []db.TimelineEvent 302 } 303 304 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 305 - return p.execute("timeline", w, params) 306 } 307 308 type SettingsParams struct { ··· 520 } 521 522 p.rctx.RepoInfo = params.RepoInfo 523 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 524 525 if params.ReadmeFileName != "" { ··· 673 } 674 } 675 676 - if params.Lines < 5000 { 677 - c := params.Contents 678 - formatter := chromahtml.New( 679 - chromahtml.InlineCode(false), 680 - chromahtml.WithLineNumbers(true), 681 - chromahtml.WithLinkableLineNumbers(true, "L"), 682 - chromahtml.Standalone(false), 683 - chromahtml.WithClasses(true), 684 - ) 685 686 - lexer := lexers.Get(filepath.Base(params.Path)) 687 - if lexer == nil { 688 - lexer = lexers.Fallback 689 - } 690 691 - iterator, err := lexer.Tokenise(nil, c) 692 - if err != nil { 693 - return fmt.Errorf("chroma tokenize: %w", err) 694 - } 695 - 696 - var code bytes.Buffer 697 - err = formatter.Format(&code, style, iterator) 698 - if err != nil { 699 - return fmt.Errorf("chroma format: %w", err) 700 - } 701 702 - params.Contents = code.String() 703 } 704 705 params.Active = "overview" 706 return p.executeRepo("repo/blob", w, params) 707 } ··· 1158 1159 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1160 return p.execute("strings/dashboard", w, params) 1161 } 1162 1163 type SingleStringParams struct {
··· 299 type TimelineParams struct { 300 LoggedInUser *oauth.User 301 Timeline []db.TimelineEvent 302 + Repos []db.Repo 303 } 304 305 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 306 + return p.execute("timeline/timeline", w, params) 307 } 308 309 type SettingsParams struct { ··· 521 } 522 523 p.rctx.RepoInfo = params.RepoInfo 524 + p.rctx.RepoInfo.Ref = params.Ref 525 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 526 527 if params.ReadmeFileName != "" { ··· 675 } 676 } 677 678 + c := params.Contents 679 + formatter := chromahtml.New( 680 + chromahtml.InlineCode(false), 681 + chromahtml.WithLineNumbers(true), 682 + chromahtml.WithLinkableLineNumbers(true, "L"), 683 + chromahtml.Standalone(false), 684 + chromahtml.WithClasses(true), 685 + ) 686 687 + lexer := lexers.Get(filepath.Base(params.Path)) 688 + if lexer == nil { 689 + lexer = lexers.Fallback 690 + } 691 692 + iterator, err := lexer.Tokenise(nil, c) 693 + if err != nil { 694 + return fmt.Errorf("chroma tokenize: %w", err) 695 + } 696 697 + var code bytes.Buffer 698 + err = formatter.Format(&code, style, iterator) 699 + if err != nil { 700 + return fmt.Errorf("chroma format: %w", err) 701 } 702 703 + params.Contents = code.String() 704 params.Active = "overview" 705 return p.executeRepo("repo/blob", w, params) 706 } ··· 1157 1158 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1159 return p.execute("strings/dashboard", w, params) 1160 + } 1161 + 1162 + type StringTimelineParams struct { 1163 + LoggedInUser *oauth.User 1164 + Strings []db.String 1165 + } 1166 + 1167 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1168 + return p.execute("strings/timeline", w, params) 1169 } 1170 1171 type SingleStringParams struct {
+15 -20
appview/pages/templates/layouts/repobase.html
··· 20 </div> 21 22 <div class="flex items-center gap-2 z-auto"> 23 {{ template "repo/fragments/repoStar" .RepoInfo }} 24 - {{ if .RepoInfo.DisableFork }} 25 - <button 26 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 27 - disabled 28 - title="Empty repositories cannot be forked" 29 - > 30 - {{ i "git-fork" "w-4 h-4" }} 31 - fork 32 - </button> 33 - {{ else }} 34 - <a 35 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 - hx-boost="true" 37 - href="/{{ .RepoInfo.FullName }}/fork" 38 - > 39 - {{ i "git-fork" "w-4 h-4" }} 40 - fork 41 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 42 - </a> 43 - {{ end }} 44 </div> 45 </div> 46 {{ template "repo/fragments/repoDescription" . }}
··· 20 </div> 21 22 <div class="flex items-center gap-2 z-auto"> 23 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 29 {{ template "repo/fragments/repoStar" .RepoInfo }} 30 + <a 31 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 + hx-boost="true" 33 + href="/{{ .RepoInfo.FullName }}/fork" 34 + > 35 + {{ i "git-fork" "w-4 h-4" }} 36 + fork 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </a> 39 </div> 40 </div> 41 {{ template "repo/fragments/repoDescription" . }}
+1 -1
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 }} 5 {{ else }} 6 <span class="italic">this repo has no description</span> 7 {{ end }}
··· 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 }}
+1 -1
appview/pages/templates/repo/issues/issue.html
··· 11 {{ define "repoContent" }} 12 <header class="pb-4"> 13 <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 </h1> 17 </header>
··· 11 {{ define "repoContent" }} 12 <header class="pb-4"> 13 <h1 class="text-2xl"> 14 + {{ .Issue.Title | description }} 15 <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 </h1> 17 </header>
+1 -1
appview/pages/templates/repo/issues/issues.html
··· 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 class="no-underline hover:underline" 47 > 48 - {{ .Title }} 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 </a> 51 </div>
··· 45 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 class="no-underline hover:underline" 47 > 48 + {{ .Title | description }} 49 <span class="text-gray-500">#{{ .IssueId }}</span> 50 </a> 51 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 <header class="pb-4"> 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 </h1> 7 </header>
··· 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 <header class="pb-4"> 3 <h1 class="text-2xl dark:text-white"> 4 + {{ .Pull.Title | description }} 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 </h1> 7 </header>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 9 </div> 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 - {{ .Title }} 13 </span> 14 </div> 15
··· 9 </div> 10 <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 + {{ .Title | description }} 13 </span> 14 </div> 15
+1 -1
appview/pages/templates/repo/pulls/pull.html
··· 122 {{ end }} 123 </div> 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 126 {{ if gt (len .Body) 0 }} 127 <button 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
··· 122 {{ end }} 123 </div> 124 <div class="flex items-center"> 125 + <span>{{ .Title | description }}</span> 126 {{ if gt (len .Body) 0 }} 127 <button 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 50 <div class="px-6 py-4 z-5"> 51 <div class="pb-2"> 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div>
··· 50 <div class="px-6 py-4 z-5"> 51 <div class="pb-2"> 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 + {{ .Title | description }} 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 </a> 56 </div>
+3 -2
appview/pages/templates/strings/fragments/form.html
··· 13 type="text" 14 id="filename" 15 name="filename" 16 - placeholder="Filename with extension" 17 required 18 value="{{ .String.Filename }}" 19 class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" ··· 31 name="content" 32 id="content-textarea" 33 wrap="off" 34 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 rows="20" 36 placeholder="Paste your string here!" 37 required>{{ .String.Contents }}</textarea> 38 <div class="flex justify-between items-center">
··· 13 type="text" 14 id="filename" 15 name="filename" 16 + placeholder="Filename" 17 required 18 value="{{ .String.Filename }}" 19 class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" ··· 31 name="content" 32 id="content-textarea" 33 wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 rows="20" 36 + spellcheck="false" 37 placeholder="Paste your string here!" 38 required>{{ .String.Contents }}</textarea> 39 <div class="flex justify-between items-center">
+2 -2
appview/pages/templates/strings/string.html
··· 35 title="Delete string" 36 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 hx-swap="none" 38 - hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 39 > 40 {{ i "trash-2" "size-4" }} 41 <span class="hidden md:inline">delete</span> ··· 77 {{ end }} 78 </div> 79 </div> 80 - <div class="overflow-auto relative"> 81 {{ if .ShowRendered }} 82 <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 {{ else }}
··· 35 title="Delete string" 36 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 > 40 {{ i "trash-2" "size-4" }} 41 <span class="hidden md:inline">delete</span> ··· 77 {{ end }} 78 </div> 79 </div> 80 + <div class="overflow-x-auto overflow-y-hidden relative"> 81 {{ if .ShowRendered }} 82 <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 83 {{ else }}
+65
appview/pages/templates/strings/timeline.html
···
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + {{ block "timeline" $ }}{{ end }} 9 + {{ end }} 10 + 11 + {{ define "timeline" }} 12 + <div> 13 + <div class="p-6"> 14 + <p class="text-xl font-bold dark:text-white">All strings</p> 15 + </div> 16 + 17 + <div class="flex flex-col gap-4"> 18 + {{ range $i, $s := .Strings }} 19 + <div class="relative"> 20 + {{ if ne $i 0 }} 21 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 22 + {{ end }} 23 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 24 + {{ template "stringCard" $s }} 25 + </div> 26 + </div> 27 + {{ end }} 28 + </div> 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "stringCard" }} 33 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 34 + <div class="font-medium dark:text-white flex gap-2 items-center"> 35 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 36 + </div> 37 + {{ with .Description }} 38 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 39 + {{ . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ template "stringCardInfo" . }} 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "stringCardInfo" }} 48 + {{ $stat := .Stats }} 49 + {{ $resolved := resolve .Did.String }} 50 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 51 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 52 + {{ template "user/fragments/picHandle" $resolved }} 53 + </a> 54 + <span class="select-none [&:before]:content-['ยท']"></span> 55 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 56 + <span class="select-none [&:before]:content-['ยท']"></span> 57 + {{ with .Edited }} 58 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 59 + {{ else }} 60 + {{ template "repo/fragments/shortTimeAgo" .Created }} 61 + {{ end }} 62 + </div> 63 + {{ end }} 64 + 65 +
+183
appview/pages/templates/timeline/timeline.html
···
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ block "hero" $ }}{{ end }} 14 + {{ end }} 15 + 16 + {{ block "trending" $ }}{{ end }} 17 + {{ block "timeline" $ }}{{ end }} 18 + {{ end }} 19 + 20 + {{ define "hero" }} 21 + <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 + <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 + 24 + <p class="text-lg"> 25 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 + </p> 27 + <p class="text-lg"> 28 + we envision a place where developers have complete ownership of their 29 + code, open source communities can freely self-govern and most 30 + importantly, coding can be social and fun again. 31 + </p> 32 + 33 + <div class="flex gap-6 items-center"> 34 + <a href="/signup" class="no-underline hover:no-underline "> 35 + <button class="btn-create flex gap-2 px-4 items-center"> 36 + join now {{ i "arrow-right" "size-4" }} 37 + </button> 38 + </a> 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "trending" }} 44 + <div class="w-full md:mx-0 py-4"> 45 + <div class="px-6 pb-4"> 46 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 + Trending 48 + {{ i "trending-up" "size-4 flex-shrink-0" }} 49 + </h3> 50 + </div> 51 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 + {{ range $index, $repo := .Repos }} 53 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 + </div> 56 + {{ else }} 57 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 + No trending repositories this week 60 + </div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "timeline" }} 68 + <div class="py-4"> 69 + <div class="px-6 pb-4"> 70 + <p class="text-xl font-bold dark:text-white">Timeline</p> 71 + </div> 72 + 73 + <div class="flex flex-col gap-4"> 74 + {{ range $i, $e := .Timeline }} 75 + <div class="relative"> 76 + {{ if ne $i 0 }} 77 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 + {{ end }} 79 + {{ with $e }} 80 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 + {{ if .Repo }} 82 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 + {{ else if .Star }} 84 + {{ block "starEvent" (list $ .Star) }} {{ end }} 85 + {{ else if .Follow }} 86 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 + {{ end }} 88 + </div> 89 + {{ end }} 90 + </div> 91 + {{ end }} 92 + </div> 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "repoEvent" }} 97 + {{ $root := index . 0 }} 98 + {{ $repo := index . 1 }} 99 + {{ $source := index . 2 }} 100 + {{ $userHandle := resolve $repo.Did }} 101 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 + {{ template "user/fragments/picHandleLink" $repo.Did }} 103 + {{ with $source }} 104 + {{ $sourceDid := resolve .Did }} 105 + forked 106 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 + {{ $sourceDid }}/{{ .Name }} 108 + </a> 109 + to 110 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 + {{ else }} 112 + created 113 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 + {{ $repo.Name }} 115 + </a> 116 + {{ end }} 117 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 + </div> 119 + {{ with $repo }} 120 + {{ template "user/fragments/repoCard" (list $root . true) }} 121 + {{ end }} 122 + {{ end }} 123 + 124 + {{ define "starEvent" }} 125 + {{ $root := index . 0 }} 126 + {{ $star := index . 1 }} 127 + {{ with $star }} 128 + {{ $starrerHandle := resolve .StarredByDid }} 129 + {{ $repoOwnerHandle := resolve .Repo.Did }} 130 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 + starred 133 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 + </a> 136 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 + </div> 138 + {{ with .Repo }} 139 + {{ template "user/fragments/repoCard" (list $root . true) }} 140 + {{ end }} 141 + {{ end }} 142 + {{ end }} 143 + 144 + 145 + {{ define "followEvent" }} 146 + {{ $root := index . 0 }} 147 + {{ $follow := index . 1 }} 148 + {{ $profile := index . 2 }} 149 + {{ $stat := index . 3 }} 150 + 151 + {{ $userHandle := resolve $follow.UserDid }} 152 + {{ $subjectHandle := resolve $follow.SubjectDid }} 153 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 + {{ template "user/fragments/picHandleLink" $userHandle }} 155 + followed 156 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 + </div> 159 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 + </div> 163 + 164 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 + <a href="/{{ $subjectHandle }}"> 166 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 + </a> 168 + {{ with $profile }} 169 + {{ with .Description }} 170 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 + {{ end }} 172 + {{ end }} 173 + {{ with $stat }} 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 175 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 + <span id="followers">{{ .Followers }} followers</span> 177 + <span class="select-none after:content-['ยท']"></span> 178 + <span id="following">{{ .Following }} following</span> 179 + </div> 180 + {{ end }} 181 + </div> 182 + </div> 183 + {{ end }}
-162
appview/pages/templates/timeline.html
··· 1 - {{ define "title" }}timeline{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="timeline ยท tangled" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 7 - <meta property="og:description" content="see what's tangling" /> 8 - {{ end }} 9 - 10 - {{ define "topbar" }} 11 - {{ template "layouts/topbar" $ }} 12 - {{ end }} 13 - 14 - {{ define "content" }} 15 - {{ with .LoggedInUser }} 16 - {{ block "timeline" $ }}{{ end }} 17 - {{ else }} 18 - {{ block "hero" $ }}{{ end }} 19 - {{ block "timeline" $ }}{{ end }} 20 - {{ end }} 21 - {{ end }} 22 - 23 - {{ define "hero" }} 24 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 26 - 27 - <p class="text-lg"> 28 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 29 - </p> 30 - <p class="text-lg"> 31 - we envision a place where developers have complete ownership of their 32 - code, open source communities can freely self-govern and most 33 - importantly, coding can be social and fun again. 34 - </p> 35 - 36 - <div class="flex gap-6 items-center"> 37 - <a href="/signup" class="no-underline hover:no-underline "> 38 - <button class="btn-create flex gap-2 px-4 items-center"> 39 - join now {{ i "arrow-right" "size-4" }} 40 - </button> 41 - </a> 42 - </div> 43 - </div> 44 - {{ end }} 45 - 46 - {{ define "timeline" }} 47 - <div> 48 - <div class="p-6"> 49 - <p class="text-xl font-bold dark:text-white">Timeline</p> 50 - </div> 51 - 52 - <div class="flex flex-col gap-4"> 53 - {{ range $i, $e := .Timeline }} 54 - <div class="relative"> 55 - {{ if ne $i 0 }} 56 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 57 - {{ end }} 58 - {{ with $e }} 59 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 60 - {{ if .Repo }} 61 - {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 62 - {{ else if .Star }} 63 - {{ block "starEvent" (list $ .Star) }} {{ end }} 64 - {{ else if .Follow }} 65 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 66 - {{ end }} 67 - </div> 68 - {{ end }} 69 - </div> 70 - {{ end }} 71 - </div> 72 - </div> 73 - {{ end }} 74 - 75 - {{ define "repoEvent" }} 76 - {{ $root := index . 0 }} 77 - {{ $repo := index . 1 }} 78 - {{ $source := index . 2 }} 79 - {{ $userHandle := resolve $repo.Did }} 80 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 81 - {{ template "user/fragments/picHandleLink" $repo.Did }} 82 - {{ with $source }} 83 - {{ $sourceDid := resolve .Did }} 84 - forked 85 - <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 86 - {{ $sourceDid }}/{{ .Name }} 87 - </a> 88 - to 89 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 90 - {{ else }} 91 - created 92 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 93 - {{ $repo.Name }} 94 - </a> 95 - {{ end }} 96 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 97 - </div> 98 - {{ with $repo }} 99 - {{ template "user/fragments/repoCard" (list $root . true) }} 100 - {{ end }} 101 - {{ end }} 102 - 103 - {{ define "starEvent" }} 104 - {{ $root := index . 0 }} 105 - {{ $star := index . 1 }} 106 - {{ with $star }} 107 - {{ $starrerHandle := resolve .StarredByDid }} 108 - {{ $repoOwnerHandle := resolve .Repo.Did }} 109 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 110 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 111 - starred 112 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 113 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 114 - </a> 115 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 116 - </div> 117 - {{ with .Repo }} 118 - {{ template "user/fragments/repoCard" (list $root . true) }} 119 - {{ end }} 120 - {{ end }} 121 - {{ end }} 122 - 123 - 124 - {{ define "followEvent" }} 125 - {{ $root := index . 0 }} 126 - {{ $follow := index . 1 }} 127 - {{ $profile := index . 2 }} 128 - {{ $stat := index . 3 }} 129 - 130 - {{ $userHandle := resolve $follow.UserDid }} 131 - {{ $subjectHandle := resolve $follow.SubjectDid }} 132 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 133 - {{ template "user/fragments/picHandleLink" $userHandle }} 134 - followed 135 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 136 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 137 - </div> 138 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 139 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 140 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 141 - </div> 142 - 143 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 144 - <a href="/{{ $subjectHandle }}"> 145 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 146 - </a> 147 - {{ with $profile }} 148 - {{ with .Description }} 149 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 150 - {{ end }} 151 - {{ end }} 152 - {{ with $stat }} 153 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 154 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 155 - <span id="followers">{{ .Followers }} followers</span> 156 - <span class="select-none after:content-['ยท']"></span> 157 - <span id="following">{{ .Following }} following</span> 158 - </div> 159 - {{ end }} 160 - </div> 161 - </div> 162 - {{ end }}
···
+5 -5
appview/pages/templates/user/fragments/repoCard.html
··· 4 {{ $fullName := index . 2 }} 5 6 {{ with $repo }} 7 - <div class="py-4 px-6 gap-2 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 8 <div class="font-medium dark:text-white flex items-center"> 9 {{ if .Source }} 10 {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} ··· 14 15 {{ $repoOwner := resolve .Did }} 16 {{- if $fullName -}} 17 - <a href="/{{ $repoOwner }}/{{ .Name }}">{{ $repoOwner }}/{{ .Name }}</a> 18 {{- else -}} 19 - <a href="/{{ $repoOwner }}/{{ .Name }}">{{ .Name }}</a> 20 {{- end -}} 21 </div> 22 {{ with .Description }} 23 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 24 - {{ . }} 25 </div> 26 {{ end }} 27
··· 4 {{ $fullName := index . 2 }} 5 6 {{ with $repo }} 7 + <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 8 <div class="font-medium dark:text-white flex items-center"> 9 {{ if .Source }} 10 {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} ··· 14 15 {{ $repoOwner := resolve .Did }} 16 {{- if $fullName -}} 17 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 18 {{- else -}} 19 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 20 {{- end -}} 21 </div> 22 {{ with .Description }} 23 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 24 + {{ . | description }} 25 </div> 26 {{ end }} 27
+6
appview/pulls/pulls.go
··· 19 "tangled.sh/tangled.sh/core/appview/notify" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 "tangled.sh/tangled.sh/core/idresolver" 24 "tangled.sh/tangled.sh/core/knotclient" ··· 737 if isPatchBased && !patchutil.IsFormatPatch(patch) { 738 if title == "" { 739 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 740 return 741 } 742 }
··· 19 "tangled.sh/tangled.sh/core/appview/notify" 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 + "tangled.sh/tangled.sh/core/appview/pages/markup" 23 "tangled.sh/tangled.sh/core/appview/reporesolver" 24 "tangled.sh/tangled.sh/core/idresolver" 25 "tangled.sh/tangled.sh/core/knotclient" ··· 738 if isPatchBased && !patchutil.IsFormatPatch(patch) { 739 if title == "" { 740 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 741 + return 742 + } 743 + sanitizer := markup.NewSanitizer() 744 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 745 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 746 return 747 } 748 }
+165
appview/repo/feed.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/reporesolver" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/feeds" 16 + ) 17 + 18 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 19 + const feedLimitPerType = 100 20 + 21 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 22 + if err != nil { 23 + return nil, err 24 + } 25 + 26 + issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + if err != nil { 28 + return nil, err 29 + } 30 + 31 + feed := &feeds.Feed{ 32 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 33 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 34 + Items: make([]*feeds.Item, 0), 35 + Updated: time.UnixMilli(0), 36 + } 37 + 38 + for _, pull := range pulls { 39 + items, err := rp.createPullItems(ctx, pull, f) 40 + if err != nil { 41 + return nil, err 42 + } 43 + feed.Items = append(feed.Items, items...) 44 + } 45 + 46 + for _, issue := range issues { 47 + item, err := rp.createIssueItem(ctx, issue, f) 48 + if err != nil { 49 + return nil, err 50 + } 51 + feed.Items = append(feed.Items, item) 52 + } 53 + 54 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 55 + if a.Created.After(b.Created) { 56 + return -1 57 + } 58 + return 1 59 + }) 60 + 61 + if len(feed.Items) > 0 { 62 + feed.Updated = feed.Items[0].Created 63 + } 64 + 65 + return feed, nil 66 + } 67 + 68 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 69 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + var items []*feeds.Item 75 + 76 + state := rp.getPullState(pull) 77 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 78 + 79 + mainItem := &feeds.Item{ 80 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 81 + Description: description, 82 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 83 + Created: pull.Created, 84 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 85 + } 86 + items = append(items, mainItem) 87 + 88 + for _, round := range pull.Submissions { 89 + if round == nil || round.RoundNumber == 0 { 90 + continue 91 + } 92 + 93 + roundItem := &feeds.Item{ 94 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 95 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 96 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 97 + Created: round.Created, 98 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 99 + } 100 + items = append(items, roundItem) 101 + } 102 + 103 + return items, nil 104 + } 105 + 106 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 108 + if err != nil { 109 + return nil, err 110 + } 111 + 112 + state := "closed" 113 + if issue.Open { 114 + state = "opened" 115 + } 116 + 117 + return &feeds.Item{ 118 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 119 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 120 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 121 + Created: issue.Created, 122 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 123 + }, nil 124 + } 125 + 126 + func (rp *Repo) getPullState(pull *db.Pull) string { 127 + if pull.State == db.PullOpen { 128 + return "opened" 129 + } 130 + return pull.State.String() 131 + } 132 + 133 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 134 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 135 + 136 + if pull.State == db.PullMerged { 137 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 138 + } 139 + 140 + return fmt.Sprintf("%s in %s", base, repoName) 141 + } 142 + 143 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 144 + f, err := rp.repoResolver.Resolve(r) 145 + if err != nil { 146 + log.Println("failed to fully resolve repo:", err) 147 + return 148 + } 149 + 150 + feed, err := rp.getRepoFeed(r.Context(), f) 151 + if err != nil { 152 + log.Println("failed to get repo feed:", err) 153 + rp.pages.Error500(w) 154 + return 155 + } 156 + 157 + atom, err := feed.ToAtom() 158 + if err != nil { 159 + rp.pages.Error500(w) 160 + return 161 + } 162 + 163 + w.Header().Set("content-type", "application/atom+xml") 164 + w.Write([]byte(atom)) 165 + }
+12 -9
appview/repo/index.go
··· 24 25 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 ref := chi.URLParam(r, "ref") 27 f, err := rp.repoResolver.Resolve(r) 28 if err != nil { 29 log.Println("failed to fully resolve repo", err) ··· 118 119 var forkInfo *types.ForkInfo 120 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 121 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 122 if err != nil { 123 log.Printf("Failed to fetch fork information: %v", err) 124 return ··· 126 } 127 128 // TODO: a bit dirty 129 - languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 130 if err != nil { 131 log.Printf("failed to compute language percentages: %s", err) 132 // non-fatal ··· 161 func (rp *Repo) getLanguageInfo( 162 f *reporesolver.ResolvedRepo, 163 signedClient *knotclient.SignedClient, 164 isDefaultRef bool, 165 ) ([]types.RepoLanguageDetails, error) { 166 // first attempt to fetch from db 167 langs, err := db.GetRepoLanguages( 168 rp.db, 169 db.FilterEq("repo_at", f.RepoAt()), 170 - db.FilterEq("ref", f.Ref), 171 ) 172 173 if err != nil || langs == nil { 174 // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, f.Ref) 176 if err != nil { 177 return nil, err 178 } ··· 183 for l, s := range ls.Languages { 184 langs = append(langs, db.RepoLanguage{ 185 RepoAt: f.RepoAt(), 186 - Ref: f.Ref, 187 IsDefaultRef: isDefaultRef, 188 Language: l, 189 Bytes: s, ··· 234 repoInfo repoinfo.RepoInfo, 235 rp *Repo, 236 f *reporesolver.ResolvedRepo, 237 user *oauth.User, 238 signedClient *knotclient.SignedClient, 239 ) (*types.ForkInfo, error) { ··· 264 } 265 266 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 267 - return branch.Name == f.Ref 268 }) { 269 forkInfo.Status = types.MissingBranch 270 return &forkInfo, nil 271 } 272 273 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 274 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 275 log.Printf("failed to update tracking branch: %s", err) 276 return nil, err 277 } 278 279 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 281 var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, f.Ref, hiddenRef) 283 if err != nil { 284 log.Printf("failed to check if fork is ahead/behind: %s", err) 285 return nil, err
··· 24 25 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 26 ref := chi.URLParam(r, "ref") 27 + 28 f, err := rp.repoResolver.Resolve(r) 29 if err != nil { 30 log.Println("failed to fully resolve repo", err) ··· 119 120 var forkInfo *types.ForkInfo 121 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 122 + forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient) 123 if err != nil { 124 log.Printf("Failed to fetch fork information: %v", err) 125 return ··· 127 } 128 129 // TODO: a bit dirty 130 + languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "") 131 if err != nil { 132 log.Printf("failed to compute language percentages: %s", err) 133 // non-fatal ··· 162 func (rp *Repo) getLanguageInfo( 163 f *reporesolver.ResolvedRepo, 164 signedClient *knotclient.SignedClient, 165 + currentRef string, 166 isDefaultRef bool, 167 ) ([]types.RepoLanguageDetails, error) { 168 // first attempt to fetch from db 169 langs, err := db.GetRepoLanguages( 170 rp.db, 171 db.FilterEq("repo_at", f.RepoAt()), 172 + db.FilterEq("ref", currentRef), 173 ) 174 175 if err != nil || langs == nil { 176 // non-fatal, fetch langs from ks 177 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 178 if err != nil { 179 return nil, err 180 } ··· 185 for l, s := range ls.Languages { 186 langs = append(langs, db.RepoLanguage{ 187 RepoAt: f.RepoAt(), 188 + Ref: currentRef, 189 IsDefaultRef: isDefaultRef, 190 Language: l, 191 Bytes: s, ··· 236 repoInfo repoinfo.RepoInfo, 237 rp *Repo, 238 f *reporesolver.ResolvedRepo, 239 + currentRef string, 240 user *oauth.User, 241 signedClient *knotclient.SignedClient, 242 ) (*types.ForkInfo, error) { ··· 267 } 268 269 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 270 + return branch.Name == currentRef 271 }) { 272 forkInfo.Status = types.MissingBranch 273 return &forkInfo, nil 274 } 275 276 + newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef) 277 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 278 log.Printf("failed to update tracking branch: %s", err) 279 return nil, err 280 } 281 282 + hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef) 283 284 var status types.AncestorCheckResponse 285 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef) 286 if err != nil { 287 log.Printf("failed to check if fork is ahead/behind: %s", err) 288 return nil, err
+25 -3
appview/repo/repo.go
··· 612 if !rp.config.Core.Dev { 613 protocol = "https" 614 } 615 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 616 - resp, err := http.Get(blobURL) 617 if err != nil { 618 - log.Println("failed to reach knotserver:", err) 619 rp.pages.Error503(w) 620 return 621 } 622 defer resp.Body.Close() 623 624 if resp.StatusCode != http.StatusOK { 625 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) ··· 1315 } 1316 1317 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1318 user := rp.oauth.GetUser(r) 1319 f, err := rp.repoResolver.Resolve(r) 1320 if err != nil { ··· 1345 forkName := fmt.Sprintf("%s", f.Name) 1346 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1347 1348 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1349 if err != nil { 1350 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1351 return
··· 612 if !rp.config.Core.Dev { 613 protocol = "https" 614 } 615 + 616 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 617 + 618 + req, err := http.NewRequest("GET", blobURL, nil) 619 if err != nil { 620 + log.Println("failed to create request", err) 621 + return 622 + } 623 + 624 + // forward the If-None-Match header 625 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 626 + req.Header.Set("If-None-Match", clientETag) 627 + } 628 + 629 + client := &http.Client{} 630 + resp, err := client.Do(req) 631 + if err != nil { 632 + log.Println("failed to reach knotserver", err) 633 rp.pages.Error503(w) 634 return 635 } 636 defer resp.Body.Close() 637 + 638 + // forward 304 not modified 639 + if resp.StatusCode == http.StatusNotModified { 640 + w.WriteHeader(http.StatusNotModified) 641 + return 642 + } 643 644 if resp.StatusCode != http.StatusOK { 645 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) ··· 1335 } 1336 1337 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1338 + ref := chi.URLParam(r, "ref") 1339 + 1340 user := rp.oauth.GetUser(r) 1341 f, err := rp.repoResolver.Resolve(r) 1342 if err != nil { ··· 1367 forkName := fmt.Sprintf("%s", f.Name) 1368 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1369 1370 + _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref) 1371 if err != nil { 1372 rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1373 return
+1
appview/repo/router.go
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 r.Get("/commits/{ref}", rp.RepoLog) 14 r.Route("/tree/{ref}", func(r chi.Router) { 15 r.Get("/", rp.RepoIndex)
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 14 r.Get("/commits/{ref}", rp.RepoLog) 15 r.Route("/tree/{ref}", func(r chi.Router) { 16 r.Get("/", rp.RepoIndex)
+17 -51
appview/reporesolver/resolver.go
··· 7 "fmt" 8 "log" 9 "net/http" 10 - "net/url" 11 "path" 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" ··· 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 22 "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/knotclient" 24 "tangled.sh/tangled.sh/core/rbac" 25 ) 26 27 type ResolvedRepo struct { 28 db.Repo 29 - OwnerId identity.Identity 30 - Ref string 31 - CurrentDir string 32 33 rr *RepoResolver 34 } ··· 56 return nil, fmt.Errorf("malformed middleware") 57 } 58 59 ref := chi.URLParam(r, "ref") 60 61 - if ref == "" { 62 - us, err := knotclient.NewUnsignedClient(repo.Knot, rr.config.Core.Dev) 63 - if err != nil { 64 - return nil, err 65 - } 66 - 67 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repo.Name) 68 - if err != nil { 69 - return nil, err 70 - } 71 - 72 - ref = defaultBranch.Branch 73 - } 74 - 75 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 76 - 77 return &ResolvedRepo{ 78 Repo: *repo, 79 OwnerId: id, 80 - Ref: ref, 81 CurrentDir: currentDir, 82 83 rr: rr, 84 }, nil ··· 195 } 196 197 knot := f.Knot 198 - var disableFork bool 199 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 200 - if err != nil { 201 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 202 - } else { 203 - result, err := us.Branches(f.OwnerDid(), f.Name) 204 - if err != nil { 205 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.Name, err) 206 - } 207 - 208 - if len(result.Branches) == 0 { 209 - disableFork = true 210 - } 211 - } 212 213 repoInfo := repoinfo.RepoInfo{ 214 OwnerDid: f.OwnerDid(), ··· 216 Name: f.Name, 217 RepoAt: repoAt, 218 Description: f.Description, 219 - Ref: f.Ref, 220 IsStarred: isStarred, 221 Knot: knot, 222 Spindle: f.Spindle, ··· 226 IssueCount: issueCount, 227 PullCount: pullCount, 228 }, 229 - DisableFork: disableFork, 230 - CurrentDir: f.CurrentDir, 231 } 232 233 if sourceRepo != nil { ··· 251 // after the ref. for example: 252 // 253 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 254 - func extractPathAfterRef(fullPath, ref string) string { 255 fullPath = strings.TrimPrefix(fullPath, "/") 256 257 - ref = url.PathEscape(ref) 258 259 - prefixes := []string{ 260 - fmt.Sprintf("blob/%s/", ref), 261 - fmt.Sprintf("tree/%s/", ref), 262 - fmt.Sprintf("raw/%s/", ref), 263 - } 264 265 - for _, prefix := range prefixes { 266 - idx := strings.Index(fullPath, prefix) 267 - if idx != -1 { 268 - return fullPath[idx+len(prefix):] 269 - } 270 } 271 272 return ""
··· 7 "fmt" 8 "log" 9 "net/http" 10 "path" 11 + "regexp" 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" ··· 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 22 "tangled.sh/tangled.sh/core/idresolver" 23 "tangled.sh/tangled.sh/core/rbac" 24 ) 25 26 type ResolvedRepo struct { 27 db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 31 32 rr *RepoResolver 33 } ··· 55 return nil, fmt.Errorf("malformed middleware") 56 } 57 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 59 ref := chi.URLParam(r, "ref") 60 61 return &ResolvedRepo{ 62 Repo: *repo, 63 OwnerId: id, 64 CurrentDir: currentDir, 65 + Ref: ref, 66 67 rr: rr, 68 }, nil ··· 179 } 180 181 knot := f.Knot 182 183 repoInfo := repoinfo.RepoInfo{ 184 OwnerDid: f.OwnerDid(), ··· 186 Name: f.Name, 187 RepoAt: repoAt, 188 Description: f.Description, 189 IsStarred: isStarred, 190 Knot: knot, 191 Spindle: f.Spindle, ··· 195 IssueCount: issueCount, 196 PullCount: pullCount, 197 }, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 200 } 201 202 if sourceRepo != nil { ··· 220 // after the ref. for example: 221 // 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 223 + func extractPathAfterRef(fullPath string) string { 224 fullPath = strings.TrimPrefix(fullPath, "/") 225 226 + // match blob/, tree/, or raw/ followed by any ref and then a slash 227 + // 228 + // captures everything after the final slash 229 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 230 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 233 234 + if len(matches) > 1 { 235 + return matches[1] 236 } 237 238 return ""
+9 -12
appview/state/git_http.go
··· 3 import ( 4 "fmt" 5 "io" 6 "net/http" 7 8 "github.com/bluesky-social/indigo/atproto/identity" 9 "github.com/go-chi/chi/v5" 10 ) 11 12 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 user := r.Context().Value("resolvedId").(identity.Identity) 14 - knot := r.Context().Value("knot").(string) 15 - repo := chi.URLParam(r, "repo") 16 17 scheme := "https" 18 if s.config.Core.Dev { 19 scheme = "http" 20 } 21 22 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 23 s.proxyRequest(w, r, targetURL) 24 25 } ··· 30 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 31 return 32 } 33 - knot := r.Context().Value("knot").(string) 34 - repo := chi.URLParam(r, "repo") 35 36 scheme := "https" 37 if s.config.Core.Dev { 38 scheme = "http" 39 } 40 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 42 s.proxyRequest(w, r, targetURL) 43 } 44 ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 - knot := r.Context().Value("knot").(string) 52 - repo := chi.URLParam(r, "repo") 53 54 scheme := "https" 55 if s.config.Core.Dev { 56 scheme = "http" 57 } 58 59 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 60 s.proxyRequest(w, r, targetURL) 61 } 62 ··· 85 defer resp.Body.Close() 86 87 // Copy response headers 88 - for k, v := range resp.Header { 89 - w.Header()[k] = v 90 - } 91 92 // Set response status code 93 w.WriteHeader(resp.StatusCode)
··· 3 import ( 4 "fmt" 5 "io" 6 + "maps" 7 "net/http" 8 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/appview/db" 12 ) 13 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 + repo := r.Context().Value("repo").(*db.Repo) 17 18 scheme := "https" 19 if s.config.Core.Dev { 20 scheme = "http" 21 } 22 23 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 24 s.proxyRequest(w, r, targetURL) 25 26 } ··· 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 return 33 } 34 + repo := r.Context().Value("repo").(*db.Repo) 35 36 scheme := "https" 37 if s.config.Core.Dev { 38 scheme = "http" 39 } 40 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 s.proxyRequest(w, r, targetURL) 43 } 44 ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 + repo := r.Context().Value("repo").(*db.Repo) 52 53 scheme := "https" 54 if s.config.Core.Dev { 55 scheme = "http" 56 } 57 58 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 59 s.proxyRequest(w, r, targetURL) 60 } 61 ··· 84 defer resp.Body.Close() 85 86 // Copy response headers 87 + maps.Copy(w.Header(), resp.Header) 88 89 // Set response status code 90 w.WriteHeader(resp.StatusCode)
+95 -82
appview/state/profile.go
··· 89 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 90 } 91 92 - var didsToResolve []string 93 - for _, r := range collaboratingRepos { 94 - didsToResolve = append(didsToResolve, r.Did) 95 - } 96 - for _, byMonth := range timeline.ByMonth { 97 - for _, pe := range byMonth.PullEvents.Items { 98 - didsToResolve = append(didsToResolve, pe.Repo.Did) 99 - } 100 - for _, ie := range byMonth.IssueEvents.Items { 101 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 102 - } 103 - for _, re := range byMonth.RepoEvents { 104 - didsToResolve = append(didsToResolve, re.Repo.Did) 105 - if re.Source != nil { 106 - didsToResolve = append(didsToResolve, re.Source.Did) 107 - } 108 - } 109 - } 110 - 111 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 112 if err != nil { 113 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) ··· 194 }) 195 } 196 197 - func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed { 198 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 199 if !ok { 200 s.pages.Error404(w) 201 - return nil 202 } 203 204 - feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String()) 205 if err != nil { 206 s.pages.Error500(w) 207 - return nil 208 } 209 210 - return feed 211 - } 212 - 213 - func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 214 - feed := s.feedFromRequest(w, r) 215 if feed == nil { 216 return 217 } ··· 226 w.Write([]byte(atom)) 227 } 228 229 - func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) { 230 - timeline, err := db.MakeProfileTimeline(s.db, did) 231 if err != nil { 232 return nil, err 233 } 234 235 author := &feeds.Author{ 236 - Name: fmt.Sprintf("@%s", handle), 237 } 238 - feed := &feeds.Feed{ 239 - Title: fmt.Sprintf("timeline feed for %s", author.Name), 240 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"}, 241 Items: make([]*feeds.Item, 0), 242 Updated: time.UnixMilli(0), 243 Author: author, 244 } 245 for _, byMonth := range timeline.ByMonth { 246 - for _, pull := range byMonth.PullEvents.Items { 247 - owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 248 - if err != nil { 249 - return nil, err 250 - } 251 - feed.Items = append(feed.Items, &feeds.Item{ 252 - Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 253 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 254 - Created: pull.Created, 255 - Author: author, 256 - }) 257 - for _, submission := range pull.Submissions { 258 - feed.Items = append(feed.Items, &feeds.Item{ 259 - Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name), 260 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 261 - Created: submission.Created, 262 - Author: author, 263 - }) 264 - } 265 } 266 - for _, issue := range byMonth.IssueEvents.Items { 267 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 268 - if err != nil { 269 - return nil, err 270 - } 271 - feed.Items = append(feed.Items, &feeds.Item{ 272 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 273 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 274 - Created: issue.Created, 275 - Author: author, 276 - }) 277 } 278 - for _, repo := range byMonth.RepoEvents { 279 - var title string 280 - if repo.Source != nil { 281 - id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 282 - if err != nil { 283 - return nil, err 284 - } 285 - title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name) 286 - } else { 287 - title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 288 - } 289 - feed.Items = append(feed.Items, &feeds.Item{ 290 - Title: title, 291 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"}, 292 - Created: repo.Repo.Created, 293 - Author: author, 294 - }) 295 } 296 } 297 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 298 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 299 }) 300 if len(feed.Items) > 0 { 301 feed.Updated = feed.Items[0].Created 302 } 303 304 - return feed, nil 305 } 306 307 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
··· 89 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 90 } 91 92 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 93 if err != nil { 94 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) ··· 175 }) 176 } 177 178 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 179 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 180 if !ok { 181 s.pages.Error404(w) 182 + return 183 } 184 185 + feed, err := s.getProfileFeed(r.Context(), &ident) 186 if err != nil { 187 s.pages.Error500(w) 188 + return 189 } 190 191 if feed == nil { 192 return 193 } ··· 202 w.Write([]byte(atom)) 203 } 204 205 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 206 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 207 if err != nil { 208 return nil, err 209 } 210 211 author := &feeds.Author{ 212 + Name: fmt.Sprintf("@%s", id.Handle), 213 } 214 + 215 + feed := feeds.Feed{ 216 + Title: fmt.Sprintf("%s's timeline", author.Name), 217 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 218 Items: make([]*feeds.Item, 0), 219 Updated: time.UnixMilli(0), 220 Author: author, 221 } 222 + 223 for _, byMonth := range timeline.ByMonth { 224 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 225 + return nil, err 226 } 227 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 228 + return nil, err 229 } 230 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 231 + return nil, err 232 } 233 } 234 + 235 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 236 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 237 }) 238 + 239 if len(feed.Items) > 0 { 240 feed.Updated = feed.Items[0].Created 241 } 242 243 + return &feed, nil 244 + } 245 + 246 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 247 + for _, pull := range pulls { 248 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + // Add pull request creation item 254 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 255 + } 256 + return nil 257 + } 258 + 259 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 260 + for _, issue := range issues { 261 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 262 + if err != nil { 263 + return err 264 + } 265 + 266 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 267 + } 268 + return nil 269 + } 270 + 271 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 272 + for _, repo := range repos { 273 + item, err := s.createRepoItem(ctx, repo, author) 274 + if err != nil { 275 + return err 276 + } 277 + feed.Items = append(feed.Items, item) 278 + } 279 + return nil 280 + } 281 + 282 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 283 + return &feeds.Item{ 284 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 285 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 286 + Created: pull.Created, 287 + Author: author, 288 + } 289 + } 290 + 291 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 292 + return &feeds.Item{ 293 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 294 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 295 + Created: issue.Created, 296 + Author: author, 297 + } 298 + } 299 + 300 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 301 + var title string 302 + if repo.Source != nil { 303 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 304 + if err != nil { 305 + return nil, err 306 + } 307 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 308 + } else { 309 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 310 + } 311 + 312 + return &feeds.Item{ 313 + Title: title, 314 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 315 + Created: repo.Repo.Created, 316 + Author: author, 317 + }, nil 318 } 319 320 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+5 -2
appview/state/router.go
··· 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 38 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 39 pat := chi.URLParam(r, "*") 40 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 41 - s.UserRouter(&middleware).ServeHTTP(w, r) 42 } else { 43 // Check if the first path element is a valid handle without '@' or a flattened DID 44 pathParts := strings.SplitN(pat, "/", 2) ··· 61 return 62 } 63 } 64 - s.StandardRouter(&middleware).ServeHTTP(w, r) 65 } 66 }) 67
··· 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 38 + userRouter := s.UserRouter(&middleware) 39 + standardRouter := s.StandardRouter(&middleware) 40 + 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 42 pat := chi.URLParam(r, "*") 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 44 + userRouter.ServeHTTP(w, r) 45 } else { 46 // Check if the first path element is a valid handle without '@' or a flattened DID 47 pathParts := strings.SplitN(pat, "/", 2) ··· 64 return 65 } 66 } 67 + standardRouter.ServeHTTP(w, r) 68 } 69 }) 70
+10
appview/state/state.go
··· 94 tangled.SpindleMemberNSID, 95 tangled.SpindleNSID, 96 tangled.StringNSID, 97 }, 98 nil, 99 slog.Default(), ··· 193 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 194 } 195 196 s.pages.Timeline(w, pages.TimelineParams{ 197 LoggedInUser: user, 198 Timeline: timeline, 199 }) 200 } 201
··· 94 tangled.SpindleMemberNSID, 95 tangled.SpindleNSID, 96 tangled.StringNSID, 97 + tangled.RepoIssueNSID, 98 + tangled.RepoIssueCommentNSID, 99 }, 100 nil, 101 slog.Default(), ··· 195 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 196 } 197 198 + repos, err := db.GetTopStarredReposLastWeek(s.db) 199 + if err != nil { 200 + log.Println(err) 201 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 202 + return 203 + } 204 + 205 s.pages.Timeline(w, pages.TimelineParams{ 206 LoggedInUser: user, 207 Timeline: timeline, 208 + Repos: repos, 209 }) 210 } 211
+23 -12
appview/strings/strings.go
··· 7 "path" 8 "slices" 9 "strconv" 10 - "strings" 11 "time" 12 13 "tangled.sh/tangled.sh/core/api/tangled" ··· 44 r := chi.NewRouter() 45 46 r. 47 With(mw.ResolveIdent()). 48 Route("/{user}", func(r chi.Router) { 49 r.Get("/", s.dashboard) ··· 70 return r 71 } 72 73 func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 l := s.Logger.With("handler", "contents") 75 ··· 91 92 strings, err := db.GetStrings( 93 s.Db, 94 db.FilterEq("did", id.DID), 95 db.FilterEq("rkey", rkey), 96 ) ··· 154 155 all, err := db.GetStrings( 156 s.Db, 157 db.FilterEq("did", id.DID), 158 ) 159 if err != nil { ··· 225 // get the string currently being edited 226 all, err := db.GetStrings( 227 s.Db, 228 db.FilterEq("did", id.DID), 229 db.FilterEq("rkey", rkey), 230 ) ··· 266 fail("Empty filename.", nil) 267 return 268 } 269 - if !strings.Contains(filename, ".") { 270 - // TODO: make this a htmx form validation 271 - fail("No extension provided for filename.", nil) 272 - return 273 - } 274 275 content := r.FormValue("content") 276 if content == "" { ··· 353 fail("Empty filename.", nil) 354 return 355 } 356 - if !strings.Contains(filename, ".") { 357 - // TODO: make this a htmx form validation 358 - fail("No extension provided for filename.", nil) 359 - return 360 - } 361 362 content := r.FormValue("content") 363 if content == "" { ··· 434 } 435 436 if user.Did != id.DID.String() { 437 - fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 438 return 439 } 440
··· 7 "path" 8 "slices" 9 "strconv" 10 "time" 11 12 "tangled.sh/tangled.sh/core/api/tangled" ··· 43 r := chi.NewRouter() 44 45 r. 46 + Get("/", s.timeline) 47 + 48 + r. 49 With(mw.ResolveIdent()). 50 Route("/{user}", func(r chi.Router) { 51 r.Get("/", s.dashboard) ··· 72 return r 73 } 74 75 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 76 + l := s.Logger.With("handler", "timeline") 77 + 78 + strings, err := db.GetStrings(s.Db, 50) 79 + if err != nil { 80 + l.Error("failed to fetch string", "err", err) 81 + w.WriteHeader(http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 86 + LoggedInUser: s.OAuth.GetUser(r), 87 + Strings: strings, 88 + }) 89 + } 90 + 91 func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 92 l := s.Logger.With("handler", "contents") 93 ··· 109 110 strings, err := db.GetStrings( 111 s.Db, 112 + 0, 113 db.FilterEq("did", id.DID), 114 db.FilterEq("rkey", rkey), 115 ) ··· 173 174 all, err := db.GetStrings( 175 s.Db, 176 + 0, 177 db.FilterEq("did", id.DID), 178 ) 179 if err != nil { ··· 245 // get the string currently being edited 246 all, err := db.GetStrings( 247 s.Db, 248 + 0, 249 db.FilterEq("did", id.DID), 250 db.FilterEq("rkey", rkey), 251 ) ··· 287 fail("Empty filename.", nil) 288 return 289 } 290 291 content := r.FormValue("content") 292 if content == "" { ··· 369 fail("Empty filename.", nil) 370 return 371 } 372 373 content := r.FormValue("content") 374 if content == "" { ··· 445 } 446 447 if user.Did != id.DID.String() { 448 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 449 return 450 } 451
+1 -1
input.css
··· 103 } 104 105 code { 106 - @apply font-mono rounded bg-gray-100 dark:bg-gray-700; 107 } 108 } 109
··· 103 } 104 105 code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 } 108 } 109
+8 -10
knotserver/git/fork.go
··· 10 ) 11 12 func Fork(repoPath, source string) error { 13 - _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 14 - URL: source, 15 - SingleBranch: false, 16 - }) 17 - 18 - if err != nil { 19 return fmt.Errorf("failed to bare clone repository: %w", err) 20 } 21 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 24 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 } 26 27 return nil 28 } 29 30 - func (g *GitRepo) Sync(branch string) error { 31 fetchOpts := &git.FetchOptions{ 32 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 34 }, 35 } 36
··· 10 ) 11 12 func Fork(repoPath, source string) error { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 15 return fmt.Errorf("failed to bare clone repository: %w", err) 16 } 17 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 21 } 22 23 return nil 24 } 25 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 29 fetchOpts := &git.FetchOptions{ 30 RefSpecs: []config.RefSpec{ 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 32 }, 33 } 34
+1
knotserver/git.go
··· 129 // If the appview gave us the repository owner's handle we can attempt to 130 // construct the correct ssh url. 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 hostname := d.c.Server.Hostname 134 if strings.Contains(hostname, ":") {
··· 129 // If the appview gave us the repository owner's handle we can attempt to 130 // construct the correct ssh url. 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 hostname := d.c.Server.Hostname 135 if strings.Contains(hostname, ":") {
+2 -2
knotserver/handler.go
··· 142 r.Delete("/", h.RemoveRepo) 143 r.Route("/fork", func(r chi.Router) { 144 r.Post("/", h.RepoFork) 145 - r.Post("/sync/{branch}", h.RepoForkSync) 146 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 147 }) 148 }) 149
··· 142 r.Delete("/", h.RemoveRepo) 143 r.Route("/fork", func(r chi.Router) { 144 r.Post("/", h.RepoFork) 145 + r.Post("/sync/*", h.RepoForkSync) 146 + r.Get("/sync/*", h.RepoForkAheadBehind) 147 }) 148 }) 149
+16 -11
knotserver/routes.go
··· 286 mimeType = "image/svg+xml" 287 } 288 289 // allow image, video, and text/plain files to be served directly 290 switch { 291 - case strings.HasPrefix(mimeType, "image/"): 292 - // allowed 293 - case strings.HasPrefix(mimeType, "video/"): 294 - // allowed 295 case strings.HasPrefix(mimeType, "text/plain"): 296 - // allowed 297 default: 298 l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 299 writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 300 return 301 } 302 303 - w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 304 - w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 305 w.Header().Set("Content-Type", mimeType) 306 w.Write(contents) 307 } ··· 710 } 711 712 func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 713 - l := h.l.With("handler", "RepoForkSync") 714 715 data := struct { 716 Did string `json:"did"` ··· 845 name = filepath.Base(source) 846 } 847 848 - branch := chi.URLParam(r, "branch") 849 branch, _ = url.PathUnescape(branch) 850 851 relativeRepoPath := filepath.Join(did, name) 852 repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 853 854 - gr, err := git.PlainOpen(repoPath) 855 if err != nil { 856 log.Println(err) 857 notFound(w) 858 return 859 } 860 861 - err = gr.Sync(branch) 862 if err != nil { 863 l.Error("error syncing repo fork", "error", err.Error()) 864 writeError(w, err.Error(), http.StatusInternalServerError)
··· 286 mimeType = "image/svg+xml" 287 } 288 289 + contentHash := sha256.Sum256(contents) 290 + eTag := fmt.Sprintf("\"%x\"", contentHash) 291 + 292 // allow image, video, and text/plain files to be served directly 293 switch { 294 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 295 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 296 + w.WriteHeader(http.StatusNotModified) 297 + return 298 + } 299 + w.Header().Set("ETag", eTag) 300 + 301 case strings.HasPrefix(mimeType, "text/plain"): 302 + w.Header().Set("Cache-Control", "public, no-cache") 303 + 304 default: 305 l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 306 writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 307 return 308 } 309 310 w.Header().Set("Content-Type", mimeType) 311 w.Write(contents) 312 } ··· 715 } 716 717 func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 718 + l := h.l.With("handler", "RepoForkAheadBehind") 719 720 data := struct { 721 Did string `json:"did"` ··· 850 name = filepath.Base(source) 851 } 852 853 + branch := chi.URLParam(r, "*") 854 branch, _ = url.PathUnescape(branch) 855 856 relativeRepoPath := filepath.Join(did, name) 857 repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 858 859 + gr, err := git.Open(repoPath, branch) 860 if err != nil { 861 log.Println(err) 862 notFound(w) 863 return 864 } 865 866 + err = gr.Sync() 867 if err != nil { 868 l.Error("error syncing repo fork", "error", err.Error()) 869 writeError(w, err.Error(), http.StatusInternalServerError)
+1 -8
lexicons/issue/comment.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": [ 13 - "issue", 14 - "body", 15 - "createdAt" 16 - ], 17 "properties": { 18 "issue": { 19 "type": "string", ··· 22 "repo": { 23 "type": "string", 24 "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 }, 29 "owner": { 30 "type": "string",
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": ["issue", "body", "createdAt"], 13 "properties": { 14 "issue": { 15 "type": "string", ··· 18 "repo": { 19 "type": "string", 20 "format": "at-uri" 21 }, 22 "owner": { 23 "type": "string",
+1 -10
lexicons/issue/issue.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 19 "properties": { 20 "repo": { 21 "type": "string", 22 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 }, 27 "owner": { 28 "type": "string",
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": ["repo", "owner", "title", "createdAt"], 13 "properties": { 14 "repo": { 15 "type": "string", 16 "format": "at-uri" 17 }, 18 "owner": { 19 "type": "string",
+7 -6
spindle/engines/nixery/engine.go
··· 195 io.Copy(os.Stdout, reader) 196 197 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 - Image: addl.image, 199 - Cmd: []string{"cat"}, 200 - OpenStdin: true, // so cat stays alive :3 201 - Tty: false, 202 - Hostname: "spindle", 203 // TODO(winter): investigate whether environment variables passed here 204 // get propagated to ContainerExec processes 205 }, &container.HostConfig{ ··· 304 envs.AddEnv(k, v) 305 } 306 envs.AddEnv("HOME", homeDir) 307 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 308 309 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 310 Cmd: []string{"bash", "-c", step.command}, 311 AttachStdout: true, 312 AttachStderr: true, 313 }) 314 if err != nil { 315 return fmt.Errorf("creating exec: %w", err)
··· 195 io.Copy(os.Stdout, reader) 196 197 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 // TODO(winter): investigate whether environment variables passed here 205 // get propagated to ContainerExec processes 206 }, &container.HostConfig{ ··· 305 envs.AddEnv(k, v) 306 } 307 envs.AddEnv("HOME", homeDir) 308 309 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 310 Cmd: []string{"bash", "-c", step.command}, 311 AttachStdout: true, 312 AttachStderr: true, 313 + Env: envs, 314 }) 315 if err != nil { 316 return fmt.Errorf("creating exec: %w", err)