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

Compare changes

Choose any two refs to compare.

Changed files
+4551 -2546
api
appview
config
db
issues
knots
labels
middleware
models
notifications
notify
db
oauth
pages
pagination
pipelines
pulls
repo
settings
signup
spindles
state
strings
xrpcclient
cmd
genjwks
docs
spindle
knotserver
legal
lexicons
nix
types
+30
api/tangled/repodeleteBranch.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.deleteBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 + ) 16 + 17 + // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 + type RepoDeleteBranch_Input struct { 19 + Branch string `json:"branch" cborgen:"branch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 + func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+10
api/tangled/repotree.go
··· 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 // ref: The git reference used 35 Ref string `json:"ref" cborgen:"ref"` 36 } 37 38 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
··· 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // readme: Readme for this file tree 35 + Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 36 // ref: The git reference used 37 Ref string `json:"ref" cborgen:"ref"` 38 + } 39 + 40 + // RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema. 41 + type RepoTree_Readme struct { 42 + // contents: Contents of the readme file 43 + Contents string `json:"contents" cborgen:"contents"` 44 + // filename: Name of the readme file 45 + Filename string `json:"filename" cborgen:"filename"` 46 } 47 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4 -2
appview/config/config.go
··· 72 } 73 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 77 } 78 79 func (cfg RedisConfig) ToURL() string {
··· 72 } 73 74 type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 } 80 81 func (cfg RedisConfig) ToURL() string {
+140
appview/db/db.go
··· 954 return err 955 }) 956 957 return &DB{db}, nil 958 } 959
··· 954 return err 955 }) 956 957 + // add generated at_uri column to pulls table 958 + // 959 + // this requires a full table recreation because stored columns 960 + // cannot be added via alter 961 + // 962 + // disable foreign-keys for the next migration 963 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 + runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 + _, err := tx.Exec(` 966 + create table if not exists pulls_new ( 967 + -- identifiers 968 + id integer primary key autoincrement, 969 + pull_id integer not null, 970 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 + 972 + -- at identifiers 973 + repo_at text not null, 974 + owner_did text not null, 975 + rkey text not null, 976 + 977 + -- content 978 + title text not null, 979 + body text not null, 980 + target_branch text not null, 981 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 + 983 + -- source info 984 + source_branch text, 985 + source_repo_at text, 986 + 987 + -- stacking 988 + stack_id text, 989 + change_id text, 990 + parent_change_id text, 991 + 992 + -- meta 993 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 + 995 + -- constraints 996 + unique(repo_at, pull_id), 997 + unique(at_uri), 998 + foreign key (repo_at) references repos(at_uri) on delete cascade 999 + ); 1000 + `) 1001 + if err != nil { 1002 + return err 1003 + } 1004 + 1005 + // transfer data 1006 + _, err = tx.Exec(` 1007 + insert into pulls_new ( 1008 + id, pull_id, repo_at, owner_did, rkey, 1009 + title, body, target_branch, state, 1010 + source_branch, source_repo_at, 1011 + stack_id, change_id, parent_change_id, 1012 + created 1013 + ) 1014 + select 1015 + id, pull_id, repo_at, owner_did, rkey, 1016 + title, body, target_branch, state, 1017 + source_branch, source_repo_at, 1018 + stack_id, change_id, parent_change_id, 1019 + created 1020 + from pulls; 1021 + `) 1022 + if err != nil { 1023 + return err 1024 + } 1025 + 1026 + // drop old table 1027 + _, err = tx.Exec(`drop table pulls`) 1028 + if err != nil { 1029 + return err 1030 + } 1031 + 1032 + // rename new table 1033 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 + return err 1035 + }) 1036 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 + 1038 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 + // 1040 + // this requires a full table recreation because stored columns 1041 + // cannot be added via alter 1042 + // 1043 + // disable foreign-keys for the next migration 1044 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 + runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 + _, err := tx.Exec(` 1047 + create table if not exists pull_submissions_new ( 1048 + -- identifiers 1049 + id integer primary key autoincrement, 1050 + pull_at text not null, 1051 + 1052 + -- content, these are immutable, and require a resubmission to update 1053 + round_number integer not null default 0, 1054 + patch text, 1055 + source_rev text, 1056 + 1057 + -- meta 1058 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 + 1060 + -- constraints 1061 + unique(pull_at, round_number), 1062 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 + ); 1064 + `) 1065 + if err != nil { 1066 + return err 1067 + } 1068 + 1069 + // transfer data, constructing pull_at from pulls table 1070 + _, err = tx.Exec(` 1071 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 + select 1073 + ps.id, 1074 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 + ps.round_number, 1076 + ps.patch, 1077 + ps.created 1078 + from pull_submissions ps 1079 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 + `) 1081 + if err != nil { 1082 + return err 1083 + } 1084 + 1085 + // drop old table 1086 + _, err = tx.Exec(`drop table pull_submissions`) 1087 + if err != nil { 1088 + return err 1089 + } 1090 + 1091 + // rename new table 1092 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 + return err 1094 + }) 1095 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 + 1097 return &DB{db}, nil 1098 } 1099
+13 -9
appview/db/email.go
··· 71 return did, nil 72 } 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 80 if isVerifiedFilter { 81 verifiedFilter = 1 82 } 83 84 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 88 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 92 } 93 94 query := ` ··· 104 return nil, err 105 } 106 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 110 for rows.Next() { 111 var email, did string
··· 71 return did, nil 72 } 73 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 80 if isVerifiedFilter { 81 verifiedFilter = 1 82 } 83 + 84 + assoc := make(map[string]string) 85 86 // Create placeholders for the IN clause 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 89 90 args[0] = verifiedFilter 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 98 } 99 100 query := ` ··· 110 return nil, err 111 } 112 defer rows.Close() 113 114 for rows.Next() { 115 var email, did string
+34
appview/db/language.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) 9 ··· 82 83 return nil 84 }
··· 1 package db 2 3 import ( 4 + "database/sql" 5 "fmt" 6 "strings" 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.org/core/appview/models" 10 ) 11 ··· 84 85 return nil 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+18 -25
appview/db/notifications.go
··· 3 import ( 4 "context" 5 "database/sql" 6 "fmt" 7 "time" 8 9 "tangled.org/core/appview/models" ··· 248 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 249 } 250 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 - } 256 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 - } 262 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()...) 274 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) 279 } 280 281 return count, nil
··· 3 import ( 4 "context" 5 "database/sql" 6 + "errors" 7 "fmt" 8 + "strings" 9 "time" 10 11 "tangled.org/core/appview/models" ··· 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
+145 -229
appview/db/pulls.go
··· 1 package db 2 3 import ( 4 "database/sql" 5 "fmt" 6 - "log" 7 "sort" 8 "strings" 9 "time" ··· 87 pull.ID = int(id) 88 89 _, 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 return err 94 } 95 ··· 108 } 109 110 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 111 - pulls := make(map[int]*models.Pull) 112 113 var conditions []string 114 var args []any ··· 211 pull.ParentChangeId = parentChangeId.String 212 } 213 214 - pulls[pull.PullId] = &pull 215 } 216 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 230 for _, p := range pulls { 231 - args[idx] = p.RepoAt 232 - idx += 1 233 } 234 - for _, p := range pulls { 235 - args[idx] = p.PullId 236 - idx += 1 237 - } 238 - submissionsRows, err := e.Query(submissionsQuery, args...) 239 if err != nil { 240 - return nil, err 241 } 242 - defer submissionsRows.Close() 243 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 258 } 259 260 - createdTime, err := time.Parse(time.RFC3339, createdAt) 261 - if err != nil { 262 - return nil, err 263 } 264 - s.Created = createdTime 265 266 - if sourceRev.Valid { 267 - s.SourceRev = sourceRev.String 268 } 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 - } 274 } 275 - if err := rows.Err(); err != nil { 276 - return nil, err 277 } 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 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 310 } 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 } 318 319 orderedByPullId := []*models.Pull{} ··· 332 } 333 334 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 - ) 379 if err != nil { 380 return nil, err 381 } 382 - 383 - createdTime, err := time.Parse(time.RFC3339, createdAt) 384 - if err != nil { 385 - return nil, err 386 } 387 - pull.Created = createdTime 388 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 - } 401 } 402 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 411 } 412 413 - submissionsQuery := ` 414 select 415 - id, pull_id, repo_at, round_number, patch, created, source_rev 416 from 417 pull_submissions 418 - where 419 - repo_at = ? and pull_id = ? 420 - ` 421 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 422 if err != nil { 423 return nil, err 424 } 425 - defer submissionsRows.Close() 426 427 - submissionsMap := make(map[int]*models.PullSubmission) 428 429 - for submissionsRows.Next() { 430 var submission models.PullSubmission 431 - var submissionCreatedStr string 432 - var submissionSourceRev sql.NullString 433 - err := submissionsRows.Scan( 434 &submission.ID, 435 - &submission.PullId, 436 - &submission.RepoAt, 437 &submission.RoundNumber, 438 &submission.Patch, 439 - &submissionCreatedStr, 440 - &submissionSourceRev, 441 ) 442 if err != nil { 443 return nil, err 444 } 445 446 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 447 if err != nil { 448 return nil, err 449 } 450 - submission.Created = submissionCreatedTime 451 452 - if submissionSourceRev.Valid { 453 - submission.SourceRev = submissionSourceRev.String 454 } 455 456 - submissionsMap[submission.ID] = &submission 457 } 458 - if err = submissionsRows.Close(); err != nil { 459 return nil, err 460 } 461 - if len(submissionsMap) == 0 { 462 - return &pull, nil 463 } 464 465 var args []any 466 - for k := range submissionsMap { 467 - args = append(args, k) 468 } 469 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 470 - commentsQuery := fmt.Sprintf(` 471 select 472 id, 473 pull_id, ··· 479 created 480 from 481 pull_comments 482 - where 483 - submission_id IN (%s) 484 order by 485 created asc 486 - `, inClause) 487 - commentsRows, err := e.Query(commentsQuery, args...) 488 if err != nil { 489 return nil, err 490 } 491 - defer commentsRows.Close() 492 493 - for commentsRows.Next() { 494 var comment models.PullComment 495 - var commentCreatedStr string 496 - err := commentsRows.Scan( 497 &comment.ID, 498 &comment.PullId, 499 &comment.SubmissionId, ··· 501 &comment.OwnerDid, 502 &comment.CommentAt, 503 &comment.Body, 504 - &commentCreatedStr, 505 ) 506 if err != nil { 507 return nil, err 508 } 509 510 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 511 - if err != nil { 512 - return nil, err 513 } 514 - comment.Created = commentCreatedTime 515 516 - // Add the comment to its submission 517 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 518 - submission.Comments = append(submission.Comments, comment) 519 - } 520 521 - } 522 - if err = commentsRows.Err(); err != nil { 523 return nil, err 524 } 525 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 544 } 545 546 // timeframe here is directly passed into the sql query filter, and any ··· 677 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 678 newRoundNumber := len(pull.Submissions) 679 _, 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) 683 684 return err 685 }
··· 1 package db 2 3 import ( 4 + "cmp" 5 "database/sql" 6 + "errors" 7 "fmt" 8 + "maps" 9 + "slices" 10 "sort" 11 "strings" 12 "time" ··· 90 pull.ID = int(id) 91 92 _, err = tx.Exec(` 93 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 + values (?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 96 return err 97 } 98 ··· 111 } 112 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 115 116 var conditions []string 117 var args []any ··· 214 pull.ParentChangeId = parentChangeId.String 215 } 216 217 + pulls[pull.PullAt()] = &pull 218 } 219 220 + var pullAts []syntax.ATURI 221 for _, p := range pulls { 222 + pullAts = append(pullAts, p.PullAt()) 223 } 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 if err != nil { 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 227 } 228 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 232 } 233 + } 234 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 239 + } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 243 } 244 + } 245 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 + for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 } 252 + } 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 256 } 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 } 261 for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 266 } 267 } 268 269 orderedByPullId := []*models.Pull{} ··· 282 } 283 284 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 286 if err != nil { 287 return nil, err 288 } 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 291 } 292 293 + return pulls[0], nil 294 + } 295 + 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 303 } 304 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 308 } 309 310 + query := fmt.Sprintf(` 311 select 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 318 from 319 pull_submissions 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 326 if err != nil { 327 return nil, err 328 } 329 + defer rows.Close() 330 331 + submissionMap := make(map[int]*models.PullSubmission) 332 333 + for rows.Next() { 334 var submission models.PullSubmission 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 338 &submission.ID, 339 + &submission.PullAt, 340 &submission.RoundNumber, 341 &submission.Patch, 342 + &createdAt, 343 + &sourceRev, 344 ) 345 if err != nil { 346 return nil, err 347 } 348 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 350 if err != nil { 351 return nil, err 352 } 353 + submission.Created = createdTime 354 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 357 } 358 359 + submissionMap[submission.ID] = &submission 360 + } 361 + 362 + if err := rows.Err(); err != nil { 363 + return nil, err 364 } 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 370 return nil, err 371 } 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 376 + } 377 + 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 382 } 383 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 389 + } 390 + 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 396 var args []any 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 400 } 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 405 + } 406 + 407 + query := fmt.Sprintf(` 408 select 409 id, 410 pull_id, ··· 416 created 417 from 418 pull_comments 419 + %s 420 order by 421 created asc 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 425 if err != nil { 426 return nil, err 427 } 428 + defer rows.Close() 429 430 + var comments []models.PullComment 431 + for rows.Next() { 432 var comment models.PullComment 433 + var createdAt string 434 + err := rows.Scan( 435 &comment.ID, 436 &comment.PullId, 437 &comment.SubmissionId, ··· 439 &comment.OwnerDid, 440 &comment.CommentAt, 441 &comment.Body, 442 + &createdAt, 443 ) 444 if err != nil { 445 return nil, err 446 } 447 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 450 } 451 452 + comments = append(comments, comment) 453 + } 454 455 + if err := rows.Err(); err != nil { 456 return nil, err 457 } 458 459 + return comments, nil 460 } 461 462 // timeframe here is directly passed into the sql query filter, and any ··· 593 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 594 newRoundNumber := len(pull.Submissions) 595 _, err := e.Exec(` 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 599 600 return err 601 }
+34 -7
appview/db/reaction.go
··· 62 return count, nil 63 } 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 67 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 71 } 72 - countMap[kind] = count 73 } 74 - return countMap, nil 75 } 76 77 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
··· 62 return count, nil 63 } 64 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 81 for _, kind := range models.OrderedReactionKinds { 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 91 } 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 99 } 100 + 101 + return reactionMap, rows.Err() 102 } 103 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+38 -10
appview/db/timeline.go
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 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) { 13 var events []models.TimelineEvent 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 16 if err != nil { 17 return nil, err 18 } 19 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 21 if err != nil { 22 return nil, err 23 } 24 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 26 if err != nil { 27 return nil, err 28 } ··· 70 return isStarred, starCount 71 } 72 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 75 if err != nil { 76 return nil, err 77 } ··· 125 return events, nil 126 } 127 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 130 if err != nil { 131 return nil, err 132 } ··· 166 return events, nil 167 } 168 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 171 if err != nil { 172 return nil, err 173 }
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 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, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 var events []models.TimelineEvent 14 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) 29 if err != nil { 30 return nil, err 31 } 32 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 34 if err != nil { 35 return nil, err 36 } 37 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 39 if err != nil { 40 return nil, err 41 } ··· 83 return isStarred, starCount 84 } 85 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...) 93 if err != nil { 94 return nil, err 95 } ··· 143 return events, nil 144 } 145 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...) 153 if err != nil { 154 return nil, err 155 } ··· 189 return events, nil 190 } 191 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...) 199 if err != nil { 200 return nil, err 201 }
+18 -14
appview/issues/issues.go
··· 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 "tangled.org/core/idresolver" 31 tlog "tangled.org/core/log" 32 "tangled.org/core/tid" ··· 83 return 84 } 85 86 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 if err != nil { 88 l.Error("failed to get issue reactions", "err", err) 89 } ··· 115 Issue: issue, 116 CommentList: issue.CommentList(), 117 OrderedReactionKinds: models.OrderedReactionKinds, 118 - Reactions: reactionCountMap, 119 UserReacted: userReactions, 120 LabelDefs: defs, 121 }) ··· 166 return 167 } 168 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 if err != nil { 171 l.Error("failed to get record", "err", err) 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 return 174 } 175 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 Collection: tangled.RepoIssueNSID, 178 Repo: user.Did, 179 Rkey: newIssue.Rkey, ··· 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 Collection: tangled.RepoIssueNSID, 246 Repo: issue.Did, 247 Rkey: issue.Rkey, ··· 408 } 409 410 // create a record first 411 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 412 Collection: tangled.RepoIssueCommentNSID, 413 Repo: comment.Did, 414 Rkey: comment.Rkey, ··· 559 // rkey is optional, it was introduced later 560 if newComment.Rkey != "" { 561 // update the record on pds 562 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 if err != nil { 564 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 return 567 } 568 569 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 570 Collection: tangled.RepoIssueCommentNSID, 571 Repo: user.Did, 572 Rkey: newComment.Rkey, ··· 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 return 735 } 736 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 737 Collection: tangled.RepoIssueCommentNSID, 738 Repo: user.Did, 739 Rkey: comment.Rkey, ··· 798 return 799 } 800 801 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 802 if err != nil { 803 log.Println("failed to fetch labels", err) 804 rp.pages.Error503(w) ··· 861 rp.pages.Notice(w, "issues", "Failed to create issue.") 862 return 863 } 864 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 865 Collection: tangled.RepoIssueNSID, 866 Repo: user.Did, 867 Rkey: issue.Rkey, ··· 919 // this is used to rollback changes made to the PDS 920 // 921 // it is a no-op if the provided ATURI is empty 922 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 923 if aturi == "" { 924 return nil 925 } ··· 930 repo := parsed.Authority().String() 931 rkey := parsed.RecordKey().String() 932 933 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 934 Collection: collection, 935 Repo: repo, 936 Rkey: rkey,
··· 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" ··· 27 "tangled.org/core/appview/pagination" 28 "tangled.org/core/appview/reporesolver" 29 "tangled.org/core/appview/validator" 30 "tangled.org/core/idresolver" 31 tlog "tangled.org/core/log" 32 "tangled.org/core/tid" ··· 83 return 84 } 85 86 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 87 if err != nil { 88 l.Error("failed to get issue reactions", "err", err) 89 } ··· 115 Issue: issue, 116 CommentList: issue.CommentList(), 117 OrderedReactionKinds: models.OrderedReactionKinds, 118 + Reactions: reactionMap, 119 UserReacted: userReactions, 120 LabelDefs: defs, 121 }) ··· 166 return 167 } 168 169 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 if err != nil { 171 l.Error("failed to get record", "err", err) 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 return 174 } 175 176 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 Collection: tangled.RepoIssueNSID, 178 Repo: user.Did, 179 Rkey: newIssue.Rkey, ··· 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } 244 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 Collection: tangled.RepoIssueNSID, 246 Repo: issue.Did, 247 Rkey: issue.Rkey, ··· 408 } 409 410 // create a record first 411 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 412 Collection: tangled.RepoIssueCommentNSID, 413 Repo: comment.Did, 414 Rkey: comment.Rkey, ··· 559 // rkey is optional, it was introduced later 560 if newComment.Rkey != "" { 561 // update the record on pds 562 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 if err != nil { 564 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 return 567 } 568 569 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 570 Collection: tangled.RepoIssueCommentNSID, 571 Repo: user.Did, 572 Rkey: newComment.Rkey, ··· 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 return 735 } 736 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 737 Collection: tangled.RepoIssueCommentNSID, 738 Repo: user.Did, 739 Rkey: comment.Rkey, ··· 798 return 799 } 800 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 806 if err != nil { 807 log.Println("failed to fetch labels", err) 808 rp.pages.Error503(w) ··· 865 rp.pages.Notice(w, "issues", "Failed to create issue.") 866 return 867 } 868 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 869 Collection: tangled.RepoIssueNSID, 870 Repo: user.Did, 871 Rkey: issue.Rkey, ··· 923 // this is used to rollback changes made to the PDS 924 // 925 // it is a no-op if the provided ATURI is empty 926 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 927 if aturi == "" { 928 return nil 929 } ··· 934 repo := parsed.Authority().String() 935 rkey := parsed.RecordKey().String() 936 937 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 938 Collection: collection, 939 Repo: repo, 940 Rkey: rkey,
+6 -6
appview/knots/knots.go
··· 185 return 186 } 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
··· 185 return 186 } 187 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
+12 -12
appview/labels/labels.go
··· 9 "net/http" 10 "time" 11 12 - comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/go-chi/chi/v5" 16 - 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/middleware" ··· 21 "tangled.org/core/appview/oauth" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/log" 26 "tangled.org/core/rbac" 27 "tangled.org/core/tid" 28 ) 29 30 type Labels struct { ··· 162 } 163 } 164 165 - // reduce the opset 166 - labelOps = models.ReduceLabelOps(labelOps) 167 - 168 for i := range labelOps { 169 def := actx.Defs[labelOps[i].OperandKey] 170 if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { ··· 172 return 173 } 174 } 175 176 // next, apply all ops introduced in this request and filter out ones that are no-ops 177 validLabelOps := labelOps[:0] ··· 196 return 197 } 198 199 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.LabelOpNSID, 201 Repo: did, 202 Rkey: rkey, ··· 252 // this is used to rollback changes made to the PDS 253 // 254 // it is a no-op if the provided ATURI is empty 255 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 256 if aturi == "" { 257 return nil 258 } ··· 263 repo := parsed.Authority().String() 264 rkey := parsed.RecordKey().String() 265 266 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 267 Collection: collection, 268 Repo: repo, 269 Rkey: rkey,
··· 9 "net/http" 10 "time" 11 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/middleware" ··· 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/validator" 19 "tangled.org/core/log" 20 "tangled.org/core/rbac" 21 "tangled.org/core/tid" 22 + 23 + comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + atpclient "github.com/bluesky-social/indigo/atproto/client" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + "github.com/go-chi/chi/v5" 28 ) 29 30 type Labels struct { ··· 162 } 163 } 164 165 for i := range labelOps { 166 def := actx.Defs[labelOps[i].OperandKey] 167 if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { ··· 169 return 170 } 171 } 172 + 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 175 176 // next, apply all ops introduced in this request and filter out ones that are no-ops 177 validLabelOps := labelOps[:0] ··· 196 return 197 } 198 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.LabelOpNSID, 201 Repo: did, 202 Rkey: rkey, ··· 252 // this is used to rollback changes made to the PDS 253 // 254 // it is a no-op if the provided ATURI is empty 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 if aturi == "" { 257 return nil 258 } ··· 263 repo := parsed.Authority().String() 264 rkey := parsed.RecordKey().String() 265 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 Collection: collection, 268 Repo: repo, 269 Rkey: rkey,
+5 -18
appview/middleware/middleware.go
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 - func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 - return func(next http.Handler) http.Handler { 48 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 - _, auth, err := mw.oauth.GetSession(r) 50 - if err != nil { 51 - log.Println("could not refresh session", "err", err, "auth", auth) 52 - } 53 - 54 - next.ServeHTTP(w, r) 55 - }) 56 - } 57 - } 58 - 59 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 60 return func(next http.Handler) http.Handler { 61 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 returnURL := "/" ··· 76 } 77 } 78 79 - _, auth, err := a.GetSession(r) 80 if err != nil { 81 - log.Println("not logged in, redirecting", "err", err) 82 redirectFunc(w, r) 83 return 84 } 85 86 - if !auth { 87 - log.Printf("not logged in, redirecting") 88 redirectFunc(w, r) 89 return 90 }
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 returnURL := "/" ··· 63 } 64 } 65 66 + sess, err := o.ResumeSession(r) 67 if err != nil { 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 redirectFunc(w, r) 70 return 71 } 72 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 redirectFunc(w, r) 76 return 77 }
+19 -17
appview/models/label.go
··· 232 } 233 234 var ops []LabelOp 235 - for _, o := range record.Add { 236 if o != nil { 237 op := mkOp(o) 238 - op.Operation = LabelOperationAdd 239 ops = append(ops, op) 240 } 241 } 242 - for _, o := range record.Delete { 243 if o != nil { 244 op := mkOp(o) 245 - op.Operation = LabelOperationDel 246 ops = append(ops, op) 247 } 248 } ··· 460 return result 461 } 462 463 func DefaultLabelDefs() []string { 464 - rkeys := []string{ 465 - "wontfix", 466 - "duplicate", 467 - "assignee", 468 - "good-first-issue", 469 - "documentation", 470 } 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 } 479 480 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
··· 232 } 233 234 var ops []LabelOp 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 237 if o != nil { 238 op := mkOp(o) 239 + op.Operation = LabelOperationDel 240 ops = append(ops, op) 241 } 242 } 243 + for _, o := range record.Add { 244 if o != nil { 245 op := mkOp(o) 246 + op.Operation = LabelOperationAdd 247 ops = append(ops, op) 248 } 249 } ··· 461 return result 462 } 463 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 } 480 } 481 482 func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
+29 -1
appview/models/notifications.go
··· 1 package models 2 3 - import "time" 4 5 type NotificationType string 6 ··· 30 RepoId *int64 31 IssueId *int64 32 PullId *int64 33 } 34 35 type NotificationWithEntity struct {
··· 1 package models 2 3 + import ( 4 + "time" 5 + ) 6 7 type NotificationType string 8 ··· 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 {
+51 -4
appview/models/pull.go
··· 77 PullSource *PullSource 78 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 81 } 82 83 func (p Pull) AsRecord() tangled.RepoPull { ··· 125 126 type PullSubmission struct { 127 // ids 128 - ID int 129 - PullId int 130 131 // at ids 132 - RepoAt syntax.ATURI 133 134 // content 135 RoundNumber int ··· 207 return p.StackId != "" 208 } 209 210 func (s PullSubmission) IsFormatPatch() bool { 211 return patchutil.IsFormatPatch(s.Patch) 212 } ··· 219 } 220 221 return patches 222 } 223 224 type Stack []*Pull ··· 308 309 return mergeable 310 }
··· 77 PullSource *PullSource 78 79 // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 } 83 84 func (p Pull) AsRecord() tangled.RepoPull { ··· 126 127 type PullSubmission struct { 128 // ids 129 + ID int 130 131 // at ids 132 + PullAt syntax.ATURI 133 134 // content 135 RoundNumber int ··· 207 return p.StackId != "" 208 } 209 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 232 func (s PullSubmission) IsFormatPatch() bool { 233 return patchutil.IsFormatPatch(s.Patch) 234 } ··· 241 } 242 243 return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 264 } 265 266 type Stack []*Pull ··· 350 351 return mergeable 352 } 353 + 354 + type BranchDeleteStatus struct { 355 + Repo *Repo 356 + Branch string 357 + }
+5
appview/models/reaction.go
··· 55 Rkey string 56 Kind ReactionKind 57 }
··· 55 Rkey string 56 Kind ReactionKind 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+5
appview/models/repo.go
··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 }
··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+41 -48
appview/notifications/notifications.go
··· 10 "tangled.org/core/appview/middleware" 11 "tangled.org/core/appview/oauth" 12 "tangled.org/core/appview/pages" 13 ) 14 15 type Notifications struct { ··· 29 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 30 r := chi.NewRouter() 31 32 - r.Use(middleware.AuthMiddleware(n.oauth)) 33 34 - r.Get("/", n.notificationsPage) 35 - 36 - r.Get("/count", n.getUnreadCount) 37 - r.Post("/{id}/read", n.markRead) 38 - r.Post("/read-all", n.markAllRead) 39 - r.Delete("/{id}", n.deleteNotification) 40 41 return r 42 } 43 44 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 45 - userDid := n.oauth.GetDid(r) 46 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 - } 55 } 56 57 - offset := 0 // default 58 - if offsetStr != "" { 59 - if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { 60 - offset = o 61 - } 62 } 63 64 - notifications, err := n.db.GetNotificationsWithEntities(r.Context(), userDid, limit+1, offset) 65 if err != nil { 66 log.Println("failed to get notifications:", err) 67 n.pages.Error500(w) 68 return 69 } 70 71 - hasMore := len(notifications) > limit 72 - if hasMore { 73 - notifications = notifications[:limit] 74 - } 75 - 76 - err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 77 if err != nil { 78 log.Println("failed to mark notifications as read:", err) 79 } 80 81 unreadCount := 0 82 83 - user := n.oauth.GetUser(r) 84 - if user == nil { 85 - http.Error(w, "Failed to get user", http.StatusInternalServerError) 86 - return 87 - } 88 - 89 - params := pages.NotificationsParams{ 90 LoggedInUser: user, 91 Notifications: notifications, 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 - } 104 } 105 106 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) 110 if err != nil { 111 http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 112 return
··· 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 { ··· 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
+8 -48
appview/notify/db/db.go
··· 30 31 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 var err error 33 - repos, err := db.GetRepos(n.db, 1, 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 - if len(repos) == 0 { 39 - log.Printf("NewStar: no repo found for %s", star.RepoAt) 40 - return 41 - } 42 - repo := repos[0] 43 44 // don't notify yourself 45 if repo.Did == star.StarredByDid { ··· 76 } 77 78 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))) 80 if err != nil { 81 log.Printf("NewIssue: failed to get repos: %v", err) 82 return 83 } 84 - if len(repos) == 0 { 85 - log.Printf("NewIssue: no repo found for %s", issue.RepoAt) 86 - return 87 - } 88 - repo := repos[0] 89 90 if repo.Did == issue.Did { 91 return ··· 129 } 130 issue := issues[0] 131 132 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 133 if err != nil { 134 log.Printf("NewIssueComment: failed to get repos: %v", err) 135 return 136 } 137 - if len(repos) == 0 { 138 - log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt) 139 - return 140 - } 141 - repo := repos[0] 142 143 recipients := make(map[string]bool) 144 ··· 211 } 212 213 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))) 215 if err != nil { 216 log.Printf("NewPull: failed to get repos: %v", err) 217 return 218 } 219 - if len(repos) == 0 { 220 - log.Printf("NewPull: no repo found for %s", pull.RepoAt) 221 - return 222 - } 223 - repo := repos[0] 224 225 if repo.Did == pull.OwnerDid { 226 return ··· 266 } 267 pull := pulls[0] 268 269 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt)) 270 if err != nil { 271 log.Printf("NewPullComment: failed to get repos: %v", err) 272 return 273 } 274 - if len(repos) == 0 { 275 - log.Printf("NewPullComment: no repo found for %s", comment.RepoAt) 276 - return 277 - } 278 - repo := repos[0] 279 280 recipients := make(map[string]bool) 281 ··· 335 336 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 337 // Get repo details 338 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 339 if err != nil { 340 log.Printf("NewIssueClosed: failed to get repos: %v", err) 341 return 342 } 343 - if len(repos) == 0 { 344 - log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt) 345 - return 346 - } 347 - repo := repos[0] 348 349 // Don't notify yourself 350 if repo.Did == issue.Did { ··· 380 381 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 382 // Get repo details 383 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 384 if err != nil { 385 log.Printf("NewPullMerged: failed to get repos: %v", err) 386 return 387 } 388 - if len(repos) == 0 { 389 - log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt) 390 - return 391 - } 392 - repo := repos[0] 393 394 // Don't notify yourself 395 if repo.Did == pull.OwnerDid { ··· 425 426 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 427 // Get repo details 428 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 429 if err != nil { 430 log.Printf("NewPullClosed: failed to get repos: %v", err) 431 return 432 } 433 - if len(repos) == 0 { 434 - log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt) 435 - return 436 - } 437 - repo := repos[0] 438 439 // Don't notify yourself 440 if repo.Did == pull.OwnerDid {
··· 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 { ··· 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 ··· 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 ··· 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 ··· 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 ··· 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 { ··· 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 { ··· 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 {
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
···
+2 -1
appview/oauth/consts.go
··· 1 package oauth 2 3 const ( 4 - SessionName = "appview-session" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 SessionPds = "pds" 8 SessionAccessJwt = "accessJwt" 9 SessionRefreshJwt = "refreshJwt"
··· 1 package oauth 2 3 const ( 4 + SessionName = "appview-session-v2" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 + SessionId = "id" 8 SessionPds = "pds" 9 SessionAccessJwt = "accessJwt" 10 SessionRefreshJwt = "refreshJwt"
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - tangled "tangled.org/core/api/tangled" 20 - sessioncache "tangled.org/core/appview/cache/session" 21 - "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 23 - "tangled.org/core/appview/middleware" 24 - "tangled.org/core/appview/oauth" 25 - "tangled.org/core/appview/oauth/client" 26 - "tangled.org/core/appview/pages" 27 - "tangled.org/core/consts" 28 - "tangled.org/core/idresolver" 29 - "tangled.org/core/rbac" 30 - "tangled.org/core/tid" 31 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
···
+65
appview/oauth/handler.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + func (o *OAuth) Router() http.Handler { 13 + r := chi.NewRouter() 14 + 15 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 16 + r.Get("/oauth/jwks.json", o.jwks) 17 + r.Get("/oauth/callback", o.callback) 18 + return r 19 + } 20 + 21 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 + doc := o.ClientApp.Config.ClientMetadata() 23 + doc.JWKSURI = &o.JwksUri 24 + 25 + w.Header().Set("Content-Type", "application/json") 26 + if err := json.NewEncoder(w).Encode(doc); err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + } 31 + 32 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 + jwks := o.Config.OAuth.Jwks 34 + pubKey, err := pubKeyFromJwk(jwks) 35 + if err != nil { 36 + log.Printf("error parsing public key: %v", err) 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + response := map[string]any{ 42 + "keys": []jwk.Key{pubKey}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.WriteHeader(http.StatusOK) 47 + json.NewEncoder(w).Encode(response) 48 + } 49 + 50 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 + ctx := r.Context() 52 + 53 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + if err := o.SaveSession(w, r, sessData); err != nil { 60 + http.Error(w, err.Error(), http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/", http.StatusFound) 65 + }
+107 -202
appview/oauth/oauth.go
··· 1 package oauth 2 3 import ( 4 "fmt" 5 - "log" 6 "net/http" 7 - "net/url" 8 "time" 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 13 "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/oauth/client" 15 - xrpc "tangled.org/core/appview/xrpcclient" 16 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 18 ) 19 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 25 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 31 } 32 } 33 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 36 } 37 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 39 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 41 if err != nil { 42 return err 43 } 44 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 48 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 50 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 52 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 65 } 66 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 74 } 75 76 - did := userSession.Values[SessionDid].(string) 77 78 - err = o.sess.DeleteSession(r.Context(), did) 79 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 81 } 82 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 86 } 87 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 92 } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 - if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 100 } 101 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 103 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 - self := o.ClientMetadata() 113 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 119 120 - if err != nil { 121 - return nil, false, err 122 - } 123 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 128 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 140 } 141 - 142 - return session, auth, nil 143 } 144 145 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 149 } 150 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 153 154 - if err != nil || clientSession.IsNew { 155 return nil 156 } 157 158 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 162 } 163 } 164 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 170 } 171 172 - return clientSession.Values[SessionDid].(string) 173 } 174 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 177 if err != nil { 178 return nil, fmt.Errorf("error getting session: %w", err) 179 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 208 } 209 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 // this is a higher level abstraction on ServerGetServiceAuth 213 type ServiceClientOpts struct { 214 service string ··· 259 return scheme + s.service 260 } 261 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 263 opts := ServiceClientOpts{} 264 for _, o := range os { 265 o(&opts) 266 } 267 268 - authorizedClient, err := o.AuthorizedClient(r) 269 if err != nil { 270 return nil, err 271 } ··· 276 opts.exp = sixty 277 } 278 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 280 if err != nil { 281 return nil, err 282 } 283 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), ··· 291 }, 292 }, nil 293 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
··· 1 package oauth 2 3 import ( 4 + "errors" 5 "fmt" 6 "net/http" 7 "time" 8 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + xrpc "github.com/bluesky-social/indigo/xrpc" 14 "github.com/gorilla/sessions" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 16 "tangled.org/core/appview/config" 17 ) 18 19 + func New(config *config.Config) (*OAuth, error) { 20 + 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 23 24 + if config.Core.Dev { 25 + clientUri = "http://127.0.0.1:3000" 26 + callbackUri := clientUri + "/oauth/callback" 27 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 28 + } else { 29 + clientUri = config.Core.AppviewHost 30 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 31 + callbackUri := clientUri + "/oauth/callback" 32 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 33 } 34 + 35 + jwksUri := clientUri + "/oauth/jwks.json" 36 + 37 + authStore, err := NewRedisStore(config.Redis.ToURL()) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 + 44 + return &OAuth{ 45 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 + Config: config, 47 + SessStore: sessStore, 48 + JwksUri: jwksUri, 49 + }, nil 50 } 51 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 57 } 58 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 60 // first we save the did in the user session 61 + userSession, err := o.SessStore.Get(r, SessionName) 62 if err != nil { 63 return err 64 } 65 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 69 userSession.Values[SessionAuthenticated] = true 70 + return userSession.Save(r, w) 71 + } 72 + 73 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 74 + userSession, err := o.SessStore.Get(r, SessionName) 75 if err != nil { 76 + return nil, fmt.Errorf("error getting user session: %w", err) 77 } 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 80 } 81 82 + d := userSession.Values[SessionDid].(string) 83 + sessDid, err := syntax.ParseDID(d) 84 + if err != nil { 85 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 86 } 87 88 + sessId := userSession.Values[SessionId].(string) 89 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 91 if err != nil { 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 93 } 94 95 + return clientSess, nil 96 } 97 98 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 99 + userSession, err := o.SessStore.Get(r, SessionName) 100 + if err != nil { 101 + return fmt.Errorf("error getting user session: %w", err) 102 } 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 105 } 106 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 109 if err != nil { 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 111 } 112 113 + sessId := userSession.Values[SessionId].(string) 114 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 117 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 121 122 + return errors.Join(err1, err2) 123 + } 124 125 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 + k, err := jwk.ParseKey([]byte(jwks)) 127 + if err != nil { 128 + return nil, err 129 + } 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 133 } 134 + return pubKey, nil 135 } 136 137 type User struct { 138 + Did string 139 + Pds string 140 } 141 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 144 145 + if err != nil || sess.IsNew { 146 return nil 147 } 148 149 return &User{ 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 152 } 153 } 154 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 158 } 159 160 + return "" 161 } 162 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 165 if err != nil { 166 return nil, fmt.Errorf("error getting session: %w", err) 167 } 168 + return session.APIClient(), nil 169 } 170 171 // this is a higher level abstraction on ServerGetServiceAuth 172 type ServiceClientOpts struct { 173 service string ··· 218 return scheme + s.service 219 } 220 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 222 opts := ServiceClientOpts{} 223 for _, o := range os { 224 o(&opts) 225 } 226 227 + client, err := o.AuthorizedClient(r) 228 if err != nil { 229 return nil, err 230 } ··· 235 opts.exp = sixty 236 } 237 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 239 if err != nil { 240 return nil, err 241 } 242 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 245 AccessJwt: resp.Token, 246 }, 247 Host: opts.Host(), ··· 250 }, 251 }, nil 252 }
+147
appview/oauth/store.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+7 -7
appview/pages/funcmap.go
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 - "cssContentHash": CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 - return p.avatarUri(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 - return p.avatarUri(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 310 } 311 } 312 313 - func (p *Pages) avatarUri(handle, size string) string { 314 handle = strings.TrimPrefix(handle, "@") 315 316 secret := p.avatar.SharedSecret ··· 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 } 327 328 - func icon(name string, classes []string) (template.HTML, error) { 329 iconPath := filepath.Join("static", "icons", name) 330 331 if filepath.Ext(name) == "" {
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 + data, err := p.icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 + data, _ = p.icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 + "cssContentHash": p.CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 + return p.AvatarUrl(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 + return p.AvatarUrl(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 310 } 311 } 312 313 + func (p *Pages) AvatarUrl(handle, size string) string { 314 handle = strings.TrimPrefix(handle, "@") 315 316 secret := p.avatar.SharedSecret ··· 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 } 327 328 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 329 iconPath := filepath.Join("static", "icons", name) 330 331 if filepath.Ext(name) == "" {
+156
appview/pages/legal/privacy.md
···
··· 1 + **Last updated:** September 26, 2025 2 + 3 + This Privacy Policy describes how Tangled ("we," "us," or "our") 4 + collects, uses, and shares your personal information when you use our 5 + platform and services (the "Service"). 6 + 7 + ## 1. Information We Collect 8 + 9 + ### Account Information 10 + 11 + When you create an account, we collect: 12 + 13 + - Your chosen username 14 + - Email address 15 + - Profile information you choose to provide 16 + - Authentication data 17 + 18 + ### Content and Activity 19 + 20 + We store: 21 + 22 + - Code repositories and associated metadata 23 + - Issues, pull requests, and comments 24 + - Activity logs and usage patterns 25 + - Public keys for authentication 26 + 27 + ## 2. Data Location and Hosting 28 + 29 + ### EU Data Hosting 30 + 31 + **All Tangled service data is hosted within the European Union.** 32 + Specifically: 33 + 34 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 35 + (*.tngl.sh) are located in Finland 36 + - **Application Data:** All other service data is stored on EU-based 37 + servers 38 + - **Data Processing:** All data processing occurs within EU 39 + jurisdiction 40 + 41 + ### External PDS Notice 42 + 43 + **Important:** If your account is hosted on Bluesky's PDS or other 44 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 45 + that data. The data protection, storage location, and privacy 46 + practices for such accounts are governed by the respective PDS 47 + provider's policies, not this Privacy Policy. We only control data 48 + processing within our own services and infrastructure. 49 + 50 + ## 3. Third-Party Data Processors 51 + 52 + We only share your data with the following third-party processors: 53 + 54 + ### Resend (Email Services) 55 + 56 + - **Purpose:** Sending transactional emails (account verification, 57 + notifications) 58 + - **Data Shared:** Email address and necessary message content 59 + 60 + ### Cloudflare (Image Caching) 61 + 62 + - **Purpose:** Caching and optimizing image delivery 63 + - **Data Shared:** Public images and associated metadata for caching 64 + purposes 65 + 66 + ### Posthog (Usage Metrics Tracking) 67 + 68 + - **Purpose:** Tracking usage and platform metrics 69 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 70 + information 71 + 72 + ## 4. How We Use Your Information 73 + 74 + We use your information to: 75 + 76 + - Provide and maintain the Service 77 + - Process your transactions and requests 78 + - Send you technical notices and support messages 79 + - Improve and develop new features 80 + - Ensure security and prevent fraud 81 + - Comply with legal obligations 82 + 83 + ## 5. Data Sharing and Disclosure 84 + 85 + We do not sell, trade, or rent your personal information. We may share 86 + your information only in the following circumstances: 87 + 88 + - With the third-party processors listed above 89 + - When required by law or legal process 90 + - To protect our rights, property, or safety, or that of our users 91 + - In connection with a merger, acquisition, or sale of assets (with 92 + appropriate protections) 93 + 94 + ## 6. Data Security 95 + 96 + We implement appropriate technical and organizational measures to 97 + protect your personal information against unauthorized access, 98 + alteration, disclosure, or destruction. However, no method of 99 + transmission over the Internet is 100% secure. 100 + 101 + ## 7. Data Retention 102 + 103 + We retain your personal information for as long as necessary to provide 104 + the Service and fulfill the purposes outlined in this Privacy Policy, 105 + unless a longer retention period is required by law. 106 + 107 + ## 8. Your Rights 108 + 109 + Under applicable data protection laws, you have the right to: 110 + 111 + - Access your personal information 112 + - Correct inaccurate information 113 + - Request deletion of your information 114 + - Object to processing of your information 115 + - Data portability 116 + - Withdraw consent (where applicable) 117 + 118 + ## 9. Cookies and Tracking 119 + 120 + We use cookies and similar technologies to: 121 + 122 + - Maintain your login session 123 + - Remember your preferences 124 + - Analyze usage patterns to improve the Service 125 + 126 + You can control cookie settings through your browser preferences. 127 + 128 + ## 10. Children's Privacy 129 + 130 + The Service is not intended for children under 16 years of age. We do 131 + not knowingly collect personal information from children under 16. If 132 + we become aware that we have collected such information, we will take 133 + steps to delete it. 134 + 135 + ## 11. International Data Transfers 136 + 137 + While all our primary data processing occurs within the EU, some of our 138 + third-party processors may process data outside the EU. When this 139 + occurs, we ensure appropriate safeguards are in place, such as Standard 140 + Contractual Clauses or adequacy decisions. 141 + 142 + ## 12. Changes to This Privacy Policy 143 + 144 + We may update this Privacy Policy from time to time. We will notify you 145 + of any changes by posting the new Privacy Policy on this page and 146 + updating the "Last updated" date. 147 + 148 + ## 13. Contact Information 149 + 150 + If you have any questions about this Privacy Policy or wish to exercise 151 + your rights, please contact us through our platform or via email. 152 + 153 + --- 154 + 155 + This Privacy Policy complies with the EU General Data Protection 156 + Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
···
··· 1 + **Last updated:** September 26, 2025 2 + 3 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 4 + to and use of the Tangled platform and services (the "Service") 5 + operated by us ("Tangled," "we," "us," or "our"). 6 + 7 + ## 1. Acceptance of Terms 8 + 9 + By accessing or using our Service, you agree to be bound by these Terms. 10 + If you disagree with any part of these terms, then you may not access 11 + the Service. 12 + 13 + ## 2. Account Registration 14 + 15 + To use certain features of the Service, you must register for an 16 + account. You agree to provide accurate, current, and complete 17 + information during the registration process and to update such 18 + information to keep it accurate, current, and complete. 19 + 20 + ## 3. Account Termination 21 + 22 + > **Important Notice** 23 + > 24 + > **We reserve the right to terminate, suspend, or restrict access to 25 + > your account at any time, for any reason, or for no reason at all, at 26 + > our sole discretion.** This includes, but is not limited to, 27 + > termination for violation of these Terms, inappropriate conduct, spam, 28 + > abuse, or any other behavior we deem harmful to the Service or other 29 + > users. 30 + > 31 + > Account termination may result in the loss of access to your 32 + > repositories, data, and other content associated with your account. We 33 + > are not obligated to provide advance notice of termination, though we 34 + > may do so in our discretion. 35 + 36 + ## 4. Acceptable Use 37 + 38 + You agree not to use the Service to: 39 + 40 + - Violate any applicable laws or regulations 41 + - Infringe upon the rights of others 42 + - Upload, store, or share content that is illegal, harmful, threatening, 43 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 44 + objectionable 45 + - Engage in spam, phishing, or other deceptive practices 46 + - Attempt to gain unauthorized access to the Service or other users' 47 + accounts 48 + - Interfere with or disrupt the Service or servers connected to the 49 + Service 50 + 51 + ## 5. Content and Intellectual Property 52 + 53 + You retain ownership of the content you upload to the Service. By 54 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 55 + license to use, reproduce, modify, and distribute your content as 56 + necessary to provide the Service. 57 + 58 + ## 6. Privacy 59 + 60 + Your privacy is important to us. Please review our [Privacy 61 + Policy](/privacy), which also governs your use of the Service. 62 + 63 + ## 7. Disclaimers 64 + 65 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 66 + no warranties, expressed or implied, and hereby disclaim and negate all 67 + other warranties including without limitation, implied warranties or 68 + conditions of merchantability, fitness for a particular purpose, or 69 + non-infringement of intellectual property or other violation of rights. 70 + 71 + ## 8. Limitation of Liability 72 + 73 + In no event shall Tangled, nor its directors, employees, partners, 74 + agents, suppliers, or affiliates, be liable for any indirect, 75 + incidental, special, consequential, or punitive damages, including 76 + without limitation, loss of profits, data, use, goodwill, or other 77 + intangible losses, resulting from your use of the Service. 78 + 79 + ## 9. Indemnification 80 + 81 + You agree to defend, indemnify, and hold harmless Tangled and its 82 + affiliates, officers, directors, employees, and agents from and against 83 + any and all claims, damages, obligations, losses, liabilities, costs, 84 + or debt, and expenses (including attorney's fees). 85 + 86 + ## 10. Governing Law 87 + 88 + These Terms shall be interpreted and governed by the laws of Finland, 89 + without regard to its conflict of law provisions. 90 + 91 + ## 11. Changes to Terms 92 + 93 + We reserve the right to modify or replace these Terms at any time. If a 94 + revision is material, we will try to provide at least 30 days notice 95 + prior to any new terms taking effect. 96 + 97 + ## 12. Contact Information 98 + 99 + If you have any questions about these Terms of Service, please contact 100 + us through our platform or via email. 101 + 102 + --- 103 + 104 + These terms are effective as of the last updated date shown above and 105 + will remain in effect except with respect to any changes in their 106 + provisions in the future, which will be in effect immediately after 107 + being posted on this page.
+15 -17
appview/pages/markup/format.go
··· 1 package markup 2 3 - import "strings" 4 5 type Format string 6 ··· 10 ) 11 12 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 16 - // ReadmeFilenames contains the list of common README filenames to search for, 17 - // in order of preference. Only includes well-supported formats. 18 - var ReadmeFilenames = []string{ 19 - "README.md", "readme.md", 20 - "README", 21 - "readme", 22 - "README.markdown", 23 - "readme.markdown", 24 - "README.txt", 25 - "readme.txt", 26 } 27 28 func GetFormat(filename string) Format { 29 - for format, extensions := range FileTypes { 30 - for _, extension := range extensions { 31 - if strings.HasSuffix(filename, extension) { 32 - return format 33 - } 34 } 35 } 36 // default format
··· 1 package markup 2 3 + import ( 4 + "regexp" 5 + ) 6 7 type Format string 8 ··· 12 ) 13 14 var FileTypes map[Format][]string = map[Format][]string{ 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 16 } 17 18 + var FileTypePatterns = map[Format]*regexp.Regexp{ 19 + FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`), 20 + } 21 + 22 + var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`) 23 + 24 + func IsReadmeFile(filename string) bool { 25 + return ReadmePattern.MatchString(filename) 26 } 27 28 func GetFormat(filename string) Format { 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 32 } 33 } 34 // default format
+6 -1
appview/pages/markup/markdown.go
··· 5 "bytes" 6 "fmt" 7 "io" 8 "net/url" 9 "path" 10 "strings" ··· 20 "github.com/yuin/goldmark/renderer/html" 21 "github.com/yuin/goldmark/text" 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 "tangled.org/core/api/tangled" ··· 45 IsDev bool 46 RendererType RendererType 47 Sanitizer Sanitizer 48 } 49 50 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 ), 64 treeblood.MathML(), 65 ), 66 goldmark.WithParserOptions( 67 parser.WithAutoHeadingID(), ··· 140 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 switch node.Type { 142 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 144 for i, attr := range node.Attr { 145 if attr.Key != "src" { 146 continue
··· 5 "bytes" 6 "fmt" 7 "io" 8 + "io/fs" 9 "net/url" 10 "path" 11 "strings" ··· 21 "github.com/yuin/goldmark/renderer/html" 22 "github.com/yuin/goldmark/text" 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 25 htmlparse "golang.org/x/net/html" 26 27 "tangled.org/core/api/tangled" ··· 47 IsDev bool 48 RendererType RendererType 49 Sanitizer Sanitizer 50 + Files fs.FS 51 } 52 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 66 ), 67 treeblood.MathML(), 68 + callout.CalloutExtention, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(), ··· 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 145 switch node.Type { 146 case htmlparse.ElementNode: 147 + switch node.Data { 148 + case "img", "source": 149 for i, attr := range node.Attr { 150 if attr.Key != "src" { 151 continue
+3
appview/pages/markup/sanitizer.go
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 return policy 118 } 119
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 + // goldmark-callout 118 + policy.AllowAttrs("data-callout").OnElements("details") 119 + 120 return policy 121 } 122
+87 -41
appview/pages/pages.go
··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 - //go:embed templates/* static 42 var Files embed.FS 43 44 type Pages struct { ··· 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 } 65 66 p := &Pages{ ··· 226 return p.executePlain("user/login", w, params) 227 } 228 229 - func (p *Pages) Signup(w io.Writer) error { 230 - return p.executePlain("user/signup", w, nil) 231 } 232 233 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 242 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 243 filename := "terms.md" 244 filePath := filepath.Join("legal", filename) 245 - markdownBytes, err := os.ReadFile(filePath) 246 if err != nil { 247 return fmt.Errorf("failed to read %s: %w", filename, err) 248 } ··· 263 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 264 filename := "privacy.md" 265 filePath := filepath.Join("legal", filename) 266 - markdownBytes, err := os.ReadFile(filePath) 267 if err != nil { 268 return fmt.Errorf("failed to read %s: %w", filename, err) 269 } ··· 276 return p.execute("legal/privacy", w, params) 277 } 278 279 type TimelineParams struct { 280 LoggedInUser *oauth.User 281 Timeline []models.TimelineEvent 282 Repos []models.Repo 283 } 284 285 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 286 return p.execute("timeline/timeline", w, params) 287 } 288 289 type UserProfileSettingsParams struct { 290 LoggedInUser *oauth.User 291 Tabs []map[string]any ··· 300 LoggedInUser *oauth.User 301 Notifications []*models.NotificationWithEntity 302 UnreadCount int 303 - HasMore bool 304 - NextOffset int 305 - Limit int 306 } 307 308 func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { ··· 318 } 319 320 type NotificationCountParams struct { 321 - Count int 322 } 323 324 func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { ··· 695 } 696 697 type RepoTreeParams struct { 698 - LoggedInUser *oauth.User 699 - RepoInfo repoinfo.RepoInfo 700 - Active string 701 - BreadCrumbs [][]string 702 - TreePath string 703 - Readme string 704 - ReadmeFileName string 705 - HTMLReadme template.HTML 706 - Raw bool 707 types.RepoTreeResponse 708 } 709 ··· 731 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 732 params.Active = "overview" 733 734 - if params.ReadmeFileName != "" { 735 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 736 737 ext := filepath.Ext(params.ReadmeFileName) 738 switch ext { 739 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 946 LabelDefs map[string]*models.LabelDefinition 947 948 OrderedReactionKinds []models.ReactionKind 949 - Reactions map[models.ReactionKind]int 950 UserReacted map[models.ReactionKind]bool 951 } 952 ··· 971 ThreadAt syntax.ATURI 972 Kind models.ReactionKind 973 Count int 974 IsReacted bool 975 } 976 ··· 1061 FilteringBy models.PullState 1062 Stacks map[string]models.Stack 1063 Pipelines map[string]models.Pipeline 1064 } 1065 1066 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1087 } 1088 1089 type RepoSinglePullParams struct { 1090 - LoggedInUser *oauth.User 1091 - RepoInfo repoinfo.RepoInfo 1092 - Active string 1093 - Pull *models.Pull 1094 - Stack models.Stack 1095 - AbandonedPulls []*models.Pull 1096 - MergeCheck types.MergeCheckResponse 1097 - ResubmitCheck ResubmitResult 1098 - Pipelines map[string]models.Pipeline 1099 1100 OrderedReactionKinds []models.ReactionKind 1101 - Reactions map[models.ReactionKind]int 1102 UserReacted map[models.ReactionKind]bool 1103 } 1104 1105 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1189 } 1190 1191 type PullActionsParams struct { 1192 - LoggedInUser *oauth.User 1193 - RepoInfo repoinfo.RepoInfo 1194 - Pull *models.Pull 1195 - RoundNumber int 1196 - MergeCheck types.MergeCheckResponse 1197 - ResubmitCheck ResubmitResult 1198 - Stack models.Stack 1199 } 1200 1201 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1432 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1433 } 1434 1435 - sub, err := fs.Sub(Files, "static") 1436 if err != nil { 1437 p.logger.Error("no static dir found? that's crazy", "err", err) 1438 panic(err) ··· 1455 }) 1456 } 1457 1458 - func CssContentHash() string { 1459 - cssFile, err := Files.Open("static/tw.css") 1460 if err != nil { 1461 slog.Debug("Error opening CSS file", "err", err) 1462 return ""
··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 + //go:embed templates/* static legal 42 var Files embed.FS 43 44 type Pages struct { ··· 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 + Files: Files, 65 } 66 67 p := &Pages{ ··· 227 return p.executePlain("user/login", w, params) 228 } 229 230 + type SignupParams struct { 231 + CloudflareSiteKey string 232 + } 233 + 234 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 235 + return p.executePlain("user/signup", w, params) 236 } 237 238 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 247 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 248 filename := "terms.md" 249 filePath := filepath.Join("legal", filename) 250 + 251 + file, err := p.embedFS.Open(filePath) 252 + if err != nil { 253 + return fmt.Errorf("failed to read %s: %w", filename, err) 254 + } 255 + defer file.Close() 256 + 257 + markdownBytes, err := io.ReadAll(file) 258 if err != nil { 259 return fmt.Errorf("failed to read %s: %w", filename, err) 260 } ··· 275 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 276 filename := "privacy.md" 277 filePath := filepath.Join("legal", filename) 278 + 279 + file, err := p.embedFS.Open(filePath) 280 + if err != nil { 281 + return fmt.Errorf("failed to read %s: %w", filename, err) 282 + } 283 + defer file.Close() 284 + 285 + markdownBytes, err := io.ReadAll(file) 286 if err != nil { 287 return fmt.Errorf("failed to read %s: %w", filename, err) 288 } ··· 295 return p.execute("legal/privacy", w, params) 296 } 297 298 + type BrandParams struct { 299 + LoggedInUser *oauth.User 300 + } 301 + 302 + func (p *Pages) Brand(w io.Writer, params BrandParams) error { 303 + return p.execute("brand/brand", w, params) 304 + } 305 + 306 type TimelineParams struct { 307 LoggedInUser *oauth.User 308 Timeline []models.TimelineEvent 309 Repos []models.Repo 310 + GfiLabel *models.LabelDefinition 311 } 312 313 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 314 return p.execute("timeline/timeline", w, params) 315 } 316 317 + type GoodFirstIssuesParams struct { 318 + LoggedInUser *oauth.User 319 + Issues []models.Issue 320 + RepoGroups []*models.RepoGroup 321 + LabelDefs map[string]*models.LabelDefinition 322 + GfiLabel *models.LabelDefinition 323 + Page pagination.Page 324 + } 325 + 326 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 327 + return p.execute("goodfirstissues/index", w, params) 328 + } 329 + 330 type UserProfileSettingsParams struct { 331 LoggedInUser *oauth.User 332 Tabs []map[string]any ··· 341 LoggedInUser *oauth.User 342 Notifications []*models.NotificationWithEntity 343 UnreadCount int 344 + Page pagination.Page 345 + Total int64 346 } 347 348 func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { ··· 358 } 359 360 type NotificationCountParams struct { 361 + Count int64 362 } 363 364 func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { ··· 735 } 736 737 type RepoTreeParams struct { 738 + LoggedInUser *oauth.User 739 + RepoInfo repoinfo.RepoInfo 740 + Active string 741 + BreadCrumbs [][]string 742 + TreePath string 743 + Raw bool 744 + HTMLReadme template.HTML 745 types.RepoTreeResponse 746 } 747 ··· 769 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 770 params.Active = "overview" 771 772 + p.rctx.RepoInfo = params.RepoInfo 773 + p.rctx.RepoInfo.Ref = params.Ref 774 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 775 776 + if params.ReadmeFileName != "" { 777 ext := filepath.Ext(params.ReadmeFileName) 778 switch ext { 779 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 986 LabelDefs map[string]*models.LabelDefinition 987 988 OrderedReactionKinds []models.ReactionKind 989 + Reactions map[models.ReactionKind]models.ReactionDisplayData 990 UserReacted map[models.ReactionKind]bool 991 } 992 ··· 1011 ThreadAt syntax.ATURI 1012 Kind models.ReactionKind 1013 Count int 1014 + Users []string 1015 IsReacted bool 1016 } 1017 ··· 1102 FilteringBy models.PullState 1103 Stacks map[string]models.Stack 1104 Pipelines map[string]models.Pipeline 1105 + LabelDefs map[string]*models.LabelDefinition 1106 } 1107 1108 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1129 } 1130 1131 type RepoSinglePullParams struct { 1132 + LoggedInUser *oauth.User 1133 + RepoInfo repoinfo.RepoInfo 1134 + Active string 1135 + Pull *models.Pull 1136 + Stack models.Stack 1137 + AbandonedPulls []*models.Pull 1138 + BranchDeleteStatus *models.BranchDeleteStatus 1139 + MergeCheck types.MergeCheckResponse 1140 + ResubmitCheck ResubmitResult 1141 + Pipelines map[string]models.Pipeline 1142 1143 OrderedReactionKinds []models.ReactionKind 1144 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1145 UserReacted map[models.ReactionKind]bool 1146 + 1147 + LabelDefs map[string]*models.LabelDefinition 1148 } 1149 1150 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1234 } 1235 1236 type PullActionsParams struct { 1237 + LoggedInUser *oauth.User 1238 + RepoInfo repoinfo.RepoInfo 1239 + Pull *models.Pull 1240 + RoundNumber int 1241 + MergeCheck types.MergeCheckResponse 1242 + ResubmitCheck ResubmitResult 1243 + BranchDeleteStatus *models.BranchDeleteStatus 1244 + Stack models.Stack 1245 } 1246 1247 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1478 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1479 } 1480 1481 + sub, err := fs.Sub(p.embedFS, "static") 1482 if err != nil { 1483 p.logger.Error("no static dir found? that's crazy", "err", err) 1484 panic(err) ··· 1501 }) 1502 } 1503 1504 + func (p *Pages) CssContentHash() string { 1505 + cssFile, err := p.embedFS.Open("static/tw.css") 1506 if err != nil { 1507 slog.Debug("Error opening CSS file", "err", err) 1508 return ""
+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 }}
+44
appview/pages/templates/fragments/dolly/silhouette.svg
···
··· 1 + <svg 2 + version="1.1" 3 + id="svg1" 4 + width="32" 5 + height="32" 6 + viewBox="0 0 25 25" 7 + sodipodi:docname="tangled_dolly_silhouette.png" 8 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 + xmlns="http://www.w3.org/2000/svg" 11 + xmlns:svg="http://www.w3.org/2000/svg"> 12 + <title>Dolly</title> 13 + <defs 14 + id="defs1" /> 15 + <sodipodi:namedview 16 + id="namedview1" 17 + pagecolor="#ffffff" 18 + bordercolor="#000000" 19 + borderopacity="0.25" 20 + inkscape:showpageshadow="2" 21 + inkscape:pageopacity="0.0" 22 + inkscape:pagecheckerboard="true" 23 + inkscape:deskcolor="#d1d1d1"> 24 + <inkscape:page 25 + x="0" 26 + y="0" 27 + width="25" 28 + height="25" 29 + id="page2" 30 + margin="0" 31 + bleed="0" /> 32 + </sodipodi:namedview> 33 + <g 34 + inkscape:groupmode="layer" 35 + inkscape:label="Image" 36 + id="g1"> 37 + <path 38 + class="dolly" 39 + fill="currentColor" 40 + style="stroke-width:1.12248" 41 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 + id="path1" /> 43 + </g> 44 + </svg>
+167
appview/pages/templates/goodfirstissues/index.html
···
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 <!-- preload main font --> 18 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 ··· 21 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 26 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 29 {{ if .LoggedInUser }} 30 <div id="upgrade-banner" ··· 38 {{ end }} 39 40 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <main class="col-span-1 md:col-span-8"> 44 {{ block "content" . }}{{ end }} 45 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 50 {{ block "contentAfter" . }}{{ end }} 51 </main> 52 - {{ end }} 53 </div> 54 {{ end }} 55 56 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 58 {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }}
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 20 <!-- preload main font --> 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 22 ··· 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 25 {{ block "extrameta" . }}{{ end }} 26 </head> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 28 {{ block "topbarLayout" . }} 29 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 31 {{ if .LoggedInUser }} 32 <div id="upgrade-banner" ··· 40 {{ end }} 41 42 {{ block "mainLayout" . }} 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 47 {{ block "content" . }}{{ end }} 48 </main> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 53 {{ block "contentAfter" . }}{{ end }} 54 </main> 55 + {{ end }} 56 + </div> 57 </div> 58 {{ end }} 59 60 {{ block "footerLayout" . }} 61 + <footer class="mt-12"> 62 {{ template "layouts/fragments/footer" . }} 63 </footer> 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 10 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 </div> 20 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 </div> 27 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 </div> 34 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 38 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 39 </div> 40 - </div> 41 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 </div> 45 </div> 46 </div>
··· 1 {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 46 </div> 47 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 + </div> 53 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 64 </div> 65 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 93 </div> 94 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 98 </div> 99 </div> 100 </div>
+2 -2
appview/pages/templates/layouts/fragments/topbar.html
··· 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 51 <summary 52 class="cursor-pointer list-none flex items-center gap-1" 53 > 54 - {{ $user := didOrHandle .Did .Handle }} 55 <img 56 src="{{ tinyAvatar $user }}" 57 alt=""
··· 1 {{ define "layouts/fragments/topbar" }} 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 51 <summary 52 class="cursor-pointer list-none flex items-center gap-1" 53 > 54 + {{ $user := .Did }} 55 <img 56 src="{{ tinyAvatar $user }}" 57 alt=""
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 {{ define "title" }}privacy policy{{ end }} 2 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}privacy policy{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Learn how we collect, use, and protect your personal information. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 15 </div> 16 + </main> 17 </div> 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 {{ define "title" }}terms of service{{ end }} 2 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}terms of service{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + A few things you should know. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 15 </div> 16 + </main> 17 </div> 18 + {{ end }}
+68 -199
appview/pages/templates/notifications/fragments/item.html
··· 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}} 51 </div> 52 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 </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> 66 {{end}} 67 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 }} 119 </div> 120 </div> 121 - </a> 122 - {{end}} 123 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> 135 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> 148 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}} 155 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> 196 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}}
··· 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 }}
+44 -25
appview/pages/templates/notifications/list.html
··· 1 {{ define "title" }}notifications{{ end }} 2 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <div class="flex items-center justify-between mb-4"> 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" }} ··· 11 </div> 12 </div> 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> 21 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}} 37 <div class="text-center py-12"> 38 <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 39 {{ i "bell-off" "w-16 h-16" }} ··· 41 <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 42 <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 43 </div> 44 - {{end}} 45 </div> 46 {{ end }}
··· 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" }} ··· 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" }} ··· 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 }}
+3 -3
appview/pages/templates/repo/commit.html
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 + <header class="col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 + <footer class="col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
+7
appview/pages/templates/repo/fork.html
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2">
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 16 <fieldset class="space-y-3"> 17 <legend class="dark:text-white">Select a knot to fork into</legend> 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+9 -1
appview/pages/templates/repo/fragments/og.html
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 - 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 <meta property="og:type" content="object" /> 9 <meta property="og:url" content="{{ $url }}" /> 10 <meta property="og:description" content="{{ $description }}" /> 11 {{ end }}
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 <meta property="og:type" content="object" /> 9 <meta property="og:url" content="{{ $url }}" /> 10 <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 {{ end }}
+26
appview/pages/templates/repo/fragments/participants.html
···
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-2 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 {{ if .IsReacted }} 24 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 {{ else }}
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 relative group 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 28 {{ if .IsReacted }} 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 {{- if .ReadmeFileName -}} 4 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 {{- if .ReadmeFileName -}} 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+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 </div> 139 </form> 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 143 </div> 144 {{ end }} 145 {{ end }}
··· 138 </div> 139 </form> 140 {{ else }} 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 148 </div> 149 {{ end }} 150 {{ end }}
+5 -29
appview/pages/templates/repo/issues/issue.html
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 26 </div> 27 </div> 28 {{ end }} ··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 {{ 114 template "repo/fragments/reaction" 115 (dict 116 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 }} 121 {{ end }} 122 </div> 123 {{ end }} 124 125 - {{ define "issueParticipants" }} 126 - {{ $all := .Issue.Participants }} 127 - {{ $ps := take $all 5 }} 128 - <div> 129 - <div class="py-1 flex items-center text-sm"> 130 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 - </div> 133 - <div class="flex items-center -space-x-3 mt-2"> 134 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 - {{ range $i, $p := $ps }} 136 - <img 137 - src="{{ tinyAvatar . }}" 138 - alt="" 139 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 - /> 141 - {{ end }} 142 - 143 - {{ if gt (len $all) 5 }} 144 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 - +{{ sub (len $all) 5 }} 146 - </span> 147 - {{ end }} 148 - </div> 149 - </div> 150 - {{ end }} 151 152 {{ define "repoAfter" }} 153 <div class="flex flex-col gap-4 mt-4">
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 </div> 27 </div> 28 {{ end }} ··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 114 {{ 115 template "repo/fragments/reaction" 116 (dict 117 "Kind" $kind 118 + "Count" $reactionData.Count 119 "IsReacted" (index $.UserReacted $kind) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 122 }} 123 {{ end }} 124 </div> 125 {{ end }} 126 127 128 {{ define "repoAfter" }} 129 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 92 </div> 93 {{ block "pagination" . }} {{ end }} 94 {{ end }}
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 42 </div> 43 {{ block "pagination" . }} {{ end }} 44 {{ end }}
+156 -140
appview/pages/templates/repo/new.html
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-2 mb-4"> 5 - <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 6 - <p class="text-gray-600 dark:text-gray-400 mb-1"> 7 - Repositories contain a project's files and version history. 8 - </p> 9 </div> 10 11 - <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 12 <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 13 14 - <div class="p-6"> 15 - <!-- Step 1: General --> 16 - <div class="flex gap-4"> 17 - <div class="flex flex-col items-center w-10 flex-shrink-0"> 18 - <div class="w-6 h-6 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 19 - 1 20 - </div> 21 - <div class="w-px bg-gray-300 dark:bg-gray-600 h-80 mt-1"></div> 22 - </div> 23 - <div class="flex-1"> 24 - <div class="mb-6"> 25 - <div class="text-lg font-semibold dark:text-white mb-1">General</div> 26 - <div class="text-sm text-gray-500 dark:text-gray-400">Basic repository information.</div> 27 - </div> 28 29 - <div class="space-y-2"> 30 - <!-- Repository Name with Owner --> 31 - <div> 32 - <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 33 - Repository name 34 - </label> 35 - <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0"> 36 - {{ if .LoggedInUser.Handle }} 37 - <div class="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"> 38 - {{ template "user/fragments/picHandle" .LoggedInUser.Handle }} 39 - <span class="md:hidden pr-2 py-2 text-gray-500 dark:text-gray-400">/</span> 40 - </div> 41 - <span class="hidden md:inline pr-2 py-2 text-gray-500 dark:text-gray-400 border-t border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">/</span> 42 - {{ end }} 43 - <input 44 - type="text" 45 - id="name" 46 - name="name" 47 - required 48 - class="w-full max-w-md 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" 49 - placeholder="repository-name" 50 - /> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 53 - Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 54 - </p> 55 - </div> 56 57 - <!-- Description --> 58 - <div> 59 - <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 60 - Description 61 - </label> 62 - <input 63 - type="text" 64 - id="description" 65 - name="description" 66 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 67 - placeholder="A brief description of your project..." 68 - /> 69 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 70 - Optional. A short description to help others understand what your project does. 71 - </p> 72 - </div> 73 - </div> 74 - </div> 75 </div> 76 77 - <!-- Step 2: Configuration --> 78 - <div class="flex gap-4"> 79 - <div class="flex flex-col items-center w-10 flex-shrink-0"> 80 - <div class="w-6 h-6 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 81 - 2 82 - </div> 83 - </div> 84 - <div class="flex-1"> 85 - <div class="mb-6"> 86 - <div class="text-lg font-semibold dark:text-white mb-1">Configuration</div> 87 - <div class="text-sm text-gray-500 dark:text-gray-400">Repository settings and hosting.</div> 88 - </div> 89 90 - <div class="space-y-2"> 91 - <!-- Default Branch --> 92 - <div> 93 - <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 94 - Default branch 95 - </label> 96 - <input 97 - type="text" 98 - id="branch" 99 - name="branch" 100 - value="main" 101 - required 102 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 103 - /> 104 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 105 - The primary branch where development happens. Common choices are "main" or "master". 106 - </p> 107 - </div> 108 109 - <!-- Knot Selection --> 110 - <div> 111 - <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 112 - Select a knot 113 - </label> 114 - <div class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 115 - {{ range .Knots }} 116 - <div class="flex items-center"> 117 - <input 118 - type="radio" 119 - name="domain" 120 - value="{{ . }}" 121 - class="mr-2" 122 - id="domain-{{ . }}" 123 - required 124 - /> 125 - <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 126 - </div> 127 - {{ else }} 128 - <p class="dark:text-white">no knots available.</p> 129 - {{ end }} 130 - </div> 131 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 132 - A knot hosts repository data and handles Git operations. 133 - You can also <a href="/knots" class="underline">register your own knot</a>. 134 - </p> 135 - </div> 136 - </div> 137 - </div> 138 </div> 139 </div> 140 141 - <!-- Create Repository Button --> 142 - <div class="p-6 border-t border-gray-200 dark:border-gray-700"> 143 - <div class="flex items-center justify-between"> 144 - <div class="text-sm text-gray-500 dark:text-gray-400"> 145 - All repositories are publicly accessible. 146 - </div> 147 - <button type="submit" class="btn-create flex items-center gap-2"> 148 - {{ i "book-plus" "w-4 h-4" }} 149 - create repo 150 - <span id="spinner" class="group"> 151 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 152 - </span> 153 - </button> 154 </div> 155 - <div id="repo" class="error mt-2"></div> 156 </div> 157 158 - </form> 159 - </div> 160 {{ end }}
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 13 </div> 14 + {{ end }} 15 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "newRepoForm" }} 23 <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 35 + </div> 36 + <div id="repo" class="error mt-2"></div> 37 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 52 + <div class="space-y-2"> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 55 </div> 56 + </div> 57 + </div> 58 + {{ end }} 59 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 64 + </div> 65 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 </div> 74 </div> 75 + </div> 76 + {{ end }} 77 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 176 {{ end }}
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 33 <span>comment</span> 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 </button> 36 {{ if and $isPushAllowed $isOpen $isLastRound }} 37 {{ $disabled := "" }} 38 {{ if $isConflicted }}
··· 33 <span>comment</span> 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 </button> 36 + {{ if .BranchDeleteStatus }} 37 + <button 38 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 + hx-swap="none" 41 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 42 + {{ i "git-branch" "w-4 h-4" }} 43 + <span>delete branch</span> 44 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 + </button> 46 + {{ end }} 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 48 {{ $disabled := "" }} 49 {{ if $isConflicted }}
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 {{ 70 template "repo/fragments/reaction" 71 (dict 72 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 }} 77 {{ end }} 78 </div>
··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 70 {{ 71 template "repo/fragments/reaction" 72 (dict 73 "Kind" $kind 74 + "Count" $reactionData.Count 75 "IsReacted" (index $.UserReacted $kind) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 78 }} 79 {{ end }} 80 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ resolve .LoggedInUser.Did }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 29 {{ end }} 30 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/fragments/topbar" . }} 34 - </header> 35 - {{ end }} 36 - 37 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 52 {{ end }} 53 </div> 54 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 63 {{ define "contentAfter" }} 64 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
··· 28 29 {{ end }} 30 31 {{ define "mainLayout" }} 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 33 {{ block "contentLayout" . }} 34 {{ block "content" . }}{{ end }} 35 {{ end }} ··· 46 {{ end }} 47 </div> 48 {{ end }} 49 50 {{ define "contentAfter" }} 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1 -13
appview/pages/templates/repo/pulls/patch.html
··· 34 </section> 35 {{ end }} 36 37 - {{ define "topbarLayout" }} 38 - <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/fragments/topbar" . }} 40 - </header> 41 - {{ end }} 42 - 43 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 45 {{ block "contentLayout" . }} 46 {{ block "content" . }}{{ end }} 47 {{ end }} ··· 57 </div> 58 {{ end }} 59 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 {{ end }} 67 68 {{ define "contentAfter" }}
··· 34 </section> 35 {{ end }} 36 37 {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 51 </div> 52 {{ end }} 53 </div> 54 {{ end }} 55 56 {{ define "contentAfter" }}
+47 -16
appview/pages/templates/repo/pulls/pull.html
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 13 {{ define "repoContent" }} 14 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 {{ with $item }} 40 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 43 <!-- round number --> 44 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 </div> 47 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 <span class="gap-1 flex items-center"> 50 {{ $owner := resolve $.Pull.OwnerDid }} 51 {{ $re := "re" }} ··· 72 <span class="hidden md:inline">diff</span> 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 83 <span id="interdiff-error-{{.RoundNumber}}"></span> 84 - {{ end }} 85 </div> 86 </summary> 87 ··· 146 147 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 {{ range $cidx, $c := .Comments }} 149 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 150 {{ if gt $cidx 0 }} 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }} ··· 169 {{ end }} 170 171 {{ if $.LoggedInUser }} 172 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 173 {{ else }} 174 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 175 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 176 - <a href="/login" class="underline">login</a> to join the discussion 177 </div> 178 {{ end }} 179 </div>
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 + {{ define "repoContentLayout" }} 13 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 14 + <div class="col-span-1 md:col-span-8"> 15 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 16 + {{ block "repoContent" . }}{{ end }} 17 + </section> 18 + {{ block "repoAfter" . }}{{ end }} 19 + </div> 20 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 21 + {{ template "repo/fragments/labelPanel" 22 + (dict "RepoInfo" $.RepoInfo 23 + "Defs" $.LabelDefs 24 + "Subject" $.Pull.PullAt 25 + "State" $.Pull.Labels) }} 26 + {{ template "repo/fragments/participants" $.Pull.Participants }} 27 + </div> 28 + </div> 29 + {{ end }} 30 31 {{ define "repoContent" }} 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 57 {{ with $item }} 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 61 <!-- round number --> 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 64 </div> 65 <!-- round summary --> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 67 <span class="gap-1 flex items-center"> 68 {{ $owner := resolve $.Pull.OwnerDid }} 69 {{ $re := "re" }} ··· 90 <span class="hidden md:inline">diff</span> 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 92 </a> 93 + {{ if ne $idx 0 }} 94 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 95 + hx-boost="true" 96 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 97 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 98 + <span class="hidden md:inline">interdiff</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </a> 101 + {{ end }} 102 <span id="interdiff-error-{{.RoundNumber}}"></span> 103 </div> 104 </summary> 105 ··· 164 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 166 {{ range $cidx, $c := .Comments }} 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 168 {{ if gt $cidx 0 }} 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 170 {{ end }} ··· 187 {{ end }} 188 189 {{ if $.LoggedInUser }} 190 + {{ template "repo/pulls/fragments/pullActions" 191 + (dict 192 + "LoggedInUser" $.LoggedInUser 193 + "Pull" $.Pull 194 + "RepoInfo" $.RepoInfo 195 + "RoundNumber" .RoundNumber 196 + "MergeCheck" $.MergeCheck 197 + "ResubmitCheck" $.ResubmitCheck 198 + "BranchDeleteStatus" $.BranchDeleteStatus 199 + "Stack" $.Stack) }} 200 {{ else }} 201 + <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"> 202 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 203 + sign up 204 + </a> 205 + <span class="text-gray-500 dark:text-gray-400">or</span> 206 + <a href="/login" class="underline">login</a> 207 + to add to the discussion 208 </div> 209 {{ end }} 210 </div>
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 </div> 112 </div> 113 {{ if .StackId }}
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 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 }} 118 </div> 119 </div> 120 {{ if .StackId }}
+1 -1
appview/pages/templates/repo/tree.html
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 - <div class="font-medium dark:text-white flex gap-2 items-center"> 31 - <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 32 </div> 33 {{ with .Description }} 34 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 43 {{ define "stringCardInfo" }} 44 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 - <a href="/strings/{{ $resolved }}" class="flex items-center gap-1"> 48 - {{ template "user/fragments/picHandle" $resolved }} 49 - </a> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 <span class="select-none [&:before]:content-['·']"></span> 53 {{ with .Edited }}
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 31 + <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 + <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 + <span class="select-none">/</span> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 35 </div> 36 {{ with .Description }} 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 45 46 {{ define "stringCardInfo" }} 47 {{ $stat := .Stats }} 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 50 <span class="select-none [&:before]:content-['·']"></span> 51 {{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 {{ template "timeline/fragments/trending" . }} 16 {{ template "timeline/fragments/timeline" . }} 17 <div class="flex justify-end">
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 17 {{ template "timeline/fragments/trending" . }} 18 {{ template "timeline/fragments/timeline" . }} 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 <link 24 rel="stylesheet" 25 href="/static/tw.css?{{ cssContentHash }}"
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 24 <link 25 rel="stylesheet" 26 href="/static/tw.css?{{ cssContentHash }}"
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 1 {{ define "user/fragments/followCard" }} 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
··· 1 {{ define "user/fragments/followCard" }} 2 {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+2 -1
appview/pages/templates/user/login.html
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login &middot; tangled</title> 13 </head> ··· 36 placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 handle to log in. If you're unsure, this is likely 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 </span>
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>login &middot; tangled</title> 14 </head> ··· 37 placeholder="akshay.tngl.sh" 38 /> 39 <span class="text-sm text-gray-500 mt-1"> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 41 handle to log in. If you're unsure, this is likely 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 43 </span>
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 39 </span> 40 - {{ end }} 41 </div> 42 </div> 43 <div class="flex items-center justify-between p-4">
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 <span class="font-bold"> 37 + {{ resolve .LoggedInUser.Did }} 38 </span> 39 </div> 40 </div> 41 <div class="flex items-center justify-between p-4">
+7 -1
appview/pages/templates/user/signup.html
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> ··· 39 invite code, desired username, and password in the next 40 page to complete your registration. 41 </span> 42 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 <span>join now</span> 44 </button> 45 </form> 46 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 48 </p> 49 50 <p id="signup-msg" class="error w-full"></p>
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 16 </head> 17 <body class="flex items-center justify-center min-h-screen"> 18 <main class="max-w-md px-6 -mt-4"> ··· 42 invite code, desired username, and password in the next 43 page to complete your registration. 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 </form> 52 <p class="text-sm text-gray-500"> 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 </p> 55 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 - Limit: 10, 12 } 13 } 14
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 + Limit: 30, 12 } 13 } 14
+2 -1
appview/pipelines/pipelines.go
··· 48 ) *Pipelines { 49 logger := log.New("pipelines") 50 51 - return &Pipelines{oauth: oauth, 52 repoResolver: repoResolver, 53 pages: pages, 54 idResolver: idResolver,
··· 48 ) *Pipelines { 49 logger := log.New("pipelines") 50 51 + return &Pipelines{ 52 + oauth: oauth, 53 repoResolver: repoResolver, 54 pages: pages, 55 idResolver: idResolver,
+119 -30
appview/pulls/pulls.go
··· 98 } 99 100 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 101 resubmitResult := pages.Unknown 102 if user.Did == pull.OwnerDid { 103 resubmitResult = s.resubmitCheck(r, f, pull, stack) 104 } 105 106 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 107 - LoggedInUser: user, 108 - RepoInfo: f.RepoInfo(user), 109 - Pull: pull, 110 - RoundNumber: roundNumber, 111 - MergeCheck: mergeCheckResponse, 112 - ResubmitCheck: resubmitResult, 113 - Stack: stack, 114 }) 115 return 116 } ··· 153 } 154 155 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 156 resubmitResult := pages.Unknown 157 if user != nil && user.Did == pull.OwnerDid { 158 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 189 m[p.Sha] = p 190 } 191 192 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 193 if err != nil { 194 log.Println("failed to get pull reactions") 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 } 202 203 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 204 - LoggedInUser: user, 205 - RepoInfo: repoInfo, 206 - Pull: pull, 207 - Stack: stack, 208 - AbandonedPulls: abandonedPulls, 209 - MergeCheck: mergeCheckResponse, 210 - ResubmitCheck: resubmitResult, 211 - Pipelines: m, 212 213 OrderedReactionKinds: models.OrderedReactionKinds, 214 - Reactions: reactionCountMap, 215 UserReacted: userReactions, 216 }) 217 } 218 ··· 283 return result 284 } 285 286 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 287 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 288 return pages.Unknown ··· 557 m[p.Sha] = p 558 } 559 560 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 LoggedInUser: s.oauth.GetUser(r), 562 RepoInfo: f.RepoInfo(user), 563 Pulls: pulls, 564 FilteringBy: state, 565 Stacks: stacks, 566 Pipelines: m, ··· 630 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 631 return 632 } 633 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 634 Collection: tangled.RepoPullCommentNSID, 635 Repo: user.Did, 636 Rkey: tid.TID(), ··· 1058 1059 // We've already checked earlier if it's diff-based and title is empty, 1060 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1062 formatPatches, err := patchutil.ExtractPatches(patch) 1063 if err != nil { 1064 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 return 1070 } 1071 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1074 } 1075 1076 rkey := tid.TID() ··· 1103 return 1104 } 1105 1106 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1107 Collection: tangled.RepoPullNSID, 1108 Repo: user.Did, 1109 Rkey: rkey, ··· 1114 Repo: string(f.RepoAt()), 1115 Branch: targetBranch, 1116 }, 1117 - Patch: patch, 1118 - Source: recordPullSource, 1119 }, 1120 }, 1121 }) ··· 1200 } 1201 writes = append(writes, &write) 1202 } 1203 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1204 Repo: user.Did, 1205 Writes: writes, 1206 }) ··· 1731 return 1732 } 1733 1734 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1735 if err != nil { 1736 // failed to get record 1737 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1754 } 1755 } 1756 1757 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1758 Collection: tangled.RepoPullNSID, 1759 Repo: user.Did, 1760 Rkey: pull.Rkey, ··· 1766 Repo: string(f.RepoAt()), 1767 Branch: pull.TargetBranch, 1768 }, 1769 - Patch: patch, // new patch 1770 - Source: recordPullSource, 1771 }, 1772 }, 1773 }) ··· 2026 return 2027 } 2028 2029 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2030 Repo: user.Did, 2031 Writes: writes, 2032 })
··· 98 } 99 100 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 101 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 102 resubmitResult := pages.Unknown 103 if user.Did == pull.OwnerDid { 104 resubmitResult = s.resubmitCheck(r, f, pull, stack) 105 } 106 107 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 108 + LoggedInUser: user, 109 + RepoInfo: f.RepoInfo(user), 110 + Pull: pull, 111 + RoundNumber: roundNumber, 112 + MergeCheck: mergeCheckResponse, 113 + ResubmitCheck: resubmitResult, 114 + BranchDeleteStatus: branchDeleteStatus, 115 + Stack: stack, 116 }) 117 return 118 } ··· 155 } 156 157 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 158 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 159 resubmitResult := pages.Unknown 160 if user != nil && user.Did == pull.OwnerDid { 161 resubmitResult = s.resubmitCheck(r, f, pull, stack) ··· 192 m[p.Sha] = p 193 } 194 195 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 196 if err != nil { 197 log.Println("failed to get pull reactions") 198 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 203 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 204 } 205 206 + labelDefs, err := db.GetLabelDefinitions( 207 + s.db, 208 + db.FilterIn("at_uri", f.Repo.Labels), 209 + db.FilterContains("scope", tangled.RepoPullNSID), 210 + ) 211 + if err != nil { 212 + log.Println("failed to fetch labels", err) 213 + s.pages.Error503(w) 214 + return 215 + } 216 + 217 + defs := make(map[string]*models.LabelDefinition) 218 + for _, l := range labelDefs { 219 + defs[l.AtUri().String()] = &l 220 + } 221 + 222 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 223 + LoggedInUser: user, 224 + RepoInfo: repoInfo, 225 + Pull: pull, 226 + Stack: stack, 227 + AbandonedPulls: abandonedPulls, 228 + BranchDeleteStatus: branchDeleteStatus, 229 + MergeCheck: mergeCheckResponse, 230 + ResubmitCheck: resubmitResult, 231 + Pipelines: m, 232 233 OrderedReactionKinds: models.OrderedReactionKinds, 234 + Reactions: reactionMap, 235 UserReacted: userReactions, 236 + 237 + LabelDefs: defs, 238 }) 239 } 240 ··· 305 return result 306 } 307 308 + func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 309 + if pull.State != models.PullMerged { 310 + return nil 311 + } 312 + 313 + user := s.oauth.GetUser(r) 314 + if user == nil { 315 + return nil 316 + } 317 + 318 + var branch string 319 + var repo *models.Repo 320 + // check if the branch exists 321 + // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 322 + if pull.IsBranchBased() { 323 + branch = pull.PullSource.Branch 324 + repo = &f.Repo 325 + } else if pull.IsForkBased() { 326 + branch = pull.PullSource.Branch 327 + repo = pull.PullSource.Repo 328 + } else { 329 + return nil 330 + } 331 + 332 + scheme := "http" 333 + if !s.config.Core.Dev { 334 + scheme = "https" 335 + } 336 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 337 + xrpcc := &indigoxrpc.Client{ 338 + Host: host, 339 + } 340 + 341 + resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 342 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 343 + return nil 344 + } 345 + 346 + return &models.BranchDeleteStatus{ 347 + Repo: repo, 348 + Branch: resp.Name, 349 + } 350 + } 351 + 352 func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 353 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 354 return pages.Unknown ··· 623 m[p.Sha] = p 624 } 625 626 + labelDefs, err := db.GetLabelDefinitions( 627 + s.db, 628 + db.FilterIn("at_uri", f.Repo.Labels), 629 + db.FilterContains("scope", tangled.RepoPullNSID), 630 + ) 631 + if err != nil { 632 + log.Println("failed to fetch labels", err) 633 + s.pages.Error503(w) 634 + return 635 + } 636 + 637 + defs := make(map[string]*models.LabelDefinition) 638 + for _, l := range labelDefs { 639 + defs[l.AtUri().String()] = &l 640 + } 641 + 642 s.pages.RepoPulls(w, pages.RepoPullsParams{ 643 LoggedInUser: s.oauth.GetUser(r), 644 RepoInfo: f.RepoInfo(user), 645 Pulls: pulls, 646 + LabelDefs: defs, 647 FilteringBy: state, 648 Stacks: stacks, 649 Pipelines: m, ··· 713 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 714 return 715 } 716 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 717 Collection: tangled.RepoPullCommentNSID, 718 Repo: user.Did, 719 Rkey: tid.TID(), ··· 1141 1142 // We've already checked earlier if it's diff-based and title is empty, 1143 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1144 + if title == "" || body == "" { 1145 formatPatches, err := patchutil.ExtractPatches(patch) 1146 if err != nil { 1147 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1152 return 1153 } 1154 1155 + if title == "" { 1156 + title = formatPatches[0].Title 1157 + } 1158 + if body == "" { 1159 + body = formatPatches[0].Body 1160 + } 1161 } 1162 1163 rkey := tid.TID() ··· 1190 return 1191 } 1192 1193 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1194 Collection: tangled.RepoPullNSID, 1195 Repo: user.Did, 1196 Rkey: rkey, ··· 1201 Repo: string(f.RepoAt()), 1202 Branch: targetBranch, 1203 }, 1204 + Patch: patch, 1205 + Source: recordPullSource, 1206 + CreatedAt: time.Now().Format(time.RFC3339), 1207 }, 1208 }, 1209 }) ··· 1288 } 1289 writes = append(writes, &write) 1290 } 1291 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1292 Repo: user.Did, 1293 Writes: writes, 1294 }) ··· 1819 return 1820 } 1821 1822 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1823 if err != nil { 1824 // failed to get record 1825 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1842 } 1843 } 1844 1845 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1846 Collection: tangled.RepoPullNSID, 1847 Repo: user.Did, 1848 Rkey: pull.Rkey, ··· 1854 Repo: string(f.RepoAt()), 1855 Branch: pull.TargetBranch, 1856 }, 1857 + Patch: patch, // new patch 1858 + Source: recordPullSource, 1859 + CreatedAt: time.Now().Format(time.RFC3339), 1860 }, 1861 }, 1862 }) ··· 2115 return 2116 } 2117 2118 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2119 Repo: user.Did, 2120 Writes: writes, 2121 })
+11 -10
appview/repo/artifact.go
··· 10 "net/url" 11 "time" 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/dustin/go-humanize" 17 - "github.com/go-chi/chi/v5" 18 - "github.com/go-git/go-git/v5/plumbing" 19 - "github.com/ipfs/go-cid" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" ··· 25 "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/tid" 27 "tangled.org/core/types" 28 ) 29 30 // TODO: proper statuses here on early exit ··· 60 return 61 } 62 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 if err != nil { 65 log.Println("failed to upload blob", err) 66 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 rkey := tid.TID() 73 createdAt := time.Now() 74 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 Collection: tangled.RepoArtifactNSID, 77 Repo: user.Did, 78 Rkey: rkey, ··· 249 return 250 } 251 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 Collection: tangled.RepoArtifactNSID, 254 Repo: user.Did, 255 Rkey: artifact.Rkey,
··· 10 "net/url" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" ··· 18 "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/tid" 20 "tangled.org/core/types" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 + "github.com/dustin/go-humanize" 26 + "github.com/go-chi/chi/v5" 27 + "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/ipfs/go-cid" 29 ) 30 31 // TODO: proper statuses here on early exit ··· 61 return 62 } 63 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 65 if err != nil { 66 log.Println("failed to upload blob", err) 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 73 rkey := tid.TID() 74 createdAt := time.Now() 75 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 Repo: user.Did, 79 Rkey: rkey, ··· 250 return 251 } 252 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 254 Collection: tangled.RepoArtifactNSID, 255 Repo: user.Did, 256 Rkey: artifact.Rkey,
+17 -22
appview/repo/index.go
··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/pages/markup" 26 "tangled.org/core/appview/reporesolver" 27 "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/types" ··· 201 }) 202 } 203 204 // update appview's cache 205 - err = db.InsertRepoLanguages(rp.db, langs) 206 if err != nil { 207 // non-fatal 208 log.Println("failed to cache lang results", err) 209 } 210 } 211 ··· 328 } 329 }() 330 331 - // readme content 332 - wg.Add(1) 333 - go func() { 334 - defer wg.Done() 335 - for _, filename := range markup.ReadmeFilenames { 336 - blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 - if err != nil { 338 - continue 339 - } 340 - 341 - if blobResp == nil { 342 - continue 343 - } 344 - 345 - readmeContent = blobResp.Content 346 - readmeFileName = filename 347 - break 348 - } 349 - }() 350 - 351 wg.Wait() 352 353 if errs != nil { ··· 374 } 375 files = append(files, niceFile) 376 } 377 } 378 379 result := &types.RepoIndexResponse{
··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/xrpcclient" 27 "tangled.org/core/types" ··· 200 }) 201 } 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 209 // update appview's cache 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 211 if err != nil { 212 // non-fatal 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 219 } 220 } 221 ··· 338 } 339 }() 340 341 wg.Wait() 342 343 if errs != nil { ··· 364 } 365 files = append(files, niceFile) 366 } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 372 } 373 374 result := &types.RepoIndexResponse{
+500
appview/repo/ogcard/card.go
···
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "image" 11 + "image/color" 12 + "io" 13 + "log" 14 + "math" 15 + "net/http" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/goki/freetype" 21 + "github.com/goki/freetype/truetype" 22 + "github.com/srwiley/oksvg" 23 + "github.com/srwiley/rasterx" 24 + "golang.org/x/image/draw" 25 + "golang.org/x/image/font" 26 + "tangled.org/core/appview/pages" 27 + 28 + _ "golang.org/x/image/webp" // for processing webp images 29 + ) 30 + 31 + type Card struct { 32 + Img *image.RGBA 33 + Font *truetype.Font 34 + Margin int 35 + Width int 36 + Height int 37 + } 38 + 39 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 + if err != nil { 42 + return nil, err 43 + } 44 + return truetype.Parse(interVar) 45 + }) 46 + 47 + // DefaultSize returns the default size for a card 48 + func DefaultSize() (int, int) { 49 + return 1200, 600 50 + } 51 + 52 + // NewCard creates a new card with the given dimensions in pixels 53 + func NewCard(width, height int) (*Card, error) { 54 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 + 57 + font, err := fontCache() 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + return &Card{ 63 + Img: img, 64 + Font: font, 65 + Margin: 0, 66 + Width: width, 67 + Height: height, 68 + }, nil 69 + } 70 + 71 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 + bounds := c.Img.Bounds() 75 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 + if vertical { 77 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 + } 83 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 + } 89 + 90 + // SetMargin sets the margins for the card 91 + func (c *Card) SetMargin(margin int) { 92 + c.Margin = margin 93 + } 94 + 95 + type ( 96 + VAlign int64 97 + HAlign int64 98 + ) 99 + 100 + const ( 101 + Top VAlign = iota 102 + Middle 103 + Bottom 104 + ) 105 + 106 + const ( 107 + Left HAlign = iota 108 + Center 109 + Right 110 + ) 111 + 112 + // DrawText draws text within the card, respecting margins and alignment 113 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 + ft := freetype.NewContext() 115 + ft.SetDPI(72) 116 + ft.SetFont(c.Font) 117 + ft.SetFontSize(sizePt) 118 + ft.SetClip(c.Img.Bounds()) 119 + ft.SetDst(c.Img) 120 + ft.SetSrc(image.NewUniform(textColor)) 121 + 122 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 + fontHeight := ft.PointToFixed(sizePt).Ceil() 124 + 125 + bounds := c.Img.Bounds() 126 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 + 130 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 + // knowing the total height, which is related to how many lines we'll have. 133 + lines := make([]string, 0) 134 + textWords := strings.Split(text, " ") 135 + currentLine := "" 136 + heightTotal := 0 137 + 138 + for { 139 + if len(textWords) == 0 { 140 + // Ran out of words. 141 + if currentLine != "" { 142 + heightTotal += fontHeight 143 + lines = append(lines, currentLine) 144 + } 145 + break 146 + } 147 + 148 + nextWord := textWords[0] 149 + proposedLine := currentLine 150 + if proposedLine != "" { 151 + proposedLine += " " 152 + } 153 + proposedLine += nextWord 154 + 155 + proposedLineWidth := font.MeasureString(face, proposedLine) 156 + if proposedLineWidth.Ceil() > boxWidth { 157 + // no, proposed line is too big; we'll use the last "currentLine" 158 + heightTotal += fontHeight 159 + if currentLine != "" { 160 + lines = append(lines, currentLine) 161 + currentLine = "" 162 + // leave nextWord in textWords and keep going 163 + } else { 164 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 + // regardless as a line by itself. It will be clipped by the drawing routine. 166 + lines = append(lines, nextWord) 167 + textWords = textWords[1:] 168 + } 169 + } else { 170 + // yes, it will fit 171 + currentLine = proposedLine 172 + textWords = textWords[1:] 173 + } 174 + } 175 + 176 + textY := 0 177 + switch valign { 178 + case Top: 179 + textY = fontHeight 180 + case Bottom: 181 + textY = boxHeight - heightTotal + fontHeight 182 + case Middle: 183 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 + } 185 + 186 + for _, line := range lines { 187 + lineWidth := font.MeasureString(face, line) 188 + 189 + textX := 0 190 + switch halign { 191 + case Left: 192 + textX = 0 193 + case Right: 194 + textX = boxWidth - lineWidth.Ceil() 195 + case Center: 196 + textX = (boxWidth - lineWidth.Ceil()) / 2 197 + } 198 + 199 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 + _, err := ft.DrawString(line, pt) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + textY += fontHeight 206 + } 207 + 208 + return lines, nil 209 + } 210 + 211 + // DrawTextAt draws text at a specific position with the given alignment 212 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 + return err 215 + } 216 + 217 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 + ft := freetype.NewContext() 220 + ft.SetDPI(72) 221 + ft.SetFont(c.Font) 222 + ft.SetFontSize(sizePt) 223 + ft.SetClip(c.Img.Bounds()) 224 + ft.SetDst(c.Img) 225 + ft.SetSrc(image.NewUniform(textColor)) 226 + 227 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 + fontHeight := ft.PointToFixed(sizePt).Ceil() 229 + lineWidth := font.MeasureString(face, text) 230 + textWidth := lineWidth.Ceil() 231 + 232 + // Adjust position based on alignment 233 + adjustedX := x 234 + adjustedY := y 235 + 236 + switch halign { 237 + case Left: 238 + // x is already at the left position 239 + case Right: 240 + adjustedX = x - textWidth 241 + case Center: 242 + adjustedX = x - textWidth/2 243 + } 244 + 245 + switch valign { 246 + case Top: 247 + adjustedY = y + fontHeight 248 + case Bottom: 249 + adjustedY = y 250 + case Middle: 251 + adjustedY = y + fontHeight/2 252 + } 253 + 254 + pt := freetype.Pt(adjustedX, adjustedY) 255 + _, err := ft.DrawString(text, pt) 256 + return textWidth, err 257 + } 258 + 259 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 + // Draw the text multiple times with slight offsets to create bold effect 262 + offsets := []struct{ dx, dy int }{ 263 + {0, 0}, // original 264 + {1, 0}, // right 265 + {0, 1}, // down 266 + {1, 1}, // diagonal 267 + } 268 + 269 + var width int 270 + for _, offset := range offsets { 271 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 + if err != nil { 273 + return 0, err 274 + } 275 + if width == 0 { 276 + width = w 277 + } 278 + } 279 + return width, nil 280 + } 281 + 282 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 + func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 + svgData, err := pages.Files.ReadFile(svgPath) 285 + if err != nil { 286 + return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 + } 288 + 289 + // Convert color to hex string for SVG 290 + rgba, isRGBA := iconColor.(color.RGBA) 291 + if !isRGBA { 292 + r, g, b, a := iconColor.RGBA() 293 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 + } 295 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 + 297 + // Replace currentColor with our desired color in the SVG 298 + svgString := string(svgData) 299 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 + 301 + // Make the stroke thicker 302 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 + 304 + // Parse SVG 305 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 + if err != nil { 307 + return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 + } 309 + 310 + // Set the icon size 311 + w, h := float64(size), float64(size) 312 + icon.SetTarget(0, 0, w, h) 313 + 314 + // Create a temporary RGBA image for the icon 315 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 + 317 + // Create scanner and rasterizer 318 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 + raster := rasterx.NewDasher(size, size, scanner) 320 + 321 + // Draw the icon 322 + icon.Draw(raster, 1.0) 323 + 324 + // Draw the icon onto the card at the specified position 325 + bounds := c.Img.Bounds() 326 + destRect := image.Rect(x, y, x+size, y+size) 327 + 328 + // Make sure we don't draw outside the card bounds 329 + if destRect.Max.X > bounds.Max.X { 330 + destRect.Max.X = bounds.Max.X 331 + } 332 + if destRect.Max.Y > bounds.Max.Y { 333 + destRect.Max.Y = bounds.Max.Y 334 + } 335 + 336 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 + 338 + return nil 339 + } 340 + 341 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 + func (c *Card) DrawImage(img image.Image) { 343 + bounds := c.Img.Bounds() 344 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 + srcBounds := img.Bounds() 346 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 + 349 + var scale float64 350 + if srcAspect > targetAspect { 351 + // Image is wider than target, scale by width 352 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 + } else { 354 + // Image is taller or equal, scale by height 355 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 + } 357 + 358 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 + 361 + // Center the image within the target rectangle 362 + offsetX := (targetRect.Dx() - newWidth) / 2 363 + offsetY := (targetRect.Dy() - newHeight) / 2 364 + 365 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 + } 368 + 369 + func fallbackImage() image.Image { 370 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 + img.Set(0, 0, color.White) 373 + return img 374 + } 375 + 376 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 + // this rendering process to be slowed down 380 + client := &http.Client{ 381 + Timeout: 1 * time.Second, // 1 second timeout 382 + } 383 + 384 + resp, err := client.Get(url) 385 + if err != nil { 386 + log.Printf("error when fetching external image from %s: %v", url, err) 387 + return nil, false 388 + } 389 + defer resp.Body.Close() 390 + 391 + if resp.StatusCode != http.StatusOK { 392 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 + return nil, false 394 + } 395 + 396 + contentType := resp.Header.Get("Content-Type") 397 + // Support content types are in-sync with the allowed custom avatar file types 398 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 + return nil, false 401 + } 402 + 403 + body := resp.Body 404 + bodyBytes, err := io.ReadAll(body) 405 + if err != nil { 406 + log.Printf("error when fetching external image from %s: %v", url, err) 407 + return nil, false 408 + } 409 + 410 + bodyBuffer := bytes.NewReader(bodyBytes) 411 + _, imgType, err := image.DecodeConfig(bodyBuffer) 412 + if err != nil { 413 + log.Printf("error when decoding external image from %s: %v", url, err) 414 + return nil, false 415 + } 416 + 417 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 + if (contentType == "image/png" && imgType != "png") || 419 + (contentType == "image/jpeg" && imgType != "jpeg") || 420 + (contentType == "image/gif" && imgType != "gif") || 421 + (contentType == "image/webp" && imgType != "webp") { 422 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 + return nil, false 424 + } 425 + 426 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 + if err != nil { 428 + log.Printf("error w/ bodyBuffer.Seek") 429 + return nil, false 430 + } 431 + img, _, err := image.Decode(bodyBuffer) 432 + if err != nil { 433 + log.Printf("error when decoding external image from %s: %v", url, err) 434 + return nil, false 435 + } 436 + 437 + return img, true 438 + } 439 + 440 + func (c *Card) DrawExternalImage(url string) { 441 + image, ok := c.fetchExternalImage(url) 442 + if !ok { 443 + image = fallbackImage() 444 + } 445 + c.DrawImage(image) 446 + } 447 + 448 + // DrawCircularExternalImage draws an external image as a circle at the specified position 449 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 + img, ok := c.fetchExternalImage(url) 451 + if !ok { 452 + img = fallbackImage() 453 + } 454 + 455 + // Create a circular mask 456 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 + center := size / 2 458 + radius := float64(size / 2) 459 + 460 + // Scale the source image to fit the circle 461 + srcBounds := img.Bounds() 462 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 + 465 + // Draw the image with circular clipping 466 + for cy := 0; cy < size; cy++ { 467 + for cx := 0; cx < size; cx++ { 468 + // Calculate distance from center 469 + dx := float64(cx - center) 470 + dy := float64(cy - center) 471 + distance := math.Sqrt(dx*dx + dy*dy) 472 + 473 + // Only draw pixels within the circle 474 + if distance <= radius { 475 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 + } 477 + } 478 + } 479 + 480 + // Draw the circle onto the card 481 + bounds := c.Img.Bounds() 482 + destRect := image.Rect(x, y, x+size, y+size) 483 + 484 + // Make sure we don't draw outside the card bounds 485 + if destRect.Max.X > bounds.Max.X { 486 + destRect.Max.X = bounds.Max.X 487 + } 488 + if destRect.Max.Y > bounds.Max.Y { 489 + destRect.Max.Y = bounds.Max.Y 490 + } 491 + 492 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 + 494 + return nil 495 + } 496 + 497 + // DrawRect draws a rect with the given color 498 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 + }
+376
appview/repo/opengraph.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/hex" 7 + "fmt" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + "sort" 13 + "strings" 14 + 15 + "github.com/go-enry/go-enry/v2" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/models" 18 + "tangled.org/core/appview/repo/ogcard" 19 + "tangled.org/core/types" 20 + ) 21 + 22 + func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { 23 + width, height := ogcard.DefaultSize() 24 + mainCard, err := ogcard.NewCard(width, height) 25 + if err != nil { 26 + return nil, err 27 + } 28 + 29 + // Split: content area (75%) and language bar + icons (25%) 30 + contentCard, bottomArea := mainCard.Split(false, 75) 31 + 32 + // Add padding to content 33 + contentCard.SetMargin(50) 34 + 35 + // Split content horizontally: main content (80%) and avatar area (20%) 36 + mainContent, avatarArea := contentCard.Split(true, 80) 37 + 38 + // Split main content: 50% for name/description, 50% for spacing 39 + topSection, _ := mainContent.Split(false, 50) 40 + 41 + // Split top section: 40% for repo name, 60% for description 42 + repoNameCard, descriptionCard := topSection.Split(false, 50) 43 + 44 + // Draw repo name with owner in regular and repo name in bold 45 + repoNameCard.SetMargin(10) 46 + var ownerHandle string 47 + owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 48 + if err != nil { 49 + ownerHandle = repo.Did 50 + } else { 51 + ownerHandle = "@" + owner.Handle.String() 52 + } 53 + 54 + // Draw repo name with wrapping support 55 + repoNameCard.SetMargin(10) 56 + bounds := repoNameCard.Img.Bounds() 57 + startX := bounds.Min.X + repoNameCard.Margin 58 + startY := bounds.Min.Y + repoNameCard.Margin 59 + currentX := startX 60 + textColor := color.RGBA{88, 96, 105, 255} 61 + 62 + // Draw owner handle in gray 63 + ownerWidth, err := repoNameCard.DrawTextAtWithWidth(ownerHandle, currentX, startY, textColor, 54, ogcard.Top, ogcard.Left) 64 + if err != nil { 65 + return nil, err 66 + } 67 + currentX += ownerWidth 68 + 69 + // Draw separator 70 + sepWidth, err := repoNameCard.DrawTextAtWithWidth(" / ", currentX, startY, textColor, 54, ogcard.Top, ogcard.Left) 71 + if err != nil { 72 + return nil, err 73 + } 74 + currentX += sepWidth 75 + 76 + // Draw repo name in bold 77 + _, err = repoNameCard.DrawBoldText(repo.Name, currentX, startY, color.Black, 54, ogcard.Top, ogcard.Left) 78 + if err != nil { 79 + return nil, err 80 + } 81 + 82 + // Draw description (DrawText handles multi-line wrapping automatically) 83 + descriptionCard.SetMargin(10) 84 + description := repo.Description 85 + if len(description) > 70 { 86 + description = description[:70] + "…" 87 + } 88 + 89 + _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 90 + if err != nil { 91 + log.Printf("failed to draw description: %v", err) 92 + return nil, err 93 + } 94 + 95 + // Draw avatar circle on the right side 96 + avatarBounds := avatarArea.Img.Bounds() 97 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 98 + if avatarSize > 220 { 99 + avatarSize = 220 100 + } 101 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 102 + avatarY := avatarBounds.Min.Y + 20 103 + 104 + // Get avatar URL and draw it 105 + avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") 106 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 107 + if err != nil { 108 + log.Printf("failed to draw avatar (non-fatal): %v", err) 109 + } 110 + 111 + // Split bottom area: icons area (65%) and language bar (35%) 112 + iconsArea, languageBarCard := bottomArea.Split(false, 75) 113 + 114 + // Split icons area: left side for stats (80%), right side for dolly (20%) 115 + statsArea, dollyArea := iconsArea.Split(true, 80) 116 + 117 + // Draw stats with icons in the stats area 118 + starsText := repo.RepoStats.StarCount 119 + issuesText := repo.RepoStats.IssueCount.Open 120 + pullRequestsText := repo.RepoStats.PullCount.Open 121 + 122 + iconColor := color.RGBA{88, 96, 105, 255} 123 + iconSize := 36 124 + textSize := 36.0 125 + 126 + // Position stats in the middle of the stats area 127 + statsBounds := statsArea.Img.Bounds() 128 + statsX := statsBounds.Min.X + 60 // left padding 129 + statsY := statsBounds.Min.Y 130 + currentX = statsX 131 + labelSize := 22.0 132 + // Draw star icon, count, and label 133 + // Align icon baseline with text baseline 134 + iconBaselineOffset := int(textSize) / 2 135 + err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 136 + if err != nil { 137 + log.Printf("failed to draw star icon: %v", err) 138 + } 139 + starIconX := currentX 140 + currentX += iconSize + 15 141 + 142 + starText := fmt.Sprintf("%d", starsText) 143 + err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 144 + if err != nil { 145 + log.Printf("failed to draw star text: %v", err) 146 + } 147 + starTextWidth := len(starText) * 20 148 + starGroupWidth := iconSize + 15 + starTextWidth 149 + 150 + // Draw "stars" label below and centered under the icon+text group 151 + labelY := statsY + iconSize + 15 152 + labelX := starIconX + starGroupWidth/2 153 + err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 154 + if err != nil { 155 + log.Printf("failed to draw stars label: %v", err) 156 + } 157 + 158 + currentX += starTextWidth + 50 159 + 160 + // Draw issues icon, count, and label 161 + issueStartX := currentX 162 + err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 163 + if err != nil { 164 + log.Printf("failed to draw circle-dot icon: %v", err) 165 + } 166 + currentX += iconSize + 15 167 + 168 + issueText := fmt.Sprintf("%d", issuesText) 169 + err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 170 + if err != nil { 171 + log.Printf("failed to draw issue text: %v", err) 172 + } 173 + issueTextWidth := len(issueText) * 20 174 + issueGroupWidth := iconSize + 15 + issueTextWidth 175 + 176 + // Draw "issues" label below and centered under the icon+text group 177 + labelX = issueStartX + issueGroupWidth/2 178 + err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 179 + if err != nil { 180 + log.Printf("failed to draw issues label: %v", err) 181 + } 182 + 183 + currentX += issueTextWidth + 50 184 + 185 + // Draw pull request icon, count, and label 186 + prStartX := currentX 187 + err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 188 + if err != nil { 189 + log.Printf("failed to draw git-pull-request icon: %v", err) 190 + } 191 + currentX += iconSize + 15 192 + 193 + prText := fmt.Sprintf("%d", pullRequestsText) 194 + err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 195 + if err != nil { 196 + log.Printf("failed to draw PR text: %v", err) 197 + } 198 + prTextWidth := len(prText) * 20 199 + prGroupWidth := iconSize + 15 + prTextWidth 200 + 201 + // Draw "pulls" label below and centered under the icon+text group 202 + labelX = prStartX + prGroupWidth/2 203 + err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 204 + if err != nil { 205 + log.Printf("failed to draw pulls label: %v", err) 206 + } 207 + 208 + dollyBounds := dollyArea.Img.Bounds() 209 + dollySize := 90 210 + dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 211 + dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 212 + dollyColor := color.RGBA{180, 180, 180, 255} // light gray 213 + err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 214 + if err != nil { 215 + log.Printf("dolly silhouette not available (this is ok): %v", err) 216 + } 217 + 218 + // Draw language bar at bottom 219 + err = drawLanguagesCard(languageBarCard, languageStats) 220 + if err != nil { 221 + log.Printf("failed to draw language bar: %v", err) 222 + return nil, err 223 + } 224 + 225 + return mainCard, nil 226 + } 227 + 228 + // hexToColor converts a hex color to a go color 229 + func hexToColor(colorStr string) (*color.RGBA, error) { 230 + colorStr = strings.TrimLeft(colorStr, "#") 231 + 232 + b, err := hex.DecodeString(colorStr) 233 + if err != nil { 234 + return nil, err 235 + } 236 + 237 + if len(b) < 3 { 238 + return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) 239 + } 240 + 241 + clr := color.RGBA{b[0], b[1], b[2], 255} 242 + 243 + return &clr, nil 244 + } 245 + 246 + func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { 247 + bounds := card.Img.Bounds() 248 + cardWidth := bounds.Dx() 249 + 250 + if len(languageStats) == 0 { 251 + // Draw a light gray bar if no languages detected 252 + card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) 253 + return nil 254 + } 255 + 256 + // Limit to top 5 languages for the visual bar 257 + displayLanguages := languageStats 258 + if len(displayLanguages) > 5 { 259 + displayLanguages = displayLanguages[:5] 260 + } 261 + 262 + currentX := bounds.Min.X 263 + 264 + for _, lang := range displayLanguages { 265 + var langColor *color.RGBA 266 + var err error 267 + 268 + if lang.Color != "" { 269 + langColor, err = hexToColor(lang.Color) 270 + if err != nil { 271 + // Fallback to a default color 272 + langColor = &color.RGBA{149, 157, 165, 255} 273 + } 274 + } else { 275 + // Default color if no color specified 276 + langColor = &color.RGBA{149, 157, 165, 255} 277 + } 278 + 279 + langWidth := float32(cardWidth) * (lang.Percentage / 100) 280 + card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) 281 + currentX += int(langWidth) 282 + } 283 + 284 + // Fill remaining space with the last color (if any gap due to rounding) 285 + if currentX < bounds.Max.X && len(displayLanguages) > 0 { 286 + lastLang := displayLanguages[len(displayLanguages)-1] 287 + var lastColor *color.RGBA 288 + var err error 289 + 290 + if lastLang.Color != "" { 291 + lastColor, err = hexToColor(lastLang.Color) 292 + if err != nil { 293 + lastColor = &color.RGBA{149, 157, 165, 255} 294 + } 295 + } else { 296 + lastColor = &color.RGBA{149, 157, 165, 255} 297 + } 298 + card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) 299 + } 300 + 301 + return nil 302 + } 303 + 304 + func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 305 + f, err := rp.repoResolver.Resolve(r) 306 + if err != nil { 307 + log.Println("failed to get repo and knot", err) 308 + return 309 + } 310 + 311 + // Get language stats directly from database 312 + var languageStats []types.RepoLanguageDetails 313 + langs, err := db.GetRepoLanguages( 314 + rp.db, 315 + db.FilterEq("repo_at", f.RepoAt()), 316 + db.FilterEq("is_default_ref", 1), 317 + ) 318 + if err != nil { 319 + log.Printf("failed to get language stats from db: %v", err) 320 + // non-fatal, continue without language stats 321 + } else if len(langs) > 0 { 322 + var total int64 323 + for _, l := range langs { 324 + total += l.Bytes 325 + } 326 + 327 + for _, l := range langs { 328 + percentage := float32(l.Bytes) / float32(total) * 100 329 + color := enry.GetColor(l.Language) 330 + languageStats = append(languageStats, types.RepoLanguageDetails{ 331 + Name: l.Language, 332 + Percentage: percentage, 333 + Color: color, 334 + }) 335 + } 336 + 337 + sort.Slice(languageStats, func(i, j int) bool { 338 + if languageStats[i].Name == enry.OtherLanguage { 339 + return false 340 + } 341 + if languageStats[j].Name == enry.OtherLanguage { 342 + return true 343 + } 344 + if languageStats[i].Percentage != languageStats[j].Percentage { 345 + return languageStats[i].Percentage > languageStats[j].Percentage 346 + } 347 + return languageStats[i].Name < languageStats[j].Name 348 + }) 349 + } 350 + 351 + card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 352 + if err != nil { 353 + log.Println("failed to draw repo summary card", err) 354 + http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) 355 + return 356 + } 357 + 358 + var imageBuffer bytes.Buffer 359 + err = png.Encode(&imageBuffer, card.Img) 360 + if err != nil { 361 + log.Println("failed to encode repo summary card", err) 362 + http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) 363 + return 364 + } 365 + 366 + imageBytes := imageBuffer.Bytes() 367 + 368 + w.Header().Set("Content-Type", "image/png") 369 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 370 + w.WriteHeader(http.StatusOK) 371 + _, err = w.Write(imageBytes) 372 + if err != nil { 373 + log.Println("failed to write repo summary card", err) 374 + return 375 + } 376 + }
+90 -66
appview/repo/repo.go
··· 17 "strings" 18 "time" 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 "tangled.org/core/api/tangled" 24 "tangled.org/core/appview/commitverify" 25 "tangled.org/core/appview/config" ··· 40 "tangled.org/core/types" 41 "tangled.org/core/xrpc/serviceauth" 42 43 securejoin "github.com/cyphar/filepath-securejoin" 44 "github.com/go-chi/chi/v5" 45 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 ) 49 50 type Repo struct { ··· 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 // 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 if err != nil { 312 // failed to get record 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 return 315 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 317 Collection: tangled.RepoNSID, 318 Repo: newRepo.Did, 319 Rkey: newRepo.Rkey, ··· 449 return 450 } 451 452 - // readme content 453 - var ( 454 - readmeContent string 455 - readmeFileName string 456 - ) 457 - 458 - for _, filename := range markup.ReadmeFilenames { 459 - path := fmt.Sprintf("%s/%s", treePath, filename) 460 - blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo) 461 - if err != nil { 462 - continue 463 - } 464 - 465 - if blobResp == nil { 466 - continue 467 - } 468 - 469 - readmeContent = blobResp.Content 470 - readmeFileName = path 471 - break 472 - } 473 - 474 // Convert XRPC response to internal types.RepoTreeResponse 475 files := make([]types.NiceTree, len(xrpcResp.Files)) 476 for i, xrpcFile := range xrpcResp.Files { ··· 506 if xrpcResp.Dotdot != nil { 507 result.DotDot = *xrpcResp.Dotdot 508 } 509 510 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 511 // so we can safely redirect to the "parent" (which is the same file). ··· 532 BreadCrumbs: breadcrumbs, 533 TreePath: treePath, 534 RepoInfo: f.RepoInfo(user), 535 - Readme: readmeContent, 536 - ReadmeFileName: readmeFileName, 537 RepoTreeResponse: result, 538 }) 539 } ··· 648 }) 649 } 650 651 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 652 f, err := rp.repoResolver.Resolve(r) 653 if err != nil { ··· 883 user := rp.oauth.GetUser(r) 884 l := rp.logger.With("handler", "EditSpindle") 885 l = l.With("did", user.Did) 886 - l = l.With("handle", user.Handle) 887 888 errorId := "operation-error" 889 fail := func(msg string, err error) { ··· 936 return 937 } 938 939 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 940 if err != nil { 941 fail("Failed to update spindle, no record found on PDS.", err) 942 return 943 } 944 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 945 Collection: tangled.RepoNSID, 946 Repo: newRepo.Did, 947 Rkey: newRepo.Rkey, ··· 971 user := rp.oauth.GetUser(r) 972 l := rp.logger.With("handler", "AddLabel") 973 l = l.With("did", user.Did) 974 - l = l.With("handle", user.Handle) 975 976 f, err := rp.repoResolver.Resolve(r) 977 if err != nil { ··· 1040 1041 // emit a labelRecord 1042 labelRecord := label.AsRecord() 1043 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1044 Collection: tangled.LabelDefinitionNSID, 1045 Repo: label.Did, 1046 Rkey: label.Rkey, ··· 1063 newRepo.Labels = append(newRepo.Labels, aturi) 1064 repoRecord := newRepo.AsRecord() 1065 1066 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1067 if err != nil { 1068 fail("Failed to update labels, no record found on PDS.", err) 1069 return 1070 } 1071 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1072 Collection: tangled.RepoNSID, 1073 Repo: newRepo.Did, 1074 Rkey: newRepo.Rkey, ··· 1131 user := rp.oauth.GetUser(r) 1132 l := rp.logger.With("handler", "DeleteLabel") 1133 l = l.With("did", user.Did) 1134 - l = l.With("handle", user.Handle) 1135 1136 f, err := rp.repoResolver.Resolve(r) 1137 if err != nil { ··· 1161 } 1162 1163 // delete label record from PDS 1164 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1165 Collection: tangled.LabelDefinitionNSID, 1166 Repo: label.Did, 1167 Rkey: label.Rkey, ··· 1183 newRepo.Labels = updated 1184 repoRecord := newRepo.AsRecord() 1185 1186 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1187 if err != nil { 1188 fail("Failed to update labels, no record found on PDS.", err) 1189 return 1190 } 1191 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1192 Collection: tangled.RepoNSID, 1193 Repo: newRepo.Did, 1194 Rkey: newRepo.Rkey, ··· 1240 user := rp.oauth.GetUser(r) 1241 l := rp.logger.With("handler", "SubscribeLabel") 1242 l = l.With("did", user.Did) 1243 - l = l.With("handle", user.Handle) 1244 1245 f, err := rp.repoResolver.Resolve(r) 1246 if err != nil { ··· 1281 return 1282 } 1283 1284 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1285 if err != nil { 1286 fail("Failed to update labels, no record found on PDS.", err) 1287 return 1288 } 1289 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1290 Collection: tangled.RepoNSID, 1291 Repo: newRepo.Did, 1292 Rkey: newRepo.Rkey, ··· 1327 user := rp.oauth.GetUser(r) 1328 l := rp.logger.With("handler", "UnsubscribeLabel") 1329 l = l.With("did", user.Did) 1330 - l = l.With("handle", user.Handle) 1331 1332 f, err := rp.repoResolver.Resolve(r) 1333 if err != nil { ··· 1370 return 1371 } 1372 1373 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1374 if err != nil { 1375 fail("Failed to update labels, no record found on PDS.", err) 1376 return 1377 } 1378 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1379 Collection: tangled.RepoNSID, 1380 Repo: newRepo.Did, 1381 Rkey: newRepo.Rkey, ··· 1499 user := rp.oauth.GetUser(r) 1500 l := rp.logger.With("handler", "AddCollaborator") 1501 l = l.With("did", user.Did) 1502 - l = l.With("handle", user.Handle) 1503 1504 f, err := rp.repoResolver.Resolve(r) 1505 if err != nil { ··· 1546 currentUser := rp.oauth.GetUser(r) 1547 rkey := tid.TID() 1548 createdAt := time.Now() 1549 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1550 Collection: tangled.RepoCollaboratorNSID, 1551 Repo: currentUser.Did, 1552 Rkey: rkey, ··· 1637 } 1638 1639 // remove record from pds 1640 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1641 if err != nil { 1642 log.Println("failed to get authorized client", err) 1643 return 1644 } 1645 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1646 Collection: tangled.RepoNSID, 1647 Repo: user.Did, 1648 Rkey: f.Rkey, ··· 1784 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1785 user := rp.oauth.GetUser(r) 1786 l := rp.logger.With("handler", "Secrets") 1787 - l = l.With("handle", user.Handle) 1788 l = l.With("did", user.Did) 1789 1790 f, err := rp.repoResolver.Resolve(r) ··· 2149 } 2150 2151 // choose a name for a fork 2152 - forkName := f.Name 2153 // this check is *only* to see if the forked repo name already exists 2154 // in the user's account. 2155 existingRepo, err := db.GetRepo( 2156 rp.db, 2157 db.FilterEq("did", user.Did), 2158 - db.FilterEq("name", f.Name), 2159 ) 2160 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 { 2164 log.Println("error fetching existing repo from db", "err", err) 2165 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2166 return 2167 } 2168 } else if existingRepo != nil { 2169 - // repo with this name already exists, append random string 2170 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2171 } 2172 l = l.With("forkName", forkName) 2173 ··· 2195 } 2196 record := repo.AsRecord() 2197 2198 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2199 if err != nil { 2200 l.Error("failed to create xrpcclient", "err", err) 2201 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2202 return 2203 } 2204 2205 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2206 Collection: tangled.RepoNSID, 2207 Repo: user.Did, 2208 Rkey: rkey, ··· 2234 rollback := func() { 2235 err1 := tx.Rollback() 2236 err2 := rp.enforcer.E.LoadPolicy() 2237 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2238 2239 // ignore txn complete errors, this is okay 2240 if errors.Is(err1, sql.ErrTxDone) { ··· 2307 aturi = "" 2308 2309 rp.notifier.NewRepo(r.Context(), repo) 2310 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2311 } 2312 } 2313 2314 // this is used to rollback changes made to the PDS 2315 // 2316 // it is a no-op if the provided ATURI is empty 2317 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2318 if aturi == "" { 2319 return nil 2320 } ··· 2325 repo := parsed.Authority().String() 2326 rkey := parsed.RecordKey().String() 2327 2328 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2329 Collection: collection, 2330 Repo: repo, 2331 Rkey: rkey,
··· 17 "strings" 18 "time" 19 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/commitverify" 22 "tangled.org/core/appview/config" ··· 37 "tangled.org/core/types" 38 "tangled.org/core/xrpc/serviceauth" 39 40 + comatproto "github.com/bluesky-social/indigo/api/atproto" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 43 + lexutil "github.com/bluesky-social/indigo/lex/util" 44 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 45 securejoin "github.com/cyphar/filepath-securejoin" 46 "github.com/go-chi/chi/v5" 47 "github.com/go-git/go-git/v5/plumbing" 48 ) 49 50 type Repo struct { ··· 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 // 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 if err != nil { 312 // failed to get record 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 return 315 } 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 Collection: tangled.RepoNSID, 318 Repo: newRepo.Did, 319 Rkey: newRepo.Rkey, ··· 449 return 450 } 451 452 // Convert XRPC response to internal types.RepoTreeResponse 453 files := make([]types.NiceTree, len(xrpcResp.Files)) 454 for i, xrpcFile := range xrpcResp.Files { ··· 484 if xrpcResp.Dotdot != nil { 485 result.DotDot = *xrpcResp.Dotdot 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 491 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 493 // so we can safely redirect to the "parent" (which is the same file). ··· 514 BreadCrumbs: breadcrumbs, 515 TreePath: treePath, 516 RepoInfo: f.RepoInfo(user), 517 RepoTreeResponse: result, 518 }) 519 } ··· 628 }) 629 } 630 631 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 632 + f, err := rp.repoResolver.Resolve(r) 633 + if err != nil { 634 + log.Println("failed to get repo and knot", err) 635 + return 636 + } 637 + 638 + noticeId := "delete-branch-error" 639 + fail := func(msg string, err error) { 640 + log.Println(msg, "err", err) 641 + rp.pages.Notice(w, noticeId, msg) 642 + } 643 + 644 + branch := r.FormValue("branch") 645 + if branch == "" { 646 + fail("No branch provided.", nil) 647 + return 648 + } 649 + 650 + client, err := rp.oauth.ServiceClient( 651 + r, 652 + oauth.WithService(f.Knot), 653 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 654 + oauth.WithDev(rp.config.Core.Dev), 655 + ) 656 + if err != nil { 657 + fail("Failed to connect to knotserver", nil) 658 + return 659 + } 660 + 661 + err = tangled.RepoDeleteBranch( 662 + r.Context(), 663 + client, 664 + &tangled.RepoDeleteBranch_Input{ 665 + Branch: branch, 666 + Repo: f.RepoAt().String(), 667 + }, 668 + ) 669 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 670 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 671 + return 672 + } 673 + log.Println("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 674 + 675 + rp.pages.HxRefresh(w) 676 + } 677 + 678 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 679 f, err := rp.repoResolver.Resolve(r) 680 if err != nil { ··· 910 user := rp.oauth.GetUser(r) 911 l := rp.logger.With("handler", "EditSpindle") 912 l = l.With("did", user.Did) 913 914 errorId := "operation-error" 915 fail := func(msg string, err error) { ··· 962 return 963 } 964 965 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 966 if err != nil { 967 fail("Failed to update spindle, no record found on PDS.", err) 968 return 969 } 970 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 971 Collection: tangled.RepoNSID, 972 Repo: newRepo.Did, 973 Rkey: newRepo.Rkey, ··· 997 user := rp.oauth.GetUser(r) 998 l := rp.logger.With("handler", "AddLabel") 999 l = l.With("did", user.Did) 1000 1001 f, err := rp.repoResolver.Resolve(r) 1002 if err != nil { ··· 1065 1066 // emit a labelRecord 1067 labelRecord := label.AsRecord() 1068 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1069 Collection: tangled.LabelDefinitionNSID, 1070 Repo: label.Did, 1071 Rkey: label.Rkey, ··· 1088 newRepo.Labels = append(newRepo.Labels, aturi) 1089 repoRecord := newRepo.AsRecord() 1090 1091 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1092 if err != nil { 1093 fail("Failed to update labels, no record found on PDS.", err) 1094 return 1095 } 1096 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1097 Collection: tangled.RepoNSID, 1098 Repo: newRepo.Did, 1099 Rkey: newRepo.Rkey, ··· 1156 user := rp.oauth.GetUser(r) 1157 l := rp.logger.With("handler", "DeleteLabel") 1158 l = l.With("did", user.Did) 1159 1160 f, err := rp.repoResolver.Resolve(r) 1161 if err != nil { ··· 1185 } 1186 1187 // delete label record from PDS 1188 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1189 Collection: tangled.LabelDefinitionNSID, 1190 Repo: label.Did, 1191 Rkey: label.Rkey, ··· 1207 newRepo.Labels = updated 1208 repoRecord := newRepo.AsRecord() 1209 1210 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1211 if err != nil { 1212 fail("Failed to update labels, no record found on PDS.", err) 1213 return 1214 } 1215 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1216 Collection: tangled.RepoNSID, 1217 Repo: newRepo.Did, 1218 Rkey: newRepo.Rkey, ··· 1264 user := rp.oauth.GetUser(r) 1265 l := rp.logger.With("handler", "SubscribeLabel") 1266 l = l.With("did", user.Did) 1267 1268 f, err := rp.repoResolver.Resolve(r) 1269 if err != nil { ··· 1304 return 1305 } 1306 1307 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1308 if err != nil { 1309 fail("Failed to update labels, no record found on PDS.", err) 1310 return 1311 } 1312 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1313 Collection: tangled.RepoNSID, 1314 Repo: newRepo.Did, 1315 Rkey: newRepo.Rkey, ··· 1350 user := rp.oauth.GetUser(r) 1351 l := rp.logger.With("handler", "UnsubscribeLabel") 1352 l = l.With("did", user.Did) 1353 1354 f, err := rp.repoResolver.Resolve(r) 1355 if err != nil { ··· 1392 return 1393 } 1394 1395 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1396 if err != nil { 1397 fail("Failed to update labels, no record found on PDS.", err) 1398 return 1399 } 1400 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1401 Collection: tangled.RepoNSID, 1402 Repo: newRepo.Did, 1403 Rkey: newRepo.Rkey, ··· 1521 user := rp.oauth.GetUser(r) 1522 l := rp.logger.With("handler", "AddCollaborator") 1523 l = l.With("did", user.Did) 1524 1525 f, err := rp.repoResolver.Resolve(r) 1526 if err != nil { ··· 1567 currentUser := rp.oauth.GetUser(r) 1568 rkey := tid.TID() 1569 createdAt := time.Now() 1570 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1571 Collection: tangled.RepoCollaboratorNSID, 1572 Repo: currentUser.Did, 1573 Rkey: rkey, ··· 1658 } 1659 1660 // remove record from pds 1661 + atpClient, err := rp.oauth.AuthorizedClient(r) 1662 if err != nil { 1663 log.Println("failed to get authorized client", err) 1664 return 1665 } 1666 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1667 Collection: tangled.RepoNSID, 1668 Repo: user.Did, 1669 Rkey: f.Rkey, ··· 1805 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1806 user := rp.oauth.GetUser(r) 1807 l := rp.logger.With("handler", "Secrets") 1808 l = l.With("did", user.Did) 1809 1810 f, err := rp.repoResolver.Resolve(r) ··· 2169 } 2170 2171 // choose a name for a fork 2172 + forkName := r.FormValue("repo_name") 2173 + if forkName == "" { 2174 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2175 + return 2176 + } 2177 + 2178 // this check is *only* to see if the forked repo name already exists 2179 // in the user's account. 2180 existingRepo, err := db.GetRepo( 2181 rp.db, 2182 db.FilterEq("did", user.Did), 2183 + db.FilterEq("name", forkName), 2184 ) 2185 if err != nil { 2186 + if !errors.Is(err, sql.ErrNoRows) { 2187 log.Println("error fetching existing repo from db", "err", err) 2188 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2189 return 2190 } 2191 } else if existingRepo != nil { 2192 + // repo with this name already exists 2193 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2194 + return 2195 } 2196 l = l.With("forkName", forkName) 2197 ··· 2219 } 2220 record := repo.AsRecord() 2221 2222 + atpClient, err := rp.oauth.AuthorizedClient(r) 2223 if err != nil { 2224 l.Error("failed to create xrpcclient", "err", err) 2225 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2226 return 2227 } 2228 2229 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2230 Collection: tangled.RepoNSID, 2231 Repo: user.Did, 2232 Rkey: rkey, ··· 2258 rollback := func() { 2259 err1 := tx.Rollback() 2260 err2 := rp.enforcer.E.LoadPolicy() 2261 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2262 2263 // ignore txn complete errors, this is okay 2264 if errors.Is(err1, sql.ErrTxDone) { ··· 2331 aturi = "" 2332 2333 rp.notifier.NewRepo(r.Context(), repo) 2334 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2335 } 2336 } 2337 2338 // this is used to rollback changes made to the PDS 2339 // 2340 // it is a no-op if the provided ATURI is empty 2341 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2342 if aturi == "" { 2343 return nil 2344 } ··· 2349 repo := parsed.Authority().String() 2350 rkey := parsed.RecordKey().String() 2351 2352 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2353 Collection: collection, 2354 Repo: repo, 2355 Rkey: rkey,
+2
appview/repo/router.go
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 r.Get("/feed.atom", rp.RepoAtomFeed) 14 r.Get("/commits/{ref}", rp.RepoLog) 15 r.Route("/tree/{ref}", func(r chi.Router) { ··· 18 }) 19 r.Get("/commit/{ref}", rp.RepoCommit) 20 r.Get("/branches", rp.RepoBranches) 21 r.Route("/tags", func(r chi.Router) { 22 r.Get("/", rp.RepoTags) 23 r.Route("/{tag}", func(r chi.Router) {
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 r.Get("/feed.atom", rp.RepoAtomFeed) 15 r.Get("/commits/{ref}", rp.RepoLog) 16 r.Route("/tree/{ref}", func(r chi.Router) { ··· 19 }) 20 r.Get("/commit/{ref}", rp.RepoCommit) 21 r.Get("/branches", rp.RepoBranches) 22 + r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 r.Get("/", rp.RepoTags) 25 r.Route("/{tag}", func(r chi.Router) {
+2 -2
appview/settings/settings.go
··· 470 } 471 472 // store in pds too 473 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 474 Collection: tangled.PublicKeyNSID, 475 Repo: did, 476 Rkey: rkey, ··· 527 528 if rkey != "" { 529 // remove from pds too 530 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 531 Collection: tangled.PublicKeyNSID, 532 Repo: did, 533 Rkey: rkey,
··· 470 } 471 472 // store in pds too 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 474 Collection: tangled.PublicKeyNSID, 475 Repo: did, 476 Rkey: rkey, ··· 527 528 if rkey != "" { 529 // remove from pds too 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 531 Collection: tangled.PublicKeyNSID, 532 Repo: did, 533 Rkey: rkey,
+65 -3
appview/signup/signup.go
··· 2 3 import ( 4 "bufio" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "os" 9 "strings" 10 ··· 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/pages" 19 "tangled.org/core/appview/state/userutil" 20 - "tangled.org/core/appview/xrpcclient" 21 "tangled.org/core/idresolver" 22 ) 23 ··· 26 db *db.DB 27 cf *dns.Cloudflare 28 posthog posthog.Client 29 - xrpc *xrpcclient.Client 30 idResolver *idresolver.Resolver 31 pages *pages.Pages 32 l *slog.Logger ··· 116 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 switch r.Method { 118 case http.MethodGet: 119 - s.pages.Signup(w) 120 case http.MethodPost: 121 if s.cf == nil { 122 http.Error(w, "signup is disabled", http.StatusFailedDependency) 123 } 124 emailId := r.FormValue("email") 125 126 noticeId := "signup-msg" 127 if !email.IsValidEmail(emailId) { 128 s.pages.Notice(w, noticeId, "Invalid email address.") 129 return ··· 255 return 256 } 257 }
··· 2 3 import ( 4 "bufio" 5 + "encoding/json" 6 + "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 + "net/url" 11 "os" 12 "strings" 13 ··· 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/state/userutil" 23 "tangled.org/core/idresolver" 24 ) 25 ··· 28 db *db.DB 29 cf *dns.Cloudflare 30 posthog posthog.Client 31 idResolver *idresolver.Resolver 32 pages *pages.Pages 33 l *slog.Logger ··· 117 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 118 switch r.Method { 119 case http.MethodGet: 120 + s.pages.Signup(w, pages.SignupParams{ 121 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 122 + }) 123 case http.MethodPost: 124 if s.cf == nil { 125 http.Error(w, "signup is disabled", http.StatusFailedDependency) 126 + return 127 } 128 emailId := r.FormValue("email") 129 + cfToken := r.FormValue("cf-turnstile-response") 130 131 noticeId := "signup-msg" 132 + 133 + if err := s.validateCaptcha(cfToken, r); err != nil { 134 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 135 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 136 + return 137 + } 138 + 139 if !email.IsValidEmail(emailId) { 140 s.pages.Notice(w, noticeId, "Invalid email address.") 141 return ··· 267 return 268 } 269 } 270 + 271 + type turnstileResponse struct { 272 + Success bool `json:"success"` 273 + ErrorCodes []string `json:"error-codes,omitempty"` 274 + ChallengeTs string `json:"challenge_ts,omitempty"` 275 + Hostname string `json:"hostname,omitempty"` 276 + } 277 + 278 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 279 + if cfToken == "" { 280 + return errors.New("captcha token is empty") 281 + } 282 + 283 + if s.config.Cloudflare.TurnstileSecretKey == "" { 284 + return errors.New("turnstile secret key not configured") 285 + } 286 + 287 + data := url.Values{} 288 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 289 + data.Set("response", cfToken) 290 + 291 + // include the client IP if we have it 292 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 293 + data.Set("remoteip", remoteIP) 294 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 295 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 296 + data.Set("remoteip", strings.TrimSpace(ips[0])) 297 + } 298 + } else { 299 + data.Set("remoteip", r.RemoteAddr) 300 + } 301 + 302 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 303 + if err != nil { 304 + return fmt.Errorf("failed to verify turnstile token: %w", err) 305 + } 306 + defer resp.Body.Close() 307 + 308 + var turnstileResp turnstileResponse 309 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 310 + return fmt.Errorf("failed to decode turnstile response: %w", err) 311 + } 312 + 313 + if !turnstileResp.Success { 314 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 315 + return errors.New("turnstile validation failed") 316 + } 317 + 318 + return nil 319 + }
+5 -5
appview/spindles/spindles.go
··· 189 return 190 } 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
··· 189 return 190 } 191 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
+151
appview/state/gfi.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+14 -1
appview/state/knotstream.go
··· 172 }) 173 } 174 175 - return db.InsertRepoLanguages(d, langs) 176 } 177 178 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
··· 172 }) 173 } 174 175 + tx, err := d.Begin() 176 + if err != nil { 177 + return err 178 + } 179 + defer tx.Rollback() 180 + 181 + // update appview's cache 182 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 183 + if err != nil { 184 + fmt.Printf("failed; %s\n", err) 185 + // non-fatal 186 + } 187 + 188 + return tx.Commit() 189 } 190 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+63
appview/state/login.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "tangled.org/core/appview/pages" 10 + ) 11 + 12 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 13 + switch r.Method { 14 + case http.MethodGet: 15 + returnURL := r.URL.Query().Get("return_url") 16 + s.pages.Login(w, pages.LoginParams{ 17 + ReturnUrl: returnURL, 18 + }) 19 + case http.MethodPost: 20 + handle := r.FormValue("handle") 21 + 22 + // when users copy their handle from bsky.app, it tends to have these characters around it: 23 + // 24 + // @nelind.dk: 25 + // \u202a ensures that the handle is always rendered left to right and 26 + // \u202c reverts that so the rest of the page renders however it should 27 + handle = strings.TrimPrefix(handle, "\u202a") 28 + handle = strings.TrimSuffix(handle, "\u202c") 29 + 30 + // `@` is harmless 31 + handle = strings.TrimPrefix(handle, "@") 32 + 33 + // basic handle validation 34 + if !strings.Contains(handle, ".") { 35 + log.Println("invalid handle format", "raw", handle) 36 + s.pages.Notice( 37 + w, 38 + "login-msg", 39 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 40 + ) 41 + return 42 + } 43 + 44 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + s.pages.HxRedirect(w, redirectURL) 51 + } 52 + } 53 + 54 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 55 + err := s.oauth.DeleteSession(w, r) 56 + if err != nil { 57 + log.Println("failed to logout", "err", err) 58 + } else { 59 + log.Println("logged out successfully") 60 + } 61 + 62 + s.pages.HxRedirect(w, "/login") 63 + }
+2 -2
appview/state/profile.go
··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
+11 -9
appview/state/reaction.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 74 if err != nil { 75 - log.Println("failed to get reaction count for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 - Count: count, 84 IsReacted: true, 85 }) 86 ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedReactionNSID, 97 Repo: currentUser.Did, 98 Rkey: reaction.Rkey, ··· 109 // this is not an issue, the firehose event might have already done this 110 } 111 112 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 if err != nil { 114 - log.Println("failed to get reaction count for ", subjectUri) 115 return 116 } 117 118 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 119 ThreadAt: subjectUri, 120 Kind: reactionKind, 121 - Count: count, 122 IsReacted: false, 123 }) 124
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 70 return 71 } 72 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 if err != nil { 75 + log.Println("failed to get reactions for ", subjectUri) 76 } 77 78 log.Println("created atproto record: ", resp.Uri) ··· 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 ThreadAt: subjectUri, 82 Kind: reactionKind, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 85 IsReacted: true, 86 }) 87 ··· 93 return 94 } 95 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 Collection: tangled.FeedReactionNSID, 98 Repo: currentUser.Did, 99 Rkey: reaction.Rkey, ··· 110 // this is not an issue, the firehose event might have already done this 111 } 112 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 114 if err != nil { 115 + log.Println("failed to get reactions for ", subjectUri) 116 return 117 } 118 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 120 ThreadAt: subjectUri, 121 Kind: reactionKind, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 124 IsReacted: false, 125 }) 126
+10 -10
appview/state/router.go
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 "tangled.org/core/appview/issues" 10 "tangled.org/core/appview/knots" 11 "tangled.org/core/appview/labels" 12 "tangled.org/core/appview/middleware" 13 "tangled.org/core/appview/notifications" 14 - oauthhandler "tangled.org/core/appview/oauth/handler" 15 "tangled.org/core/appview/pipelines" 16 "tangled.org/core/appview/pulls" 17 "tangled.org/core/appview/repo" ··· 34 s.pages, 35 ) 36 37 - router.Use(middleware.TryRefreshSession()) 38 router.Get("/favicon.svg", s.Favicon) 39 router.Get("/favicon.ico", s.Favicon) 40 41 userRouter := s.UserRouter(&middleware) 42 standardRouter := s.StandardRouter(&middleware) ··· 122 // special-case handler for serving tangled.org/core 123 r.Get("/core", s.Core()) 124 125 r.Route("/repo", func(r chi.Router) { 126 r.Route("/new", func(r chi.Router) { 127 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 131 // r.Post("/import", s.ImportRepo) 132 }) 133 134 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 135 r.Post("/", s.Follow) 136 r.Delete("/", s.Follow) ··· 161 r.Mount("/notifications", s.NotificationsRouter(mw)) 162 163 r.Mount("/signup", s.SignupRouter()) 164 - r.Mount("/", s.OAuthRouter()) 165 166 r.Get("/keys/{user}", s.Keys) 167 r.Get("/terms", s.TermsOfService) 168 r.Get("/privacy", s.PrivacyPolicy) 169 170 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 171 s.pages.Error404(w) ··· 185 186 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 187 } 188 - } 189 - 190 - func (s *State) OAuthRouter() http.Handler { 191 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 192 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 193 - return oauth.Router() 194 } 195 196 func (s *State) SettingsRouter() http.Handler {
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.org/core/appview/issues" 9 "tangled.org/core/appview/knots" 10 "tangled.org/core/appview/labels" 11 "tangled.org/core/appview/middleware" 12 "tangled.org/core/appview/notifications" 13 "tangled.org/core/appview/pipelines" 14 "tangled.org/core/appview/pulls" 15 "tangled.org/core/appview/repo" ··· 32 s.pages, 33 ) 34 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 39 40 userRouter := s.UserRouter(&middleware) 41 standardRouter := s.StandardRouter(&middleware) ··· 121 // special-case handler for serving tangled.org/core 122 r.Get("/core", s.Core()) 123 124 + r.Get("/login", s.Login) 125 + r.Post("/login", s.Login) 126 + r.Post("/logout", s.Logout) 127 + 128 r.Route("/repo", func(r chi.Router) { 129 r.Route("/new", func(r chi.Router) { 130 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 134 // r.Post("/import", s.ImportRepo) 135 }) 136 137 + r.Get("/goodfirstissues", s.GoodFirstIssues) 138 + 139 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 140 r.Post("/", s.Follow) 141 r.Delete("/", s.Follow) ··· 166 r.Mount("/notifications", s.NotificationsRouter(mw)) 167 168 r.Mount("/signup", s.SignupRouter()) 169 + r.Mount("/", s.oauth.Router()) 170 171 r.Get("/keys/{user}", s.Keys) 172 r.Get("/terms", s.TermsOfService) 173 r.Get("/privacy", s.PrivacyPolicy) 174 + r.Get("/brand", s.Brand) 175 176 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 177 s.pages.Error404(w) ··· 191 192 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 193 } 194 } 195 196 func (s *State) SettingsRouter() http.Handler {
+2 -2
appview/state/star.go
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
+75 -20
appview/state/state.go
··· 11 "strings" 12 "time" 13 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview" 22 "tangled.org/core/appview/cache" ··· 38 tlog "tangled.org/core/log" 39 "tangled.org/core/rbac" 40 "tangled.org/core/tid" 41 ) 42 43 type State struct { ··· 75 res = idresolver.DefaultResolver() 76 } 77 78 - pgs := pages.NewPages(config, res) 79 cache := cache.New(config.Redis.Addr) 80 sess := session.New(cache) 81 - oauth := oauth.NewOAuth(config, sess) 82 validator := validator.New(d, res, enforcer) 83 84 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 162 state := &State{ 163 d, 164 notifier, 165 - oauth, 166 enforcer, 167 - pgs, 168 sess, 169 res, 170 posthog, ··· 198 s.pages.Favicon(w) 199 } 200 201 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 202 user := s.oauth.GetUser(r) 203 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 212 }) 213 } 214 215 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 216 if s.oauth.GetUser(r) != nil { 217 s.Timeline(w, r) ··· 223 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 224 user := s.oauth.GetUser(r) 225 226 var userDid string 227 if user != nil { 228 userDid = user.Did 229 } 230 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 231 if err != nil { 232 log.Println(err) 233 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 240 return 241 } 242 243 s.pages.Timeline(w, pages.TimelineParams{ 244 LoggedInUser: user, 245 Timeline: timeline, 246 Repos: repos, 247 }) 248 } 249 ··· 255 256 l := s.logger.With("handler", "UpgradeBanner") 257 l = l.With("did", user.Did) 258 - l = l.With("handle", user.Handle) 259 260 regs, err := db.GetRegistrations( 261 s.db, ··· 286 } 287 288 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 289 - timeline, err := db.MakeTimeline(s.db, 5, "") 290 if err != nil { 291 log.Println(err) 292 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 395 396 user := s.oauth.GetUser(r) 397 l = l.With("did", user.Did) 398 - l = l.With("handle", user.Handle) 399 400 // form validation 401 domain := r.FormValue("domain") ··· 459 } 460 record := repo.AsRecord() 461 462 - xrpcClient, err := s.oauth.AuthorizedClient(r) 463 if err != nil { 464 l.Info("PDS write failed", "err", err) 465 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 466 return 467 } 468 469 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 470 Collection: tangled.RepoNSID, 471 Repo: user.Did, 472 Rkey: rkey, ··· 498 rollback := func() { 499 err1 := tx.Rollback() 500 err2 := s.enforcer.E.LoadPolicy() 501 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 502 503 // ignore txn complete errors, this is okay 504 if errors.Is(err1, sql.ErrTxDone) { ··· 571 aturi = "" 572 573 s.notifier.NewRepo(r.Context(), repo) 574 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 575 } 576 } 577 578 // this is used to rollback changes made to the PDS 579 // 580 // it is a no-op if the provided ATURI is empty 581 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 582 if aturi == "" { 583 return nil 584 } ··· 589 repo := parsed.Authority().String() 590 rkey := parsed.RecordKey().String() 591 592 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 593 Collection: collection, 594 Repo: repo, 595 Rkey: rkey,
··· 11 "strings" 12 "time" 13 14 "tangled.org/core/api/tangled" 15 "tangled.org/core/appview" 16 "tangled.org/core/appview/cache" ··· 32 tlog "tangled.org/core/log" 33 "tangled.org/core/rbac" 34 "tangled.org/core/tid" 35 + 36 + comatproto "github.com/bluesky-social/indigo/api/atproto" 37 + atpclient "github.com/bluesky-social/indigo/atproto/client" 38 + "github.com/bluesky-social/indigo/atproto/syntax" 39 + lexutil "github.com/bluesky-social/indigo/lex/util" 40 + securejoin "github.com/cyphar/filepath-securejoin" 41 + "github.com/go-chi/chi/v5" 42 + "github.com/posthog/posthog-go" 43 ) 44 45 type State struct { ··· 77 res = idresolver.DefaultResolver() 78 } 79 80 + pages := pages.NewPages(config, res) 81 cache := cache.New(config.Redis.Addr) 82 sess := session.New(cache) 83 + oauth2, err := oauth.New(config) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 86 + } 87 validator := validator.New(d, res, enforcer) 88 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 167 state := &State{ 168 d, 169 notifier, 170 + oauth2, 171 enforcer, 172 + pages, 173 sess, 174 res, 175 posthog, ··· 203 s.pages.Favicon(w) 204 } 205 206 + func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 207 + w.Header().Set("Content-Type", "text/plain") 208 + w.Header().Set("Cache-Control", "public, max-age=86400") // one day 209 + 210 + robotsTxt := `User-agent: * 211 + Allow: / 212 + ` 213 + w.Write([]byte(robotsTxt)) 214 + } 215 + 216 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 217 + const manifestJson = `{ 218 + "name": "tangled", 219 + "description": "tightly-knit social coding.", 220 + "icons": [ 221 + { 222 + "src": "/favicon.svg", 223 + "sizes": "144x144" 224 + } 225 + ], 226 + "start_url": "/", 227 + "id": "org.tangled", 228 + 229 + "display": "standalone", 230 + "background_color": "#111827", 231 + "theme_color": "#111827" 232 + }` 233 + 234 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 235 + w.Header().Set("Content-Type", "application/json") 236 + w.Write([]byte(manifestJson)) 237 + } 238 + 239 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 240 user := s.oauth.GetUser(r) 241 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 250 }) 251 } 252 253 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 254 + user := s.oauth.GetUser(r) 255 + s.pages.Brand(w, pages.BrandParams{ 256 + LoggedInUser: user, 257 + }) 258 + } 259 + 260 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 261 if s.oauth.GetUser(r) != nil { 262 s.Timeline(w, r) ··· 268 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 269 user := s.oauth.GetUser(r) 270 271 + // TODO: set this flag based on the UI 272 + filtered := false 273 + 274 var userDid string 275 if user != nil { 276 userDid = user.Did 277 } 278 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 279 if err != nil { 280 log.Println(err) 281 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 288 return 289 } 290 291 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 292 + if err != nil { 293 + // non-fatal 294 + } 295 + 296 s.pages.Timeline(w, pages.TimelineParams{ 297 LoggedInUser: user, 298 Timeline: timeline, 299 Repos: repos, 300 + GfiLabel: gfiLabel, 301 }) 302 } 303 ··· 309 310 l := s.logger.With("handler", "UpgradeBanner") 311 l = l.With("did", user.Did) 312 313 regs, err := db.GetRegistrations( 314 s.db, ··· 339 } 340 341 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 342 + // TODO: set this flag based on the UI 343 + filtered := false 344 + 345 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 346 if err != nil { 347 log.Println(err) 348 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 451 452 user := s.oauth.GetUser(r) 453 l = l.With("did", user.Did) 454 455 // form validation 456 domain := r.FormValue("domain") ··· 514 } 515 record := repo.AsRecord() 516 517 + atpClient, err := s.oauth.AuthorizedClient(r) 518 if err != nil { 519 l.Info("PDS write failed", "err", err) 520 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 521 return 522 } 523 524 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 525 Collection: tangled.RepoNSID, 526 Repo: user.Did, 527 Rkey: rkey, ··· 553 rollback := func() { 554 err1 := tx.Rollback() 555 err2 := s.enforcer.E.LoadPolicy() 556 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 557 558 // ignore txn complete errors, this is okay 559 if errors.Is(err1, sql.ErrTxDone) { ··· 626 aturi = "" 627 628 s.notifier.NewRepo(r.Context(), repo) 629 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 630 } 631 } 632 633 // this is used to rollback changes made to the PDS 634 // 635 // it is a no-op if the provided ATURI is empty 636 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 637 if aturi == "" { 638 return nil 639 } ··· 644 repo := parsed.Authority().String() 645 rkey := parsed.RecordKey().String() 646 647 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 648 Collection: collection, 649 Repo: repo, 650 Rkey: rkey,
+9 -7
appview/strings/strings.go
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 "github.com/go-chi/chi/v5" 27 ) 28 29 type Strings struct { ··· 254 } 255 256 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 if err != nil { 259 fail("Failed to updated existing record.", err) 260 return 261 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 263 Collection: tangled.StringNSID, 264 Repo: entry.Did.String(), 265 Rkey: entry.Rkey, ··· 284 s.Notifier.EditString(r.Context(), &entry) 285 286 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 288 } 289 290 } ··· 336 return 337 } 338 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 340 Collection: tangled.StringNSID, 341 Repo: user.Did, 342 Rkey: string.Rkey, ··· 360 s.Notifier.NewString(r.Context(), &string) 361 362 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 364 } 365 } 366 ··· 403 404 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 407 } 408 409 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 ) 30 31 type Strings struct { ··· 256 } 257 258 // first replace the existing record in the PDS 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 260 if err != nil { 261 fail("Failed to updated existing record.", err) 262 return 263 } 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 265 Collection: tangled.StringNSID, 266 Repo: entry.Did.String(), 267 Rkey: entry.Rkey, ··· 286 s.Notifier.EditString(r.Context(), &entry) 287 288 // if that went okay, redir to the string 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 290 } 291 292 } ··· 338 return 339 } 340 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 342 Collection: tangled.StringNSID, 343 Repo: user.Did, 344 Rkey: string.Rkey, ··· 362 s.Notifier.NewString(r.Context(), &string) 363 364 // successful 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 366 } 367 } 368 ··· 405 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 407 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 409 } 410 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-99
appview/xrpcclient/xrpc.go
··· 1 package xrpcclient 2 3 import ( 4 - "bytes" 5 - "context" 6 "errors" 7 - "io" 8 "net/http" 9 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 ) 15 16 var ( ··· 19 ErrXrpcFailed = errors.New("xrpc request failed") 20 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 116 // produces a more manageable error 117 func HandleXrpcErr(err error) error {
··· 1 package xrpcclient 2 3 import ( 4 "errors" 5 "net/http" 6 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 8 ) 9 10 var ( ··· 13 ErrXrpcFailed = errors.New("xrpc request failed") 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 15 ) 16 17 // produces a more manageable error 18 func HandleXrpcErr(err error) error {
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 2 3 package main 4
··· 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 3 package main 4
+1 -1
docs/spindle/pipeline.md
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
+10 -6
go.mod
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 github.com/google/uuid v1.6.0 25 github.com/gorilla/feeds v1.2.0 26 github.com/gorilla/sessions v1.4.0 ··· 36 github.com/redis/go-redis/v9 v9.7.3 37 github.com/resend/resend-go/v2 v2.15.0 38 github.com/sethvargo/go-envconfig v1.1.0 39 github.com/stretchr/testify v1.10.0 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/net v0.42.0 47 - golang.org/x/sync v0.16.0 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 ) 52 53 require ( ··· 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 github.com/wyatt915/treeblood v0.1.15 // indirect 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 168 go.uber.org/atomic v1.11.0 // indirect 169 go.uber.org/multierr v1.11.0 // indirect 170 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect 175 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0 ··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 25 github.com/google/uuid v1.6.0 26 github.com/gorilla/feeds v1.2.0 27 github.com/gorilla/sessions v1.4.0 ··· 37 github.com/redis/go-redis/v9 v9.7.3 38 github.com/resend/resend-go/v2 v2.15.0 39 github.com/sethvargo/go-envconfig v1.1.0 40 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 41 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 42 github.com/stretchr/testify v1.10.0 43 github.com/urfave/cli/v3 v3.3.3 44 github.com/whyrusleeping/cbor-gen v0.3.1 45 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 46 + github.com/yuin/goldmark v1.7.13 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 48 golang.org/x/crypto v0.40.0 49 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 + golang.org/x/image v0.31.0 51 golang.org/x/net v0.42.0 52 + golang.org/x/sync v0.17.0 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 54 gopkg.in/yaml.v3 v3.0.1 55 ) 56 57 require ( ··· 160 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 161 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 162 github.com/wyatt915/treeblood v0.1.15 // indirect 163 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 164 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 165 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 166 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 173 go.uber.org/atomic v1.11.0 // indirect 174 go.uber.org/multierr v1.11.0 // indirect 175 go.uber.org/zap v1.27.0 // indirect 176 golang.org/x/sys v0.34.0 // indirect 177 + golang.org/x/text v0.29.0 // indirect 178 golang.org/x/time v0.12.0 // indirect 179 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 180 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+18 -12
go.sum
··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 139 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 243 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 244 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 245 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 246 - github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 247 - github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 399 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 402 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 403 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 404 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 489 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 492 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 493 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 494 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 528 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 529 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 530 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 531 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 532 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 533 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 534 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 535 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 583 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 586 - golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 587 - golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 588 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 590 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 652 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 27 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 139 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 140 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 141 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 142 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 143 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 245 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 246 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 247 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 399 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 402 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 403 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 404 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 405 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 406 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 407 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 408 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 440 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 441 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 442 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 443 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 444 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 445 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 446 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 447 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 448 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 449 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 450 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 451 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 495 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 496 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 497 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 498 + golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 499 + golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 500 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 501 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 502 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 536 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 537 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 538 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 539 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 540 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 541 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 542 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 543 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 591 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 592 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 593 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 594 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 595 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 596 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 597 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 598 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 660 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 661 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 662 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 663 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 664 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71 -12
input.css
··· 134 } 135 136 .prose hr { 137 - @apply my-2; 138 } 139 140 .prose li:has(input) { 141 - @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 - @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 - @apply no-underline; 162 } 163 164 .prose li { 165 - @apply my-0 py-0; 166 } 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 170 } 171 172 .prose img { ··· 176 } 177 178 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 } 181 182 .prose input[type="checkbox"] { 183 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 } 185 } 186 @layer utilities { 187 .error { ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 234 /* LineNumbersTable */
··· 134 } 135 136 .prose hr { 137 + @apply my-2; 138 } 139 140 .prose li:has(input) { 141 + @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 + @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 + @apply no-underline; 162 } 163 164 .prose li { 165 + @apply my-0 py-0; 166 } 167 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 171 } 172 173 .prose img { ··· 177 } 178 179 .prose input { 180 + @apply inline-block my-0 mb-1 mx-1; 181 } 182 183 .prose input[type="checkbox"] { 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 244 } 245 @layer utilities { 246 .error { ··· 287 } 288 /* LineHighlight */ 289 .chroma .hl { 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 291 } 292 293 /* LineNumbersTable */
+1 -1
knotserver/config/config.go
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
+5
knotserver/git/branch.go
··· 110 slices.Reverse(branches) 111 return branches, nil 112 }
··· 110 slices.Reverse(branches) 111 return branches, nil 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+11 -33
knotserver/git/git.go
··· 71 return &g, nil 72 } 73 74 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 75 commits := []*object.Commit{} 76 ··· 120 return g.r.CommitObject(h) 121 } 122 123 - func (g *GitRepo) LastCommit() (*object.Commit, error) { 124 - c, err := g.r.CommitObject(g.h) 125 - if err != nil { 126 - return nil, fmt.Errorf("last commit: %w", err) 127 - } 128 - return c, nil 129 - } 130 - 131 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 132 c, err := g.r.CommitObject(g.h) 133 if err != nil { ··· 160 } 161 162 return buf.Bytes(), nil 163 - } 164 - 165 - func (g *GitRepo) FileContent(path string) (string, error) { 166 - c, err := g.r.CommitObject(g.h) 167 - if err != nil { 168 - return "", fmt.Errorf("commit object: %w", err) 169 - } 170 - 171 - tree, err := c.Tree() 172 - if err != nil { 173 - return "", fmt.Errorf("file tree: %w", err) 174 - } 175 - 176 - file, err := tree.File(path) 177 - if err != nil { 178 - return "", err 179 - } 180 - 181 - isbin, _ := file.IsBinary() 182 - 183 - if !isbin { 184 - return file.Contents() 185 - } else { 186 - return "", ErrBinaryFile 187 - } 188 } 189 190 func (g *GitRepo) RawContent(path string) ([]byte, error) {
··· 71 return &g, nil 72 } 73 74 + // re-open a repository and update references 75 + func (g *GitRepo) Refresh() error { 76 + refreshed, err := PlainOpen(g.path) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + *g = *refreshed 82 + return nil 83 + } 84 + 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 86 commits := []*object.Commit{} 87 ··· 131 return g.r.CommitObject(h) 132 } 133 134 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 135 c, err := g.r.CommitObject(g.h) 136 if err != nil { ··· 163 } 164 165 return buf.Bytes(), nil 166 } 167 168 func (g *GitRepo) RawContent(path string) ([]byte, error) {
+150 -37
knotserver/git/merge.go
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 "os" 8 "os/exec" 9 "regexp" ··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 type MergeCheckCache struct { ··· 32 mergeCheckCache = MergeCheckCache{cache} 33 } 34 35 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 36 sep := byte(':') 37 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 38 return fmt.Sprintf("%x", hash) ··· 49 } 50 } 51 52 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 53 key := m.cacheKey(g, patch, targetBranch) 54 val := m.cacheVal(mergeCheck) 55 m.cache.Set(key, val, 0) 56 } 57 58 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 59 key := m.cacheKey(g, patch, targetBranch) 60 if val, ok := m.cache.Get(key); ok { 61 if val == struct{}{} { ··· 104 return fmt.Sprintf("merge failed: %s", e.Message) 105 } 106 107 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 108 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 if err != nil { 110 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 } 112 113 - if _, err := tmpFile.Write(patchData); err != nil { 114 tmpFile.Close() 115 os.Remove(tmpFile.Name()) 116 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 162 return nil 163 } 164 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 var stderr bytes.Buffer 167 var cmd *exec.Cmd 168 169 // configure default git user before merge 170 - exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 - exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 174 // if patch is a format-patch, apply using 'git am' 175 if opts.FormatPatch { 176 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 - } else { 178 - // else, apply using 'git apply' and commit it manually 179 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 - applyCmd.Stderr = &stderr 181 - if err := applyCmd.Run(); err != nil { 182 - return fmt.Errorf("patch application failed: %s", stderr.String()) 183 - } 184 185 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 - if err := stageCmd.Run(); err != nil { 187 - return fmt.Errorf("failed to stage changes: %w", err) 188 - } 189 190 - commitArgs := []string{"-C", tmpDir, "commit"} 191 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 195 196 - if authorName != "" && authorEmail != "" { 197 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 - } 199 - // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 200 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 206 207 - cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 ··· 216 return nil 217 } 218 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } ··· 244 return result 245 } 246 247 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 248 patchFile, err := g.createTempFileWithPatch(patchData) 249 if err != nil { 250 return &ErrMerge{ ··· 263 } 264 defer os.RemoveAll(tmpDir) 265 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 + "log" 8 "os" 9 "os/exec" 10 "regexp" ··· 13 "github.com/dgraph-io/ristretto" 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 18 ) 19 20 type MergeCheckCache struct { ··· 35 mergeCheckCache = MergeCheckCache{cache} 36 } 37 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 39 sep := byte(':') 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 41 return fmt.Sprintf("%x", hash) ··· 52 } 53 } 54 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 56 key := m.cacheKey(g, patch, targetBranch) 57 val := m.cacheVal(mergeCheck) 58 m.cache.Set(key, val, 0) 59 } 60 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 62 key := m.cacheKey(g, patch, targetBranch) 63 if val, ok := m.cache.Get(key); ok { 64 if val == struct{}{} { ··· 107 return fmt.Sprintf("merge failed: %s", e.Message) 108 } 109 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 112 if err != nil { 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 114 } 115 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 117 tmpFile.Close() 118 os.Remove(tmpFile.Name()) 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 165 return nil 166 } 167 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 169 var stderr bytes.Buffer 170 var cmd *exec.Cmd 171 172 // configure default git user before merge 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 176 177 // if patch is a format-patch, apply using 'git am' 178 if opts.FormatPatch { 179 + return g.applyMailbox(patchData) 180 + } 181 182 + // else, apply using 'git apply' and commit it manually 183 + applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 + applyCmd.Stderr = &stderr 185 + if err := applyCmd.Run(); err != nil { 186 + return fmt.Errorf("patch application failed: %s", stderr.String()) 187 + } 188 189 + stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 + if err := stageCmd.Run(); err != nil { 191 + return fmt.Errorf("failed to stage changes: %w", err) 192 + } 193 194 + commitArgs := []string{"-C", g.path, "commit"} 195 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 199 200 + if authorName != "" && authorEmail != "" { 201 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 + } 203 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 204 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 209 } 210 + 211 + cmd = exec.Command("git", commitArgs...) 212 213 cmd.Stderr = &stderr 214 ··· 219 return nil 220 } 221 222 + func (g *GitRepo) applyMailbox(patchData string) error { 223 + fps, err := patchutil.ExtractPatches(patchData) 224 + if err != nil { 225 + return fmt.Errorf("failed to extract patches: %w", err) 226 + } 227 + 228 + // apply each patch one by one 229 + // update the newly created commit object to add the change-id header 230 + total := len(fps) 231 + for i, p := range fps { 232 + newCommit, err := g.applySingleMailbox(p) 233 + if err != nil { 234 + return err 235 + } 236 + 237 + log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 + } 239 + 240 + return nil 241 + } 242 + 243 + func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 + tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 + if err != nil { 246 + return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 + } 248 + 249 + var stderr bytes.Buffer 250 + cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 + cmd.Stderr = &stderr 252 + 253 + head, err := g.r.Head() 254 + if err != nil { 255 + return plumbing.ZeroHash, err 256 + } 257 + log.Println("head before apply", head.Hash().String()) 258 + 259 + if err := cmd.Run(); err != nil { 260 + return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 + } 262 + 263 + if err := g.Refresh(); err != nil { 264 + return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 + } 266 + 267 + head, err = g.r.Head() 268 + if err != nil { 269 + return plumbing.ZeroHash, err 270 + } 271 + log.Println("head after apply", head.Hash().String()) 272 + 273 + newHash := head.Hash() 274 + if changeId, err := singlePatch.ChangeId(); err != nil { 275 + // no change ID 276 + } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 + return plumbing.ZeroHash, err 278 + } else { 279 + newHash = updatedHash 280 + } 281 + 282 + return newHash, nil 283 + } 284 + 285 + func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 + log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 + obj, err := g.r.CommitObject(hash) 288 + if err != nil { 289 + return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 + } 291 + 292 + // write the change-id header 293 + obj.ExtraHeaders["change-id"] = []byte(changeId) 294 + 295 + // create a new object 296 + dest := g.r.Storer.NewEncodedObject() 297 + if err := obj.Encode(dest); err != nil { 298 + return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 + } 300 + 301 + // store the new object 302 + newHash, err := g.r.Storer.SetEncodedObject(dest) 303 + if err != nil { 304 + return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 + } 306 + 307 + log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 + 309 + // find the branch that HEAD is pointing to 310 + ref, err := g.r.Head() 311 + if err != nil { 312 + return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 + } 314 + 315 + // and update that branch to point to new commit 316 + if ref.Name().IsBranch() { 317 + err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 + if err != nil { 319 + return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 + } 321 + } 322 + 323 + // new hash of commit 324 + return newHash, nil 325 + } 326 + 327 + func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 329 return val 330 } ··· 352 return result 353 } 354 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 356 patchFile, err := g.createTempFileWithPatch(patchData) 357 if err != nil { 358 return &ErrMerge{ ··· 371 } 372 defer os.RemoveAll(tmpDir) 373 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 380 return err 381 } 382
+1 -3
knotserver/git/tag.go
··· 2 3 import ( 4 "fmt" 5 - "slices" 6 "strconv" 7 "strings" 8 "time" ··· 35 outFormat.WriteString("") 36 outFormat.WriteString(recordSeparator) 37 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 39 if err != nil { 40 return nil, fmt.Errorf("failed to get tags: %w", err) 41 } ··· 94 tags = append(tags, tag) 95 } 96 97 - slices.Reverse(tags) 98 return tags, nil 99 }
··· 2 3 import ( 4 "fmt" 5 "strconv" 6 "strings" 7 "time" ··· 34 outFormat.WriteString("") 35 outFormat.WriteString(recordSeparator) 36 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 38 if err != nil { 39 return nil, fmt.Errorf("failed to get tags: %w", err) 40 } ··· 93 tags = append(tags, tag) 94 } 95 96 return tags, nil 97 }
-4
knotserver/http_util.go
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 }
+46
knotserver/internal.go
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/hook" 18 "tangled.org/core/knotserver/config" 19 "tangled.org/core/knotserver/db" 20 "tangled.org/core/knotserver/git" ··· 118 // non-fatal 119 } 120 121 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 if err != nil { 123 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 126 } 127 128 writeJSON(w, resp) 129 } 130 131 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-git/go-git/v5/plumbing" 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 20 "tangled.org/core/knotserver/config" 21 "tangled.org/core/knotserver/db" 22 "tangled.org/core/knotserver/git" ··· 120 // non-fatal 121 } 122 123 + if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 124 + msg, err := h.replyCompare(line, gitUserDid, gitRelativeDir, repoName, r.Context()) 125 + if err != nil { 126 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 127 + // non-fatal 128 + } else { 129 + for msgLine := range msg { 130 + resp.Messages = append(resp.Messages, msg[msgLine]) 131 + } 132 + } 133 + } 134 + 135 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 136 if err != nil { 137 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 140 } 141 142 writeJSON(w, resp) 143 + } 144 + 145 + func (h *InternalHandle) replyCompare(line git.PostReceiveLine, gitUserDid string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 146 + l := h.l.With("handler", "replyCompare") 147 + userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, gitUserDid) 148 + user := gitUserDid 149 + if err != nil { 150 + l.Error("Failed to fetch user identity", "err", err) 151 + // non-fatal 152 + } else { 153 + user = userIdent.Handle.String() 154 + } 155 + gr, err := git.PlainOpen(gitRelativeDir) 156 + if err != nil { 157 + l.Error("Failed to open git repository", "err", err) 158 + return []string{}, err 159 + } 160 + defaultBranch, err := gr.FindMainBranch() 161 + if err != nil { 162 + l.Error("Failed to fetch default branch", "err", err) 163 + return []string{}, err 164 + } 165 + if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 166 + return []string{}, nil 167 + } 168 + ZWS := "\u200B" 169 + var msg []string 170 + msg = append(msg, ZWS) 171 + msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 172 + msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 173 + msg = append(msg, ZWS) 174 + return msg, nil 175 } 176 177 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
+87
knotserver/xrpc/delete_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDeleteBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(xrpcerr.GenericError(err)) 76 + return 77 + } 78 + 79 + err = gr.DeleteBranch(data.Branch) 80 + if err != nil { 81 + l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) 82 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
+1 -1
knotserver/xrpc/merge.go
··· 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 89 if err != nil { 90 var mergeErr *git.ErrMerge 91 if errors.As(err, &mergeErr) {
··· 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 89 if err != nil { 90 var mergeErr *git.ErrMerge 91 if errors.As(err, &mergeErr) {
+1 -1
knotserver/xrpc/merge_check.go
··· 51 return 52 } 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false,
··· 51 return 52 } 53 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false,
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
+24
knotserver/xrpc/repo_tree.go
··· 4 "net/http" 5 "path/filepath" 6 "time" 7 8 "tangled.org/core/api/tangled" 9 "tangled.org/core/knotserver/git" 10 xrpcerr "tangled.org/core/xrpc/errors" 11 ) ··· 43 return 44 } 45 46 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 for i, file := range files { ··· 83 Parent: parentPtr, 84 Dotdot: dotdotPtr, 85 Files: treeEntries, 86 } 87 88 writeJson(w, response)
··· 4 "net/http" 5 "path/filepath" 6 "time" 7 + "unicode/utf8" 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 11 "tangled.org/core/knotserver/git" 12 xrpcerr "tangled.org/core/xrpc/errors" 13 ) ··· 45 return 46 } 47 48 + // if any of these files are a readme candidate, pass along its blob contents too 49 + var readmeFileName string 50 + var readmeContents string 51 + for _, file := range files { 52 + if markup.IsReadmeFile(file.Name) { 53 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 54 + if err != nil { 55 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 56 + } 57 + 58 + if utf8.Valid(contents) { 59 + readmeFileName = file.Name 60 + readmeContents = string(contents) 61 + break 62 + } 63 + } 64 + } 65 + 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 for i, file := range files { ··· 103 Parent: parentPtr, 104 Dotdot: dotdotPtr, 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 110 } 111 112 writeJson(w, response)
+1
knotserver/xrpc/xrpc.go
··· 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
··· 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
-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.
···
+30
lexicons/repo/deleteBranch.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.deleteBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a branch on this repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "branch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "branch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 +
+19
lexicons/repo/tree.json
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 "files": { 45 "type": "array", 46 "items": { ··· 69 "description": "Invalid request parameters" 70 } 71 ] 72 }, 73 "treeEntry": { 74 "type": "object",
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 49 "files": { 50 "type": "array", 51 "items": { ··· 74 "description": "Invalid request parameters" 75 } 76 ] 77 + }, 78 + "readme": { 79 + "type": "object", 80 + "required": ["filename", "contents"], 81 + "properties": { 82 + "filename": { 83 + "type": "string", 84 + "description": "Name of the readme file" 85 + }, 86 + "contents": { 87 + "type": "string", 88 + "description": "Contents of the readme file" 89 + } 90 + } 91 }, 92 "treeEntry": { 93 "type": "object",
+23 -11
nix/gomod2nix.toml
··· 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 45 [mod."github.com/bluesky-social/jetstream"] 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 163 [mod."github.com/gogo/protobuf"] 164 version = "v1.3.2" 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 [mod."github.com/golang-jwt/jwt/v5"] 167 version = "v5.2.3" 168 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 407 [mod."github.com/spaolacci/murmur3"] 408 version = "v1.1.0" 409 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 410 [mod."github.com/stretchr/testify"] 411 version = "v1.10.0" 412 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 432 version = "v0.1.15" 433 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 434 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 version = "v2.0.0-20230729083705-37449abec8cc" 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 440 [mod."gitlab.com/yawning/secp256k1-voi"] 441 version = "v0.0.0-20230925100816-f2616030848b" 442 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 479 [mod."golang.org/x/exp"] 480 version = "v0.0.0-20250620022241-b7579e27df2b" 481 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 482 [mod."golang.org/x/net"] 483 version = "v0.42.0" 484 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 488 [mod."golang.org/x/sys"] 489 version = "v0.34.0" 490 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 494 [mod."golang.org/x/time"] 495 version = "v0.12.0" 496 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 [mod."lukechampine.com/blake3"] 528 version = "v1.4.1" 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 531 - version = "v0.0.0-20250724194903-28e660378cb1" 532 - hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
··· 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 [mod."github.com/bluesky-social/indigo"] 43 + version = "v0.0.0-20251003000214-3259b215110e" 44 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 45 [mod."github.com/bluesky-social/jetstream"] 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 163 [mod."github.com/gogo/protobuf"] 164 version = "v1.3.2" 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 + [mod."github.com/goki/freetype"] 167 + version = "v1.0.5" 168 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 169 [mod."github.com/golang-jwt/jwt/v5"] 170 version = "v5.2.3" 171 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 410 [mod."github.com/spaolacci/murmur3"] 411 version = "v1.1.0" 412 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 413 + [mod."github.com/srwiley/oksvg"] 414 + version = "v0.0.0-20221011165216-be6e8873101c" 415 + hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 416 + [mod."github.com/srwiley/rasterx"] 417 + version = "v0.0.0-20220730225603-2ab79fcdd4ef" 418 + hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 419 [mod."github.com/stretchr/testify"] 420 version = "v1.10.0" 421 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 441 version = "v0.1.15" 442 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 443 [mod."github.com/yuin/goldmark"] 444 + version = "v1.7.13" 445 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 446 [mod."github.com/yuin/goldmark-highlighting/v2"] 447 version = "v2.0.0-20230729083705-37449abec8cc" 448 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 449 + [mod."gitlab.com/staticnoise/goldmark-callout"] 450 + version = "v0.0.0-20240609120641-6366b799e4ab" 451 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 452 [mod."gitlab.com/yawning/secp256k1-voi"] 453 version = "v0.0.0-20230925100816-f2616030848b" 454 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 491 [mod."golang.org/x/exp"] 492 version = "v0.0.0-20250620022241-b7579e27df2b" 493 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 494 + [mod."golang.org/x/image"] 495 + version = "v0.31.0" 496 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 497 [mod."golang.org/x/net"] 498 version = "v0.42.0" 499 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 500 [mod."golang.org/x/sync"] 501 + version = "v0.17.0" 502 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 503 [mod."golang.org/x/sys"] 504 version = "v0.34.0" 505 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 506 [mod."golang.org/x/text"] 507 + version = "v0.29.0" 508 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 509 [mod."golang.org/x/time"] 510 version = "v0.12.0" 511 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 542 [mod."lukechampine.com/blake3"] 543 version = "v1.4.1" 544 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
+1
nix/pkgs/appview-static-files.nix
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 # for whatever reason (produces broken css), so we are doing this instead
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 # for whatever reason (produces broken css), so we are doing this instead
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 sqlite-lib, 5 src, 6 }: let 7 - version = "1.9.0-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
··· 4 sqlite-lib, 5 src, 6 }: let 7 + version = "1.9.1-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
+7 -5
types/repo.go
··· 41 } 42 43 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 49 } 50 51 type TagReference struct {
··· 41 } 42 43 type RepoTreeResponse struct { 44 + Ref string `json:"ref,omitempty"` 45 + Parent string `json:"parent,omitempty"` 46 + Description string `json:"description,omitempty"` 47 + DotDot string `json:"dotdot,omitempty"` 48 + Files []NiceTree `json:"files,omitempty"` 49 + ReadmeFileName string `json:"readme_filename,omitempty"` 50 + Readme string `json:"readme_contents,omitempty"` 51 } 52 53 type TagReference struct {