Monorepo for Tangled — https://tangled.org

Merge branch 'master' of tangled.sh:tangled.org/core into fork

+737 -630
+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 {
+140
appview/db/db.go
··· 954 954 return err 955 955 }) 956 956 957 + // add generated at_uri column to pulls table 958 + // 959 + // this requires a full table recreation because stored columns 960 + // cannot be added via alter 961 + // 962 + // disable foreign-keys for the next migration 963 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 + runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 + _, err := tx.Exec(` 966 + create table if not exists pulls_new ( 967 + -- identifiers 968 + id integer primary key autoincrement, 969 + pull_id integer not null, 970 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 + 972 + -- at identifiers 973 + repo_at text not null, 974 + owner_did text not null, 975 + rkey text not null, 976 + 977 + -- content 978 + title text not null, 979 + body text not null, 980 + target_branch text not null, 981 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 + 983 + -- source info 984 + source_branch text, 985 + source_repo_at text, 986 + 987 + -- stacking 988 + stack_id text, 989 + change_id text, 990 + parent_change_id text, 991 + 992 + -- meta 993 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 + 995 + -- constraints 996 + unique(repo_at, pull_id), 997 + unique(at_uri), 998 + foreign key (repo_at) references repos(at_uri) on delete cascade 999 + ); 1000 + `) 1001 + if err != nil { 1002 + return err 1003 + } 1004 + 1005 + // transfer data 1006 + _, err = tx.Exec(` 1007 + insert into pulls_new ( 1008 + id, pull_id, repo_at, owner_did, rkey, 1009 + title, body, target_branch, state, 1010 + source_branch, source_repo_at, 1011 + stack_id, change_id, parent_change_id, 1012 + created 1013 + ) 1014 + select 1015 + id, pull_id, repo_at, owner_did, rkey, 1016 + title, body, target_branch, state, 1017 + source_branch, source_repo_at, 1018 + stack_id, change_id, parent_change_id, 1019 + created 1020 + from pulls; 1021 + `) 1022 + if err != nil { 1023 + return err 1024 + } 1025 + 1026 + // drop old table 1027 + _, err = tx.Exec(`drop table pulls`) 1028 + if err != nil { 1029 + return err 1030 + } 1031 + 1032 + // rename new table 1033 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 + return err 1035 + }) 1036 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 + 1038 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 + // 1040 + // this requires a full table recreation because stored columns 1041 + // cannot be added via alter 1042 + // 1043 + // disable foreign-keys for the next migration 1044 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 + runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 + _, err := tx.Exec(` 1047 + create table if not exists pull_submissions_new ( 1048 + -- identifiers 1049 + id integer primary key autoincrement, 1050 + pull_at text not null, 1051 + 1052 + -- content, these are immutable, and require a resubmission to update 1053 + round_number integer not null default 0, 1054 + patch text, 1055 + source_rev text, 1056 + 1057 + -- meta 1058 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 + 1060 + -- constraints 1061 + unique(pull_at, round_number), 1062 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 + ); 1064 + `) 1065 + if err != nil { 1066 + return err 1067 + } 1068 + 1069 + // transfer data, constructing pull_at from pulls table 1070 + _, err = tx.Exec(` 1071 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 + select 1073 + ps.id, 1074 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 + ps.round_number, 1076 + ps.patch, 1077 + ps.created 1078 + from pull_submissions ps 1079 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 + `) 1081 + if err != nil { 1082 + return err 1083 + } 1084 + 1085 + // drop old table 1086 + _, err = tx.Exec(`drop table pull_submissions`) 1087 + if err != nil { 1088 + return err 1089 + } 1090 + 1091 + // rename new table 1092 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 + return err 1094 + }) 1095 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 + 957 1097 return &DB{db}, nil 958 1098 } 959 1099
+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
+18 -25
appview/db/notifications.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "errors" 6 7 "fmt" 8 + "strings" 7 9 "time" 8 10 9 11 "tangled.org/core/appview/models" ··· 248 250 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 249 251 } 250 252 251 - // GetNotifications retrieves notifications for a user with pagination (legacy method for backward compatibility) 252 - func (d *DB) GetNotifications(ctx context.Context, userDID string, limit, offset int) ([]*models.Notification, error) { 253 - page := pagination.Page{Limit: limit, Offset: offset} 254 - return GetNotificationsPaginated(d.DB, page, FilterEq("recipient_did", userDID)) 255 - } 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 + } 256 260 257 - // GetNotificationsWithEntities retrieves notifications with entities for a user with pagination 258 - func (d *DB) GetNotificationsWithEntities(ctx context.Context, userDID string, limit, offset int) ([]*models.NotificationWithEntity, error) { 259 - page := pagination.Page{Limit: limit, Offset: offset} 260 - return GetNotificationsWithEntities(d.DB, page, FilterEq("recipient_did", userDID)) 261 - } 261 + whereClause := "" 262 + if conditions != nil { 263 + whereClause = " where " + strings.Join(conditions, " and ") 264 + } 262 265 263 - func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) { 264 - recipientFilter := FilterEq("recipient_did", userDID) 265 - readFilter := FilterEq("read", 0) 266 - 267 - query := fmt.Sprintf(` 268 - SELECT COUNT(*) 269 - FROM notifications 270 - WHERE %s AND %s 271 - `, recipientFilter.Condition(), readFilter.Condition()) 272 - 273 - args := append(recipientFilter.Arg(), readFilter.Arg()...) 266 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 267 + var count int64 268 + err := e.QueryRow(query, args...).Scan(&count) 274 269 275 - var count int 276 - err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count) 277 - if err != nil { 278 - return 0, fmt.Errorf("failed to get unread count: %w", err) 270 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 271 + return 0, err 279 272 } 280 273 281 274 return count, nil
+145 -229
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" ··· 87 90 pull.ID = int(id) 88 91 89 92 _, err = tx.Exec(` 90 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 91 - values (?, ?, ?, ?, ?) 92 - `, 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) 93 96 return err 94 97 } 95 98 ··· 108 111 } 109 112 110 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 111 - pulls := make(map[int]*models.Pull) 114 + pulls := make(map[syntax.ATURI]*models.Pull) 112 115 113 116 var conditions []string 114 117 var args []any ··· 211 214 pull.ParentChangeId = parentChangeId.String 212 215 } 213 216 214 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 215 218 } 216 219 217 - // get latest round no. for each pull 218 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 219 - submissionsQuery := fmt.Sprintf(` 220 - select 221 - id, pull_id, round_number, patch, created, source_rev 222 - from 223 - pull_submissions 224 - where 225 - repo_at in (%s) and pull_id in (%s) 226 - `, inClause, inClause) 227 - 228 - args = make([]any, len(pulls)*2) 229 - idx := 0 220 + var pullAts []syntax.ATURI 230 221 for _, p := range pulls { 231 - args[idx] = p.RepoAt 232 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 233 223 } 234 - for _, p := range pulls { 235 - args[idx] = p.PullId 236 - idx += 1 237 - } 238 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 239 225 if err != nil { 240 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 241 227 } 242 - defer submissionsRows.Close() 243 228 244 - for submissionsRows.Next() { 245 - var s models.PullSubmission 246 - var sourceRev sql.NullString 247 - var createdAt string 248 - err := submissionsRows.Scan( 249 - &s.ID, 250 - &s.PullId, 251 - &s.RoundNumber, 252 - &s.Patch, 253 - &createdAt, 254 - &sourceRev, 255 - ) 256 - if err != nil { 257 - return nil, err 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 258 232 } 233 + } 259 234 260 - createdTime, err := time.Parse(time.RFC3339, createdAt) 261 - if err != nil { 262 - return nil, err 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 239 + } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 263 243 } 264 - s.Created = createdTime 244 + } 265 245 266 - if sourceRev.Valid { 267 - s.SourceRev = sourceRev.String 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 + for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 268 251 } 269 - 270 - if p, ok := pulls[s.PullId]; ok { 271 - p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 272 - p.Submissions[s.RoundNumber] = &s 273 - } 252 + } 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 274 256 } 275 - if err := rows.Err(); err != nil { 276 - return nil, err 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 277 260 } 278 - 279 - // get comment count on latest submission on each pull 280 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 281 - commentsQuery := fmt.Sprintf(` 282 - select 283 - count(id), pull_id 284 - from 285 - pull_comments 286 - where 287 - submission_id in (%s) 288 - group by 289 - submission_id 290 - `, inClause) 291 - 292 - args = []any{} 293 261 for _, p := range pulls { 294 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 295 - } 296 - commentsRows, err := e.Query(commentsQuery, args...) 297 - if err != nil { 298 - return nil, err 299 - } 300 - defer commentsRows.Close() 301 - 302 - for commentsRows.Next() { 303 - var commentCount, pullId int 304 - err := commentsRows.Scan( 305 - &commentCount, 306 - &pullId, 307 - ) 308 - if err != nil { 309 - return nil, err 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 310 266 } 311 - if p, ok := pulls[pullId]; ok { 312 - p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 313 - } 314 - } 315 - if err := rows.Err(); err != nil { 316 - return nil, err 317 267 } 318 268 319 269 orderedByPullId := []*models.Pull{} ··· 332 282 } 333 283 334 284 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 335 - query := ` 336 - select 337 - id, 338 - owner_did, 339 - pull_id, 340 - created, 341 - title, 342 - state, 343 - target_branch, 344 - repo_at, 345 - body, 346 - rkey, 347 - source_branch, 348 - source_repo_at, 349 - stack_id, 350 - change_id, 351 - parent_change_id 352 - from 353 - pulls 354 - where 355 - repo_at = ? and pull_id = ? 356 - ` 357 - row := e.QueryRow(query, repoAt, pullId) 358 - 359 - var pull models.Pull 360 - var createdAt string 361 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 362 - err := row.Scan( 363 - &pull.ID, 364 - &pull.OwnerDid, 365 - &pull.PullId, 366 - &createdAt, 367 - &pull.Title, 368 - &pull.State, 369 - &pull.TargetBranch, 370 - &pull.RepoAt, 371 - &pull.Body, 372 - &pull.Rkey, 373 - &sourceBranch, 374 - &sourceRepoAt, 375 - &stackId, 376 - &changeId, 377 - &parentChangeId, 378 - ) 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 379 286 if err != nil { 380 287 return nil, err 381 288 } 382 - 383 - createdTime, err := time.Parse(time.RFC3339, createdAt) 384 - if err != nil { 385 - return nil, err 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 386 291 } 387 - pull.Created = createdTime 388 292 389 - // populate source 390 - if sourceBranch.Valid { 391 - pull.PullSource = &models.PullSource{ 392 - Branch: sourceBranch.String, 393 - } 394 - if sourceRepoAt.Valid { 395 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 396 - if err != nil { 397 - return nil, err 398 - } 399 - pull.PullSource.RepoAt = &sourceRepoAtParsed 400 - } 293 + return pulls[0], nil 294 + } 295 + 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 401 303 } 402 304 403 - if stackId.Valid { 404 - pull.StackId = stackId.String 405 - } 406 - if changeId.Valid { 407 - pull.ChangeId = changeId.String 408 - } 409 - if parentChangeId.Valid { 410 - pull.ParentChangeId = parentChangeId.String 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 411 308 } 412 309 413 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 414 311 select 415 - 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 416 318 from 417 319 pull_submissions 418 - where 419 - repo_at = ? and pull_id = ? 420 - ` 421 - 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...) 422 326 if err != nil { 423 327 return nil, err 424 328 } 425 - defer submissionsRows.Close() 329 + defer rows.Close() 426 330 427 - submissionsMap := make(map[int]*models.PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 428 332 429 - for submissionsRows.Next() { 333 + for rows.Next() { 430 334 var submission models.PullSubmission 431 - var submissionCreatedStr string 432 - var submissionSourceRev sql.NullString 433 - err := submissionsRows.Scan( 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 434 338 &submission.ID, 435 - &submission.PullId, 436 - &submission.RepoAt, 339 + &submission.PullAt, 437 340 &submission.RoundNumber, 438 341 &submission.Patch, 439 - &submissionCreatedStr, 440 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 441 344 ) 442 345 if err != nil { 443 346 return nil, err 444 347 } 445 348 446 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 447 350 if err != nil { 448 351 return nil, err 449 352 } 450 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 451 354 452 - if submissionSourceRev.Valid { 453 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 454 357 } 455 358 456 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 360 + } 361 + 362 + if err := rows.Err(); err != nil { 363 + return nil, err 457 364 } 458 - if err = submissionsRows.Close(); err != 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 { 459 370 return nil, err 460 371 } 461 - if len(submissionsMap) == 0 { 462 - return &pull, nil 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) 463 382 } 464 383 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 389 + } 390 + 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 465 396 var args []any 466 - for k := range submissionsMap { 467 - args = append(args, k) 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 468 400 } 469 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 470 - commentsQuery := fmt.Sprintf(` 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 405 + } 406 + 407 + query := fmt.Sprintf(` 471 408 select 472 409 id, 473 410 pull_id, ··· 479 416 created 480 417 from 481 418 pull_comments 482 - where 483 - submission_id IN (%s) 419 + %s 484 420 order by 485 421 created asc 486 - `, inClause) 487 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 488 425 if err != nil { 489 426 return nil, err 490 427 } 491 - defer commentsRows.Close() 428 + defer rows.Close() 492 429 493 - for commentsRows.Next() { 430 + var comments []models.PullComment 431 + for rows.Next() { 494 432 var comment models.PullComment 495 - var commentCreatedStr string 496 - err := commentsRows.Scan( 433 + var createdAt string 434 + err := rows.Scan( 497 435 &comment.ID, 498 436 &comment.PullId, 499 437 &comment.SubmissionId, ··· 501 439 &comment.OwnerDid, 502 440 &comment.CommentAt, 503 441 &comment.Body, 504 - &commentCreatedStr, 442 + &createdAt, 505 443 ) 506 444 if err != nil { 507 445 return nil, err 508 446 } 509 447 510 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 511 - if err != nil { 512 - return nil, err 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 513 450 } 514 - comment.Created = commentCreatedTime 515 451 516 - // Add the comment to its submission 517 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 518 - submission.Comments = append(submission.Comments, comment) 519 - } 452 + comments = append(comments, comment) 453 + } 520 454 521 - } 522 - if err = commentsRows.Err(); err != nil { 455 + if err := rows.Err(); err != nil { 523 456 return nil, err 524 457 } 525 458 526 - var pullSourceRepo *models.Repo 527 - if pull.PullSource != nil { 528 - if pull.PullSource.RepoAt != nil { 529 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 530 - if err != nil { 531 - log.Printf("failed to get repo by at uri: %v", err) 532 - } else { 533 - pull.PullSource.Repo = pullSourceRepo 534 - } 535 - } 536 - } 537 - 538 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 539 - for _, submission := range submissionsMap { 540 - pull.Submissions[submission.RoundNumber] = submission 541 - } 542 - 543 - return &pull, nil 459 + return comments, nil 544 460 } 545 461 546 462 // timeframe here is directly passed into the sql query filter, and any ··· 677 593 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 678 594 newRoundNumber := len(pull.Submissions) 679 595 _, err := e.Exec(` 680 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 681 - values (?, ?, ?, ?, ?) 682 - `, 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) 683 599 684 600 return err 685 601 }
+5 -1
appview/issues/issues.go
··· 798 798 return 799 799 } 800 800 801 - 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 + ) 802 806 if err != nil { 803 807 log.Println("failed to fetch labels", err) 804 808 rp.pages.Error503(w)
+29 -1
appview/models/notifications.go
··· 1 1 package models 2 2 3 - import "time" 3 + import ( 4 + "time" 5 + ) 4 6 5 7 type NotificationType string 6 8 ··· 30 32 RepoId *int64 31 33 IssueId *int64 32 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 + } 33 61 } 34 62 35 63 type NotificationWithEntity struct {
+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
+30 -35
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 5 6 "net/http" 6 7 "strconv" ··· 10 11 "tangled.org/core/appview/middleware" 11 12 "tangled.org/core/appview/oauth" 12 13 "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 13 15 ) 14 16 15 17 type Notifications struct { ··· 31 33 32 34 r.Use(middleware.AuthMiddleware(n.oauth)) 33 35 34 - r.Get("/", n.notificationsPage) 36 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 35 37 36 38 r.Get("/count", n.getUnreadCount) 37 39 r.Post("/{id}/read", n.markRead) ··· 44 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 45 47 userDid := n.oauth.GetDid(r) 46 48 47 - limitStr := r.URL.Query().Get("limit") 48 - offsetStr := r.URL.Query().Get("offset") 49 - 50 - limit := 20 // default 51 - if limitStr != "" { 52 - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 53 - limit = l 54 - } 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 55 53 } 56 54 57 - offset := 0 // default 58 - if offsetStr != "" { 59 - if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { 60 - offset = o 61 - } 55 + total, err := db.CountNotifications( 56 + n.db, 57 + db.FilterEq("recipient_did", userDid), 58 + ) 59 + if err != nil { 60 + log.Println("failed to get total notifications:", err) 61 + n.pages.Error500(w) 62 + return 62 63 } 63 64 64 - notifications, err := n.db.GetNotificationsWithEntities(r.Context(), userDid, limit+1, offset) 65 + notifications, err := db.GetNotificationsWithEntities( 66 + n.db, 67 + page, 68 + db.FilterEq("recipient_did", userDid), 69 + ) 65 70 if err != nil { 66 71 log.Println("failed to get notifications:", err) 67 72 n.pages.Error500(w) 68 73 return 69 - } 70 - 71 - hasMore := len(notifications) > limit 72 - if hasMore { 73 - notifications = notifications[:limit] 74 74 } 75 75 76 76 err = n.db.MarkAllNotificationsRead(r.Context(), userDid) ··· 86 86 return 87 87 } 88 88 89 - params := pages.NotificationsParams{ 89 + fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 90 90 LoggedInUser: user, 91 91 Notifications: notifications, 92 92 UnreadCount: unreadCount, 93 - HasMore: hasMore, 94 - NextOffset: offset + limit, 95 - Limit: limit, 96 - } 97 - 98 - err = n.pages.Notifications(w, params) 99 - if err != nil { 100 - log.Println("failed to load notifs:", err) 101 - n.pages.Error500(w) 102 - return 103 - } 93 + Page: page, 94 + Total: total, 95 + })) 104 96 } 105 97 106 98 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 107 - userDid := n.oauth.GetDid(r) 108 - 109 - count, err := n.db.GetUnreadNotificationCount(r.Context(), userDid) 99 + user := n.oauth.GetUser(r) 100 + count, err := db.CountNotifications( 101 + n.db, 102 + db.FilterEq("recipient_did", user.Did), 103 + db.FilterEq("read", 0), 104 + ) 110 105 if err != nil { 111 106 http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 112 107 return
+8 -48
appview/notify/db/db.go
··· 30 30 31 31 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 32 var err error 33 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt))) 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 34 if err != nil { 35 35 log.Printf("NewStar: failed to get repos: %v", err) 36 36 return 37 37 } 38 - if len(repos) == 0 { 39 - log.Printf("NewStar: no repo found for %s", star.RepoAt) 40 - return 41 - } 42 - repo := repos[0] 43 38 44 39 // don't notify yourself 45 40 if repo.Did == star.StarredByDid { ··· 76 71 } 77 72 78 73 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 79 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 80 75 if err != nil { 81 76 log.Printf("NewIssue: failed to get repos: %v", err) 82 77 return 83 78 } 84 - if len(repos) == 0 { 85 - log.Printf("NewIssue: no repo found for %s", issue.RepoAt) 86 - return 87 - } 88 - repo := repos[0] 89 79 90 80 if repo.Did == issue.Did { 91 81 return ··· 129 119 } 130 120 issue := issues[0] 131 121 132 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 133 123 if err != nil { 134 124 log.Printf("NewIssueComment: failed to get repos: %v", err) 135 125 return 136 126 } 137 - if len(repos) == 0 { 138 - log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt) 139 - return 140 - } 141 - repo := repos[0] 142 127 143 128 recipients := make(map[string]bool) 144 129 ··· 211 196 } 212 197 213 198 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 214 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 215 200 if err != nil { 216 201 log.Printf("NewPull: failed to get repos: %v", err) 217 202 return 218 203 } 219 - if len(repos) == 0 { 220 - log.Printf("NewPull: no repo found for %s", pull.RepoAt) 221 - return 222 - } 223 - repo := repos[0] 224 204 225 205 if repo.Did == pull.OwnerDid { 226 206 return ··· 266 246 } 267 247 pull := pulls[0] 268 248 269 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt)) 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 270 250 if err != nil { 271 251 log.Printf("NewPullComment: failed to get repos: %v", err) 272 252 return 273 253 } 274 - if len(repos) == 0 { 275 - log.Printf("NewPullComment: no repo found for %s", comment.RepoAt) 276 - return 277 - } 278 - repo := repos[0] 279 254 280 255 recipients := make(map[string]bool) 281 256 ··· 335 310 336 311 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 337 312 // Get repo details 338 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 339 314 if err != nil { 340 315 log.Printf("NewIssueClosed: failed to get repos: %v", err) 341 316 return 342 317 } 343 - if len(repos) == 0 { 344 - log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt) 345 - return 346 - } 347 - repo := repos[0] 348 318 349 319 // Don't notify yourself 350 320 if repo.Did == issue.Did { ··· 380 350 381 351 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 382 352 // Get repo details 383 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 384 354 if err != nil { 385 355 log.Printf("NewPullMerged: failed to get repos: %v", err) 386 356 return 387 357 } 388 - if len(repos) == 0 { 389 - log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt) 390 - return 391 - } 392 - repo := repos[0] 393 358 394 359 // Don't notify yourself 395 360 if repo.Did == pull.OwnerDid { ··· 425 390 426 391 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 427 392 // Get repo details 428 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 429 394 if err != nil { 430 395 log.Printf("NewPullClosed: failed to get repos: %v", err) 431 396 return 432 397 } 433 - if len(repos) == 0 { 434 - log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt) 435 - return 436 - } 437 - repo := repos[0] 438 398 439 399 // Don't notify yourself 440 400 if repo.Did == pull.OwnerDid {
+12 -6
appview/pages/pages.go
··· 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 { ··· 322 326 LoggedInUser *oauth.User 323 327 Notifications []*models.NotificationWithEntity 324 328 UnreadCount int 325 - HasMore bool 326 - NextOffset int 327 - Limit int 329 + Page pagination.Page 330 + Total int64 328 331 } 329 332 330 333 func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { ··· 340 343 } 341 344 342 345 type NotificationCountParams struct { 343 - Count int 346 + Count int64 344 347 } 345 348 346 349 func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { ··· 1083 1086 FilteringBy models.PullState 1084 1087 Stacks map[string]models.Stack 1085 1088 Pipelines map[string]models.Pipeline 1089 + LabelDefs map[string]*models.LabelDefinition 1086 1090 } 1087 1091 1088 1092 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1122 1126 OrderedReactionKinds []models.ReactionKind 1123 1127 Reactions map[models.ReactionKind]int 1124 1128 UserReacted map[models.ReactionKind]bool 1129 + 1130 + LabelDefs map[string]*models.LabelDefinition 1125 1131 } 1126 1132 1127 1133 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+1 -1
appview/pages/templates/layouts/fragments/footer.html
··· 23 23 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 24 <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 25 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 26 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 27 27 </div> 28 28 29 29 <div class="flex flex-col gap-1">
+68 -199
appview/pages/templates/notifications/fragments/item.html
··· 1 1 {{define "notifications/fragments/item"}} 2 - <div class="border border-gray-200 dark:border-gray-700 rounded-sm p-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors {{if not .Read}}bg-blue-50 dark:bg-blue-900/20{{end}}"> 3 - {{if .Issue}} 4 - {{template "issueNotification" .}} 5 - {{else if .Pull}} 6 - {{template "pullNotification" .}} 7 - {{else if .Repo}} 8 - {{template "repoNotification" .}} 9 - {{else if eq .Type "followed"}} 10 - {{template "followNotification" .}} 11 - {{else}} 12 - {{template "genericNotification" .}} 13 - {{end}} 14 - </div> 15 - {{end}} 16 - 17 - {{define "issueNotification"}} 18 - {{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 19 - <a 20 - href="{{$url}}" 21 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 22 - > 23 - <div class="flex items-center justify-between"> 24 - <div class="min-w-0 flex-1"> 25 - <!-- First line: icon + actor action --> 26 - <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 27 - {{if eq .Type "issue_created"}} 28 - <span class="text-green-600 dark:text-green-500"> 29 - {{ i "circle-dot" "w-4 h-4" }} 30 - </span> 31 - {{else if eq .Type "issue_commented"}} 32 - <span class="text-gray-500 dark:text-gray-400"> 33 - {{ i "message-circle" "w-4 h-4" }} 34 - </span> 35 - {{else if eq .Type "issue_closed"}} 36 - <span class="text-gray-500 dark:text-gray-400"> 37 - {{ i "ban" "w-4 h-4" }} 38 - </span> 39 - {{end}} 40 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 41 - {{if eq .Type "issue_created"}} 42 - <span class="text-gray-500 dark:text-gray-400">opened issue</span> 43 - {{else if eq .Type "issue_commented"}} 44 - <span class="text-gray-500 dark:text-gray-400">commented on issue</span> 45 - {{else if eq .Type "issue_closed"}} 46 - <span class="text-gray-500 dark:text-gray-400">closed issue</span> 47 - {{end}} 48 - {{if not .Read}} 49 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 50 - {{end}} 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> 51 13 </div> 52 14 53 - <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 54 - <span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span> 55 - <span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span> 56 - <span>on</span> 57 - <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 58 - </div> 59 15 </div> 60 - 61 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 62 - {{ template "repo/fragments/time" .Created }} 63 - </div> 64 - </div> 65 - </a> 16 + </a> 66 17 {{end}} 67 18 68 - {{define "pullNotification"}} 69 - {{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 70 - <a 71 - href="{{$url}}" 72 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 73 - > 74 - <div class="flex items-center justify-between"> 75 - <div class="min-w-0 flex-1"> 76 - <div class="flex items-center gap-2 text-gray-900 dark:text-white"> 77 - {{if eq .Type "pull_created"}} 78 - <span class="text-green-600 dark:text-green-500"> 79 - {{ i "git-pull-request-create" "w-4 h-4" }} 80 - </span> 81 - {{else if eq .Type "pull_commented"}} 82 - <span class="text-gray-500 dark:text-gray-400"> 83 - {{ i "message-circle" "w-4 h-4" }} 84 - </span> 85 - {{else if eq .Type "pull_merged"}} 86 - <span class="text-purple-600 dark:text-purple-500"> 87 - {{ i "git-merge" "w-4 h-4" }} 88 - </span> 89 - {{else if eq .Type "pull_closed"}} 90 - <span class="text-red-600 dark:text-red-500"> 91 - {{ i "git-pull-request-closed" "w-4 h-4" }} 92 - </span> 93 - {{end}} 94 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 95 - {{if eq .Type "pull_created"}} 96 - <span class="text-gray-500 dark:text-gray-400">opened pull request</span> 97 - {{else if eq .Type "pull_commented"}} 98 - <span class="text-gray-500 dark:text-gray-400">commented on pull request</span> 99 - {{else if eq .Type "pull_merged"}} 100 - <span class="text-gray-500 dark:text-gray-400">merged pull request</span> 101 - {{else if eq .Type "pull_closed"}} 102 - <span class="text-gray-500 dark:text-gray-400">closed pull request</span> 103 - {{end}} 104 - {{if not .Read}} 105 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 106 - {{end}} 107 - </div> 108 - 109 - <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1"> 110 - <span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span> 111 - <span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span> 112 - <span>on</span> 113 - <span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 114 - </div> 115 - </div> 116 - 117 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 118 - {{ template "repo/fragments/time" .Created }} 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" }} 119 24 </div> 120 25 </div> 121 - </a> 122 - {{end}} 26 + {{ end }} 123 27 124 - {{define "repoNotification"}} 125 - {{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 126 - <a 127 - href="{{$url}}" 128 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 129 - > 130 - <div class="flex items-center justify-between"> 131 - <div class="flex items-center gap-2 min-w-0 flex-1"> 132 - <span class="text-yellow-500 dark:text-yellow-400"> 133 - {{ i "star" "w-4 h-4" }} 134 - </span> 28 + {{ define "notificationHeader" }} 29 + {{ $actor := resolve .ActorDid }} 135 30 136 - <div class="min-w-0 flex-1"> 137 - <!-- Single line for stars: actor action subject --> 138 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 139 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 140 - <span class="text-gray-500 dark:text-gray-400">starred</span> 141 - <span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span> 142 - {{if not .Read}} 143 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 144 - {{end}} 145 - </div> 146 - </div> 147 - </div> 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 }} 148 53 149 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 150 - {{ template "repo/fragments/time" .Created }} 151 - </div> 152 - </div> 153 - </a> 154 - {{end}} 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 }} 155 66 156 - {{define "followNotification"}} 157 - {{$url := printf "/%s" (resolve .ActorDid)}} 158 - <a 159 - href="{{$url}}" 160 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 161 - > 162 - <div class="flex items-center justify-between"> 163 - <div class="flex items-center gap-2 min-w-0 flex-1"> 164 - <span class="text-blue-600 dark:text-blue-400"> 165 - {{ i "user-plus" "w-4 h-4" }} 166 - </span> 167 - 168 - <div class="min-w-0 flex-1"> 169 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 170 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 171 - <span class="text-gray-500 dark:text-gray-400">followed you</span> 172 - {{if not .Read}} 173 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 174 - {{end}} 175 - </div> 176 - </div> 177 - </div> 178 - 179 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 180 - {{ template "repo/fragments/time" .Created }} 181 - </div> 182 - </div> 183 - </a> 184 - {{end}} 185 - 186 - {{define "genericNotification"}} 187 - <a 188 - href="#" 189 - class="block no-underline hover:no-underline text-inherit -m-3 p-3" 190 - > 191 - <div class="flex items-center justify-between"> 192 - <div class="flex items-center gap-2 min-w-0 flex-1"> 193 - <span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}"> 194 - {{ i "bell" "w-4 h-4" }} 195 - </span> 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 }} 196 79 197 - <div class="min-w-0 flex-1"> 198 - <div class="flex items-center gap-1 text-gray-900 dark:text-white"> 199 - <span>New notification</span> 200 - {{if not .Read}} 201 - <div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div> 202 - {{end}} 203 - </div> 204 - </div> 205 - </div> 206 - 207 - <div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2"> 208 - {{ template "repo/fragments/time" .Created }} 209 - </div> 210 - </div> 211 - </a> 212 - {{end}} 80 + {{ $url }} 81 + {{ end }}
+44 -25
appview/pages/templates/notifications/list.html
··· 1 1 {{ define "title" }}notifications{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <div class="flex items-center justify-between mb-4"> 4 + <div class="px-6 py-4"> 5 + <div class="flex items-center justify-between"> 6 6 <p class="text-xl font-bold dark:text-white">Notifications</p> 7 7 <a href="/settings/notifications" class="flex items-center gap-2"> 8 8 {{ i "settings" "w-4 h-4" }} ··· 11 11 </div> 12 12 </div> 13 13 14 - <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 15 - {{if .Notifications}} 16 - <div class="flex flex-col gap-4" id="notifications-list"> 17 - {{range .Notifications}} 18 - {{template "notifications/fragments/item" .}} 19 - {{end}} 20 - </div> 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> 21 20 22 - {{if .HasMore}} 23 - <div class="mt-6 text-center"> 24 - <button 25 - class="btn gap-2 group" 26 - hx-get="/notifications?offset={{.NextOffset}}&limit={{.Limit}}" 27 - hx-target="#notifications-list" 28 - hx-swap="beforeend" 29 - > 30 - {{ i "chevron-down" "w-4 h-4 group-[.htmx-request]:hidden" }} 31 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 - Load more 33 - </button> 34 - </div> 35 - {{end}} 36 - {{else}} 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"> 37 23 <div class="text-center py-12"> 38 24 <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 39 25 {{ i "bell-off" "w-16 h-16" }} ··· 41 27 <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 42 28 <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 43 29 </div> 44 - {{end}} 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 }} 45 64 </div> 46 65 {{ end }}
+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-6 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-6 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 }}
+1 -27
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 }} ··· 122 122 </div> 123 123 {{ end }} 124 124 125 - {{ define "issueParticipants" }} 126 - {{ $all := .Issue.Participants }} 127 - {{ $ps := take $all 5 }} 128 - <div> 129 - <div class="py-1 flex items-center text-sm"> 130 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 - </div> 133 - <div class="flex items-center -space-x-3 mt-2"> 134 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 - {{ range $i, $p := $ps }} 136 - <img 137 - src="{{ tinyAvatar . }}" 138 - alt="" 139 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 - /> 141 - {{ end }} 142 - 143 - {{ if gt (len $all) 5 }} 144 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 - +{{ sub (len $all) 5 }} 146 - </span> 147 - {{ end }} 148 - </div> 149 - </div> 150 - {{ end }} 151 125 152 126 {{ define "repoAfter" }} 153 127 <div class="flex flex-col gap-4 mt-4">
+30 -12
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 }}
+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/user/login.html
··· 36 36 placeholder="akshay.tngl.sh" 37 37 /> 38 38 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 39 + Use your <a href="https://atproto.com">AT Protocol</a> 40 40 handle to log in. If you're unsure, this is likely 41 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 42 </span>
+6 -1
appview/pages/templates/user/signup.html
··· 10 10 <script src="/static/htmx.min.js"></script> 11 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 12 <title>sign up &middot; tangled</title> 13 + 14 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 15 </head> 14 16 <body class="flex items-center justify-center min-h-screen"> 15 17 <main class="max-w-md px-6 -mt-4"> ··· 39 41 invite code, desired username, and password in the next 40 42 page to complete your registration. 41 43 </span> 44 + <div class="w-full mt-4 text-center"> 45 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 46 + </div> 42 47 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 48 <span>join now</span> 44 49 </button> 45 50 </form> 46 51 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 53 </p> 49 54 50 55 <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
+35
appview/pulls/pulls.go
··· 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, ··· 213 229 OrderedReactionKinds: models.OrderedReactionKinds, 214 230 Reactions: reactionCountMap, 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,
+65 -1
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 ··· 116 119 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 120 switch r.Method { 118 121 case http.MethodGet: 119 - s.pages.Signup(w) 122 + s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 124 + }) 120 125 case http.MethodPost: 121 126 if s.cf == nil { 122 127 http.Error(w, "signup is disabled", http.StatusFailedDependency) 128 + return 123 129 } 124 130 emailId := r.FormValue("email") 131 + cfToken := r.FormValue("cf-turnstile-response") 125 132 126 133 noticeId := "signup-msg" 134 + 135 + if err := s.validateCaptcha(cfToken, r); err != nil { 136 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 137 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 + return 139 + } 140 + 127 141 if !email.IsValidEmail(emailId) { 128 142 s.pages.Notice(w, noticeId, "Invalid email address.") 129 143 return ··· 255 269 return 256 270 } 257 271 } 272 + 273 + type turnstileResponse struct { 274 + Success bool `json:"success"` 275 + ErrorCodes []string `json:"error-codes,omitempty"` 276 + ChallengeTs string `json:"challenge_ts,omitempty"` 277 + Hostname string `json:"hostname,omitempty"` 278 + } 279 + 280 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 281 + if cfToken == "" { 282 + return errors.New("captcha token is empty") 283 + } 284 + 285 + if s.config.Cloudflare.TurnstileSecretKey == "" { 286 + return errors.New("turnstile secret key not configured") 287 + } 288 + 289 + data := url.Values{} 290 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 291 + data.Set("response", cfToken) 292 + 293 + // include the client IP if we have it 294 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 295 + data.Set("remoteip", remoteIP) 296 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 297 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 298 + data.Set("remoteip", strings.TrimSpace(ips[0])) 299 + } 300 + } else { 301 + data.Set("remoteip", r.RemoteAddr) 302 + } 303 + 304 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 305 + if err != nil { 306 + return fmt.Errorf("failed to verify turnstile token: %w", err) 307 + } 308 + defer resp.Body.Close() 309 + 310 + var turnstileResp turnstileResponse 311 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 312 + return fmt.Errorf("failed to decode turnstile response: %w", err) 313 + } 314 + 315 + if !turnstileResp.Success { 316 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 317 + return errors.New("turnstile validation failed") 318 + } 319 + 320 + return nil 321 + }
+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";