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

Compare changes

Choose any two refs to compare.

+5820 -2272
+10
api/tangled/repotree.go
··· 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 // ref: The git reference used 35 Ref string `json:"ref" cborgen:"ref"` 36 } 37 38 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
··· 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // readme: Readme for this file tree 35 + Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 36 // ref: The git reference used 37 Ref string `json:"ref" cborgen:"ref"` 38 + } 39 + 40 + // RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema. 41 + type RepoTree_Readme struct { 42 + // contents: Contents of the readme file 43 + Contents string `json:"contents" cborgen:"contents"` 44 + // filename: Name of the readme file 45 + Filename string `json:"filename" cborgen:"filename"` 46 } 47 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4 -2
appview/config/config.go
··· 72 } 73 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 77 } 78 79 func (cfg RedisConfig) ToURL() string {
··· 72 } 73 74 type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 } 80 81 func (cfg RedisConfig) ToURL() string {
+172 -10
appview/db/db.go
··· 527 -- label to subscribe to 528 label_at text not null, 529 530 - unique (repo_at, label_at), 531 - foreign key (label_at) references label_definitions (at_uri) 532 ); 533 534 create table if not exists migrations ( ··· 536 name text unique 537 ); 538 539 - -- indexes for better star query performance 540 create index if not exists idx_stars_created on stars(created); 541 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 542 `) ··· 788 _, err := tx.Exec(` 789 alter table spindles add column needs_upgrade integer not null default 0; 790 `) 791 - if err != nil { 792 - return err 793 - } 794 - 795 - _, err = tx.Exec(` 796 - update spindles set needs_upgrade = 1; 797 - `) 798 return err 799 }) 800 ··· 931 _, err = tx.Exec(`drop table comments`) 932 return err 933 }) 934 935 return &DB{db}, nil 936 }
··· 527 -- label to subscribe to 528 label_at text not null, 529 530 + unique (repo_at, label_at) 531 + ); 532 + 533 + create table if not exists notifications ( 534 + id integer primary key autoincrement, 535 + recipient_did text not null, 536 + actor_did text not null, 537 + type text not null, 538 + entity_type text not null, 539 + entity_id text not null, 540 + read integer not null default 0, 541 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 542 + repo_id integer references repos(id), 543 + issue_id integer references issues(id), 544 + pull_id integer references pulls(id) 545 + ); 546 + 547 + create table if not exists notification_preferences ( 548 + id integer primary key autoincrement, 549 + user_did text not null unique, 550 + repo_starred integer not null default 1, 551 + issue_created integer not null default 1, 552 + issue_commented integer not null default 1, 553 + pull_created integer not null default 1, 554 + pull_commented integer not null default 1, 555 + followed integer not null default 1, 556 + pull_merged integer not null default 1, 557 + issue_closed integer not null default 1, 558 + email_notifications integer not null default 0 559 ); 560 561 create table if not exists migrations ( ··· 563 name text unique 564 ); 565 566 + -- indexes for better performance 567 + create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 568 + create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 569 create index if not exists idx_stars_created on stars(created); 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 571 `) ··· 817 _, err := tx.Exec(` 818 alter table spindles add column needs_upgrade integer not null default 0; 819 `) 820 return err 821 }) 822 ··· 953 _, err = tx.Exec(`drop table comments`) 954 return err 955 }) 956 + 957 + // add generated at_uri column to pulls table 958 + // 959 + // this requires a full table recreation because stored columns 960 + // cannot be added via alter 961 + // 962 + // disable foreign-keys for the next migration 963 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 + runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 + _, err := tx.Exec(` 966 + create table if not exists pulls_new ( 967 + -- identifiers 968 + id integer primary key autoincrement, 969 + pull_id integer not null, 970 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 + 972 + -- at identifiers 973 + repo_at text not null, 974 + owner_did text not null, 975 + rkey text not null, 976 + 977 + -- content 978 + title text not null, 979 + body text not null, 980 + target_branch text not null, 981 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 + 983 + -- source info 984 + source_branch text, 985 + source_repo_at text, 986 + 987 + -- stacking 988 + stack_id text, 989 + change_id text, 990 + parent_change_id text, 991 + 992 + -- meta 993 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 + 995 + -- constraints 996 + unique(repo_at, pull_id), 997 + unique(at_uri), 998 + foreign key (repo_at) references repos(at_uri) on delete cascade 999 + ); 1000 + `) 1001 + if err != nil { 1002 + return err 1003 + } 1004 + 1005 + // transfer data 1006 + _, err = tx.Exec(` 1007 + insert into pulls_new ( 1008 + id, pull_id, repo_at, owner_did, rkey, 1009 + title, body, target_branch, state, 1010 + source_branch, source_repo_at, 1011 + stack_id, change_id, parent_change_id, 1012 + created 1013 + ) 1014 + select 1015 + id, pull_id, repo_at, owner_did, rkey, 1016 + title, body, target_branch, state, 1017 + source_branch, source_repo_at, 1018 + stack_id, change_id, parent_change_id, 1019 + created 1020 + from pulls; 1021 + `) 1022 + if err != nil { 1023 + return err 1024 + } 1025 + 1026 + // drop old table 1027 + _, err = tx.Exec(`drop table pulls`) 1028 + if err != nil { 1029 + return err 1030 + } 1031 + 1032 + // rename new table 1033 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 + return err 1035 + }) 1036 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 + 1038 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 + // 1040 + // this requires a full table recreation because stored columns 1041 + // cannot be added via alter 1042 + // 1043 + // disable foreign-keys for the next migration 1044 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 + runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 + _, err := tx.Exec(` 1047 + create table if not exists pull_submissions_new ( 1048 + -- identifiers 1049 + id integer primary key autoincrement, 1050 + pull_at text not null, 1051 + 1052 + -- content, these are immutable, and require a resubmission to update 1053 + round_number integer not null default 0, 1054 + patch text, 1055 + source_rev text, 1056 + 1057 + -- meta 1058 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 + 1060 + -- constraints 1061 + unique(pull_at, round_number), 1062 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 + ); 1064 + `) 1065 + if err != nil { 1066 + return err 1067 + } 1068 + 1069 + // transfer data, constructing pull_at from pulls table 1070 + _, err = tx.Exec(` 1071 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 + select 1073 + ps.id, 1074 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 + ps.round_number, 1076 + ps.patch, 1077 + ps.created 1078 + from pull_submissions ps 1079 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 + `) 1081 + if err != nil { 1082 + return err 1083 + } 1084 + 1085 + // drop old table 1086 + _, err = tx.Exec(`drop table pull_submissions`) 1087 + if err != nil { 1088 + return err 1089 + } 1090 + 1091 + // rename new table 1092 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 + return err 1094 + }) 1095 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 1097 return &DB{db}, nil 1098 }
+13 -9
appview/db/email.go
··· 71 return did, nil 72 } 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 80 if isVerifiedFilter { 81 verifiedFilter = 1 82 } 83 84 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 88 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 92 } 93 94 query := ` ··· 104 return nil, err 105 } 106 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 110 for rows.Next() { 111 var email, did string
··· 71 return did, nil 72 } 73 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 80 if isVerifiedFilter { 81 verifiedFilter = 1 82 } 83 + 84 + assoc := make(map[string]string) 85 86 // Create placeholders for the IN clause 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 89 90 args[0] = verifiedFilter 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 98 } 99 100 query := ` ··· 110 return nil, err 111 } 112 defer rows.Close() 113 114 for rows.Next() { 115 var email, did string
+259
appview/db/issues.go
··· 237 } 238 239 sort.Slice(issues, func(i, j int) bool { 240 return issues[i].Created.After(issues[j].Created) 241 }) 242 ··· 490 491 return count, nil 492 }
··· 237 } 238 239 sort.Slice(issues, func(i, j int) bool { 240 + if issues[i].Created.Equal(issues[j].Created) { 241 + // Tiebreaker: use issue_id for stable sort 242 + return issues[i].IssueId > issues[j].IssueId 243 + } 244 return issues[i].Created.After(issues[j].Created) 245 }) 246 ··· 494 495 return count, nil 496 } 497 + 498 + func SearchIssues(e Execer, page pagination.Page, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]models.Issue, error) { 499 + var conditions []string 500 + var args []any 501 + 502 + for _, filter := range filters { 503 + conditions = append(conditions, filter.Condition()) 504 + args = append(args, filter.Arg()...) 505 + } 506 + 507 + if text != "" { 508 + searchPattern := "%" + text + "%" 509 + conditions = append(conditions, "(title like ? or body like ?)") 510 + args = append(args, searchPattern, searchPattern) 511 + } 512 + 513 + whereClause := "" 514 + if len(conditions) > 0 { 515 + whereClause = " where " + strings.Join(conditions, " and ") 516 + } 517 + 518 + pLower := FilterGte("row_num", page.Offset+1) 519 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 520 + args = append(args, pLower.Arg()...) 521 + args = append(args, pUpper.Arg()...) 522 + paginationClause := " where " + pLower.Condition() + " and " + pUpper.Condition() 523 + 524 + query := fmt.Sprintf( 525 + ` 526 + select * from ( 527 + select 528 + id, 529 + did, 530 + rkey, 531 + repo_at, 532 + issue_id, 533 + title, 534 + body, 535 + open, 536 + created, 537 + edited, 538 + deleted, 539 + row_number() over (order by created desc) as row_num 540 + from 541 + issues 542 + %s 543 + ) ranked_issues 544 + %s 545 + `, 546 + whereClause, 547 + paginationClause, 548 + ) 549 + 550 + rows, err := e.Query(query, args...) 551 + if err != nil { 552 + return nil, fmt.Errorf("failed to query issues: %w", err) 553 + } 554 + defer rows.Close() 555 + 556 + issueMap := make(map[string]*models.Issue) 557 + for rows.Next() { 558 + var issue models.Issue 559 + var createdAt string 560 + var editedAt, deletedAt sql.Null[string] 561 + var rowNum int64 562 + 563 + err := rows.Scan( 564 + &issue.Id, 565 + &issue.Did, 566 + &issue.Rkey, 567 + &issue.RepoAt, 568 + &issue.IssueId, 569 + &issue.Title, 570 + &issue.Body, 571 + &issue.Open, 572 + &createdAt, 573 + &editedAt, 574 + &deletedAt, 575 + &rowNum, 576 + ) 577 + if err != nil { 578 + return nil, fmt.Errorf("failed to scan issue: %w", err) 579 + } 580 + 581 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 582 + issue.Created = t 583 + } 584 + if editedAt.Valid { 585 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 586 + issue.Edited = &t 587 + } 588 + } 589 + if deletedAt.Valid { 590 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 591 + issue.Deleted = &t 592 + } 593 + } 594 + 595 + atUri := issue.AtUri().String() 596 + issueMap[atUri] = &issue 597 + } 598 + 599 + repoAts := make([]string, 0, len(issueMap)) 600 + for _, issue := range issueMap { 601 + repoAts = append(repoAts, string(issue.RepoAt)) 602 + } 603 + 604 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 605 + if err != nil { 606 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 607 + } 608 + 609 + repoMap := make(map[string]*models.Repo) 610 + for i := range repos { 611 + repoMap[string(repos[i].RepoAt())] = &repos[i] 612 + } 613 + 614 + for issueAt, i := range issueMap { 615 + if r, ok := repoMap[string(i.RepoAt)]; ok { 616 + i.Repo = r 617 + } else { 618 + delete(issueMap, issueAt) 619 + } 620 + } 621 + 622 + issueAts := slices.Collect(maps.Keys(issueMap)) 623 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 624 + if err != nil { 625 + return nil, fmt.Errorf("failed to query comments: %w", err) 626 + } 627 + for i := range comments { 628 + issueAt := comments[i].IssueAt 629 + if issue, ok := issueMap[issueAt]; ok { 630 + issue.Comments = append(issue.Comments, comments[i]) 631 + } 632 + } 633 + 634 + allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 635 + if err != nil { 636 + return nil, fmt.Errorf("failed to query labels: %w", err) 637 + } 638 + for issueAt, labels := range allLabels { 639 + if issue, ok := issueMap[issueAt.String()]; ok { 640 + issue.Labels = labels 641 + } 642 + } 643 + 644 + reactionCounts := make(map[string]int) 645 + if len(issueAts) > 0 { 646 + reactionArgs := make([]any, len(issueAts)) 647 + for i, v := range issueAts { 648 + reactionArgs[i] = v 649 + } 650 + rows, err := e.Query(` 651 + select thread_at, count(*) as total 652 + from reactions 653 + where thread_at in (`+strings.Repeat("?,", len(issueAts)-1)+"?"+`) 654 + group by thread_at 655 + `, reactionArgs...) 656 + if err == nil { 657 + defer rows.Close() 658 + for rows.Next() { 659 + var threadAt string 660 + var count int 661 + if err := rows.Scan(&threadAt, &count); err == nil { 662 + reactionCounts[threadAt] = count 663 + } 664 + } 665 + } 666 + } 667 + 668 + if len(labels) > 0 { 669 + if len(issueMap) > 0 { 670 + var repoAt string 671 + for _, issue := range issueMap { 672 + repoAt = string(issue.RepoAt) 673 + break 674 + } 675 + 676 + repo, err := GetRepoByAtUri(e, repoAt) 677 + if err == nil && len(repo.Labels) > 0 { 678 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 679 + if err == nil { 680 + labelNameToUri := make(map[string]string) 681 + for _, def := range labelDefs { 682 + labelNameToUri[def.Name] = def.AtUri().String() 683 + } 684 + 685 + for issueAt, issue := range issueMap { 686 + hasAllLabels := true 687 + for _, labelName := range labels { 688 + labelUri, found := labelNameToUri[labelName] 689 + if !found { 690 + hasAllLabels = false 691 + break 692 + } 693 + if !issue.Labels.ContainsLabel(labelUri) { 694 + hasAllLabels = false 695 + break 696 + } 697 + } 698 + if !hasAllLabels { 699 + delete(issueMap, issueAt) 700 + } 701 + } 702 + } 703 + } 704 + } 705 + } 706 + 707 + var issues []models.Issue 708 + for _, i := range issueMap { 709 + i.ReactionCount = reactionCounts[i.AtUri().String()] 710 + issues = append(issues, *i) 711 + } 712 + 713 + sort.Slice(issues, func(i, j int) bool { 714 + var less bool 715 + 716 + switch sortBy { 717 + case "comments": 718 + if len(issues[i].Comments) == len(issues[j].Comments) { 719 + // Tiebreaker: use issue_id for stable sort 720 + less = issues[i].IssueId > issues[j].IssueId 721 + } else { 722 + less = len(issues[i].Comments) > len(issues[j].Comments) 723 + } 724 + case "reactions": 725 + iCount := reactionCounts[issues[i].AtUri().String()] 726 + jCount := reactionCounts[issues[j].AtUri().String()] 727 + if iCount == jCount { 728 + // Tiebreaker: use issue_id for stable sort 729 + less = issues[i].IssueId > issues[j].IssueId 730 + } else { 731 + less = iCount > jCount 732 + } 733 + case "created": 734 + fallthrough 735 + default: 736 + if issues[i].Created.Equal(issues[j].Created) { 737 + // Tiebreaker: use issue_id for stable sort 738 + less = issues[i].IssueId > issues[j].IssueId 739 + } else { 740 + less = issues[i].Created.After(issues[j].Created) 741 + } 742 + } 743 + 744 + if sortOrder == "asc" { 745 + return !less 746 + } 747 + return less 748 + }) 749 + 750 + return issues, nil 751 + }
+34
appview/db/language.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) 9 ··· 82 83 return nil 84 }
··· 1 package db 2 3 import ( 4 + "database/sql" 5 "fmt" 6 "strings" 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.org/core/appview/models" 10 ) 11 ··· 84 85 return nil 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+450
appview/db/notifications.go
···
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pagination" 13 + ) 14 + 15 + func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 16 + query := ` 17 + INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 + ` 20 + 21 + result, err := d.DB.ExecContext(ctx, query, 22 + notification.RecipientDid, 23 + notification.ActorDid, 24 + string(notification.Type), 25 + notification.EntityType, 26 + notification.EntityId, 27 + notification.Read, 28 + notification.RepoId, 29 + notification.IssueId, 30 + notification.PullId, 31 + ) 32 + if err != nil { 33 + return fmt.Errorf("failed to create notification: %w", err) 34 + } 35 + 36 + id, err := result.LastInsertId() 37 + if err != nil { 38 + return fmt.Errorf("failed to get notification ID: %w", err) 39 + } 40 + 41 + notification.ID = id 42 + return nil 43 + } 44 + 45 + // GetNotificationsPaginated retrieves notifications with filters and pagination 46 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 47 + var conditions []string 48 + var args []any 49 + 50 + for _, filter := range filters { 51 + conditions = append(conditions, filter.Condition()) 52 + args = append(args, filter.Arg()...) 53 + } 54 + 55 + whereClause := "" 56 + if len(conditions) > 0 { 57 + whereClause = "WHERE " + conditions[0] 58 + for _, condition := range conditions[1:] { 59 + whereClause += " AND " + condition 60 + } 61 + } 62 + 63 + query := fmt.Sprintf(` 64 + select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 65 + from notifications 66 + %s 67 + order by created desc 68 + limit ? offset ? 69 + `, whereClause) 70 + 71 + args = append(args, page.Limit, page.Offset) 72 + 73 + rows, err := e.QueryContext(context.Background(), query, args...) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to query notifications: %w", err) 76 + } 77 + defer rows.Close() 78 + 79 + var notifications []*models.Notification 80 + for rows.Next() { 81 + var n models.Notification 82 + var typeStr string 83 + var createdStr string 84 + err := rows.Scan( 85 + &n.ID, 86 + &n.RecipientDid, 87 + &n.ActorDid, 88 + &typeStr, 89 + &n.EntityType, 90 + &n.EntityId, 91 + &n.Read, 92 + &createdStr, 93 + &n.RepoId, 94 + &n.IssueId, 95 + &n.PullId, 96 + ) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed to scan notification: %w", err) 99 + } 100 + n.Type = models.NotificationType(typeStr) 101 + n.Created, err = time.Parse(time.RFC3339, createdStr) 102 + if err != nil { 103 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 104 + } 105 + notifications = append(notifications, &n) 106 + } 107 + 108 + return notifications, nil 109 + } 110 + 111 + // GetNotificationsWithEntities retrieves notifications with their related entities 112 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 113 + var conditions []string 114 + var args []any 115 + 116 + for _, filter := range filters { 117 + conditions = append(conditions, filter.Condition()) 118 + args = append(args, filter.Arg()...) 119 + } 120 + 121 + whereClause := "" 122 + if len(conditions) > 0 { 123 + whereClause = "WHERE " + conditions[0] 124 + for _, condition := range conditions[1:] { 125 + whereClause += " AND " + condition 126 + } 127 + } 128 + 129 + query := fmt.Sprintf(` 130 + select 131 + n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 132 + n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 133 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 134 + i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 135 + p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 136 + from notifications n 137 + left join repos r on n.repo_id = r.id 138 + left join issues i on n.issue_id = i.id 139 + left join pulls p on n.pull_id = p.id 140 + %s 141 + order by n.created desc 142 + limit ? offset ? 143 + `, whereClause) 144 + 145 + args = append(args, page.Limit, page.Offset) 146 + 147 + rows, err := e.QueryContext(context.Background(), query, args...) 148 + if err != nil { 149 + return nil, fmt.Errorf("failed to query notifications with entities: %w", err) 150 + } 151 + defer rows.Close() 152 + 153 + var notifications []*models.NotificationWithEntity 154 + for rows.Next() { 155 + var n models.Notification 156 + var typeStr string 157 + var createdStr string 158 + var repo models.Repo 159 + var issue models.Issue 160 + var pull models.Pull 161 + var rId, iId, pId sql.NullInt64 162 + var rDid, rName, rDescription sql.NullString 163 + var iDid sql.NullString 164 + var iIssueId sql.NullInt64 165 + var iTitle sql.NullString 166 + var iOpen sql.NullBool 167 + var pOwnerDid sql.NullString 168 + var pPullId sql.NullInt64 169 + var pTitle sql.NullString 170 + var pState sql.NullInt64 171 + 172 + err := rows.Scan( 173 + &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 174 + &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 175 + &rId, &rDid, &rName, &rDescription, 176 + &iId, &iDid, &iIssueId, &iTitle, &iOpen, 177 + &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 178 + ) 179 + if err != nil { 180 + return nil, fmt.Errorf("failed to scan notification with entities: %w", err) 181 + } 182 + 183 + n.Type = models.NotificationType(typeStr) 184 + n.Created, err = time.Parse(time.RFC3339, createdStr) 185 + if err != nil { 186 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 187 + } 188 + 189 + nwe := &models.NotificationWithEntity{Notification: &n} 190 + 191 + // populate repo if present 192 + if rId.Valid { 193 + repo.Id = rId.Int64 194 + if rDid.Valid { 195 + repo.Did = rDid.String 196 + } 197 + if rName.Valid { 198 + repo.Name = rName.String 199 + } 200 + if rDescription.Valid { 201 + repo.Description = rDescription.String 202 + } 203 + nwe.Repo = &repo 204 + } 205 + 206 + // populate issue if present 207 + if iId.Valid { 208 + issue.Id = iId.Int64 209 + if iDid.Valid { 210 + issue.Did = iDid.String 211 + } 212 + if iIssueId.Valid { 213 + issue.IssueId = int(iIssueId.Int64) 214 + } 215 + if iTitle.Valid { 216 + issue.Title = iTitle.String 217 + } 218 + if iOpen.Valid { 219 + issue.Open = iOpen.Bool 220 + } 221 + nwe.Issue = &issue 222 + } 223 + 224 + // populate pull if present 225 + if pId.Valid { 226 + pull.ID = int(pId.Int64) 227 + if pOwnerDid.Valid { 228 + pull.OwnerDid = pOwnerDid.String 229 + } 230 + if pPullId.Valid { 231 + pull.PullId = int(pPullId.Int64) 232 + } 233 + if pTitle.Valid { 234 + pull.Title = pTitle.String 235 + } 236 + if pState.Valid { 237 + pull.State = models.PullState(pState.Int64) 238 + } 239 + nwe.Pull = &pull 240 + } 241 + 242 + notifications = append(notifications, nwe) 243 + } 244 + 245 + return notifications, nil 246 + } 247 + 248 + // GetNotifications retrieves notifications with filters 249 + func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 250 + return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 251 + } 252 + 253 + func CountNotifications(e Execer, filters ...filter) (int64, error) { 254 + var conditions []string 255 + var args []any 256 + for _, filter := range filters { 257 + conditions = append(conditions, filter.Condition()) 258 + args = append(args, filter.Arg()...) 259 + } 260 + 261 + whereClause := "" 262 + if conditions != nil { 263 + whereClause = " where " + strings.Join(conditions, " and ") 264 + } 265 + 266 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 267 + var count int64 268 + err := e.QueryRow(query, args...).Scan(&count) 269 + 270 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 271 + return 0, err 272 + } 273 + 274 + return count, nil 275 + } 276 + 277 + func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 278 + idFilter := FilterEq("id", notificationID) 279 + recipientFilter := FilterEq("recipient_did", userDID) 280 + 281 + query := fmt.Sprintf(` 282 + UPDATE notifications 283 + SET read = 1 284 + WHERE %s AND %s 285 + `, idFilter.Condition(), recipientFilter.Condition()) 286 + 287 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 + 289 + result, err := d.DB.ExecContext(ctx, query, args...) 290 + if err != nil { 291 + return fmt.Errorf("failed to mark notification as read: %w", err) 292 + } 293 + 294 + rowsAffected, err := result.RowsAffected() 295 + if err != nil { 296 + return fmt.Errorf("failed to get rows affected: %w", err) 297 + } 298 + 299 + if rowsAffected == 0 { 300 + return fmt.Errorf("notification not found or access denied") 301 + } 302 + 303 + return nil 304 + } 305 + 306 + func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 307 + recipientFilter := FilterEq("recipient_did", userDID) 308 + readFilter := FilterEq("read", 0) 309 + 310 + query := fmt.Sprintf(` 311 + UPDATE notifications 312 + SET read = 1 313 + WHERE %s AND %s 314 + `, recipientFilter.Condition(), readFilter.Condition()) 315 + 316 + args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 + 318 + _, err := d.DB.ExecContext(ctx, query, args...) 319 + if err != nil { 320 + return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 + } 322 + 323 + return nil 324 + } 325 + 326 + func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 327 + idFilter := FilterEq("id", notificationID) 328 + recipientFilter := FilterEq("recipient_did", userDID) 329 + 330 + query := fmt.Sprintf(` 331 + DELETE FROM notifications 332 + WHERE %s AND %s 333 + `, idFilter.Condition(), recipientFilter.Condition()) 334 + 335 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 + 337 + result, err := d.DB.ExecContext(ctx, query, args...) 338 + if err != nil { 339 + return fmt.Errorf("failed to delete notification: %w", err) 340 + } 341 + 342 + rowsAffected, err := result.RowsAffected() 343 + if err != nil { 344 + return fmt.Errorf("failed to get rows affected: %w", err) 345 + } 346 + 347 + if rowsAffected == 0 { 348 + return fmt.Errorf("notification not found or access denied") 349 + } 350 + 351 + return nil 352 + } 353 + 354 + func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 + userFilter := FilterEq("user_did", userDID) 356 + 357 + query := fmt.Sprintf(` 358 + SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 + pull_commented, followed, pull_merged, issue_closed, email_notifications 360 + FROM notification_preferences 361 + WHERE %s 362 + `, userFilter.Condition()) 363 + 364 + var prefs models.NotificationPreferences 365 + err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 + &prefs.ID, 367 + &prefs.UserDid, 368 + &prefs.RepoStarred, 369 + &prefs.IssueCreated, 370 + &prefs.IssueCommented, 371 + &prefs.PullCreated, 372 + &prefs.PullCommented, 373 + &prefs.Followed, 374 + &prefs.PullMerged, 375 + &prefs.IssueClosed, 376 + &prefs.EmailNotifications, 377 + ) 378 + 379 + if err != nil { 380 + if err == sql.ErrNoRows { 381 + return &models.NotificationPreferences{ 382 + UserDid: userDID, 383 + RepoStarred: true, 384 + IssueCreated: true, 385 + IssueCommented: true, 386 + PullCreated: true, 387 + PullCommented: true, 388 + Followed: true, 389 + PullMerged: true, 390 + IssueClosed: true, 391 + EmailNotifications: false, 392 + }, nil 393 + } 394 + return nil, fmt.Errorf("failed to get notification preferences: %w", err) 395 + } 396 + 397 + return &prefs, nil 398 + } 399 + 400 + func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 401 + query := ` 402 + INSERT OR REPLACE INTO notification_preferences 403 + (user_did, repo_starred, issue_created, issue_commented, pull_created, 404 + pull_commented, followed, pull_merged, issue_closed, email_notifications) 405 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 406 + ` 407 + 408 + result, err := d.DB.ExecContext(ctx, query, 409 + prefs.UserDid, 410 + prefs.RepoStarred, 411 + prefs.IssueCreated, 412 + prefs.IssueCommented, 413 + prefs.PullCreated, 414 + prefs.PullCommented, 415 + prefs.Followed, 416 + prefs.PullMerged, 417 + prefs.IssueClosed, 418 + prefs.EmailNotifications, 419 + ) 420 + if err != nil { 421 + return fmt.Errorf("failed to update notification preferences: %w", err) 422 + } 423 + 424 + if prefs.ID == 0 { 425 + id, err := result.LastInsertId() 426 + if err != nil { 427 + return fmt.Errorf("failed to get preferences ID: %w", err) 428 + } 429 + prefs.ID = id 430 + } 431 + 432 + return nil 433 + } 434 + 435 + func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 436 + cutoff := time.Now().Add(-olderThan) 437 + createdFilter := FilterLte("created", cutoff) 438 + 439 + query := fmt.Sprintf(` 440 + DELETE FROM notifications 441 + WHERE %s 442 + `, createdFilter.Condition()) 443 + 444 + _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) 445 + if err != nil { 446 + return fmt.Errorf("failed to cleanup old notifications: %w", err) 447 + } 448 + 449 + return nil 450 + }
+351 -228
appview/db/pulls.go
··· 1 package db 2 3 import ( 4 "database/sql" 5 "fmt" 6 - "log" 7 "sort" 8 "strings" 9 "time" ··· 55 parentChangeId = &pull.ParentChangeId 56 } 57 58 - _, err = tx.Exec( 59 ` 60 insert into pulls ( 61 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 79 return err 80 } 81 82 _, err = tx.Exec(` 83 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 84 - values (?, ?, ?, ?, ?) 85 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 86 return err 87 } 88 ··· 101 } 102 103 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 104 - pulls := make(map[int]*models.Pull) 105 106 var conditions []string 107 var args []any ··· 121 122 query := fmt.Sprintf(` 123 select 124 owner_did, 125 repo_at, 126 pull_id, ··· 154 var createdAt string 155 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 156 err := rows.Scan( 157 &pull.OwnerDid, 158 &pull.RepoAt, 159 &pull.PullId, ··· 202 pull.ParentChangeId = parentChangeId.String 203 } 204 205 - pulls[pull.PullId] = &pull 206 } 207 208 - // get latest round no. for each pull 209 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 210 - submissionsQuery := fmt.Sprintf(` 211 - select 212 - id, pull_id, round_number, patch, created, source_rev 213 - from 214 - pull_submissions 215 - where 216 - repo_at in (%s) and pull_id in (%s) 217 - `, inClause, inClause) 218 - 219 - args = make([]any, len(pulls)*2) 220 - idx := 0 221 for _, p := range pulls { 222 - args[idx] = p.RepoAt 223 - idx += 1 224 - } 225 - for _, p := range pulls { 226 - args[idx] = p.PullId 227 - idx += 1 228 } 229 - submissionsRows, err := e.Query(submissionsQuery, args...) 230 if err != nil { 231 - return nil, err 232 } 233 - defer submissionsRows.Close() 234 235 - for submissionsRows.Next() { 236 - var s models.PullSubmission 237 - var sourceRev sql.NullString 238 - var createdAt string 239 - err := submissionsRows.Scan( 240 - &s.ID, 241 - &s.PullId, 242 - &s.RoundNumber, 243 - &s.Patch, 244 - &createdAt, 245 - &sourceRev, 246 - ) 247 - if err != nil { 248 - return nil, err 249 } 250 251 - createdTime, err := time.Parse(time.RFC3339, createdAt) 252 - if err != nil { 253 - return nil, err 254 - } 255 - s.Created = createdTime 256 - 257 - if sourceRev.Valid { 258 - s.SourceRev = sourceRev.String 259 - } 260 - 261 - if p, ok := pulls[s.PullId]; ok { 262 - p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 263 - p.Submissions[s.RoundNumber] = &s 264 - } 265 } 266 - if err := rows.Err(); err != nil { 267 - return nil, err 268 } 269 270 - // get comment count on latest submission on each pull 271 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 272 - commentsQuery := fmt.Sprintf(` 273 - select 274 - count(id), pull_id 275 - from 276 - pull_comments 277 - where 278 - submission_id in (%s) 279 - group by 280 - submission_id 281 - `, inClause) 282 - 283 - args = []any{} 284 for _, p := range pulls { 285 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 286 } 287 - commentsRows, err := e.Query(commentsQuery, args...) 288 - if err != nil { 289 - return nil, err 290 } 291 - defer commentsRows.Close() 292 - 293 - for commentsRows.Next() { 294 - var commentCount, pullId int 295 - err := commentsRows.Scan( 296 - &commentCount, 297 - &pullId, 298 - ) 299 - if err != nil { 300 - return nil, err 301 - } 302 - if p, ok := pulls[pullId]; ok { 303 - p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 304 - } 305 } 306 - if err := rows.Err(); err != nil { 307 - return nil, err 308 } 309 310 orderedByPullId := []*models.Pull{} ··· 323 } 324 325 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 326 - query := ` 327 - select 328 - owner_did, 329 - pull_id, 330 - created, 331 - title, 332 - state, 333 - target_branch, 334 - repo_at, 335 - body, 336 - rkey, 337 - source_branch, 338 - source_repo_at, 339 - stack_id, 340 - change_id, 341 - parent_change_id 342 - from 343 - pulls 344 - where 345 - repo_at = ? and pull_id = ? 346 - ` 347 - row := e.QueryRow(query, repoAt, pullId) 348 - 349 - var pull models.Pull 350 - var createdAt string 351 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 352 - err := row.Scan( 353 - &pull.OwnerDid, 354 - &pull.PullId, 355 - &createdAt, 356 - &pull.Title, 357 - &pull.State, 358 - &pull.TargetBranch, 359 - &pull.RepoAt, 360 - &pull.Body, 361 - &pull.Rkey, 362 - &sourceBranch, 363 - &sourceRepoAt, 364 - &stackId, 365 - &changeId, 366 - &parentChangeId, 367 - ) 368 if err != nil { 369 return nil, err 370 } 371 - 372 - createdTime, err := time.Parse(time.RFC3339, createdAt) 373 - if err != nil { 374 - return nil, err 375 } 376 - pull.Created = createdTime 377 378 - // populate source 379 - if sourceBranch.Valid { 380 - pull.PullSource = &models.PullSource{ 381 - Branch: sourceBranch.String, 382 - } 383 - if sourceRepoAt.Valid { 384 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 385 - if err != nil { 386 - return nil, err 387 - } 388 - pull.PullSource.RepoAt = &sourceRepoAtParsed 389 - } 390 } 391 392 - if stackId.Valid { 393 - pull.StackId = stackId.String 394 - } 395 - if changeId.Valid { 396 - pull.ChangeId = changeId.String 397 - } 398 - if parentChangeId.Valid { 399 - pull.ParentChangeId = parentChangeId.String 400 } 401 402 - submissionsQuery := ` 403 select 404 - id, pull_id, repo_at, round_number, patch, created, source_rev 405 from 406 pull_submissions 407 - where 408 - repo_at = ? and pull_id = ? 409 - ` 410 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 411 if err != nil { 412 return nil, err 413 } 414 - defer submissionsRows.Close() 415 416 - submissionsMap := make(map[int]*models.PullSubmission) 417 418 - for submissionsRows.Next() { 419 var submission models.PullSubmission 420 - var submissionCreatedStr string 421 - var submissionSourceRev sql.NullString 422 - err := submissionsRows.Scan( 423 &submission.ID, 424 - &submission.PullId, 425 - &submission.RepoAt, 426 &submission.RoundNumber, 427 &submission.Patch, 428 - &submissionCreatedStr, 429 - &submissionSourceRev, 430 ) 431 if err != nil { 432 return nil, err 433 } 434 435 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 436 if err != nil { 437 return nil, err 438 } 439 - submission.Created = submissionCreatedTime 440 441 - if submissionSourceRev.Valid { 442 - submission.SourceRev = submissionSourceRev.String 443 } 444 445 - submissionsMap[submission.ID] = &submission 446 } 447 - if err = submissionsRows.Close(); err != nil { 448 return nil, err 449 } 450 - if len(submissionsMap) == 0 { 451 - return &pull, nil 452 } 453 454 var args []any 455 - for k := range submissionsMap { 456 - args = append(args, k) 457 } 458 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 459 - commentsQuery := fmt.Sprintf(` 460 select 461 id, 462 pull_id, ··· 468 created 469 from 470 pull_comments 471 - where 472 - submission_id IN (%s) 473 order by 474 created asc 475 - `, inClause) 476 - commentsRows, err := e.Query(commentsQuery, args...) 477 if err != nil { 478 return nil, err 479 } 480 - defer commentsRows.Close() 481 482 - for commentsRows.Next() { 483 var comment models.PullComment 484 - var commentCreatedStr string 485 - err := commentsRows.Scan( 486 &comment.ID, 487 &comment.PullId, 488 &comment.SubmissionId, ··· 490 &comment.OwnerDid, 491 &comment.CommentAt, 492 &comment.Body, 493 - &commentCreatedStr, 494 ) 495 if err != nil { 496 return nil, err 497 } 498 499 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 500 - if err != nil { 501 - return nil, err 502 - } 503 - comment.Created = commentCreatedTime 504 - 505 - // Add the comment to its submission 506 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 507 - submission.Comments = append(submission.Comments, comment) 508 } 509 510 } 511 - if err = commentsRows.Err(); err != nil { 512 return nil, err 513 } 514 515 - var pullSourceRepo *models.Repo 516 - if pull.PullSource != nil { 517 - if pull.PullSource.RepoAt != nil { 518 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 519 - if err != nil { 520 - log.Printf("failed to get repo by at uri: %v", err) 521 - } else { 522 - pull.PullSource.Repo = pullSourceRepo 523 - } 524 - } 525 - } 526 - 527 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 528 - for _, submission := range submissionsMap { 529 - pull.Submissions[submission.RoundNumber] = submission 530 - } 531 - 532 - return &pull, nil 533 } 534 535 // timeframe here is directly passed into the sql query filter, and any ··· 666 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 667 newRoundNumber := len(pull.Submissions) 668 _, err := e.Exec(` 669 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 670 - values (?, ?, ?, ?, ?) 671 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 672 673 return err 674 } ··· 809 810 return pulls, nil 811 }
··· 1 package db 2 3 import ( 4 + "cmp" 5 "database/sql" 6 + "errors" 7 "fmt" 8 + "maps" 9 + "slices" 10 "sort" 11 "strings" 12 "time" ··· 58 parentChangeId = &pull.ParentChangeId 59 } 60 61 + result, err := tx.Exec( 62 ` 63 insert into pulls ( 64 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 82 return err 83 } 84 85 + // Set the database primary key ID 86 + id, err := result.LastInsertId() 87 + if err != nil { 88 + return err 89 + } 90 + pull.ID = int(id) 91 + 92 _, err = tx.Exec(` 93 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 + values (?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 96 return err 97 } 98 ··· 111 } 112 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 115 116 var conditions []string 117 var args []any ··· 131 132 query := fmt.Sprintf(` 133 select 134 + id, 135 owner_did, 136 repo_at, 137 pull_id, ··· 165 var createdAt string 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 167 err := rows.Scan( 168 + &pull.ID, 169 &pull.OwnerDid, 170 &pull.RepoAt, 171 &pull.PullId, ··· 214 pull.ParentChangeId = parentChangeId.String 215 } 216 217 + pulls[pull.PullAt()] = &pull 218 } 219 220 + var pullAts []syntax.ATURI 221 for _, p := range pulls { 222 + pullAts = append(pullAts, p.PullAt()) 223 } 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 if err != nil { 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 227 } 228 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 232 } 233 + } 234 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 239 } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 243 + } 244 } 245 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 252 } 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 256 } 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 } 261 + for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 266 + } 267 } 268 269 orderedByPullId := []*models.Pull{} ··· 282 } 283 284 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 286 if err != nil { 287 return nil, err 288 } 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 291 } 292 293 + return pulls[0], nil 294 + } 295 + 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 303 } 304 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 308 } 309 310 + query := fmt.Sprintf(` 311 select 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 318 from 319 pull_submissions 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 326 if err != nil { 327 return nil, err 328 } 329 + defer rows.Close() 330 331 + submissionMap := make(map[int]*models.PullSubmission) 332 333 + for rows.Next() { 334 var submission models.PullSubmission 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 338 &submission.ID, 339 + &submission.PullAt, 340 &submission.RoundNumber, 341 &submission.Patch, 342 + &createdAt, 343 + &sourceRev, 344 ) 345 if err != nil { 346 return nil, err 347 } 348 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 350 if err != nil { 351 return nil, err 352 } 353 + submission.Created = createdTime 354 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 357 } 358 359 + submissionMap[submission.ID] = &submission 360 } 361 + 362 + if err := rows.Err(); err != nil { 363 return nil, err 364 } 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 370 + return nil, err 371 + } 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 376 + } 377 + 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 382 + } 383 + 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 389 } 390 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 396 var args []any 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 400 } 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 405 + } 406 + 407 + query := fmt.Sprintf(` 408 select 409 id, 410 pull_id, ··· 416 created 417 from 418 pull_comments 419 + %s 420 order by 421 created asc 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 425 if err != nil { 426 return nil, err 427 } 428 + defer rows.Close() 429 430 + var comments []models.PullComment 431 + for rows.Next() { 432 var comment models.PullComment 433 + var createdAt string 434 + err := rows.Scan( 435 &comment.ID, 436 &comment.PullId, 437 &comment.SubmissionId, ··· 439 &comment.OwnerDid, 440 &comment.CommentAt, 441 &comment.Body, 442 + &createdAt, 443 ) 444 if err != nil { 445 return nil, err 446 } 447 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 450 } 451 452 + comments = append(comments, comment) 453 } 454 + 455 + if err := rows.Err(); err != nil { 456 return nil, err 457 } 458 459 + return comments, nil 460 } 461 462 // timeframe here is directly passed into the sql query filter, and any ··· 593 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 594 newRoundNumber := len(pull.Submissions) 595 _, err := e.Exec(` 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 599 600 return err 601 } ··· 736 737 return pulls, nil 738 } 739 + 740 + func SearchPulls(e Execer, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]*models.Pull, error) { 741 + var conditions []string 742 + var args []any 743 + 744 + for _, filter := range filters { 745 + conditions = append(conditions, filter.Condition()) 746 + args = append(args, filter.Arg()...) 747 + } 748 + 749 + if text != "" { 750 + searchPattern := "%" + text + "%" 751 + conditions = append(conditions, "title like ?") 752 + args = append(args, searchPattern) 753 + } 754 + 755 + whereClause := "" 756 + if len(conditions) > 0 { 757 + whereClause = " where " + strings.Join(conditions, " and ") 758 + } 759 + 760 + query := fmt.Sprintf(` 761 + select 762 + id, 763 + owner_did, 764 + pull_id, 765 + title, 766 + body, 767 + target_branch, 768 + repo_at, 769 + rkey, 770 + state, 771 + source_branch, 772 + source_repo_at, 773 + stack_id, 774 + change_id, 775 + parent_change_id, 776 + created 777 + from pulls 778 + %s 779 + order by created desc 780 + `, whereClause) 781 + 782 + rows, err := e.Query(query, args...) 783 + if err != nil { 784 + return nil, fmt.Errorf("failed to query pulls: %w", err) 785 + } 786 + defer rows.Close() 787 + 788 + pullMap := make(map[string]*models.Pull) 789 + for rows.Next() { 790 + var pull models.Pull 791 + var createdAt string 792 + var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.Null[string] 793 + 794 + err := rows.Scan( 795 + &pull.ID, 796 + &pull.OwnerDid, 797 + &pull.PullId, 798 + &pull.Title, 799 + &pull.Body, 800 + &pull.TargetBranch, 801 + &pull.RepoAt, 802 + &pull.Rkey, 803 + &pull.State, 804 + &sourceBranch, 805 + &sourceRepoAt, 806 + &stackId, 807 + &changeId, 808 + &parentChangeId, 809 + &createdAt, 810 + ) 811 + if err != nil { 812 + return nil, fmt.Errorf("failed to scan pull: %w", err) 813 + } 814 + 815 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 816 + pull.Created = t 817 + } 818 + 819 + if sourceBranch.Valid || sourceRepoAt.Valid { 820 + pull.PullSource = &models.PullSource{} 821 + if sourceBranch.Valid { 822 + pull.PullSource.Branch = sourceBranch.V 823 + } 824 + if sourceRepoAt.Valid { 825 + uri := syntax.ATURI(sourceRepoAt.V) 826 + pull.PullSource.RepoAt = &uri 827 + } 828 + } 829 + 830 + if stackId.Valid { 831 + pull.StackId = stackId.V 832 + } 833 + if changeId.Valid { 834 + pull.ChangeId = changeId.V 835 + } 836 + if parentChangeId.Valid { 837 + pull.ParentChangeId = parentChangeId.V 838 + } 839 + 840 + pullAt := pull.PullAt().String() 841 + pullMap[pullAt] = &pull 842 + } 843 + 844 + // Load submissions and labels 845 + for _, pull := range pullMap { 846 + submissionsMap, err := GetPullSubmissions(e, FilterEq("pull_at", pull.PullAt().String())) 847 + if err != nil { 848 + return nil, fmt.Errorf("failed to query submissions: %w", err) 849 + } 850 + if subs, ok := submissionsMap[pull.PullAt()]; ok { 851 + pull.Submissions = subs 852 + } 853 + } 854 + 855 + // Collect labels 856 + pullAts := slices.Collect(maps.Keys(pullMap)) 857 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 858 + if err != nil { 859 + return nil, fmt.Errorf("failed to query labels: %w", err) 860 + } 861 + for pullAt, labels := range allLabels { 862 + if pull, ok := pullMap[pullAt.String()]; ok { 863 + pull.Labels = labels 864 + } 865 + } 866 + 867 + // Filter by labels if specified 868 + if len(labels) > 0 { 869 + if len(pullMap) > 0 { 870 + var repoAt string 871 + for _, pull := range pullMap { 872 + repoAt = string(pull.RepoAt) 873 + break 874 + } 875 + 876 + repo, err := GetRepoByAtUri(e, repoAt) 877 + if err == nil && len(repo.Labels) > 0 { 878 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 879 + if err == nil { 880 + labelNameToUri := make(map[string]string) 881 + for _, def := range labelDefs { 882 + labelNameToUri[def.Name] = def.AtUri().String() 883 + } 884 + 885 + for pullAt, pull := range pullMap { 886 + hasAllLabels := true 887 + for _, labelName := range labels { 888 + labelUri, found := labelNameToUri[labelName] 889 + if !found { 890 + hasAllLabels = false 891 + break 892 + } 893 + if !pull.Labels.ContainsLabel(labelUri) { 894 + hasAllLabels = false 895 + break 896 + } 897 + } 898 + if !hasAllLabels { 899 + delete(pullMap, pullAt) 900 + } 901 + } 902 + } 903 + } 904 + } 905 + } 906 + 907 + var pulls []*models.Pull 908 + for _, p := range pullMap { 909 + pulls = append(pulls, p) 910 + } 911 + 912 + sort.Slice(pulls, func(i, j int) bool { 913 + var less bool 914 + 915 + switch sortBy { 916 + case "created": 917 + fallthrough 918 + default: 919 + if pulls[i].Created.Equal(pulls[j].Created) { 920 + // Tiebreaker: use pull_id for stable sort 921 + less = pulls[i].PullId > pulls[j].PullId 922 + } else { 923 + less = pulls[i].Created.After(pulls[j].Created) 924 + } 925 + } 926 + 927 + if sortOrder == "asc" { 928 + return !less 929 + } 930 + return less 931 + }) 932 + 933 + return pulls, nil 934 + }
+34 -7
appview/db/reaction.go
··· 62 return count, nil 63 } 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 67 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 71 } 72 - countMap[kind] = count 73 } 74 - return countMap, nil 75 } 76 77 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
··· 62 return count, nil 63 } 64 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 81 for _, kind := range models.OrderedReactionKinds { 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 91 } 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 99 } 100 + 101 + return reactionMap, rows.Err() 102 } 103 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+67 -9
appview/db/repos.go
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 ) 15 16 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 repoMap := make(map[syntax.ATURI]*models.Repo) 18 ··· 35 36 repoQuery := fmt.Sprintf( 37 `select 38 did, 39 name, 40 knot, ··· 63 var description, source, spindle sql.NullString 64 65 err := rows.Scan( 66 &repo.Did, 67 &repo.Name, 68 &repo.Knot, ··· 327 var repo models.Repo 328 var nullableDescription sql.NullString 329 330 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 331 332 var createdAt string 333 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 334 return nil, err 335 } 336 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 342 repo.Description = "" 343 } 344 345 return &repo, nil 346 } 347 348 - func AddRepo(e Execer, repo *models.Repo) error { 349 - _, err := e.Exec( 350 `insert into repos 351 (did, name, knot, rkey, at_uri, description, source) 352 values (?, ?, ?, ?, ?, ?, ?)`, 353 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 354 ) 355 - return err 356 } 357 358 func RemoveRepo(e Execer, did, name string) error { ··· 373 var repos []models.Repo 374 375 rows, err := e.Query( 376 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 377 from repos r 378 left join collaborators c on r.at_uri = c.repo_at 379 where (r.did = ? or c.subject_did = ?) ··· 393 var nullableDescription sql.NullString 394 var nullableSource sql.NullString 395 396 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 397 if err != nil { 398 return nil, err 399 } ··· 430 var nullableSource sql.NullString 431 432 row := e.QueryRow( 433 - `select did, name, knot, rkey, description, created, source 434 from repos 435 where did = ? and name = ? and source is not null and source != ''`, 436 did, name, 437 ) 438 439 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 440 if err != nil { 441 return nil, err 442 }
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/models" 16 ) 17 18 + type Repo struct { 19 + Id int64 20 + Did string 21 + Name string 22 + Knot string 23 + Rkey string 24 + Created time.Time 25 + Description string 26 + Spindle string 27 + 28 + // optionally, populate this when querying for reverse mappings 29 + RepoStats *models.RepoStats 30 + 31 + // optional 32 + Source string 33 + } 34 + 35 + func (r Repo) RepoAt() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 + } 38 + 39 + func (r Repo) DidSlashRepo() string { 40 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 + return p 42 + } 43 + 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 repoMap := make(map[syntax.ATURI]*models.Repo) 46 ··· 63 64 repoQuery := fmt.Sprintf( 65 `select 66 + id, 67 did, 68 name, 69 knot, ··· 92 var description, source, spindle sql.NullString 93 94 err := rows.Scan( 95 + &repo.Id, 96 &repo.Did, 97 &repo.Name, 98 &repo.Knot, ··· 357 var repo models.Repo 358 var nullableDescription sql.NullString 359 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 361 362 var createdAt string 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 364 return nil, err 365 } 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 372 repo.Description = "" 373 } 374 375 + // Load labels for this repo 376 + rows, err := e.Query(`select label_at from repo_labels where repo_at = ?`, atUri) 377 + if err != nil { 378 + return nil, fmt.Errorf("failed to load repo labels: %w", err) 379 + } 380 + defer rows.Close() 381 + 382 + for rows.Next() { 383 + var labelAt string 384 + if err := rows.Scan(&labelAt); err != nil { 385 + continue 386 + } 387 + repo.Labels = append(repo.Labels, labelAt) 388 + } 389 + 390 return &repo, nil 391 } 392 393 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 394 + _, err := tx.Exec( 395 `insert into repos 396 (did, name, knot, rkey, at_uri, description, source) 397 values (?, ?, ?, ?, ?, ?, ?)`, 398 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 399 ) 400 + if err != nil { 401 + return fmt.Errorf("failed to insert repo: %w", err) 402 + } 403 + 404 + for _, dl := range repo.Labels { 405 + if err := SubscribeLabel(tx, &models.RepoLabel{ 406 + RepoAt: repo.RepoAt(), 407 + LabelAt: syntax.ATURI(dl), 408 + }); err != nil { 409 + return fmt.Errorf("failed to subscribe to label: %w", err) 410 + } 411 + } 412 + 413 + return nil 414 } 415 416 func RemoveRepo(e Execer, did, name string) error { ··· 431 var repos []models.Repo 432 433 rows, err := e.Query( 434 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 435 from repos r 436 left join collaborators c on r.at_uri = c.repo_at 437 where (r.did = ? or c.subject_did = ?) ··· 451 var nullableDescription sql.NullString 452 var nullableSource sql.NullString 453 454 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 455 if err != nil { 456 return nil, err 457 } ··· 488 var nullableSource sql.NullString 489 490 row := e.QueryRow( 491 + `select id, did, name, knot, rkey, description, created, source 492 from repos 493 where did = ? and name = ? and source is not null and source != ''`, 494 did, name, 495 ) 496 497 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 498 if err != nil { 499 return nil, err 500 }
+80
appview/ingester.go
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 9 "time" 10 ··· 80 err = i.ingestIssueComment(e) 81 case tangled.LabelDefinitionNSID: 82 err = i.ingestLabelDefinition(e) 83 } 84 l = i.Logger.With("nsid", e.Commit.Collection) 85 } ··· 953 954 return nil 955 }
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 + "maps" 9 + "slices" 10 11 "time" 12 ··· 82 err = i.ingestIssueComment(e) 83 case tangled.LabelDefinitionNSID: 84 err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 87 } 88 l = i.Logger.With("nsid", e.Commit.Collection) 89 } ··· 957 958 return nil 959 } 960 + 961 + func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { 962 + did := e.Did 963 + rkey := e.Commit.RKey 964 + 965 + var err error 966 + 967 + l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 968 + l.Info("ingesting record") 969 + 970 + ddb, ok := i.Db.Execer.(*db.DB) 971 + if !ok { 972 + return fmt.Errorf("failed to index label op, invalid db cast") 973 + } 974 + 975 + switch e.Commit.Operation { 976 + case jmodels.CommitOperationCreate: 977 + raw := json.RawMessage(e.Commit.Record) 978 + record := tangled.LabelOp{} 979 + err = json.Unmarshal(raw, &record) 980 + if err != nil { 981 + return fmt.Errorf("invalid record: %w", err) 982 + } 983 + 984 + subject := syntax.ATURI(record.Subject) 985 + collection := subject.Collection() 986 + 987 + var repo *models.Repo 988 + switch collection { 989 + case tangled.RepoIssueNSID: 990 + i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 991 + if err != nil || len(i) != 1 { 992 + return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 + } 994 + repo = i[0].Repo 995 + default: 996 + return fmt.Errorf("unsupport label subject: %s", collection) 997 + } 998 + 999 + actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1000 + if err != nil { 1001 + return fmt.Errorf("failed to build label application ctx: %w", err) 1002 + } 1003 + 1004 + ops := models.LabelOpsFromRecord(did, rkey, record) 1005 + 1006 + for _, o := range ops { 1007 + def, ok := actx.Defs[o.OperandKey] 1008 + if !ok { 1009 + return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 + } 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 + return fmt.Errorf("failed to validate labelop: %w", err) 1013 + } 1014 + } 1015 + 1016 + tx, err := ddb.Begin() 1017 + if err != nil { 1018 + return err 1019 + } 1020 + defer tx.Rollback() 1021 + 1022 + for _, o := range ops { 1023 + _, err = db.AddLabelOp(tx, &o) 1024 + if err != nil { 1025 + return fmt.Errorf("failed to add labelop: %w", err) 1026 + } 1027 + } 1028 + 1029 + if err = tx.Commit(); err != nil { 1030 + return err 1031 + } 1032 + } 1033 + 1034 + return nil 1035 + }
+59 -15
appview/issues/issues.go
··· 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 25 "tangled.org/core/appview/pages" 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 "tangled.org/core/idresolver" 31 tlog "tangled.org/core/log" 32 "tangled.org/core/tid" ··· 83 return 84 } 85 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 if err != nil { 88 l.Error("failed to get issue reactions", "err", err) 89 } ··· 115 Issue: issue, 116 CommentList: issue.CommentList(), 117 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 119 UserReacted: userReactions, 120 LabelDefs: defs, 121 }) ··· 166 return 167 } 168 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 if err != nil { 171 l.Error("failed to get record", "err", err) 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 return 174 } 175 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 Collection: tangled.RepoIssueNSID, 178 Repo: user.Did, 179 Rkey: newIssue.Rkey, ··· 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 Collection: tangled.RepoIssueNSID, 246 Repo: issue.Did, 247 Rkey: issue.Rkey, ··· 301 return 302 } 303 304 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 305 return 306 } else { ··· 405 } 406 407 // create a record first 408 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 409 Collection: tangled.RepoIssueCommentNSID, 410 Repo: comment.Did, 411 Rkey: comment.Rkey, ··· 434 435 // reset atUri to make rollback a no-op 436 atUri = "" 437 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 438 } 439 ··· 551 // rkey is optional, it was introduced later 552 if newComment.Rkey != "" { 553 // update the record on pds 554 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 555 if err != nil { 556 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 557 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 558 return 559 } 560 561 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 562 Collection: tangled.RepoIssueCommentNSID, 563 Repo: user.Did, 564 Rkey: newComment.Rkey, ··· 725 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 726 return 727 } 728 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 729 Collection: tangled.RepoIssueCommentNSID, 730 Repo: user.Did, 731 Rkey: comment.Rkey, ··· 751 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 752 params := r.URL.Query() 753 state := params.Get("state") 754 isOpen := true 755 switch state { 756 case "open": ··· 778 if isOpen { 779 openVal = 1 780 } 781 - issues, err := db.GetIssuesPaginated( 782 rp.db, 783 page, 784 db.FilterEq("repo_at", f.RepoAt()), 785 db.FilterEq("open", openVal), 786 ) 787 if err != nil { 788 log.Println("failed to get issues", err) 789 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 790 return 791 } 792 793 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 794 if err != nil { 795 log.Println("failed to fetch labels", err) 796 rp.pages.Error503(w) ··· 809 LabelDefs: defs, 810 FilteringByOpen: isOpen, 811 Page: page, 812 }) 813 } 814 ··· 853 rp.pages.Notice(w, "issues", "Failed to create issue.") 854 return 855 } 856 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 857 Collection: tangled.RepoIssueNSID, 858 Repo: user.Did, 859 Rkey: issue.Rkey, ··· 911 // this is used to rollback changes made to the PDS 912 // 913 // it is a no-op if the provided ATURI is empty 914 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 915 if aturi == "" { 916 return nil 917 } ··· 922 repo := parsed.Authority().String() 923 rkey := parsed.RecordKey().String() 924 925 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 926 Collection: collection, 927 Repo: repo, 928 Rkey: rkey,
··· 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" ··· 26 "tangled.org/core/appview/pages" 27 "tangled.org/core/appview/pagination" 28 "tangled.org/core/appview/reporesolver" 29 + "tangled.org/core/appview/search" 30 "tangled.org/core/appview/validator" 31 "tangled.org/core/idresolver" 32 tlog "tangled.org/core/log" 33 "tangled.org/core/tid" ··· 84 return 85 } 86 87 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 88 if err != nil { 89 l.Error("failed to get issue reactions", "err", err) 90 } ··· 116 Issue: issue, 117 CommentList: issue.CommentList(), 118 OrderedReactionKinds: models.OrderedReactionKinds, 119 + Reactions: reactionMap, 120 UserReacted: userReactions, 121 LabelDefs: defs, 122 }) ··· 167 return 168 } 169 170 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 171 if err != nil { 172 l.Error("failed to get record", "err", err) 173 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 174 return 175 } 176 177 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 178 Collection: tangled.RepoIssueNSID, 179 Repo: user.Did, 180 Rkey: newIssue.Rkey, ··· 242 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 243 return 244 } 245 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 246 Collection: tangled.RepoIssueNSID, 247 Repo: issue.Did, 248 Rkey: issue.Rkey, ··· 302 return 303 } 304 305 + // notify about the issue closure 306 + rp.notifier.NewIssueClosed(r.Context(), issue) 307 + 308 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 309 return 310 } else { ··· 409 } 410 411 // create a record first 412 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 413 Collection: tangled.RepoIssueCommentNSID, 414 Repo: comment.Did, 415 Rkey: comment.Rkey, ··· 438 439 // reset atUri to make rollback a no-op 440 atUri = "" 441 + 442 + // notify about the new comment 443 + comment.Id = commentId 444 + rp.notifier.NewIssueComment(r.Context(), &comment) 445 + 446 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 447 } 448 ··· 560 // rkey is optional, it was introduced later 561 if newComment.Rkey != "" { 562 // update the record on pds 563 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 564 if err != nil { 565 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 566 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 567 return 568 } 569 570 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 571 Collection: tangled.RepoIssueCommentNSID, 572 Repo: user.Did, 573 Rkey: newComment.Rkey, ··· 734 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 735 return 736 } 737 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 738 Collection: tangled.RepoIssueCommentNSID, 739 Repo: user.Did, 740 Rkey: comment.Rkey, ··· 760 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 761 params := r.URL.Query() 762 state := params.Get("state") 763 + searchQuery := params.Get("q") 764 + sortBy := params.Get("sort_by") 765 + sortOrder := params.Get("sort_order") 766 + 767 + // Use for template (preserve empty values) 768 + templateSortBy := sortBy 769 + templateSortOrder := sortOrder 770 + 771 + // Default sort values for queries 772 + if sortBy == "" { 773 + sortBy = "created" 774 + } 775 + if sortOrder == "" { 776 + sortOrder = "desc" 777 + } 778 + 779 isOpen := true 780 switch state { 781 case "open": ··· 803 if isOpen { 804 openVal = 1 805 } 806 + 807 + var issues []models.Issue 808 + 809 + // Parse the search query (even if empty, to handle label filters) 810 + query := search.Parse(searchQuery) 811 + 812 + // Always use search function to handle sorting 813 + issues, err = db.SearchIssues( 814 rp.db, 815 page, 816 + query.Text, 817 + query.Labels, 818 + sortBy, 819 + sortOrder, 820 db.FilterEq("repo_at", f.RepoAt()), 821 db.FilterEq("open", openVal), 822 ) 823 + 824 if err != nil { 825 log.Println("failed to get issues", err) 826 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 827 return 828 } 829 830 + labelDefs, err := db.GetLabelDefinitions( 831 + rp.db, 832 + db.FilterIn("at_uri", f.Repo.Labels), 833 + db.FilterContains("scope", tangled.RepoIssueNSID), 834 + ) 835 if err != nil { 836 log.Println("failed to fetch labels", err) 837 rp.pages.Error503(w) ··· 850 LabelDefs: defs, 851 FilteringByOpen: isOpen, 852 Page: page, 853 + SearchQuery: searchQuery, 854 + SortBy: templateSortBy, 855 + SortOrder: templateSortOrder, 856 }) 857 } 858 ··· 897 rp.pages.Notice(w, "issues", "Failed to create issue.") 898 return 899 } 900 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 901 Collection: tangled.RepoIssueNSID, 902 Repo: user.Did, 903 Rkey: issue.Rkey, ··· 955 // this is used to rollback changes made to the PDS 956 // 957 // it is a no-op if the provided ATURI is empty 958 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 959 if aturi == "" { 960 return nil 961 } ··· 966 repo := parsed.Authority().String() 967 rkey := parsed.RecordKey().String() 968 969 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 970 Collection: collection, 971 Repo: repo, 972 Rkey: rkey,
+6 -6
appview/knots/knots.go
··· 185 return 186 } 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
··· 185 return 186 } 187 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
+23 -16
appview/labels/labels.go
··· 9 "net/http" 10 "time" 11 12 - comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/go-chi/chi/v5" 16 - 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/middleware" ··· 21 "tangled.org/core/appview/oauth" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/log" 26 "tangled.org/core/tid" 27 ) 28 29 type Labels struct { ··· 32 db *db.DB 33 logger *slog.Logger 34 validator *validator.Validator 35 } 36 37 func New( ··· 39 pages *pages.Pages, 40 db *db.DB, 41 validator *validator.Validator, 42 ) *Labels { 43 logger := log.New("labels") 44 ··· 48 db: db, 49 logger: logger, 50 validator: validator, 51 } 52 } 53 ··· 86 repoAt := r.Form.Get("repo") 87 subjectUri := r.Form.Get("subject") 88 89 // find all the labels that this repo subscribes to 90 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 91 if err != nil { ··· 103 fail("Invalid form data.", err) 104 return 105 } 106 - 107 - l.logger.Info("actx", "labels", labelAts) 108 - l.logger.Info("actx", "defs", actx.Defs) 109 110 // calculate the start state by applying already known labels 111 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) ··· 155 } 156 } 157 158 - // reduce the opset 159 - labelOps = models.ReduceLabelOps(labelOps) 160 - 161 for i := range labelOps { 162 def := actx.Defs[labelOps[i].OperandKey] 163 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 164 fail(fmt.Sprintf("Invalid form data: %s", err), err) 165 return 166 } 167 } 168 169 // next, apply all ops introduced in this request and filter out ones that are no-ops 170 validLabelOps := labelOps[:0] 171 for _, op := range labelOps { ··· 189 return 190 } 191 192 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 193 Collection: tangled.LabelOpNSID, 194 Repo: did, 195 Rkey: rkey, ··· 245 // this is used to rollback changes made to the PDS 246 // 247 // it is a no-op if the provided ATURI is empty 248 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 249 if aturi == "" { 250 return nil 251 } ··· 256 repo := parsed.Authority().String() 257 rkey := parsed.RecordKey().String() 258 259 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 260 Collection: collection, 261 Repo: repo, 262 Rkey: rkey,
··· 9 "net/http" 10 "time" 11 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/middleware" ··· 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/validator" 19 "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 "tangled.org/core/tid" 22 + 23 + comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + atpclient "github.com/bluesky-social/indigo/atproto/client" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + "github.com/go-chi/chi/v5" 28 ) 29 30 type Labels struct { ··· 33 db *db.DB 34 logger *slog.Logger 35 validator *validator.Validator 36 + enforcer *rbac.Enforcer 37 } 38 39 func New( ··· 41 pages *pages.Pages, 42 db *db.DB, 43 validator *validator.Validator, 44 + enforcer *rbac.Enforcer, 45 ) *Labels { 46 logger := log.New("labels") 47 ··· 51 db: db, 52 logger: logger, 53 validator: validator, 54 + enforcer: enforcer, 55 } 56 } 57 ··· 90 repoAt := r.Form.Get("repo") 91 subjectUri := r.Form.Get("subject") 92 93 + repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 94 + if err != nil { 95 + fail("Failed to get repository.", err) 96 + return 97 + } 98 + 99 // find all the labels that this repo subscribes to 100 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 101 if err != nil { ··· 113 fail("Invalid form data.", err) 114 return 115 } 116 117 // calculate the start state by applying already known labels 118 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) ··· 162 } 163 } 164 165 for i := range labelOps { 166 def := actx.Defs[labelOps[i].OperandKey] 167 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 168 fail(fmt.Sprintf("Invalid form data: %s", err), err) 169 return 170 } 171 } 172 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 175 + 176 // next, apply all ops introduced in this request and filter out ones that are no-ops 177 validLabelOps := labelOps[:0] 178 for _, op := range labelOps { ··· 196 return 197 } 198 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.LabelOpNSID, 201 Repo: did, 202 Rkey: rkey, ··· 252 // this is used to rollback changes made to the PDS 253 // 254 // it is a no-op if the provided ATURI is empty 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 if aturi == "" { 257 return nil 258 } ··· 263 repo := parsed.Authority().String() 264 rkey := parsed.RecordKey().String() 265 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 Collection: collection, 268 Repo: repo, 269 Rkey: rkey,
+5 -5
appview/middleware/middleware.go
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 returnURL := "/" ··· 63 } 64 } 65 66 - _, auth, err := a.GetSession(r) 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 69 redirectFunc(w, r) 70 return 71 } 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 75 redirectFunc(w, r) 76 return 77 }
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 returnURL := "/" ··· 63 } 64 } 65 66 + sess, err := o.ResumeSession(r) 67 if err != nil { 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 redirectFunc(w, r) 70 return 71 } 72 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 redirectFunc(w, r) 76 return 77 }
+4 -3
appview/models/issue.go
··· 24 25 // optionally, populate this when querying for reverse mappings 26 // like comment counts, parent repo etc. 27 - Comments []IssueComment 28 - Labels LabelState 29 - Repo *Repo 30 } 31 32 func (i *Issue) AtUri() syntax.ATURI {
··· 24 25 // optionally, populate this when querying for reverse mappings 26 // like comment counts, parent repo etc. 27 + Comments []IssueComment 28 + ReactionCount int 29 + Labels LabelState 30 + Repo *Repo 31 } 32 33 func (i *Issue) AtUri() syntax.ATURI {
+83 -14
appview/models/label.go
··· 1 package models 2 3 import ( 4 "crypto/sha1" 5 "encoding/hex" 6 "errors" 7 "fmt" 8 "slices" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/consts" 14 ) 15 16 type ConcreteType string ··· 227 } 228 229 var ops []LabelOp 230 - for _, o := range record.Add { 231 if o != nil { 232 op := mkOp(o) 233 - op.Operation = LabelOperationAdd 234 ops = append(ops, op) 235 } 236 } 237 - for _, o := range record.Delete { 238 if o != nil { 239 op := mkOp(o) 240 - op.Operation = LabelOperationDel 241 ops = append(ops, op) 242 } 243 } ··· 455 return result 456 } 457 458 func DefaultLabelDefs() []string { 459 - rkeys := []string{ 460 - "wontfix", 461 - "duplicate", 462 - "assignee", 463 - "good-first-issue", 464 - "documentation", 465 } 466 467 - defs := make([]string, len(rkeys)) 468 - for i, r := range rkeys { 469 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 470 } 471 472 - return defs 473 }
··· 1 package models 2 3 import ( 4 + "context" 5 "crypto/sha1" 6 "encoding/hex" 7 + "encoding/json" 8 "errors" 9 "fmt" 10 "slices" 11 "time" 12 13 + "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 19 ) 20 21 type ConcreteType string ··· 232 } 233 234 var ops []LabelOp 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 237 if o != nil { 238 op := mkOp(o) 239 + op.Operation = LabelOperationDel 240 ops = append(ops, op) 241 } 242 } 243 + for _, o := range record.Add { 244 if o != nil { 245 op := mkOp(o) 246 + op.Operation = LabelOperationAdd 247 ops = append(ops, op) 248 } 249 } ··· 461 return result 462 } 463 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 } 480 + } 481 482 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 + if err != nil { 485 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 + } 487 + pdsEndpoint := resolved.PDSEndpoint() 488 + if pdsEndpoint == "" { 489 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 + } 491 + client := &xrpc.Client{ 492 + Host: pdsEndpoint, 493 + } 494 + 495 + var labelDefs []LabelDefinition 496 + 497 + for _, dl := range DefaultLabelDefs() { 498 + atUri := syntax.ATURI(dl) 499 + parsedUri, err := syntax.ParseATURI(string(atUri)) 500 + if err != nil { 501 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 + } 503 + record, err := atproto.RepoGetRecord( 504 + context.Background(), 505 + client, 506 + "", 507 + parsedUri.Collection().String(), 508 + parsedUri.Authority().String(), 509 + parsedUri.RecordKey().String(), 510 + ) 511 + if err != nil { 512 + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 513 + } 514 + 515 + if record != nil { 516 + bytes, err := record.Value.MarshalJSON() 517 + if err != nil { 518 + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 519 + } 520 + 521 + raw := json.RawMessage(bytes) 522 + labelRecord := tangled.LabelDefinition{} 523 + err = json.Unmarshal(raw, &labelRecord) 524 + if err != nil { 525 + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 526 + } 527 + 528 + labelDef, err := LabelDefinitionFromRecord( 529 + parsedUri.Authority().String(), 530 + parsedUri.RecordKey().String(), 531 + labelRecord, 532 + ) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 535 + } 536 + 537 + labelDefs = append(labelDefs, *labelDef) 538 + } 539 } 540 541 + return labelDefs, nil 542 }
+82
appview/models/notifications.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type NotificationType string 8 + 9 + const ( 10 + NotificationTypeRepoStarred NotificationType = "repo_starred" 11 + NotificationTypeIssueCreated NotificationType = "issue_created" 12 + NotificationTypeIssueCommented NotificationType = "issue_commented" 13 + NotificationTypePullCreated NotificationType = "pull_created" 14 + NotificationTypePullCommented NotificationType = "pull_commented" 15 + NotificationTypeFollowed NotificationType = "followed" 16 + NotificationTypePullMerged NotificationType = "pull_merged" 17 + NotificationTypeIssueClosed NotificationType = "issue_closed" 18 + NotificationTypePullClosed NotificationType = "pull_closed" 19 + ) 20 + 21 + type Notification struct { 22 + ID int64 23 + RecipientDid string 24 + ActorDid string 25 + Type NotificationType 26 + EntityType string 27 + EntityId string 28 + Read bool 29 + Created time.Time 30 + 31 + // foreign key references 32 + RepoId *int64 33 + IssueId *int64 34 + PullId *int64 35 + } 36 + 37 + // lucide icon that represents this notification 38 + func (n *Notification) Icon() string { 39 + switch n.Type { 40 + case NotificationTypeRepoStarred: 41 + return "star" 42 + case NotificationTypeIssueCreated: 43 + return "circle-dot" 44 + case NotificationTypeIssueCommented: 45 + return "message-square" 46 + case NotificationTypeIssueClosed: 47 + return "ban" 48 + case NotificationTypePullCreated: 49 + return "git-pull-request-create" 50 + case NotificationTypePullCommented: 51 + return "message-square" 52 + case NotificationTypePullMerged: 53 + return "git-merge" 54 + case NotificationTypePullClosed: 55 + return "git-pull-request-closed" 56 + case NotificationTypeFollowed: 57 + return "user-plus" 58 + default: 59 + return "" 60 + } 61 + } 62 + 63 + type NotificationWithEntity struct { 64 + *Notification 65 + Repo *Repo 66 + Issue *Issue 67 + Pull *Pull 68 + } 69 + 70 + type NotificationPreferences struct { 71 + ID int64 72 + UserDid string 73 + RepoStarred bool 74 + IssueCreated bool 75 + IssueCommented bool 76 + PullCreated bool 77 + PullCommented bool 78 + Followed bool 79 + PullMerged bool 80 + IssueClosed bool 81 + EmailNotifications bool 82 + }
+46 -4
appview/models/pull.go
··· 77 PullSource *PullSource 78 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 81 } 82 83 func (p Pull) AsRecord() tangled.RepoPull { ··· 125 126 type PullSubmission struct { 127 // ids 128 - ID int 129 - PullId int 130 131 // at ids 132 - RepoAt syntax.ATURI 133 134 // content 135 RoundNumber int ··· 207 return p.StackId != "" 208 } 209 210 func (s PullSubmission) IsFormatPatch() bool { 211 return patchutil.IsFormatPatch(s.Patch) 212 } ··· 219 } 220 221 return patches 222 } 223 224 type Stack []*Pull
··· 77 PullSource *PullSource 78 79 // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 } 83 84 func (p Pull) AsRecord() tangled.RepoPull { ··· 126 127 type PullSubmission struct { 128 // ids 129 + ID int 130 131 // at ids 132 + PullAt syntax.ATURI 133 134 // content 135 RoundNumber int ··· 207 return p.StackId != "" 208 } 209 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 232 func (s PullSubmission) IsFormatPatch() bool { 233 return patchutil.IsFormatPatch(s.Patch) 234 } ··· 241 } 242 243 return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 264 } 265 266 type Stack []*Pull
+5
appview/models/reaction.go
··· 55 Rkey string 56 Kind ReactionKind 57 }
··· 55 Rkey string 56 Kind ReactionKind 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+6
appview/models/repo.go
··· 10 ) 11 12 type Repo struct { 13 Did string 14 Name string 15 Knot string ··· 85 RepoAt syntax.ATURI 86 LabelAt syntax.ATURI 87 }
··· 10 ) 11 12 type Repo struct { 13 + Id int64 14 Did string 15 Name string 16 Knot string ··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+166
appview/notifications/notifications.go
···
··· 1 + package notifications 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/middleware" 11 + "tangled.org/core/appview/oauth" 12 + "tangled.org/core/appview/pages" 13 + "tangled.org/core/appview/pagination" 14 + ) 15 + 16 + type Notifications struct { 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + } 21 + 22 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 23 + return &Notifications{ 24 + db: database, 25 + oauth: oauthHandler, 26 + pages: pagesHandler, 27 + } 28 + } 29 + 30 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 31 + r := chi.NewRouter() 32 + 33 + r.Get("/count", n.getUnreadCount) 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(middleware.AuthMiddleware(n.oauth)) 37 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 38 + r.Post("/{id}/read", n.markRead) 39 + r.Post("/read-all", n.markAllRead) 40 + r.Delete("/{id}", n.deleteNotification) 41 + }) 42 + 43 + return r 44 + } 45 + 46 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + user := n.oauth.GetUser(r) 48 + 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 53 + } 54 + 55 + total, err := db.CountNotifications( 56 + n.db, 57 + db.FilterEq("recipient_did", user.Did), 58 + ) 59 + if err != nil { 60 + log.Println("failed to get total notifications:", err) 61 + n.pages.Error500(w) 62 + return 63 + } 64 + 65 + notifications, err := db.GetNotificationsWithEntities( 66 + n.db, 67 + page, 68 + db.FilterEq("recipient_did", user.Did), 69 + ) 70 + if err != nil { 71 + log.Println("failed to get notifications:", err) 72 + n.pages.Error500(w) 73 + return 74 + } 75 + 76 + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 + if err != nil { 78 + log.Println("failed to mark notifications as read:", err) 79 + } 80 + 81 + unreadCount := 0 82 + 83 + n.pages.Notifications(w, pages.NotificationsParams{ 84 + LoggedInUser: user, 85 + Notifications: notifications, 86 + UnreadCount: unreadCount, 87 + Page: page, 88 + Total: total, 89 + }) 90 + } 91 + 92 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 + user := n.oauth.GetUser(r) 94 + if user == nil { 95 + return 96 + } 97 + 98 + count, err := db.CountNotifications( 99 + n.db, 100 + db.FilterEq("recipient_did", user.Did), 101 + db.FilterEq("read", 0), 102 + ) 103 + if err != nil { 104 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 105 + return 106 + } 107 + 108 + params := pages.NotificationCountParams{ 109 + Count: count, 110 + } 111 + err = n.pages.NotificationCount(w, params) 112 + if err != nil { 113 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 114 + return 115 + } 116 + } 117 + 118 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 119 + userDid := n.oauth.GetDid(r) 120 + 121 + idStr := chi.URLParam(r, "id") 122 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 123 + if err != nil { 124 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 125 + return 126 + } 127 + 128 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 129 + if err != nil { 130 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + w.WriteHeader(http.StatusNoContent) 135 + } 136 + 137 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 138 + userDid := n.oauth.GetDid(r) 139 + 140 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 141 + if err != nil { 142 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 143 + return 144 + } 145 + 146 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 147 + } 148 + 149 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 150 + userDid := n.oauth.GetDid(r) 151 + 152 + idStr := chi.URLParam(r, "id") 153 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 154 + if err != nil { 155 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 156 + return 157 + } 158 + 159 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 160 + if err != nil { 161 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + w.WriteHeader(http.StatusOK) 166 + }
+429
appview/notify/db/db.go
···
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + "tangled.org/core/idresolver" 11 + ) 12 + 13 + type databaseNotifier struct { 14 + db *db.DB 15 + res *idresolver.Resolver 16 + } 17 + 18 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 19 + return &databaseNotifier{ 20 + db: database, 21 + res: resolver, 22 + } 23 + } 24 + 25 + var _ notify.Notifier = &databaseNotifier{} 26 + 27 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + // no-op for now 29 + } 30 + 31 + func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 + var err error 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 + if err != nil { 35 + log.Printf("NewStar: failed to get repos: %v", err) 36 + return 37 + } 38 + 39 + // don't notify yourself 40 + if repo.Did == star.StarredByDid { 41 + return 42 + } 43 + 44 + // check if user wants these notifications 45 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 + if err != nil { 47 + log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 + return 49 + } 50 + if !prefs.RepoStarred { 51 + return 52 + } 53 + 54 + notification := &models.Notification{ 55 + RecipientDid: repo.Did, 56 + ActorDid: star.StarredByDid, 57 + Type: models.NotificationTypeRepoStarred, 58 + EntityType: "repo", 59 + EntityId: string(star.RepoAt), 60 + RepoId: &repo.Id, 61 + } 62 + err = n.db.CreateNotification(ctx, notification) 63 + if err != nil { 64 + log.Printf("NewStar: failed to create notification: %v", err) 65 + return 66 + } 67 + } 68 + 69 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 + // no-op 71 + } 72 + 73 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 + if err != nil { 76 + log.Printf("NewIssue: failed to get repos: %v", err) 77 + return 78 + } 79 + 80 + if repo.Did == issue.Did { 81 + return 82 + } 83 + 84 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 + if err != nil { 86 + log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 + return 88 + } 89 + if !prefs.IssueCreated { 90 + return 91 + } 92 + 93 + notification := &models.Notification{ 94 + RecipientDid: repo.Did, 95 + ActorDid: issue.Did, 96 + Type: models.NotificationTypeIssueCreated, 97 + EntityType: "issue", 98 + EntityId: string(issue.AtUri()), 99 + RepoId: &repo.Id, 100 + IssueId: &issue.Id, 101 + } 102 + 103 + err = n.db.CreateNotification(ctx, notification) 104 + if err != nil { 105 + log.Printf("NewIssue: failed to create notification: %v", err) 106 + return 107 + } 108 + } 109 + 110 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 111 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 112 + if err != nil { 113 + log.Printf("NewIssueComment: failed to get issues: %v", err) 114 + return 115 + } 116 + if len(issues) == 0 { 117 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 118 + return 119 + } 120 + issue := issues[0] 121 + 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 + if err != nil { 124 + log.Printf("NewIssueComment: failed to get repos: %v", err) 125 + return 126 + } 127 + 128 + recipients := make(map[string]bool) 129 + 130 + // notify issue author (if not the commenter) 131 + if issue.Did != comment.Did { 132 + prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 + if err == nil && prefs.IssueCommented { 134 + recipients[issue.Did] = true 135 + } else if err != nil { 136 + log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 137 + } 138 + } 139 + 140 + // notify repo owner (if not the commenter and not already added) 141 + if repo.Did != comment.Did && repo.Did != issue.Did { 142 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 + if err == nil && prefs.IssueCommented { 144 + recipients[repo.Did] = true 145 + } else if err != nil { 146 + log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 + } 148 + } 149 + 150 + // create notifications for all recipients 151 + for recipientDid := range recipients { 152 + notification := &models.Notification{ 153 + RecipientDid: recipientDid, 154 + ActorDid: comment.Did, 155 + Type: models.NotificationTypeIssueCommented, 156 + EntityType: "issue", 157 + EntityId: string(issue.AtUri()), 158 + RepoId: &repo.Id, 159 + IssueId: &issue.Id, 160 + } 161 + 162 + err = n.db.CreateNotification(ctx, notification) 163 + if err != nil { 164 + log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 + } 166 + } 167 + } 168 + 169 + func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 + prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 + if err != nil { 172 + log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 + return 174 + } 175 + if !prefs.Followed { 176 + return 177 + } 178 + 179 + notification := &models.Notification{ 180 + RecipientDid: follow.SubjectDid, 181 + ActorDid: follow.UserDid, 182 + Type: models.NotificationTypeFollowed, 183 + EntityType: "follow", 184 + EntityId: follow.UserDid, 185 + } 186 + 187 + err = n.db.CreateNotification(ctx, notification) 188 + if err != nil { 189 + log.Printf("NewFollow: failed to create notification: %v", err) 190 + return 191 + } 192 + } 193 + 194 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 195 + // no-op 196 + } 197 + 198 + func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 200 + if err != nil { 201 + log.Printf("NewPull: failed to get repos: %v", err) 202 + return 203 + } 204 + 205 + if repo.Did == pull.OwnerDid { 206 + return 207 + } 208 + 209 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 210 + if err != nil { 211 + log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 212 + return 213 + } 214 + if !prefs.PullCreated { 215 + return 216 + } 217 + 218 + notification := &models.Notification{ 219 + RecipientDid: repo.Did, 220 + ActorDid: pull.OwnerDid, 221 + Type: models.NotificationTypePullCreated, 222 + EntityType: "pull", 223 + EntityId: string(pull.RepoAt), 224 + RepoId: &repo.Id, 225 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 + } 227 + 228 + err = n.db.CreateNotification(ctx, notification) 229 + if err != nil { 230 + log.Printf("NewPull: failed to create notification: %v", err) 231 + return 232 + } 233 + } 234 + 235 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 + pulls, err := db.GetPulls(n.db, 237 + db.FilterEq("repo_at", comment.RepoAt), 238 + db.FilterEq("pull_id", comment.PullId)) 239 + if err != nil { 240 + log.Printf("NewPullComment: failed to get pulls: %v", err) 241 + return 242 + } 243 + if len(pulls) == 0 { 244 + log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 + return 246 + } 247 + pull := pulls[0] 248 + 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 + if err != nil { 251 + log.Printf("NewPullComment: failed to get repos: %v", err) 252 + return 253 + } 254 + 255 + recipients := make(map[string]bool) 256 + 257 + // notify pull request author (if not the commenter) 258 + if pull.OwnerDid != comment.OwnerDid { 259 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 + if err == nil && prefs.PullCommented { 261 + recipients[pull.OwnerDid] = true 262 + } else if err != nil { 263 + log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 + } 265 + } 266 + 267 + // notify repo owner (if not the commenter and not already added) 268 + if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 + if err == nil && prefs.PullCommented { 271 + recipients[repo.Did] = true 272 + } else if err != nil { 273 + log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 + } 275 + } 276 + 277 + for recipientDid := range recipients { 278 + notification := &models.Notification{ 279 + RecipientDid: recipientDid, 280 + ActorDid: comment.OwnerDid, 281 + Type: models.NotificationTypePullCommented, 282 + EntityType: "pull", 283 + EntityId: comment.RepoAt, 284 + RepoId: &repo.Id, 285 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 + } 287 + 288 + err = n.db.CreateNotification(ctx, notification) 289 + if err != nil { 290 + log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 + } 292 + } 293 + } 294 + 295 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 296 + // no-op 297 + } 298 + 299 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 300 + // no-op 301 + } 302 + 303 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 304 + // no-op 305 + } 306 + 307 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 308 + // no-op 309 + } 310 + 311 + func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 + // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 + if err != nil { 315 + log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 + return 317 + } 318 + 319 + // Don't notify yourself 320 + if repo.Did == issue.Did { 321 + return 322 + } 323 + 324 + // Check if user wants these notifications 325 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 + if err != nil { 327 + log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 + return 329 + } 330 + if !prefs.IssueClosed { 331 + return 332 + } 333 + 334 + notification := &models.Notification{ 335 + RecipientDid: repo.Did, 336 + ActorDid: issue.Did, 337 + Type: models.NotificationTypeIssueClosed, 338 + EntityType: "issue", 339 + EntityId: string(issue.AtUri()), 340 + RepoId: &repo.Id, 341 + IssueId: &issue.Id, 342 + } 343 + 344 + err = n.db.CreateNotification(ctx, notification) 345 + if err != nil { 346 + log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 + return 348 + } 349 + } 350 + 351 + func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 352 + // Get repo details 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 + if err != nil { 355 + log.Printf("NewPullMerged: failed to get repos: %v", err) 356 + return 357 + } 358 + 359 + // Don't notify yourself 360 + if repo.Did == pull.OwnerDid { 361 + return 362 + } 363 + 364 + // Check if user wants these notifications 365 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 + if err != nil { 367 + log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 + return 369 + } 370 + if !prefs.PullMerged { 371 + return 372 + } 373 + 374 + notification := &models.Notification{ 375 + RecipientDid: pull.OwnerDid, 376 + ActorDid: repo.Did, 377 + Type: models.NotificationTypePullMerged, 378 + EntityType: "pull", 379 + EntityId: string(pull.RepoAt), 380 + RepoId: &repo.Id, 381 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 382 + } 383 + 384 + err = n.db.CreateNotification(ctx, notification) 385 + if err != nil { 386 + log.Printf("NewPullMerged: failed to create notification: %v", err) 387 + return 388 + } 389 + } 390 + 391 + func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 + // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 + if err != nil { 395 + log.Printf("NewPullClosed: failed to get repos: %v", err) 396 + return 397 + } 398 + 399 + // Don't notify yourself 400 + if repo.Did == pull.OwnerDid { 401 + return 402 + } 403 + 404 + // Check if user wants these notifications - reuse pull_merged preference for now 405 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 406 + if err != nil { 407 + log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 408 + return 409 + } 410 + if !prefs.PullMerged { 411 + return 412 + } 413 + 414 + notification := &models.Notification{ 415 + RecipientDid: pull.OwnerDid, 416 + ActorDid: repo.Did, 417 + Type: models.NotificationTypePullClosed, 418 + EntityType: "pull", 419 + EntityId: string(pull.RepoAt), 420 + RepoId: &repo.Id, 421 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 422 + } 423 + 424 + err = n.db.CreateNotification(ctx, notification) 425 + if err != nil { 426 + log.Printf("NewPullClosed: failed to create notification: %v", err) 427 + return 428 + } 429 + }
+23
appview/notify/merged_notifier.go
··· 38 notifier.NewIssue(ctx, issue) 39 } 40 } 41 42 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 for _, notifier := range m.notifiers { ··· 58 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 for _, notifier := range m.notifiers { 60 notifier.NewPullComment(ctx, comment) 61 } 62 } 63
··· 38 notifier.NewIssue(ctx, issue) 39 } 40 } 41 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 + for _, notifier := range m.notifiers { 43 + notifier.NewIssueComment(ctx, comment) 44 + } 45 + } 46 + 47 + func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 + for _, notifier := range m.notifiers { 49 + notifier.NewIssueClosed(ctx, issue) 50 + } 51 + } 52 53 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 54 for _, notifier := range m.notifiers { ··· 69 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 70 for _, notifier := range m.notifiers { 71 notifier.NewPullComment(ctx, comment) 72 + } 73 + } 74 + 75 + func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 + for _, notifier := range m.notifiers { 77 + notifier.NewPullMerged(ctx, pull) 78 + } 79 + } 80 + 81 + func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 + for _, notifier := range m.notifiers { 83 + notifier.NewPullClosed(ctx, pull) 84 } 85 } 86
+9 -1
appview/notify/notifier.go
··· 13 DeleteStar(ctx context.Context, star *models.Star) 14 15 NewIssue(ctx context.Context, issue *models.Issue) 16 17 NewFollow(ctx context.Context, follow *models.Follow) 18 DeleteFollow(ctx context.Context, follow *models.Follow) 19 20 NewPull(ctx context.Context, pull *models.Pull) 21 NewPullComment(ctx context.Context, comment *models.PullComment) 22 23 UpdateProfile(ctx context.Context, profile *models.Profile) 24 ··· 37 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 38 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 39 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 41 42 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 43 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 44 45 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 46 func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 47 48 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 49
··· 13 DeleteStar(ctx context.Context, star *models.Star) 14 15 NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 18 19 NewFollow(ctx context.Context, follow *models.Follow) 20 DeleteFollow(ctx context.Context, follow *models.Follow) 21 22 NewPull(ctx context.Context, pull *models.Pull) 23 NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 26 27 UpdateProfile(ctx context.Context, profile *models.Profile) 28 ··· 41 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 43 44 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 + func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 47 48 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 50 51 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 + func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 55 56 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 57
+219
appview/notify/posthog/notifier.go
···
··· 1 + package posthog 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: pull.OwnerDid, 104 + Event: "pull_closed", 105 + Properties: posthog.Properties{ 106 + "repo_at": pull.RepoAt, 107 + "pull_id": pull.PullId, 108 + }, 109 + }) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 115 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 116 + err := n.client.Enqueue(posthog.Capture{ 117 + DistinctId: follow.UserDid, 118 + Event: "follow", 119 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 120 + }) 121 + if err != nil { 122 + log.Println("failed to enqueue posthog event:", err) 123 + } 124 + } 125 + 126 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 127 + err := n.client.Enqueue(posthog.Capture{ 128 + DistinctId: follow.UserDid, 129 + Event: "unfollow", 130 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 131 + }) 132 + if err != nil { 133 + log.Println("failed to enqueue posthog event:", err) 134 + } 135 + } 136 + 137 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 138 + err := n.client.Enqueue(posthog.Capture{ 139 + DistinctId: profile.Did, 140 + Event: "edit_profile", 141 + }) 142 + if err != nil { 143 + log.Println("failed to enqueue posthog event:", err) 144 + } 145 + } 146 + 147 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 148 + err := n.client.Enqueue(posthog.Capture{ 149 + DistinctId: did, 150 + Event: "delete_string", 151 + Properties: posthog.Properties{"rkey": rkey}, 152 + }) 153 + if err != nil { 154 + log.Println("failed to enqueue posthog event:", err) 155 + } 156 + } 157 + 158 + func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 159 + err := n.client.Enqueue(posthog.Capture{ 160 + DistinctId: string.Did.String(), 161 + Event: "edit_string", 162 + Properties: posthog.Properties{"rkey": string.Rkey}, 163 + }) 164 + if err != nil { 165 + log.Println("failed to enqueue posthog event:", err) 166 + } 167 + } 168 + 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 170 + err := n.client.Enqueue(posthog.Capture{ 171 + DistinctId: string.Did.String(), 172 + Event: "new_string", 173 + Properties: posthog.Properties{"rkey": string.Rkey}, 174 + }) 175 + if err != nil { 176 + log.Println("failed to enqueue posthog event:", err) 177 + } 178 + } 179 + 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 + err := n.client.Enqueue(posthog.Capture{ 182 + DistinctId: comment.Did, 183 + Event: "new_issue_comment", 184 + Properties: posthog.Properties{ 185 + "issue_at": comment.IssueAt, 186 + }, 187 + }) 188 + if err != nil { 189 + log.Println("failed to enqueue posthog event:", err) 190 + } 191 + } 192 + 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 + err := n.client.Enqueue(posthog.Capture{ 195 + DistinctId: issue.Did, 196 + Event: "issue_closed", 197 + Properties: posthog.Properties{ 198 + "repo_at": issue.RepoAt.String(), 199 + "issue_id": issue.IssueId, 200 + }, 201 + }) 202 + if err != nil { 203 + log.Println("failed to enqueue posthog event:", err) 204 + } 205 + } 206 + 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 + err := n.client.Enqueue(posthog.Capture{ 209 + DistinctId: pull.OwnerDid, 210 + Event: "pull_merged", 211 + Properties: posthog.Properties{ 212 + "repo_at": pull.RepoAt, 213 + "pull_id": pull.PullId, 214 + }, 215 + }) 216 + if err != nil { 217 + log.Println("failed to enqueue posthog event:", err) 218 + } 219 + }
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
···
+2 -1
appview/oauth/consts.go
··· 1 package oauth 2 3 const ( 4 - SessionName = "appview-session" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 SessionPds = "pds" 8 SessionAccessJwt = "accessJwt" 9 SessionRefreshJwt = "refreshJwt"
··· 1 package oauth 2 3 const ( 4 + SessionName = "appview-session-v2" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 + SessionId = "id" 8 SessionPds = "pds" 9 SessionAccessJwt = "accessJwt" 10 SessionRefreshJwt = "refreshJwt"
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - tangled "tangled.org/core/api/tangled" 20 - sessioncache "tangled.org/core/appview/cache/session" 21 - "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 23 - "tangled.org/core/appview/middleware" 24 - "tangled.org/core/appview/oauth" 25 - "tangled.org/core/appview/oauth/client" 26 - "tangled.org/core/appview/pages" 27 - "tangled.org/core/consts" 28 - "tangled.org/core/idresolver" 29 - "tangled.org/core/rbac" 30 - "tangled.org/core/tid" 31 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
···
+65
appview/oauth/handler.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + func (o *OAuth) Router() http.Handler { 13 + r := chi.NewRouter() 14 + 15 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 16 + r.Get("/oauth/jwks.json", o.jwks) 17 + r.Get("/oauth/callback", o.callback) 18 + return r 19 + } 20 + 21 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 + doc := o.ClientApp.Config.ClientMetadata() 23 + doc.JWKSURI = &o.JwksUri 24 + 25 + w.Header().Set("Content-Type", "application/json") 26 + if err := json.NewEncoder(w).Encode(doc); err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + } 31 + 32 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 + jwks := o.Config.OAuth.Jwks 34 + pubKey, err := pubKeyFromJwk(jwks) 35 + if err != nil { 36 + log.Printf("error parsing public key: %v", err) 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + response := map[string]any{ 42 + "keys": []jwk.Key{pubKey}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.WriteHeader(http.StatusOK) 47 + json.NewEncoder(w).Encode(response) 48 + } 49 + 50 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 + ctx := r.Context() 52 + 53 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + if err := o.SaveSession(w, r, sessData); err != nil { 60 + http.Error(w, err.Error(), http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/", http.StatusFound) 65 + }
+107 -202
appview/oauth/oauth.go
··· 1 package oauth 2 3 import ( 4 "fmt" 5 - "log" 6 "net/http" 7 - "net/url" 8 "time" 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 13 "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/oauth/client" 15 - xrpc "tangled.org/core/appview/xrpcclient" 16 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 18 ) 19 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 25 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 31 } 32 } 33 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 36 } 37 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 39 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 41 if err != nil { 42 return err 43 } 44 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 48 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 50 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 52 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 65 } 66 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 74 } 75 76 - did := userSession.Values[SessionDid].(string) 77 78 - err = o.sess.DeleteSession(r.Context(), did) 79 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 81 } 82 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 86 } 87 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 92 } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 - if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 100 } 101 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 103 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 - self := o.ClientMetadata() 113 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 119 120 - if err != nil { 121 - return nil, false, err 122 - } 123 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 128 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 140 } 141 - 142 - return session, auth, nil 143 } 144 145 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 149 } 150 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 153 154 - if err != nil || clientSession.IsNew { 155 return nil 156 } 157 158 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 162 } 163 } 164 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 170 } 171 172 - return clientSession.Values[SessionDid].(string) 173 } 174 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 177 if err != nil { 178 return nil, fmt.Errorf("error getting session: %w", err) 179 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 208 } 209 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 // this is a higher level abstraction on ServerGetServiceAuth 213 type ServiceClientOpts struct { 214 service string ··· 259 return scheme + s.service 260 } 261 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 263 opts := ServiceClientOpts{} 264 for _, o := range os { 265 o(&opts) 266 } 267 268 - authorizedClient, err := o.AuthorizedClient(r) 269 if err != nil { 270 return nil, err 271 } ··· 276 opts.exp = sixty 277 } 278 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 280 if err != nil { 281 return nil, err 282 } 283 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), ··· 291 }, 292 }, nil 293 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
··· 1 package oauth 2 3 import ( 4 + "errors" 5 "fmt" 6 "net/http" 7 "time" 8 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + xrpc "github.com/bluesky-social/indigo/xrpc" 14 "github.com/gorilla/sessions" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 16 "tangled.org/core/appview/config" 17 ) 18 19 + func New(config *config.Config) (*OAuth, error) { 20 + 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 23 24 + if config.Core.Dev { 25 + clientUri = "http://127.0.0.1:3000" 26 + callbackUri := clientUri + "/oauth/callback" 27 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 28 + } else { 29 + clientUri = config.Core.AppviewHost 30 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 31 + callbackUri := clientUri + "/oauth/callback" 32 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 33 } 34 + 35 + jwksUri := clientUri + "/oauth/jwks.json" 36 + 37 + authStore, err := NewRedisStore(config.Redis.ToURL()) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 + 44 + return &OAuth{ 45 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 + Config: config, 47 + SessStore: sessStore, 48 + JwksUri: jwksUri, 49 + }, nil 50 } 51 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 57 } 58 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 60 // first we save the did in the user session 61 + userSession, err := o.SessStore.Get(r, SessionName) 62 if err != nil { 63 return err 64 } 65 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 69 userSession.Values[SessionAuthenticated] = true 70 + return userSession.Save(r, w) 71 + } 72 + 73 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 74 + userSession, err := o.SessStore.Get(r, SessionName) 75 if err != nil { 76 + return nil, fmt.Errorf("error getting user session: %w", err) 77 } 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 80 } 81 82 + d := userSession.Values[SessionDid].(string) 83 + sessDid, err := syntax.ParseDID(d) 84 + if err != nil { 85 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 86 } 87 88 + sessId := userSession.Values[SessionId].(string) 89 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 91 if err != nil { 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 93 } 94 95 + return clientSess, nil 96 } 97 98 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 99 + userSession, err := o.SessStore.Get(r, SessionName) 100 + if err != nil { 101 + return fmt.Errorf("error getting user session: %w", err) 102 } 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 105 } 106 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 109 if err != nil { 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 111 } 112 113 + sessId := userSession.Values[SessionId].(string) 114 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 117 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 121 122 + return errors.Join(err1, err2) 123 + } 124 125 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 + k, err := jwk.ParseKey([]byte(jwks)) 127 + if err != nil { 128 + return nil, err 129 + } 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 133 } 134 + return pubKey, nil 135 } 136 137 type User struct { 138 + Did string 139 + Pds string 140 } 141 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 144 145 + if err != nil || sess.IsNew { 146 return nil 147 } 148 149 return &User{ 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 152 } 153 } 154 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 158 } 159 160 + return "" 161 } 162 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 165 if err != nil { 166 return nil, fmt.Errorf("error getting session: %w", err) 167 } 168 + return session.APIClient(), nil 169 } 170 171 // this is a higher level abstraction on ServerGetServiceAuth 172 type ServiceClientOpts struct { 173 service string ··· 218 return scheme + s.service 219 } 220 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 222 opts := ServiceClientOpts{} 223 for _, o := range os { 224 o(&opts) 225 } 226 227 + client, err := o.AuthorizedClient(r) 228 if err != nil { 229 return nil, err 230 } ··· 235 opts.exp = sixty 236 } 237 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 239 if err != nil { 240 return nil, err 241 } 242 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 245 AccessJwt: resp.Token, 246 }, 247 Host: opts.Host(), ··· 250 }, 251 }, nil 252 }
+147
appview/oauth/store.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+156
appview/pages/legal/privacy.md
···
··· 1 + **Last updated:** September 26, 2025 2 + 3 + This Privacy Policy describes how Tangled ("we," "us," or "our") 4 + collects, uses, and shares your personal information when you use our 5 + platform and services (the "Service"). 6 + 7 + ## 1. Information We Collect 8 + 9 + ### Account Information 10 + 11 + When you create an account, we collect: 12 + 13 + - Your chosen username 14 + - Email address 15 + - Profile information you choose to provide 16 + - Authentication data 17 + 18 + ### Content and Activity 19 + 20 + We store: 21 + 22 + - Code repositories and associated metadata 23 + - Issues, pull requests, and comments 24 + - Activity logs and usage patterns 25 + - Public keys for authentication 26 + 27 + ## 2. Data Location and Hosting 28 + 29 + ### EU Data Hosting 30 + 31 + **All Tangled service data is hosted within the European Union.** 32 + Specifically: 33 + 34 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 35 + (*.tngl.sh) are located in Finland 36 + - **Application Data:** All other service data is stored on EU-based 37 + servers 38 + - **Data Processing:** All data processing occurs within EU 39 + jurisdiction 40 + 41 + ### External PDS Notice 42 + 43 + **Important:** If your account is hosted on Bluesky's PDS or other 44 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 45 + that data. The data protection, storage location, and privacy 46 + practices for such accounts are governed by the respective PDS 47 + provider's policies, not this Privacy Policy. We only control data 48 + processing within our own services and infrastructure. 49 + 50 + ## 3. Third-Party Data Processors 51 + 52 + We only share your data with the following third-party processors: 53 + 54 + ### Resend (Email Services) 55 + 56 + - **Purpose:** Sending transactional emails (account verification, 57 + notifications) 58 + - **Data Shared:** Email address and necessary message content 59 + 60 + ### Cloudflare (Image Caching) 61 + 62 + - **Purpose:** Caching and optimizing image delivery 63 + - **Data Shared:** Public images and associated metadata for caching 64 + purposes 65 + 66 + ### Posthog (Usage Metrics Tracking) 67 + 68 + - **Purpose:** Tracking usage and platform metrics 69 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 70 + information 71 + 72 + ## 4. How We Use Your Information 73 + 74 + We use your information to: 75 + 76 + - Provide and maintain the Service 77 + - Process your transactions and requests 78 + - Send you technical notices and support messages 79 + - Improve and develop new features 80 + - Ensure security and prevent fraud 81 + - Comply with legal obligations 82 + 83 + ## 5. Data Sharing and Disclosure 84 + 85 + We do not sell, trade, or rent your personal information. We may share 86 + your information only in the following circumstances: 87 + 88 + - With the third-party processors listed above 89 + - When required by law or legal process 90 + - To protect our rights, property, or safety, or that of our users 91 + - In connection with a merger, acquisition, or sale of assets (with 92 + appropriate protections) 93 + 94 + ## 6. Data Security 95 + 96 + We implement appropriate technical and organizational measures to 97 + protect your personal information against unauthorized access, 98 + alteration, disclosure, or destruction. However, no method of 99 + transmission over the Internet is 100% secure. 100 + 101 + ## 7. Data Retention 102 + 103 + We retain your personal information for as long as necessary to provide 104 + the Service and fulfill the purposes outlined in this Privacy Policy, 105 + unless a longer retention period is required by law. 106 + 107 + ## 8. Your Rights 108 + 109 + Under applicable data protection laws, you have the right to: 110 + 111 + - Access your personal information 112 + - Correct inaccurate information 113 + - Request deletion of your information 114 + - Object to processing of your information 115 + - Data portability 116 + - Withdraw consent (where applicable) 117 + 118 + ## 9. Cookies and Tracking 119 + 120 + We use cookies and similar technologies to: 121 + 122 + - Maintain your login session 123 + - Remember your preferences 124 + - Analyze usage patterns to improve the Service 125 + 126 + You can control cookie settings through your browser preferences. 127 + 128 + ## 10. Children's Privacy 129 + 130 + The Service is not intended for children under 16 years of age. We do 131 + not knowingly collect personal information from children under 16. If 132 + we become aware that we have collected such information, we will take 133 + steps to delete it. 134 + 135 + ## 11. International Data Transfers 136 + 137 + While all our primary data processing occurs within the EU, some of our 138 + third-party processors may process data outside the EU. When this 139 + occurs, we ensure appropriate safeguards are in place, such as Standard 140 + Contractual Clauses or adequacy decisions. 141 + 142 + ## 12. Changes to This Privacy Policy 143 + 144 + We may update this Privacy Policy from time to time. We will notify you 145 + of any changes by posting the new Privacy Policy on this page and 146 + updating the "Last updated" date. 147 + 148 + ## 13. Contact Information 149 + 150 + If you have any questions about this Privacy Policy or wish to exercise 151 + your rights, please contact us through our platform or via email. 152 + 153 + --- 154 + 155 + This Privacy Policy complies with the EU General Data Protection 156 + Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
···
··· 1 + **Last updated:** September 26, 2025 2 + 3 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 4 + to and use of the Tangled platform and services (the "Service") 5 + operated by us ("Tangled," "we," "us," or "our"). 6 + 7 + ## 1. Acceptance of Terms 8 + 9 + By accessing or using our Service, you agree to be bound by these Terms. 10 + If you disagree with any part of these terms, then you may not access 11 + the Service. 12 + 13 + ## 2. Account Registration 14 + 15 + To use certain features of the Service, you must register for an 16 + account. You agree to provide accurate, current, and complete 17 + information during the registration process and to update such 18 + information to keep it accurate, current, and complete. 19 + 20 + ## 3. Account Termination 21 + 22 + > **Important Notice** 23 + > 24 + > **We reserve the right to terminate, suspend, or restrict access to 25 + > your account at any time, for any reason, or for no reason at all, at 26 + > our sole discretion.** This includes, but is not limited to, 27 + > termination for violation of these Terms, inappropriate conduct, spam, 28 + > abuse, or any other behavior we deem harmful to the Service or other 29 + > users. 30 + > 31 + > Account termination may result in the loss of access to your 32 + > repositories, data, and other content associated with your account. We 33 + > are not obligated to provide advance notice of termination, though we 34 + > may do so in our discretion. 35 + 36 + ## 4. Acceptable Use 37 + 38 + You agree not to use the Service to: 39 + 40 + - Violate any applicable laws or regulations 41 + - Infringe upon the rights of others 42 + - Upload, store, or share content that is illegal, harmful, threatening, 43 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 44 + objectionable 45 + - Engage in spam, phishing, or other deceptive practices 46 + - Attempt to gain unauthorized access to the Service or other users' 47 + accounts 48 + - Interfere with or disrupt the Service or servers connected to the 49 + Service 50 + 51 + ## 5. Content and Intellectual Property 52 + 53 + You retain ownership of the content you upload to the Service. By 54 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 55 + license to use, reproduce, modify, and distribute your content as 56 + necessary to provide the Service. 57 + 58 + ## 6. Privacy 59 + 60 + Your privacy is important to us. Please review our [Privacy 61 + Policy](/privacy), which also governs your use of the Service. 62 + 63 + ## 7. Disclaimers 64 + 65 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 66 + no warranties, expressed or implied, and hereby disclaim and negate all 67 + other warranties including without limitation, implied warranties or 68 + conditions of merchantability, fitness for a particular purpose, or 69 + non-infringement of intellectual property or other violation of rights. 70 + 71 + ## 8. Limitation of Liability 72 + 73 + In no event shall Tangled, nor its directors, employees, partners, 74 + agents, suppliers, or affiliates, be liable for any indirect, 75 + incidental, special, consequential, or punitive damages, including 76 + without limitation, loss of profits, data, use, goodwill, or other 77 + intangible losses, resulting from your use of the Service. 78 + 79 + ## 9. Indemnification 80 + 81 + You agree to defend, indemnify, and hold harmless Tangled and its 82 + affiliates, officers, directors, employees, and agents from and against 83 + any and all claims, damages, obligations, losses, liabilities, costs, 84 + or debt, and expenses (including attorney's fees). 85 + 86 + ## 10. Governing Law 87 + 88 + These Terms shall be interpreted and governed by the laws of Finland, 89 + without regard to its conflict of law provisions. 90 + 91 + ## 11. Changes to Terms 92 + 93 + We reserve the right to modify or replace these Terms at any time. If a 94 + revision is material, we will try to provide at least 30 days notice 95 + prior to any new terms taking effect. 96 + 97 + ## 12. Contact Information 98 + 99 + If you have any questions about these Terms of Service, please contact 100 + us through our platform or via email. 101 + 102 + --- 103 + 104 + These terms are effective as of the last updated date shown above and 105 + will remain in effect except with respect to any changes in their 106 + provisions in the future, which will be in effect immediately after 107 + being posted on this page.
+15 -17
appview/pages/markup/format.go
··· 1 package markup 2 3 - import "strings" 4 5 type Format string 6 ··· 10 ) 11 12 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 16 - // ReadmeFilenames contains the list of common README filenames to search for, 17 - // in order of preference. Only includes well-supported formats. 18 - var ReadmeFilenames = []string{ 19 - "README.md", "readme.md", 20 - "README", 21 - "readme", 22 - "README.markdown", 23 - "readme.markdown", 24 - "README.txt", 25 - "readme.txt", 26 } 27 28 func GetFormat(filename string) Format { 29 - for format, extensions := range FileTypes { 30 - for _, extension := range extensions { 31 - if strings.HasSuffix(filename, extension) { 32 - return format 33 - } 34 } 35 } 36 // default format
··· 1 package markup 2 3 + import ( 4 + "regexp" 5 + ) 6 7 type Format string 8 ··· 12 ) 13 14 var FileTypes map[Format][]string = map[Format][]string{ 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 16 } 17 18 + var FileTypePatterns = map[Format]*regexp.Regexp{ 19 + FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`), 20 + } 21 + 22 + var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`) 23 + 24 + func IsReadmeFile(filename string) bool { 25 + return ReadmePattern.MatchString(filename) 26 } 27 28 func GetFormat(filename string) Format { 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 32 } 33 } 34 // default format
+117 -27
appview/pages/pages.go
··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 - //go:embed templates/* static 42 var Files embed.FS 43 44 type Pages struct { ··· 226 return p.executePlain("user/login", w, params) 227 } 228 229 - func (p *Pages) Signup(w io.Writer) error { 230 - return p.executePlain("user/signup", w, nil) 231 } 232 233 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 242 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 243 filename := "terms.md" 244 filePath := filepath.Join("legal", filename) 245 - markdownBytes, err := os.ReadFile(filePath) 246 if err != nil { 247 return fmt.Errorf("failed to read %s: %w", filename, err) 248 } ··· 263 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 264 filename := "privacy.md" 265 filePath := filepath.Join("legal", filename) 266 - markdownBytes, err := os.ReadFile(filePath) 267 if err != nil { 268 return fmt.Errorf("failed to read %s: %w", filename, err) 269 } ··· 276 return p.execute("legal/privacy", w, params) 277 } 278 279 type TimelineParams struct { 280 LoggedInUser *oauth.User 281 Timeline []models.TimelineEvent 282 Repos []models.Repo 283 } 284 285 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 286 return p.execute("timeline/timeline", w, params) 287 } 288 289 type UserProfileSettingsParams struct { 290 LoggedInUser *oauth.User 291 Tabs []map[string]any ··· 296 return p.execute("user/settings/profile", w, params) 297 } 298 299 type UserKeysSettingsParams struct { 300 LoggedInUser *oauth.User 301 PubKeys []models.PublicKey ··· 316 317 func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 318 return p.execute("user/settings/emails", w, params) 319 } 320 321 type UpgradeBannerParams struct { ··· 655 } 656 657 type RepoTreeParams struct { 658 - LoggedInUser *oauth.User 659 - RepoInfo repoinfo.RepoInfo 660 - Active string 661 - BreadCrumbs [][]string 662 - TreePath string 663 - Readme string 664 - ReadmeFileName string 665 - HTMLReadme template.HTML 666 - Raw bool 667 types.RepoTreeResponse 668 } 669 ··· 691 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 692 params.Active = "overview" 693 694 - if params.ReadmeFileName != "" { 695 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 696 697 ext := filepath.Ext(params.ReadmeFileName) 698 switch ext { 699 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 835 } 836 837 type RepoGeneralSettingsParams struct { 838 - LoggedInUser *oauth.User 839 - RepoInfo repoinfo.RepoInfo 840 - Labels []models.LabelDefinition 841 - DefaultLabels []models.LabelDefinition 842 - SubscribedLabels map[string]struct{} 843 - Active string 844 - Tabs []map[string]any 845 - Tab string 846 - Branches []types.Branch 847 } 848 849 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 889 LabelDefs map[string]*models.LabelDefinition 890 Page pagination.Page 891 FilteringByOpen bool 892 } 893 894 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 905 LabelDefs map[string]*models.LabelDefinition 906 907 OrderedReactionKinds []models.ReactionKind 908 - Reactions map[models.ReactionKind]int 909 UserReacted map[models.ReactionKind]bool 910 } 911 ··· 930 ThreadAt syntax.ATURI 931 Kind models.ReactionKind 932 Count int 933 IsReacted bool 934 } 935 ··· 1020 FilteringBy models.PullState 1021 Stacks map[string]models.Stack 1022 Pipelines map[string]models.Pipeline 1023 } 1024 1025 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1057 Pipelines map[string]models.Pipeline 1058 1059 OrderedReactionKinds []models.ReactionKind 1060 - Reactions map[models.ReactionKind]int 1061 UserReacted map[models.ReactionKind]bool 1062 } 1063 1064 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 + //go:embed templates/* static legal 42 var Files embed.FS 43 44 type Pages struct { ··· 226 return p.executePlain("user/login", w, params) 227 } 228 229 + type SignupParams struct { 230 + CloudflareSiteKey string 231 + } 232 + 233 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 234 + return p.executePlain("user/signup", w, params) 235 } 236 237 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 filename := "terms.md" 248 filePath := filepath.Join("legal", filename) 249 + 250 + file, err := p.embedFS.Open(filePath) 251 + if err != nil { 252 + return fmt.Errorf("failed to read %s: %w", filename, err) 253 + } 254 + defer file.Close() 255 + 256 + markdownBytes, err := io.ReadAll(file) 257 if err != nil { 258 return fmt.Errorf("failed to read %s: %w", filename, err) 259 } ··· 274 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 275 filename := "privacy.md" 276 filePath := filepath.Join("legal", filename) 277 + 278 + file, err := p.embedFS.Open(filePath) 279 + if err != nil { 280 + return fmt.Errorf("failed to read %s: %w", filename, err) 281 + } 282 + defer file.Close() 283 + 284 + markdownBytes, err := io.ReadAll(file) 285 if err != nil { 286 return fmt.Errorf("failed to read %s: %w", filename, err) 287 } ··· 294 return p.execute("legal/privacy", w, params) 295 } 296 297 + type BrandParams struct { 298 + LoggedInUser *oauth.User 299 + } 300 + 301 + func (p *Pages) Brand(w io.Writer, params BrandParams) error { 302 + return p.execute("brand/brand", w, params) 303 + } 304 + 305 type TimelineParams struct { 306 LoggedInUser *oauth.User 307 Timeline []models.TimelineEvent 308 Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 310 } 311 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 313 return p.execute("timeline/timeline", w, params) 314 } 315 316 + type GoodFirstIssuesParams struct { 317 + LoggedInUser *oauth.User 318 + Issues []models.Issue 319 + RepoGroups []*models.RepoGroup 320 + LabelDefs map[string]*models.LabelDefinition 321 + GfiLabel *models.LabelDefinition 322 + Page pagination.Page 323 + } 324 + 325 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 326 + return p.execute("goodfirstissues/index", w, params) 327 + } 328 + 329 type UserProfileSettingsParams struct { 330 LoggedInUser *oauth.User 331 Tabs []map[string]any ··· 336 return p.execute("user/settings/profile", w, params) 337 } 338 339 + type NotificationsParams struct { 340 + LoggedInUser *oauth.User 341 + Notifications []*models.NotificationWithEntity 342 + UnreadCount int 343 + Page pagination.Page 344 + Total int64 345 + } 346 + 347 + func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 348 + return p.execute("notifications/list", w, params) 349 + } 350 + 351 + type NotificationItemParams struct { 352 + Notification *models.Notification 353 + } 354 + 355 + func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 356 + return p.executePlain("notifications/fragments/item", w, params) 357 + } 358 + 359 + type NotificationCountParams struct { 360 + Count int64 361 + } 362 + 363 + func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 364 + return p.executePlain("notifications/fragments/count", w, params) 365 + } 366 + 367 type UserKeysSettingsParams struct { 368 LoggedInUser *oauth.User 369 PubKeys []models.PublicKey ··· 384 385 func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 386 return p.execute("user/settings/emails", w, params) 387 + } 388 + 389 + type UserNotificationSettingsParams struct { 390 + LoggedInUser *oauth.User 391 + Preferences *models.NotificationPreferences 392 + Tabs []map[string]any 393 + Tab string 394 + } 395 + 396 + func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 397 + return p.execute("user/settings/notifications", w, params) 398 } 399 400 type UpgradeBannerParams struct { ··· 734 } 735 736 type RepoTreeParams struct { 737 + LoggedInUser *oauth.User 738 + RepoInfo repoinfo.RepoInfo 739 + Active string 740 + BreadCrumbs [][]string 741 + TreePath string 742 + Raw bool 743 + HTMLReadme template.HTML 744 types.RepoTreeResponse 745 } 746 ··· 768 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 769 params.Active = "overview" 770 771 + p.rctx.RepoInfo = params.RepoInfo 772 + p.rctx.RepoInfo.Ref = params.Ref 773 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 774 775 + if params.ReadmeFileName != "" { 776 ext := filepath.Ext(params.ReadmeFileName) 777 switch ext { 778 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 914 } 915 916 type RepoGeneralSettingsParams struct { 917 + LoggedInUser *oauth.User 918 + RepoInfo repoinfo.RepoInfo 919 + Labels []models.LabelDefinition 920 + DefaultLabels []models.LabelDefinition 921 + SubscribedLabels map[string]struct{} 922 + ShouldSubscribeAll bool 923 + Active string 924 + Tabs []map[string]any 925 + Tab string 926 + Branches []types.Branch 927 } 928 929 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 969 LabelDefs map[string]*models.LabelDefinition 970 Page pagination.Page 971 FilteringByOpen bool 972 + SearchQuery string 973 + SortBy string 974 + SortOrder string 975 } 976 977 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 988 LabelDefs map[string]*models.LabelDefinition 989 990 OrderedReactionKinds []models.ReactionKind 991 + Reactions map[models.ReactionKind]models.ReactionDisplayData 992 UserReacted map[models.ReactionKind]bool 993 } 994 ··· 1013 ThreadAt syntax.ATURI 1014 Kind models.ReactionKind 1015 Count int 1016 + Users []string 1017 IsReacted bool 1018 } 1019 ··· 1104 FilteringBy models.PullState 1105 Stacks map[string]models.Stack 1106 Pipelines map[string]models.Pipeline 1107 + LabelDefs map[string]*models.LabelDefinition 1108 + SearchQuery string 1109 + SortBy string 1110 + SortOrder string 1111 } 1112 1113 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1145 Pipelines map[string]models.Pipeline 1146 1147 OrderedReactionKinds []models.ReactionKind 1148 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1149 UserReacted map[models.ReactionKind]bool 1150 + 1151 + LabelDefs map[string]*models.LabelDefinition 1152 } 1153 1154 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+224
appview/pages/templates/brand/brand.html
···
··· 1 + {{ define "title" }}brand{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Assets and guidelines for using Tangled's logo and brand elements. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="space-y-16"> 14 + 15 + <!-- Introduction Section --> 16 + <section> 17 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 18 + Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 + follow the below guidelines when using Dolly and the logotype. 20 + </p> 21 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 22 + All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 + </p> 24 + </section> 25 + 26 + <!-- Black Logotype Section --> 27 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 28 + <div class="order-2 lg:order-1"> 29 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 30 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 31 + alt="Tangled logo - black version" 32 + class="w-full max-w-sm mx-auto" /> 33 + </div> 34 + </div> 35 + <div class="order-1 lg:order-2"> 36 + <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 38 + <p class="text-gray-700 dark:text-gray-300"> 39 + This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 + backgrounds and designs. 41 + </p> 42 + </div> 43 + </section> 44 + 45 + <!-- White Logotype Section --> 46 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 47 + <div class="order-2 lg:order-1"> 48 + <div class="bg-black p-8 sm:p-16 rounded"> 49 + <img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg" 50 + alt="Tangled logo - white version" 51 + class="w-full max-w-sm mx-auto" /> 52 + </div> 53 + </div> 54 + <div class="order-1 lg:order-2"> 55 + <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 57 + <p class="text-gray-700 dark:text-gray-300"> 58 + This version features white text and elements, ideal for dark backgrounds 59 + and inverted designs. 60 + </p> 61 + </div> 62 + </section> 63 + 64 + <!-- Mark Only Section --> 65 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 66 + <div class="order-2 lg:order-1"> 67 + <div class="grid grid-cols-2 gap-2"> 68 + <!-- Black mark on light background --> 69 + <div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded"> 70 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 71 + alt="Dolly face - black version" 72 + class="w-full max-w-16 mx-auto" /> 73 + </div> 74 + <!-- White mark on dark background --> 75 + <div class="bg-black p-8 sm:p-12 rounded"> 76 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 77 + alt="Dolly face - white version" 78 + class="w-full max-w-16 mx-auto" /> 79 + </div> 80 + </div> 81 + </div> 82 + <div class="order-1 lg:order-2"> 83 + <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 85 + When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 + </p> 87 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 88 + <strong class="font-semibold">Note</strong>: for situations where the background 89 + is unknown, use the black version for ideal contrast in most environments. 90 + </p> 91 + </div> 92 + </section> 93 + 94 + <!-- Colored Backgrounds Section --> 95 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 96 + <div class="order-2 lg:order-1"> 97 + <div class="grid grid-cols-2 gap-2"> 98 + <!-- Pastel Green background --> 99 + <div class="bg-green-500 p-8 sm:p-12 rounded"> 100 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 101 + alt="Tangled logo on pastel green background" 102 + class="w-full max-w-16 mx-auto" /> 103 + </div> 104 + <!-- Pastel Blue background --> 105 + <div class="bg-blue-500 p-8 sm:p-12 rounded"> 106 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 107 + alt="Tangled logo on pastel blue background" 108 + class="w-full max-w-16 mx-auto" /> 109 + </div> 110 + <!-- Pastel Yellow background --> 111 + <div class="bg-yellow-500 p-8 sm:p-12 rounded"> 112 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 113 + alt="Tangled logo on pastel yellow background" 114 + class="w-full max-w-16 mx-auto" /> 115 + </div> 116 + <!-- Pastel Red background --> 117 + <div class="bg-red-500 p-8 sm:p-12 rounded"> 118 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 119 + alt="Tangled logo on pastel red background" 120 + class="w-full max-w-16 mx-auto" /> 121 + </div> 122 + </div> 123 + </div> 124 + <div class="order-1 lg:order-2"> 125 + <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 127 + White logo mark on colored backgrounds. 128 + </p> 129 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 130 + The white logo mark provides contrast on colored backgrounds. 131 + Perfect for more fun design contexts. 132 + </p> 133 + </div> 134 + </section> 135 + 136 + <!-- Black Logo on Pastel Backgrounds Section --> 137 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 138 + <div class="order-2 lg:order-1"> 139 + <div class="grid grid-cols-2 gap-2"> 140 + <!-- Pastel Green background --> 141 + <div class="bg-green-200 p-8 sm:p-12 rounded"> 142 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 143 + alt="Tangled logo on pastel green background" 144 + class="w-full max-w-16 mx-auto" /> 145 + </div> 146 + <!-- Pastel Blue background --> 147 + <div class="bg-blue-200 p-8 sm:p-12 rounded"> 148 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 149 + alt="Tangled logo on pastel blue background" 150 + class="w-full max-w-16 mx-auto" /> 151 + </div> 152 + <!-- Pastel Yellow background --> 153 + <div class="bg-yellow-200 p-8 sm:p-12 rounded"> 154 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 155 + alt="Tangled logo on pastel yellow background" 156 + class="w-full max-w-16 mx-auto" /> 157 + </div> 158 + <!-- Pastel Pink background --> 159 + <div class="bg-pink-200 p-8 sm:p-12 rounded"> 160 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 161 + alt="Tangled logo on pastel pink background" 162 + class="w-full max-w-16 mx-auto" /> 163 + </div> 164 + </div> 165 + </div> 166 + <div class="order-1 lg:order-2"> 167 + <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 169 + Dark logo mark on lighter, pastel backgrounds. 170 + </p> 171 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 172 + The dark logo mark works beautifully on pastel backgrounds, 173 + providing crisp contrast. 174 + </p> 175 + </div> 176 + </section> 177 + 178 + <!-- Recoloring Section --> 179 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 180 + <div class="order-2 lg:order-1"> 181 + <div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded"> 182 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 183 + alt="Recolored Tangled logotype in gray/sand color" 184 + class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" /> 185 + </div> 186 + </div> 187 + <div class="order-1 lg:order-2"> 188 + <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 190 + Custom coloring of the logotype is permitted. 191 + </p> 192 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 193 + Recoloring the logotype is allowed as long as readability is maintained. 194 + </p> 195 + <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 + <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 + </p> 198 + </div> 199 + </section> 200 + 201 + <!-- Silhouette Section --> 202 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 + <div class="order-2 lg:order-1"> 204 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 + <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 + alt="Dolly silhouette" 207 + class="w-full max-w-32 mx-auto" /> 208 + </div> 209 + </div> 210 + <div class="order-1 lg:order-2"> 211 + <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 + <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 + <p class="text-gray-700 dark:text-gray-300"> 214 + The silhouette can be used where a subtle brand presence is needed, 215 + or as a background element. Works on any background color with proper contrast. 216 + For example, we use this as the site's favicon. 217 + </p> 218 + </div> 219 + </section> 220 + 221 + </div> 222 + </main> 223 + </div> 224 + {{ end }}
+4 -11
appview/pages/templates/errors/500.html
··· 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 16 <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 <button onclick="location.reload()" class="btn-create gap-2"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 30 </button> 31 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> 35 </div>
··· 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 16 <p class="text-gray-600 dark:text-gray-300"> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 <button onclick="location.reload()" class="btn-create gap-2"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 23 </button> 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 back to home 27 </a> 28 </div>
+167
appview/pages/templates/goodfirstissues/index.html
···
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 <!-- preload main font --> 18 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 ··· 21 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 26 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 29 {{ if .LoggedInUser }} 30 <div id="upgrade-banner" ··· 38 {{ end }} 39 40 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <main class="col-span-1 md:col-span-8"> 44 {{ block "content" . }}{{ end }} 45 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 50 {{ block "contentAfter" . }}{{ end }} 51 </main> 52 - {{ end }} 53 </div> 54 {{ end }} 55 56 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 58 {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }}
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 20 <!-- preload main font --> 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 22 ··· 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 25 {{ block "extrameta" . }}{{ end }} 26 </head> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 28 {{ block "topbarLayout" . }} 29 + <header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 31 {{ if .LoggedInUser }} 32 <div id="upgrade-banner" ··· 40 {{ end }} 41 42 {{ block "mainLayout" . }} 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 47 {{ block "content" . }}{{ end }} 48 </main> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 53 {{ block "contentAfter" . }}{{ end }} 54 </main> 55 + {{ end }} 56 + </div> 57 </div> 58 {{ end }} 59 60 {{ block "footerLayout" . }} 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 62 {{ template "layouts/fragments/footer" . }} 63 </footer> 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 10 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 </div> 20 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 </div> 27 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 </div> 34 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 38 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 39 </div> 40 - </div> 41 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 </div> 45 </div> 46 </div>
··· 1 {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 46 </div> 47 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 + </div> 53 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 64 </div> 65 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 93 </div> 94 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 98 </div> 99 </div> 100 </div>
+18 -8
appview/pages/templates/layouts/fragments/topbar.html
··· 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 7 </a> 8 </div> 9 10 - <div id="right-items" class="flex items-center gap-2"> 11 {{ with .LoggedInUser }} 12 {{ block "newButton" . }} {{ end }} 13 {{ block "dropDown" . }} {{ end }} 14 {{ else }} 15 <a href="/login">login</a> ··· 26 {{ define "newButton" }} 27 <details class="relative inline-block text-left nav-dropdown"> 28 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 30 </summary> 31 <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 {{ define "dropDown" }} 45 <details class="relative inline-block text-left nav-dropdown"> 46 <summary 47 - class="cursor-pointer list-none flex items-center" 48 > 49 - {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 51 </summary> 52 <div 53 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
··· 1 {{ define "layouts/fragments/topbar" }} 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 + <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 + <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 + alpha 10 + </span> 11 </a> 12 </div> 13 14 + <div id="right-items" class="flex items-center gap-4"> 15 {{ with .LoggedInUser }} 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 18 {{ block "dropDown" . }} {{ end }} 19 {{ else }} 20 <a href="/login">login</a> ··· 31 {{ define "newButton" }} 32 <details class="relative inline-block text-left nav-dropdown"> 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 </summary> 36 <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 49 {{ define "dropDown" }} 50 <details class="relative inline-block text-left nav-dropdown"> 51 <summary 52 + class="cursor-pointer list-none flex items-center gap-1" 53 > 54 + {{ $user := .Did }} 55 + <img 56 + src="{{ tinyAvatar $user }}" 57 + alt="" 58 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 59 + /> 60 + <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 61 </summary> 62 <div 63 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 {{ define "title" }}privacy policy{{ end }} 2 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}privacy policy{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Learn how we collect, use, and protect your personal information. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 15 </div> 16 + </main> 17 </div> 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 {{ define "title" }}terms of service{{ end }} 2 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}terms of service{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + A few things you should know. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 15 </div> 16 + </main> 17 </div> 18 + {{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
···
··· 1 + {{define "notifications/fragments/bell"}} 2 + <div class="relative" 3 + hx-get="/notifications/count" 4 + hx-target="#notification-count" 5 + hx-trigger="load, every 30s"> 6 + <a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group"> 7 + {{ i "bell" "w-5 h-5" }} 8 + <span id="notification-count"></span> 9 + </a> 10 + </div> 11 + {{end}}
+7
appview/pages/templates/notifications/fragments/count.html
···
··· 1 + {{define "notifications/fragments/count"}} 2 + {{if and .Count (gt .Count 0)}} 3 + <span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center"> 4 + {{if gt .Count 99}}99+{{else}}{{.Count}}{{end}} 5 + </span> 6 + {{end}} 7 + {{end}}
+81
appview/pages/templates/notifications/fragments/item.html
···
··· 1 + {{define "notifications/fragments/item"}} 2 + <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 3 + <div 4 + class=" 5 + w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 6 + {{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}} 7 + flex gap-2 items-center 8 + "> 9 + {{ template "notificationIcon" . }} 10 + <div class="flex-1 w-full flex flex-col gap-1"> 11 + <span>{{ template "notificationHeader" . }}</span> 12 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 + </div> 14 + 15 + </div> 16 + </a> 17 + {{end}} 18 + 19 + {{ define "notificationIcon" }} 20 + <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 22 + <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 23 + {{ i .Icon "size-3 text-black dark:text-white" }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "notificationHeader" }} 29 + {{ $actor := resolve .ActorDid }} 30 + 31 + <span class="text-black dark:text-white w-fit">{{ $actor }}</span> 32 + {{ if eq .Type "repo_starred" }} 33 + starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span> 34 + {{ else if eq .Type "issue_created" }} 35 + opened an issue 36 + {{ else if eq .Type "issue_commented" }} 37 + commented on an issue 38 + {{ else if eq .Type "issue_closed" }} 39 + closed an issue 40 + {{ else if eq .Type "pull_created" }} 41 + created a pull request 42 + {{ else if eq .Type "pull_commented" }} 43 + commented on a pull request 44 + {{ else if eq .Type "pull_merged" }} 45 + merged a pull request 46 + {{ else if eq .Type "pull_closed" }} 47 + closed a pull request 48 + {{ else if eq .Type "followed" }} 49 + followed you 50 + {{ else }} 51 + {{ end }} 52 + {{ end }} 53 + 54 + {{ define "notificationSummary" }} 55 + {{ if eq .Type "repo_starred" }} 56 + <!-- no summary --> 57 + {{ else if .Issue }} 58 + #{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 59 + {{ else if .Pull }} 60 + #{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 61 + {{ else if eq .Type "followed" }} 62 + <!-- no summary --> 63 + {{ else }} 64 + {{ end }} 65 + {{ end }} 66 + 67 + {{ define "notificationUrl" }} 68 + {{ $url := "" }} 69 + {{ if eq .Type "repo_starred" }} 70 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 71 + {{ else if .Issue }} 72 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 73 + {{ else if .Pull }} 74 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 75 + {{ else if eq .Type "followed" }} 76 + {{$url = printf "/%s" (resolve .ActorDid)}} 77 + {{ else }} 78 + {{ end }} 79 + 80 + {{ $url }} 81 + {{ end }}
+65
appview/pages/templates/notifications/list.html
···
··· 1 + {{ define "title" }}notifications{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex items-center justify-between"> 6 + <p class="text-xl font-bold dark:text-white">Notifications</p> 7 + <a href="/settings/notifications" class="flex items-center gap-2"> 8 + {{ i "settings" "w-4 h-4" }} 9 + preferences 10 + </a> 11 + </div> 12 + </div> 13 + 14 + {{if .Notifications}} 15 + <div class="flex flex-col gap-2" id="notifications-list"> 16 + {{range .Notifications}} 17 + {{template "notifications/fragments/item" .}} 18 + {{end}} 19 + </div> 20 + 21 + {{else}} 22 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 + <div class="text-center py-12"> 24 + <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 + {{ i "bell-off" "w-16 h-16" }} 26 + </div> 27 + <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 + <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 29 + </div> 30 + </div> 31 + {{end}} 32 + 33 + {{ template "pagination" . }} 34 + {{ end }} 35 + 36 + {{ define "pagination" }} 37 + <div class="flex justify-end mt-4 gap-2"> 38 + {{ if gt .Page.Offset 0 }} 39 + {{ $prev := .Page.Previous }} 40 + <a 41 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 42 + hx-boost="true" 43 + href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 44 + > 45 + {{ i "chevron-left" "w-4 h-4" }} 46 + previous 47 + </a> 48 + {{ else }} 49 + <div></div> 50 + {{ end }} 51 + 52 + {{ $next := .Page.Next }} 53 + {{ if lt $next.Offset .Total }} 54 + {{ $next := .Page.Next }} 55 + <a 56 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 + hx-boost="true" 58 + href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 59 + > 60 + next 61 + {{ i "chevron-right" "w-4 h-4" }} 62 + </a> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+7
appview/pages/templates/repo/fork.html
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2">
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 16 <fieldset class="space-y-3"> 17 <legend class="dark:text-white">Select a knot to fork into</legend> 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+26
appview/pages/templates/repo/fragments/participants.html
···
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-2 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 {{ if .IsReacted }} 24 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 {{ else }}
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 relative group 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 28 {{ if .IsReacted }} 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 {{- if .ReadmeFileName -}} 4 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 {{- if .ReadmeFileName -}} 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+185
appview/pages/templates/repo/fragments/searchBar.html
···
··· 1 + {{ define "repo/fragments/searchBar" }} 2 + <div class="flex gap-2 items-center w-full"> 3 + <form class="flex-grow flex gap-2" method="get" action=""> 4 + <div class="flex-grow flex items-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800"> 5 + <input 6 + type="text" 7 + name="q" 8 + value="{{ .SearchQuery }}" 9 + placeholder="Search {{ .Placeholder }}... (e.g., 'has:enhancement fix bug')" 10 + class="flex-grow px-4 py-2 bg-transparent dark:text-white focus:outline-none" 11 + /> 12 + <button type="submit" class="px-3 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"> 13 + {{ i "search" "w-5 h-5" }} 14 + </button> 15 + </div> 16 + 17 + <!-- Keep state filter in search --> 18 + {{ if .State }} 19 + <input type="hidden" name="state" value="{{ .State }}" /> 20 + {{ end }} 21 + 22 + <!-- Sort options --> 23 + {{ $sortBy := .SortBy }} 24 + {{ $sortOrder := .SortOrder }} 25 + {{ $defaultSortBy := "created" }} 26 + {{ $defaultSortOrder := "desc" }} 27 + {{ if not $sortBy }} 28 + {{ $sortBy = $defaultSortBy }} 29 + {{ end }} 30 + {{ if not $sortOrder }} 31 + {{ $sortOrder = $defaultSortOrder }} 32 + {{ end }} 33 + <input type="hidden" name="sort_by" value="{{ $sortBy }}" id="sortByInput" /> 34 + <input type="hidden" name="sort_order" value="{{ $sortOrder }}" id="sortOrderInput" /> 35 + 36 + <details class="relative dropdown-menu" id="sortDropdown"> 37 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 38 + {{ i "arrow-down-up" "w-4 h-4" }} 39 + <span> 40 + {{ if .SortBy }} 41 + {{ if eq $sortBy "created" }}Created{{ else if eq $sortBy "comments" }}Comments{{ else if eq $sortBy "reactions" }}Reactions{{ end }} 42 + {{ else }} 43 + Sort 44 + {{ end }} 45 + </span> 46 + {{ i "chevron-down" "w-4 h-4" }} 47 + </summary> 48 + <div class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10"> 49 + <div class="p-3"> 50 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Sort by</div> 51 + <div class="space-y-1 mb-3"> 52 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="created"> 53 + {{ if eq $sortBy "created" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 54 + <span class="text-sm dark:text-gray-200">Created</span> 55 + </div> 56 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="comments"> 57 + {{ if eq $sortBy "comments" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 58 + <span class="text-sm dark:text-gray-200">Comments</span> 59 + </div> 60 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="reactions"> 61 + {{ if eq $sortBy "reactions" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 62 + <span class="text-sm dark:text-gray-200">Reactions</span> 63 + </div> 64 + </div> 65 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300 pt-2 border-t border-gray-200 dark:border-gray-600">Order</div> 66 + <div class="space-y-1"> 67 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="desc"> 68 + {{ if eq $sortOrder "desc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 69 + <span class="text-sm dark:text-gray-200">Descending</span> 70 + </div> 71 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="asc"> 72 + {{ if eq $sortOrder "asc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 73 + <span class="text-sm dark:text-gray-200">Ascending</span> 74 + </div> 75 + </div> 76 + </div> 77 + </div> 78 + </details> 79 + 80 + <!-- Label filter dropdown --> 81 + <details class="relative dropdown-menu" id="labelDropdown"> 82 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 83 + {{ i "tag" "w-4 h-4" }} 84 + <span>label</span> 85 + {{ i "chevron-down" "w-4 h-4" }} 86 + </summary> 87 + <div class="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10 max-h-96 overflow-y-auto"> 88 + <div class="p-3"> 89 + <div class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Filter by label</div> 90 + <div class="space-y-2"> 91 + {{ range $uri, $def := .LabelDefs }} 92 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded label-option" data-label-name="{{ $def.Name }}"> 93 + <span class="label-checkbox-icon w-4 h-4"></span> 94 + <span class="flex-grow text-sm dark:text-gray-200"> 95 + {{ template "labels/fragments/label" (dict "def" $def "val" "" "withPrefix" false) }} 96 + </span> 97 + </div> 98 + {{ end }} 99 + </div> 100 + </div> 101 + </div> 102 + </details> 103 + </form> 104 + </div> 105 + 106 + <script> 107 + (function() { 108 + // Handle label filter changes 109 + const labelOptions = document.querySelectorAll('.label-option'); 110 + const searchInput = document.querySelector('input[name="q"]'); 111 + 112 + // Initialize checkmarks based on current query 113 + const currentQuery = searchInput.value; 114 + labelOptions.forEach(option => { 115 + const labelName = option.getAttribute('data-label-name'); 116 + const hasFilter = 'has:' + labelName; 117 + const iconSpan = option.querySelector('.label-checkbox-icon'); 118 + 119 + if (currentQuery.includes(hasFilter)) { 120 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 121 + } 122 + }); 123 + 124 + labelOptions.forEach(option => { 125 + option.addEventListener('click', function() { 126 + const labelName = this.getAttribute('data-label-name'); 127 + let currentQuery = searchInput.value; 128 + const hasFilter = 'has:' + labelName; 129 + const iconSpan = this.querySelector('.label-checkbox-icon'); 130 + const isChecked = currentQuery.includes(hasFilter); 131 + 132 + if (isChecked) { 133 + // Remove has: filter 134 + currentQuery = currentQuery.replace(hasFilter, '').replace(/\s+/g, ' '); 135 + searchInput.value = currentQuery.trim(); 136 + iconSpan.innerHTML = ''; 137 + } else { 138 + // Add has: filter if not already present 139 + currentQuery = currentQuery.trim() + ' ' + hasFilter; 140 + searchInput.value = currentQuery.trim(); 141 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 142 + } 143 + 144 + form.submit(); 145 + }); 146 + }); 147 + 148 + // Handle sort option changes 149 + const sortByOptions = document.querySelectorAll('.sort-by-option'); 150 + const sortOrderOptions = document.querySelectorAll('.sort-order-option'); 151 + const sortByInput = document.getElementById('sortByInput'); 152 + const sortOrderInput = document.getElementById('sortOrderInput'); 153 + const form = searchInput.closest('form'); 154 + 155 + sortByOptions.forEach(option => { 156 + option.addEventListener('click', function() { 157 + sortByInput.value = this.getAttribute('data-value'); 158 + form.submit(); 159 + }); 160 + }); 161 + 162 + sortOrderOptions.forEach(option => { 163 + option.addEventListener('click', function() { 164 + sortOrderInput.value = this.getAttribute('data-value'); 165 + form.submit(); 166 + }); 167 + }); 168 + 169 + // Make dropdowns mutually exclusive - close others when one opens 170 + const dropdowns = document.querySelectorAll('.dropdown-menu'); 171 + dropdowns.forEach(dropdown => { 172 + dropdown.addEventListener('toggle', function(e) { 173 + if (this.open) { 174 + // Close all other dropdowns 175 + dropdowns.forEach(other => { 176 + if (other !== this && other.open) { 177 + other.open = false; 178 + } 179 + }); 180 + } 181 + }); 182 + }); 183 + })(); 184 + </script> 185 + {{ end }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+65
appview/pages/templates/repo/issues/fragments/issueListing.html
···
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ if gt .ReactionCount 0 }} 46 + <span class="before:content-['·']"> 47 + {{ $s := "s" }} 48 + {{ if eq .ReactionCount 1 }} 49 + {{ $s = "" }} 50 + {{ end }} 51 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .ReactionCount }} reaction{{$s}}</a> 52 + </span> 53 + {{ end }} 54 + 55 + {{ $state := .Labels }} 56 + {{ range $k, $d := $.LabelDefs }} 57 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 58 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 59 + {{ end }} 60 + {{ end }} 61 + </div> 62 + </div> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 </div> 139 </form> 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 143 </div> 144 {{ end }} 145 {{ end }}
··· 138 </div> 139 </form> 140 {{ else }} 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 148 </div> 149 {{ end }} 150 {{ end }}
+5 -29
appview/pages/templates/repo/issues/issue.html
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 26 </div> 27 </div> 28 {{ end }} ··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 {{ 114 template "repo/fragments/reaction" 115 (dict 116 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 }} 121 {{ end }} 122 </div> 123 {{ end }} 124 125 - {{ define "issueParticipants" }} 126 - {{ $all := .Issue.Participants }} 127 - {{ $ps := take $all 5 }} 128 - <div> 129 - <div class="py-1 flex items-center text-sm"> 130 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 - </div> 133 - <div class="flex items-center -space-x-3 mt-2"> 134 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 - {{ range $i, $p := $ps }} 136 - <img 137 - src="{{ tinyAvatar . }}" 138 - alt="" 139 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 - /> 141 - {{ end }} 142 - 143 - {{ if gt (len $all) 5 }} 144 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 - +{{ sub (len $all) 5 }} 146 - </span> 147 - {{ end }} 148 - </div> 149 - </div> 150 - {{ end }} 151 152 {{ define "repoAfter" }} 153 <div class="flex flex-col gap-4 mt-4">
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 </div> 27 </div> 28 {{ end }} ··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 114 {{ 115 template "repo/fragments/reaction" 116 (dict 117 "Kind" $kind 118 + "Count" $reactionData.Count 119 "IsReacted" (index $.UserReacted $kind) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 122 }} 123 {{ end }} 124 </div> 125 {{ end }} 126 127 128 {{ define "repoAfter" }} 129 <div class="flex flex-col gap-4 mt-4">
+32 -55
appview/pages/templates/repo/issues/issues.html
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 12 <div class="flex gap-4"> 13 <a 14 href="?state=open" ··· 33 <span>new</span> 34 </a> 35 </div> 36 <div class="error" id="issues"></div> 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 92 </div> 93 {{ block "pagination" . }} {{ end }} 94 {{ end }} ··· 102 103 {{ if gt .Page.Offset 0 }} 104 {{ $prev := .Page.Previous }} 105 <a 106 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 107 hx-boost="true" 108 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 109 > 110 {{ i "chevron-left" "w-4 h-4" }} 111 previous ··· 116 117 {{ if eq (len .Issues) .Page.Limit }} 118 {{ $next := .Page.Next }} 119 <a 120 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 121 hx-boost="true" 122 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 123 > 124 next 125 {{ i "chevron-right" "w-4 h-4" }}
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 + <div class="flex justify-between items-center gap-4 mb-4"> 12 <div class="flex gap-4"> 13 <a 14 href="?state=open" ··· 33 <span>new</span> 34 </a> 35 </div> 36 + 37 + {{ $state := "open" }} 38 + {{ if not .FilteringByOpen }} 39 + {{ $state = "closed" }} 40 + {{ end }} 41 + 42 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "issues" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 43 <div class="error" id="issues"></div> 44 {{ end }} 45 46 {{ define "repoAfter" }} 47 + <div class="mt-2"> 48 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 49 </div> 50 {{ block "pagination" . }} {{ end }} 51 {{ end }} ··· 59 60 {{ if gt .Page.Offset 0 }} 61 {{ $prev := .Page.Previous }} 62 + {{ $prevUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $prev.Offset $prev.Limit }} 63 + {{ if .SearchQuery }} 64 + {{ $prevUrl = printf "%s&q=%s" $prevUrl .SearchQuery }} 65 + {{ end }} 66 + {{ if .SortBy }} 67 + {{ $prevUrl = printf "%s&sort_by=%s" $prevUrl .SortBy }} 68 + {{ end }} 69 + {{ if .SortOrder }} 70 + {{ $prevUrl = printf "%s&sort_order=%s" $prevUrl .SortOrder }} 71 + {{ end }} 72 <a 73 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 74 hx-boost="true" 75 + href = "{{ $prevUrl }}" 76 > 77 {{ i "chevron-left" "w-4 h-4" }} 78 previous ··· 83 84 {{ if eq (len .Issues) .Page.Limit }} 85 {{ $next := .Page.Next }} 86 + {{ $nextUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $next.Offset $next.Limit }} 87 + {{ if .SearchQuery }} 88 + {{ $nextUrl = printf "%s&q=%s" $nextUrl .SearchQuery }} 89 + {{ end }} 90 + {{ if .SortBy }} 91 + {{ $nextUrl = printf "%s&sort_by=%s" $nextUrl .SortBy }} 92 + {{ end }} 93 + {{ if .SortOrder }} 94 + {{ $nextUrl = printf "%s&sort_order=%s" $nextUrl .SortOrder }} 95 + {{ end }} 96 <a 97 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 98 hx-boost="true" 99 + href = "{{ $nextUrl }}" 100 > 101 next 102 {{ i "chevron-right" "w-4 h-4" }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 6 </div> 7 - <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - <div class="space-y-2"> 10 - <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 - <input 12 - type="text" 13 - id="name" 14 - name="name" 15 - required 16 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 - /> 18 - <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 19 20 - <label for="branch" class="dark:text-white">Default branch</label> 21 - <input 22 - type="text" 23 - id="branch" 24 - name="branch" 25 - value="main" 26 - required 27 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 - /> 29 30 - <label for="description" class="dark:text-white">Description</label> 31 - <input 32 - type="text" 33 - id="description" 34 - name="description" 35 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 - /> 37 </div> 38 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 41 <div class="space-y-2"> 42 - <div class="flex flex-col"> 43 - {{ range .Knots }} 44 - <div class="flex items-center"> 45 - <input 46 - type="radio" 47 - name="domain" 48 - value="{{ . }}" 49 - class="mr-2" 50 - id="domain-{{ . }}" 51 - /> 52 - <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 - </div> 54 - {{ else }} 55 - <p class="dark:text-white">No knots available.</p> 56 - {{ end }} 57 - </div> 58 </div> 59 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 - </fieldset> 61 62 - <div class="space-y-2"> 63 - <button type="submit" class="btn-create flex items-center gap-2"> 64 - {{ i "book-plus" "w-4 h-4" }} 65 - create repo 66 - <span id="spinner" class="group"> 67 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 - </span> 69 - </button> 70 - <div id="repo" class="error"></div> 71 </div> 72 - </form> 73 - </div> 74 {{ end }}
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 13 </div> 14 + {{ end }} 15 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 21 22 + {{ define "newRepoForm" }} 23 + <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 + 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 35 </div> 36 + <div id="repo" class="error mt-2"></div> 37 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 52 <div class="space-y-2"> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 55 </div> 56 + </div> 57 + </div> 58 + {{ end }} 59 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 64 </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 + </div> 74 + </div> 75 + </div> 76 + {{ end }} 77 + 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 + </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 + </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 176 {{ end }}
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 {{ 70 template "repo/fragments/reaction" 71 (dict 72 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 }} 77 {{ end }} 78 </div>
··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 70 {{ 71 template "repo/fragments/reaction" 72 (dict 73 "Kind" $kind 74 + "Count" $reactionData.Count 75 "IsReacted" (index $.UserReacted $kind) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 78 }} 79 {{ end }} 80 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ resolve .LoggedInUser.Did }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+37 -15
appview/pages/templates/repo/pulls/pull.html
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 13 {{ define "repoContent" }} 14 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 {{ with $item }} 40 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 43 <!-- round number --> 44 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 </div> 47 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 <span class="gap-1 flex items-center"> 50 {{ $owner := resolve $.Pull.OwnerDid }} 51 {{ $re := "re" }} ··· 72 <span class="hidden md:inline">diff</span> 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 83 - <span id="interdiff-error-{{.RoundNumber}}"></span> 84 {{ end }} 85 </div> 86 </summary> 87 ··· 146 147 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 {{ range $cidx, $c := .Comments }} 149 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 150 {{ if gt $cidx 0 }} 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }} ··· 171 {{ if $.LoggedInUser }} 172 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 173 {{ else }} 174 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 175 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 176 - <a href="/login" class="underline">login</a> to join the discussion 177 </div> 178 {{ end }} 179 </div>
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 + {{ define "repoContentLayout" }} 13 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 14 + <div class="col-span-1 md:col-span-8"> 15 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 16 + {{ block "repoContent" . }}{{ end }} 17 + </section> 18 + {{ block "repoAfter" . }}{{ end }} 19 + </div> 20 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 21 + {{ template "repo/fragments/labelPanel" 22 + (dict "RepoInfo" $.RepoInfo 23 + "Defs" $.LabelDefs 24 + "Subject" $.Pull.PullAt 25 + "State" $.Pull.Labels) }} 26 + {{ template "repo/fragments/participants" $.Pull.Participants }} 27 + </div> 28 + </div> 29 + {{ end }} 30 31 {{ define "repoContent" }} 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 57 {{ with $item }} 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 61 <!-- round number --> 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 64 </div> 65 <!-- round summary --> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 67 <span class="gap-1 flex items-center"> 68 {{ $owner := resolve $.Pull.OwnerDid }} 69 {{ $re := "re" }} ··· 90 <span class="hidden md:inline">diff</span> 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 92 </a> 93 + {{ if ne $idx 0 }} 94 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 95 + hx-boost="true" 96 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 97 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 98 + <span class="hidden md:inline">interdiff</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </a> 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 103 </div> 104 </summary> 105 ··· 164 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 166 {{ range $cidx, $c := .Comments }} 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 168 {{ if gt $cidx 0 }} 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 170 {{ end }} ··· 189 {{ if $.LoggedInUser }} 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 191 {{ else }} 192 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 193 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 194 + sign up 195 + </a> 196 + <span class="text-gray-500 dark:text-gray-400">or</span> 197 + <a href="/login" class="underline">login</a> 198 + to add to the discussion 199 </div> 200 {{ end }} 201 </div>
+17 -1
appview/pages/templates/repo/pulls/pulls.html
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center"> 12 <div class="flex gap-4"> 13 <a 14 href="?state=open" ··· 40 <span>new</span> 41 </a> 42 </div> 43 <div class="error" id="pulls"></div> 44 {{ end }} 45 ··· 107 {{ if and $pipeline $pipeline.Id }} 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 </div> 112 </div>
··· 8 {{ end }} 9 10 {{ define "repoContent" }} 11 + <div class="flex justify-between items-center mb-4"> 12 <div class="flex gap-4"> 13 <a 14 href="?state=open" ··· 40 <span>new</span> 41 </a> 42 </div> 43 + 44 + {{ $state := "open" }} 45 + {{ if .FilteringBy.IsMerged }} 46 + {{ $state = "merged" }} 47 + {{ else if .FilteringBy.IsClosed }} 48 + {{ $state = "closed" }} 49 + {{ end }} 50 + 51 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "pulls" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 52 <div class="error" id="pulls"></div> 53 {{ end }} 54 ··· 116 {{ if and $pipeline $pipeline.Id }} 117 <span class="before:content-['·']"></span> 118 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 119 + {{ end }} 120 + 121 + {{ $state := .Labels }} 122 + {{ range $k, $d := $.LabelDefs }} 123 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 124 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 125 + {{ end }} 126 {{ end }} 127 </div> 128 </div>
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 47 {{ define "defaultLabelSettings" }} 48 <div class="flex flex-col gap-2"> 49 - <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 50 - <p class="text-gray-500 dark:text-gray-400"> 51 - Manage your issues and pulls by creating labels to categorize them. Only 52 - repository owners may configure labels. You may choose to subscribe to 53 - default labels, or create entirely custom labels. 54 - </p> 55 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 56 {{ range .DefaultLabels }} 57 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
··· 46 47 {{ define "defaultLabelSettings" }} 48 <div class="flex flex-col gap-2"> 49 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 50 + <div class="col-span-1 md:col-span-2"> 51 + <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 52 + <p class="text-gray-500 dark:text-gray-400"> 53 + Manage your issues and pulls by creating labels to categorize them. Only 54 + repository owners may configure labels. You may choose to subscribe to 55 + default labels, or create entirely custom labels. 56 + <p> 57 + </div> 58 + <form class="col-span-1 md:col-span-1 md:justify-self-end"> 59 + {{ $title := "Unubscribe from all labels" }} 60 + {{ $icon := "x" }} 61 + {{ $text := "unsubscribe all" }} 62 + {{ $action := "unsubscribe" }} 63 + {{ if $.ShouldSubscribeAll }} 64 + {{ $title = "Subscribe to all labels" }} 65 + {{ $icon = "check-check" }} 66 + {{ $text = "subscribe all" }} 67 + {{ $action = "subscribe" }} 68 + {{ end }} 69 + {{ range .DefaultLabels }} 70 + <input type="hidden" name="label" value="{{ .AtUri.String }}"> 71 + {{ end }} 72 + <button 73 + type="submit" 74 + title="{{$title}}" 75 + class="btn flex items-center gap-2 group" 76 + hx-swap="none" 77 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 78 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 79 + {{ i $icon "size-4" }} 80 + {{ $text }} 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + </form> 84 + </div> 85 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 86 {{ range .DefaultLabels }} 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+1 -1
appview/pages/templates/repo/tree.html
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
+2 -2
appview/pages/templates/strings/put.html
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 5 {{ if eq .Action "new" }} 6 - <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 - <p class="">Store and share code snippets with ease.</p> 8 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 5 {{ if eq .Action "new" }} 6 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 8 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 - <div class="font-medium dark:text-white flex gap-2 items-center"> 31 - <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 32 </div> 33 {{ with .Description }} 34 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 43 {{ define "stringCardInfo" }} 44 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 - <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 - {{ template "user/fragments/picHandle" $resolved }} 49 - </a> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 <span class="select-none [&:before]:content-['·']"></span> 53 {{ with .Edited }}
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 31 + <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 + <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 + <span class="select-none">/</span> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 35 </div> 36 {{ with .Description }} 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 45 46 {{ define "stringCardInfo" }} 47 {{ $stat := .Stats }} 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 50 <span class="select-none [&:before]:content-['·']"></span> 51 {{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 {{ template "timeline/fragments/trending" . }} 16 {{ template "timeline/fragments/timeline" . }} 17 <div class="flex justify-end">
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 17 {{ template "timeline/fragments/trending" . }} 18 {{ template "timeline/fragments/timeline" . }} 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 <link 24 rel="stylesheet" 25 href="/static/tw.css?{{ cssContentHash }}"
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 24 <link 25 rel="stylesheet" 26 href="/static/tw.css?{{ cssContentHash }}"
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 1 {{ define "user/fragments/followCard" }} 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
··· 1 {{ define "user/fragments/followCard" }} 2 {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 - {{ . | truncateAt30 }} 8 {{ end }}
··· 2 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 /> 7 + {{ . | resolve | truncateAt30 }} 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 5 </a> 6 {{ end }}
··· 1 {{ define "user/fragments/picHandleLink" }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 4 </a> 5 {{ end }}
+2 -1
appview/pages/templates/user/login.html
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login &middot; tangled</title> 13 </head> ··· 36 placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 handle to log in. If you're unsure, this is likely 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 </span>
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>login &middot; tangled</title> 14 </head> ··· 37 placeholder="akshay.tngl.sh" 38 /> 39 <span class="text-sm text-gray-500 mt-1"> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 41 handle to log in. If you're unsure, this is likely 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 43 </span>
+173
appview/pages/templates/user/settings/notifications.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "notificationSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "notificationSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Choose which notifications you want to receive when activity happens on your repositories and profile. 25 + </p> 26 + </div> 27 + </div> 28 + 29 + <form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6"> 30 + 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + <div class="flex items-center justify-between p-2"> 33 + <div class="flex items-center gap-2"> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-bold">Repository starred</span> 36 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 37 + <span>When someone stars your repository.</span> 38 + </div> 39 + </div> 40 + </div> 41 + <label class="flex items-center gap-2"> 42 + <input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}> 43 + </label> 44 + </div> 45 + 46 + <div class="flex items-center justify-between p-2"> 47 + <div class="flex items-center gap-2"> 48 + <div class="flex flex-col gap-1"> 49 + <span class="font-bold">New issues</span> 50 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 51 + <span>When someone creates an issue on your repository.</span> 52 + </div> 53 + </div> 54 + </div> 55 + <label class="flex items-center gap-2"> 56 + <input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}> 57 + </label> 58 + </div> 59 + 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-2"> 62 + <div class="flex flex-col gap-1"> 63 + <span class="font-bold">Issue comments</span> 64 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 65 + <span>When someone comments on an issue you're involved with.</span> 66 + </div> 67 + </div> 68 + </div> 69 + <label class="flex items-center gap-2"> 70 + <input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}> 71 + </label> 72 + </div> 73 + 74 + <div class="flex items-center justify-between p-2"> 75 + <div class="flex items-center gap-2"> 76 + <div class="flex flex-col gap-1"> 77 + <span class="font-bold">Issue closed</span> 78 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 79 + <span>When an issue on your repository is closed.</span> 80 + </div> 81 + </div> 82 + </div> 83 + <label class="flex items-center gap-2"> 84 + <input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}> 85 + </label> 86 + </div> 87 + 88 + <div class="flex items-center justify-between p-2"> 89 + <div class="flex items-center gap-2"> 90 + <div class="flex flex-col gap-1"> 91 + <span class="font-bold">New pull requests</span> 92 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 93 + <span>When someone creates a pull request on your repository.</span> 94 + </div> 95 + </div> 96 + </div> 97 + <label class="flex items-center gap-2"> 98 + <input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}> 99 + </label> 100 + </div> 101 + 102 + <div class="flex items-center justify-between p-2"> 103 + <div class="flex items-center gap-2"> 104 + <div class="flex flex-col gap-1"> 105 + <span class="font-bold">Pull request comments</span> 106 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 107 + <span>When someone comments on a pull request you're involved with.</span> 108 + </div> 109 + </div> 110 + </div> 111 + <label class="flex items-center gap-2"> 112 + <input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}> 113 + </label> 114 + </div> 115 + 116 + <div class="flex items-center justify-between p-2"> 117 + <div class="flex items-center gap-2"> 118 + <div class="flex flex-col gap-1"> 119 + <span class="font-bold">Pull request merged</span> 120 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 121 + <span>When your pull request is merged.</span> 122 + </div> 123 + </div> 124 + </div> 125 + <label class="flex items-center gap-2"> 126 + <input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}> 127 + </label> 128 + </div> 129 + 130 + <div class="flex items-center justify-between p-2"> 131 + <div class="flex items-center gap-2"> 132 + <div class="flex flex-col gap-1"> 133 + <span class="font-bold">New followers</span> 134 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 135 + <span>When someone follows you.</span> 136 + </div> 137 + </div> 138 + </div> 139 + <label class="flex items-center gap-2"> 140 + <input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}> 141 + </label> 142 + </div> 143 + 144 + <div class="flex items-center justify-between p-2"> 145 + <div class="flex items-center gap-2"> 146 + <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Email notifications</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>Receive notifications via email in addition to in-app notifications.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 155 + </label> 156 + </div> 157 + </div> 158 + 159 + <div class="flex justify-end pt-2"> 160 + <button 161 + type="submit" 162 + class="btn-create flex items-center gap-2 group" 163 + > 164 + {{ i "save" "w-4 h-4" }} 165 + save 166 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 167 + </button> 168 + </div> 169 + <div id="settings-notifications-success"></div> 170 + 171 + <div id="settings-notifications-error" class="error"></div> 172 + </form> 173 + {{ end }}
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 39 </span> 40 - {{ end }} 41 </div> 42 </div> 43 <div class="flex items-center justify-between p-4">
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 <span class="font-bold"> 37 + {{ resolve .LoggedInUser.Did }} 38 </span> 39 </div> 40 </div> 41 <div class="flex items-center justify-between p-4">
+7 -1
appview/pages/templates/user/signup.html
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> ··· 39 invite code, desired username, and password in the next 40 page to complete your registration. 41 </span> 42 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 <span>join now</span> 44 </button> 45 </form> 46 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 48 </p> 49 50 <p id="signup-msg" class="error w-full"></p>
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 16 </head> 17 <body class="flex items-center justify-center min-h-screen"> 18 <main class="max-w-md px-6 -mt-4"> ··· 42 invite code, desired username, and password in the next 43 page to complete your registration. 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 </form> 52 <p class="text-sm text-gray-500"> 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 </p> 55 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 - Limit: 10, 12 } 13 } 14
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 + Limit: 30, 12 } 13 } 14
+2 -1
appview/pipelines/pipelines.go
··· 48 ) *Pipelines { 49 logger := log.New("pipelines") 50 51 - return &Pipelines{oauth: oauth, 52 repoResolver: repoResolver, 53 pages: pages, 54 idResolver: idResolver,
··· 48 ) *Pipelines { 49 logger := log.New("pipelines") 50 51 + return &Pipelines{ 52 + oauth: oauth, 53 repoResolver: repoResolver, 54 pages: pages, 55 idResolver: idResolver,
-164
appview/posthog/notifier.go
··· 1 - package posthog_service 2 - 3 - import ( 4 - "context" 5 - "log" 6 - 7 - "github.com/posthog/posthog-go" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/notify" 10 - ) 11 - 12 - type posthogNotifier struct { 13 - client posthog.Client 14 - notify.BaseNotifier 15 - } 16 - 17 - func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 - return &posthogNotifier{ 19 - client, 20 - notify.BaseNotifier{}, 21 - } 22 - } 23 - 24 - var _ notify.Notifier = &posthogNotifier{} 25 - 26 - func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 - err := n.client.Enqueue(posthog.Capture{ 28 - DistinctId: repo.Did, 29 - Event: "new_repo", 30 - Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 - }) 32 - if err != nil { 33 - log.Println("failed to enqueue posthog event:", err) 34 - } 35 - } 36 - 37 - func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 - err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 - Event: "star", 41 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 - }) 43 - if err != nil { 44 - log.Println("failed to enqueue posthog event:", err) 45 - } 46 - } 47 - 48 - func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 - err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 - Event: "unstar", 52 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 - }) 54 - if err != nil { 55 - log.Println("failed to enqueue posthog event:", err) 56 - } 57 - } 58 - 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 - err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.Did, 62 - Event: "new_issue", 63 - Properties: posthog.Properties{ 64 - "repo_at": issue.RepoAt.String(), 65 - "issue_id": issue.IssueId, 66 - }, 67 - }) 68 - if err != nil { 69 - log.Println("failed to enqueue posthog event:", err) 70 - } 71 - } 72 - 73 - func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 - err := n.client.Enqueue(posthog.Capture{ 75 - DistinctId: pull.OwnerDid, 76 - Event: "new_pull", 77 - Properties: posthog.Properties{ 78 - "repo_at": pull.RepoAt, 79 - "pull_id": pull.PullId, 80 - }, 81 - }) 82 - if err != nil { 83 - log.Println("failed to enqueue posthog event:", err) 84 - } 85 - } 86 - 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 - err := n.client.Enqueue(posthog.Capture{ 89 - DistinctId: comment.OwnerDid, 90 - Event: "new_pull_comment", 91 - Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 - }, 95 - }) 96 - if err != nil { 97 - log.Println("failed to enqueue posthog event:", err) 98 - } 99 - } 100 - 101 - func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 102 - err := n.client.Enqueue(posthog.Capture{ 103 - DistinctId: follow.UserDid, 104 - Event: "follow", 105 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 - }) 107 - if err != nil { 108 - log.Println("failed to enqueue posthog event:", err) 109 - } 110 - } 111 - 112 - func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 113 - err := n.client.Enqueue(posthog.Capture{ 114 - DistinctId: follow.UserDid, 115 - Event: "unfollow", 116 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 - }) 118 - if err != nil { 119 - log.Println("failed to enqueue posthog event:", err) 120 - } 121 - } 122 - 123 - func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 124 - err := n.client.Enqueue(posthog.Capture{ 125 - DistinctId: profile.Did, 126 - Event: "edit_profile", 127 - }) 128 - if err != nil { 129 - log.Println("failed to enqueue posthog event:", err) 130 - } 131 - } 132 - 133 - func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 - err := n.client.Enqueue(posthog.Capture{ 135 - DistinctId: did, 136 - Event: "delete_string", 137 - Properties: posthog.Properties{"rkey": rkey}, 138 - }) 139 - if err != nil { 140 - log.Println("failed to enqueue posthog event:", err) 141 - } 142 - } 143 - 144 - func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 145 - err := n.client.Enqueue(posthog.Capture{ 146 - DistinctId: string.Did.String(), 147 - Event: "edit_string", 148 - Properties: posthog.Properties{"rkey": string.Rkey}, 149 - }) 150 - if err != nil { 151 - log.Println("failed to enqueue posthog event:", err) 152 - } 153 - } 154 - 155 - func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) { 156 - err := n.client.Enqueue(posthog.Capture{ 157 - DistinctId: string.Did.String(), 158 - Event: "create_string", 159 - Properties: posthog.Properties{"rkey": string.Rkey}, 160 - }) 161 - if err != nil { 162 - log.Println("failed to enqueue posthog event:", err) 163 - } 164 - }
···
+86 -12
appview/pulls/pulls.go
··· 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/pages/markup" 23 "tangled.org/core/appview/reporesolver" 24 "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/idresolver" 26 "tangled.org/core/patchutil" ··· 189 m[p.Sha] = p 190 } 191 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 193 if err != nil { 194 log.Println("failed to get pull reactions") 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 } 202 203 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 204 LoggedInUser: user, 205 RepoInfo: repoInfo, ··· 211 Pipelines: m, 212 213 OrderedReactionKinds: models.OrderedReactionKinds, 214 - Reactions: reactionCountMap, 215 UserReacted: userReactions, 216 }) 217 } 218 ··· 474 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 475 user := s.oauth.GetUser(r) 476 params := r.URL.Query() 477 478 state := models.PullOpen 479 switch params.Get("state") { ··· 489 return 490 } 491 492 - pulls, err := db.GetPulls( 493 s.db, 494 db.FilterEq("repo_at", f.RepoAt()), 495 db.FilterEq("state", state), 496 ) 497 if err != nil { 498 log.Println("failed to get pulls", err) 499 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") ··· 557 m[p.Sha] = p 558 } 559 560 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 LoggedInUser: s.oauth.GetUser(r), 562 RepoInfo: f.RepoInfo(user), 563 Pulls: pulls, 564 FilteringBy: state, 565 Stacks: stacks, 566 Pipelines: m, 567 }) 568 } 569 ··· 630 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 631 return 632 } 633 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 634 Collection: tangled.RepoPullCommentNSID, 635 Repo: user.Did, 636 Rkey: tid.TID(), ··· 1058 1059 // We've already checked earlier if it's diff-based and title is empty, 1060 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1062 formatPatches, err := patchutil.ExtractPatches(patch) 1063 if err != nil { 1064 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 return 1070 } 1071 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1074 } 1075 1076 rkey := tid.TID() ··· 1103 return 1104 } 1105 1106 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1107 Collection: tangled.RepoPullNSID, 1108 Repo: user.Did, 1109 Rkey: rkey, ··· 1200 } 1201 writes = append(writes, &write) 1202 } 1203 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1204 Repo: user.Did, 1205 Writes: writes, 1206 }) ··· 1731 return 1732 } 1733 1734 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1735 if err != nil { 1736 // failed to get record 1737 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1754 } 1755 } 1756 1757 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1758 Collection: tangled.RepoPullNSID, 1759 Repo: user.Did, 1760 Rkey: pull.Rkey, ··· 2026 return 2027 } 2028 2029 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2030 Repo: user.Did, 2031 Writes: writes, 2032 }) ··· 2147 return 2148 } 2149 2150 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2151 } 2152 ··· 2212 log.Println("failed to commit transaction", err) 2213 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2214 return 2215 } 2216 2217 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
··· 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/pages/markup" 23 "tangled.org/core/appview/reporesolver" 24 + "tangled.org/core/appview/search" 25 "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/idresolver" 27 "tangled.org/core/patchutil" ··· 190 m[p.Sha] = p 191 } 192 193 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 194 if err != nil { 195 log.Println("failed to get pull reactions") 196 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 201 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 202 } 203 204 + labelDefs, err := db.GetLabelDefinitions( 205 + s.db, 206 + db.FilterIn("at_uri", f.Repo.Labels), 207 + db.FilterContains("scope", tangled.RepoPullNSID), 208 + ) 209 + if err != nil { 210 + log.Println("failed to fetch labels", err) 211 + s.pages.Error503(w) 212 + return 213 + } 214 + 215 + defs := make(map[string]*models.LabelDefinition) 216 + for _, l := range labelDefs { 217 + defs[l.AtUri().String()] = &l 218 + } 219 + 220 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 221 LoggedInUser: user, 222 RepoInfo: repoInfo, ··· 228 Pipelines: m, 229 230 OrderedReactionKinds: models.OrderedReactionKinds, 231 + Reactions: reactionMap, 232 UserReacted: userReactions, 233 + 234 + LabelDefs: defs, 235 }) 236 } 237 ··· 493 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 494 user := s.oauth.GetUser(r) 495 params := r.URL.Query() 496 + searchQuery := params.Get("q") 497 + sortBy := params.Get("sort_by") 498 + sortOrder := params.Get("sort_order") 499 + 500 + templateSortBy := sortBy 501 + templateSortOrder := sortOrder 502 + 503 + if sortBy == "" { 504 + sortBy = "created" 505 + } 506 + if sortOrder == "" { 507 + sortOrder = "desc" 508 + } 509 510 state := models.PullOpen 511 switch params.Get("state") { ··· 521 return 522 } 523 524 + var pulls []*models.Pull 525 + 526 + query := search.Parse(searchQuery) 527 + 528 + pulls, err = db.SearchPulls( 529 s.db, 530 + query.Text, 531 + query.Labels, 532 + sortBy, 533 + sortOrder, 534 db.FilterEq("repo_at", f.RepoAt()), 535 db.FilterEq("state", state), 536 ) 537 + 538 if err != nil { 539 log.Println("failed to get pulls", err) 540 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") ··· 598 m[p.Sha] = p 599 } 600 601 + labelDefs, err := db.GetLabelDefinitions( 602 + s.db, 603 + db.FilterIn("at_uri", f.Repo.Labels), 604 + db.FilterContains("scope", tangled.RepoPullNSID), 605 + ) 606 + if err != nil { 607 + log.Println("failed to fetch labels", err) 608 + s.pages.Error503(w) 609 + return 610 + } 611 + 612 + defs := make(map[string]*models.LabelDefinition) 613 + for _, l := range labelDefs { 614 + defs[l.AtUri().String()] = &l 615 + } 616 + 617 s.pages.RepoPulls(w, pages.RepoPullsParams{ 618 LoggedInUser: s.oauth.GetUser(r), 619 RepoInfo: f.RepoInfo(user), 620 Pulls: pulls, 621 + LabelDefs: defs, 622 FilteringBy: state, 623 Stacks: stacks, 624 Pipelines: m, 625 + SearchQuery: searchQuery, 626 + SortBy: templateSortBy, 627 + SortOrder: templateSortOrder, 628 }) 629 } 630 ··· 691 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 692 return 693 } 694 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 695 Collection: tangled.RepoPullCommentNSID, 696 Repo: user.Did, 697 Rkey: tid.TID(), ··· 1119 1120 // We've already checked earlier if it's diff-based and title is empty, 1121 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1122 + if title == "" || body == "" { 1123 formatPatches, err := patchutil.ExtractPatches(patch) 1124 if err != nil { 1125 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1130 return 1131 } 1132 1133 + if title == "" { 1134 + title = formatPatches[0].Title 1135 + } 1136 + if body == "" { 1137 + body = formatPatches[0].Body 1138 + } 1139 } 1140 1141 rkey := tid.TID() ··· 1168 return 1169 } 1170 1171 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1172 Collection: tangled.RepoPullNSID, 1173 Repo: user.Did, 1174 Rkey: rkey, ··· 1265 } 1266 writes = append(writes, &write) 1267 } 1268 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1269 Repo: user.Did, 1270 Writes: writes, 1271 }) ··· 1796 return 1797 } 1798 1799 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1800 if err != nil { 1801 // failed to get record 1802 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1819 } 1820 } 1821 1822 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1823 Collection: tangled.RepoPullNSID, 1824 Repo: user.Did, 1825 Rkey: pull.Rkey, ··· 2091 return 2092 } 2093 2094 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2095 Repo: user.Did, 2096 Writes: writes, 2097 }) ··· 2212 return 2213 } 2214 2215 + // notify about the pull merge 2216 + for _, p := range pullsToMerge { 2217 + s.notifier.NewPullMerged(r.Context(), p) 2218 + } 2219 + 2220 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2221 } 2222 ··· 2282 log.Println("failed to commit transaction", err) 2283 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2284 return 2285 + } 2286 + 2287 + for _, p := range pullsToClose { 2288 + s.notifier.NewPullClosed(r.Context(), p) 2289 } 2290 2291 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+11 -10
appview/repo/artifact.go
··· 10 "net/url" 11 "time" 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/dustin/go-humanize" 17 - "github.com/go-chi/chi/v5" 18 - "github.com/go-git/go-git/v5/plumbing" 19 - "github.com/ipfs/go-cid" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" ··· 25 "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/tid" 27 "tangled.org/core/types" 28 ) 29 30 // TODO: proper statuses here on early exit ··· 60 return 61 } 62 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 if err != nil { 65 log.Println("failed to upload blob", err) 66 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 rkey := tid.TID() 73 createdAt := time.Now() 74 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 Collection: tangled.RepoArtifactNSID, 77 Repo: user.Did, 78 Rkey: rkey, ··· 249 return 250 } 251 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 Collection: tangled.RepoArtifactNSID, 254 Repo: user.Did, 255 Rkey: artifact.Rkey,
··· 10 "net/url" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" ··· 18 "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/tid" 20 "tangled.org/core/types" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 + "github.com/dustin/go-humanize" 26 + "github.com/go-chi/chi/v5" 27 + "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/ipfs/go-cid" 29 ) 30 31 // TODO: proper statuses here on early exit ··· 61 return 62 } 63 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 65 if err != nil { 66 log.Println("failed to upload blob", err) 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 73 rkey := tid.TID() 74 createdAt := time.Now() 75 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 Repo: user.Did, 79 Rkey: rkey, ··· 250 return 251 } 252 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 254 Collection: tangled.RepoArtifactNSID, 255 Repo: user.Did, 256 Rkey: artifact.Rkey,
+17 -22
appview/repo/index.go
··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/pages/markup" 26 "tangled.org/core/appview/reporesolver" 27 "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/types" ··· 201 }) 202 } 203 204 // update appview's cache 205 - err = db.InsertRepoLanguages(rp.db, langs) 206 if err != nil { 207 // non-fatal 208 log.Println("failed to cache lang results", err) 209 } 210 } 211 ··· 328 } 329 }() 330 331 - // readme content 332 - wg.Add(1) 333 - go func() { 334 - defer wg.Done() 335 - for _, filename := range markup.ReadmeFilenames { 336 - blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 - if err != nil { 338 - continue 339 - } 340 - 341 - if blobResp == nil { 342 - continue 343 - } 344 - 345 - readmeContent = blobResp.Content 346 - readmeFileName = filename 347 - break 348 - } 349 - }() 350 - 351 wg.Wait() 352 353 if errs != nil { ··· 374 } 375 files = append(files, niceFile) 376 } 377 } 378 379 result := &types.RepoIndexResponse{
··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/xrpcclient" 27 "tangled.org/core/types" ··· 200 }) 201 } 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 209 // update appview's cache 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 211 if err != nil { 212 // non-fatal 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 219 } 220 } 221 ··· 338 } 339 }() 340 341 wg.Wait() 342 343 if errs != nil { ··· 364 } 365 files = append(files, niceFile) 366 } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 372 } 373 374 result := &types.RepoIndexResponse{
+105 -86
appview/repo/repo.go
··· 17 "strings" 18 "time" 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 "tangled.org/core/api/tangled" 24 "tangled.org/core/appview/commitverify" 25 "tangled.org/core/appview/config" ··· 40 "tangled.org/core/types" 41 "tangled.org/core/xrpc/serviceauth" 42 43 securejoin "github.com/cyphar/filepath-securejoin" 44 "github.com/go-chi/chi/v5" 45 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 ) 49 50 type Repo struct { ··· 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 // 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 if err != nil { 312 // failed to get record 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 return 315 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 317 Collection: tangled.RepoNSID, 318 Repo: newRepo.Did, 319 Rkey: newRepo.Rkey, ··· 449 return 450 } 451 452 - // readme content 453 - var ( 454 - readmeContent string 455 - readmeFileName string 456 - ) 457 - 458 - for _, filename := range markup.ReadmeFilenames { 459 - path := fmt.Sprintf("%s/%s", treePath, filename) 460 - blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo) 461 - if err != nil { 462 - continue 463 - } 464 - 465 - if blobResp == nil { 466 - continue 467 - } 468 - 469 - readmeContent = blobResp.Content 470 - readmeFileName = path 471 - break 472 - } 473 - 474 // Convert XRPC response to internal types.RepoTreeResponse 475 files := make([]types.NiceTree, len(xrpcResp.Files)) 476 for i, xrpcFile := range xrpcResp.Files { ··· 506 if xrpcResp.Dotdot != nil { 507 result.DotDot = *xrpcResp.Dotdot 508 } 509 510 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 511 // so we can safely redirect to the "parent" (which is the same file). ··· 532 BreadCrumbs: breadcrumbs, 533 TreePath: treePath, 534 RepoInfo: f.RepoInfo(user), 535 - Readme: readmeContent, 536 - ReadmeFileName: readmeFileName, 537 RepoTreeResponse: result, 538 }) 539 } ··· 883 user := rp.oauth.GetUser(r) 884 l := rp.logger.With("handler", "EditSpindle") 885 l = l.With("did", user.Did) 886 - l = l.With("handle", user.Handle) 887 888 errorId := "operation-error" 889 fail := func(msg string, err error) { ··· 936 return 937 } 938 939 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 940 if err != nil { 941 fail("Failed to update spindle, no record found on PDS.", err) 942 return 943 } 944 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 945 Collection: tangled.RepoNSID, 946 Repo: newRepo.Did, 947 Rkey: newRepo.Rkey, ··· 971 user := rp.oauth.GetUser(r) 972 l := rp.logger.With("handler", "AddLabel") 973 l = l.With("did", user.Did) 974 - l = l.With("handle", user.Handle) 975 976 f, err := rp.repoResolver.Resolve(r) 977 if err != nil { ··· 1040 1041 // emit a labelRecord 1042 labelRecord := label.AsRecord() 1043 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1044 Collection: tangled.LabelDefinitionNSID, 1045 Repo: label.Did, 1046 Rkey: label.Rkey, ··· 1063 newRepo.Labels = append(newRepo.Labels, aturi) 1064 repoRecord := newRepo.AsRecord() 1065 1066 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1067 if err != nil { 1068 fail("Failed to update labels, no record found on PDS.", err) 1069 return 1070 } 1071 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1072 Collection: tangled.RepoNSID, 1073 Repo: newRepo.Did, 1074 Rkey: newRepo.Rkey, ··· 1131 user := rp.oauth.GetUser(r) 1132 l := rp.logger.With("handler", "DeleteLabel") 1133 l = l.With("did", user.Did) 1134 - l = l.With("handle", user.Handle) 1135 1136 f, err := rp.repoResolver.Resolve(r) 1137 if err != nil { ··· 1161 } 1162 1163 // delete label record from PDS 1164 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1165 Collection: tangled.LabelDefinitionNSID, 1166 Repo: label.Did, 1167 Rkey: label.Rkey, ··· 1183 newRepo.Labels = updated 1184 repoRecord := newRepo.AsRecord() 1185 1186 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1187 if err != nil { 1188 fail("Failed to update labels, no record found on PDS.", err) 1189 return 1190 } 1191 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1192 Collection: tangled.RepoNSID, 1193 Repo: newRepo.Did, 1194 Rkey: newRepo.Rkey, ··· 1240 user := rp.oauth.GetUser(r) 1241 l := rp.logger.With("handler", "SubscribeLabel") 1242 l = l.With("did", user.Did) 1243 - l = l.With("handle", user.Handle) 1244 1245 f, err := rp.repoResolver.Resolve(r) 1246 if err != nil { 1247 l.Error("failed to get repo and knot", "err", err) 1248 return 1249 } 1250 ··· 1254 rp.pages.Notice(w, errorId, msg) 1255 } 1256 1257 - labelAt := r.FormValue("label") 1258 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1259 if err != nil { 1260 fail("Failed to subscribe to label.", err) 1261 return 1262 } 1263 1264 newRepo := f.Repo 1265 - newRepo.Labels = append(newRepo.Labels, labelAt) 1266 repoRecord := newRepo.AsRecord() 1267 1268 client, err := rp.oauth.AuthorizedClient(r) ··· 1271 return 1272 } 1273 1274 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1275 if err != nil { 1276 fail("Failed to update labels, no record found on PDS.", err) 1277 return 1278 } 1279 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1280 Collection: tangled.RepoNSID, 1281 Repo: newRepo.Did, 1282 Rkey: newRepo.Rkey, ··· 1286 }, 1287 }) 1288 1289 - err = db.SubscribeLabel(rp.db, &models.RepoLabel{ 1290 - RepoAt: f.RepoAt(), 1291 - LabelAt: syntax.ATURI(labelAt), 1292 - }) 1293 if err != nil { 1294 fail("Failed to subscribe to label.", err) 1295 return 1296 } 1297 1298 // everything succeeded 1299 rp.pages.HxRefresh(w) ··· 1303 user := rp.oauth.GetUser(r) 1304 l := rp.logger.With("handler", "UnsubscribeLabel") 1305 l = l.With("did", user.Did) 1306 - l = l.With("handle", user.Handle) 1307 1308 f, err := rp.repoResolver.Resolve(r) 1309 if err != nil { ··· 1311 return 1312 } 1313 1314 errorId := "default-label-operation" 1315 fail := func(msg string, err error) { 1316 l.Error(msg, "err", err) 1317 rp.pages.Notice(w, errorId, msg) 1318 } 1319 1320 - labelAt := r.FormValue("label") 1321 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1322 if err != nil { 1323 fail("Failed to unsubscribe to label.", err) 1324 return ··· 1328 newRepo := f.Repo 1329 var updated []string 1330 for _, l := range newRepo.Labels { 1331 - if l != labelAt { 1332 updated = append(updated, l) 1333 } 1334 } ··· 1341 return 1342 } 1343 1344 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1345 if err != nil { 1346 fail("Failed to update labels, no record found on PDS.", err) 1347 return 1348 } 1349 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1350 Collection: tangled.RepoNSID, 1351 Repo: newRepo.Did, 1352 Rkey: newRepo.Rkey, ··· 1359 err = db.UnsubscribeLabel( 1360 rp.db, 1361 db.FilterEq("repo_at", f.RepoAt()), 1362 - db.FilterEq("label_at", labelAt), 1363 ) 1364 if err != nil { 1365 fail("Failed to unsubscribe label.", err) ··· 1470 user := rp.oauth.GetUser(r) 1471 l := rp.logger.With("handler", "AddCollaborator") 1472 l = l.With("did", user.Did) 1473 - l = l.With("handle", user.Handle) 1474 1475 f, err := rp.repoResolver.Resolve(r) 1476 if err != nil { ··· 1517 currentUser := rp.oauth.GetUser(r) 1518 rkey := tid.TID() 1519 createdAt := time.Now() 1520 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1521 Collection: tangled.RepoCollaboratorNSID, 1522 Repo: currentUser.Did, 1523 Rkey: rkey, ··· 1608 } 1609 1610 // remove record from pds 1611 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1612 if err != nil { 1613 log.Println("failed to get authorized client", err) 1614 return 1615 } 1616 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1617 Collection: tangled.RepoNSID, 1618 Repo: user.Did, 1619 Rkey: f.Rkey, ··· 1755 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1756 user := rp.oauth.GetUser(r) 1757 l := rp.logger.With("handler", "Secrets") 1758 - l = l.With("handle", user.Handle) 1759 l = l.With("did", user.Did) 1760 1761 f, err := rp.repoResolver.Resolve(r) ··· 1927 subscribedLabels[l] = struct{}{} 1928 } 1929 1930 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1931 - LoggedInUser: user, 1932 - RepoInfo: f.RepoInfo(user), 1933 - Branches: result.Branches, 1934 - Labels: labels, 1935 - DefaultLabels: defaultLabels, 1936 - SubscribedLabels: subscribedLabels, 1937 - Tabs: settingsTabs, 1938 - Tab: "general", 1939 }) 1940 } 1941 ··· 2108 } 2109 2110 // choose a name for a fork 2111 - forkName := f.Name 2112 // this check is *only* to see if the forked repo name already exists 2113 // in the user's account. 2114 existingRepo, err := db.GetRepo( 2115 rp.db, 2116 db.FilterEq("did", user.Did), 2117 - db.FilterEq("name", f.Name), 2118 ) 2119 if err != nil { 2120 - if errors.Is(err, sql.ErrNoRows) { 2121 - // no existing repo with this name found, we can use the name as is 2122 - } else { 2123 log.Println("error fetching existing repo from db", "err", err) 2124 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2125 return 2126 } 2127 } else if existingRepo != nil { 2128 - // repo with this name already exists, append random string 2129 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2130 } 2131 l = l.With("forkName", forkName) 2132 ··· 2148 Knot: targetKnot, 2149 Rkey: rkey, 2150 Source: sourceAt, 2151 - Description: existingRepo.Description, 2152 Created: time.Now(), 2153 } 2154 record := repo.AsRecord() 2155 2156 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2157 if err != nil { 2158 l.Error("failed to create xrpcclient", "err", err) 2159 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2160 return 2161 } 2162 2163 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2164 Collection: tangled.RepoNSID, 2165 Repo: user.Did, 2166 Rkey: rkey, ··· 2192 rollback := func() { 2193 err1 := tx.Rollback() 2194 err2 := rp.enforcer.E.LoadPolicy() 2195 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2196 2197 // ignore txn complete errors, this is okay 2198 if errors.Is(err1, sql.ErrTxDone) { ··· 2265 aturi = "" 2266 2267 rp.notifier.NewRepo(r.Context(), repo) 2268 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2269 } 2270 } 2271 2272 // this is used to rollback changes made to the PDS 2273 // 2274 // it is a no-op if the provided ATURI is empty 2275 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2276 if aturi == "" { 2277 return nil 2278 } ··· 2283 repo := parsed.Authority().String() 2284 rkey := parsed.RecordKey().String() 2285 2286 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2287 Collection: collection, 2288 Repo: repo, 2289 Rkey: rkey,
··· 17 "strings" 18 "time" 19 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/commitverify" 22 "tangled.org/core/appview/config" ··· 37 "tangled.org/core/types" 38 "tangled.org/core/xrpc/serviceauth" 39 40 + comatproto "github.com/bluesky-social/indigo/api/atproto" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 43 + lexutil "github.com/bluesky-social/indigo/lex/util" 44 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 45 securejoin "github.com/cyphar/filepath-securejoin" 46 "github.com/go-chi/chi/v5" 47 "github.com/go-git/go-git/v5/plumbing" 48 ) 49 50 type Repo struct { ··· 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 // 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 if err != nil { 312 // failed to get record 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 return 315 } 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 Collection: tangled.RepoNSID, 318 Repo: newRepo.Did, 319 Rkey: newRepo.Rkey, ··· 449 return 450 } 451 452 // Convert XRPC response to internal types.RepoTreeResponse 453 files := make([]types.NiceTree, len(xrpcResp.Files)) 454 for i, xrpcFile := range xrpcResp.Files { ··· 484 if xrpcResp.Dotdot != nil { 485 result.DotDot = *xrpcResp.Dotdot 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 491 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 493 // so we can safely redirect to the "parent" (which is the same file). ··· 514 BreadCrumbs: breadcrumbs, 515 TreePath: treePath, 516 RepoInfo: f.RepoInfo(user), 517 RepoTreeResponse: result, 518 }) 519 } ··· 863 user := rp.oauth.GetUser(r) 864 l := rp.logger.With("handler", "EditSpindle") 865 l = l.With("did", user.Did) 866 867 errorId := "operation-error" 868 fail := func(msg string, err error) { ··· 915 return 916 } 917 918 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 919 if err != nil { 920 fail("Failed to update spindle, no record found on PDS.", err) 921 return 922 } 923 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 924 Collection: tangled.RepoNSID, 925 Repo: newRepo.Did, 926 Rkey: newRepo.Rkey, ··· 950 user := rp.oauth.GetUser(r) 951 l := rp.logger.With("handler", "AddLabel") 952 l = l.With("did", user.Did) 953 954 f, err := rp.repoResolver.Resolve(r) 955 if err != nil { ··· 1018 1019 // emit a labelRecord 1020 labelRecord := label.AsRecord() 1021 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1022 Collection: tangled.LabelDefinitionNSID, 1023 Repo: label.Did, 1024 Rkey: label.Rkey, ··· 1041 newRepo.Labels = append(newRepo.Labels, aturi) 1042 repoRecord := newRepo.AsRecord() 1043 1044 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1045 if err != nil { 1046 fail("Failed to update labels, no record found on PDS.", err) 1047 return 1048 } 1049 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1050 Collection: tangled.RepoNSID, 1051 Repo: newRepo.Did, 1052 Rkey: newRepo.Rkey, ··· 1109 user := rp.oauth.GetUser(r) 1110 l := rp.logger.With("handler", "DeleteLabel") 1111 l = l.With("did", user.Did) 1112 1113 f, err := rp.repoResolver.Resolve(r) 1114 if err != nil { ··· 1138 } 1139 1140 // delete label record from PDS 1141 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1142 Collection: tangled.LabelDefinitionNSID, 1143 Repo: label.Did, 1144 Rkey: label.Rkey, ··· 1160 newRepo.Labels = updated 1161 repoRecord := newRepo.AsRecord() 1162 1163 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1164 if err != nil { 1165 fail("Failed to update labels, no record found on PDS.", err) 1166 return 1167 } 1168 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1169 Collection: tangled.RepoNSID, 1170 Repo: newRepo.Did, 1171 Rkey: newRepo.Rkey, ··· 1217 user := rp.oauth.GetUser(r) 1218 l := rp.logger.With("handler", "SubscribeLabel") 1219 l = l.With("did", user.Did) 1220 1221 f, err := rp.repoResolver.Resolve(r) 1222 if err != nil { 1223 l.Error("failed to get repo and knot", "err", err) 1224 + return 1225 + } 1226 + 1227 + if err := r.ParseForm(); err != nil { 1228 + l.Error("invalid form", "err", err) 1229 return 1230 } 1231 ··· 1235 rp.pages.Notice(w, errorId, msg) 1236 } 1237 1238 + labelAts := r.Form["label"] 1239 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1240 if err != nil { 1241 fail("Failed to subscribe to label.", err) 1242 return 1243 } 1244 1245 newRepo := f.Repo 1246 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1247 + 1248 + // dedup 1249 + slices.Sort(newRepo.Labels) 1250 + newRepo.Labels = slices.Compact(newRepo.Labels) 1251 + 1252 repoRecord := newRepo.AsRecord() 1253 1254 client, err := rp.oauth.AuthorizedClient(r) ··· 1257 return 1258 } 1259 1260 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1261 if err != nil { 1262 fail("Failed to update labels, no record found on PDS.", err) 1263 return 1264 } 1265 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1266 Collection: tangled.RepoNSID, 1267 Repo: newRepo.Did, 1268 Rkey: newRepo.Rkey, ··· 1272 }, 1273 }) 1274 1275 + tx, err := rp.db.Begin() 1276 if err != nil { 1277 fail("Failed to subscribe to label.", err) 1278 return 1279 } 1280 + defer tx.Rollback() 1281 + 1282 + for _, l := range labelAts { 1283 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1284 + RepoAt: f.RepoAt(), 1285 + LabelAt: syntax.ATURI(l), 1286 + }) 1287 + if err != nil { 1288 + fail("Failed to subscribe to label.", err) 1289 + return 1290 + } 1291 + } 1292 + 1293 + if err := tx.Commit(); err != nil { 1294 + fail("Failed to subscribe to label.", err) 1295 + return 1296 + } 1297 1298 // everything succeeded 1299 rp.pages.HxRefresh(w) ··· 1303 user := rp.oauth.GetUser(r) 1304 l := rp.logger.With("handler", "UnsubscribeLabel") 1305 l = l.With("did", user.Did) 1306 1307 f, err := rp.repoResolver.Resolve(r) 1308 if err != nil { ··· 1310 return 1311 } 1312 1313 + if err := r.ParseForm(); err != nil { 1314 + l.Error("invalid form", "err", err) 1315 + return 1316 + } 1317 + 1318 errorId := "default-label-operation" 1319 fail := func(msg string, err error) { 1320 l.Error(msg, "err", err) 1321 rp.pages.Notice(w, errorId, msg) 1322 } 1323 1324 + labelAts := r.Form["label"] 1325 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1326 if err != nil { 1327 fail("Failed to unsubscribe to label.", err) 1328 return ··· 1332 newRepo := f.Repo 1333 var updated []string 1334 for _, l := range newRepo.Labels { 1335 + if !slices.Contains(labelAts, l) { 1336 updated = append(updated, l) 1337 } 1338 } ··· 1345 return 1346 } 1347 1348 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1349 if err != nil { 1350 fail("Failed to update labels, no record found on PDS.", err) 1351 return 1352 } 1353 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1354 Collection: tangled.RepoNSID, 1355 Repo: newRepo.Did, 1356 Rkey: newRepo.Rkey, ··· 1363 err = db.UnsubscribeLabel( 1364 rp.db, 1365 db.FilterEq("repo_at", f.RepoAt()), 1366 + db.FilterIn("label_at", labelAts), 1367 ) 1368 if err != nil { 1369 fail("Failed to unsubscribe label.", err) ··· 1474 user := rp.oauth.GetUser(r) 1475 l := rp.logger.With("handler", "AddCollaborator") 1476 l = l.With("did", user.Did) 1477 1478 f, err := rp.repoResolver.Resolve(r) 1479 if err != nil { ··· 1520 currentUser := rp.oauth.GetUser(r) 1521 rkey := tid.TID() 1522 createdAt := time.Now() 1523 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1524 Collection: tangled.RepoCollaboratorNSID, 1525 Repo: currentUser.Did, 1526 Rkey: rkey, ··· 1611 } 1612 1613 // remove record from pds 1614 + atpClient, err := rp.oauth.AuthorizedClient(r) 1615 if err != nil { 1616 log.Println("failed to get authorized client", err) 1617 return 1618 } 1619 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1620 Collection: tangled.RepoNSID, 1621 Repo: user.Did, 1622 Rkey: f.Rkey, ··· 1758 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1759 user := rp.oauth.GetUser(r) 1760 l := rp.logger.With("handler", "Secrets") 1761 l = l.With("did", user.Did) 1762 1763 f, err := rp.repoResolver.Resolve(r) ··· 1929 subscribedLabels[l] = struct{}{} 1930 } 1931 1932 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1933 + // if all default labels are subbed, show the "unsubscribe all" button 1934 + shouldSubscribeAll := false 1935 + for _, dl := range defaultLabels { 1936 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1937 + // one of the default labels is not subscribed to 1938 + shouldSubscribeAll = true 1939 + break 1940 + } 1941 + } 1942 + 1943 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1944 + LoggedInUser: user, 1945 + RepoInfo: f.RepoInfo(user), 1946 + Branches: result.Branches, 1947 + Labels: labels, 1948 + DefaultLabels: defaultLabels, 1949 + SubscribedLabels: subscribedLabels, 1950 + ShouldSubscribeAll: shouldSubscribeAll, 1951 + Tabs: settingsTabs, 1952 + Tab: "general", 1953 }) 1954 } 1955 ··· 2122 } 2123 2124 // choose a name for a fork 2125 + forkName := r.FormValue("repo_name") 2126 + if forkName == "" { 2127 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2128 + return 2129 + } 2130 + 2131 // this check is *only* to see if the forked repo name already exists 2132 // in the user's account. 2133 existingRepo, err := db.GetRepo( 2134 rp.db, 2135 db.FilterEq("did", user.Did), 2136 + db.FilterEq("name", forkName), 2137 ) 2138 if err != nil { 2139 + if !errors.Is(err, sql.ErrNoRows) { 2140 log.Println("error fetching existing repo from db", "err", err) 2141 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2142 return 2143 } 2144 } else if existingRepo != nil { 2145 + // repo with this name already exists 2146 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2147 + return 2148 } 2149 l = l.With("forkName", forkName) 2150 ··· 2166 Knot: targetKnot, 2167 Rkey: rkey, 2168 Source: sourceAt, 2169 + Description: f.Repo.Description, 2170 Created: time.Now(), 2171 + Labels: models.DefaultLabelDefs(), 2172 } 2173 record := repo.AsRecord() 2174 2175 + atpClient, err := rp.oauth.AuthorizedClient(r) 2176 if err != nil { 2177 l.Error("failed to create xrpcclient", "err", err) 2178 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2179 return 2180 } 2181 2182 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2183 Collection: tangled.RepoNSID, 2184 Repo: user.Did, 2185 Rkey: rkey, ··· 2211 rollback := func() { 2212 err1 := tx.Rollback() 2213 err2 := rp.enforcer.E.LoadPolicy() 2214 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2215 2216 // ignore txn complete errors, this is okay 2217 if errors.Is(err1, sql.ErrTxDone) { ··· 2284 aturi = "" 2285 2286 rp.notifier.NewRepo(r.Context(), repo) 2287 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2288 } 2289 } 2290 2291 // this is used to rollback changes made to the PDS 2292 // 2293 // it is a no-op if the provided ATURI is empty 2294 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2295 if aturi == "" { 2296 return nil 2297 } ··· 2302 repo := parsed.Authority().String() 2303 rkey := parsed.RecordKey().String() 2304 2305 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2306 Collection: collection, 2307 Repo: repo, 2308 Rkey: rkey,
+63
appview/search/query.go
···
··· 1 + package search 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + // Query represents a parsed search query 8 + type Query struct { 9 + // Text search terms (anything that's not a has: filter) 10 + Text string 11 + // Label filters from has:labelname syntax 12 + Labels []string 13 + } 14 + 15 + // Parse parses a search query string into a Query struct 16 + // Syntax: 17 + // - "has:enhancement" adds a label filter 18 + // - Other text becomes part of the text search 19 + func Parse(queryStr string) Query { 20 + q := Query{ 21 + Labels: []string{}, 22 + } 23 + 24 + // Split query into tokens 25 + tokens := strings.Fields(queryStr) 26 + var textParts []string 27 + 28 + for _, token := range tokens { 29 + // Check if it's a has: filter 30 + if strings.HasPrefix(token, "has:") { 31 + label := strings.TrimPrefix(token, "has:") 32 + if label != "" { 33 + q.Labels = append(q.Labels, label) 34 + } 35 + } else { 36 + // It's a text search term 37 + textParts = append(textParts, token) 38 + } 39 + } 40 + 41 + q.Text = strings.Join(textParts, " ") 42 + return q 43 + } 44 + 45 + // String converts a Query back to a query string 46 + func (q Query) String() string { 47 + var parts []string 48 + 49 + if q.Text != "" { 50 + parts = append(parts, q.Text) 51 + } 52 + 53 + for _, label := range q.Labels { 54 + parts = append(parts, "has:"+label) 55 + } 56 + 57 + return strings.Join(parts, " ") 58 + } 59 + 60 + // HasFilters returns true if the query has any search filters 61 + func (q Query) HasFilters() bool { 62 + return q.Text != "" || len(q.Labels) > 0 63 + }
+53 -2
appview/settings/settings.go
··· 41 {"Name": "profile", "Icon": "user"}, 42 {"Name": "keys", "Icon": "key"}, 43 {"Name": "emails", "Icon": "mail"}, 44 } 45 ) 46 ··· 68 r.Post("/primary", s.emailsPrimary) 69 }) 70 71 return r 72 } 73 ··· 79 Tabs: settingsTabs, 80 Tab: "profile", 81 }) 82 } 83 84 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { ··· 419 } 420 421 // store in pds too 422 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 423 Collection: tangled.PublicKeyNSID, 424 Repo: did, 425 Rkey: rkey, ··· 476 477 if rkey != "" { 478 // remove from pds too 479 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 480 Collection: tangled.PublicKeyNSID, 481 Repo: did, 482 Rkey: rkey,
··· 41 {"Name": "profile", "Icon": "user"}, 42 {"Name": "keys", "Icon": "key"}, 43 {"Name": "emails", "Icon": "mail"}, 44 + {"Name": "notifications", "Icon": "bell"}, 45 } 46 ) 47 ··· 69 r.Post("/primary", s.emailsPrimary) 70 }) 71 72 + r.Route("/notifications", func(r chi.Router) { 73 + r.Get("/", s.notificationsSettings) 74 + r.Put("/", s.updateNotificationPreferences) 75 + }) 76 + 77 return r 78 } 79 ··· 85 Tabs: settingsTabs, 86 Tab: "profile", 87 }) 88 + } 89 + 90 + func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 91 + user := s.OAuth.GetUser(r) 92 + did := s.OAuth.GetDid(r) 93 + 94 + prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 95 + if err != nil { 96 + log.Printf("failed to get notification preferences: %s", err) 97 + s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 98 + return 99 + } 100 + 101 + s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{ 102 + LoggedInUser: user, 103 + Preferences: prefs, 104 + Tabs: settingsTabs, 105 + Tab: "notifications", 106 + }) 107 + } 108 + 109 + func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) { 110 + did := s.OAuth.GetDid(r) 111 + 112 + prefs := &models.NotificationPreferences{ 113 + UserDid: did, 114 + RepoStarred: r.FormValue("repo_starred") == "on", 115 + IssueCreated: r.FormValue("issue_created") == "on", 116 + IssueCommented: r.FormValue("issue_commented") == "on", 117 + IssueClosed: r.FormValue("issue_closed") == "on", 118 + PullCreated: r.FormValue("pull_created") == "on", 119 + PullCommented: r.FormValue("pull_commented") == "on", 120 + PullMerged: r.FormValue("pull_merged") == "on", 121 + Followed: r.FormValue("followed") == "on", 122 + EmailNotifications: r.FormValue("email_notifications") == "on", 123 + } 124 + 125 + err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 126 + if err != nil { 127 + log.Printf("failed to update notification preferences: %s", err) 128 + s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 129 + return 130 + } 131 + 132 + s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.") 133 } 134 135 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { ··· 470 } 471 472 // store in pds too 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 474 Collection: tangled.PublicKeyNSID, 475 Repo: did, 476 Rkey: rkey, ··· 527 528 if rkey != "" { 529 // remove from pds too 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 531 Collection: tangled.PublicKeyNSID, 532 Repo: did, 533 Rkey: rkey,
+65 -3
appview/signup/signup.go
··· 2 3 import ( 4 "bufio" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "os" 9 "strings" 10 ··· 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/pages" 19 "tangled.org/core/appview/state/userutil" 20 - "tangled.org/core/appview/xrpcclient" 21 "tangled.org/core/idresolver" 22 ) 23 ··· 26 db *db.DB 27 cf *dns.Cloudflare 28 posthog posthog.Client 29 - xrpc *xrpcclient.Client 30 idResolver *idresolver.Resolver 31 pages *pages.Pages 32 l *slog.Logger ··· 116 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 switch r.Method { 118 case http.MethodGet: 119 - s.pages.Signup(w) 120 case http.MethodPost: 121 if s.cf == nil { 122 http.Error(w, "signup is disabled", http.StatusFailedDependency) 123 } 124 emailId := r.FormValue("email") 125 126 noticeId := "signup-msg" 127 if !email.IsValidEmail(emailId) { 128 s.pages.Notice(w, noticeId, "Invalid email address.") 129 return ··· 255 return 256 } 257 }
··· 2 3 import ( 4 "bufio" 5 + "encoding/json" 6 + "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 + "net/url" 11 "os" 12 "strings" 13 ··· 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/state/userutil" 23 "tangled.org/core/idresolver" 24 ) 25 ··· 28 db *db.DB 29 cf *dns.Cloudflare 30 posthog posthog.Client 31 idResolver *idresolver.Resolver 32 pages *pages.Pages 33 l *slog.Logger ··· 117 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 118 switch r.Method { 119 case http.MethodGet: 120 + s.pages.Signup(w, pages.SignupParams{ 121 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 122 + }) 123 case http.MethodPost: 124 if s.cf == nil { 125 http.Error(w, "signup is disabled", http.StatusFailedDependency) 126 + return 127 } 128 emailId := r.FormValue("email") 129 + cfToken := r.FormValue("cf-turnstile-response") 130 131 noticeId := "signup-msg" 132 + 133 + if err := s.validateCaptcha(cfToken, r); err != nil { 134 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 135 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 136 + return 137 + } 138 + 139 if !email.IsValidEmail(emailId) { 140 s.pages.Notice(w, noticeId, "Invalid email address.") 141 return ··· 267 return 268 } 269 } 270 + 271 + type turnstileResponse struct { 272 + Success bool `json:"success"` 273 + ErrorCodes []string `json:"error-codes,omitempty"` 274 + ChallengeTs string `json:"challenge_ts,omitempty"` 275 + Hostname string `json:"hostname,omitempty"` 276 + } 277 + 278 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 279 + if cfToken == "" { 280 + return errors.New("captcha token is empty") 281 + } 282 + 283 + if s.config.Cloudflare.TurnstileSecretKey == "" { 284 + return errors.New("turnstile secret key not configured") 285 + } 286 + 287 + data := url.Values{} 288 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 289 + data.Set("response", cfToken) 290 + 291 + // include the client IP if we have it 292 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 293 + data.Set("remoteip", remoteIP) 294 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 295 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 296 + data.Set("remoteip", strings.TrimSpace(ips[0])) 297 + } 298 + } else { 299 + data.Set("remoteip", r.RemoteAddr) 300 + } 301 + 302 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 303 + if err != nil { 304 + return fmt.Errorf("failed to verify turnstile token: %w", err) 305 + } 306 + defer resp.Body.Close() 307 + 308 + var turnstileResp turnstileResponse 309 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 310 + return fmt.Errorf("failed to decode turnstile response: %w", err) 311 + } 312 + 313 + if !turnstileResp.Success { 314 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 315 + return errors.New("turnstile validation failed") 316 + } 317 + 318 + return nil 319 + }
+5 -5
appview/spindles/spindles.go
··· 189 return 190 } 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
··· 189 return 190 } 191 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
+151
appview/state/gfi.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+14 -1
appview/state/knotstream.go
··· 172 }) 173 } 174 175 - return db.InsertRepoLanguages(d, langs) 176 } 177 178 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
··· 172 }) 173 } 174 175 + tx, err := d.Begin() 176 + if err != nil { 177 + return err 178 + } 179 + defer tx.Rollback() 180 + 181 + // update appview's cache 182 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 183 + if err != nil { 184 + fmt.Printf("failed; %s\n", err) 185 + // non-fatal 186 + } 187 + 188 + return tx.Commit() 189 } 190 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+63
appview/state/login.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "tangled.org/core/appview/pages" 10 + ) 11 + 12 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 13 + switch r.Method { 14 + case http.MethodGet: 15 + returnURL := r.URL.Query().Get("return_url") 16 + s.pages.Login(w, pages.LoginParams{ 17 + ReturnUrl: returnURL, 18 + }) 19 + case http.MethodPost: 20 + handle := r.FormValue("handle") 21 + 22 + // when users copy their handle from bsky.app, it tends to have these characters around it: 23 + // 24 + // @nelind.dk: 25 + // \u202a ensures that the handle is always rendered left to right and 26 + // \u202c reverts that so the rest of the page renders however it should 27 + handle = strings.TrimPrefix(handle, "\u202a") 28 + handle = strings.TrimSuffix(handle, "\u202c") 29 + 30 + // `@` is harmless 31 + handle = strings.TrimPrefix(handle, "@") 32 + 33 + // basic handle validation 34 + if !strings.Contains(handle, ".") { 35 + log.Println("invalid handle format", "raw", handle) 36 + s.pages.Notice( 37 + w, 38 + "login-msg", 39 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 40 + ) 41 + return 42 + } 43 + 44 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + s.pages.HxRedirect(w, redirectURL) 51 + } 52 + } 53 + 54 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 55 + err := s.oauth.DeleteSession(w, r) 56 + if err != nil { 57 + log.Println("failed to logout", "err", err) 58 + } else { 59 + log.Println("logged out successfully") 60 + } 61 + 62 + s.pages.HxRedirect(w, "/login") 63 + }
+2 -2
appview/state/profile.go
··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 - Count: count, 84 IsReacted: true, 85 }) 86 ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedReactionNSID, 97 Repo: currentUser.Did, 98 Rkey: reaction.Rkey, ··· 109 // this is not an issue, the firehose event might have already done this 110 } 111 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 return 116 } 117 118 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 ThreadAt: subjectUri, 120 Kind: reactionKind, 121 - Count: count, 122 IsReacted: false, 123 }) 124
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 if err != nil { 75 + log.Println("failed to get reactions for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 85 IsReacted: true, 86 }) 87 ··· 93 return 94 } 95 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 Collection: tangled.FeedReactionNSID, 98 Repo: currentUser.Did, 99 Rkey: reaction.Rkey, ··· 110 // this is not an issue, the firehose event might have already done this 111 } 112 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 114 if err != nil { 115 + log.Println("failed to get reactions for ", subjectUri) 116 return 117 } 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 120 ThreadAt: subjectUri, 121 Kind: reactionKind, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 124 IsReacted: false, 125 }) 126
+20 -12
appview/state/router.go
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 "tangled.org/core/appview/issues" 10 "tangled.org/core/appview/knots" 11 "tangled.org/core/appview/labels" 12 "tangled.org/core/appview/middleware" 13 - oauthhandler "tangled.org/core/appview/oauth/handler" 14 "tangled.org/core/appview/pipelines" 15 "tangled.org/core/appview/pulls" 16 "tangled.org/core/appview/repo" ··· 35 36 router.Get("/favicon.svg", s.Favicon) 37 router.Get("/favicon.ico", s.Favicon) 38 39 userRouter := s.UserRouter(&middleware) 40 standardRouter := s.StandardRouter(&middleware) ··· 115 116 r.Get("/", s.HomeOrTimeline) 117 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 119 120 // special-case handler for serving tangled.org/core 121 r.Get("/core", s.Core()) 122 123 r.Route("/repo", func(r chi.Router) { 124 r.Route("/new", func(r chi.Router) { 125 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 128 }) 129 // r.Post("/import", s.ImportRepo) 130 }) 131 132 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 133 r.Post("/", s.Follow) ··· 156 r.Mount("/strings", s.StringsRouter(mw)) 157 r.Mount("/knots", s.KnotsRouter()) 158 r.Mount("/spindles", s.SpindlesRouter()) 159 r.Mount("/signup", s.SignupRouter()) 160 - r.Mount("/", s.OAuthRouter()) 161 162 r.Get("/keys/{user}", s.Keys) 163 r.Get("/terms", s.TermsOfService) 164 r.Get("/privacy", s.PrivacyPolicy) 165 166 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 167 s.pages.Error404(w) ··· 175 return func(w http.ResponseWriter, r *http.Request) { 176 if r.URL.Query().Get("go-get") == "1" { 177 w.Header().Set("Content-Type", "text/html") 178 - w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/tangled.org/core">`)) 179 return 180 } 181 182 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 183 } 184 - } 185 - 186 - func (s *State) OAuthRouter() http.Handler { 187 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 188 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 189 - return oauth.Router() 190 } 191 192 func (s *State) SettingsRouter() http.Handler { ··· 270 } 271 272 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 273 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 274 return ls.Router(mw) 275 } 276 277 func (s *State) SignupRouter() http.Handler {
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.org/core/appview/issues" 9 "tangled.org/core/appview/knots" 10 "tangled.org/core/appview/labels" 11 "tangled.org/core/appview/middleware" 12 + "tangled.org/core/appview/notifications" 13 "tangled.org/core/appview/pipelines" 14 "tangled.org/core/appview/pulls" 15 "tangled.org/core/appview/repo" ··· 34 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 39 userRouter := s.UserRouter(&middleware) 40 standardRouter := s.StandardRouter(&middleware) ··· 115 116 r.Get("/", s.HomeOrTimeline) 117 r.Get("/timeline", s.Timeline) 118 + r.Get("/upgradeBanner", s.UpgradeBanner) 119 120 // special-case handler for serving tangled.org/core 121 r.Get("/core", s.Core()) 122 123 + r.Get("/login", s.Login) 124 + r.Post("/login", s.Login) 125 + r.Post("/logout", s.Logout) 126 + 127 r.Route("/repo", func(r chi.Router) { 128 r.Route("/new", func(r chi.Router) { 129 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 132 }) 133 // r.Post("/import", s.ImportRepo) 134 }) 135 + 136 + r.Get("/goodfirstissues", s.GoodFirstIssues) 137 138 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 139 r.Post("/", s.Follow) ··· 162 r.Mount("/strings", s.StringsRouter(mw)) 163 r.Mount("/knots", s.KnotsRouter()) 164 r.Mount("/spindles", s.SpindlesRouter()) 165 + r.Mount("/notifications", s.NotificationsRouter(mw)) 166 + 167 r.Mount("/signup", s.SignupRouter()) 168 + r.Mount("/", s.oauth.Router()) 169 170 r.Get("/keys/{user}", s.Keys) 171 r.Get("/terms", s.TermsOfService) 172 r.Get("/privacy", s.PrivacyPolicy) 173 + r.Get("/brand", s.Brand) 174 175 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 176 s.pages.Error404(w) ··· 184 return func(w http.ResponseWriter, r *http.Request) { 185 if r.URL.Query().Get("go-get") == "1" { 186 w.Header().Set("Content-Type", "text/html") 187 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 188 return 189 } 190 191 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 192 } 193 } 194 195 func (s *State) SettingsRouter() http.Handler { ··· 273 } 274 275 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 276 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 277 return ls.Router(mw) 278 + } 279 + 280 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 281 + notifs := notifications.New(s.db, s.oauth, s.pages) 282 + return notifs.Router(mw) 283 } 284 285 func (s *State) SignupRouter() http.Handler {
+2 -2
appview/state/star.go
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
+103 -21
appview/state/state.go
··· 11 "strings" 12 "time" 13 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview" 22 "tangled.org/core/appview/cache" ··· 25 "tangled.org/core/appview/db" 26 "tangled.org/core/appview/models" 27 "tangled.org/core/appview/notify" 28 "tangled.org/core/appview/oauth" 29 "tangled.org/core/appview/pages" 30 - posthogService "tangled.org/core/appview/posthog" 31 "tangled.org/core/appview/reporesolver" 32 "tangled.org/core/appview/validator" 33 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 37 tlog "tangled.org/core/log" 38 "tangled.org/core/rbac" 39 "tangled.org/core/tid" 40 ) 41 42 type State struct { ··· 74 res = idresolver.DefaultResolver() 75 } 76 77 - pgs := pages.NewPages(config, res) 78 cache := cache.New(config.Redis.Addr) 79 sess := session.New(cache) 80 - oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d, res) 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 if err != nil { ··· 103 tangled.RepoIssueNSID, 104 tangled.RepoIssueCommentNSID, 105 tangled.LabelDefinitionNSID, 106 }, 107 nil, 108 slog.Default(), ··· 115 ) 116 if err != nil { 117 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 118 } 119 120 ingester := appview.Ingester{ ··· 143 spindlestream.Start(ctx) 144 145 var notifiers []notify.Notifier 146 if !config.Core.Dev { 147 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 148 } 149 notifier := notify.NewMergedNotifier(notifiers...) 150 151 state := &State{ 152 d, 153 notifier, 154 - oauth, 155 enforcer, 156 - pgs, 157 sess, 158 res, 159 posthog, ··· 187 s.pages.Favicon(w) 188 } 189 190 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 191 user := s.oauth.GetUser(r) 192 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 197 func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 198 user := s.oauth.GetUser(r) 199 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 200 LoggedInUser: user, 201 }) 202 } ··· 229 return 230 } 231 232 s.pages.Timeline(w, pages.TimelineParams{ 233 LoggedInUser: user, 234 Timeline: timeline, 235 Repos: repos, 236 }) 237 } 238 239 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 240 user := s.oauth.GetUser(r) 241 l := s.logger.With("handler", "UpgradeBanner") 242 l = l.With("did", user.Did) 243 - l = l.With("handle", user.Handle) 244 245 regs, err := db.GetRegistrations( 246 s.db, ··· 380 381 user := s.oauth.GetUser(r) 382 l = l.With("did", user.Did) 383 - l = l.With("handle", user.Handle) 384 385 // form validation 386 domain := r.FormValue("domain") ··· 440 Rkey: rkey, 441 Description: description, 442 Created: time.Now(), 443 } 444 record := repo.AsRecord() 445 446 - xrpcClient, err := s.oauth.AuthorizedClient(r) 447 if err != nil { 448 l.Info("PDS write failed", "err", err) 449 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 450 return 451 } 452 453 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 Collection: tangled.RepoNSID, 455 Repo: user.Did, 456 Rkey: rkey, ··· 482 rollback := func() { 483 err1 := tx.Rollback() 484 err2 := s.enforcer.E.LoadPolicy() 485 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 486 487 // ignore txn complete errors, this is okay 488 if errors.Is(err1, sql.ErrTxDone) { ··· 555 aturi = "" 556 557 s.notifier.NewRepo(r.Context(), repo) 558 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 559 } 560 } 561 562 // this is used to rollback changes made to the PDS 563 // 564 // it is a no-op if the provided ATURI is empty 565 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 566 if aturi == "" { 567 return nil 568 } ··· 573 repo := parsed.Authority().String() 574 rkey := parsed.RecordKey().String() 575 576 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 577 Collection: collection, 578 Repo: repo, 579 Rkey: rkey, 580 }) 581 return err 582 }
··· 11 "strings" 12 "time" 13 14 "tangled.org/core/api/tangled" 15 "tangled.org/core/appview" 16 "tangled.org/core/appview/cache" ··· 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/notify" 22 + dbnotify "tangled.org/core/appview/notify/db" 23 + phnotify "tangled.org/core/appview/notify/posthog" 24 "tangled.org/core/appview/oauth" 25 "tangled.org/core/appview/pages" 26 "tangled.org/core/appview/reporesolver" 27 "tangled.org/core/appview/validator" 28 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 32 tlog "tangled.org/core/log" 33 "tangled.org/core/rbac" 34 "tangled.org/core/tid" 35 + 36 + comatproto "github.com/bluesky-social/indigo/api/atproto" 37 + atpclient "github.com/bluesky-social/indigo/atproto/client" 38 + "github.com/bluesky-social/indigo/atproto/syntax" 39 + lexutil "github.com/bluesky-social/indigo/lex/util" 40 + securejoin "github.com/cyphar/filepath-securejoin" 41 + "github.com/go-chi/chi/v5" 42 + "github.com/posthog/posthog-go" 43 ) 44 45 type State struct { ··· 77 res = idresolver.DefaultResolver() 78 } 79 80 + pages := pages.NewPages(config, res) 81 cache := cache.New(config.Redis.Addr) 82 sess := session.New(cache) 83 + oauth2, err := oauth.New(config) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 86 + } 87 + validator := validator.New(d, res, enforcer) 88 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 90 if err != nil { ··· 109 tangled.RepoIssueNSID, 110 tangled.RepoIssueCommentNSID, 111 tangled.LabelDefinitionNSID, 112 + tangled.LabelOpNSID, 113 }, 114 nil, 115 slog.Default(), ··· 122 ) 123 if err != nil { 124 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 125 + } 126 + 127 + if err := BackfillDefaultDefs(d, res); err != nil { 128 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 129 } 130 131 ingester := appview.Ingester{ ··· 154 spindlestream.Start(ctx) 155 156 var notifiers []notify.Notifier 157 + 158 + // Always add the database notifier 159 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 160 + 161 + // Add other notifiers in production only 162 if !config.Core.Dev { 163 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 164 } 165 notifier := notify.NewMergedNotifier(notifiers...) 166 167 state := &State{ 168 d, 169 notifier, 170 + oauth2, 171 enforcer, 172 + pages, 173 sess, 174 res, 175 posthog, ··· 203 s.pages.Favicon(w) 204 } 205 206 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 207 + const manifestJson = `{ 208 + "name": "tangled", 209 + "description": "tightly-knit social coding.", 210 + "icons": [ 211 + { 212 + "src": "/favicon.svg", 213 + "sizes": "144x144" 214 + } 215 + ], 216 + "start_url": "/", 217 + "id": "org.tangled", 218 + 219 + "display": "standalone", 220 + "background_color": "#111827", 221 + "theme_color": "#111827" 222 + }` 223 + 224 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 225 + w.Header().Set("Content-Type", "application/json") 226 + w.Write([]byte(manifestJson)) 227 + } 228 + 229 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 230 user := s.oauth.GetUser(r) 231 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 236 func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 237 user := s.oauth.GetUser(r) 238 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 239 + LoggedInUser: user, 240 + }) 241 + } 242 + 243 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 244 + user := s.oauth.GetUser(r) 245 + s.pages.Brand(w, pages.BrandParams{ 246 LoggedInUser: user, 247 }) 248 } ··· 275 return 276 } 277 278 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 279 + if err != nil { 280 + // non-fatal 281 + } 282 + 283 s.pages.Timeline(w, pages.TimelineParams{ 284 LoggedInUser: user, 285 Timeline: timeline, 286 Repos: repos, 287 + GfiLabel: gfiLabel, 288 }) 289 } 290 291 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 292 user := s.oauth.GetUser(r) 293 + if user == nil { 294 + return 295 + } 296 + 297 l := s.logger.With("handler", "UpgradeBanner") 298 l = l.With("did", user.Did) 299 300 regs, err := db.GetRegistrations( 301 s.db, ··· 435 436 user := s.oauth.GetUser(r) 437 l = l.With("did", user.Did) 438 439 // form validation 440 domain := r.FormValue("domain") ··· 494 Rkey: rkey, 495 Description: description, 496 Created: time.Now(), 497 + Labels: models.DefaultLabelDefs(), 498 } 499 record := repo.AsRecord() 500 501 + atpClient, err := s.oauth.AuthorizedClient(r) 502 if err != nil { 503 l.Info("PDS write failed", "err", err) 504 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 505 return 506 } 507 508 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 509 Collection: tangled.RepoNSID, 510 Repo: user.Did, 511 Rkey: rkey, ··· 537 rollback := func() { 538 err1 := tx.Rollback() 539 err2 := s.enforcer.E.LoadPolicy() 540 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 541 542 // ignore txn complete errors, this is okay 543 if errors.Is(err1, sql.ErrTxDone) { ··· 610 aturi = "" 611 612 s.notifier.NewRepo(r.Context(), repo) 613 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 614 } 615 } 616 617 // this is used to rollback changes made to the PDS 618 // 619 // it is a no-op if the provided ATURI is empty 620 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 621 if aturi == "" { 622 return nil 623 } ··· 628 repo := parsed.Authority().String() 629 rkey := parsed.RecordKey().String() 630 631 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 632 Collection: collection, 633 Repo: repo, 634 Rkey: rkey, 635 }) 636 return err 637 } 638 + 639 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 640 + defaults := models.DefaultLabelDefs() 641 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 642 + if err != nil { 643 + return err 644 + } 645 + // already present 646 + if len(defaultLabels) == len(defaults) { 647 + return nil 648 + } 649 + 650 + labelDefs, err := models.FetchDefaultDefs(r) 651 + if err != nil { 652 + return err 653 + } 654 + 655 + // Insert each label definition to the database 656 + for _, labelDef := range labelDefs { 657 + _, err = db.AddLabelDefinition(e, &labelDef) 658 + if err != nil { 659 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 660 + } 661 + } 662 + 663 + return nil 664 + }
+9 -7
appview/strings/strings.go
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 "github.com/go-chi/chi/v5" 27 ) 28 29 type Strings struct { ··· 254 } 255 256 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 if err != nil { 259 fail("Failed to updated existing record.", err) 260 return 261 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 263 Collection: tangled.StringNSID, 264 Repo: entry.Did.String(), 265 Rkey: entry.Rkey, ··· 284 s.Notifier.EditString(r.Context(), &entry) 285 286 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 288 } 289 290 } ··· 336 return 337 } 338 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 340 Collection: tangled.StringNSID, 341 Repo: user.Did, 342 Rkey: string.Rkey, ··· 360 s.Notifier.NewString(r.Context(), &string) 361 362 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 364 } 365 } 366 ··· 403 404 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 407 } 408 409 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 ) 30 31 type Strings struct { ··· 256 } 257 258 // first replace the existing record in the PDS 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 260 if err != nil { 261 fail("Failed to updated existing record.", err) 262 return 263 } 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 265 Collection: tangled.StringNSID, 266 Repo: entry.Did.String(), 267 Rkey: entry.Rkey, ··· 286 s.Notifier.EditString(r.Context(), &entry) 287 288 // if that went okay, redir to the string 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 290 } 291 292 } ··· 338 return 339 } 340 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 342 Collection: tangled.StringNSID, 343 Repo: user.Did, 344 Rkey: string.Rkey, ··· 362 s.Notifier.NewString(r.Context(), &string) 363 364 // successful 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 366 } 367 } 368 ··· 405 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 407 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 409 } 410 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+15 -1
appview/validator/label.go
··· 95 return nil 96 } 97 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 99 if labelDef == nil { 100 return fmt.Errorf("label definition is required") 101 } 102 if labelOp == nil { 103 return fmt.Errorf("label operation is required") 104 } 105 106 expectedKey := labelDef.AtUri().String()
··· 95 return nil 96 } 97 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 if labelDef == nil { 100 return fmt.Errorf("label definition is required") 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 105 if labelOp == nil { 106 return fmt.Errorf("label operation is required") 107 + } 108 + 109 + // validate permissions: only collaborators can apply labels currently 110 + // 111 + // TODO: introduce a repo:triage permission 112 + ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 + if err != nil { 114 + return fmt.Errorf("failed to enforce permissions: %w", err) 115 + } 116 + if !ok { 117 + return fmt.Errorf("unauhtorized label operation") 118 } 119 120 expectedKey := labelDef.AtUri().String()
+4 -1
appview/validator/validator.go
··· 4 "tangled.org/core/appview/db" 5 "tangled.org/core/appview/pages/markup" 6 "tangled.org/core/idresolver" 7 ) 8 9 type Validator struct { 10 db *db.DB 11 sanitizer markup.Sanitizer 12 resolver *idresolver.Resolver 13 } 14 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 16 return &Validator{ 17 db: db, 18 sanitizer: markup.NewSanitizer(), 19 resolver: res, 20 } 21 }
··· 4 "tangled.org/core/appview/db" 5 "tangled.org/core/appview/pages/markup" 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 8 ) 9 10 type Validator struct { 11 db *db.DB 12 sanitizer markup.Sanitizer 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 15 } 16 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 return &Validator{ 19 db: db, 20 sanitizer: markup.NewSanitizer(), 21 resolver: res, 22 + enforcer: enforcer, 23 } 24 }
-99
appview/xrpcclient/xrpc.go
··· 1 package xrpcclient 2 3 import ( 4 - "bytes" 5 - "context" 6 "errors" 7 - "io" 8 "net/http" 9 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 ) 15 16 var ( ··· 19 ErrXrpcFailed = errors.New("xrpc request failed") 20 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 116 // produces a more manageable error 117 func HandleXrpcErr(err error) error {
··· 1 package xrpcclient 2 3 import ( 4 "errors" 5 "net/http" 6 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 8 ) 9 10 var ( ··· 13 ErrXrpcFailed = errors.New("xrpc request failed") 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 15 ) 16 17 // produces a more manageable error 18 func HandleXrpcErr(err error) error {
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 2 3 package main 4
··· 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 3 package main 4
+1 -1
docs/spindle/pipeline.md
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
+4 -4
go.mod
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/net v0.42.0 47 golang.org/x/sync v0.16.0 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 ) 52 53 require ( ··· 168 go.uber.org/atomic v1.11.0 // indirect 169 go.uber.org/multierr v1.11.0 // indirect 170 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 + github.com/yuin/goldmark v1.7.13 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 47 golang.org/x/net v0.42.0 48 golang.org/x/sync v0.16.0 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 50 gopkg.in/yaml.v3 v3.0.1 51 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 52 ) 53 54 require ( ··· 169 go.uber.org/atomic v1.11.0 // indirect 170 go.uber.org/multierr v1.11.0 // indirect 171 go.uber.org/zap v1.27.0 // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect
+6 -4
go.sum
··· 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= ··· 652 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
··· 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 438 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 439 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 440 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 441 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 442 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 443 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 444 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 445 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= ··· 654 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 655 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 656 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 657 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 h1:EpQ9MT09jSf4Zjs1+yFvB4CD/fBkFdx8UaDJDwO1Jk8= 658 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5/go.mod h1:BQFGoN2V+h5KtgKsQgWU73R55ILdDy/R5RZTrZi6wog= 659 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 660 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+1 -1
knotserver/config/config.go
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
-103
knotserver/git/git.go
··· 27 h plumbing.Hash 28 } 29 30 - type TagList struct { 31 - refs []*TagReference 32 - r *git.Repository 33 - } 34 - 35 - // TagReference is used to list both tag and non-annotated tags. 36 - // Non-annotated tags should only contains a reference. 37 - // Annotated tags should contain its reference and its tag information. 38 - type TagReference struct { 39 - ref *plumbing.Reference 40 - tag *object.Tag 41 - } 42 - 43 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 // to tar WriteHeader 45 type infoWrapper struct { ··· 48 mode fs.FileMode 49 modTime time.Time 50 isDir bool 51 - } 52 - 53 - func (self *TagList) Len() int { 54 - return len(self.refs) 55 - } 56 - 57 - func (self *TagList) Swap(i, j int) { 58 - self.refs[i], self.refs[j] = self.refs[j], self.refs[i] 59 - } 60 - 61 - // sorting tags in reverse chronological order 62 - func (self *TagList) Less(i, j int) bool { 63 - var dateI time.Time 64 - var dateJ time.Time 65 - 66 - if self.refs[i].tag != nil { 67 - dateI = self.refs[i].tag.Tagger.When 68 - } else { 69 - c, err := self.r.CommitObject(self.refs[i].ref.Hash()) 70 - if err != nil { 71 - dateI = time.Now() 72 - } else { 73 - dateI = c.Committer.When 74 - } 75 - } 76 - 77 - if self.refs[j].tag != nil { 78 - dateJ = self.refs[j].tag.Tagger.When 79 - } else { 80 - c, err := self.r.CommitObject(self.refs[j].ref.Hash()) 81 - if err != nil { 82 - dateJ = time.Now() 83 - } else { 84 - dateJ = c.Committer.When 85 - } 86 - } 87 - 88 - return dateI.After(dateJ) 89 } 90 91 func Open(path string, ref string) (*GitRepo, error) { ··· 171 return g.r.CommitObject(h) 172 } 173 174 - func (g *GitRepo) LastCommit() (*object.Commit, error) { 175 - c, err := g.r.CommitObject(g.h) 176 - if err != nil { 177 - return nil, fmt.Errorf("last commit: %w", err) 178 - } 179 - return c, nil 180 - } 181 - 182 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 c, err := g.r.CommitObject(g.h) 184 if err != nil { ··· 211 } 212 213 return buf.Bytes(), nil 214 - } 215 - 216 - func (g *GitRepo) FileContent(path string) (string, error) { 217 - c, err := g.r.CommitObject(g.h) 218 - if err != nil { 219 - return "", fmt.Errorf("commit object: %w", err) 220 - } 221 - 222 - tree, err := c.Tree() 223 - if err != nil { 224 - return "", fmt.Errorf("file tree: %w", err) 225 - } 226 - 227 - file, err := tree.File(path) 228 - if err != nil { 229 - return "", err 230 - } 231 - 232 - isbin, _ := file.IsBinary() 233 - 234 - if !isbin { 235 - return file.Contents() 236 - } else { 237 - return "", ErrBinaryFile 238 - } 239 } 240 241 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 func (i *infoWrapper) Sys() any { 411 return nil 412 } 413 - 414 - func (t *TagReference) Name() string { 415 - return t.ref.Name().Short() 416 - } 417 - 418 - func (t *TagReference) Message() string { 419 - if t.tag != nil { 420 - return t.tag.Message 421 - } 422 - return "" 423 - } 424 - 425 - func (t *TagReference) TagObject() *object.Tag { 426 - return t.tag 427 - } 428 - 429 - func (t *TagReference) Hash() plumbing.Hash { 430 - return t.ref.Hash() 431 - }
··· 27 h plumbing.Hash 28 } 29 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 31 // to tar WriteHeader 32 type infoWrapper struct { ··· 35 mode fs.FileMode 36 modTime time.Time 37 isDir bool 38 } 39 40 func Open(path string, ref string) (*GitRepo, error) { ··· 120 return g.r.CommitObject(h) 121 } 122 123 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 124 c, err := g.r.CommitObject(g.h) 125 if err != nil { ··· 152 } 153 154 return buf.Bytes(), nil 155 } 156 157 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 326 func (i *infoWrapper) Sys() any { 327 return nil 328 }
+1 -3
knotserver/git/tag.go
··· 2 3 import ( 4 "fmt" 5 - "slices" 6 "strconv" 7 "strings" 8 "time" ··· 35 outFormat.WriteString("") 36 outFormat.WriteString(recordSeparator) 37 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 39 if err != nil { 40 return nil, fmt.Errorf("failed to get tags: %w", err) 41 } ··· 94 tags = append(tags, tag) 95 } 96 97 - slices.Reverse(tags) 98 return tags, nil 99 }
··· 2 3 import ( 4 "fmt" 5 "strconv" 6 "strings" 7 "time" ··· 34 outFormat.WriteString("") 35 outFormat.WriteString(recordSeparator) 36 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 38 if err != nil { 39 return nil, fmt.Errorf("failed to get tags: %w", err) 40 } ··· 93 tags = append(tags, tag) 94 } 95 96 return tags, nil 97 }
-4
knotserver/http_util.go
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 }
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
+24
knotserver/xrpc/repo_tree.go
··· 4 "net/http" 5 "path/filepath" 6 "time" 7 8 "tangled.org/core/api/tangled" 9 "tangled.org/core/knotserver/git" 10 xrpcerr "tangled.org/core/xrpc/errors" 11 ) ··· 43 return 44 } 45 46 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 for i, file := range files { ··· 83 Parent: parentPtr, 84 Dotdot: dotdotPtr, 85 Files: treeEntries, 86 } 87 88 writeJson(w, response)
··· 4 "net/http" 5 "path/filepath" 6 "time" 7 + "unicode/utf8" 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 11 "tangled.org/core/knotserver/git" 12 xrpcerr "tangled.org/core/xrpc/errors" 13 ) ··· 45 return 46 } 47 48 + // if any of these files are a readme candidate, pass along its blob contents too 49 + var readmeFileName string 50 + var readmeContents string 51 + for _, file := range files { 52 + if markup.IsReadmeFile(file.Name) { 53 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 54 + if err != nil { 55 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 56 + } 57 + 58 + if utf8.Valid(contents) { 59 + readmeFileName = file.Name 60 + readmeContents = string(contents) 61 + break 62 + } 63 + } 64 + } 65 + 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 for i, file := range files { ··· 103 Parent: parentPtr, 104 Dotdot: dotdotPtr, 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 110 } 111 112 writeJson(w, response)
-158
legal/privacy.md
··· 1 - # Privacy Policy 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - This Privacy Policy describes how Tangled ("we," "us," or "our") 6 - collects, uses, and shares your personal information when you use our 7 - platform and services (the "Service"). 8 - 9 - ## 1. Information We Collect 10 - 11 - ### Account Information 12 - 13 - When you create an account, we collect: 14 - 15 - - Your chosen username 16 - - Email address 17 - - Profile information you choose to provide 18 - - Authentication data 19 - 20 - ### Content and Activity 21 - 22 - We store: 23 - 24 - - Code repositories and associated metadata 25 - - Issues, pull requests, and comments 26 - - Activity logs and usage patterns 27 - - Public keys for authentication 28 - 29 - ## 2. Data Location and Hosting 30 - 31 - ### EU Data Hosting 32 - 33 - **All Tangled service data is hosted within the European Union.** 34 - Specifically: 35 - 36 - - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 - (*.tngl.sh) are located in Finland 38 - - **Application Data:** All other service data is stored on EU-based 39 - servers 40 - - **Data Processing:** All data processing occurs within EU 41 - jurisdiction 42 - 43 - ### External PDS Notice 44 - 45 - **Important:** If your account is hosted on Bluesky's PDS or other 46 - self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 - that data. The data protection, storage location, and privacy 48 - practices for such accounts are governed by the respective PDS 49 - provider's policies, not this Privacy Policy. We only control data 50 - processing within our own services and infrastructure. 51 - 52 - ## 3. Third-Party Data Processors 53 - 54 - We only share your data with the following third-party processors: 55 - 56 - ### Resend (Email Services) 57 - 58 - - **Purpose:** Sending transactional emails (account verification, 59 - notifications) 60 - - **Data Shared:** Email address and necessary message content 61 - 62 - ### Cloudflare (Image Caching) 63 - 64 - - **Purpose:** Caching and optimizing image delivery 65 - - **Data Shared:** Public images and associated metadata for caching 66 - purposes 67 - 68 - ### Posthog (Usage Metrics Tracking) 69 - 70 - - **Purpose:** Tracking usage and platform metrics 71 - - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 - information 73 - 74 - ## 4. How We Use Your Information 75 - 76 - We use your information to: 77 - 78 - - Provide and maintain the Service 79 - - Process your transactions and requests 80 - - Send you technical notices and support messages 81 - - Improve and develop new features 82 - - Ensure security and prevent fraud 83 - - Comply with legal obligations 84 - 85 - ## 5. Data Sharing and Disclosure 86 - 87 - We do not sell, trade, or rent your personal information. We may share 88 - your information only in the following circumstances: 89 - 90 - - With the third-party processors listed above 91 - - When required by law or legal process 92 - - To protect our rights, property, or safety, or that of our users 93 - - In connection with a merger, acquisition, or sale of assets (with 94 - appropriate protections) 95 - 96 - ## 6. Data Security 97 - 98 - We implement appropriate technical and organizational measures to 99 - protect your personal information against unauthorized access, 100 - alteration, disclosure, or destruction. However, no method of 101 - transmission over the Internet is 100% secure. 102 - 103 - ## 7. Data Retention 104 - 105 - We retain your personal information for as long as necessary to provide 106 - the Service and fulfill the purposes outlined in this Privacy Policy, 107 - unless a longer retention period is required by law. 108 - 109 - ## 8. Your Rights 110 - 111 - Under applicable data protection laws, you have the right to: 112 - 113 - - Access your personal information 114 - - Correct inaccurate information 115 - - Request deletion of your information 116 - - Object to processing of your information 117 - - Data portability 118 - - Withdraw consent (where applicable) 119 - 120 - ## 9. Cookies and Tracking 121 - 122 - We use cookies and similar technologies to: 123 - 124 - - Maintain your login session 125 - - Remember your preferences 126 - - Analyze usage patterns to improve the Service 127 - 128 - You can control cookie settings through your browser preferences. 129 - 130 - ## 10. Children's Privacy 131 - 132 - The Service is not intended for children under 16 years of age. We do 133 - not knowingly collect personal information from children under 16. If 134 - we become aware that we have collected such information, we will take 135 - steps to delete it. 136 - 137 - ## 11. International Data Transfers 138 - 139 - While all our primary data processing occurs within the EU, some of our 140 - third-party processors may process data outside the EU. When this 141 - occurs, we ensure appropriate safeguards are in place, such as Standard 142 - Contractual Clauses or adequacy decisions. 143 - 144 - ## 12. Changes to This Privacy Policy 145 - 146 - We may update this Privacy Policy from time to time. We will notify you 147 - of any changes by posting the new Privacy Policy on this page and 148 - updating the "Last updated" date. 149 - 150 - ## 13. Contact Information 151 - 152 - If you have any questions about this Privacy Policy or wish to exercise 153 - your rights, please contact us through our platform or via email. 154 - 155 - --- 156 - 157 - This Privacy Policy complies with the EU General Data Protection 158 - Regulation (GDPR) and other applicable data protection laws.
···
-109
legal/terms.md
··· 1 - # Terms of Service 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 - to and use of the Tangled platform and services (the "Service") 7 - operated by us ("Tangled," "we," "us," or "our"). 8 - 9 - ## 1. Acceptance of Terms 10 - 11 - By accessing or using our Service, you agree to be bound by these Terms. 12 - If you disagree with any part of these terms, then you may not access 13 - the Service. 14 - 15 - ## 2. Account Registration 16 - 17 - To use certain features of the Service, you must register for an 18 - account. You agree to provide accurate, current, and complete 19 - information during the registration process and to update such 20 - information to keep it accurate, current, and complete. 21 - 22 - ## 3. Account Termination 23 - 24 - > **Important Notice** 25 - > 26 - > **We reserve the right to terminate, suspend, or restrict access to 27 - > your account at any time, for any reason, or for no reason at all, at 28 - > our sole discretion.** This includes, but is not limited to, 29 - > termination for violation of these Terms, inappropriate conduct, spam, 30 - > abuse, or any other behavior we deem harmful to the Service or other 31 - > users. 32 - > 33 - > Account termination may result in the loss of access to your 34 - > repositories, data, and other content associated with your account. We 35 - > are not obligated to provide advance notice of termination, though we 36 - > may do so in our discretion. 37 - 38 - ## 4. Acceptable Use 39 - 40 - You agree not to use the Service to: 41 - 42 - - Violate any applicable laws or regulations 43 - - Infringe upon the rights of others 44 - - Upload, store, or share content that is illegal, harmful, threatening, 45 - abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 - objectionable 47 - - Engage in spam, phishing, or other deceptive practices 48 - - Attempt to gain unauthorized access to the Service or other users' 49 - accounts 50 - - Interfere with or disrupt the Service or servers connected to the 51 - Service 52 - 53 - ## 5. Content and Intellectual Property 54 - 55 - You retain ownership of the content you upload to the Service. By 56 - uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 - license to use, reproduce, modify, and distribute your content as 58 - necessary to provide the Service. 59 - 60 - ## 6. Privacy 61 - 62 - Your privacy is important to us. Please review our [Privacy 63 - Policy](/privacy), which also governs your use of the Service. 64 - 65 - ## 7. Disclaimers 66 - 67 - The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 - no warranties, expressed or implied, and hereby disclaim and negate all 69 - other warranties including without limitation, implied warranties or 70 - conditions of merchantability, fitness for a particular purpose, or 71 - non-infringement of intellectual property or other violation of rights. 72 - 73 - ## 8. Limitation of Liability 74 - 75 - In no event shall Tangled, nor its directors, employees, partners, 76 - agents, suppliers, or affiliates, be liable for any indirect, 77 - incidental, special, consequential, or punitive damages, including 78 - without limitation, loss of profits, data, use, goodwill, or other 79 - intangible losses, resulting from your use of the Service. 80 - 81 - ## 9. Indemnification 82 - 83 - You agree to defend, indemnify, and hold harmless Tangled and its 84 - affiliates, officers, directors, employees, and agents from and against 85 - any and all claims, damages, obligations, losses, liabilities, costs, 86 - or debt, and expenses (including attorney's fees). 87 - 88 - ## 10. Governing Law 89 - 90 - These Terms shall be interpreted and governed by the laws of Finland, 91 - without regard to its conflict of law provisions. 92 - 93 - ## 11. Changes to Terms 94 - 95 - We reserve the right to modify or replace these Terms at any time. If a 96 - revision is material, we will try to provide at least 30 days notice 97 - prior to any new terms taking effect. 98 - 99 - ## 12. Contact Information 100 - 101 - If you have any questions about these Terms of Service, please contact 102 - us through our platform or via email. 103 - 104 - --- 105 - 106 - These terms are effective as of the last updated date shown above and 107 - will remain in effect except with respect to any changes in their 108 - provisions in the future, which will be in effect immediately after 109 - being posted on this page.
···
+19
lexicons/repo/tree.json
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 "files": { 45 "type": "array", 46 "items": { ··· 69 "description": "Invalid request parameters" 70 } 71 ] 72 }, 73 "treeEntry": { 74 "type": "object",
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 49 "files": { 50 "type": "array", 51 "items": { ··· 74 "description": "Invalid request parameters" 75 } 76 ] 77 + }, 78 + "readme": { 79 + "type": "object", 80 + "required": ["filename", "contents"], 81 + "properties": { 82 + "filename": { 83 + "type": "string", 84 + "description": "Name of the readme file" 85 + }, 86 + "contents": { 87 + "type": "string", 88 + "description": "Contents of the readme file" 89 + } 90 + } 91 }, 92 "treeEntry": { 93 "type": "object",
+1 -1
nix/gomod2nix.toml
··· 527 [mod."lukechampine.com/blake3"] 528 version = "v1.4.1" 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 531 version = "v0.0.0-20250724194903-28e660378cb1" 532 hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
··· 527 [mod."lukechampine.com/blake3"] 528 version = "v1.4.1" 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 + [mod."tangled.org/anirudh.fi/atproto-oauth"] 531 version = "v0.0.0-20250724194903-28e660378cb1" 532 hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 sqlite-lib, 5 src, 6 }: let 7 - version = "1.9.0-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
··· 4 sqlite-lib, 5 src, 6 }: let 7 + version = "1.9.1-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
+7 -5
types/repo.go
··· 41 } 42 43 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 49 } 50 51 type TagReference struct {
··· 41 } 42 43 type RepoTreeResponse struct { 44 + Ref string `json:"ref,omitempty"` 45 + Parent string `json:"parent,omitempty"` 46 + Description string `json:"description,omitempty"` 47 + DotDot string `json:"dotdot,omitempty"` 48 + Files []NiceTree `json:"files,omitempty"` 49 + ReadmeFileName string `json:"readme_filename,omitempty"` 50 + Readme string `json:"readme_contents,omitempty"` 51 } 52 53 type TagReference struct {