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

Compare changes

Choose any two refs to compare.

Changed files
+4727 -2236
api
tangled
appview
config
db
issues
knots
labels
middleware
models
notifications
notify
oauth
pages
pagination
pipelines
posthog
pulls
repo
settings
signup
spindles
state
strings
validator
xrpcclient
cmd
genjwks
docs
spindle
knotserver
legal
lexicons
repo
nix
types
+10
api/tangled/repotree.go
··· 31 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 32 // parent: The parent path in the tree 33 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"` 34 36 // ref: The git reference used 35 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"` 36 46 } 37 47 38 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 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"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
+171 -8
appview/db/db.go
··· 530 530 unique (repo_at, label_at) 531 531 ); 532 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 + 533 561 create table if not exists migrations ( 534 562 id integer primary key autoincrement, 535 563 name text unique 536 564 ); 537 565 538 - -- indexes for better star query performance 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); 539 569 create index if not exists idx_stars_created on stars(created); 540 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 541 571 `) ··· 787 817 _, err := tx.Exec(` 788 818 alter table spindles add column needs_upgrade integer not null default 0; 789 819 `) 790 - if err != nil { 791 - return err 792 - } 793 - 794 - _, err = tx.Exec(` 795 - update spindles set needs_upgrade = 1; 796 - `) 797 820 return err 798 821 }) 799 822 ··· 930 953 _, err = tx.Exec(`drop table comments`) 931 954 return err 932 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;") 933 1096 934 1097 return &DB{db}, nil 935 1098 }
+13 -9
appview/db/email.go
··· 71 71 return did, nil 72 72 } 73 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 76 return make(map[string]string), nil 77 77 } 78 78 ··· 80 80 if isVerifiedFilter { 81 81 verifiedFilter = 1 82 82 } 83 + 84 + assoc := make(map[string]string) 83 85 84 86 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 87 89 88 90 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 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) 92 98 } 93 99 94 100 query := ` ··· 104 110 return nil, err 105 111 } 106 112 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 113 110 114 for rows.Next() { 111 115 var email, did string
+34
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 "tangled.org/core/appview/models" 8 10 ) 9 11 ··· 82 84 83 85 return nil 84 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 + }
+155 -228
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 9 + "slices" 7 10 "sort" 8 11 "strings" 9 12 "time" ··· 55 58 parentChangeId = &pull.ParentChangeId 56 59 } 57 60 58 - _, err = tx.Exec( 61 + result, err := tx.Exec( 59 62 ` 60 63 insert into pulls ( 61 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 ··· 79 82 return err 80 83 } 81 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 + 82 92 _, 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) 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) 86 96 return err 87 97 } 88 98 ··· 101 111 } 102 112 103 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 104 - pulls := make(map[int]*models.Pull) 114 + pulls := make(map[syntax.ATURI]*models.Pull) 105 115 106 116 var conditions []string 107 117 var args []any ··· 121 131 122 132 query := fmt.Sprintf(` 123 133 select 134 + id, 124 135 owner_did, 125 136 repo_at, 126 137 pull_id, ··· 154 165 var createdAt string 155 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 156 167 err := rows.Scan( 168 + &pull.ID, 157 169 &pull.OwnerDid, 158 170 &pull.RepoAt, 159 171 &pull.PullId, ··· 202 214 pull.ParentChangeId = parentChangeId.String 203 215 } 204 216 205 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 206 218 } 207 219 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 - } 220 + var pullAts []syntax.ATURI 225 221 for _, p := range pulls { 226 - args[idx] = p.PullId 227 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 228 223 } 229 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 230 225 if err != nil { 231 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 232 227 } 233 - defer submissionsRows.Close() 234 228 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 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 259 232 } 233 + } 260 234 261 - if p, ok := pulls[s.PullId]; ok { 262 - p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 263 - p.Submissions[s.RoundNumber] = &s 264 - } 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) 265 239 } 266 - if err := rows.Err(); err != nil { 267 - return nil, err 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 243 + } 268 244 } 269 245 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{} 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 284 248 for _, p := range pulls { 285 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 286 252 } 287 - commentsRows, err := e.Query(commentsQuery, args...) 288 - if err != nil { 289 - return nil, err 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) 290 256 } 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 - } 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 305 260 } 306 - if err := rows.Err(); err != nil { 307 - return nil, err 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 + } 308 267 } 309 268 310 269 orderedByPullId := []*models.Pull{} ··· 323 282 } 324 283 325 284 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 - ) 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 368 286 if err != nil { 369 287 return nil, err 370 288 } 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 291 + } 371 292 372 - createdTime, err := time.Parse(time.RFC3339, createdAt) 373 - if err != nil { 374 - return nil, err 375 - } 376 - pull.Created = createdTime 293 + return pulls[0], nil 294 + } 377 295 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 - } 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()...) 390 303 } 391 304 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 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 400 308 } 401 309 402 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 403 311 select 404 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 405 318 from 406 319 pull_submissions 407 - where 408 - repo_at = ? and pull_id = ? 409 - ` 410 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 411 326 if err != nil { 412 327 return nil, err 413 328 } 414 - defer submissionsRows.Close() 329 + defer rows.Close() 415 330 416 - submissionsMap := make(map[int]*models.PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 417 332 418 - for submissionsRows.Next() { 333 + for rows.Next() { 419 334 var submission models.PullSubmission 420 - var submissionCreatedStr string 421 - var submissionSourceRev sql.NullString 422 - err := submissionsRows.Scan( 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 423 338 &submission.ID, 424 - &submission.PullId, 425 - &submission.RepoAt, 339 + &submission.PullAt, 426 340 &submission.RoundNumber, 427 341 &submission.Patch, 428 - &submissionCreatedStr, 429 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 430 344 ) 431 345 if err != nil { 432 346 return nil, err 433 347 } 434 348 435 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 436 350 if err != nil { 437 351 return nil, err 438 352 } 439 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 440 354 441 - if submissionSourceRev.Valid { 442 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 443 357 } 444 358 445 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 446 360 } 447 - if err = submissionsRows.Close(); err != nil { 361 + 362 + if err := rows.Err(); err != nil { 448 363 return nil, err 449 364 } 450 - if len(submissionsMap) == 0 { 451 - return &pull, nil 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 + }) 452 389 } 453 390 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 454 396 var args []any 455 - for k := range submissionsMap { 456 - args = append(args, k) 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 ") 457 405 } 458 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 459 - commentsQuery := fmt.Sprintf(` 406 + 407 + query := fmt.Sprintf(` 460 408 select 461 409 id, 462 410 pull_id, ··· 468 416 created 469 417 from 470 418 pull_comments 471 - where 472 - submission_id IN (%s) 419 + %s 473 420 order by 474 421 created asc 475 - `, inClause) 476 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 477 425 if err != nil { 478 426 return nil, err 479 427 } 480 - defer commentsRows.Close() 428 + defer rows.Close() 481 429 482 - for commentsRows.Next() { 430 + var comments []models.PullComment 431 + for rows.Next() { 483 432 var comment models.PullComment 484 - var commentCreatedStr string 485 - err := commentsRows.Scan( 433 + var createdAt string 434 + err := rows.Scan( 486 435 &comment.ID, 487 436 &comment.PullId, 488 437 &comment.SubmissionId, ··· 490 439 &comment.OwnerDid, 491 440 &comment.CommentAt, 492 441 &comment.Body, 493 - &commentCreatedStr, 442 + &createdAt, 494 443 ) 495 444 if err != nil { 496 445 return nil, err 497 446 } 498 447 499 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 500 - if err != nil { 501 - return nil, err 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 502 450 } 503 - comment.Created = commentCreatedTime 504 451 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 - } 452 + comments = append(comments, comment) 525 453 } 526 454 527 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 528 - for _, submission := range submissionsMap { 529 - pull.Submissions[submission.RoundNumber] = submission 455 + if err := rows.Err(); err != nil { 456 + return nil, err 530 457 } 531 458 532 - return &pull, nil 459 + return comments, nil 533 460 } 534 461 535 462 // timeframe here is directly passed into the sql query filter, and any ··· 666 593 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 667 594 newRoundNumber := len(pull.Submissions) 668 595 _, 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) 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 672 599 673 600 return err 674 601 }
+34 -7
appview/db/reaction.go
··· 62 62 return count, nil 63 63 } 64 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 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{} 67 81 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 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 71 91 } 72 - countMap[kind] = count 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 73 99 } 74 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 75 102 } 76 103 77 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+36 -6
appview/db/repos.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 13 15 "tangled.org/core/appview/models" 14 16 ) 15 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 + 16 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 45 repoMap := make(map[syntax.ATURI]*models.Repo) 18 46 ··· 35 63 36 64 repoQuery := fmt.Sprintf( 37 65 `select 66 + id, 38 67 did, 39 68 name, 40 69 knot, ··· 63 92 var description, source, spindle sql.NullString 64 93 65 94 err := rows.Scan( 95 + &repo.Id, 66 96 &repo.Did, 67 97 &repo.Name, 68 98 &repo.Knot, ··· 327 357 var repo models.Repo 328 358 var nullableDescription sql.NullString 329 359 330 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 331 361 332 362 var createdAt string 333 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 334 364 return nil, err 335 365 } 336 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 386 416 var repos []models.Repo 387 417 388 418 rows, err := e.Query( 389 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 390 420 from repos r 391 421 left join collaborators c on r.at_uri = c.repo_at 392 422 where (r.did = ? or c.subject_did = ?) ··· 406 436 var nullableDescription sql.NullString 407 437 var nullableSource sql.NullString 408 438 409 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 410 440 if err != nil { 411 441 return nil, err 412 442 } ··· 443 473 var nullableSource sql.NullString 444 474 445 475 row := e.QueryRow( 446 - `select did, name, knot, rkey, description, created, source 476 + `select id, did, name, knot, rkey, description, created, source 447 477 from repos 448 478 where did = ? and name = ? and source is not null and source != ''`, 449 479 did, name, 450 480 ) 451 481 452 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 453 483 if err != nil { 454 484 return nil, err 455 485 }
+38 -10
appview/db/timeline.go
··· 9 9 10 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 - func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 13 var events []models.TimelineEvent 14 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 15 + var userIsFollowing []string 16 + if limitToUsersIsFollowing { 17 + following, err := GetFollowing(e, loggedInUserDid) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + userIsFollowing = make([]string, 0, len(following)) 23 + for _, follow := range following { 24 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 + } 26 + } 27 + 28 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 16 29 if err != nil { 17 30 return nil, err 18 31 } 19 32 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 21 34 if err != nil { 22 35 return nil, err 23 36 } 24 37 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 26 39 if err != nil { 27 40 return nil, err 28 41 } ··· 70 83 return isStarred, starCount 71 84 } 72 85 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 86 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 + filters := make([]filter, 0) 88 + if userIsFollowing != nil { 89 + filters = append(filters, FilterIn("did", userIsFollowing)) 90 + } 91 + 92 + repos, err := GetRepos(e, limit, filters...) 75 93 if err != nil { 76 94 return nil, err 77 95 } ··· 125 143 return events, nil 126 144 } 127 145 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 146 + func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 + filters := make([]filter, 0) 148 + if userIsFollowing != nil { 149 + filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 + } 151 + 152 + stars, err := GetStars(e, limit, filters...) 130 153 if err != nil { 131 154 return nil, err 132 155 } ··· 166 189 return events, nil 167 190 } 168 191 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 192 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 + filters := make([]filter, 0) 194 + if userIsFollowing != nil { 195 + filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 + } 197 + 198 + follows, err := GetFollows(e, limit, filters...) 171 199 if err != nil { 172 200 return nil, err 173 201 }
+1 -1
appview/ingester.go
··· 1008 1008 if !ok { 1009 1009 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 1010 } 1011 - if err := i.Validator.ValidateLabelOp(def, &o); err != nil { 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 1012 return fmt.Errorf("failed to validate labelop: %w", err) 1013 1013 } 1014 1014 }
+26 -14
appview/issues/issues.go
··· 12 12 "time" 13 13 14 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 lexutil "github.com/bluesky-social/indigo/lex/util" 17 18 "github.com/go-chi/chi/v5" ··· 26 27 "tangled.org/core/appview/pagination" 27 28 "tangled.org/core/appview/reporesolver" 28 29 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 30 "tangled.org/core/idresolver" 31 31 tlog "tangled.org/core/log" 32 32 "tangled.org/core/tid" ··· 83 83 return 84 84 } 85 85 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 86 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 87 87 if err != nil { 88 88 l.Error("failed to get issue reactions", "err", err) 89 89 } ··· 115 115 Issue: issue, 116 116 CommentList: issue.CommentList(), 117 117 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 118 + Reactions: reactionMap, 119 119 UserReacted: userReactions, 120 120 LabelDefs: defs, 121 121 }) ··· 166 166 return 167 167 } 168 168 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 169 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 170 if err != nil { 171 171 l.Error("failed to get record", "err", err) 172 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 173 return 174 174 } 175 175 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 176 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 177 Collection: tangled.RepoIssueNSID, 178 178 Repo: user.Did, 179 179 Rkey: newIssue.Rkey, ··· 241 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 242 return 243 243 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 244 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 245 Collection: tangled.RepoIssueNSID, 246 246 Repo: issue.Did, 247 247 Rkey: issue.Rkey, ··· 300 300 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 301 return 302 302 } 303 + 304 + // notify about the issue closure 305 + rp.notifier.NewIssueClosed(r.Context(), issue) 303 306 304 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 305 308 return ··· 405 408 } 406 409 407 410 // create a record first 408 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 411 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 409 412 Collection: tangled.RepoIssueCommentNSID, 410 413 Repo: comment.Did, 411 414 Rkey: comment.Rkey, ··· 434 437 435 438 // reset atUri to make rollback a no-op 436 439 atUri = "" 440 + 441 + // notify about the new comment 442 + comment.Id = commentId 443 + rp.notifier.NewIssueComment(r.Context(), &comment) 444 + 437 445 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 438 446 } 439 447 ··· 551 559 // rkey is optional, it was introduced later 552 560 if newComment.Rkey != "" { 553 561 // update the record on pds 554 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 562 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 555 563 if err != nil { 556 564 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 557 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 558 566 return 559 567 } 560 568 561 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 569 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 562 570 Collection: tangled.RepoIssueCommentNSID, 563 571 Repo: user.Did, 564 572 Rkey: newComment.Rkey, ··· 725 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 726 734 return 727 735 } 728 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 736 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 729 737 Collection: tangled.RepoIssueCommentNSID, 730 738 Repo: user.Did, 731 739 Rkey: comment.Rkey, ··· 790 798 return 791 799 } 792 800 793 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 794 806 if err != nil { 795 807 log.Println("failed to fetch labels", err) 796 808 rp.pages.Error503(w) ··· 853 865 rp.pages.Notice(w, "issues", "Failed to create issue.") 854 866 return 855 867 } 856 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 868 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 857 869 Collection: tangled.RepoIssueNSID, 858 870 Repo: user.Did, 859 871 Rkey: issue.Rkey, ··· 911 923 // this is used to rollback changes made to the PDS 912 924 // 913 925 // it is a no-op if the provided ATURI is empty 914 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 926 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 915 927 if aturi == "" { 916 928 return nil 917 929 } ··· 922 934 repo := parsed.Authority().String() 923 935 rkey := parsed.RecordKey().String() 924 936 925 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 937 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 926 938 Collection: collection, 927 939 Repo: repo, 928 940 Rkey: rkey,
+6 -6
appview/knots/knots.go
··· 185 185 return 186 186 } 187 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 189 var exCid *string 190 190 if ex != nil { 191 191 exCid = ex.Cid 192 192 } 193 193 194 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 196 Collection: tangled.KnotNSID, 197 197 Repo: user.Did, 198 198 Rkey: domain, ··· 323 323 return 324 324 } 325 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 327 Collection: tangled.KnotNSID, 328 328 Repo: user.Did, 329 329 Rkey: domain, ··· 431 431 return 432 432 } 433 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 435 var exCid *string 436 436 if ex != nil { 437 437 exCid = ex.Cid 438 438 } 439 439 440 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 442 Collection: tangled.KnotNSID, 443 443 Repo: user.Did, 444 444 Rkey: domain, ··· 555 555 556 556 rkey := tid.TID() 557 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 559 Collection: tangled.KnotMemberNSID, 560 560 Repo: user.Did, 561 561 Rkey: rkey,
+23 -13
appview/labels/labels.go
··· 9 9 "net/http" 10 10 "time" 11 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 12 "tangled.org/core/api/tangled" 18 13 "tangled.org/core/appview/db" 19 14 "tangled.org/core/appview/middleware" ··· 21 16 "tangled.org/core/appview/oauth" 22 17 "tangled.org/core/appview/pages" 23 18 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 19 "tangled.org/core/log" 20 + "tangled.org/core/rbac" 26 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" 27 28 ) 28 29 29 30 type Labels struct { ··· 32 33 db *db.DB 33 34 logger *slog.Logger 34 35 validator *validator.Validator 36 + enforcer *rbac.Enforcer 35 37 } 36 38 37 39 func New( ··· 39 41 pages *pages.Pages, 40 42 db *db.DB, 41 43 validator *validator.Validator, 44 + enforcer *rbac.Enforcer, 42 45 ) *Labels { 43 46 logger := log.New("labels") 44 47 ··· 48 51 db: db, 49 52 logger: logger, 50 53 validator: validator, 54 + enforcer: enforcer, 51 55 } 52 56 } 53 57 ··· 86 90 repoAt := r.Form.Get("repo") 87 91 subjectUri := r.Form.Get("subject") 88 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 + 89 99 // find all the labels that this repo subscribes to 90 100 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 91 101 if err != nil { ··· 152 162 } 153 163 } 154 164 155 - // reduce the opset 156 - labelOps = models.ReduceLabelOps(labelOps) 157 - 158 165 for i := range labelOps { 159 166 def := actx.Defs[labelOps[i].OperandKey] 160 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 167 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 161 168 fail(fmt.Sprintf("Invalid form data: %s", err), err) 162 169 return 163 170 } 164 171 } 165 172 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 175 + 166 176 // next, apply all ops introduced in this request and filter out ones that are no-ops 167 177 validLabelOps := labelOps[:0] 168 178 for _, op := range labelOps { ··· 186 196 return 187 197 } 188 198 189 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 190 200 Collection: tangled.LabelOpNSID, 191 201 Repo: did, 192 202 Rkey: rkey, ··· 242 252 // this is used to rollback changes made to the PDS 243 253 // 244 254 // it is a no-op if the provided ATURI is empty 245 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 246 256 if aturi == "" { 247 257 return nil 248 258 } ··· 253 263 repo := parsed.Authority().String() 254 264 rkey := parsed.RecordKey().String() 255 265 256 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 257 267 Collection: collection, 258 268 Repo: repo, 259 269 Rkey: rkey,
+5 -5
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 49 returnURL := "/" ··· 63 63 } 64 64 } 65 65 66 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 67 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 69 redirectFunc(w, r) 70 70 return 71 71 } 72 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 75 redirectFunc(w, r) 76 76 return 77 77 }
+19 -17
appview/models/label.go
··· 232 232 } 233 233 234 234 var ops []LabelOp 235 - for _, o := range record.Add { 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 236 237 if o != nil { 237 238 op := mkOp(o) 238 - op.Operation = LabelOperationAdd 239 + op.Operation = LabelOperationDel 239 240 ops = append(ops, op) 240 241 } 241 242 } 242 - for _, o := range record.Delete { 243 + for _, o := range record.Add { 243 244 if o != nil { 244 245 op := mkOp(o) 245 - op.Operation = LabelOperationDel 246 + op.Operation = LabelOperationAdd 246 247 ops = append(ops, op) 247 248 } 248 249 } ··· 460 461 return result 461 462 } 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 + 463 472 func DefaultLabelDefs() []string { 464 - rkeys := []string{ 465 - "wontfix", 466 - "duplicate", 467 - "assignee", 468 - "good-first-issue", 469 - "documentation", 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 470 479 } 471 - 472 - defs := make([]string, len(rkeys)) 473 - for i, r := range rkeys { 474 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 475 - } 476 - 477 - return defs 478 480 } 479 481 480 482 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+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 77 PullSource *PullSource 78 78 79 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 80 + Labels LabelState 81 + Repo *Repo 81 82 } 82 83 83 84 func (p Pull) AsRecord() tangled.RepoPull { ··· 125 126 126 127 type PullSubmission struct { 127 128 // ids 128 - ID int 129 - PullId int 129 + ID int 130 130 131 131 // at ids 132 - RepoAt syntax.ATURI 132 + PullAt syntax.ATURI 133 133 134 134 // content 135 135 RoundNumber int ··· 207 207 return p.StackId != "" 208 208 } 209 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 + 210 232 func (s PullSubmission) IsFormatPatch() bool { 211 233 return patchutil.IsFormatPatch(s.Patch) 212 234 } ··· 219 241 } 220 242 221 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 222 264 } 223 265 224 266 type Stack []*Pull
+5
appview/models/reaction.go
··· 55 55 Rkey string 56 56 Kind ReactionKind 57 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+6
appview/models/repo.go
··· 10 10 ) 11 11 12 12 type Repo struct { 13 + Id int64 13 14 Did string 14 15 Name string 15 16 Knot string ··· 85 86 RepoAt syntax.ATURI 86 87 LabelAt syntax.ATURI 87 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 38 notifier.NewIssue(ctx, issue) 39 39 } 40 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 + } 41 52 42 53 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 54 for _, notifier := range m.notifiers { ··· 58 69 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 70 for _, notifier := range m.notifiers { 60 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) 61 84 } 62 85 } 63 86
+9 -1
appview/notify/notifier.go
··· 13 13 DeleteStar(ctx context.Context, star *models.Star) 14 14 15 15 NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 16 18 17 19 NewFollow(ctx context.Context, follow *models.Follow) 18 20 DeleteFollow(ctx context.Context, follow *models.Follow) 19 21 20 22 NewPull(ctx context.Context, pull *models.Pull) 21 23 NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 22 26 23 27 UpdateProfile(ctx context.Context, profile *models.Profile) 24 28 ··· 37 41 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 38 42 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 39 43 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 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) {} 41 47 42 48 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 43 49 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 44 50 45 51 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 46 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) {} 47 55 48 56 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 49 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 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 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 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 6 "net/http" 7 - "net/url" 8 7 "time" 9 8 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 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" 11 14 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 13 16 "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 17 ) 19 18 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 19 + func New(config *config.Config) (*OAuth, error) { 20 + 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 25 23 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, 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"}) 31 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 32 50 } 33 51 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 36 57 } 37 58 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 60 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 61 + userSession, err := o.SessStore.Get(r, SessionName) 41 62 if err != nil { 42 63 return err 43 64 } 44 65 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 48 69 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 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) 50 75 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 76 + return nil, fmt.Errorf("error getting user session: %w", err) 52 77 } 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), 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 65 80 } 66 81 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) 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) 74 86 } 75 87 76 - did := userSession.Values[SessionDid].(string) 88 + sessId := userSession.Values[SessionId].(string) 77 89 78 - err = o.sess.DeleteSession(r.Context(), did) 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 91 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 93 } 82 94 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 95 + return clientSess, nil 86 96 } 87 97 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) 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) 92 102 } 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) 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 100 105 } 101 106 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 103 109 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 111 } 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 112 - self := o.ClientMetadata() 113 + sessId := userSession.Values[SessionId].(string) 113 114 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 119 117 120 - if err != nil { 121 - return nil, false, err 122 - } 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 123 121 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 122 + return errors.Join(err1, err2) 123 + } 128 124 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 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 140 133 } 141 - 142 - return session, auth, nil 134 + return pubKey, nil 143 135 } 144 136 145 137 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 138 + Did string 139 + Pds string 149 140 } 150 141 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 153 144 154 - if err != nil || clientSession.IsNew { 145 + if err != nil || sess.IsNew { 155 146 return nil 156 147 } 157 148 158 149 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 162 152 } 163 153 } 164 154 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 "" 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 170 158 } 171 159 172 - return clientSession.Values[SessionDid].(string) 160 + return "" 173 161 } 174 162 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 177 165 if err != nil { 178 166 return nil, fmt.Errorf("error getting session: %w", err) 179 167 } 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 168 + return session.APIClient(), nil 208 169 } 209 170 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 171 // this is a higher level abstraction on ServerGetServiceAuth 213 172 type ServiceClientOpts struct { 214 173 service string ··· 259 218 return scheme + s.service 260 219 } 261 220 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 222 opts := ServiceClientOpts{} 264 223 for _, o := range os { 265 224 o(&opts) 266 225 } 267 226 268 - authorizedClient, err := o.AuthorizedClient(r) 227 + client, err := o.AuthorizedClient(r) 269 228 if err != nil { 270 229 return nil, err 271 230 } ··· 276 235 opts.exp = sixty 277 236 } 278 237 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 239 if err != nil { 281 240 return nil, err 282 241 } 283 242 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 286 245 AccessJwt: resp.Token, 287 246 }, 288 247 Host: opts.Host(), ··· 291 250 }, 292 251 }, nil 293 252 } 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 - }
+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 1 package markup 2 2 3 - import "strings" 3 + import ( 4 + "regexp" 5 + ) 4 6 5 7 type Format string 6 8 ··· 10 12 ) 11 13 12 14 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 16 } 15 17 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", 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 26 } 27 27 28 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 - } 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 34 32 } 35 33 } 36 34 // default format
+101 -18
appview/pages/pages.go
··· 38 38 "github.com/go-git/go-git/v5/plumbing/object" 39 39 ) 40 40 41 - //go:embed templates/* static 41 + //go:embed templates/* static legal 42 42 var Files embed.FS 43 43 44 44 type Pages struct { ··· 226 226 return p.executePlain("user/login", w, params) 227 227 } 228 228 229 - func (p *Pages) Signup(w io.Writer) error { 230 - return p.executePlain("user/signup", w, nil) 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) 231 235 } 232 236 233 237 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 242 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 243 247 filename := "terms.md" 244 248 filePath := filepath.Join("legal", filename) 245 - markdownBytes, err := os.ReadFile(filePath) 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) 246 257 if err != nil { 247 258 return fmt.Errorf("failed to read %s: %w", filename, err) 248 259 } ··· 263 274 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 264 275 filename := "privacy.md" 265 276 filePath := filepath.Join("legal", filename) 266 - markdownBytes, err := os.ReadFile(filePath) 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) 267 285 if err != nil { 268 286 return fmt.Errorf("failed to read %s: %w", filename, err) 269 287 } ··· 276 294 return p.execute("legal/privacy", w, params) 277 295 } 278 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 + 279 305 type TimelineParams struct { 280 306 LoggedInUser *oauth.User 281 307 Timeline []models.TimelineEvent 282 308 Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 283 310 } 284 311 285 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 286 313 return p.execute("timeline/timeline", w, params) 287 314 } 288 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 + 289 329 type UserProfileSettingsParams struct { 290 330 LoggedInUser *oauth.User 291 331 Tabs []map[string]any ··· 296 336 return p.execute("user/settings/profile", w, params) 297 337 } 298 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 + 299 367 type UserKeysSettingsParams struct { 300 368 LoggedInUser *oauth.User 301 369 PubKeys []models.PublicKey ··· 316 384 317 385 func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 318 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) 319 398 } 320 399 321 400 type UpgradeBannerParams struct { ··· 655 734 } 656 735 657 736 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 737 + LoggedInUser *oauth.User 738 + RepoInfo repoinfo.RepoInfo 739 + Active string 740 + BreadCrumbs [][]string 741 + TreePath string 742 + Raw bool 743 + HTMLReadme template.HTML 667 744 types.RepoTreeResponse 668 745 } 669 746 ··· 691 768 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 692 769 params.Active = "overview" 693 770 694 - if params.ReadmeFileName != "" { 695 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 771 + p.rctx.RepoInfo = params.RepoInfo 772 + p.rctx.RepoInfo.Ref = params.Ref 773 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 696 774 775 + if params.ReadmeFileName != "" { 697 776 ext := filepath.Ext(params.ReadmeFileName) 698 777 switch ext { 699 778 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 906 985 LabelDefs map[string]*models.LabelDefinition 907 986 908 987 OrderedReactionKinds []models.ReactionKind 909 - Reactions map[models.ReactionKind]int 988 + Reactions map[models.ReactionKind]models.ReactionDisplayData 910 989 UserReacted map[models.ReactionKind]bool 911 990 } 912 991 ··· 931 1010 ThreadAt syntax.ATURI 932 1011 Kind models.ReactionKind 933 1012 Count int 1013 + Users []string 934 1014 IsReacted bool 935 1015 } 936 1016 ··· 1021 1101 FilteringBy models.PullState 1022 1102 Stacks map[string]models.Stack 1023 1103 Pipelines map[string]models.Pipeline 1104 + LabelDefs map[string]*models.LabelDefinition 1024 1105 } 1025 1106 1026 1107 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1058 1139 Pipelines map[string]models.Pipeline 1059 1140 1060 1141 OrderedReactionKinds []models.ReactionKind 1061 - Reactions map[models.ReactionKind]int 1142 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1062 1143 UserReacted map[models.ReactionKind]bool 1144 + 1145 + LabelDefs map[string]*models.LabelDefinition 1063 1146 } 1064 1147 1065 1148 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 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 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" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 11 ··· 14 14 500 &mdash; internal server error 15 15 </h1> 16 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> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 20 <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 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 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 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"> 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 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </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);"> 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"> 26 28 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 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;"> 28 30 29 31 {{ if .LoggedInUser }} 30 32 <div id="upgrade-banner" ··· 38 40 {{ end }} 39 41 40 42 {{ 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"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 44 47 {{ block "content" . }}{{ end }} 45 48 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 50 53 {{ block "contentAfter" . }}{{ end }} 51 54 </main> 52 - {{ end }} 55 + {{ end }} 56 + </div> 53 57 </div> 54 58 {{ end }} 55 59 56 60 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 58 62 {{ template "layouts/fragments/footer" . }} 59 63 </footer> 60 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 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> 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> 10 13 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> 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> 19 46 </div> 20 47 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> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 26 51 </div> 52 + </div> 27 53 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> 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> 33 64 </div> 34 65 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> 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> 39 93 </div> 40 - </div> 41 94 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> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 44 98 </div> 45 99 </div> 46 100 </div>
+18 -8
appview/pages/templates/layouts/fragments/topbar.html
··· 1 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"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 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> 7 11 </a> 8 12 </div> 9 13 10 - <div id="right-items" class="flex items-center gap-2"> 14 + <div id="right-items" class="flex items-center gap-4"> 11 15 {{ with .LoggedInUser }} 12 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 13 18 {{ block "dropDown" . }} {{ end }} 14 19 {{ else }} 15 20 <a href="/login">login</a> ··· 26 31 {{ define "newButton" }} 27 32 <details class="relative inline-block text-left nav-dropdown"> 28 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 30 35 </summary> 31 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"> 32 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 49 {{ define "dropDown" }} 45 50 <details class="relative inline-block text-left nav-dropdown"> 46 51 <summary 47 - class="cursor-pointer list-none flex items-center" 52 + class="cursor-pointer list-none flex items-center gap-1" 48 53 > 49 - {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 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> 51 61 </summary> 52 62 <div 53 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 1 {{ define "title" }}privacy policy{{ end }} 2 2 3 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> 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 }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 1 {{ define "title" }}terms of service{{ end }} 2 2 3 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> 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 }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 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 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 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 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 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 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 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 }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 1 {{ define "repo/fragments/readme" }} 2 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 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"> 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 7 </div> 8 8 {{- end -}} 9 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+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 }}
+55
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 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 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 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 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+5 -29
appview/pages/templates/repo/issues/issue.html
··· 22 22 "Defs" $.LabelDefs 23 23 "Subject" $.Issue.AtUri 24 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 26 </div> 27 27 </div> 28 28 {{ end }} ··· 110 110 <div class="flex items-center gap-2"> 111 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 113 114 {{ 114 115 template "repo/fragments/reaction" 115 116 (dict 116 117 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 + "Count" $reactionData.Count 118 119 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 120 122 }} 121 123 {{ end }} 122 124 </div> 123 125 {{ end }} 124 126 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 127 152 128 {{ define "repoAfter" }} 153 129 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 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 }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 42 </div> 93 43 {{ block "pagination" . }} {{ end }} 94 44 {{ end }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 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" . }} 6 13 </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> 14 + {{ end }} 19 15 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 - /> 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 }} 29 21 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 - /> 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> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 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 + 41 52 <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> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </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> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 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> 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 }} 71 64 </div> 72 - </form> 73 - </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> 74 176 {{ end }}
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 66 <div class="flex items-center gap-2 mt-2"> 67 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 69 70 {{ 70 71 template "repo/fragments/reaction" 71 72 (dict 72 73 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 + "Count" $reactionData.Count 74 75 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 76 78 }} 77 79 {{ end }} 78 80 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+37 -15
appview/pages/templates/repo/pulls/pull.html
··· 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 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 }} 12 30 13 31 {{ define "repoContent" }} 14 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 57 {{ with $item }} 40 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 43 61 <!-- round number --> 44 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 64 </div> 47 65 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 67 <span class="gap-1 flex items-center"> 50 68 {{ $owner := resolve $.Pull.OwnerDid }} 51 69 {{ $re := "re" }} ··· 72 90 <span class="hidden md:inline">diff</span> 73 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 92 </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> 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> 84 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 85 103 </div> 86 104 </summary> 87 105 ··· 146 164 147 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 166 {{ 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"> 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 168 {{ if gt $cidx 0 }} 151 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 170 {{ end }} ··· 171 189 {{ if $.LoggedInUser }} 172 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 173 191 {{ 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 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 177 199 </div> 178 200 {{ end }} 179 201 </div>
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 108 <span class="before:content-['·']"></span> 109 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 111 118 </div> 112 119 </div> 113 120 {{ if .StackId }}
+1 -1
appview/pages/templates/repo/tree.html
··· 91 91 92 92 {{ define "repoAfter" }} 93 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 94 + {{ template "repo/fragments/readme" . }} 95 95 {{- end -}} 96 96 {{ end }}
+2 -2
appview/pages/templates/strings/put.html
··· 3 3 {{ define "content" }} 4 4 <div class="px-6 py-2 mb-4"> 5 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> 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 8 {{ else }} 9 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 10 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 26 {{ end }} 27 27 28 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 29 30 <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> 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> 32 35 </div> 33 36 {{ with .Description }} 34 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 45 43 46 {{ define "stringCardInfo" }} 44 47 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 48 <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 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 50 <span class="select-none [&:before]:content-['·']"></span> 53 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 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 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"> 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 6 /> 7 - {{ . | truncateAt30 }} 7 + {{ . | resolve | truncateAt30 }} 8 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 5 4 </a> 6 5 {{ end }}
+2 -1
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> ··· 36 37 placeholder="akshay.tngl.sh" 37 38 /> 38 39 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 40 41 handle to log in. If you're unsure, this is likely 41 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 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 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+7 -1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> ··· 39 42 invite code, desired username, and password in the next 40 43 page to complete your registration. 41 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 42 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 49 <span>join now</span> 44 50 </button> 45 51 </form> 46 52 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 54 </p> 49 55 50 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
+2 -1
appview/pipelines/pipelines.go
··· 48 48 ) *Pipelines { 49 49 logger := log.New("pipelines") 50 50 51 - return &Pipelines{oauth: oauth, 51 + return &Pipelines{ 52 + oauth: oauth, 52 53 repoResolver: repoResolver, 53 54 pages: pages, 54 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 - }
+59 -11
appview/pulls/pulls.go
··· 189 189 m[p.Sha] = p 190 190 } 191 191 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 192 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 193 193 if err != nil { 194 194 log.Println("failed to get pull reactions") 195 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 200 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 201 } 202 202 203 + labelDefs, err := db.GetLabelDefinitions( 204 + s.db, 205 + db.FilterIn("at_uri", f.Repo.Labels), 206 + db.FilterContains("scope", tangled.RepoPullNSID), 207 + ) 208 + if err != nil { 209 + log.Println("failed to fetch labels", err) 210 + s.pages.Error503(w) 211 + return 212 + } 213 + 214 + defs := make(map[string]*models.LabelDefinition) 215 + for _, l := range labelDefs { 216 + defs[l.AtUri().String()] = &l 217 + } 218 + 203 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 204 220 LoggedInUser: user, 205 221 RepoInfo: repoInfo, ··· 211 227 Pipelines: m, 212 228 213 229 OrderedReactionKinds: models.OrderedReactionKinds, 214 - Reactions: reactionCountMap, 230 + Reactions: reactionMap, 215 231 UserReacted: userReactions, 232 + 233 + LabelDefs: defs, 216 234 }) 217 235 } 218 236 ··· 557 575 m[p.Sha] = p 558 576 } 559 577 578 + labelDefs, err := db.GetLabelDefinitions( 579 + s.db, 580 + db.FilterIn("at_uri", f.Repo.Labels), 581 + db.FilterContains("scope", tangled.RepoPullNSID), 582 + ) 583 + if err != nil { 584 + log.Println("failed to fetch labels", err) 585 + s.pages.Error503(w) 586 + return 587 + } 588 + 589 + defs := make(map[string]*models.LabelDefinition) 590 + for _, l := range labelDefs { 591 + defs[l.AtUri().String()] = &l 592 + } 593 + 560 594 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 595 LoggedInUser: s.oauth.GetUser(r), 562 596 RepoInfo: f.RepoInfo(user), 563 597 Pulls: pulls, 598 + LabelDefs: defs, 564 599 FilteringBy: state, 565 600 Stacks: stacks, 566 601 Pipelines: m, ··· 630 665 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 631 666 return 632 667 } 633 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 668 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 634 669 Collection: tangled.RepoPullCommentNSID, 635 670 Repo: user.Did, 636 671 Rkey: tid.TID(), ··· 1058 1093 1059 1094 // We've already checked earlier if it's diff-based and title is empty, 1060 1095 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1096 + if title == "" || body == "" { 1062 1097 formatPatches, err := patchutil.ExtractPatches(patch) 1063 1098 if err != nil { 1064 1099 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 1104 return 1070 1105 } 1071 1106 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1107 + if title == "" { 1108 + title = formatPatches[0].Title 1109 + } 1110 + if body == "" { 1111 + body = formatPatches[0].Body 1112 + } 1074 1113 } 1075 1114 1076 1115 rkey := tid.TID() ··· 1103 1142 return 1104 1143 } 1105 1144 1106 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1145 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1107 1146 Collection: tangled.RepoPullNSID, 1108 1147 Repo: user.Did, 1109 1148 Rkey: rkey, ··· 1200 1239 } 1201 1240 writes = append(writes, &write) 1202 1241 } 1203 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1242 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1204 1243 Repo: user.Did, 1205 1244 Writes: writes, 1206 1245 }) ··· 1731 1770 return 1732 1771 } 1733 1772 1734 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1773 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1735 1774 if err != nil { 1736 1775 // failed to get record 1737 1776 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1754 1793 } 1755 1794 } 1756 1795 1757 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1796 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1758 1797 Collection: tangled.RepoPullNSID, 1759 1798 Repo: user.Did, 1760 1799 Rkey: pull.Rkey, ··· 2026 2065 return 2027 2066 } 2028 2067 2029 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2068 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2030 2069 Repo: user.Did, 2031 2070 Writes: writes, 2032 2071 }) ··· 2147 2186 return 2148 2187 } 2149 2188 2189 + // notify about the pull merge 2190 + for _, p := range pullsToMerge { 2191 + s.notifier.NewPullMerged(r.Context(), p) 2192 + } 2193 + 2150 2194 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2151 2195 } 2152 2196 ··· 2212 2256 log.Println("failed to commit transaction", err) 2213 2257 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2214 2258 return 2259 + } 2260 + 2261 + for _, p := range pullsToClose { 2262 + s.notifier.NewPullClosed(r.Context(), p) 2215 2263 } 2216 2264 2217 2265 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+11 -10
appview/repo/artifact.go
··· 10 10 "net/url" 11 11 "time" 12 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 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview/db" 22 15 "tangled.org/core/appview/models" ··· 25 18 "tangled.org/core/appview/xrpcclient" 26 19 "tangled.org/core/tid" 27 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" 28 29 ) 29 30 30 31 // TODO: proper statuses here on early exit ··· 60 61 return 61 62 } 62 63 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 64 65 if err != nil { 65 66 log.Println("failed to upload blob", err) 66 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 73 rkey := tid.TID() 73 74 createdAt := time.Now() 74 75 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 76 77 Collection: tangled.RepoArtifactNSID, 77 78 Repo: user.Did, 78 79 Rkey: rkey, ··· 249 250 return 250 251 } 251 252 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 253 254 Collection: tangled.RepoArtifactNSID, 254 255 Repo: user.Did, 255 256 Rkey: artifact.Rkey,
+17 -22
appview/repo/index.go
··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/pages/markup" 26 25 "tangled.org/core/appview/reporesolver" 27 26 "tangled.org/core/appview/xrpcclient" 28 27 "tangled.org/core/types" ··· 201 200 }) 202 201 } 203 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 204 209 // update appview's cache 205 - err = db.InsertRepoLanguages(rp.db, langs) 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 206 211 if err != nil { 207 212 // non-fatal 208 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 209 219 } 210 220 } 211 221 ··· 328 338 } 329 339 }() 330 340 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 341 wg.Wait() 352 342 353 343 if errs != nil { ··· 374 364 } 375 365 files = append(files, niceFile) 376 366 } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 377 372 } 378 373 379 374 result := &types.RepoIndexResponse{
+44 -67
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 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 20 "tangled.org/core/api/tangled" 24 21 "tangled.org/core/appview/commitverify" 25 22 "tangled.org/core/appview/config" ··· 40 37 "tangled.org/core/types" 41 38 "tangled.org/core/xrpc/serviceauth" 42 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" 43 45 securejoin "github.com/cyphar/filepath-securejoin" 44 46 "github.com/go-chi/chi/v5" 45 47 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 48 ) 49 49 50 50 type Repo struct { ··· 307 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 308 // 309 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) 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 311 if err != nil { 312 312 // failed to get record 313 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 314 return 315 315 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 317 Collection: tangled.RepoNSID, 318 318 Repo: newRepo.Did, 319 319 Rkey: newRepo.Rkey, ··· 449 449 return 450 450 } 451 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 452 // Convert XRPC response to internal types.RepoTreeResponse 475 453 files := make([]types.NiceTree, len(xrpcResp.Files)) 476 454 for i, xrpcFile := range xrpcResp.Files { ··· 506 484 if xrpcResp.Dotdot != nil { 507 485 result.DotDot = *xrpcResp.Dotdot 508 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 509 491 510 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 511 493 // so we can safely redirect to the "parent" (which is the same file). ··· 532 514 BreadCrumbs: breadcrumbs, 533 515 TreePath: treePath, 534 516 RepoInfo: f.RepoInfo(user), 535 - Readme: readmeContent, 536 - ReadmeFileName: readmeFileName, 537 517 RepoTreeResponse: result, 538 518 }) 539 519 } ··· 883 863 user := rp.oauth.GetUser(r) 884 864 l := rp.logger.With("handler", "EditSpindle") 885 865 l = l.With("did", user.Did) 886 - l = l.With("handle", user.Handle) 887 866 888 867 errorId := "operation-error" 889 868 fail := func(msg string, err error) { ··· 936 915 return 937 916 } 938 917 939 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 918 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 940 919 if err != nil { 941 920 fail("Failed to update spindle, no record found on PDS.", err) 942 921 return 943 922 } 944 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 923 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 945 924 Collection: tangled.RepoNSID, 946 925 Repo: newRepo.Did, 947 926 Rkey: newRepo.Rkey, ··· 971 950 user := rp.oauth.GetUser(r) 972 951 l := rp.logger.With("handler", "AddLabel") 973 952 l = l.With("did", user.Did) 974 - l = l.With("handle", user.Handle) 975 953 976 954 f, err := rp.repoResolver.Resolve(r) 977 955 if err != nil { ··· 1040 1018 1041 1019 // emit a labelRecord 1042 1020 labelRecord := label.AsRecord() 1043 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1021 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1044 1022 Collection: tangled.LabelDefinitionNSID, 1045 1023 Repo: label.Did, 1046 1024 Rkey: label.Rkey, ··· 1063 1041 newRepo.Labels = append(newRepo.Labels, aturi) 1064 1042 repoRecord := newRepo.AsRecord() 1065 1043 1066 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1044 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1067 1045 if err != nil { 1068 1046 fail("Failed to update labels, no record found on PDS.", err) 1069 1047 return 1070 1048 } 1071 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1049 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1072 1050 Collection: tangled.RepoNSID, 1073 1051 Repo: newRepo.Did, 1074 1052 Rkey: newRepo.Rkey, ··· 1131 1109 user := rp.oauth.GetUser(r) 1132 1110 l := rp.logger.With("handler", "DeleteLabel") 1133 1111 l = l.With("did", user.Did) 1134 - l = l.With("handle", user.Handle) 1135 1112 1136 1113 f, err := rp.repoResolver.Resolve(r) 1137 1114 if err != nil { ··· 1161 1138 } 1162 1139 1163 1140 // delete label record from PDS 1164 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1141 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1165 1142 Collection: tangled.LabelDefinitionNSID, 1166 1143 Repo: label.Did, 1167 1144 Rkey: label.Rkey, ··· 1183 1160 newRepo.Labels = updated 1184 1161 repoRecord := newRepo.AsRecord() 1185 1162 1186 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1163 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1187 1164 if err != nil { 1188 1165 fail("Failed to update labels, no record found on PDS.", err) 1189 1166 return 1190 1167 } 1191 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1168 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1192 1169 Collection: tangled.RepoNSID, 1193 1170 Repo: newRepo.Did, 1194 1171 Rkey: newRepo.Rkey, ··· 1240 1217 user := rp.oauth.GetUser(r) 1241 1218 l := rp.logger.With("handler", "SubscribeLabel") 1242 1219 l = l.With("did", user.Did) 1243 - l = l.With("handle", user.Handle) 1244 1220 1245 1221 f, err := rp.repoResolver.Resolve(r) 1246 1222 if err != nil { ··· 1281 1257 return 1282 1258 } 1283 1259 1284 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1260 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1285 1261 if err != nil { 1286 1262 fail("Failed to update labels, no record found on PDS.", err) 1287 1263 return 1288 1264 } 1289 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1265 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1290 1266 Collection: tangled.RepoNSID, 1291 1267 Repo: newRepo.Did, 1292 1268 Rkey: newRepo.Rkey, ··· 1327 1303 user := rp.oauth.GetUser(r) 1328 1304 l := rp.logger.With("handler", "UnsubscribeLabel") 1329 1305 l = l.With("did", user.Did) 1330 - l = l.With("handle", user.Handle) 1331 1306 1332 1307 f, err := rp.repoResolver.Resolve(r) 1333 1308 if err != nil { ··· 1370 1345 return 1371 1346 } 1372 1347 1373 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1348 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1374 1349 if err != nil { 1375 1350 fail("Failed to update labels, no record found on PDS.", err) 1376 1351 return 1377 1352 } 1378 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1353 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1379 1354 Collection: tangled.RepoNSID, 1380 1355 Repo: newRepo.Did, 1381 1356 Rkey: newRepo.Rkey, ··· 1499 1474 user := rp.oauth.GetUser(r) 1500 1475 l := rp.logger.With("handler", "AddCollaborator") 1501 1476 l = l.With("did", user.Did) 1502 - l = l.With("handle", user.Handle) 1503 1477 1504 1478 f, err := rp.repoResolver.Resolve(r) 1505 1479 if err != nil { ··· 1546 1520 currentUser := rp.oauth.GetUser(r) 1547 1521 rkey := tid.TID() 1548 1522 createdAt := time.Now() 1549 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1523 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1550 1524 Collection: tangled.RepoCollaboratorNSID, 1551 1525 Repo: currentUser.Did, 1552 1526 Rkey: rkey, ··· 1637 1611 } 1638 1612 1639 1613 // remove record from pds 1640 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1614 + atpClient, err := rp.oauth.AuthorizedClient(r) 1641 1615 if err != nil { 1642 1616 log.Println("failed to get authorized client", err) 1643 1617 return 1644 1618 } 1645 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1619 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1646 1620 Collection: tangled.RepoNSID, 1647 1621 Repo: user.Did, 1648 1622 Rkey: f.Rkey, ··· 1784 1758 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1785 1759 user := rp.oauth.GetUser(r) 1786 1760 l := rp.logger.With("handler", "Secrets") 1787 - l = l.With("handle", user.Handle) 1788 1761 l = l.With("did", user.Did) 1789 1762 1790 1763 f, err := rp.repoResolver.Resolve(r) ··· 2149 2122 } 2150 2123 2151 2124 // choose a name for a fork 2152 - forkName := f.Name 2125 + forkName := r.FormValue("repo_name") 2126 + if forkName == "" { 2127 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2128 + return 2129 + } 2130 + 2153 2131 // this check is *only* to see if the forked repo name already exists 2154 2132 // in the user's account. 2155 2133 existingRepo, err := db.GetRepo( 2156 2134 rp.db, 2157 2135 db.FilterEq("did", user.Did), 2158 - db.FilterEq("name", f.Name), 2136 + db.FilterEq("name", forkName), 2159 2137 ) 2160 2138 if err != nil { 2161 - if errors.Is(err, sql.ErrNoRows) { 2162 - // no existing repo with this name found, we can use the name as is 2163 - } else { 2139 + if !errors.Is(err, sql.ErrNoRows) { 2164 2140 log.Println("error fetching existing repo from db", "err", err) 2165 2141 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2166 2142 return 2167 2143 } 2168 2144 } else if existingRepo != nil { 2169 - // repo with this name already exists, append random string 2170 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2145 + // repo with this name already exists 2146 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2147 + return 2171 2148 } 2172 2149 l = l.With("forkName", forkName) 2173 2150 ··· 2189 2166 Knot: targetKnot, 2190 2167 Rkey: rkey, 2191 2168 Source: sourceAt, 2192 - Description: existingRepo.Description, 2169 + Description: f.Repo.Description, 2193 2170 Created: time.Now(), 2194 2171 Labels: models.DefaultLabelDefs(), 2195 2172 } 2196 2173 record := repo.AsRecord() 2197 2174 2198 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2175 + atpClient, err := rp.oauth.AuthorizedClient(r) 2199 2176 if err != nil { 2200 2177 l.Error("failed to create xrpcclient", "err", err) 2201 2178 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2202 2179 return 2203 2180 } 2204 2181 2205 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2182 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2206 2183 Collection: tangled.RepoNSID, 2207 2184 Repo: user.Did, 2208 2185 Rkey: rkey, ··· 2234 2211 rollback := func() { 2235 2212 err1 := tx.Rollback() 2236 2213 err2 := rp.enforcer.E.LoadPolicy() 2237 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2214 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2238 2215 2239 2216 // ignore txn complete errors, this is okay 2240 2217 if errors.Is(err1, sql.ErrTxDone) { ··· 2307 2284 aturi = "" 2308 2285 2309 2286 rp.notifier.NewRepo(r.Context(), repo) 2310 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2287 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2311 2288 } 2312 2289 } 2313 2290 2314 2291 // this is used to rollback changes made to the PDS 2315 2292 // 2316 2293 // it is a no-op if the provided ATURI is empty 2317 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2294 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2318 2295 if aturi == "" { 2319 2296 return nil 2320 2297 } ··· 2325 2302 repo := parsed.Authority().String() 2326 2303 rkey := parsed.RecordKey().String() 2327 2304 2328 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2305 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2329 2306 Collection: collection, 2330 2307 Repo: repo, 2331 2308 Rkey: rkey,
+53 -2
appview/settings/settings.go
··· 41 41 {"Name": "profile", "Icon": "user"}, 42 42 {"Name": "keys", "Icon": "key"}, 43 43 {"Name": "emails", "Icon": "mail"}, 44 + {"Name": "notifications", "Icon": "bell"}, 44 45 } 45 46 ) 46 47 ··· 68 69 r.Post("/primary", s.emailsPrimary) 69 70 }) 70 71 72 + r.Route("/notifications", func(r chi.Router) { 73 + r.Get("/", s.notificationsSettings) 74 + r.Put("/", s.updateNotificationPreferences) 75 + }) 76 + 71 77 return r 72 78 } 73 79 ··· 79 85 Tabs: settingsTabs, 80 86 Tab: "profile", 81 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.") 82 133 } 83 134 84 135 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { ··· 419 470 } 420 471 421 472 // store in pds too 422 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 423 474 Collection: tangled.PublicKeyNSID, 424 475 Repo: did, 425 476 Rkey: rkey, ··· 476 527 477 528 if rkey != "" { 478 529 // remove from pds too 479 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 480 531 Collection: tangled.PublicKeyNSID, 481 532 Repo: did, 482 533 Rkey: rkey,
+65 -3
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "encoding/json" 6 + "errors" 5 7 "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "net/url" 8 11 "os" 9 12 "strings" 10 13 ··· 17 20 "tangled.org/core/appview/models" 18 21 "tangled.org/core/appview/pages" 19 22 "tangled.org/core/appview/state/userutil" 20 - "tangled.org/core/appview/xrpcclient" 21 23 "tangled.org/core/idresolver" 22 24 ) 23 25 ··· 26 28 db *db.DB 27 29 cf *dns.Cloudflare 28 30 posthog posthog.Client 29 - xrpc *xrpcclient.Client 30 31 idResolver *idresolver.Resolver 31 32 pages *pages.Pages 32 33 l *slog.Logger ··· 116 117 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 118 switch r.Method { 118 119 case http.MethodGet: 119 - s.pages.Signup(w) 120 + s.pages.Signup(w, pages.SignupParams{ 121 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 122 + }) 120 123 case http.MethodPost: 121 124 if s.cf == nil { 122 125 http.Error(w, "signup is disabled", http.StatusFailedDependency) 126 + return 123 127 } 124 128 emailId := r.FormValue("email") 129 + cfToken := r.FormValue("cf-turnstile-response") 125 130 126 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 + 127 139 if !email.IsValidEmail(emailId) { 128 140 s.pages.Notice(w, noticeId, "Invalid email address.") 129 141 return ··· 255 267 return 256 268 } 257 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 189 return 190 190 } 191 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 193 var exCid *string 194 194 if ex != nil { 195 195 exCid = ex.Cid 196 196 } 197 197 198 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.SpindleNSID, 201 201 Repo: user.Did, 202 202 Rkey: instance, ··· 332 332 return 333 333 } 334 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 336 Collection: tangled.SpindleNSID, 337 337 Repo: user.Did, 338 338 Rkey: instance, ··· 542 542 return 543 543 } 544 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 546 Collection: tangled.SpindleMemberNSID, 547 547 Repo: user.Did, 548 548 Rkey: rkey, ··· 683 683 } 684 684 685 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 687 Collection: tangled.SpindleMemberNSID, 688 688 Repo: user.Did, 689 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 43 case http.MethodPost: 44 44 createdAt := time.Now().Format(time.RFC3339) 45 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 47 Collection: tangled.GraphFollowNSID, 48 48 Repo: currentUser.Did, 49 49 Rkey: rkey, ··· 88 88 return 89 89 } 90 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 92 Collection: tangled.GraphFollowNSID, 93 93 Repo: currentUser.Did, 94 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 172 }) 173 173 } 174 174 175 - return db.InsertRepoLanguages(d, langs) 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() 176 189 } 177 190 178 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 634 vanityStats = append(vanityStats, string(v.Kind)) 635 635 } 636 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 638 var cid *string 639 639 if ex != nil { 640 640 cid = ex.Cid 641 641 } 642 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 644 Collection: tangled.ActorProfileNSID, 645 645 Repo: user.Did, 646 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" ··· 47 47 case http.MethodPost: 48 48 createdAt := time.Now().Format(time.RFC3339) 49 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 52 Repo: currentUser.Did, 53 53 Rkey: rkey, ··· 70 70 return 71 71 } 72 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions for ", subjectUri) 76 76 } 77 77 78 78 log.Println("created atproto record: ", resp.Uri) ··· 80 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 81 ThreadAt: subjectUri, 82 82 Kind: reactionKind, 83 - Count: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 84 85 IsReacted: true, 85 86 }) 86 87 ··· 92 93 return 93 94 } 94 95 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 97 Collection: tangled.FeedReactionNSID, 97 98 Repo: currentUser.Did, 98 99 Rkey: reaction.Rkey, ··· 109 110 // this is not an issue, the firehose event might have already done this 110 111 } 111 112 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 113 114 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 115 116 return 116 117 } 117 118 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 120 ThreadAt: subjectUri, 120 121 Kind: reactionKind, 121 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 122 124 IsReacted: false, 123 125 }) 124 126
+19 -11
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 8 "tangled.org/core/appview/issues" 10 9 "tangled.org/core/appview/knots" 11 10 "tangled.org/core/appview/labels" 12 11 "tangled.org/core/appview/middleware" 13 - oauthhandler "tangled.org/core/appview/oauth/handler" 12 + "tangled.org/core/appview/notifications" 14 13 "tangled.org/core/appview/pipelines" 15 14 "tangled.org/core/appview/pulls" 16 15 "tangled.org/core/appview/repo" ··· 35 34 36 35 router.Get("/favicon.svg", s.Favicon) 37 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 38 39 39 userRouter := s.UserRouter(&middleware) 40 40 standardRouter := s.StandardRouter(&middleware) ··· 115 115 116 116 r.Get("/", s.HomeOrTimeline) 117 117 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 118 + r.Get("/upgradeBanner", s.UpgradeBanner) 119 119 120 120 // special-case handler for serving tangled.org/core 121 121 r.Get("/core", s.Core()) 122 122 123 + r.Get("/login", s.Login) 124 + r.Post("/login", s.Login) 125 + r.Post("/logout", s.Logout) 126 + 123 127 r.Route("/repo", func(r chi.Router) { 124 128 r.Route("/new", func(r chi.Router) { 125 129 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 128 132 }) 129 133 // r.Post("/import", s.ImportRepo) 130 134 }) 135 + 136 + r.Get("/goodfirstissues", s.GoodFirstIssues) 131 137 132 138 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 133 139 r.Post("/", s.Follow) ··· 156 162 r.Mount("/strings", s.StringsRouter(mw)) 157 163 r.Mount("/knots", s.KnotsRouter()) 158 164 r.Mount("/spindles", s.SpindlesRouter()) 165 + r.Mount("/notifications", s.NotificationsRouter(mw)) 166 + 159 167 r.Mount("/signup", s.SignupRouter()) 160 - r.Mount("/", s.OAuthRouter()) 168 + r.Mount("/", s.oauth.Router()) 161 169 162 170 r.Get("/keys/{user}", s.Keys) 163 171 r.Get("/terms", s.TermsOfService) 164 172 r.Get("/privacy", s.PrivacyPolicy) 173 + r.Get("/brand", s.Brand) 165 174 166 175 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 167 176 s.pages.Error404(w) ··· 181 190 182 191 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 183 192 } 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 193 } 191 194 192 195 func (s *State) SettingsRouter() http.Handler { ··· 270 273 } 271 274 272 275 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 273 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 276 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 274 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) 275 283 } 276 284 277 285 func (s *State) SignupRouter() http.Handler {
+2 -2
appview/state/star.go
··· 40 40 case http.MethodPost: 41 41 createdAt := time.Now().Format(time.RFC3339) 42 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 45 Repo: currentUser.Did, 46 46 Rkey: rkey, ··· 92 92 return 93 93 } 94 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+78 -23
appview/state/state.go
··· 11 11 "strings" 12 12 "time" 13 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 14 "tangled.org/core/api/tangled" 21 15 "tangled.org/core/appview" 22 16 "tangled.org/core/appview/cache" ··· 25 19 "tangled.org/core/appview/db" 26 20 "tangled.org/core/appview/models" 27 21 "tangled.org/core/appview/notify" 22 + dbnotify "tangled.org/core/appview/notify/db" 23 + phnotify "tangled.org/core/appview/notify/posthog" 28 24 "tangled.org/core/appview/oauth" 29 25 "tangled.org/core/appview/pages" 30 - posthogService "tangled.org/core/appview/posthog" 31 26 "tangled.org/core/appview/reporesolver" 32 27 "tangled.org/core/appview/validator" 33 28 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 37 32 tlog "tangled.org/core/log" 38 33 "tangled.org/core/rbac" 39 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" 40 43 ) 41 44 42 45 type State struct { ··· 74 77 res = idresolver.DefaultResolver() 75 78 } 76 79 77 - pgs := pages.NewPages(config, res) 80 + pages := pages.NewPages(config, res) 78 81 cache := cache.New(config.Redis.Addr) 79 82 sess := session.New(cache) 80 - oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d, res) 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) 82 88 83 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 90 if err != nil { ··· 148 154 spindlestream.Start(ctx) 149 155 150 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 151 162 if !config.Core.Dev { 152 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 163 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 153 164 } 154 165 notifier := notify.NewMergedNotifier(notifiers...) 155 166 156 167 state := &State{ 157 168 d, 158 169 notifier, 159 - oauth, 170 + oauth2, 160 171 enforcer, 161 - pgs, 172 + pages, 162 173 sess, 163 174 res, 164 175 posthog, ··· 192 203 s.pages.Favicon(w) 193 204 } 194 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 + 195 229 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 196 230 user := s.oauth.GetUser(r) 197 231 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 206 240 }) 207 241 } 208 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 + } 249 + 209 250 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 210 251 if s.oauth.GetUser(r) != nil { 211 252 s.Timeline(w, r) ··· 216 257 217 258 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 218 259 user := s.oauth.GetUser(r) 260 + 261 + // TODO: set this flag based on the UI 262 + filtered := false 219 263 220 264 var userDid string 221 265 if user != nil { 222 266 userDid = user.Did 223 267 } 224 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 268 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 225 269 if err != nil { 226 270 log.Println(err) 227 271 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 234 278 return 235 279 } 236 280 281 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 282 + if err != nil { 283 + // non-fatal 284 + } 285 + 237 286 s.pages.Timeline(w, pages.TimelineParams{ 238 287 LoggedInUser: user, 239 288 Timeline: timeline, 240 289 Repos: repos, 290 + GfiLabel: gfiLabel, 241 291 }) 242 292 } 243 293 244 294 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 245 295 user := s.oauth.GetUser(r) 296 + if user == nil { 297 + return 298 + } 299 + 246 300 l := s.logger.With("handler", "UpgradeBanner") 247 301 l = l.With("did", user.Did) 248 - l = l.With("handle", user.Handle) 249 302 250 303 regs, err := db.GetRegistrations( 251 304 s.db, ··· 276 329 } 277 330 278 331 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 279 - timeline, err := db.MakeTimeline(s.db, 5, "") 332 + // TODO: set this flag based on the UI 333 + filtered := false 334 + 335 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 280 336 if err != nil { 281 337 log.Println(err) 282 338 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 385 441 386 442 user := s.oauth.GetUser(r) 387 443 l = l.With("did", user.Did) 388 - l = l.With("handle", user.Handle) 389 444 390 445 // form validation 391 446 domain := r.FormValue("domain") ··· 449 504 } 450 505 record := repo.AsRecord() 451 506 452 - xrpcClient, err := s.oauth.AuthorizedClient(r) 507 + atpClient, err := s.oauth.AuthorizedClient(r) 453 508 if err != nil { 454 509 l.Info("PDS write failed", "err", err) 455 510 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 456 511 return 457 512 } 458 513 459 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 514 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 460 515 Collection: tangled.RepoNSID, 461 516 Repo: user.Did, 462 517 Rkey: rkey, ··· 488 543 rollback := func() { 489 544 err1 := tx.Rollback() 490 545 err2 := s.enforcer.E.LoadPolicy() 491 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 546 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 492 547 493 548 // ignore txn complete errors, this is okay 494 549 if errors.Is(err1, sql.ErrTxDone) { ··· 561 616 aturi = "" 562 617 563 618 s.notifier.NewRepo(r.Context(), repo) 564 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 619 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 565 620 } 566 621 } 567 622 568 623 // this is used to rollback changes made to the PDS 569 624 // 570 625 // it is a no-op if the provided ATURI is empty 571 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 626 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 572 627 if aturi == "" { 573 628 return nil 574 629 } ··· 579 634 repo := parsed.Authority().String() 580 635 rkey := parsed.RecordKey().String() 581 636 582 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 637 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 583 638 Collection: collection, 584 639 Repo: repo, 585 640 Rkey: rkey,
+9 -7
appview/strings/strings.go
··· 22 22 "github.com/bluesky-social/indigo/api/atproto" 23 23 "github.com/bluesky-social/indigo/atproto/identity" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 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" 27 29 ) 28 30 29 31 type Strings struct { ··· 254 256 } 255 257 256 258 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 260 if err != nil { 259 261 fail("Failed to updated existing record.", err) 260 262 return 261 263 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 263 265 Collection: tangled.StringNSID, 264 266 Repo: entry.Did.String(), 265 267 Rkey: entry.Rkey, ··· 284 286 s.Notifier.EditString(r.Context(), &entry) 285 287 286 288 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 288 290 } 289 291 290 292 } ··· 336 338 return 337 339 } 338 340 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 340 342 Collection: tangled.StringNSID, 341 343 Repo: user.Did, 342 344 Rkey: string.Rkey, ··· 360 362 s.Notifier.NewString(r.Context(), &string) 361 363 362 364 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 364 366 } 365 367 } 366 368 ··· 403 405 404 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 407 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 407 409 } 408 410 409 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+15 -1
appview/validator/label.go
··· 95 95 return nil 96 96 } 97 97 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 99 if labelDef == nil { 100 100 return fmt.Errorf("label definition is required") 101 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 102 105 if labelOp == nil { 103 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") 104 118 } 105 119 106 120 expectedKey := labelDef.AtUri().String()
+4 -1
appview/validator/validator.go
··· 4 4 "tangled.org/core/appview/db" 5 5 "tangled.org/core/appview/pages/markup" 6 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 7 8 ) 8 9 9 10 type Validator struct { 10 11 db *db.DB 11 12 sanitizer markup.Sanitizer 12 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 13 15 } 14 16 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 16 18 return &Validator{ 17 19 db: db, 18 20 sanitizer: markup.NewSanitizer(), 19 21 resolver: res, 22 + enforcer: enforcer, 20 23 } 21 24 }
-99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 19 13 ErrXrpcFailed = errors.New("xrpc request failed") 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 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 16 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error {
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 2 3 3 package main 4 4
+1 -1
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 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 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: 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 25 26 26 ```yaml 27 27 when:
+4 -4
go.mod
··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 40 40 github.com/urfave/cli/v3 v3.3.3 41 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 43 + github.com/yuin/goldmark v1.7.13 44 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 46 47 golang.org/x/net v0.42.0 47 48 golang.org/x/sync v0.16.0 48 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 50 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 51 52 ) 52 53 53 54 require ( ··· 168 169 go.uber.org/atomic v1.11.0 // indirect 169 170 go.uber.org/multierr v1.11.0 // indirect 170 171 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 172 golang.org/x/sys v0.34.0 // indirect 173 173 golang.org/x/text v0.27.0 // indirect 174 174 golang.org/x/time v0.12.0 // indirect
+6 -4
go.sum
··· 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 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= 28 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 436 438 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 439 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 440 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 v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 442 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 443 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 444 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 445 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= ··· 652 654 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 655 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 656 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.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= 657 659 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 660 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
-103
knotserver/git/git.go
··· 27 27 h plumbing.Hash 28 28 } 29 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 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 31 // to tar WriteHeader 45 32 type infoWrapper struct { ··· 48 35 mode fs.FileMode 49 36 modTime time.Time 50 37 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 38 } 90 39 91 40 func Open(path string, ref string) (*GitRepo, error) { ··· 171 120 return g.r.CommitObject(h) 172 121 } 173 122 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 123 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 124 c, err := g.r.CommitObject(g.h) 184 125 if err != nil { ··· 211 152 } 212 153 213 154 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 155 } 240 156 241 157 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 326 func (i *infoWrapper) Sys() any { 411 327 return nil 412 328 } 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 - }
+1 -3
knotserver/git/tag.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "slices" 6 5 "strconv" 7 6 "strings" 8 7 "time" ··· 35 34 outFormat.WriteString("") 36 35 outFormat.WriteString(recordSeparator) 37 36 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 39 38 if err != nil { 40 39 return nil, fmt.Errorf("failed to get tags: %w", err) 41 40 } ··· 94 93 tags = append(tags, tag) 95 94 } 96 95 97 - slices.Reverse(tags) 98 96 return tags, nil 99 97 }
-4
knotserver/http_util.go
··· 16 16 w.WriteHeader(status) 17 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 44 45 45 contents, err := gr.RawContent(treePath) 46 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 48 writeError(w, xrpcerr.NewXrpcError( 49 49 xrpcerr.WithTag("FileNotFound"), 50 50 xrpcerr.WithMessage("file not found at the specified path"),
+24
knotserver/xrpc/repo_tree.go
··· 4 4 "net/http" 5 5 "path/filepath" 6 6 "time" 7 + "unicode/utf8" 7 8 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 9 11 "tangled.org/core/knotserver/git" 10 12 xrpcerr "tangled.org/core/xrpc/errors" 11 13 ) ··· 43 45 return 44 46 } 45 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 + 46 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 68 for i, file := range files { ··· 83 103 Parent: parentPtr, 84 104 Dotdot: dotdotPtr, 85 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 86 110 } 87 111 88 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 41 "type": "string", 42 42 "description": "Parent directory path" 43 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 44 49 "files": { 45 50 "type": "array", 46 51 "items": { ··· 69 74 "description": "Invalid request parameters" 70 75 } 71 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 + } 72 91 }, 73 92 "treeEntry": { 74 93 "type": "object",
+1 -1
nix/gomod2nix.toml
··· 527 527 [mod."lukechampine.com/blake3"] 528 528 version = "v1.4.1" 529 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 530 + [mod."tangled.org/anirudh.fi/atproto-oauth"] 531 531 version = "v0.0.0-20250724194903-28e660378cb1" 532 532 hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+7 -5
types/repo.go
··· 41 41 } 42 42 43 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"` 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"` 49 51 } 50 52 51 53 type TagReference struct {