forked from tangled.org/core
Monorepo for Tangled

Compare changes

Choose any two refs to compare.

+4695 -4352
+79 -20
api/tangled/cbor_gen.go
··· 7934 7934 } 7935 7935 7936 7936 cw := cbg.NewCborWriter(w) 7937 - fieldCount := 9 7937 + fieldCount := 10 7938 7938 7939 7939 if t.Body == nil { 7940 7940 fieldCount-- 7941 7941 } 7942 7942 7943 7943 if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.Patch == nil { 7944 7948 fieldCount-- 7945 7949 } 7946 7950 ··· 8008 8012 } 8009 8013 8010 8014 // t.Patch (string) (string) 8011 - if len("patch") > 1000000 { 8012 - return xerrors.Errorf("Value in field \"patch\" was too long") 8013 - } 8015 + if t.Patch != nil { 8014 8016 8015 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8016 - return err 8017 - } 8018 - if _, err := cw.WriteString(string("patch")); err != nil { 8019 - return err 8020 - } 8017 + if len("patch") > 1000000 { 8018 + return xerrors.Errorf("Value in field \"patch\" was too long") 8019 + } 8021 8020 8022 - if len(t.Patch) > 1000000 { 8023 - return xerrors.Errorf("Value in field t.Patch was too long") 8024 - } 8021 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8022 + return err 8023 + } 8024 + if _, err := cw.WriteString(string("patch")); err != nil { 8025 + return err 8026 + } 8027 + 8028 + if t.Patch == nil { 8029 + if _, err := cw.Write(cbg.CborNull); err != nil { 8030 + return err 8031 + } 8032 + } else { 8033 + if len(*t.Patch) > 1000000 { 8034 + return xerrors.Errorf("Value in field t.Patch was too long") 8035 + } 8025 8036 8026 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil { 8027 - return err 8028 - } 8029 - if _, err := cw.WriteString(string(t.Patch)); err != nil { 8030 - return err 8037 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil { 8038 + return err 8039 + } 8040 + if _, err := cw.WriteString(string(*t.Patch)); err != nil { 8041 + return err 8042 + } 8043 + } 8031 8044 } 8032 8045 8033 8046 // t.Title (string) (string) ··· 8147 8160 return err 8148 8161 } 8149 8162 8163 + // t.PatchBlob (util.LexBlob) (struct) 8164 + if len("patchBlob") > 1000000 { 8165 + return xerrors.Errorf("Value in field \"patchBlob\" was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil { 8169 + return err 8170 + } 8171 + if _, err := cw.WriteString(string("patchBlob")); err != nil { 8172 + return err 8173 + } 8174 + 8175 + if err := t.PatchBlob.MarshalCBOR(cw); err != nil { 8176 + return err 8177 + } 8178 + 8150 8179 // t.References ([]string) (slice) 8151 8180 if t.References != nil { 8152 8181 ··· 8262 8291 case "patch": 8263 8292 8264 8293 { 8265 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 8294 + b, err := cr.ReadByte() 8266 8295 if err != nil { 8267 8296 return err 8268 8297 } 8298 + if b != cbg.CborNull[0] { 8299 + if err := cr.UnreadByte(); err != nil { 8300 + return err 8301 + } 8269 8302 8270 - t.Patch = string(sval) 8303 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8304 + if err != nil { 8305 + return err 8306 + } 8307 + 8308 + t.Patch = (*string)(&sval) 8309 + } 8271 8310 } 8272 8311 // t.Title (string) (string) 8273 8312 case "title": ··· 8370 8409 } 8371 8410 8372 8411 t.CreatedAt = string(sval) 8412 + } 8413 + // t.PatchBlob (util.LexBlob) (struct) 8414 + case "patchBlob": 8415 + 8416 + { 8417 + 8418 + b, err := cr.ReadByte() 8419 + if err != nil { 8420 + return err 8421 + } 8422 + if b != cbg.CborNull[0] { 8423 + if err := cr.UnreadByte(); err != nil { 8424 + return err 8425 + } 8426 + t.PatchBlob = new(util.LexBlob) 8427 + if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil { 8428 + return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err) 8429 + } 8430 + } 8431 + 8373 8432 } 8374 8433 // t.References ([]string) (slice) 8375 8434 case "references":
+12 -9
api/tangled/repopull.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPull 19 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 - Patch string `json:"patch" cborgen:"patch"` 25 - References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 - Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 27 - Target *RepoPull_Target `json:"target" cborgen:"target"` 28 - Title string `json:"title" cborgen:"title"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + // patch: (deprecated) use patchBlob instead 25 + Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"` 26 + // patchBlob: patch content 27 + PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"` 28 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 29 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 30 + Target *RepoPull_Target `json:"target" cborgen:"target"` 31 + Title string `json:"title" cborgen:"title"` 29 32 } 30 33 31 34 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
-56
appview/db/issues.go
··· 295 295 return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 296 } 297 297 298 - // GetIssueIDs gets list of all existing issue's IDs 299 - func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 300 - var ids []int64 301 - 302 - var filters []orm.Filter 303 - openValue := 0 304 - if opts.IsOpen { 305 - openValue = 1 306 - } 307 - filters = append(filters, orm.FilterEq("open", openValue)) 308 - if opts.RepoAt != "" { 309 - filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 - } 311 - 312 - var conditions []string 313 - var args []any 314 - 315 - for _, filter := range filters { 316 - conditions = append(conditions, filter.Condition()) 317 - args = append(args, filter.Arg()...) 318 - } 319 - 320 - whereClause := "" 321 - if conditions != nil { 322 - whereClause = " where " + strings.Join(conditions, " and ") 323 - } 324 - query := fmt.Sprintf( 325 - ` 326 - select 327 - id 328 - from 329 - issues 330 - %s 331 - limit ? offset ?`, 332 - whereClause, 333 - ) 334 - args = append(args, opts.Page.Limit, opts.Page.Offset) 335 - rows, err := e.Query(query, args...) 336 - if err != nil { 337 - return nil, err 338 - } 339 - defer rows.Close() 340 - 341 - for rows.Next() { 342 - var id int64 343 - err := rows.Scan(&id) 344 - if err != nil { 345 - return nil, err 346 - } 347 - 348 - ids = append(ids, id) 349 - } 350 - 351 - return ids, nil 352 - } 353 - 354 298 func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 299 result, err := tx.Exec( 356 300 `insert into issue_comments (
+18 -11
appview/db/profile.go
··· 20 20 timeline := models.ProfileTimeline{ 21 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 22 22 } 23 - currentMonth := time.Now().Month() 23 + now := time.Now() 24 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 25 26 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 30 30 31 31 // group pulls by month 32 32 for _, pull := range pulls { 33 - pullMonth := pull.Created.Month() 33 + monthsAgo := monthsBetween(pull.Created, now) 34 34 35 - if currentMonth-pullMonth >= TimeframeMonths { 35 + if monthsAgo >= TimeframeMonths { 36 36 // shouldn't happen; but times are weird 37 37 continue 38 38 } 39 39 40 - idx := currentMonth - pullMonth 40 + idx := monthsAgo 41 41 items := &timeline.ByMonth[idx].PullEvents.Items 42 42 43 43 *items = append(*items, &pull) ··· 53 53 } 54 54 55 55 for _, issue := range issues { 56 - issueMonth := issue.Created.Month() 56 + monthsAgo := monthsBetween(issue.Created, now) 57 57 58 - if currentMonth-issueMonth >= TimeframeMonths { 58 + if monthsAgo >= TimeframeMonths { 59 59 // shouldn't happen; but times are weird 60 60 continue 61 61 } 62 62 63 - idx := currentMonth - issueMonth 63 + idx := monthsAgo 64 64 items := &timeline.ByMonth[idx].IssueEvents.Items 65 65 66 66 *items = append(*items, &issue) ··· 77 77 if repo.Source != "" { 78 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 79 if err != nil { 80 - return nil, err 80 + // the source repo was not found, skip this bit 81 + log.Println("profile", "err", err) 81 82 } 82 83 } 83 84 84 - repoMonth := repo.Created.Month() 85 + monthsAgo := monthsBetween(repo.Created, now) 85 86 86 - if currentMonth-repoMonth >= TimeframeMonths { 87 + if monthsAgo >= TimeframeMonths { 87 88 // shouldn't happen; but times are weird 88 89 continue 89 90 } 90 91 91 - idx := currentMonth - repoMonth 92 + idx := monthsAgo 92 93 93 94 items := &timeline.ByMonth[idx].RepoEvents 94 95 *items = append(*items, models.RepoEvent{ ··· 98 99 } 99 100 100 101 return &timeline, nil 102 + } 103 + 104 + func monthsBetween(from, to time.Time) int { 105 + years := to.Year() - from.Year() 106 + months := int(to.Month() - from.Month()) 107 + return years*12 + months 101 108 } 102 109 103 110 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+12 -68
appview/db/pulls.go
··· 13 13 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pagination" 16 17 "tangled.org/core/orm" 17 18 ) 18 19 ··· 119 120 return pullId - 1, err 120 121 } 121 122 122 - func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) { 123 + func GetPullsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Pull, error) { 123 124 pulls := make(map[syntax.ATURI]*models.Pull) 124 125 125 126 var conditions []string ··· 133 134 if conditions != nil { 134 135 whereClause = " where " + strings.Join(conditions, " and ") 135 136 } 136 - limitClause := "" 137 - if limit != 0 { 138 - limitClause = fmt.Sprintf(" limit %d ", limit) 137 + pageClause := "" 138 + if page.Limit != 0 { 139 + pageClause = fmt.Sprintf( 140 + " limit %d offset %d ", 141 + page.Limit, 142 + page.Offset, 143 + ) 139 144 } 140 145 141 146 query := fmt.Sprintf(` ··· 161 166 order by 162 167 created desc 163 168 %s 164 - `, whereClause, limitClause) 169 + `, whereClause, pageClause) 165 170 166 171 rows, err := e.Query(query, args...) 167 172 if err != nil { ··· 297 302 } 298 303 299 304 func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 300 - return GetPullsWithLimit(e, 0, filters...) 301 - } 302 - 303 - func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 304 - var ids []int64 305 - 306 - var filters []orm.Filter 307 - filters = append(filters, orm.FilterEq("state", opts.State)) 308 - if opts.RepoAt != "" { 309 - filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 - } 311 - 312 - var conditions []string 313 - var args []any 314 - 315 - for _, filter := range filters { 316 - conditions = append(conditions, filter.Condition()) 317 - args = append(args, filter.Arg()...) 318 - } 319 - 320 - whereClause := "" 321 - if conditions != nil { 322 - whereClause = " where " + strings.Join(conditions, " and ") 323 - } 324 - pageClause := "" 325 - if opts.Page.Limit != 0 { 326 - pageClause = fmt.Sprintf( 327 - " limit %d offset %d ", 328 - opts.Page.Limit, 329 - opts.Page.Offset, 330 - ) 331 - } 332 - 333 - query := fmt.Sprintf( 334 - ` 335 - select 336 - id 337 - from 338 - pulls 339 - %s 340 - %s`, 341 - whereClause, 342 - pageClause, 343 - ) 344 - args = append(args, opts.Page.Limit, opts.Page.Offset) 345 - rows, err := e.Query(query, args...) 346 - if err != nil { 347 - return nil, err 348 - } 349 - defer rows.Close() 350 - 351 - for rows.Next() { 352 - var id int64 353 - err := rows.Scan(&id) 354 - if err != nil { 355 - return nil, err 356 - } 357 - 358 - ids = append(ids, id) 359 - } 360 - 361 - return ids, nil 305 + return GetPullsPaginated(e, pagination.Page{}, filters...) 362 306 } 363 307 364 308 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 365 - pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 309 + pulls, err := GetPullsPaginated(e, pagination.Page{Limit: 1}, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 366 310 if err != nil { 367 311 return nil, err 368 312 }
+1 -1
appview/db/punchcard.go
··· 78 78 punch.Count = int(count.Int64) 79 79 } 80 80 81 - punchcard.Punches[punch.Date.YearDay()] = punch 81 + punchcard.Punches[punch.Date.YearDay()-1] = punch 82 82 punchcard.Total += punch.Count 83 83 } 84 84
+32 -32
appview/issues/issues.go
··· 81 81 82 82 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 83 83 l := rp.logger.With("handler", "RepoSingleIssue") 84 - user := rp.oauth.GetUser(r) 84 + user := rp.oauth.GetMultiAccountUser(r) 85 85 f, err := rp.repoResolver.Resolve(r) 86 86 if err != nil { 87 87 l.Error("failed to get repo and knot", "err", err) ··· 102 102 103 103 userReactions := map[models.ReactionKind]bool{} 104 104 if user != nil { 105 - userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 105 + userReactions = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 106 106 } 107 107 108 108 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) ··· 143 143 144 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 145 145 l := rp.logger.With("handler", "EditIssue") 146 - user := rp.oauth.GetUser(r) 146 + user := rp.oauth.GetMultiAccountUser(r) 147 147 148 148 issue, ok := r.Context().Value("issue").(*models.Issue) 149 149 if !ok { ··· 182 182 return 183 183 } 184 184 185 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 185 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Active.Did, newIssue.Rkey) 186 186 if err != nil { 187 187 l.Error("failed to get record", "err", err) 188 188 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") ··· 191 191 192 192 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 193 193 Collection: tangled.RepoIssueNSID, 194 - Repo: user.Did, 194 + Repo: user.Active.Did, 195 195 Rkey: newIssue.Rkey, 196 196 SwapRecord: ex.Cid, 197 197 Record: &lexutil.LexiconTypeDecoder{ ··· 292 292 293 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 294 294 l := rp.logger.With("handler", "CloseIssue") 295 - user := rp.oauth.GetUser(r) 295 + user := rp.oauth.GetMultiAccountUser(r) 296 296 f, err := rp.repoResolver.Resolve(r) 297 297 if err != nil { 298 298 l.Error("failed to get repo and knot", "err", err) ··· 306 306 return 307 307 } 308 308 309 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 309 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 310 310 isRepoOwner := roles.IsOwner() 311 311 isCollaborator := roles.IsCollaborator() 312 - isIssueOwner := user.Did == issue.Did 312 + isIssueOwner := user.Active.Did == issue.Did 313 313 314 314 // TODO: make this more granular 315 315 if isIssueOwner || isRepoOwner || isCollaborator { ··· 326 326 issue.Open = false 327 327 328 328 // notify about the issue closure 329 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 329 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 330 330 331 331 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 332 332 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 340 340 341 341 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 342 342 l := rp.logger.With("handler", "ReopenIssue") 343 - user := rp.oauth.GetUser(r) 343 + user := rp.oauth.GetMultiAccountUser(r) 344 344 f, err := rp.repoResolver.Resolve(r) 345 345 if err != nil { 346 346 l.Error("failed to get repo and knot", "err", err) ··· 354 354 return 355 355 } 356 356 357 - roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 357 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 358 358 isRepoOwner := roles.IsOwner() 359 359 isCollaborator := roles.IsCollaborator() 360 - isIssueOwner := user.Did == issue.Did 360 + isIssueOwner := user.Active.Did == issue.Did 361 361 362 362 if isCollaborator || isRepoOwner || isIssueOwner { 363 363 err := db.ReopenIssues( ··· 373 373 issue.Open = true 374 374 375 375 // notify about the issue reopen 376 - rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 376 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Active.Did), issue) 377 377 378 378 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 379 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) ··· 387 387 388 388 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 389 389 l := rp.logger.With("handler", "NewIssueComment") 390 - user := rp.oauth.GetUser(r) 390 + user := rp.oauth.GetMultiAccountUser(r) 391 391 f, err := rp.repoResolver.Resolve(r) 392 392 if err != nil { 393 393 l.Error("failed to get repo and knot", "err", err) ··· 416 416 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 417 418 418 comment := models.IssueComment{ 419 - Did: user.Did, 419 + Did: user.Active.Did, 420 420 Rkey: tid.TID(), 421 421 IssueAt: issue.AtUri().String(), 422 422 ReplyTo: replyTo, ··· 495 495 496 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 497 497 l := rp.logger.With("handler", "IssueComment") 498 - user := rp.oauth.GetUser(r) 498 + user := rp.oauth.GetMultiAccountUser(r) 499 499 500 500 issue, ok := r.Context().Value("issue").(*models.Issue) 501 501 if !ok { ··· 531 531 532 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 533 533 l := rp.logger.With("handler", "EditIssueComment") 534 - user := rp.oauth.GetUser(r) 534 + user := rp.oauth.GetMultiAccountUser(r) 535 535 536 536 issue, ok := r.Context().Value("issue").(*models.Issue) 537 537 if !ok { ··· 557 557 } 558 558 comment := comments[0] 559 559 560 - if comment.Did != user.Did { 561 - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 560 + if comment.Did != user.Active.Did { 561 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 562 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 563 563 return 564 564 } ··· 608 608 // rkey is optional, it was introduced later 609 609 if newComment.Rkey != "" { 610 610 // update the record on pds 611 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 611 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey) 612 612 if err != nil { 613 613 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 614 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 617 617 618 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 619 Collection: tangled.RepoIssueCommentNSID, 620 - Repo: user.Did, 620 + Repo: user.Active.Did, 621 621 Rkey: newComment.Rkey, 622 622 SwapRecord: ex.Cid, 623 623 Record: &lexutil.LexiconTypeDecoder{ ··· 641 641 642 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 643 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 644 - user := rp.oauth.GetUser(r) 644 + user := rp.oauth.GetMultiAccountUser(r) 645 645 646 646 issue, ok := r.Context().Value("issue").(*models.Issue) 647 647 if !ok { ··· 677 677 678 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 679 679 l := rp.logger.With("handler", "ReplyIssueComment") 680 - user := rp.oauth.GetUser(r) 680 + user := rp.oauth.GetMultiAccountUser(r) 681 681 682 682 issue, ok := r.Context().Value("issue").(*models.Issue) 683 683 if !ok { ··· 713 713 714 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 715 715 l := rp.logger.With("handler", "DeleteIssueComment") 716 - user := rp.oauth.GetUser(r) 716 + user := rp.oauth.GetMultiAccountUser(r) 717 717 718 718 issue, ok := r.Context().Value("issue").(*models.Issue) 719 719 if !ok { ··· 739 739 } 740 740 comment := comments[0] 741 741 742 - if comment.Did != user.Did { 743 - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 742 + if comment.Did != user.Active.Did { 743 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 744 744 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 745 745 return 746 746 } ··· 769 769 } 770 770 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 771 771 Collection: tangled.RepoIssueCommentNSID, 772 - Repo: user.Did, 772 + Repo: user.Active.Did, 773 773 Rkey: comment.Rkey, 774 774 }) 775 775 if err != nil { ··· 807 807 808 808 page := pagination.FromContext(r.Context()) 809 809 810 - user := rp.oauth.GetUser(r) 810 + user := rp.oauth.GetMultiAccountUser(r) 811 811 f, err := rp.repoResolver.Resolve(r) 812 812 if err != nil { 813 813 l.Error("failed to get repo and knot", "err", err) ··· 884 884 } 885 885 886 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 887 - LoggedInUser: rp.oauth.GetUser(r), 887 + LoggedInUser: rp.oauth.GetMultiAccountUser(r), 888 888 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 889 889 Issues: issues, 890 890 IssueCount: totalIssues, ··· 897 897 898 898 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 899 899 l := rp.logger.With("handler", "NewIssue") 900 - user := rp.oauth.GetUser(r) 900 + user := rp.oauth.GetMultiAccountUser(r) 901 901 902 902 f, err := rp.repoResolver.Resolve(r) 903 903 if err != nil { ··· 921 921 Title: r.FormValue("title"), 922 922 Body: body, 923 923 Open: true, 924 - Did: user.Did, 924 + Did: user.Active.Did, 925 925 Created: time.Now(), 926 926 Mentions: mentions, 927 927 References: references, ··· 945 945 } 946 946 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 947 947 Collection: tangled.RepoIssueNSID, 948 - Repo: user.Did, 948 + Repo: user.Active.Did, 949 949 Rkey: issue.Rkey, 950 950 Record: &lexutil.LexiconTypeDecoder{ 951 951 Val: &record,
+2 -2
appview/issues/opengraph.go
··· 193 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 196 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 197 197 if err != nil { 198 - log.Printf("dolly silhouette not available (this is ok): %v", err) 198 + log.Printf("dolly not available (this is ok): %v", err) 199 199 } 200 200 201 201 // Draw "opened by @author" and date at the bottom with more spacing
+31 -36
appview/knots/knots.go
··· 70 70 } 71 71 72 72 func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 73 - user := k.OAuth.GetUser(r) 73 + user := k.OAuth.GetMultiAccountUser(r) 74 74 registrations, err := db.GetRegistrations( 75 75 k.Db, 76 - orm.FilterEq("did", user.Did), 76 + orm.FilterEq("did", user.Active.Did), 77 77 ) 78 78 if err != nil { 79 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 92 92 func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 93 93 l := k.Logger.With("handler", "dashboard") 94 94 95 - user := k.OAuth.GetUser(r) 96 - l = l.With("user", user.Did) 95 + user := k.OAuth.GetMultiAccountUser(r) 96 + l = l.With("user", user.Active.Did) 97 97 98 98 domain := chi.URLParam(r, "domain") 99 99 if domain == "" { ··· 103 103 104 104 registrations, err := db.GetRegistrations( 105 105 k.Db, 106 - orm.FilterEq("did", user.Did), 106 + orm.FilterEq("did", user.Active.Did), 107 107 orm.FilterEq("domain", domain), 108 108 ) 109 109 if err != nil { ··· 154 154 } 155 155 156 156 func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 157 - user := k.OAuth.GetUser(r) 157 + user := k.OAuth.GetMultiAccountUser(r) 158 158 l := k.Logger.With("handler", "register") 159 159 160 160 noticeId := "register-error" ··· 175 175 return 176 176 } 177 177 l = l.With("domain", domain) 178 - l = l.With("user", user.Did) 178 + l = l.With("user", user.Active.Did) 179 179 180 180 tx, err := k.Db.Begin() 181 181 if err != nil { ··· 188 188 k.Enforcer.E.LoadPolicy() 189 189 }() 190 190 191 - err = db.AddKnot(tx, domain, user.Did) 191 + err = db.AddKnot(tx, domain, user.Active.Did) 192 192 if err != nil { 193 193 l.Error("failed to insert", "err", err) 194 194 fail() ··· 210 210 return 211 211 } 212 212 213 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 213 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Active.Did, domain) 214 214 var exCid *string 215 215 if ex != nil { 216 216 exCid = ex.Cid ··· 219 219 // re-announce by registering under same rkey 220 220 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 221 221 Collection: tangled.KnotNSID, 222 - Repo: user.Did, 222 + Repo: user.Active.Did, 223 223 Rkey: domain, 224 224 Record: &lexutil.LexiconTypeDecoder{ 225 225 Val: &tangled.Knot{ ··· 250 250 } 251 251 252 252 // begin verification 253 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 253 + err = serververify.RunVerification(r.Context(), domain, user.Active.Did, k.Config.Core.Dev) 254 254 if err != nil { 255 255 l.Error("verification failed", "err", err) 256 256 k.Pages.HxRefresh(w) 257 257 return 258 258 } 259 259 260 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 260 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Active.Did) 261 261 if err != nil { 262 262 l.Error("failed to mark verified", "err", err) 263 263 k.Pages.HxRefresh(w) ··· 275 275 } 276 276 277 277 func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 278 - user := k.OAuth.GetUser(r) 278 + user := k.OAuth.GetMultiAccountUser(r) 279 279 l := k.Logger.With("handler", "delete") 280 280 281 281 noticeId := "operation-error" ··· 294 294 // get record from db first 295 295 registrations, err := db.GetRegistrations( 296 296 k.Db, 297 - orm.FilterEq("did", user.Did), 297 + orm.FilterEq("did", user.Active.Did), 298 298 orm.FilterEq("domain", domain), 299 299 ) 300 300 if err != nil { ··· 322 322 323 323 err = db.DeleteKnot( 324 324 tx, 325 - orm.FilterEq("did", user.Did), 325 + orm.FilterEq("did", user.Active.Did), 326 326 orm.FilterEq("domain", domain), 327 327 ) 328 328 if err != nil { ··· 350 350 351 351 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 352 352 Collection: tangled.KnotNSID, 353 - Repo: user.Did, 353 + Repo: user.Active.Did, 354 354 Rkey: domain, 355 355 }) 356 356 if err != nil { ··· 382 382 } 383 383 384 384 func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 385 - user := k.OAuth.GetUser(r) 385 + user := k.OAuth.GetMultiAccountUser(r) 386 386 l := k.Logger.With("handler", "retry") 387 387 388 388 noticeId := "operation-error" ··· 398 398 return 399 399 } 400 400 l = l.With("domain", domain) 401 - l = l.With("user", user.Did) 401 + l = l.With("user", user.Active.Did) 402 402 403 403 // get record from db first 404 404 registrations, err := db.GetRegistrations( 405 405 k.Db, 406 - orm.FilterEq("did", user.Did), 406 + orm.FilterEq("did", user.Active.Did), 407 407 orm.FilterEq("domain", domain), 408 408 ) 409 409 if err != nil { ··· 419 419 registration := registrations[0] 420 420 421 421 // begin verification 422 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 422 + err = serververify.RunVerification(r.Context(), domain, user.Active.Did, k.Config.Core.Dev) 423 423 if err != nil { 424 424 l.Error("verification failed", "err", err) 425 425 ··· 437 437 return 438 438 } 439 439 440 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 440 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Active.Did) 441 441 if err != nil { 442 442 l.Error("failed to mark verified", "err", err) 443 443 k.Pages.Notice(w, noticeId, err.Error()) ··· 456 456 return 457 457 } 458 458 459 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 459 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Active.Did, domain) 460 460 var exCid *string 461 461 if ex != nil { 462 462 exCid = ex.Cid ··· 465 465 // ignore the error here 466 466 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 467 467 Collection: tangled.KnotNSID, 468 - Repo: user.Did, 468 + Repo: user.Active.Did, 469 469 Rkey: domain, 470 470 Record: &lexutil.LexiconTypeDecoder{ 471 471 Val: &tangled.Knot{ ··· 494 494 // Get updated registration to show 495 495 registrations, err = db.GetRegistrations( 496 496 k.Db, 497 - orm.FilterEq("did", user.Did), 497 + orm.FilterEq("did", user.Active.Did), 498 498 orm.FilterEq("domain", domain), 499 499 ) 500 500 if err != nil { ··· 516 516 } 517 517 518 518 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 519 - user := k.OAuth.GetUser(r) 519 + user := k.OAuth.GetMultiAccountUser(r) 520 520 l := k.Logger.With("handler", "addMember") 521 521 522 522 domain := chi.URLParam(r, "domain") ··· 526 526 return 527 527 } 528 528 l = l.With("domain", domain) 529 - l = l.With("user", user.Did) 529 + l = l.With("user", user.Active.Did) 530 530 531 531 registrations, err := db.GetRegistrations( 532 532 k.Db, 533 - orm.FilterEq("did", user.Did), 533 + orm.FilterEq("did", user.Active.Did), 534 534 orm.FilterEq("domain", domain), 535 535 orm.FilterIsNot("registered", "null"), 536 536 ) ··· 583 583 584 584 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 585 585 Collection: tangled.KnotMemberNSID, 586 - Repo: user.Did, 586 + Repo: user.Active.Did, 587 587 Rkey: rkey, 588 588 Record: &lexutil.LexiconTypeDecoder{ 589 589 Val: &tangled.KnotMember{ ··· 618 618 } 619 619 620 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 621 - user := k.OAuth.GetUser(r) 621 + user := k.OAuth.GetMultiAccountUser(r) 622 622 l := k.Logger.With("handler", "removeMember") 623 623 624 624 noticeId := "operation-error" ··· 634 634 return 635 635 } 636 636 l = l.With("domain", domain) 637 - l = l.With("user", user.Did) 637 + l = l.With("user", user.Active.Did) 638 638 639 639 registrations, err := db.GetRegistrations( 640 640 k.Db, 641 - orm.FilterEq("did", user.Did), 641 + orm.FilterEq("did", user.Active.Did), 642 642 orm.FilterEq("domain", domain), 643 643 orm.FilterIsNot("registered", "null"), 644 644 ) ··· 663 663 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 664 664 if err != nil { 665 665 l.Error("failed to resolve member identity to handle", "err", err) 666 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 - return 668 - } 669 - if memberId.Handle.IsInvalidHandle() { 670 - l.Error("failed to resolve member identity to handle") 671 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 672 667 return 673 668 }
+2 -2
appview/labels/labels.go
··· 68 68 // - this handler should calculate the diff in order to create the labelop record 69 69 // - we need the diff in order to maintain a "history" of operations performed by users 70 70 func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { 71 - user := l.oauth.GetUser(r) 71 + user := l.oauth.GetMultiAccountUser(r) 72 72 73 73 noticeId := "add-label-error" 74 74 ··· 82 82 return 83 83 } 84 84 85 - did := user.Did 85 + did := user.Active.Did 86 86 rkey := tid.TID() 87 87 performedAt := time.Now() 88 88 indexedAt := time.Now()
+10 -8
appview/middleware/middleware.go
··· 115 115 return func(next http.Handler) http.Handler { 116 116 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 117 // requires auth also 118 - actor := mw.oauth.GetUser(r) 118 + actor := mw.oauth.GetMultiAccountUser(r) 119 119 if actor == nil { 120 120 // we need a logged in user 121 121 log.Printf("not logged in, redirecting") ··· 128 128 return 129 129 } 130 130 131 - ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 131 + ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Active.Did, group, domain) 132 132 if err != nil || !ok { 133 - // we need a logged in user 134 - log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) 133 + log.Printf("%s does not have perms of a %s in domain %s", actor.Active.Did, group, domain) 135 134 http.Error(w, "Forbiden", http.StatusUnauthorized) 136 135 return 137 136 } ··· 149 148 return func(next http.Handler) http.Handler { 150 149 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 150 // requires auth also 152 - actor := mw.oauth.GetUser(r) 151 + actor := mw.oauth.GetMultiAccountUser(r) 153 152 if actor == nil { 154 153 // we need a logged in user 155 154 log.Printf("not logged in, redirecting") ··· 162 161 return 163 162 } 164 163 165 - ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 164 + ok, err := mw.enforcer.E.Enforce(actor.Active.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 166 165 if err != nil || !ok { 167 - // we need a logged in user 168 - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 166 + log.Printf("%s does not have perms of a %s in repo %s", actor.Active.Did, requiredPerm, f.DidSlashRepo()) 169 167 http.Error(w, "Forbiden", http.StatusUnauthorized) 170 168 return 171 169 } ··· 223 221 ) 224 222 if err != nil { 225 223 log.Println("failed to resolve repo", "err", err) 224 + w.WriteHeader(http.StatusNotFound) 226 225 mw.pages.ErrorKnot404(w) 227 226 return 228 227 } ··· 240 239 f, err := mw.repoResolver.Resolve(r) 241 240 if err != nil { 242 241 log.Println("failed to fully resolve repo", err) 242 + w.WriteHeader(http.StatusNotFound) 243 243 mw.pages.ErrorKnot404(w) 244 244 return 245 245 } ··· 288 288 f, err := mw.repoResolver.Resolve(r) 289 289 if err != nil { 290 290 log.Println("failed to fully resolve repo", err) 291 + w.WriteHeader(http.StatusNotFound) 291 292 mw.pages.ErrorKnot404(w) 292 293 return 293 294 } ··· 324 325 f, err := mw.repoResolver.Resolve(r) 325 326 if err != nil { 326 327 log.Println("failed to fully resolve repo", err) 328 + w.WriteHeader(http.StatusNotFound) 327 329 mw.pages.ErrorKnot404(w) 328 330 return 329 331 }
+38
appview/models/pipeline.go
··· 3 3 import ( 4 4 "fmt" 5 5 "slices" 6 + "strings" 6 7 "time" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 56 57 } 57 58 58 59 return 0 60 + } 61 + 62 + // produces short summary of successes: 63 + // - "0/4" when zero successes of 4 workflows 64 + // - "4/4" when all successes of 4 workflows 65 + // - "0/0" when no workflows run in this pipeline 66 + func (p Pipeline) ShortStatusSummary() string { 67 + counts := make(map[spindle.StatusKind]int) 68 + for _, w := range p.Statuses { 69 + counts[w.Latest().Status] += 1 70 + } 71 + 72 + total := len(p.Statuses) 73 + successes := counts[spindle.StatusKindSuccess] 74 + 75 + return fmt.Sprintf("%d/%d", successes, total) 76 + } 77 + 78 + // produces a string of the form "3/4 success, 2/4 failed, 1/4 pending" 79 + func (p Pipeline) LongStatusSummary() string { 80 + counts := make(map[spindle.StatusKind]int) 81 + for _, w := range p.Statuses { 82 + counts[w.Latest().Status] += 1 83 + } 84 + 85 + total := len(p.Statuses) 86 + 87 + var result []string 88 + // finish states first, followed by start states 89 + states := append(spindle.FinishStates[:], spindle.StartStates[:]...) 90 + for _, state := range states { 91 + if count, ok := counts[state]; ok { 92 + result = append(result, fmt.Sprintf("%d/%d %s", count, total, state.String())) 93 + } 94 + } 95 + 96 + return strings.Join(result, ", ") 59 97 } 60 98 61 99 func (p Pipeline) Counts() map[string]int {
+8 -18
appview/models/pull.go
··· 83 83 Repo *Repo 84 84 } 85 85 86 + // NOTE: This method does not include patch blob in returned atproto record 86 87 func (p Pull) AsRecord() tangled.RepoPull { 87 88 var source *tangled.RepoPull_Source 88 89 if p.PullSource != nil { ··· 113 114 Repo: p.RepoAt.String(), 114 115 Branch: p.TargetBranch, 115 116 }, 116 - Patch: p.LatestPatch(), 117 117 Source: source, 118 118 } 119 119 return record ··· 171 171 return syntax.ATURI(p.CommentAt) 172 172 } 173 173 174 - // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 - // mentions := make([]string, len(p.Mentions)) 176 - // for i, did := range p.Mentions { 177 - // mentions[i] = string(did) 178 - // } 179 - // references := make([]string, len(p.References)) 180 - // for i, uri := range p.References { 181 - // references[i] = string(uri) 182 - // } 183 - // return tangled.RepoPullComment{ 184 - // Pull: p.PullAt, 185 - // Body: p.Body, 186 - // Mentions: mentions, 187 - // References: references, 188 - // CreatedAt: p.Created.Format(time.RFC3339), 189 - // } 190 - // } 174 + func (p *Pull) TotalComments() int { 175 + total := 0 176 + for _, s := range p.Submissions { 177 + total += len(s.Comments) 178 + } 179 + return total 180 + } 191 181 192 182 func (p *Pull) LastRoundNumber() int { 193 183 return len(p.Submissions) - 1
+7 -6
appview/notifications/notifications.go
··· 48 48 49 49 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 50 50 l := n.logger.With("handler", "notificationsPage") 51 - user := n.oauth.GetUser(r) 51 + user := n.oauth.GetMultiAccountUser(r) 52 52 53 53 page := pagination.FromContext(r.Context()) 54 54 55 55 total, err := db.CountNotifications( 56 56 n.db, 57 - orm.FilterEq("recipient_did", user.Did), 57 + orm.FilterEq("recipient_did", user.Active.Did), 58 58 ) 59 59 if err != nil { 60 60 l.Error("failed to get total notifications", "err", err) ··· 65 65 notifications, err := db.GetNotificationsWithEntities( 66 66 n.db, 67 67 page, 68 - orm.FilterEq("recipient_did", user.Did), 68 + orm.FilterEq("recipient_did", user.Active.Did), 69 69 ) 70 70 if err != nil { 71 71 l.Error("failed to get notifications", "err", err) ··· 73 73 return 74 74 } 75 75 76 - err = db.MarkAllNotificationsRead(n.db, user.Did) 76 + err = db.MarkAllNotificationsRead(n.db, user.Active.Did) 77 77 if err != nil { 78 78 l.Error("failed to mark notifications as read", "err", err) 79 79 } ··· 90 90 } 91 91 92 92 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 - user := n.oauth.GetUser(r) 93 + user := n.oauth.GetMultiAccountUser(r) 94 94 if user == nil { 95 + http.Error(w, "Forbidden", http.StatusUnauthorized) 95 96 return 96 97 } 97 98 98 99 count, err := db.CountNotifications( 99 100 n.db, 100 - orm.FilterEq("recipient_did", user.Did), 101 + orm.FilterEq("recipient_did", user.Active.Did), 101 102 orm.FilterEq("read", 0), 102 103 ) 103 104 if err != nil {
+191
appview/oauth/accounts.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "net/http" 7 + "time" 8 + ) 9 + 10 + const MaxAccounts = 20 11 + 12 + var ErrMaxAccountsReached = errors.New("maximum number of linked accounts reached") 13 + 14 + type AccountInfo struct { 15 + Did string `json:"did"` 16 + Handle string `json:"handle"` 17 + SessionId string `json:"session_id"` 18 + AddedAt int64 `json:"added_at"` 19 + } 20 + 21 + type AccountRegistry struct { 22 + Accounts []AccountInfo `json:"accounts"` 23 + } 24 + 25 + type MultiAccountUser struct { 26 + Active *User 27 + Accounts []AccountInfo 28 + } 29 + 30 + func (m *MultiAccountUser) Did() string { 31 + if m.Active == nil { 32 + return "" 33 + } 34 + return m.Active.Did 35 + } 36 + 37 + func (m *MultiAccountUser) Pds() string { 38 + if m.Active == nil { 39 + return "" 40 + } 41 + return m.Active.Pds 42 + } 43 + 44 + func (o *OAuth) GetAccounts(r *http.Request) *AccountRegistry { 45 + session, err := o.SessStore.Get(r, AccountsName) 46 + if err != nil || session.IsNew { 47 + return &AccountRegistry{Accounts: []AccountInfo{}} 48 + } 49 + 50 + data, ok := session.Values["accounts"].(string) 51 + if !ok { 52 + return &AccountRegistry{Accounts: []AccountInfo{}} 53 + } 54 + 55 + var registry AccountRegistry 56 + if err := json.Unmarshal([]byte(data), &registry); err != nil { 57 + return &AccountRegistry{Accounts: []AccountInfo{}} 58 + } 59 + 60 + return &registry 61 + } 62 + 63 + func (o *OAuth) SaveAccounts(w http.ResponseWriter, r *http.Request, registry *AccountRegistry) error { 64 + session, err := o.SessStore.Get(r, AccountsName) 65 + if err != nil { 66 + return err 67 + } 68 + 69 + data, err := json.Marshal(registry) 70 + if err != nil { 71 + return err 72 + } 73 + 74 + session.Values["accounts"] = string(data) 75 + session.Options.MaxAge = 60 * 60 * 24 * 365 76 + session.Options.HttpOnly = true 77 + session.Options.Secure = !o.Config.Core.Dev 78 + session.Options.SameSite = http.SameSiteLaxMode 79 + 80 + return session.Save(r, w) 81 + } 82 + 83 + func (r *AccountRegistry) AddAccount(did, handle, sessionId string) error { 84 + for i, acc := range r.Accounts { 85 + if acc.Did == did { 86 + r.Accounts[i].SessionId = sessionId 87 + r.Accounts[i].Handle = handle 88 + return nil 89 + } 90 + } 91 + 92 + if len(r.Accounts) >= MaxAccounts { 93 + return ErrMaxAccountsReached 94 + } 95 + 96 + r.Accounts = append(r.Accounts, AccountInfo{ 97 + Did: did, 98 + Handle: handle, 99 + SessionId: sessionId, 100 + AddedAt: time.Now().Unix(), 101 + }) 102 + return nil 103 + } 104 + 105 + func (r *AccountRegistry) RemoveAccount(did string) { 106 + filtered := make([]AccountInfo, 0, len(r.Accounts)) 107 + for _, acc := range r.Accounts { 108 + if acc.Did != did { 109 + filtered = append(filtered, acc) 110 + } 111 + } 112 + r.Accounts = filtered 113 + } 114 + 115 + func (r *AccountRegistry) FindAccount(did string) *AccountInfo { 116 + for i := range r.Accounts { 117 + if r.Accounts[i].Did == did { 118 + return &r.Accounts[i] 119 + } 120 + } 121 + return nil 122 + } 123 + 124 + func (r *AccountRegistry) OtherAccounts(activeDid string) []AccountInfo { 125 + result := make([]AccountInfo, 0, len(r.Accounts)) 126 + for _, acc := range r.Accounts { 127 + if acc.Did != activeDid { 128 + result = append(result, acc) 129 + } 130 + } 131 + return result 132 + } 133 + 134 + func (o *OAuth) GetMultiAccountUser(r *http.Request) *MultiAccountUser { 135 + user := o.GetUser(r) 136 + if user == nil { 137 + return nil 138 + } 139 + 140 + registry := o.GetAccounts(r) 141 + return &MultiAccountUser{ 142 + Active: user, 143 + Accounts: registry.Accounts, 144 + } 145 + } 146 + 147 + type AuthReturnInfo struct { 148 + ReturnURL string 149 + AddAccount bool 150 + } 151 + 152 + func (o *OAuth) SetAuthReturn(w http.ResponseWriter, r *http.Request, returnURL string, addAccount bool) error { 153 + session, err := o.SessStore.Get(r, AuthReturnName) 154 + if err != nil { 155 + return err 156 + } 157 + 158 + session.Values[AuthReturnURL] = returnURL 159 + session.Values[AuthAddAccount] = addAccount 160 + session.Options.MaxAge = 60 * 30 161 + session.Options.HttpOnly = true 162 + session.Options.Secure = !o.Config.Core.Dev 163 + session.Options.SameSite = http.SameSiteLaxMode 164 + 165 + return session.Save(r, w) 166 + } 167 + 168 + func (o *OAuth) GetAuthReturn(r *http.Request) *AuthReturnInfo { 169 + session, err := o.SessStore.Get(r, AuthReturnName) 170 + if err != nil || session.IsNew { 171 + return &AuthReturnInfo{} 172 + } 173 + 174 + returnURL, _ := session.Values[AuthReturnURL].(string) 175 + addAccount, _ := session.Values[AuthAddAccount].(bool) 176 + 177 + return &AuthReturnInfo{ 178 + ReturnURL: returnURL, 179 + AddAccount: addAccount, 180 + } 181 + } 182 + 183 + func (o *OAuth) ClearAuthReturn(w http.ResponseWriter, r *http.Request) error { 184 + session, err := o.SessStore.Get(r, AuthReturnName) 185 + if err != nil { 186 + return err 187 + } 188 + 189 + session.Options.MaxAge = -1 190 + return session.Save(r, w) 191 + }
+265
appview/oauth/accounts_test.go
··· 1 + package oauth 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestAccountRegistry_AddAccount(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + initial []AccountInfo 11 + addDid string 12 + addHandle string 13 + addSessionId string 14 + wantErr error 15 + wantLen int 16 + wantSessionId string 17 + }{ 18 + { 19 + name: "add first account", 20 + initial: []AccountInfo{}, 21 + addDid: "did:plc:abc123", 22 + addHandle: "alice.bsky.social", 23 + addSessionId: "session-1", 24 + wantErr: nil, 25 + wantLen: 1, 26 + wantSessionId: "session-1", 27 + }, 28 + { 29 + name: "add second account", 30 + initial: []AccountInfo{ 31 + {Did: "did:plc:abc123", Handle: "alice.bsky.social", SessionId: "session-1", AddedAt: 1000}, 32 + }, 33 + addDid: "did:plc:def456", 34 + addHandle: "bob.bsky.social", 35 + addSessionId: "session-2", 36 + wantErr: nil, 37 + wantLen: 2, 38 + wantSessionId: "session-2", 39 + }, 40 + { 41 + name: "update existing account session", 42 + initial: []AccountInfo{ 43 + {Did: "did:plc:abc123", Handle: "alice.bsky.social", SessionId: "old-session", AddedAt: 1000}, 44 + }, 45 + addDid: "did:plc:abc123", 46 + addHandle: "alice.bsky.social", 47 + addSessionId: "new-session", 48 + wantErr: nil, 49 + wantLen: 1, 50 + wantSessionId: "new-session", 51 + }, 52 + } 53 + 54 + for _, tt := range tests { 55 + t.Run(tt.name, func(t *testing.T) { 56 + registry := &AccountRegistry{Accounts: tt.initial} 57 + err := registry.AddAccount(tt.addDid, tt.addHandle, tt.addSessionId) 58 + 59 + if err != tt.wantErr { 60 + t.Errorf("AddAccount() error = %v, want %v", err, tt.wantErr) 61 + } 62 + 63 + if len(registry.Accounts) != tt.wantLen { 64 + t.Errorf("AddAccount() len = %d, want %d", len(registry.Accounts), tt.wantLen) 65 + } 66 + 67 + found := registry.FindAccount(tt.addDid) 68 + if found == nil { 69 + t.Errorf("AddAccount() account not found after add") 70 + return 71 + } 72 + 73 + if found.SessionId != tt.wantSessionId { 74 + t.Errorf("AddAccount() sessionId = %s, want %s", found.SessionId, tt.wantSessionId) 75 + } 76 + }) 77 + } 78 + } 79 + 80 + func TestAccountRegistry_AddAccount_MaxLimit(t *testing.T) { 81 + registry := &AccountRegistry{Accounts: make([]AccountInfo, 0, MaxAccounts)} 82 + 83 + for i := range MaxAccounts { 84 + err := registry.AddAccount("did:plc:user"+string(rune('a'+i)), "handle", "session") 85 + if err != nil { 86 + t.Fatalf("AddAccount() unexpected error on account %d: %v", i, err) 87 + } 88 + } 89 + 90 + if len(registry.Accounts) != MaxAccounts { 91 + t.Errorf("expected %d accounts, got %d", MaxAccounts, len(registry.Accounts)) 92 + } 93 + 94 + err := registry.AddAccount("did:plc:overflow", "overflow", "session-overflow") 95 + if err != ErrMaxAccountsReached { 96 + t.Errorf("AddAccount() error = %v, want %v", err, ErrMaxAccountsReached) 97 + } 98 + 99 + if len(registry.Accounts) != MaxAccounts { 100 + t.Errorf("account added despite max limit, got %d", len(registry.Accounts)) 101 + } 102 + } 103 + 104 + func TestAccountRegistry_RemoveAccount(t *testing.T) { 105 + tests := []struct { 106 + name string 107 + initial []AccountInfo 108 + removeDid string 109 + wantLen int 110 + wantDids []string 111 + }{ 112 + { 113 + name: "remove existing account", 114 + initial: []AccountInfo{ 115 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 116 + {Did: "did:plc:def456", Handle: "bob", SessionId: "s2"}, 117 + }, 118 + removeDid: "did:plc:abc123", 119 + wantLen: 1, 120 + wantDids: []string{"did:plc:def456"}, 121 + }, 122 + { 123 + name: "remove non-existing account", 124 + initial: []AccountInfo{ 125 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 126 + }, 127 + removeDid: "did:plc:notfound", 128 + wantLen: 1, 129 + wantDids: []string{"did:plc:abc123"}, 130 + }, 131 + { 132 + name: "remove last account", 133 + initial: []AccountInfo{ 134 + {Did: "did:plc:abc123", Handle: "alice", SessionId: "s1"}, 135 + }, 136 + removeDid: "did:plc:abc123", 137 + wantLen: 0, 138 + wantDids: []string{}, 139 + }, 140 + { 141 + name: "remove from empty registry", 142 + initial: []AccountInfo{}, 143 + removeDid: "did:plc:abc123", 144 + wantLen: 0, 145 + wantDids: []string{}, 146 + }, 147 + } 148 + 149 + for _, tt := range tests { 150 + t.Run(tt.name, func(t *testing.T) { 151 + registry := &AccountRegistry{Accounts: tt.initial} 152 + registry.RemoveAccount(tt.removeDid) 153 + 154 + if len(registry.Accounts) != tt.wantLen { 155 + t.Errorf("RemoveAccount() len = %d, want %d", len(registry.Accounts), tt.wantLen) 156 + } 157 + 158 + for _, wantDid := range tt.wantDids { 159 + if registry.FindAccount(wantDid) == nil { 160 + t.Errorf("RemoveAccount() expected %s to remain", wantDid) 161 + } 162 + } 163 + 164 + if registry.FindAccount(tt.removeDid) != nil && tt.wantLen < len(tt.initial) { 165 + t.Errorf("RemoveAccount() %s should have been removed", tt.removeDid) 166 + } 167 + }) 168 + } 169 + } 170 + 171 + func TestAccountRegistry_FindAccount(t *testing.T) { 172 + registry := &AccountRegistry{ 173 + Accounts: []AccountInfo{ 174 + {Did: "did:plc:first", Handle: "first", SessionId: "s1", AddedAt: 1000}, 175 + {Did: "did:plc:second", Handle: "second", SessionId: "s2", AddedAt: 2000}, 176 + {Did: "did:plc:third", Handle: "third", SessionId: "s3", AddedAt: 3000}, 177 + }, 178 + } 179 + 180 + t.Run("find existing account", func(t *testing.T) { 181 + found := registry.FindAccount("did:plc:second") 182 + if found == nil { 183 + t.Fatal("FindAccount() returned nil for existing account") 184 + } 185 + if found.Handle != "second" { 186 + t.Errorf("FindAccount() handle = %s, want second", found.Handle) 187 + } 188 + if found.SessionId != "s2" { 189 + t.Errorf("FindAccount() sessionId = %s, want s2", found.SessionId) 190 + } 191 + }) 192 + 193 + t.Run("find non-existing account", func(t *testing.T) { 194 + found := registry.FindAccount("did:plc:notfound") 195 + if found != nil { 196 + t.Errorf("FindAccount() = %v, want nil", found) 197 + } 198 + }) 199 + 200 + t.Run("returned pointer is mutable", func(t *testing.T) { 201 + found := registry.FindAccount("did:plc:first") 202 + if found == nil { 203 + t.Fatal("FindAccount() returned nil") 204 + } 205 + found.SessionId = "modified" 206 + 207 + refetch := registry.FindAccount("did:plc:first") 208 + if refetch.SessionId != "modified" { 209 + t.Errorf("FindAccount() pointer not referencing original, got %s", refetch.SessionId) 210 + } 211 + }) 212 + } 213 + 214 + func TestAccountRegistry_OtherAccounts(t *testing.T) { 215 + registry := &AccountRegistry{ 216 + Accounts: []AccountInfo{ 217 + {Did: "did:plc:active", Handle: "active", SessionId: "s1"}, 218 + {Did: "did:plc:other1", Handle: "other1", SessionId: "s2"}, 219 + {Did: "did:plc:other2", Handle: "other2", SessionId: "s3"}, 220 + }, 221 + } 222 + 223 + others := registry.OtherAccounts("did:plc:active") 224 + 225 + if len(others) != 2 { 226 + t.Errorf("OtherAccounts() len = %d, want 2", len(others)) 227 + } 228 + 229 + for _, acc := range others { 230 + if acc.Did == "did:plc:active" { 231 + t.Errorf("OtherAccounts() should not include active account") 232 + } 233 + } 234 + 235 + hasDid := func(did string) bool { 236 + for _, acc := range others { 237 + if acc.Did == did { 238 + return true 239 + } 240 + } 241 + return false 242 + } 243 + 244 + if !hasDid("did:plc:other1") || !hasDid("did:plc:other2") { 245 + t.Errorf("OtherAccounts() missing expected accounts") 246 + } 247 + } 248 + 249 + func TestMultiAccountUser_Did(t *testing.T) { 250 + t.Run("with active user", func(t *testing.T) { 251 + user := &MultiAccountUser{ 252 + Active: &User{Did: "did:plc:test", Pds: "https://bsky.social"}, 253 + } 254 + if user.Did() != "did:plc:test" { 255 + t.Errorf("Did() = %s, want did:plc:test", user.Did()) 256 + } 257 + }) 258 + 259 + t.Run("with nil active", func(t *testing.T) { 260 + user := &MultiAccountUser{Active: nil} 261 + if user.Did() != "" { 262 + t.Errorf("Did() = %s, want empty string", user.Did()) 263 + } 264 + }) 265 + }
+4
appview/oauth/consts.go
··· 2 2 3 3 const ( 4 4 SessionName = "appview-session-v2" 5 + AccountsName = "appview-accounts-v2" 6 + AuthReturnName = "appview-auth-return" 7 + AuthReturnURL = "return_url" 8 + AuthAddAccount = "add_account" 5 9 SessionHandle = "handle" 6 10 SessionDid = "did" 7 11 SessionId = "id"
+14 -2
appview/oauth/handler.go
··· 55 55 ctx := r.Context() 56 56 l := o.Logger.With("query", r.URL.Query()) 57 57 58 + authReturn := o.GetAuthReturn(r) 59 + _ = o.ClearAuthReturn(w, r) 60 + 58 61 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 59 62 if err != nil { 60 63 var callbackErr *oauth.AuthRequestCallbackError ··· 70 73 71 74 if err := o.SaveSession(w, r, sessData); err != nil { 72 75 l.Error("failed to save session", "data", sessData, "err", err) 73 - http.Redirect(w, r, "/login?error=session", http.StatusFound) 76 + errorCode := "session" 77 + if errors.Is(err, ErrMaxAccountsReached) { 78 + errorCode = "max_accounts" 79 + } 80 + http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 74 81 return 75 82 } 76 83 ··· 88 95 } 89 96 } 90 97 91 - http.Redirect(w, r, "/", http.StatusFound) 98 + redirectURL := "/" 99 + if authReturn.ReturnURL != "" { 100 + redirectURL = authReturn.ReturnURL 101 + } 102 + 103 + http.Redirect(w, r, redirectURL, http.StatusFound) 92 104 } 93 105 94 106 func (o *OAuth) addToDefaultSpindle(did string) {
+66 -4
appview/oauth/oauth.go
··· 98 98 } 99 99 100 100 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 101 - // first we save the did in the user session 102 101 userSession, err := o.SessStore.Get(r, SessionName) 103 102 if err != nil { 104 103 return err ··· 108 107 userSession.Values[SessionPds] = sessData.HostURL 109 108 userSession.Values[SessionId] = sessData.SessionID 110 109 userSession.Values[SessionAuthenticated] = true 111 - return userSession.Save(r, w) 110 + 111 + if err := userSession.Save(r, w); err != nil { 112 + return err 113 + } 114 + 115 + handle := "" 116 + resolved, err := o.IdResolver.ResolveIdent(r.Context(), sessData.AccountDID.String()) 117 + if err == nil && resolved.Handle.String() != "" { 118 + handle = resolved.Handle.String() 119 + } 120 + 121 + registry := o.GetAccounts(r) 122 + if err := registry.AddAccount(sessData.AccountDID.String(), handle, sessData.SessionID); err != nil { 123 + return err 124 + } 125 + return o.SaveAccounts(w, r, registry) 112 126 } 113 127 114 128 func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { ··· 163 177 return errors.Join(err1, err2) 164 178 } 165 179 180 + func (o *OAuth) SwitchAccount(w http.ResponseWriter, r *http.Request, targetDid string) error { 181 + registry := o.GetAccounts(r) 182 + account := registry.FindAccount(targetDid) 183 + if account == nil { 184 + return fmt.Errorf("account not found in registry: %s", targetDid) 185 + } 186 + 187 + did, err := syntax.ParseDID(targetDid) 188 + if err != nil { 189 + return fmt.Errorf("invalid DID: %w", err) 190 + } 191 + 192 + sess, err := o.ClientApp.ResumeSession(r.Context(), did, account.SessionId) 193 + if err != nil { 194 + registry.RemoveAccount(targetDid) 195 + _ = o.SaveAccounts(w, r, registry) 196 + return fmt.Errorf("session expired for account: %w", err) 197 + } 198 + 199 + userSession, err := o.SessStore.Get(r, SessionName) 200 + if err != nil { 201 + return err 202 + } 203 + 204 + userSession.Values[SessionDid] = sess.Data.AccountDID.String() 205 + userSession.Values[SessionPds] = sess.Data.HostURL 206 + userSession.Values[SessionId] = sess.Data.SessionID 207 + userSession.Values[SessionAuthenticated] = true 208 + 209 + return userSession.Save(r, w) 210 + } 211 + 212 + func (o *OAuth) RemoveAccount(w http.ResponseWriter, r *http.Request, targetDid string) error { 213 + registry := o.GetAccounts(r) 214 + account := registry.FindAccount(targetDid) 215 + if account == nil { 216 + return nil 217 + } 218 + 219 + did, err := syntax.ParseDID(targetDid) 220 + if err == nil { 221 + _ = o.ClientApp.Logout(r.Context(), did, account.SessionId) 222 + } 223 + 224 + registry.RemoveAccount(targetDid) 225 + return o.SaveAccounts(w, r, registry) 226 + } 227 + 166 228 type User struct { 167 229 Did string 168 230 Pds string ··· 181 243 } 182 244 183 245 func (o *OAuth) GetDid(r *http.Request) string { 184 - if u := o.GetUser(r); u != nil { 185 - return u.Did 246 + if u := o.GetMultiAccountUser(r); u != nil { 247 + return u.Did() 186 248 } 187 249 188 250 return ""
+9 -9
appview/ogcard/card.go
··· 334 334 return nil 335 335 } 336 336 337 - func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error { 337 + func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { 338 338 tpl, err := template.New("dolly"). 339 - ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html") 339 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 340 340 if err != nil { 341 - return fmt.Errorf("failed to read dolly silhouette template: %w", err) 341 + return fmt.Errorf("failed to read dolly template: %w", err) 342 342 } 343 343 344 344 var svgData bytes.Buffer 345 - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil { 346 - return fmt.Errorf("failed to execute dolly silhouette template: %w", err) 345 + if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { 346 + return fmt.Errorf("failed to execute dolly template: %w", err) 347 347 } 348 348 349 349 icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) ··· 453 453 454 454 // Handle SVG separately 455 455 if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 - return c.convertSVGToPNG(bodyBytes) 456 + return convertSVGToPNG(bodyBytes) 457 457 } 458 458 459 459 // Support content types are in-sync with the allowed custom avatar file types ··· 493 493 } 494 494 495 495 // convertSVGToPNG converts SVG data to a PNG image 496 - func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 496 + func convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 497 // Parse the SVG 498 498 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 499 if err != nil { ··· 547 547 draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 548 549 549 // Draw the image with circular clipping 550 - for cy := 0; cy < size; cy++ { 551 - for cx := 0; cx < size; cx++ { 550 + for cy := range size { 551 + for cx := range size { 552 552 // Calculate distance from center 553 553 dx := float64(cx - center) 554 554 dy := float64(cy - center)
+30 -9
appview/pages/funcmap.go
··· 26 26 "github.com/go-enry/go-enry/v2" 27 27 "github.com/yuin/goldmark" 28 28 emoji "github.com/yuin/goldmark-emoji" 29 - "tangled.org/core/appview/filetree" 30 29 "tangled.org/core/appview/models" 30 + "tangled.org/core/appview/oauth" 31 31 "tangled.org/core/appview/pages/markup" 32 32 "tangled.org/core/crypto" 33 33 ) ··· 334 334 }, 335 335 "deref": func(v any) any { 336 336 val := reflect.ValueOf(v) 337 - if val.Kind() == reflect.Ptr && !val.IsNil() { 337 + if val.Kind() == reflect.Pointer && !val.IsNil() { 338 338 return val.Elem().Interface() 339 339 } 340 340 return nil ··· 348 348 return template.HTML(data) 349 349 }, 350 350 "cssContentHash": p.CssContentHash, 351 - "fileTree": filetree.FileTree, 352 351 "pathEscape": func(s string) string { 353 352 return url.PathEscape(s) 354 353 }, ··· 366 365 return p.AvatarUrl(handle, "") 367 366 }, 368 367 "langColor": enry.GetColor, 369 - "layoutSide": func() string { 370 - return "col-span-1 md:col-span-2 lg:col-span-3" 368 + "reverse": func(s any) any { 369 + if s == nil { 370 + return nil 371 + } 372 + 373 + v := reflect.ValueOf(s) 374 + 375 + if v.Kind() != reflect.Slice { 376 + return s 377 + } 378 + 379 + length := v.Len() 380 + reversed := reflect.MakeSlice(v.Type(), length, length) 381 + 382 + for i := range length { 383 + reversed.Index(i).Set(v.Index(length - 1 - i)) 384 + } 385 + 386 + return reversed.Interface() 371 387 }, 372 - "layoutCenter": func() string { 373 - return "col-span-1 md:col-span-8 lg:col-span-6" 374 - }, 375 - 376 388 "normalizeForHtmlId": func(s string) string { 377 389 normalized := strings.ReplaceAll(s, ":", "_") 378 390 normalized = strings.ReplaceAll(normalized, ".", "_") ··· 384 396 return "error" 385 397 } 386 398 return fp 399 + }, 400 + "otherAccounts": func(activeDid string, accounts []oauth.AccountInfo) []oauth.AccountInfo { 401 + result := make([]oauth.AccountInfo, 0, len(accounts)) 402 + for _, acc := range accounts { 403 + if acc.Did != activeDid { 404 + result = append(result, acc) 405 + } 406 + } 407 + return result 387 408 }, 388 409 } 389 410 }
+93 -71
appview/pages/pages.go
··· 210 210 return tpl.ExecuteTemplate(w, "layouts/base", params) 211 211 } 212 212 213 + type DollyParams struct { 214 + Classes string 215 + FillColor string 216 + } 217 + 218 + func (p *Pages) Dolly(w io.Writer, params DollyParams) error { 219 + return p.executePlain("fragments/dolly/logo", w, params) 220 + } 221 + 213 222 func (p *Pages) Favicon(w io.Writer) error { 214 - return p.executePlain("fragments/dolly/silhouette", w, nil) 223 + return p.Dolly(w, DollyParams{ 224 + Classes: "text-black dark:text-white", 225 + }) 215 226 } 216 227 217 228 type LoginParams struct { 218 - ReturnUrl string 219 - ErrorCode string 229 + ReturnUrl string 230 + ErrorCode string 231 + AddAccount bool 232 + LoggedInUser *oauth.MultiAccountUser 220 233 } 221 234 222 235 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 236 249 } 237 250 238 251 type TermsOfServiceParams struct { 239 - LoggedInUser *oauth.User 252 + LoggedInUser *oauth.MultiAccountUser 240 253 Content template.HTML 241 254 } 242 255 ··· 264 277 } 265 278 266 279 type PrivacyPolicyParams struct { 267 - LoggedInUser *oauth.User 280 + LoggedInUser *oauth.MultiAccountUser 268 281 Content template.HTML 269 282 } 270 283 ··· 292 305 } 293 306 294 307 type BrandParams struct { 295 - LoggedInUser *oauth.User 308 + LoggedInUser *oauth.MultiAccountUser 296 309 } 297 310 298 311 func (p *Pages) Brand(w io.Writer, params BrandParams) error { ··· 300 313 } 301 314 302 315 type TimelineParams struct { 303 - LoggedInUser *oauth.User 316 + LoggedInUser *oauth.MultiAccountUser 304 317 Timeline []models.TimelineEvent 305 318 Repos []models.Repo 306 319 GfiLabel *models.LabelDefinition ··· 311 324 } 312 325 313 326 type GoodFirstIssuesParams struct { 314 - LoggedInUser *oauth.User 327 + LoggedInUser *oauth.MultiAccountUser 315 328 Issues []models.Issue 316 329 RepoGroups []*models.RepoGroup 317 330 LabelDefs map[string]*models.LabelDefinition ··· 324 337 } 325 338 326 339 type UserProfileSettingsParams struct { 327 - LoggedInUser *oauth.User 340 + LoggedInUser *oauth.MultiAccountUser 328 341 Tabs []map[string]any 329 342 Tab string 330 343 } ··· 334 347 } 335 348 336 349 type NotificationsParams struct { 337 - LoggedInUser *oauth.User 350 + LoggedInUser *oauth.MultiAccountUser 338 351 Notifications []*models.NotificationWithEntity 339 352 UnreadCount int 340 353 Page pagination.Page ··· 362 375 } 363 376 364 377 type UserKeysSettingsParams struct { 365 - LoggedInUser *oauth.User 378 + LoggedInUser *oauth.MultiAccountUser 366 379 PubKeys []models.PublicKey 367 380 Tabs []map[string]any 368 381 Tab string ··· 373 386 } 374 387 375 388 type UserEmailsSettingsParams struct { 376 - LoggedInUser *oauth.User 389 + LoggedInUser *oauth.MultiAccountUser 377 390 Emails []models.Email 378 391 Tabs []map[string]any 379 392 Tab string ··· 384 397 } 385 398 386 399 type UserNotificationSettingsParams struct { 387 - LoggedInUser *oauth.User 400 + LoggedInUser *oauth.MultiAccountUser 388 401 Preferences *models.NotificationPreferences 389 402 Tabs []map[string]any 390 403 Tab string ··· 404 417 } 405 418 406 419 type KnotsParams struct { 407 - LoggedInUser *oauth.User 420 + LoggedInUser *oauth.MultiAccountUser 408 421 Registrations []models.Registration 409 422 Tabs []map[string]any 410 423 Tab string ··· 415 428 } 416 429 417 430 type KnotParams struct { 418 - LoggedInUser *oauth.User 431 + LoggedInUser *oauth.MultiAccountUser 419 432 Registration *models.Registration 420 433 Members []string 421 434 Repos map[string][]models.Repo ··· 437 450 } 438 451 439 452 type SpindlesParams struct { 440 - LoggedInUser *oauth.User 453 + LoggedInUser *oauth.MultiAccountUser 441 454 Spindles []models.Spindle 442 455 Tabs []map[string]any 443 456 Tab string ··· 458 471 } 459 472 460 473 type SpindleDashboardParams struct { 461 - LoggedInUser *oauth.User 474 + LoggedInUser *oauth.MultiAccountUser 462 475 Spindle models.Spindle 463 476 Members []string 464 477 Repos map[string][]models.Repo ··· 471 484 } 472 485 473 486 type NewRepoParams struct { 474 - LoggedInUser *oauth.User 487 + LoggedInUser *oauth.MultiAccountUser 475 488 Knots []string 476 489 } 477 490 ··· 480 493 } 481 494 482 495 type ForkRepoParams struct { 483 - LoggedInUser *oauth.User 496 + LoggedInUser *oauth.MultiAccountUser 484 497 Knots []string 485 498 RepoInfo repoinfo.RepoInfo 486 499 } ··· 518 531 } 519 532 520 533 type ProfileOverviewParams struct { 521 - LoggedInUser *oauth.User 534 + LoggedInUser *oauth.MultiAccountUser 522 535 Repos []models.Repo 523 536 CollaboratingRepos []models.Repo 524 537 ProfileTimeline *models.ProfileTimeline ··· 532 545 } 533 546 534 547 type ProfileReposParams struct { 535 - LoggedInUser *oauth.User 548 + LoggedInUser *oauth.MultiAccountUser 536 549 Repos []models.Repo 537 550 Card *ProfileCard 538 551 Active string ··· 544 557 } 545 558 546 559 type ProfileStarredParams struct { 547 - LoggedInUser *oauth.User 560 + LoggedInUser *oauth.MultiAccountUser 548 561 Repos []models.Repo 549 562 Card *ProfileCard 550 563 Active string ··· 556 569 } 557 570 558 571 type ProfileStringsParams struct { 559 - LoggedInUser *oauth.User 572 + LoggedInUser *oauth.MultiAccountUser 560 573 Strings []models.String 561 574 Card *ProfileCard 562 575 Active string ··· 569 582 570 583 type FollowCard struct { 571 584 UserDid string 572 - LoggedInUser *oauth.User 585 + LoggedInUser *oauth.MultiAccountUser 573 586 FollowStatus models.FollowStatus 574 587 FollowersCount int64 575 588 FollowingCount int64 ··· 577 590 } 578 591 579 592 type ProfileFollowersParams struct { 580 - LoggedInUser *oauth.User 593 + LoggedInUser *oauth.MultiAccountUser 581 594 Followers []FollowCard 582 595 Card *ProfileCard 583 596 Active string ··· 589 602 } 590 603 591 604 type ProfileFollowingParams struct { 592 - LoggedInUser *oauth.User 605 + LoggedInUser *oauth.MultiAccountUser 593 606 Following []FollowCard 594 607 Card *ProfileCard 595 608 Active string ··· 601 614 } 602 615 603 616 type FollowFragmentParams struct { 604 - UserDid string 605 - FollowStatus models.FollowStatus 617 + UserDid string 618 + FollowStatus models.FollowStatus 619 + FollowersCount int64 606 620 } 607 621 608 622 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 609 - return p.executePlain("user/fragments/follow", w, params) 623 + return p.executePlain("user/fragments/follow-oob", w, params) 610 624 } 611 625 612 626 type EditBioParams struct { 613 - LoggedInUser *oauth.User 627 + LoggedInUser *oauth.MultiAccountUser 614 628 Profile *models.Profile 615 629 } 616 630 ··· 619 633 } 620 634 621 635 type EditPinsParams struct { 622 - LoggedInUser *oauth.User 636 + LoggedInUser *oauth.MultiAccountUser 623 637 Profile *models.Profile 624 638 AllRepos []PinnedRepo 625 639 } ··· 637 651 IsStarred bool 638 652 SubjectAt syntax.ATURI 639 653 StarCount int 654 + HxSwapOob bool 640 655 } 641 656 642 657 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 - return p.executePlain("fragments/starBtn-oob", w, params) 658 + params.HxSwapOob = true 659 + return p.executePlain("fragments/starBtn", w, params) 644 660 } 645 661 646 662 type RepoIndexParams struct { 647 - LoggedInUser *oauth.User 663 + LoggedInUser *oauth.MultiAccountUser 648 664 RepoInfo repoinfo.RepoInfo 649 665 Active string 650 666 TagMap map[string][]string ··· 693 709 } 694 710 695 711 type RepoLogParams struct { 696 - LoggedInUser *oauth.User 712 + LoggedInUser *oauth.MultiAccountUser 697 713 RepoInfo repoinfo.RepoInfo 698 714 TagMap map[string][]string 699 715 Active string ··· 710 726 } 711 727 712 728 type RepoCommitParams struct { 713 - LoggedInUser *oauth.User 729 + LoggedInUser *oauth.MultiAccountUser 714 730 RepoInfo repoinfo.RepoInfo 715 731 Active string 716 732 EmailToDid map[string]string ··· 729 745 } 730 746 731 747 type RepoTreeParams struct { 732 - LoggedInUser *oauth.User 748 + LoggedInUser *oauth.MultiAccountUser 733 749 RepoInfo repoinfo.RepoInfo 734 750 Active string 735 751 BreadCrumbs [][]string ··· 784 800 } 785 801 786 802 type RepoBranchesParams struct { 787 - LoggedInUser *oauth.User 803 + LoggedInUser *oauth.MultiAccountUser 788 804 RepoInfo repoinfo.RepoInfo 789 805 Active string 790 806 types.RepoBranchesResponse ··· 796 812 } 797 813 798 814 type RepoTagsParams struct { 799 - LoggedInUser *oauth.User 815 + LoggedInUser *oauth.MultiAccountUser 800 816 RepoInfo repoinfo.RepoInfo 801 817 Active string 802 818 types.RepoTagsResponse ··· 810 826 } 811 827 812 828 type RepoArtifactParams struct { 813 - LoggedInUser *oauth.User 829 + LoggedInUser *oauth.MultiAccountUser 814 830 RepoInfo repoinfo.RepoInfo 815 831 Artifact models.Artifact 816 832 } ··· 820 836 } 821 837 822 838 type RepoBlobParams struct { 823 - LoggedInUser *oauth.User 839 + LoggedInUser *oauth.MultiAccountUser 824 840 RepoInfo repoinfo.RepoInfo 825 841 Active string 826 842 BreadCrumbs [][]string ··· 844 860 } 845 861 846 862 type RepoSettingsParams struct { 847 - LoggedInUser *oauth.User 863 + LoggedInUser *oauth.MultiAccountUser 848 864 RepoInfo repoinfo.RepoInfo 849 865 Collaborators []Collaborator 850 866 Active string ··· 863 879 } 864 880 865 881 type RepoGeneralSettingsParams struct { 866 - LoggedInUser *oauth.User 882 + LoggedInUser *oauth.MultiAccountUser 867 883 RepoInfo repoinfo.RepoInfo 868 884 Labels []models.LabelDefinition 869 885 DefaultLabels []models.LabelDefinition ··· 881 897 } 882 898 883 899 type RepoAccessSettingsParams struct { 884 - LoggedInUser *oauth.User 900 + LoggedInUser *oauth.MultiAccountUser 885 901 RepoInfo repoinfo.RepoInfo 886 902 Active string 887 903 Tabs []map[string]any ··· 895 911 } 896 912 897 913 type RepoPipelineSettingsParams struct { 898 - LoggedInUser *oauth.User 914 + LoggedInUser *oauth.MultiAccountUser 899 915 RepoInfo repoinfo.RepoInfo 900 916 Active string 901 917 Tabs []map[string]any ··· 911 927 } 912 928 913 929 type RepoIssuesParams struct { 914 - LoggedInUser *oauth.User 930 + LoggedInUser *oauth.MultiAccountUser 915 931 RepoInfo repoinfo.RepoInfo 916 932 Active string 917 933 Issues []models.Issue ··· 928 944 } 929 945 930 946 type RepoSingleIssueParams struct { 931 - LoggedInUser *oauth.User 947 + LoggedInUser *oauth.MultiAccountUser 932 948 RepoInfo repoinfo.RepoInfo 933 949 Active string 934 950 Issue *models.Issue ··· 947 963 } 948 964 949 965 type EditIssueParams struct { 950 - LoggedInUser *oauth.User 966 + LoggedInUser *oauth.MultiAccountUser 951 967 RepoInfo repoinfo.RepoInfo 952 968 Issue *models.Issue 953 969 Action string ··· 971 987 } 972 988 973 989 type RepoNewIssueParams struct { 974 - LoggedInUser *oauth.User 990 + LoggedInUser *oauth.MultiAccountUser 975 991 RepoInfo repoinfo.RepoInfo 976 992 Issue *models.Issue // existing issue if any -- passed when editing 977 993 Active string ··· 985 1001 } 986 1002 987 1003 type EditIssueCommentParams struct { 988 - LoggedInUser *oauth.User 1004 + LoggedInUser *oauth.MultiAccountUser 989 1005 RepoInfo repoinfo.RepoInfo 990 1006 Issue *models.Issue 991 1007 Comment *models.IssueComment ··· 996 1012 } 997 1013 998 1014 type ReplyIssueCommentPlaceholderParams struct { 999 - LoggedInUser *oauth.User 1015 + LoggedInUser *oauth.MultiAccountUser 1000 1016 RepoInfo repoinfo.RepoInfo 1001 1017 Issue *models.Issue 1002 1018 Comment *models.IssueComment ··· 1007 1023 } 1008 1024 1009 1025 type ReplyIssueCommentParams struct { 1010 - LoggedInUser *oauth.User 1026 + LoggedInUser *oauth.MultiAccountUser 1011 1027 RepoInfo repoinfo.RepoInfo 1012 1028 Issue *models.Issue 1013 1029 Comment *models.IssueComment ··· 1018 1034 } 1019 1035 1020 1036 type IssueCommentBodyParams struct { 1021 - LoggedInUser *oauth.User 1037 + LoggedInUser *oauth.MultiAccountUser 1022 1038 RepoInfo repoinfo.RepoInfo 1023 1039 Issue *models.Issue 1024 1040 Comment *models.IssueComment ··· 1029 1045 } 1030 1046 1031 1047 type RepoNewPullParams struct { 1032 - LoggedInUser *oauth.User 1048 + LoggedInUser *oauth.MultiAccountUser 1033 1049 RepoInfo repoinfo.RepoInfo 1034 1050 Branches []types.Branch 1035 1051 Strategy string ··· 1046 1062 } 1047 1063 1048 1064 type RepoPullsParams struct { 1049 - LoggedInUser *oauth.User 1065 + LoggedInUser *oauth.MultiAccountUser 1050 1066 RepoInfo repoinfo.RepoInfo 1051 1067 Pulls []*models.Pull 1052 1068 Active string ··· 1055 1071 Stacks map[string]models.Stack 1056 1072 Pipelines map[string]models.Pipeline 1057 1073 LabelDefs map[string]*models.LabelDefinition 1074 + Page pagination.Page 1075 + PullCount int 1058 1076 } 1059 1077 1060 1078 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1081 1099 } 1082 1100 1083 1101 type RepoSinglePullParams struct { 1084 - LoggedInUser *oauth.User 1102 + LoggedInUser *oauth.MultiAccountUser 1085 1103 RepoInfo repoinfo.RepoInfo 1086 1104 Active string 1087 1105 Pull *models.Pull ··· 1092 1110 MergeCheck types.MergeCheckResponse 1093 1111 ResubmitCheck ResubmitResult 1094 1112 Pipelines map[string]models.Pipeline 1113 + Diff types.DiffRenderer 1114 + DiffOpts types.DiffOpts 1115 + ActiveRound int 1116 + IsInterdiff bool 1095 1117 1096 1118 OrderedReactionKinds []models.ReactionKind 1097 1119 Reactions map[models.ReactionKind]models.ReactionDisplayData ··· 1106 1128 } 1107 1129 1108 1130 type RepoPullPatchParams struct { 1109 - LoggedInUser *oauth.User 1131 + LoggedInUser *oauth.MultiAccountUser 1110 1132 RepoInfo repoinfo.RepoInfo 1111 1133 Pull *models.Pull 1112 1134 Stack models.Stack ··· 1123 1145 } 1124 1146 1125 1147 type RepoPullInterdiffParams struct { 1126 - LoggedInUser *oauth.User 1148 + LoggedInUser *oauth.MultiAccountUser 1127 1149 RepoInfo repoinfo.RepoInfo 1128 1150 Pull *models.Pull 1129 1151 Round int ··· 1176 1198 } 1177 1199 1178 1200 type PullResubmitParams struct { 1179 - LoggedInUser *oauth.User 1201 + LoggedInUser *oauth.MultiAccountUser 1180 1202 RepoInfo repoinfo.RepoInfo 1181 1203 Pull *models.Pull 1182 1204 SubmissionId int ··· 1187 1209 } 1188 1210 1189 1211 type PullActionsParams struct { 1190 - LoggedInUser *oauth.User 1212 + LoggedInUser *oauth.MultiAccountUser 1191 1213 RepoInfo repoinfo.RepoInfo 1192 1214 Pull *models.Pull 1193 1215 RoundNumber int ··· 1202 1224 } 1203 1225 1204 1226 type PullNewCommentParams struct { 1205 - LoggedInUser *oauth.User 1227 + LoggedInUser *oauth.MultiAccountUser 1206 1228 RepoInfo repoinfo.RepoInfo 1207 1229 Pull *models.Pull 1208 1230 RoundNumber int ··· 1213 1235 } 1214 1236 1215 1237 type RepoCompareParams struct { 1216 - LoggedInUser *oauth.User 1238 + LoggedInUser *oauth.MultiAccountUser 1217 1239 RepoInfo repoinfo.RepoInfo 1218 1240 Forks []models.Repo 1219 1241 Branches []types.Branch ··· 1232 1254 } 1233 1255 1234 1256 type RepoCompareNewParams struct { 1235 - LoggedInUser *oauth.User 1257 + LoggedInUser *oauth.MultiAccountUser 1236 1258 RepoInfo repoinfo.RepoInfo 1237 1259 Forks []models.Repo 1238 1260 Branches []types.Branch ··· 1249 1271 } 1250 1272 1251 1273 type RepoCompareAllowPullParams struct { 1252 - LoggedInUser *oauth.User 1274 + LoggedInUser *oauth.MultiAccountUser 1253 1275 RepoInfo repoinfo.RepoInfo 1254 1276 Base string 1255 1277 Head string ··· 1269 1291 } 1270 1292 1271 1293 type LabelPanelParams struct { 1272 - LoggedInUser *oauth.User 1294 + LoggedInUser *oauth.MultiAccountUser 1273 1295 RepoInfo repoinfo.RepoInfo 1274 1296 Defs map[string]*models.LabelDefinition 1275 1297 Subject string ··· 1281 1303 } 1282 1304 1283 1305 type EditLabelPanelParams struct { 1284 - LoggedInUser *oauth.User 1306 + LoggedInUser *oauth.MultiAccountUser 1285 1307 RepoInfo repoinfo.RepoInfo 1286 1308 Defs map[string]*models.LabelDefinition 1287 1309 Subject string ··· 1293 1315 } 1294 1316 1295 1317 type PipelinesParams struct { 1296 - LoggedInUser *oauth.User 1318 + LoggedInUser *oauth.MultiAccountUser 1297 1319 RepoInfo repoinfo.RepoInfo 1298 1320 Pipelines []models.Pipeline 1299 1321 Active string ··· 1336 1358 } 1337 1359 1338 1360 type WorkflowParams struct { 1339 - LoggedInUser *oauth.User 1361 + LoggedInUser *oauth.MultiAccountUser 1340 1362 RepoInfo repoinfo.RepoInfo 1341 1363 Pipeline models.Pipeline 1342 1364 Workflow string ··· 1350 1372 } 1351 1373 1352 1374 type PutStringParams struct { 1353 - LoggedInUser *oauth.User 1375 + LoggedInUser *oauth.MultiAccountUser 1354 1376 Action string 1355 1377 1356 1378 // this is supplied in the case of editing an existing string ··· 1362 1384 } 1363 1385 1364 1386 type StringsDashboardParams struct { 1365 - LoggedInUser *oauth.User 1387 + LoggedInUser *oauth.MultiAccountUser 1366 1388 Card ProfileCard 1367 1389 Strings []models.String 1368 1390 } ··· 1372 1394 } 1373 1395 1374 1396 type StringTimelineParams struct { 1375 - LoggedInUser *oauth.User 1397 + LoggedInUser *oauth.MultiAccountUser 1376 1398 Strings []models.String 1377 1399 } 1378 1400 ··· 1381 1403 } 1382 1404 1383 1405 type SingleStringParams struct { 1384 - LoggedInUser *oauth.User 1406 + LoggedInUser *oauth.MultiAccountUser 1385 1407 ShowRendered bool 1386 1408 RenderToggle bool 1387 1409 RenderedContents template.HTML
+9 -29
appview/pages/templates/brand/brand.html
··· 4 4 <div class="grid grid-cols-10"> 5 5 <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 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"> 7 + <p class="text-gray-500 dark:text-gray-300 mb-1"> 8 8 Assets and guidelines for using Tangled's logo and brand elements. 9 9 </p> 10 10 </header> ··· 14 14 15 15 <!-- Introduction Section --> 16 16 <section> 17 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 18 18 Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 19 follow the below guidelines when using Dolly and the logotype. 20 20 </p> 21 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 21 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 22 22 All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 23 </p> 24 24 </section> ··· 34 34 </div> 35 35 <div class="order-1 lg:order-2"> 36 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> 37 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p> 38 38 <p class="text-gray-700 dark:text-gray-300"> 39 39 This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 40 backgrounds and designs. ··· 53 53 </div> 54 54 <div class="order-1 lg:order-2"> 55 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> 56 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p> 57 57 <p class="text-gray-700 dark:text-gray-300"> 58 58 This version features white text and elements, ideal for dark backgrounds 59 59 and inverted designs. ··· 81 81 </div> 82 82 <div class="order-1 lg:order-2"> 83 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"> 84 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 85 85 When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 86 </p> 87 87 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 123 123 </div> 124 124 <div class="order-1 lg:order-2"> 125 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"> 126 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 127 127 White logo mark on colored backgrounds. 128 128 </p> 129 129 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 165 165 </div> 166 166 <div class="order-1 lg:order-2"> 167 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"> 168 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 169 169 Dark logo mark on lighter, pastel backgrounds. 170 170 </p> 171 171 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 186 186 </div> 187 187 <div class="order-1 lg:order-2"> 188 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"> 189 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 190 190 Custom coloring of the logotype is permitted. 191 191 </p> 192 192 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 194 194 </p> 195 195 <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 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 197 </p> 218 198 </div> 219 199 </section>
+14 -2
appview/pages/templates/fragments/dolly/logo.html
··· 2 2 <svg 3 3 version="1.1" 4 4 id="svg1" 5 - class="{{ . }}" 5 + class="{{ .Classes }}" 6 6 width="25" 7 7 height="25" 8 8 viewBox="0 0 25 25" ··· 17 17 xmlns:svg="http://www.w3.org/2000/svg" 18 18 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 19 xmlns:cc="http://creativecommons.org/ns#"> 20 + <style> 21 + .dolly { 22 + color: #000000; 23 + } 24 + 25 + @media (prefers-color-scheme: dark) { 26 + .dolly { 27 + color: #ffffff; 28 + } 29 + } 30 + </style> 20 31 <sodipodi:namedview 21 32 id="namedview1" 22 33 pagecolor="#ffffff" ··· 51 62 id="g1" 52 63 transform="translate(-0.42924038,-0.87777209)"> 53 64 <path 54 - fill="currentColor" 65 + class="dolly" 66 + fill="{{ or .FillColor "currentColor" }}" 55 67 style="stroke-width:0.111183;" 56 68 d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 57 69 id="path4"
-95
appview/pages/templates/fragments/dolly/silhouette.html
··· 1 - {{ define "fragments/dolly/silhouette" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - width="25" 6 - height="25" 7 - viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 9 - inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg" 10 - inkscape:export-xdpi="96" 11 - inkscape:export-ydpi="96" 12 - inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 13 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 14 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 15 - xmlns="http://www.w3.org/2000/svg" 16 - xmlns:svg="http://www.w3.org/2000/svg" 17 - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 18 - xmlns:cc="http://creativecommons.org/ns#"> 19 - <style> 20 - .dolly { 21 - color: #000000; 22 - } 23 - 24 - @media (prefers-color-scheme: dark) { 25 - .dolly { 26 - color: #ffffff; 27 - } 28 - } 29 - </style> 30 - <sodipodi:namedview 31 - id="namedview1" 32 - pagecolor="#ffffff" 33 - bordercolor="#000000" 34 - borderopacity="0.25" 35 - inkscape:showpageshadow="2" 36 - inkscape:pageopacity="0.0" 37 - inkscape:pagecheckerboard="true" 38 - inkscape:deskcolor="#d5d5d5" 39 - inkscape:zoom="64" 40 - inkscape:cx="4.96875" 41 - inkscape:cy="13.429688" 42 - inkscape:window-width="3840" 43 - inkscape:window-height="2160" 44 - inkscape:window-x="0" 45 - inkscape:window-y="0" 46 - inkscape:window-maximized="0" 47 - inkscape:current-layer="g1" 48 - borderlayer="true"> 49 - <inkscape:page 50 - x="0" 51 - y="0" 52 - width="25" 53 - height="25" 54 - id="page2" 55 - margin="0" 56 - bleed="0" /> 57 - </sodipodi:namedview> 58 - <g 59 - inkscape:groupmode="layer" 60 - inkscape:label="Image" 61 - id="g1" 62 - transform="translate(-0.42924038,-0.87777209)"> 63 - <path 64 - class="dolly" 65 - fill="currentColor" 66 - style="stroke-width:0.111183" 67 - d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z" 68 - id="path7" 69 - sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" /> 70 - </g> 71 - <metadata 72 - id="metadata1"> 73 - <rdf:RDF> 74 - <cc:Work 75 - rdf:about=""> 76 - <cc:license 77 - rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 78 - </cc:Work> 79 - <cc:License 80 - rdf:about="http://creativecommons.org/licenses/by/4.0/"> 81 - <cc:permits 82 - rdf:resource="http://creativecommons.org/ns#Reproduction" /> 83 - <cc:permits 84 - rdf:resource="http://creativecommons.org/ns#Distribution" /> 85 - <cc:requires 86 - rdf:resource="http://creativecommons.org/ns#Notice" /> 87 - <cc:requires 88 - rdf:resource="http://creativecommons.org/ns#Attribution" /> 89 - <cc:permits 90 - rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 91 - </cc:License> 92 - </rdf:RDF> 93 - </metadata> 94 - </svg> 95 - {{ end }}
+1 -1
appview/pages/templates/fragments/logotype.html
··· 1 1 {{ define "fragments/logotype" }} 2 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }} 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }} 4 4 <span class="font-bold text-4xl not-italic">tangled</span> 5 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 6 alpha
+1 -1
appview/pages/templates/fragments/logotypeSmall.html
··· 1 1 {{ define "fragments/logotypeSmall" }} 2 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}} 4 4 <span class="font-bold text-xl not-italic">tangled</span> 5 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 6 alpha
+95
appview/pages/templates/fragments/pagination.html
··· 1 + {{ define "fragments/pagination" }} 2 + {{/* Params: Page (pagination.Page), TotalCount (int), BasePath (string), QueryParams (string) */}} 3 + {{ $page := .Page }} 4 + {{ $totalCount := .TotalCount }} 5 + {{ $basePath := .BasePath }} 6 + {{ $queryParams := .QueryParams }} 7 + 8 + {{ $prev := $page.Previous.Offset }} 9 + {{ $next := $page.Next.Offset }} 10 + {{ $lastPage := sub $totalCount (mod $totalCount $page.Limit) }} 11 + 12 + <div class="flex justify-center items-center mt-4 gap-2"> 13 + <a 14 + class=" 15 + btn flex items-center gap-2 no-underline hover:no-underline 16 + dark:text-white dark:hover:bg-gray-700 17 + {{ if le $page.Offset 0 }} 18 + cursor-not-allowed opacity-50 19 + {{ end }} 20 + " 21 + {{ if gt $page.Offset 0 }} 22 + hx-boost="true" 23 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $prev }}&limit={{ $page.Limit }}" 24 + {{ end }} 25 + > 26 + {{ i "chevron-left" "w-4 h-4" }} 27 + previous 28 + </a> 29 + 30 + {{ if gt $page.Offset 0 }} 31 + <a 32 + hx-boost="true" 33 + href="{{ $basePath }}?{{ $queryParams }}&offset=0&limit={{ $page.Limit }}" 34 + > 35 + 1 36 + </a> 37 + {{ end }} 38 + 39 + {{ if gt $prev $page.Limit }} 40 + <span>...</span> 41 + {{ end }} 42 + 43 + {{ if gt $prev 0 }} 44 + <a 45 + hx-boost="true" 46 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $prev }}&limit={{ $page.Limit }}" 47 + > 48 + {{ add (div $prev $page.Limit) 1 }} 49 + </a> 50 + {{ end }} 51 + 52 + <span class="font-bold"> 53 + {{ add (div $page.Offset $page.Limit) 1 }} 54 + </span> 55 + 56 + {{ if lt $next $lastPage }} 57 + <a 58 + hx-boost="true" 59 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $next }}&limit={{ $page.Limit }}" 60 + > 61 + {{ add (div $next $page.Limit) 1 }} 62 + </a> 63 + {{ end }} 64 + 65 + {{ if lt $next (sub $totalCount (mul 2 $page.Limit)) }} 66 + <span>...</span> 67 + {{ end }} 68 + 69 + {{ if lt $page.Offset $lastPage }} 70 + <a 71 + hx-boost="true" 72 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $lastPage }}&limit={{ $page.Limit }}" 73 + > 74 + {{ add (div $lastPage $page.Limit) 1 }} 75 + </a> 76 + {{ end }} 77 + 78 + <a 79 + class=" 80 + btn flex items-center gap-2 no-underline hover:no-underline 81 + dark:text-white dark:hover:bg-gray-700 82 + {{ if lt $next $totalCount | not }} 83 + cursor-not-allowed opacity-50 84 + {{ end }} 85 + " 86 + {{ if lt $next $totalCount }} 87 + hx-boost="true" 88 + href="{{ $basePath }}?{{ $queryParams }}&offset={{ $next }}&limit={{ $page.Limit }}" 89 + {{ end }} 90 + > 91 + next 92 + {{ i "chevron-right" "w-4 h-4" }} 93 + </a> 94 + </div> 95 + {{ end }}
-5
appview/pages/templates/fragments/starBtn-oob.html
··· 1 - {{ define "fragments/starBtn-oob" }} 2 - <div hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'> 3 - {{ template "fragments/starBtn" . }} 4 - </div> 5 - {{ end }}
+1
appview/pages/templates/fragments/starBtn.html
··· 9 9 {{ else }} 10 10 hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 11 11 {{ end }} 12 + {{ if .HxSwapOob }}hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'{{ end }} 12 13 13 14 hx-trigger="click" 14 15 hx-disabled-elt="#starBtn"
+7 -5
appview/pages/templates/fragments/tinyAvatarList.html
··· 5 5 <div class="inline-flex items-center -space-x-3"> 6 6 {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 7 {{ range $i, $p := $ps }} 8 - <img 9 - src="{{ tinyAvatar . }}" 10 - alt="" 11 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 12 - /> 8 + <a href="/{{ resolve . }}" title="{{ resolve . }}"> 9 + <img 10 + src="{{ tinyAvatar . }}" 11 + alt="" 12 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 13 + /> 14 + </a> 13 15 {{ end }} 14 16 15 17 {{ if gt (len $all) 5 }}
+30 -17
appview/pages/templates/labels/fragments/label.html
··· 2 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 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 5 8 - {{ $lhs := printf "%s" $d.Name }} 9 - {{ $rhs := "" }} 6 + {{ $lhs := printf "%s" $d.Name }} 7 + {{ $rhs := "" }} 8 + {{ $isDid := false }} 9 + {{ $resolvedVal := "" }} 10 10 11 - {{ if not $d.ValueType.IsNull }} 12 - {{ if $d.ValueType.IsDidFormat }} 13 - {{ $v = resolve $v }} 14 - {{ end }} 11 + {{ if not $d.ValueType.IsNull }} 12 + {{ $isDid = $d.ValueType.IsDidFormat }} 13 + {{ if $isDid }} 14 + {{ $resolvedVal = resolve $v }} 15 + {{ $v = $resolvedVal }} 16 + {{ end }} 17 + 18 + {{ if not $withPrefix }} 19 + {{ $lhs = "" }} 20 + {{ else }} 21 + {{ $lhs = printf "%s/" $d.Name }} 22 + {{ end }} 15 23 16 - {{ if not $withPrefix }} 17 - {{ $lhs = "" }} 18 - {{ else }} 19 - {{ $lhs = printf "%s/" $d.Name }} 20 - {{ end }} 24 + {{ $rhs = printf "%s" $v }} 25 + {{ end }} 21 26 22 - {{ $rhs = printf "%s" $v }} 23 - {{ end }} 27 + {{ $chipClasses := "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" }} 24 28 25 - {{ printf "%s%s" $lhs $rhs }} 26 - </span> 29 + {{ if $isDid }} 30 + <a href="/{{ $resolvedVal }}" class="{{ $chipClasses }} no-underline hover:underline"> 31 + {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 32 + {{ printf "%s%s" $lhs $rhs }} 33 + </a> 34 + {{ else }} 35 + <span class="{{ $chipClasses }}"> 36 + {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 37 + {{ printf "%s%s" $lhs $rhs }} 38 + </span> 39 + {{ end }} 27 40 {{ end }} 28 41 29 42
+4
appview/pages/templates/layouts/base.html
··· 11 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 12 <script defer src="/static/actor-typeahead.js" type="module"></script> 13 13 14 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 15 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 16 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 17 + 14 18 <!-- preconnect to image cdn --> 15 19 <link rel="preconnect" href="https://avatar.tangled.sh" /> 16 20 <link rel="preconnect" href="https://camo.tangled.sh" />
+2 -2
appview/pages/templates/layouts/fragments/footer.html
··· 47 47 48 48 <!-- Right section --> 49 49 <div class="text-right"> 50 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 50 + <div class="text-xs">&copy; 2026 Tangled Labs Oy. All rights reserved.</div> 51 51 </div> 52 52 </div> 53 53 ··· 93 93 </div> 94 94 95 95 <div class="text-center"> 96 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 96 + <div class="text-xs">&copy; 2026 Tangled Labs Oy. All rights reserved.</div> 97 97 </div> 98 98 </div> 99 99 </div>
+50 -16
appview/pages/templates/layouts/fragments/topbar.html
··· 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 - <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 - <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 - alpha 10 - </span> 6 + {{ template "fragments/logotypeSmall" }} 11 7 </a> 12 8 </div> 13 9 ··· 49 45 {{ define "profileDropdown" }} 50 46 <details class="relative inline-block text-left nav-dropdown"> 51 47 <summary class="cursor-pointer list-none flex items-center gap-1"> 52 - {{ $user := .Did }} 48 + {{ $user := .Active.Did }} 53 49 <img 54 50 src="{{ tinyAvatar $user }}" 55 51 alt="" ··· 57 53 /> 58 54 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 59 55 </summary> 60 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 61 - <a href="/{{ $user }}">profile</a> 62 - <a href="/{{ $user }}?tab=repos">repositories</a> 63 - <a href="/{{ $user }}?tab=strings">strings</a> 64 - <a href="/settings">settings</a> 65 - <a href="#" 66 - hx-post="/logout" 67 - hx-swap="none" 68 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 69 - logout 56 + <div class="absolute right-0 mt-4 p-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50" style="width: 14rem;"> 57 + {{ $active := .Active.Did }} 58 + 59 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 60 + <div class="flex items-center gap-2"> 61 + <img src="{{ tinyAvatar $active }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 62 + <div class="flex-1 overflow-hidden"> 63 + <p class="font-medium text-sm truncate">{{ $active | resolve }}</p> 64 + <p class="text-xs text-green-600 dark:text-green-400">active</p> 65 + </div> 66 + </div> 67 + </div> 68 + 69 + {{ $others := .Accounts | otherAccounts $active }} 70 + {{ if $others }} 71 + <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 72 + <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Switch Account</p> 73 + {{ range $others }} 74 + <button 75 + type="button" 76 + hx-post="/account/switch" 77 + hx-vals='{"did": "{{ .Did }}"}' 78 + hx-swap="none" 79 + class="flex items-center gap-2 w-full py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left" 80 + > 81 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-6 w-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 82 + <span class="text-sm truncate flex-1">{{ .Did | resolve }}</span> 83 + </button> 84 + {{ end }} 85 + </div> 86 + {{ end }} 87 + 88 + <a href="/login?mode=add_account" class="flex items-center gap-2 py-1 text-sm"> 89 + {{ i "plus" "w-4 h-4 flex-shrink-0" }} 90 + <span>Add another account</span> 70 91 </a> 92 + 93 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-1"> 94 + <a href="/{{ $active }}" class="block py-1 text-sm">profile</a> 95 + <a href="/{{ $active }}?tab=repos" class="block py-1 text-sm">repositories</a> 96 + <a href="/{{ $active }}?tab=strings" class="block py-1 text-sm">strings</a> 97 + <a href="/settings" class="block py-1 text-sm">settings</a> 98 + <a href="#" 99 + hx-post="/logout" 100 + hx-swap="none" 101 + class="block py-1 text-sm text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 102 + logout 103 + </a> 104 + </div> 71 105 </div> 72 106 </details> 73 107
+1 -1
appview/pages/templates/layouts/repobase.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 p-2 dark:text-white"> 4 + <section id="repo-header" class="mb-2 py-2 px-4 dark:text-white"> 5 5 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 6 <!-- left items --> 7 7 <div class="flex flex-col gap-2">
+1 -18
appview/pages/templates/repo/commit.html
··· 116 116 {{ block "content" . }}{{ end }} 117 117 {{ end }} 118 118 119 - {{ block "contentAfterLayout" . }} 120 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 121 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 122 - {{ block "contentAfterLeft" . }} {{ end }} 123 - </div> 124 - <main class="col-span-1 md:col-span-10"> 125 - {{ block "contentAfter" . }}{{ end }} 126 - </main> 127 - </div> 128 - {{ end }} 119 + {{ block "contentAfter" . }}{{ end }} 129 120 </div> 130 121 {{ end }} 131 122 ··· 139 130 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 140 131 {{end}} 141 132 142 - {{ define "contentAfterLeft" }} 143 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 144 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 145 - </div> 146 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 147 - {{ template "repo/fragments/diffChangedFiles" .Diff }} 148 - </div> 149 - {{end}}
+1 -19
appview/pages/templates/repo/compare/compare.html
··· 22 22 {{ block "content" . }}{{ end }} 23 23 {{ end }} 24 24 25 - {{ block "contentAfterLayout" . }} 26 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 - {{ block "contentAfterLeft" . }} {{ end }} 29 - </div> 30 - <main class="col-span-1 md:col-span-10"> 31 - {{ block "contentAfter" . }}{{ end }} 32 - </main> 33 - </div> 34 - {{ end }} 25 + {{ block "contentAfter" . }}{{ end }} 35 26 </div> 36 27 {{ end }} 37 28 ··· 44 35 {{ define "contentAfter" }} 45 36 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 46 37 {{end}} 47 - 48 - {{ define "contentAfterLeft" }} 49 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 - </div> 52 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 - {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 - </div> 55 - {{end}}
+81 -96
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.org" }} 5 - {{ end }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 + {{ end }} 6 6 7 - <details id="clone-dropdown" class="relative inline-block text-left group"> 8 - <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 - {{ i "download" "w-4 h-4" }} 10 - <span class="hidden md:inline">code</span> 11 - <span class="group-open:hidden"> 12 - {{ i "chevron-down" "w-4 h-4" }} 13 - </span> 14 - <span class="hidden group-open:flex"> 15 - {{ i "chevron-up" "w-4 h-4" }} 16 - </span> 17 - </summary> 7 + <button 8 + popovertarget="clone-dropdown" 9 + popovertargetaction="toggle" 10 + class="btn-create cursor-pointer list-none flex items-center gap-2 px-4"> 11 + {{ i "download" "w-4 h-4" }} 12 + <span class="hidden md:inline">code</span> 13 + </button> 14 + <div 15 + popover 16 + id="clone-dropdown" 17 + class=" 18 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 19 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 20 + w-96 p-4 rounded drop-shadow overflow-visible"> 21 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-5">Clone this repository</h3> 18 22 19 - <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 - <div class="p-4"> 21 - <div class="mb-3"> 22 - <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 - </div> 23 + <!-- HTTPS Clone --> 24 + <div class="mb-3"> 25 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 26 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 27 + <code 28 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 29 + onclick="window.getSelection().selectAllChildren(this)" 30 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 31 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 32 + <button 33 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 34 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 35 + title="Copy to clipboard" 36 + > 37 + {{ i "copy" "w-4 h-4" }} 38 + </button> 39 + </div> 40 + </div> 24 41 25 - <!-- HTTPS Clone --> 26 - <div class="mb-3"> 27 - <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 - <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 - <code 30 - class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 - onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 34 - <button 35 - onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 - class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 - title="Copy to clipboard" 38 - > 39 - {{ i "copy" "w-4 h-4" }} 40 - </button> 41 - </div> 42 - </div> 42 + <!-- SSH Clone --> 43 + <div class="mb-3"> 44 + {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 45 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 46 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 47 + <code 48 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 49 + onclick="window.getSelection().selectAllChildren(this)" 50 + data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 51 + >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 52 + <button 53 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 54 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 55 + title="Copy to clipboard" 56 + > 57 + {{ i "copy" "w-4 h-4" }} 58 + </button> 59 + </div> 60 + </div> 43 61 44 - <!-- SSH Clone --> 45 - <div class="mb-3"> 46 - {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 47 - <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 48 - <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 49 - <code 50 - class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 51 - onclick="window.getSelection().selectAllChildren(this)" 52 - data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 53 - >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 54 - <button 55 - onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 56 - class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 57 - title="Copy to clipboard" 58 - > 59 - {{ i "copy" "w-4 h-4" }} 60 - </button> 61 - </div> 62 - </div> 63 - 64 - <!-- Note for self-hosted --> 65 - <p class="text-xs text-gray-500 dark:text-gray-400"> 66 - For self-hosted knots, clone URLs may differ based on your setup. 67 - </p> 68 - 69 - <!-- Download Archive --> 70 - <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 71 - <a 72 - href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 73 - class="flex items-center gap-2 px-3 py-2 text-sm" 74 - > 75 - {{ i "download" "w-4 h-4" }} 76 - Download tar.gz 77 - </a> 78 - </div> 79 - 80 - </div> 81 - </div> 82 - </details> 62 + <!-- Note for self-hosted --> 63 + <p class="text-xs text-gray-500 dark:text-gray-400"> 64 + For self-hosted knots, clone URLs may differ based on your setup. 65 + </p> 83 66 84 - <script> 85 - function copyToClipboard(button, text) { 86 - navigator.clipboard.writeText(text).then(() => { 87 - const originalContent = button.innerHTML; 88 - button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 89 - setTimeout(() => { 90 - button.innerHTML = originalContent; 91 - }, 2000); 92 - }); 93 - } 67 + <!-- Download Archive --> 68 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 69 + <a 70 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 71 + class="flex items-center gap-2 px-3 py-2 text-sm" 72 + > 73 + {{ i "download" "w-4 h-4" }} 74 + Download tar.gz 75 + </a> 76 + </div> 77 + </div> 94 78 95 - // Close clone dropdown when clicking outside 96 - document.addEventListener('click', function(event) { 97 - const cloneDropdown = document.getElementById('clone-dropdown'); 98 - if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 99 - if (!cloneDropdown.contains(event.target)) { 100 - cloneDropdown.removeAttribute('open'); 101 - } 102 - } 79 + <script> 80 + function copyToClipboard(button, text) { 81 + navigator.clipboard.writeText(text).then(() => { 82 + const originalContent = button.innerHTML; 83 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 84 + setTimeout(() => { 85 + button.innerHTML = originalContent; 86 + }, 2000); 103 87 }); 104 - </script> 88 + } 89 + </script> 105 90 {{ end }}
+164 -43
appview/pages/templates/repo/fragments/diff.html
··· 1 1 {{ define "repo/fragments/diff" }} 2 + <style> 3 + #filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; } 4 + #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 5 + #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 6 + #filesToggle:checked ~ div div#files { width: fit-content; max-width: 15vw; margin-right: 1rem; } 7 + #filesToggle:not(:checked) ~ div div#files { width: 0; display: hidden; margin-right: 0; } 8 + </style> 9 + 10 + {{ template "diffTopbar" . }} 11 + {{ block "diffLayout" . }} {{ end }} 12 + {{ end }} 13 + 14 + {{ define "diffTopbar" }} 2 15 {{ $diff := index . 0 }} 3 16 {{ $opts := index . 1 }} 4 17 5 - {{ $commit := $diff.Commit }} 6 - {{ $diff := $diff.Diff }} 7 - {{ $isSplit := $opts.Split }} 8 - {{ $this := $commit.This }} 9 - {{ $parent := $commit.Parent }} 10 - {{ $last := sub (len $diff) 1 }} 18 + {{ block "filesCheckbox" $ }} {{ end }} 19 + {{ block "subsCheckbox" $ }} {{ end }} 20 + 21 + <!-- top bar --> 22 + <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12 p-2"> 23 + <!-- left panel toggle --> 24 + {{ template "filesToggle" . }} 11 25 26 + <!-- stats --> 27 + {{ $stat := $diff.Stats }} 28 + {{ $count := len $diff.ChangedFiles }} 29 + {{ template "repo/fragments/diffStatPill" $stat }} 30 + {{ $count }} changed file{{ if ne $count 1 }}s{{ end }} 31 + 32 + <!-- spacer --> 33 + <div class="flex-grow"></div> 34 + 35 + <!-- collapse diffs --> 36 + {{ template "collapseToggle" }} 37 + 38 + <!-- diff options --> 39 + {{ template "repo/fragments/diffOpts" $opts }} 40 + 41 + <!-- right panel toggle --> 42 + {{ block "subsToggle" $ }} {{ end }} 43 + </div> 44 + 45 + {{ end }} 46 + 47 + {{ define "diffLayout" }} 48 + {{ $diff := index . 0 }} 49 + {{ $opts := index . 1 }} 50 + 51 + <div class="flex col-span-full flex-grow"> 52 + <!-- left panel --> 53 + <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 54 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 55 + {{ template "repo/fragments/fileTree" $diff.FileTree }} 56 + </section> 57 + </div> 58 + 59 + <!-- main content --> 60 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 61 + {{ template "diffFiles" (list $diff $opts) }} 62 + </div> 63 + 64 + </div> 65 + {{ end }} 66 + 67 + {{ define "diffFiles" }} 68 + {{ $diff := index . 0 }} 69 + {{ $opts := index . 1 }} 70 + {{ $files := $diff.ChangedFiles }} 71 + {{ $isSplit := $opts.Split }} 12 72 <div class="flex flex-col gap-4"> 13 - {{ if eq (len $diff) 0 }} 73 + {{ if eq (len $files) 0 }} 14 74 <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 15 75 <p>No differences found between the selected revisions.</p> 16 76 </div> 17 77 {{ else }} 18 - {{ range $idx, $hunk := $diff }} 19 - {{ with $hunk }} 20 - <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 21 - <summary class="list-none cursor-pointer sticky top-0"> 22 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 24 - <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 25 - <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 26 - {{ template "repo/fragments/diffStatPill" .Stats }} 78 + {{ range $idx, $file := $files }} 79 + {{ template "diffFile" (list $idx $file $isSplit) }} 80 + {{ end }} 81 + {{ end }} 82 + </div> 83 + {{ end }} 27 84 28 - <div class="flex gap-2 items-center overflow-x-auto"> 29 - {{ if .IsDelete }} 30 - {{ .Name.Old }} 31 - {{ else if (or .IsCopy .IsRename) }} 32 - {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 33 - {{ else }} 34 - {{ .Name.New }} 35 - {{ end }} 36 - </div> 37 - </div> 38 - </div> 39 - </summary> 85 + {{ define "diffFile" }} 86 + {{ $idx := index . 0 }} 87 + {{ $file := index . 1 }} 88 + {{ $isSplit := index . 2 }} 89 + {{ with $file }} 90 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 91 + <summary class="list-none cursor-pointer sticky top-12 group-open:border-b border-gray-200 dark:border-gray-700"> 92 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 93 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 94 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 95 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 96 + {{ template "repo/fragments/diffStatPill" .Stats }} 40 97 41 - <div class="transition-all duration-700 ease-in-out"> 42 - {{ if .IsBinary }} 43 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 44 - This is a binary file and will not be displayed. 45 - </p> 46 - {{ else }} 47 - {{ if $isSplit }} 48 - {{- template "repo/fragments/splitDiff" .Split -}} 98 + <div class="flex gap-2 items-center overflow-x-auto"> 99 + {{ $n := .Names }} 100 + {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 101 + {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 102 + {{ else if $n.New }} 103 + {{ $n.New }} 49 104 {{ else }} 50 - {{- template "repo/fragments/unifiedDiff" . -}} 105 + {{ $n.Old }} 51 106 {{ end }} 52 - {{- end -}} 107 + </div> 53 108 </div> 54 - </details> 55 - {{ end }} 56 - {{ end }} 57 - {{ end }} 58 - </div> 109 + </div> 110 + </summary> 111 + 112 + <div class="transition-all duration-700 ease-in-out"> 113 + {{ $reason := .CanRender }} 114 + {{ if $reason }} 115 + <p class="text-center text-gray-400 dark:text-gray-500 p-4">{{ $reason }}</p> 116 + {{ else }} 117 + {{ if $isSplit }} 118 + {{- template "repo/fragments/splitDiff" .Split -}} 119 + {{ else }} 120 + {{- template "repo/fragments/unifiedDiff" . -}} 121 + {{ end }} 122 + {{- end -}} 123 + </div> 124 + </details> 125 + {{ end }} 126 + {{ end }} 127 + 128 + {{ define "filesCheckbox" }} 129 + <input type="checkbox" id="filesToggle" class="peer/files hidden" checked/> 130 + {{ end }} 131 + 132 + {{ define "filesToggle" }} 133 + <label title="Toggle filetree panel" for="filesToggle" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer text-normal font-normal normalcase"> 134 + <span class="show-text">{{ i "panel-left-open" "size-4" }}</span> 135 + <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span> 136 + </label> 137 + {{ end }} 138 + 139 + {{ define "collapseToggle" }} 140 + <label 141 + title="Expand/Collapse diffs" 142 + for="collapseToggle" 143 + class="btn font-normal normal-case p-2" 144 + > 145 + <input type="checkbox" id="collapseToggle" class="peer/collapse hidden" checked/> 146 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 147 + {{ i "fold-vertical" "w-4 h-4" }} 148 + <span class="hidden md:inline">expand all</span> 149 + </span> 150 + <span class="peer-checked/collapse:inline-flex hidden flex items-center gap-2"> 151 + {{ i "unfold-vertical" "w-4 h-4" }} 152 + <span class="hidden md:inline">collapse all</span> 153 + </span> 154 + </label> 155 + <script> 156 + document.addEventListener('DOMContentLoaded', function() { 157 + const checkbox = document.getElementById('collapseToggle'); 158 + const details = document.querySelectorAll('details[id^="file-"]'); 159 + 160 + checkbox.addEventListener('change', function() { 161 + details.forEach(detail => { 162 + detail.open = checkbox.checked; 163 + }); 164 + }); 165 + 166 + details.forEach(detail => { 167 + detail.addEventListener('toggle', function() { 168 + const allOpen = Array.from(details).every(d => d.open); 169 + const allClosed = Array.from(details).every(d => !d.open); 170 + 171 + if (allOpen) { 172 + checkbox.checked = true; 173 + } else if (allClosed) { 174 + checkbox.checked = false; 175 + } 176 + }); 177 + }); 178 + }); 179 + </script> 59 180 {{ end }}
-13
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 - {{ define "repo/fragments/diffChangedFiles" }} 2 - {{ $stat := .Stat }} 3 - {{ $fileTree := fileTree .ChangedFiles }} 4 - <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 - <div class="diff-stat"> 6 - <div class="flex gap-2 items-center"> 7 - <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 8 - {{ template "repo/fragments/diffStatPill" $stat }} 9 - </div> 10 - {{ template "repo/fragments/fileTree" $fileTree }} 11 - </div> 12 - </section> 13 - {{ end }}
+22 -25
appview/pages/templates/repo/fragments/diffOpts.html
··· 1 1 {{ define "repo/fragments/diffOpts" }} 2 - <section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 3 - <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 - {{ $active := "unified" }} 5 - {{ if .Split }} 6 - {{ $active = "split" }} 7 - {{ end }} 2 + {{ $active := "unified" }} 3 + {{ if .Split }} 4 + {{ $active = "split" }} 5 + {{ end }} 8 6 9 - {{ $unified := 10 - (dict 11 - "Key" "unified" 12 - "Value" "unified" 13 - "Icon" "square-split-vertical" 14 - "Meta" "") }} 15 - {{ $split := 16 - (dict 17 - "Key" "split" 18 - "Value" "split" 19 - "Icon" "square-split-horizontal" 20 - "Meta" "") }} 21 - {{ $values := list $unified $split }} 7 + {{ $unified := 8 + (dict 9 + "Key" "unified" 10 + "Value" "unified" 11 + "Icon" "square-split-vertical" 12 + "Meta" "") }} 13 + {{ $split := 14 + (dict 15 + "Key" "split" 16 + "Value" "split" 17 + "Icon" "square-split-horizontal" 18 + "Meta" "") }} 19 + {{ $values := list $unified $split }} 22 20 23 - {{ template "fragments/tabSelector" 24 - (dict 25 - "Name" "diff" 26 - "Values" $values 27 - "Active" $active) }} 28 - </section> 21 + {{ template "fragments/tabSelector" 22 + (dict 23 + "Name" "diff" 24 + "Values" $values 25 + "Active" $active) }} 29 26 {{ end }} 30 27
-67
appview/pages/templates/repo/fragments/interdiff.html
··· 1 - {{ define "repo/fragments/interdiff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $x := index . 1 }} 4 - {{ $opts := index . 2 }} 5 - {{ $fileTree := fileTree $x.AffectedFiles }} 6 - {{ $diff := $x.Files }} 7 - {{ $last := sub (len $diff) 1 }} 8 - {{ $isSplit := $opts.Split }} 9 - 10 - <div class="flex flex-col gap-4"> 11 - {{ range $idx, $hunk := $diff }} 12 - {{ with $hunk }} 13 - <details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 - <summary class="list-none cursor-pointer sticky top-0"> 15 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 16 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 17 - <div class="flex gap-1 items-center" style="direction: ltr;"> 18 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 19 - {{ if .Status.IsOk }} 20 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 21 - {{ else if .Status.IsUnchanged }} 22 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 23 - {{ else if .Status.IsOnlyInOne }} 24 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 25 - {{ else if .Status.IsOnlyInTwo }} 26 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 27 - {{ else if .Status.IsRebased }} 28 - <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 29 - {{ else }} 30 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 31 - {{ end }} 32 - </div> 33 - 34 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div> 35 - </div> 36 - 37 - </div> 38 - </summary> 39 - 40 - <div class="transition-all duration-700 ease-in-out"> 41 - {{ if .Status.IsUnchanged }} 42 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 43 - This file has not been changed. 44 - </p> 45 - {{ else if .Status.IsRebased }} 46 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 47 - This patch was likely rebased, as context lines do not match. 48 - </p> 49 - {{ else if .Status.IsError }} 50 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 51 - Failed to calculate interdiff for this file. 52 - </p> 53 - {{ else }} 54 - {{ if $isSplit }} 55 - {{- template "repo/fragments/splitDiff" .Split -}} 56 - {{ else }} 57 - {{- template "repo/fragments/unifiedDiff" . -}} 58 - {{ end }} 59 - {{- end -}} 60 - </div> 61 - 62 - </details> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 -
-11
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 - {{ define "repo/fragments/interdiffFiles" }} 2 - {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 - <div class="diff-stat"> 5 - <div class="flex gap-2 items-center"> 6 - <strong class="text-sm uppercase dark:text-gray-200">files</strong> 7 - </div> 8 - {{ template "repo/fragments/fileTree" $fileTree }} 9 - </div> 10 - </section> 11 - {{ end }}
+35 -35
appview/pages/templates/repo/fragments/splitDiff.html
··· 3 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 7 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 13 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 14 14 {{- range .LeftLines -}} 15 15 {{- if .IsEmpty -}} 16 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 - </div> 16 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 18 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 19 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 20 + </span> 21 21 {{- else if eq .Op.String "-" -}} 22 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 - <div class="px-2">{{ .Content }}</div> 26 - </div> 22 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 24 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 25 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 26 + </span> 27 27 {{- else if eq .Op.String " " -}} 28 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 - <div class="px-2">{{ .Content }}</div> 32 - </div> 28 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 30 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 31 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 32 + </span> 33 33 {{- end -}} 34 34 {{- end -}} 35 - {{- end -}}</div></div></pre> 35 + {{- end -}}</div></div></div> 36 36 37 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 37 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 38 38 {{- range .RightLines -}} 39 39 {{- if .IsEmpty -}} 40 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 - </div> 40 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 42 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 43 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 44 + </span> 45 45 {{- else if eq .Op.String "+" -}} 46 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 - <div class="px-2" >{{ .Content }}</div> 50 - </div> 46 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span> 48 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 49 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 50 + </span> 51 51 {{- else if eq .Op.String " " -}} 52 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 - <div class="px-2">{{ .Content }}</div> 56 - </div> 52 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span> 54 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 55 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 56 + </span> 57 57 {{- end -}} 58 58 {{- end -}} 59 - {{- end -}}</div></div></pre> 59 + {{- end -}}</div></div></div> 60 60 </div> 61 61 {{ end }}
+21 -22
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 1 {{ define "repo/fragments/unifiedDiff" }} 2 2 {{ $name := .Id }} 3 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 3 + <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 4 {{- $oldStart := .OldPosition -}} 5 5 {{- $newStart := .NewPosition -}} 6 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 8 {{- $lineNrSepStyle1 := "" -}} 9 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 11 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 15 {{- range .Lines -}} 16 16 {{- if eq .Op.String "+" -}} 17 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 - <div class="px-2">{{ .Line }}</div> 22 - </div> 17 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span> 19 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span> 20 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 21 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 22 + </span> 23 23 {{- $newStart = add64 $newStart 1 -}} 24 24 {{- end -}} 25 25 {{- if eq .Op.String "-" -}} 26 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 - <div class="px-2">{{ .Line }}</div> 31 - </div> 26 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span> 28 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span> 29 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 30 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 31 + </span> 32 32 {{- $oldStart = add64 $oldStart 1 -}} 33 33 {{- end -}} 34 34 {{- if eq .Op.String " " -}} 35 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 - <div class="px-2">{{ .Line }}</div> 40 - </div> 35 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span> 37 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span> 38 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 39 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 40 + </span> 41 41 {{- $newStart = add64 $newStart 1 -}} 42 42 {{- $oldStart = add64 $oldStart 1 -}} 43 43 {{- end -}} 44 44 {{- end -}} 45 - {{- end -}}</div></div></pre> 45 + {{- end -}}</div></div></div> 46 46 {{ end }} 47 -
+4 -9
appview/pages/templates/repo/index.html
··· 14 14 {{ end }} 15 15 <div class="flex items-center justify-between pb-5"> 16 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-3"> 18 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 17 + <div class="flex items-center gap-3"> 18 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 19 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 20 </a> 21 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 22 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 23 23 </a> 24 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex md:hidden items-center text-sm gap-1 font-bold"> 25 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 26 26 </a> 27 27 {{ template "repo/fragments/cloneDropdown" . }} ··· 109 109 {{ i "git-compare" "w-4 h-4" }} 110 110 </a> 111 111 </div> 112 - </div> 113 - 114 - <!-- Clone dropdown in top right --> 115 - <div class="hidden md:flex items-center "> 116 - {{ template "repo/fragments/cloneDropdown" . }} 117 112 </div> 118 113 </div> 119 114 {{ end }}
+35 -22
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 1 {{ define "repo/issues/fragments/commentList" }} 2 - <div class="flex flex-col gap-8"> 2 + <div class="flex flex-col gap-4"> 3 3 {{ range $item := .CommentList }} 4 4 {{ template "commentListing" (list $ .) }} 5 5 {{ end }} ··· 19 19 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 20 {{ template "topLevelComment" $params }} 21 21 22 - <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 22 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 23 23 {{ range $index, $reply := $comment.Replies }} 24 - <div class="relative "> 25 - <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 - 28 - <div class="pl-2"> 29 - {{ 30 - template "replyComment" 31 - (dict 32 - "RepoInfo" $root.RepoInfo 33 - "LoggedInUser" $root.LoggedInUser 34 - "Issue" $root.Issue 35 - "Comment" $reply) 36 - }} 37 - </div> 24 + <div class="-ml-4"> 25 + {{ 26 + template "replyComment" 27 + (dict 28 + "RepoInfo" $root.RepoInfo 29 + "LoggedInUser" $root.LoggedInUser 30 + "Issue" $root.Issue 31 + "Comment" $reply) 32 + }} 38 33 </div> 39 34 {{ end }} 40 35 </div> ··· 44 39 {{ end }} 45 40 46 41 {{ define "topLevelComment" }} 47 - <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 - {{ template "repo/issues/fragments/issueCommentBody" . }} 42 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 + <div class="flex-shrink-0"> 44 + <img 45 + src="{{ tinyAvatar .Comment.Did }}" 46 + alt="" 47 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 48 + /> 49 + </div> 50 + <div class="flex-1 min-w-0"> 51 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 52 + {{ template "repo/issues/fragments/issueCommentBody" . }} 53 + </div> 50 54 </div> 51 55 {{ end }} 52 56 53 57 {{ define "replyComment" }} 54 - <div class="p-4 w-full mx-auto overflow-hidden"> 55 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 - {{ template "repo/issues/fragments/issueCommentBody" . }} 58 + <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 59 + <div class="flex-shrink-0"> 60 + <img 61 + src="{{ tinyAvatar .Comment.Did }}" 62 + alt="" 63 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 64 + /> 65 + </div> 66 + <div class="flex-1 min-w-0"> 67 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 68 + {{ template "repo/issues/fragments/issueCommentBody" . }} 69 + </div> 57 70 </div> 58 71 {{ end }}
-63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 - {{ define "repo/issues/fragments/globalIssueListing" }} 2 - <div class="flex flex-col gap-2"> 3 - {{ range .Issues }} 4 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 - <div class="pb-2 mb-3"> 6 - <div class="flex items-center gap-3 mb-2"> 7 - <a 8 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 - class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 - > 11 - {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 - </a> 13 - </div> 14 - <a 15 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 - class="no-underline hover:underline" 17 - > 18 - {{ .Title | description }} 19 - <span class="text-gray-500">#{{ .IssueId }}</span> 20 - </a> 21 - </div> 22 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 - {{ $icon := "ban" }} 25 - {{ $state := "closed" }} 26 - {{ if .Open }} 27 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 - {{ $icon = "circle-dot" }} 29 - {{ $state = "open" }} 30 - {{ end }} 31 - 32 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 - <span class="text-white dark:text-white">{{ $state }}</span> 35 - </span> 36 - 37 - <span class="ml-1"> 38 - {{ template "user/fragments/picHandleLink" .Did }} 39 - </span> 40 - 41 - <span class="before:content-['ยท']"> 42 - {{ template "repo/fragments/time" .Created }} 43 - </span> 44 - 45 - <span class="before:content-['ยท']"> 46 - {{ $s := "s" }} 47 - {{ if eq (len .Comments) 1 }} 48 - {{ $s = "" }} 49 - {{ end }} 50 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 - </span> 52 - 53 - {{ $state := .Labels }} 54 - {{ range $k, $d := $.LabelDefs }} 55 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 - {{ end }} 58 - {{ end }} 59 - </div> 60 - </div> 61 - {{ end }} 62 - </div> 63 - {{ end }}
+2 -1
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did }} 3 + {{ resolve .Comment.Did }} 4 4 {{ template "hats" $ }} 5 + <span class="before:content-['ยท']"></span> 5 6 {{ template "timestamp" . }} 6 7 {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 8 {{ if and $isCommentOwner (not .Comment.Deleted) }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 21 21 {{ $state = "open" }} 22 22 {{ end }} 23 23 24 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 25 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> 26 + <span class="text-white dark:text-white text-sm">{{ $state }}</span> 27 27 </span> 28 28 29 29 <span class="ml-1">
+1 -1
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 18 18 <textarea 19 19 name="body" 20 20 id="body" 21 - rows="6" 21 + rows="15" 22 22 class="w-full resize-y" 23 23 placeholder="Describe your issue. Markdown is supported." 24 24 >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+3 -3
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 - <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 3 {{ if .LoggedInUser }} 4 4 <img 5 5 src="{{ tinyAvatar .LoggedInUser.Did }}" 6 6 alt="" 7 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 7 + class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 8 /> 9 9 {{ end }} 10 10 <input 11 - class="w-full py-2 border-none focus:outline-none" 11 + class="w-full p-0 border-none focus:outline-none" 12 12 placeholder="Leave a reply..." 13 13 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 14 hx-trigger="focus"
+5 -5
appview/pages/templates/repo/issues/issue.html
··· 58 58 {{ $icon = "circle-dot" }} 59 59 {{ end }} 60 60 <div class="inline-flex items-center gap-2"> 61 - <div id="state" 62 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 63 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 64 - <span class="text-white">{{ .Issue.State }}</span> 65 - </div> 61 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 62 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 63 + <span class="text-white dark:text-white text-sm">{{ .Issue.State }}</span> 64 + </span> 65 + 66 66 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 67 67 opened by 68 68 {{ template "user/fragments/picHandleLink" .Issue.Did }}
+10 -103
appview/pages/templates/repo/issues/issues.html
··· 71 71 <div class="mt-2"> 72 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 73 73 </div> 74 - {{if gt .IssueCount .Page.Limit }} 75 - {{ block "pagination" . }} {{ end }} 76 - {{ end }} 77 - {{ end }} 78 - 79 - {{ define "pagination" }} 80 - <div class="flex justify-center items-center mt-4 gap-2"> 81 - {{ $currentState := "closed" }} 82 - {{ if .FilteringByOpen }} 83 - {{ $currentState = "open" }} 84 - {{ end }} 85 - 86 - {{ $prev := .Page.Previous.Offset }} 87 - {{ $next := .Page.Next.Offset }} 88 - {{ $lastPage := sub .IssueCount (mod .IssueCount .Page.Limit) }} 89 - 90 - <a 91 - class=" 92 - btn flex items-center gap-2 no-underline hover:no-underline 93 - dark:text-white dark:hover:bg-gray-700 94 - {{ if le .Page.Offset 0 }} 95 - cursor-not-allowed opacity-50 96 - {{ end }} 97 - " 98 - {{ if gt .Page.Offset 0 }} 99 - hx-boost="true" 100 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 74 + {{if gt .IssueCount .Page.Limit }} 75 + {{ $state := "closed" }} 76 + {{ if .FilteringByOpen }} 77 + {{ $state = "open" }} 101 78 {{ end }} 102 - > 103 - {{ i "chevron-left" "w-4 h-4" }} 104 - previous 105 - </a> 106 - 107 - <!-- dont show first page if current page is first page --> 108 - {{ if gt .Page.Offset 0 }} 109 - <a 110 - hx-boost="true" 111 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset=0&limit={{ .Page.Limit }}" 112 - > 113 - 1 114 - </a> 115 - {{ end }} 116 - 117 - <!-- if previous page is not first or second page (prev > limit) --> 118 - {{ if gt $prev .Page.Limit }} 119 - <span>...</span> 79 + {{ template "fragments/pagination" (dict 80 + "Page" .Page 81 + "TotalCount" .IssueCount 82 + "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 83 + "QueryParams" (printf "state=%s&q=%s" $state .FilterQuery) 84 + ) }} 120 85 {{ end }} 121 - 122 - <!-- if previous page is not the first page --> 123 - {{ if gt $prev 0 }} 124 - <a 125 - hx-boost="true" 126 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 127 - > 128 - {{ add (div $prev .Page.Limit) 1 }} 129 - </a> 130 - {{ end }} 131 - 132 - <!-- current page. this is always visible --> 133 - <span class="font-bold"> 134 - {{ add (div .Page.Offset .Page.Limit) 1 }} 135 - </span> 136 - 137 - <!-- if next page is not last page --> 138 - {{ if lt $next $lastPage }} 139 - <a 140 - hx-boost="true" 141 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 142 - > 143 - {{ add (div $next .Page.Limit) 1 }} 144 - </a> 145 - {{ end }} 146 - 147 - <!-- if next page is not second last or last page (next < issues - 2 * limit) --> 148 - {{ if lt ($next) (sub .IssueCount (mul (2) .Page.Limit)) }} 149 - <span>...</span> 150 - {{ end }} 151 - 152 - <!-- if its not the last page --> 153 - {{ if lt .Page.Offset $lastPage }} 154 - <a 155 - hx-boost="true" 156 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $lastPage }}&limit={{ .Page.Limit }}" 157 - > 158 - {{ add (div $lastPage .Page.Limit) 1 }} 159 - </a> 160 - {{ end }} 161 - 162 - <a 163 - class=" 164 - btn flex items-center gap-2 no-underline hover:no-underline 165 - dark:text-white dark:hover:bg-gray-700 166 - {{ if ne (len .Issues) .Page.Limit }} 167 - cursor-not-allowed opacity-50 168 - {{ end }} 169 - " 170 - {{ if eq (len .Issues) .Page.Limit }} 171 - hx-boost="true" 172 - href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 173 - {{ end }} 174 - > 175 - next 176 - {{ i "chevron-right" "w-4 h-4" }} 177 - </a> 178 - </div> 179 86 {{ end }}
+60 -69
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 1 1 {{ define "repo/pipelines/fragments/pipelineSymbol" }} 2 - <div class="cursor-pointer"> 3 - {{ $c := .Counts }} 4 - {{ $statuses := .Statuses }} 5 - {{ $total := len $statuses }} 6 - {{ $success := index $c "success" }} 7 - {{ $fail := index $c "failed" }} 8 - {{ $timeout := index $c "timeout" }} 9 - {{ $empty := eq $total 0 }} 10 - {{ $allPass := eq $success $total }} 11 - {{ $allFail := eq $fail $total }} 12 - {{ $allTimeout := eq $timeout $total }} 13 - 14 - {{ if $empty }} 15 - <div class="flex gap-1 items-center"> 16 - {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 17 - <span>0/{{ $total }}</span> 18 - </div> 19 - {{ else if $allPass }} 20 - <div class="flex gap-1 items-center"> 21 - {{ i "check" "size-4 text-green-600" }} 22 - <span>{{ $total }}/{{ $total }}</span> 23 - </div> 24 - {{ else if $allFail }} 25 - <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-500" }} 27 - <span>0/{{ $total }}</span> 28 - </div> 29 - {{ else if $allTimeout }} 30 - <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-500" }} 32 - <span>0/{{ $total }}</span> 33 - </div> 2 + <div class="cursor-pointer flex gap-2 items-center"> 3 + {{ template "symbol" .Pipeline }} 4 + {{ if .ShortSummary }} 5 + {{ .Pipeline.ShortStatusSummary }} 34 6 {{ else }} 35 - {{ $radius := f64 8 }} 36 - {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 37 - {{ $offset := 0.0 }} 38 - <div class="flex gap-1 items-center"> 39 - <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 40 - <circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/> 7 + {{ .Pipeline.LongStatusSummary }} 8 + {{ end }} 9 + </div> 10 + {{ end }} 41 11 42 - {{ range $kind, $count := $c }} 43 - {{ $color := "" }} 44 - {{ if or (eq $kind "pending") (eq $kind "running") }} 45 - {{ $color = "#eab308" }} {{/* amber-500 */}} 46 - {{ else if eq $kind "success" }} 47 - {{ $color = "#10b981" }} {{/* green-500 */}} 48 - {{ else if eq $kind "cancelled" }} 49 - {{ $color = "#6b7280" }} {{/* gray-500 */}} 50 - {{ else if eq $kind "timeout" }} 51 - {{ $color = "#fb923c" }} {{/* orange-400 */}} 52 - {{ else }} 53 - {{ $color = "#ef4444" }} {{/* red-500 for failed or unknown */}} 54 - {{ end }} 12 + {{ define "symbol" }} 13 + {{ $c := .Counts }} 14 + {{ $statuses := .Statuses }} 15 + {{ $total := len $statuses }} 16 + {{ $success := index $c "success" }} 17 + {{ $fail := index $c "failed" }} 18 + {{ $timeout := index $c "timeout" }} 19 + {{ $empty := eq $total 0 }} 20 + {{ $allPass := eq $success $total }} 21 + {{ $allFail := eq $fail $total }} 22 + {{ $allTimeout := eq $timeout $total }} 55 23 56 - {{ $percent := divf64 (f64 $count) (f64 $total) }} 57 - {{ $length := mulf64 $percent $circumference }} 58 - 59 - <circle 60 - cx="10" cy="10" r="{{ $radius }}" 61 - fill="none" 62 - stroke="{{ $color }}" 63 - stroke-width="2" 64 - stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 65 - stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 66 - /> 67 - {{ $offset = addf64 $offset $length }} 68 - {{ end }} 69 - </svg> 70 - <span>{{ $success }}/{{ $total }}</span> 71 - </div> 72 - {{ end }} 73 - </div> 24 + {{ if $empty }} 25 + {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 26 + {{ else if $allPass }} 27 + {{ i "check" "size-4 text-green-600 dark:text-green-500" }} 28 + {{ else if $allFail }} 29 + {{ i "x" "size-4 text-red-600 dark:text-red-500" }} 30 + {{ else if $allTimeout }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 + {{ else }} 33 + {{ $radius := f64 8 }} 34 + {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 35 + {{ $offset := 0.0 }} 36 + <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 37 + <circle cx="10" cy="10" r="{{ $radius }}" fill="none" class="stroke-gray-200 dark:stroke-gray-700" stroke-width="2"/> 38 + {{ range $kind, $count := $c }} 39 + {{ $colorClass := "" }} 40 + {{ if or (eq $kind "pending") (eq $kind "running") }} 41 + {{ $colorClass = "stroke-yellow-600 dark:stroke-yellow-500" }} 42 + {{ else if eq $kind "success" }} 43 + {{ $colorClass = "stroke-green-600 dark:stroke-green-500" }} 44 + {{ else if eq $kind "cancelled" }} 45 + {{ $colorClass = "stroke-gray-600 dark:stroke-gray-500" }} 46 + {{ else if eq $kind "timeout" }} 47 + {{ $colorClass = "stroke-orange-600 dark:stroke-orange-500" }} 48 + {{ else }} 49 + {{ $colorClass = "stroke-red-600 dark:stroke-red-500" }} 50 + {{ end }} 51 + {{ $percent := divf64 (f64 $count) (f64 $total) }} 52 + {{ $length := mulf64 $percent $circumference }} 53 + <circle 54 + cx="10" cy="10" r="{{ $radius }}" 55 + fill="none" 56 + class="{{ $colorClass }}" 57 + stroke-width="2" 58 + stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 59 + stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 60 + /> 61 + {{ $offset = addf64 $offset $length }} 62 + {{ end }} 63 + </svg> 64 + {{ end }} 74 65 {{ end }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
··· 4 4 <div class="relative inline-block"> 5 5 <details class="relative"> 6 6 <summary class="cursor-pointer list-none"> 7 - {{ template "repo/pipelines/fragments/pipelineSymbol" .Pipeline }} 7 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 8 8 </summary> 9 9 {{ template "repo/pipelines/fragments/tooltip" $ }} 10 10 </details>
+17 -17
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 25 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative p-2"> 26 26 <button 27 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 28 hx-target="#actions-{{$roundNumber}}" 29 29 hx-swap="outerHtml" 30 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 - {{ i "message-square-plus" "w-4 h-4" }} 32 - <span>comment</span> 30 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 + {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 33 32 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + comment 34 34 </button> 35 35 {{ if .BranchDeleteStatus }} 36 36 <button 37 37 hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 39 hx-swap="none" 40 - 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"> 40 + class="btn-flat 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"> 41 41 {{ i "git-branch" "w-4 h-4" }} 42 42 <span>delete branch</span> 43 43 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 52 52 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 53 hx-swap="none" 54 54 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 - {{ i "git-merge" "w-4 h-4" }} 57 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 55 + class="btn-flat p-2 flex items-center gap-2 group" {{ $disabled }}> 56 + {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 57 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + merge{{if $stackCount}} {{$stackCount}}{{end}} 59 59 </button> 60 60 {{ end }} 61 61 ··· 74 74 {{ end }} 75 75 76 76 hx-disabled-elt="#resubmitBtn" 77 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 77 + class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 78 79 79 {{ if $disabled }} 80 80 title="Update this branch to resubmit this pull request" ··· 82 82 title="Resubmit this pull request" 83 83 {{ end }} 84 84 > 85 - {{ i "rotate-ccw" "w-4 h-4" }} 86 - <span>resubmit</span> 85 + {{ i "rotate-ccw" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 87 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 + resubmit 88 88 </button> 89 89 {{ end }} 90 90 ··· 92 92 <button 93 93 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 94 hx-swap="none" 95 - class="btn p-2 flex items-center gap-2 group"> 96 - {{ i "ban" "w-4 h-4" }} 97 - <span>close</span> 95 + class="btn-flat p-2 flex items-center gap-2 group"> 96 + {{ i "ban" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 98 97 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 98 + close 99 99 </button> 100 100 {{ end }} 101 101 ··· 103 103 <button 104 104 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 105 hx-swap="none" 106 - class="btn p-2 flex items-center gap-2 group"> 107 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 108 - <span>reopen</span> 106 + class="btn-flat p-2 flex items-center gap-2 group"> 107 + {{ i "refresh-ccw-dot" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 109 108 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 109 + reopen 110 110 </button> 111 111 {{ end }} 112 112 </div>
+6 -7
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 - <header class="pb-4"> 2 + <header class="pb-2"> 3 3 <h1 class="text-2xl dark:text-white"> 4 4 {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> ··· 17 17 {{ $icon = "git-merge" }} 18 18 {{ end }} 19 19 20 - <section class="mt-2"> 20 + <section> 21 21 <div class="flex items-center gap-2"> 22 - <div 23 - id="state" 24 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 22 + <span 23 + class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm" 25 24 > 26 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white" }} 27 26 <span class="text-white">{{ .Pull.State.String }}</span> 28 - </div> 27 + </span> 29 28 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 29 opened by 31 30 {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
+39 -24
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 2 <div 3 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> 4 + class="w-full flex flex-col gap-2"> 5 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 8 6 <form 9 7 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 - hx-indicator="#create-comment-spinner" 11 8 hx-swap="none" 12 - class="w-full flex flex-wrap gap-2" 9 + hx-on::after-request="if(event.detail.successful) this.reset()" 10 + hx-disabled-elt="#reply-{{ .RoundNumber }}" 11 + class="w-full flex flex-wrap gap-2 group" 13 12 > 14 13 <textarea 15 14 name="body" 16 15 class="w-full p-2 rounded border border-gray-200" 16 + rows=8 17 17 placeholder="Add to the discussion..."></textarea 18 18 > 19 - <button type="submit" class="btn flex items-center gap-2"> 20 - {{ i "message-square" "w-4 h-4" }} 21 - <span>comment</span> 22 - <span id="create-comment-spinner" class="group"> 23 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 24 - </span> 25 - </button> 26 - <button 27 - type="button" 28 - class="btn flex items-center gap-2 group" 29 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 30 - hx-swap="outerHTML" 31 - hx-target="#pull-comment-card-{{ .RoundNumber }}" 32 - > 33 - {{ i "x" "w-4 h-4" }} 34 - <span>cancel</span> 35 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 - </button> 19 + {{ template "replyActions" . }} 37 20 <div id="pull-comment"></div> 38 21 </form> 39 22 </div> 40 23 {{ end }} 24 + 25 + {{ define "replyActions" }} 26 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm w-full"> 27 + {{ template "cancel" . }} 28 + {{ template "reply" . }} 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "cancel" }} 33 + <button 34 + type="button" 35 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 36 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 37 + hx-swap="outerHTML" 38 + hx-target="#actions-{{.RoundNumber}}" 39 + > 40 + {{ i "x" "w-4 h-4" }} 41 + <span>cancel</span> 42 + </button> 43 + {{ end }} 44 + 45 + {{ define "reply" }} 46 + <button 47 + type="submit" 48 + id="reply-{{ .RoundNumber }}" 49 + class="btn-create flex items-center gap-2"> 50 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + reply 53 + </button> 54 + {{ end }} 55 +
+2 -3
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 15 15 16 16 <div class="flex-shrink-0 flex items-center gap-2"> 17 17 {{ $latestRound := .LastRoundNumber }} 18 - {{ $lastSubmission := index .Submissions $latestRound }} 19 - {{ $commentCount := len $lastSubmission.Comments }} 18 + {{ $commentCount := .TotalComments }} 20 19 {{ if and $pipeline $pipeline.Id }} 21 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 20 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 22 21 <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 22 {{ end }} 24 23 <span>
+2 -21
appview/pages/templates/repo/pulls/interdiff.html
··· 25 25 {{ template "repo/pulls/fragments/pullHeader" . }} 26 26 </header> 27 27 </section> 28 - 29 28 {{ end }} 30 29 31 30 {{ define "mainLayout" }} ··· 34 33 {{ block "content" . }}{{ end }} 35 34 {{ end }} 36 35 37 - {{ block "contentAfterLayout" . }} 38 - <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 39 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 40 - {{ block "contentAfterLeft" . }} {{ end }} 41 - </div> 42 - <main class="col-span-1 md:col-span-10"> 43 - {{ block "contentAfter" . }}{{ end }} 44 - </main> 45 - </div> 46 - {{ end }} 36 + {{ block "contentAfter" . }}{{ end }} 47 37 </div> 48 38 {{ end }} 49 39 50 40 {{ define "contentAfter" }} 51 - {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 52 - {{end}} 53 - 54 - {{ define "contentAfterLeft" }} 55 - <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 56 - {{ template "repo/fragments/diffOpts" .DiffOpts }} 57 - </div> 58 - <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 59 - {{ template "repo/fragments/interdiffFiles" .Interdiff }} 60 - </div> 41 + {{ template "repo/fragments/diff" (list .Interdiff .DiffOpts) }} 61 42 {{end}}
+448 -232
appview/pages/templates/repo/pulls/pull.html
··· 6 6 {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 7 {{ end }} 8 8 9 + {{ define "mainLayout" }} 10 + <div class="px-1 flex-grow flex flex-col gap-4"> 11 + <div class="max-w-screen-lg mx-auto"> 12 + {{ block "contentLayout" . }} 13 + {{ block "content" . }}{{ end }} 14 + {{ end }} 15 + </div> 16 + {{ block "contentAfterLayout" . }} 17 + <main> 18 + {{ block "contentAfter" . }}{{ end }} 19 + </main> 20 + {{ end }} 21 + </div> 22 + <script> 23 + (function() { 24 + const details = document.getElementById('bottomSheet'); 25 + const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; 26 + 27 + // close on mobile initially 28 + if (!isDesktop()) { 29 + details.open = false; 30 + } 31 + 32 + // prevent closing on desktop 33 + details.addEventListener('toggle', function(e) { 34 + if (isDesktop() && !this.open) { 35 + this.open = true; 36 + } 37 + }); 38 + 39 + const mediaQuery = window.matchMedia('(min-width: 768px)'); 40 + mediaQuery.addEventListener('change', function(e) { 41 + if (e.matches) { 42 + // switched to desktop - keep open 43 + details.open = true; 44 + } else { 45 + // switched to mobile - close 46 + details.open = false; 47 + } 48 + }); 49 + })(); 50 + </script> 51 + {{ end }} 52 + 9 53 {{ define "repoContentLayout" }} 10 - <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 11 - <div class="col-span-1 md:col-span-8"> 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 13 - {{ block "repoContent" . }}{{ end }} 14 - </section> 15 - {{ block "repoAfter" . }}{{ end }} 16 - </div> 17 - <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 54 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4"> 55 + <section class="bg-white col-span-1 md:col-span-8 dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 56 + {{ block "repoContent" . }}{{ end }} 57 + </section> 58 + <div class="flex flex-col gap-6 col-span-1 md:col-span-2"> 18 59 {{ template "repo/fragments/labelPanel" 19 60 (dict "RepoInfo" $.RepoInfo 20 61 "Defs" $.LabelDefs ··· 29 70 </div> 30 71 {{ end }} 31 72 73 + {{ define "contentAfter" }} 74 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 75 + {{ end }} 76 + 32 77 {{ define "repoContent" }} 33 78 {{ template "repo/pulls/fragments/pullHeader" . }} 34 - 35 79 {{ if .Pull.IsStacked }} 36 80 <div class="mt-8"> 37 81 {{ template "repo/pulls/fragments/pullStack" . }} ··· 39 83 {{ end }} 40 84 {{ end }} 41 85 42 - {{ define "repoAfter" }} 43 - <section id="submissions" class="mt-4"> 44 - <div class="flex flex-col gap-4"> 45 - {{ block "submissions" . }} {{ end }} 86 + {{ define "diffLayout" }} 87 + {{ $diff := index . 0 }} 88 + {{ $opts := index . 1 }} 89 + {{ $root := index . 2 }} 90 + 91 + <div class="flex col-span-full"> 92 + <!-- left panel --> 93 + <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 94 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 95 + {{ template "repo/fragments/fileTree" $diff.FileTree }} 96 + </section> 97 + </div> 98 + 99 + <!-- main content --> 100 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 101 + {{ template "diffFiles" (list $diff $opts) }} 102 + </div> 103 + 104 + <!-- right panel --> 105 + {{ template "subsPanel" $ }} 106 + </div> 107 + {{ end }} 108 + 109 + {{ define "subsPanel" }} 110 + {{ $root := index . 2 }} 111 + {{ $pull := $root.Pull }} 112 + 113 + <!-- backdrop overlay - only visible on mobile when open --> 114 + <div class=" 115 + fixed inset-0 bg-black/50 z-50 md:hidden opacity-0 116 + pointer-events-none transition-opacity duration-300 117 + has-[~#subs_details[open]]:opacity-100 has-[~#subs_details[open]]:pointer-events-auto"> 118 + </div> 119 + <!-- right panel - bottom sheet on mobile, side panel on desktop --> 120 + <div id="subs" class="fixed bottom-0 left-0 right-0 z-50 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden"> 121 + <details open id="bottomSheet" class="group rounded-t-2xl md:rounded-t-sm drop-shadow-lg md:drop-shadow-none"> 122 + <summary class=" 123 + flex gap-4 items-center justify-between 124 + rounded-t-2xl md:rounded-t-sm cursor-pointer list-none p-4 md:h-12 125 + text-white md:text-black md:dark:text-white 126 + bg-green-600 dark:bg-green-600 127 + md:bg-white md:dark:bg-gray-800 128 + drop-shadow-sm 129 + md:border-b md:border-x border-gray-200 dark:border-gray-700"> 130 + <h2 class="">Review Panel </h2> 131 + {{ template "subsPanelSummary" $ }} 132 + </summary> 133 + <div class="max-h-[85vh] md:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 md:bg-transparent"> 134 + {{ template "submissions" $root }} 46 135 </div> 47 - </section> 136 + </details> 137 + </div> 138 + {{ end }} 139 + 140 + {{ define "subsPanelSummary" }} 141 + {{ $root := index . 2 }} 142 + {{ $pull := $root.Pull }} 143 + {{ $latest := $pull.LastRoundNumber }} 144 + <div class="flex items-center gap-2 text-sm"> 145 + {{ if $root.IsInterdiff }} 146 + <span> 147 + viewing interdiff of 148 + <span class="font-mono">#{{ $root.ActiveRound }}</span> 149 + and 150 + <span class="font-mono">#{{ sub $root.ActiveRound 1 }}</span> 151 + </span> 152 + {{ else }} 153 + <span> 154 + viewing round 155 + <span class="font-mono">#{{ $root.ActiveRound }}</span> 156 + </span> 157 + {{ if ne $root.ActiveRound $latest }} 158 + <span>(outdated)</span> 159 + <span class="before:content-['ยท']"></span> 160 + <a class="underline" href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $latest }}?{{ safeUrl $root.DiffOpts.Encode }}"> 161 + view latest 162 + </a> 163 + {{ end }} 164 + {{ end }} 165 + <span class="md:hidden inline"> 166 + <span class="inline group-open:hidden">{{ i "chevron-up" "size-4" }}</span> 167 + <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 168 + </span> 169 + </div> 170 + {{ end }} 48 171 49 - <div id="pull-close"></div> 50 - <div id="pull-reopen"></div> 172 + {{ define "subsCheckbox" }} 173 + <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 51 174 {{ end }} 52 175 176 + {{ define "subsToggle" }} 177 + <style> 178 + /* Mobile: full width */ 179 + #subsToggle:checked ~ div div#subs { 180 + width: 100%; 181 + margin-left: 0; 182 + } 183 + #subsToggle:checked ~ div label[for="subsToggle"] .show-toggle { display: none; } 184 + #subsToggle:checked ~ div label[for="subsToggle"] .hide-toggle { display: flex; } 185 + #subsToggle:not(:checked) ~ div label[for="subsToggle"] .hide-toggle { display: none; } 186 + 187 + /* Desktop: 25vw with left margin */ 188 + @media (min-width: 768px) { 189 + #subsToggle:checked ~ div div#subs { 190 + width: 25vw; 191 + margin-left: 1rem; 192 + } 193 + /* Unchecked state */ 194 + #subsToggle:not(:checked) ~ div div#subs { 195 + width: 0; 196 + display: none; 197 + margin-left: 0; 198 + } 199 + } 200 + </style> 201 + <label title="Toggle review panel" for="subsToggle" class="hidden md:flex items-center justify-end rounded cursor-pointer"> 202 + <span class="show-toggle">{{ i "message-square-more" "size-4" }}</span> 203 + <span class="hide-toggle w-[25vw] flex justify-end">{{ i "message-square" "size-4" }}</span> 204 + </label> 205 + {{ end }} 206 + 207 + 53 208 {{ define "submissions" }} 54 209 {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 55 - {{ $targetBranch := .Pull.TargetBranch }} 56 - {{ $repoName := .RepoInfo.FullName }} 57 - {{ range $idx, $item := .Pull.Submissions }} 58 - {{ with $item }} 59 - <details {{ if eq $idx $lastIdx }}open{{ end }}> 60 - <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 61 - <div class="flex flex-wrap gap-2 items-stretch"> 62 - <!-- round number --> 63 - <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 64 - <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 65 - </div> 66 - <!-- round summary --> 67 - <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 68 - <span class="gap-1 flex items-center"> 69 - {{ $owner := resolve $.Pull.OwnerDid }} 70 - {{ $re := "re" }} 71 - {{ if eq .RoundNumber 0 }} 72 - {{ $re = "" }} 73 - {{ end }} 74 - <span class="hidden md:inline">{{$re}}submitted</span> 75 - by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 76 - <span class="select-none before:content-['\00B7']"></span> 77 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 78 - <span class="select-none before:content-['ยท']"></span> 79 - {{ $s := "s" }} 80 - {{ if eq (len .Comments) 1 }} 81 - {{ $s = "" }} 82 - {{ end }} 83 - {{ len .Comments }} comment{{$s}} 84 - </span> 85 - </div> 210 + {{ range $ridx, $item := reverse .Pull.Submissions }} 211 + {{ $idx := sub $lastIdx $ridx }} 212 + {{ template "submission" (list $item $idx $lastIdx $) }} 213 + {{ end }} 214 + {{ end }} 86 215 87 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 88 - hx-boost="true" 89 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 90 - {{ i "file-diff" "w-4 h-4" }} 91 - <span class="hidden md:inline">diff</span> 92 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 93 - </a> 94 - {{ if ne $idx 0 }} 95 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 96 - hx-boost="true" 97 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 98 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 99 - <span class="hidden md:inline">interdiff</span> 100 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 101 - </a> 102 - {{ end }} 103 - <span id="interdiff-error-{{.RoundNumber}}"></span> 104 - </div> 105 - </summary> 216 + {{ define "submission" }} 217 + {{ $item := index . 0 }} 218 + {{ $idx := index . 1 }} 219 + {{ $lastIdx := index . 2 }} 220 + {{ $root := index . 3 }} 221 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-800/50"> 222 + {{ template "submissionHeader" $ }} 223 + {{ template "submissionComments" $ }} 106 224 107 - {{ if .IsFormatPatch }} 108 - {{ $patches := .AsFormatPatch }} 109 - {{ $round := .RoundNumber }} 110 - <details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm"> 111 - <summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 112 - {{ $s := "s" }} 113 - {{ if eq (len $patches) 1 }} 114 - {{ $s = "" }} 115 - {{ end }} 116 - <div class="group-open:hidden flex items-center gap-2 ml-2"> 117 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}} 118 - </div> 119 - <div class="hidden group-open:flex items-center gap-2 ml-2"> 120 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}} 121 - </div> 122 - </summary> 123 - {{ range $patches }} 124 - <div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col"> 125 - <div class="flex items-center gap-2"> 126 - {{ i "git-commit-horizontal" "w-4 h-4" }} 127 - <div class="text-sm text-gray-500 dark:text-gray-400"> 128 - <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 129 - {{ $fullRepo := "" }} 130 - {{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }} 131 - {{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }} 132 - {{ else if $.Pull.IsBranchBased }} 133 - {{ $fullRepo = $.RepoInfo.FullName }} 134 - {{ end }} 225 + {{ if eq $lastIdx $item.RoundNumber }} 226 + {{ block "mergeStatus" $root }} {{ end }} 227 + {{ block "resubmitStatus" $root }} {{ end }} 228 + {{ end }} 135 229 136 - <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 137 - {{ if $fullRepo }} 138 - <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 139 - {{ else }} 140 - <span class="font-mono">{{ slice .SHA 0 8 }}</span> 141 - {{ end }} 142 - </div> 143 - <div class="flex items-center"> 144 - <span>{{ .Title | description }}</span> 145 - {{ if gt (len .Body) 0 }} 146 - <button 147 - class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 148 - hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 149 - > 150 - {{ i "ellipsis" "w-3 h-3" }} 151 - </button> 152 - {{ end }} 153 - </div> 154 - </div> 155 - {{ if gt (len .Body) 0 }} 156 - <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2"> 157 - {{ nl2br .Body }} 158 - </p> 159 - {{ end }} 160 - </div> 161 - {{ end }} 162 - </details> 163 - {{ end }} 230 + {{ if $root.LoggedInUser }} 231 + {{ template "repo/pulls/fragments/pullActions" 232 + (dict 233 + "LoggedInUser" $root.LoggedInUser 234 + "Pull" $root.Pull 235 + "RepoInfo" $root.RepoInfo 236 + "RoundNumber" $item.RoundNumber 237 + "MergeCheck" $root.MergeCheck 238 + "ResubmitCheck" $root.ResubmitCheck 239 + "BranchDeleteStatus" $root.BranchDeleteStatus 240 + "Stack" $root.Stack) }} 241 + {{ else }} 242 + {{ template "loginPrompt" $ }} 243 + {{ end }} 244 + </div> 245 + {{ end }} 164 246 247 + {{ define "submissionHeader" }} 248 + {{ $item := index . 0 }} 249 + {{ $lastIdx := index . 2 }} 250 + {{ $root := index . 3 }} 251 + {{ $round := $item.RoundNumber }} 252 + <div class="rounded px-6 py-4 pr-2 pt-2 bg-white dark:bg-gray-800 flex gap-2 sticky top-0 z-20 border-b border-gray-200 dark:border-gray-700"> 253 + <!-- left column: just profile picture --> 254 + <div class="flex-shrink-0 pt-2"> 255 + <img 256 + src="{{ tinyAvatar $root.Pull.OwnerDid }}" 257 + alt="" 258 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 259 + /> 260 + </div> 261 + <!-- right column --> 262 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 263 + {{ template "submissionInfo" $ }} 264 + {{ template "submissionCommits" $ }} 265 + {{ template "submissionPipeline" $ }} 266 + {{ if eq $lastIdx $round }} 267 + {{ block "mergeCheck" $root }} {{ end }} 268 + {{ end }} 269 + </div> 270 + </div> 271 + {{ end }} 165 272 166 - <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 - {{ range $cidx, $c := .Comments }} 168 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 - {{ if gt $cidx 0 }} 170 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 - {{ end }} 172 - <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 - {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 174 - <span class="before:content-['ยท']"></span> 175 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 176 - </div> 177 - <div class="prose dark:prose-invert"> 178 - {{ $c.Body | markdown }} 179 - </div> 180 - </div> 181 - {{ end }} 273 + {{ define "submissionInfo" }} 274 + {{ $item := index . 0 }} 275 + {{ $idx := index . 1 }} 276 + {{ $root := index . 3 }} 277 + {{ $round := $item.RoundNumber }} 278 + <div class="flex gap-2 items-center justify-between mb-1"> 279 + <span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 pt-2"> 280 + {{ resolve $root.Pull.OwnerDid }} submitted v{{ $round }} 281 + <span class="select-none before:content-['\00B7']"></span> 282 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}"> 283 + {{ template "repo/fragments/shortTimeAgo" $item.Created }} 284 + </a> 285 + </span> 286 + <div class="flex gap-2 items-center"> 287 + {{ if ne $root.ActiveRound $round }} 288 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 289 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}"> 290 + {{ i "diff" "w-4 h-4" }} 291 + diff 292 + </a> 293 + {{ end }} 294 + {{ if ne $idx 0 }} 295 + <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 296 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}/interdiff?{{ safeUrl $root.DiffOpts.Encode }}"> 297 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 298 + interdiff 299 + </a> 300 + {{ end }} 301 + </div> 302 + </div> 303 + {{ end }} 182 304 183 - {{ block "pipelineStatus" (list $ .) }} {{ end }} 305 + {{ define "submissionCommits" }} 306 + {{ $item := index . 0 }} 307 + {{ $root := index . 3 }} 308 + {{ $round := $item.RoundNumber }} 309 + {{ $patches := $item.AsFormatPatch }} 310 + {{ if $patches }} 311 + <details class="group/commit"> 312 + <summary class="list-none cursor-pointer flex items-center gap-2"> 313 + <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 314 + {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 315 + <div class="text-sm text-gray-500 dark:text-gray-400"> 316 + <span class="group-open/commit:hidden inline">expand</span> 317 + <span class="hidden group-open/commit:inline">collapse</span> 318 + </div> 319 + </summary> 320 + {{ range $patches }} 321 + {{ template "submissionCommit" (list . $item $root) }} 322 + {{ end }} 323 + </details> 324 + {{ end }} 325 + {{ end }} 184 326 185 - {{ if eq $lastIdx .RoundNumber }} 186 - {{ block "mergeStatus" $ }} {{ end }} 187 - {{ block "resubmitStatus" $ }} {{ end }} 327 + {{ define "submissionCommit" }} 328 + {{ $patch := index . 0 }} 329 + {{ $item := index . 1 }} 330 + {{ $root := index . 2 }} 331 + {{ $round := $item.RoundNumber }} 332 + {{ with $patch }} 333 + <div id="commit-{{.SHA}}" class="py-1 relative w-full md:max-w-3/5 md:w-fit flex flex-col text-gray-600 dark:text-gray-300"> 334 + <div class="flex items-baseline gap-2"> 335 + <div class="text-xs"> 336 + <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 337 + {{ $fullRepo := "" }} 338 + {{ if and $root.Pull.IsForkBased $root.Pull.PullSource.Repo }} 339 + {{ $fullRepo = printf "%s/%s" $root.Pull.OwnerDid $root.Pull.PullSource.Repo.Name }} 340 + {{ else if $root.Pull.IsBranchBased }} 341 + {{ $fullRepo = $root.RepoInfo.FullName }} 188 342 {{ end }} 189 343 190 - {{ if $.LoggedInUser }} 191 - {{ template "repo/pulls/fragments/pullActions" 192 - (dict 193 - "LoggedInUser" $.LoggedInUser 194 - "Pull" $.Pull 195 - "RepoInfo" $.RepoInfo 196 - "RoundNumber" .RoundNumber 197 - "MergeCheck" $.MergeCheck 198 - "ResubmitCheck" $.ResubmitCheck 199 - "BranchDeleteStatus" $.BranchDeleteStatus 200 - "Stack" $.Stack) }} 344 + <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 345 + {{ if $fullRepo }} 346 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 201 347 {{ else }} 202 - <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"> 203 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 204 - sign up 205 - </a> 206 - <span class="text-gray-500 dark:text-gray-400">or</span> 207 - <a href="/login" class="underline">login</a> 208 - to add to the discussion 209 - </div> 348 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 349 + {{ end }} 350 + </div> 351 + 352 + <div> 353 + <span>{{ .Title | description }}</span> 354 + {{ if gt (len .Body) 0 }} 355 + <button 356 + class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 357 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 358 + > 359 + {{ i "ellipsis" "w-3 h-3" }} 360 + </button> 361 + {{ end }} 362 + {{ if gt (len .Body) 0 }} 363 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 pb-2">{{ nl2br .Body }}</p> 210 364 {{ end }} 211 365 </div> 366 + </div> 367 + </div> 368 + {{ end }} 369 + {{ end }} 370 + 371 + {{ define "mergeCheck" }} 372 + {{ $isOpen := .Pull.State.IsOpen }} 373 + {{ if and $isOpen .MergeCheck .MergeCheck.Error }} 374 + <div class="flex items-center gap-2"> 375 + {{ i "triangle-alert" "w-4 h-4 text-red-600 dark:text-red-500" }} 376 + {{ .MergeCheck.Error }} 377 + </div> 378 + {{ else if and $isOpen .MergeCheck .MergeCheck.IsConflicted }} 379 + <details class="group/conflict"> 380 + <summary class="flex items-center justify-between cursor-pointer list-none"> 381 + <div class="flex items-center gap-2 "> 382 + {{ i "triangle-alert" "text-red-600 dark:text-red-500 w-4 h-4" }} 383 + <span class="font-medium">merge conflicts detected</span> 384 + <div class="text-sm text-gray-500 dark:text-gray-400"> 385 + <span class="group-open/conflict:hidden inline">expand</span> 386 + <span class="hidden group-open/conflict:inline">collapse</span> 387 + </div> 388 + </div> 389 + </summary> 390 + {{ if gt (len .MergeCheck.Conflicts) 0 }} 391 + <ul class="space-y-1 mt-2 overflow-x-auto"> 392 + {{ range .MergeCheck.Conflicts }} 393 + {{ if .Filename }} 394 + <li class="flex items-center whitespace-nowrap"> 395 + {{ i "file-warning" "inline-flex w-4 h-4 mr-1.5 text-red-600 dark:text-red-500 flex-shrink-0" }} 396 + <span class="font-mono">{{ .Filename }}</span> 397 + </li> 398 + {{ else if .Reason }} 399 + <li class="flex items-center whitespace-nowrap"> 400 + {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-600 dark:text-red-500 " }} 401 + <span>{{.Reason}}</span> 402 + </li> 403 + {{ end }} 404 + {{ end }} 405 + </ul> 406 + {{ end }} 212 407 </details> 213 - {{ end }} 408 + {{ else if and $isOpen .MergeCheck }} 409 + <div class="flex items-center gap-2"> 410 + {{ i "check" "w-4 h-4 text-green-600 dark:text-green-500" }} 411 + <span>no conflicts, ready to merge</span> 412 + </div> 214 413 {{ end }} 215 414 {{ end }} 216 415 217 416 {{ define "mergeStatus" }} 218 417 {{ if .Pull.State.IsClosed }} 219 - <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 418 + <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative"> 220 419 <div class="flex items-center gap-2 text-black dark:text-white"> 221 420 {{ i "ban" "w-4 h-4" }} 222 421 <span class="font-medium">closed without merging</span ··· 224 423 </div> 225 424 </div> 226 425 {{ else if .Pull.State.IsMerged }} 227 - <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 426 + <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative"> 228 427 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 229 428 {{ i "git-merge" "w-4 h-4" }} 230 429 <span class="font-medium">pull request successfully merged</span ··· 232 431 </div> 233 432 </div> 234 433 {{ else if .Pull.State.IsDeleted }} 235 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 434 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative"> 236 435 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 237 436 {{ i "git-pull-request-closed" "w-4 h-4" }} 238 437 <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 239 438 </div> 240 439 </div> 241 - {{ else if and .MergeCheck .MergeCheck.Error }} 242 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 243 - <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 244 - {{ i "triangle-alert" "w-4 h-4" }} 245 - <span class="font-medium">{{ .MergeCheck.Error }}</span> 246 - </div> 247 - </div> 248 - {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 249 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 250 - <div class="flex flex-col gap-2 text-red-500 dark:text-red-300"> 251 - <div class="flex items-center gap-2"> 252 - {{ i "triangle-alert" "w-4 h-4" }} 253 - <span class="font-medium">merge conflicts detected</span> 254 - </div> 255 - {{ if gt (len .MergeCheck.Conflicts) 0 }} 256 - <ul class="space-y-1"> 257 - {{ range .MergeCheck.Conflicts }} 258 - {{ if .Filename }} 259 - <li class="flex items-center"> 260 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 261 - <span class="font-mono">{{ .Filename }}</span> 262 - </li> 263 - {{ else if .Reason }} 264 - <li class="flex items-center"> 265 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 266 - <span>{{.Reason}}</span> 267 - </li> 268 - {{ end }} 269 - {{ end }} 270 - </ul> 271 - {{ end }} 272 - </div> 273 - </div> 274 - {{ else if .MergeCheck }} 275 - <div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 276 - <div class="flex items-center gap-2 text-green-500 dark:text-green-300"> 277 - {{ i "circle-check-big" "w-4 h-4" }} 278 - <span class="font-medium">no conflicts, ready to merge</span> 279 - </div> 280 - </div> 281 440 {{ end }} 282 441 {{ end }} 283 442 284 443 {{ define "resubmitStatus" }} 285 444 {{ if .ResubmitCheck.Yes }} 286 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 445 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative"> 287 446 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 288 447 {{ i "triangle-alert" "w-4 h-4" }} 289 448 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 292 451 {{ end }} 293 452 {{ end }} 294 453 295 - {{ define "pipelineStatus" }} 296 - {{ $root := index . 0 }} 297 - {{ $submission := index . 1 }} 298 - {{ $pipeline := index $root.Pipelines $submission.SourceRev }} 454 + {{ define "submissionPipeline" }} 455 + {{ $item := index . 0 }} 456 + {{ $root := index . 3 }} 457 + {{ $pipeline := index $root.Pipelines $item.SourceRev }} 299 458 {{ with $pipeline }} 300 459 {{ $id := .Id }} 301 460 {{ if .Statuses }} 302 - <div class="max-w-80 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 303 - {{ range $name, $all := .Statuses }} 304 - <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 305 - <div 306 - class="flex gap-2 items-center justify-between p-2"> 307 - {{ $lastStatus := $all.Latest }} 308 - {{ $kind := $lastStatus.Status.String }} 461 + <details class="group/pipeline"> 462 + <summary class="cursor-pointer list-none flex items-center gap-2"> 463 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }} 464 + <div class="text-sm text-gray-500 dark:text-gray-400"> 465 + <span class="group-open/pipeline:hidden inline">expand</span> 466 + <span class="hidden group-open/pipeline:inline">collapse</span> 467 + </div> 468 + </summary> 469 + <div class="my-2 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 470 + {{ range $name, $all := .Statuses }} 471 + <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 472 + <div 473 + class="flex gap-2 items-center justify-between p-2"> 474 + {{ $lastStatus := $all.Latest }} 475 + {{ $kind := $lastStatus.Status.String }} 309 476 310 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 311 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 312 - {{ $name }} 313 - </div> 314 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 315 - <span class="font-bold">{{ $kind }}</span> 316 - {{ if .TimeTaken }} 317 - {{ template "repo/fragments/duration" .TimeTaken }} 318 - {{ else }} 319 - {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 320 - {{ end }} 321 - </div> 477 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 478 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 479 + {{ $name }} 480 + </div> 481 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 482 + <span class="font-bold">{{ $kind }}</span> 483 + {{ if .TimeTaken }} 484 + {{ template "repo/fragments/duration" .TimeTaken }} 485 + {{ else }} 486 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 487 + {{ end }} 488 + </div> 489 + </div> 490 + </a> 491 + {{ end }} 322 492 </div> 493 + </details> 494 + {{ end }} 495 + {{ end }} 496 + {{ end }} 497 + 498 + {{ define "submissionComments" }} 499 + {{ $item := index . 0 }} 500 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 501 + {{ range $item.Comments }} 502 + {{ template "submissionComment" . }} 503 + {{ end }} 504 + </div> 505 + {{ end }} 506 + 507 + {{ define "submissionComment" }} 508 + <div id="comment-{{.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 509 + <!-- left column: profile picture --> 510 + <div class="flex-shrink-0"> 511 + <img 512 + src="{{ tinyAvatar .OwnerDid }}" 513 + alt="" 514 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 515 + /> 516 + </div> 517 + <!-- right column: name and body in two rows --> 518 + <div class="flex-1 min-w-0"> 519 + <!-- Row 1: Author and timestamp --> 520 + <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 521 + <span>{{ resolve .OwnerDid }}</span> 522 + <span class="before:content-['ยท']"></span> 523 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"> 524 + {{ template "repo/fragments/time" .Created }} 323 525 </a> 324 - {{ end }} 526 + </div> 527 + <!-- Row 2: Body text --> 528 + <div class="prose dark:prose-invert mt-1"> 529 + {{ .Body | markdown }} 325 530 </div> 326 - {{ end }} 327 - {{ end }} 531 + </div> 532 + </div> 533 + {{ end }} 534 + 535 + {{ define "loginPrompt" }} 536 + <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"> 537 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 538 + sign up 539 + </a> 540 + <span class="text-gray-500 dark:text-gray-400">or</span> 541 + <a href="/login" class="underline">login</a> 542 + to add to the discussion 543 + </div> 328 544 {{ end }}
+11 -12
appview/pages/templates/repo/pulls/pulls.html
··· 112 112 {{ template "repo/fragments/time" .Created }} 113 113 </span> 114 114 115 - 116 - {{ $latestRound := .LastRoundNumber }} 117 - {{ $lastSubmission := index .Submissions $latestRound }} 118 - 119 115 <span class="before:content-['ยท']"> 120 - {{ $commentCount := len $lastSubmission.Comments }} 121 - {{ $s := "s" }} 122 - {{ if eq $commentCount 1 }} 123 - {{ $s = "" }} 124 - {{ end }} 125 - 126 - {{ len $lastSubmission.Comments}} comment{{$s}} 116 + {{ $commentCount := .TotalComments }} 117 + {{ $commentCount }} comment{{ if ne $commentCount 1 }}s{{ end }} 127 118 </span> 128 119 129 120 <span class="before:content-['ยท']"> ··· 136 127 {{ $pipeline := index $.Pipelines .LatestSha }} 137 128 {{ if and $pipeline $pipeline.Id }} 138 129 <span class="before:content-['ยท']"></span> 139 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 130 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 140 131 {{ end }} 141 132 142 133 {{ $state := .Labels }} ··· 170 161 </div> 171 162 {{ end }} 172 163 </div> 164 + {{if gt .PullCount .Page.Limit }} 165 + {{ template "fragments/pagination" (dict 166 + "Page" .Page 167 + "TotalCount" .PullCount 168 + "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 169 + "QueryParams" (printf "state=%s&q=%s" .FilteringBy.String .FilterQuery) 170 + ) }} 171 + {{ end }} 173 172 {{ end }} 174 173 175 174 {{ define "stackedPullList" }}
+24 -16
appview/pages/templates/strings/string.html
··· 10 10 11 11 {{ define "content" }} 12 12 {{ $ownerId := resolve .Owner.DID.String }} 13 - <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 - <div class="text-lg flex items-center justify-between"> 15 - <div> 16 - <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 17 - <span class="select-none">/</span> 18 - <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 13 + <section id="string-header" class="mb-2 py-2 px-4 dark:text-white"> 14 + <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 15 + <!-- left items --> 16 + <div class="flex flex-col gap-2"> 17 + <!-- string owner / string name --> 18 + <div class="flex items-center gap-2 flex-wrap"> 19 + {{ template "user/fragments/picHandleLink" .Owner.DID.String }} 20 + <span class="select-none">/</span> 21 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 22 + </div> 23 + 24 + <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 25 + {{ if .String.Description }} 26 + {{ .String.Description }} 27 + {{ else }} 28 + <span class="italic">this string has no description</span> 29 + {{ end }} 30 + </span> 19 31 </div> 20 - <div class="flex gap-2 items-stretch text-base"> 32 + 33 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 21 34 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 35 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 23 36 hx-boost="true" 24 37 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 25 - {{ i "pencil" "size-4" }} 38 + {{ i "pencil" "w-4 h-4" }} 26 39 <span class="hidden md:inline">edit</span> 27 40 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 28 41 </a> 29 42 <button 30 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 43 + class="btn text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-2 group" 31 44 title="Delete string" 32 45 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 33 46 hx-swap="none" 34 47 hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 35 48 > 36 - {{ i "trash-2" "size-4" }} 49 + {{ i "trash-2" "w-4 h-4" }} 37 50 <span class="hidden md:inline">delete</span> 38 51 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 52 </button> ··· 44 57 "StarCount" .StarCount) }} 45 58 </div> 46 59 </div> 47 - <span> 48 - {{ with .String.Description }} 49 - {{ . }} 50 - {{ end }} 51 - </span> 52 60 </section> 53 61 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 54 62 <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
+6
appview/pages/templates/user/fragments/follow-oob.html
··· 1 + {{ define "user/fragments/follow-oob" }} 2 + {{ template "user/fragments/follow" . }} 3 + <span hx-swap-oob='innerHTML:[data-followers-did="{{ .UserDid }}"]'> 4 + <a href="/{{ resolve .UserDid }}?tab=followers">{{ .FollowersCount }} followers</a> 5 + </span> 6 + {{ end }}
+5 -3
appview/pages/templates/user/fragments/followCard.html
··· 9 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 11 <a href="/{{ $userIdent }}"> 12 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ 13 + $userIdent | truncateAt30 }}</span> 13 14 </a> 14 15 {{ with .Profile }} 15 16 <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 17 {{ end }} 17 18 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 19 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 + <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 21 + .FollowersCount }} followers</a></span> 20 22 <span class="select-none after:content-['ยท']"></span> 21 23 <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 24 </div> ··· 29 31 </div> 30 32 </div> 31 33 </div> 32 - {{ end }} 34 + {{ end }}
+97 -99
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := resolve .UserDid }} 3 - <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 - <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - <div class="w-3/4 aspect-square relative"> 6 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 - </div> 8 - </div> 9 - <div class="col-span-2"> 10 - <div class="flex items-center flex-row flex-nowrap gap-2"> 11 - <p title="{{ $userIdent }}" 12 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 - {{ $userIdent }} 14 - </p> 15 - {{ with .Profile }} 16 - {{ if .Pronouns }} 17 - <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 - {{ end }} 19 - {{ end }} 20 - </div> 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 + <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 + <div class="w-3/4 aspect-square relative"> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 + </div> 8 + </div> 9 + <div class="col-span-2"> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ $userIdent }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ $userIdent }} 14 + </p> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 20 + </div> 21 21 22 - <div class="md:hidden"> 23 - {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 24 - </div> 25 - </div> 26 - <div class="col-span-3 md:col-span-full"> 27 - <div id="profile-bio" class="text-sm"> 28 - {{ $profile := .Profile }} 29 - {{ with .Profile }} 22 + <div class="md:hidden"> 23 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 24 + </div> 25 + </div> 26 + <div class="col-span-3 md:col-span-full"> 27 + <div id="profile-bio" class="text-sm"> 28 + {{ $profile := .Profile }} 29 + {{ with .Profile }} 30 30 31 - {{ if .Description }} 32 - <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 33 - {{ end }} 31 + {{ if .Description }} 32 + <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 33 + {{ end }} 34 34 35 - <div class="hidden md:block"> 36 - {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 37 - </div> 35 + <div class="hidden md:block"> 36 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 37 + </div> 38 38 39 - <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 40 - {{ if .Location }} 41 - <div class="flex items-center gap-2"> 42 - <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 43 - <span>{{ .Location }}</span> 44 - </div> 45 - {{ end }} 46 - {{ if .IncludeBluesky }} 47 - <div class="flex items-center gap-2"> 48 - <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 49 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 50 - </div> 51 - {{ end }} 52 - {{ range $link := .Links }} 53 - {{ if $link }} 54 - <div class="flex items-center gap-2"> 55 - <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 56 - <a href="{{ $link }}">{{ $link }}</a> 57 - </div> 58 - {{ end }} 59 - {{ end }} 60 - {{ if not $profile.IsStatsEmpty }} 61 - <div class="flex items-center justify-evenly gap-2 py-2"> 62 - {{ range $stat := .Stats }} 63 - {{ if $stat.Kind }} 64 - <div class="flex flex-col items-center gap-2"> 65 - <span class="text-xl font-bold">{{ $stat.Value }}</span> 66 - <span>{{ $stat.Kind.String }}</span> 67 - </div> 68 - {{ end }} 69 - {{ end }} 70 - </div> 71 - {{ end }} 39 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 40 + {{ if .Location }} 41 + <div class="flex items-center gap-2"> 42 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 43 + <span>{{ .Location }}</span> 44 + </div> 45 + {{ end }} 46 + {{ if .IncludeBluesky }} 47 + <div class="flex items-center gap-2"> 48 + <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" 49 + }}</span> 50 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 51 + </div> 52 + {{ end }} 53 + {{ range $link := .Links }} 54 + {{ if $link }} 55 + <div class="flex items-center gap-2"> 56 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 57 + <a href="{{ $link }}">{{ $link }}</a> 58 + </div> 59 + {{ end }} 60 + {{ end }} 61 + {{ if not $profile.IsStatsEmpty }} 62 + <div class="flex items-center justify-evenly gap-2 py-2"> 63 + {{ range $stat := .Stats }} 64 + {{ if $stat.Kind }} 65 + <div class="flex flex-col items-center gap-2"> 66 + <span class="text-xl font-bold">{{ $stat.Value }}</span> 67 + <span>{{ $stat.Kind.String }}</span> 72 68 </div> 73 69 {{ end }} 74 - 75 - <div class="flex mt-2 items-center gap-2"> 76 - {{ if ne .FollowStatus.String "IsSelf" }} 77 - {{ template "user/fragments/follow" . }} 78 - {{ else }} 79 - <button id="editBtn" 80 - class="btn w-full flex items-center gap-2 group" 81 - hx-target="#profile-bio" 82 - hx-get="/profile/edit-bio" 83 - hx-swap="innerHTML"> 84 - {{ i "pencil" "w-4 h-4" }} 85 - edit 86 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 - </button> 88 - {{ end }} 70 + {{ end }} 71 + </div> 72 + {{ end }} 73 + </div> 74 + {{ end }} 89 75 90 - <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 - href="/{{ $userIdent }}/feed.atom"> 92 - {{ i "rss" "size-4" }} 93 - </a> 94 - </div> 76 + <div class="flex mt-2 items-center gap-2"> 77 + {{ if ne .FollowStatus.String "IsSelf" }} 78 + {{ template "user/fragments/follow" . }} 79 + {{ else }} 80 + <button id="editBtn" class="btn w-full flex items-center gap-2 group" hx-target="#profile-bio" 81 + hx-get="/profile/edit-bio" hx-swap="innerHTML"> 82 + {{ i "pencil" "w-4 h-4" }} 83 + edit 84 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </button> 86 + {{ end }} 95 87 96 - </div> 97 - <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 88 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 89 + href="/{{ $userIdent }}/feed.atom"> 90 + {{ i "rss" "size-4" }} 91 + </a> 98 92 </div> 93 + 99 94 </div> 95 + <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 96 + </div> 97 + </div> 100 98 {{ end }} 101 99 102 100 {{ define "followerFollowing" }} 103 - {{ $root := index . 0 }} 104 - {{ $userIdent := index . 1 }} 105 - {{ with $root }} 106 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 107 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 108 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 109 - <span class="select-none after:content-['ยท']"></span> 110 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 111 - </div> 112 - {{ end }} 101 + {{ $root := index . 0 }} 102 + {{ $userIdent := index . 1 }} 103 + {{ with $root }} 104 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 105 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 106 + <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 107 + .Stats.FollowersCount }} followers</a></span> 108 + <span class="select-none after:content-['ยท']"></span> 109 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 110 + </div> 113 111 {{ end }} 114 - 112 + {{ end }}
+53
appview/pages/templates/user/login.html
··· 20 20 <h2 class="text-center text-xl italic dark:text-white"> 21 21 tightly-knit social coding. 22 22 </h2> 23 + 24 + {{ if .AddAccount }} 25 + <div class="flex gap-2 my-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-300 dark:border-sky-800 rounded px-3 py-2 text-blue-600 dark:text-blue-300"> 26 + <span class="py-1">{{ i "user-plus" "w-4 h-4" }}</span> 27 + <div> 28 + <h5 class="font-medium">Add another account</h5> 29 + <p class="text-sm">Sign in with a different account to add it to your account list.</p> 30 + </div> 31 + </div> 32 + {{ end }} 33 + 34 + {{ if and .LoggedInUser .LoggedInUser.Accounts }} 35 + {{ $accounts := .LoggedInUser.Accounts }} 36 + {{ if $accounts }} 37 + <div class="my-4 border border-gray-200 dark:border-gray-700 rounded overflow-hidden"> 38 + <div class="px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> 39 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide font-medium">Saved accounts</span> 40 + </div> 41 + <div class="divide-y divide-gray-200 dark:divide-gray-700"> 42 + {{ range $accounts }} 43 + <div class="flex items-center justify-between px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"> 44 + <button 45 + type="button" 46 + hx-post="/account/switch" 47 + hx-vals='{"did": "{{ .Did }}"}' 48 + hx-swap="none" 49 + class="flex items-center gap-2 flex-1 text-left min-w-0" 50 + > 51 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 52 + <div class="flex flex-col min-w-0"> 53 + <span class="text-sm font-medium dark:text-white truncate">{{ .Did | resolve | truncateAt30 }}</span> 54 + <span class="text-xs text-gray-500 dark:text-gray-400">Click to switch</span> 55 + </div> 56 + </button> 57 + <button 58 + type="button" 59 + hx-delete="/account/{{ .Did }}" 60 + hx-swap="none" 61 + class="p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 flex-shrink-0" 62 + title="Remove account" 63 + > 64 + {{ i "x" "w-4 h-4" }} 65 + </button> 66 + </div> 67 + {{ end }} 68 + </div> 69 + </div> 70 + {{ end }} 71 + {{ end }} 72 + 23 73 <form 24 74 class="mt-4" 25 75 hx-post="/login" ··· 46 96 </span> 47 97 </div> 48 98 <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 99 + <input type="hidden" name="add_account" value="{{ if .AddAccount }}true{{ end }}"> 49 100 50 101 <button 51 102 class="btn w-full my-2 mt-6 text-base " ··· 66 117 You have not authorized the app. 67 118 {{ else if eq .ErrorCode "session" }} 68 119 Server failed to create user session. 120 + {{ else if eq .ErrorCode "max_accounts" }} 121 + You have reached the maximum of 20 linked accounts. Please remove an account before adding a new one. 69 122 {{ else }} 70 123 Internal Server error. 71 124 {{ end }}
+2 -3
appview/pipelines/pipelines.go
··· 77 77 } 78 78 79 79 func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) { 80 - user := p.oauth.GetUser(r) 80 + user := p.oauth.GetMultiAccountUser(r) 81 81 l := p.logger.With("handler", "Index") 82 82 83 83 f, err := p.repoResolver.Resolve(r) ··· 106 106 } 107 107 108 108 func (p *Pipelines) Workflow(w http.ResponseWriter, r *http.Request) { 109 - user := p.oauth.GetUser(r) 109 + user := p.oauth.GetMultiAccountUser(r) 110 110 l := p.logger.With("handler", "Workflow") 111 111 112 112 f, err := p.repoResolver.Resolve(r) ··· 391 391 Workflow: workflow, 392 392 }, 393 393 ) 394 - err = fmt.Errorf("boo! new error") 395 394 errorId := "workflow-error" 396 395 if err != nil { 397 396 l.Error("failed to cancel workflow", "err", err)
+3 -3
appview/pulls/opengraph.go
··· 18 18 "tangled.org/core/types" 19 19 ) 20 20 21 - func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) { 21 + func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { 22 22 width, height := ogcard.DefaultSize() 23 23 mainCard, err := ogcard.NewCard(width, height) 24 24 if err != nil { ··· 242 242 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 243 243 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 244 244 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 245 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 245 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 246 246 if err != nil { 247 247 log.Printf("dolly silhouette not available (this is ok): %v", err) 248 248 } ··· 284 284 commentCount := len(comments) 285 285 286 286 // Calculate diff stats from latest submission using patchutil 287 - var diffStats types.DiffStat 287 + var diffStats types.DiffFileStat 288 288 filesChanged := 0 289 289 if len(pull.Submissions) > 0 { 290 290 latestSubmission := pull.Submissions[len(pull.Submissions)-1]
+197 -196
appview/pulls/pulls.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "bytes" 5 + "compress/gzip" 4 6 "context" 5 7 "database/sql" 6 8 "encoding/json" 7 9 "errors" 8 10 "fmt" 11 + "io" 9 12 "log" 10 13 "log/slog" 11 14 "net/http" ··· 26 29 "tangled.org/core/appview/pages" 27 30 "tangled.org/core/appview/pages/markup" 28 31 "tangled.org/core/appview/pages/repoinfo" 32 + "tangled.org/core/appview/pagination" 29 33 "tangled.org/core/appview/reporesolver" 30 34 "tangled.org/core/appview/validator" 31 35 "tangled.org/core/appview/xrpcclient" ··· 93 97 func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 94 98 switch r.Method { 95 99 case http.MethodGet: 96 - user := s.oauth.GetUser(r) 100 + user := s.oauth.GetMultiAccountUser(r) 97 101 f, err := s.repoResolver.Resolve(r) 98 102 if err != nil { 99 103 log.Println("failed to get repo and knot", err) ··· 124 128 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 125 129 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 126 130 resubmitResult := pages.Unknown 127 - if user.Did == pull.OwnerDid { 131 + if user.Active.Did == pull.OwnerDid { 128 132 resubmitResult = s.resubmitCheck(r, f, pull, stack) 129 133 } 130 134 ··· 142 146 } 143 147 } 144 148 145 - func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 146 - user := s.oauth.GetUser(r) 149 + func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 150 + user := s.oauth.GetMultiAccountUser(r) 147 151 f, err := s.repoResolver.Resolve(r) 148 152 if err != nil { 149 153 log.Println("failed to get repo and knot", err) ··· 164 168 return 165 169 } 166 170 171 + roundId := chi.URLParam(r, "round") 172 + roundIdInt := pull.LastRoundNumber() 173 + if r, err := strconv.Atoi(roundId); err == nil { 174 + roundIdInt = r 175 + } 176 + if roundIdInt >= len(pull.Submissions) { 177 + http.Error(w, "bad round id", http.StatusBadRequest) 178 + log.Println("failed to parse round id", err) 179 + return 180 + } 181 + 182 + var diffOpts types.DiffOpts 183 + if d := r.URL.Query().Get("diff"); d == "split" { 184 + diffOpts.Split = true 185 + } 186 + 167 187 // can be nil if this pull is not stacked 168 188 stack, _ := r.Context().Value("stack").(models.Stack) 169 189 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 171 191 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 172 192 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 173 193 resubmitResult := pages.Unknown 174 - if user != nil && user.Did == pull.OwnerDid { 194 + if user != nil && user.Active != nil && user.Active.Did == pull.OwnerDid { 175 195 resubmitResult = s.resubmitCheck(r, f, pull, stack) 176 196 } 177 197 ··· 213 233 214 234 userReactions := map[models.ReactionKind]bool{} 215 235 if user != nil { 216 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 236 + userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 217 237 } 218 238 219 239 labelDefs, err := db.GetLabelDefinitions( ··· 232 252 defs[l.AtUri().String()] = &l 233 253 } 234 254 255 + patch := pull.Submissions[roundIdInt].CombinedPatch() 256 + var diff types.DiffRenderer 257 + diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 258 + 259 + if interdiff { 260 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 261 + if err != nil { 262 + log.Println("failed to interdiff; current patch malformed") 263 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 264 + return 265 + } 266 + 267 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 268 + if err != nil { 269 + log.Println("failed to interdiff; previous patch malformed") 270 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 271 + return 272 + } 273 + 274 + diff = patchutil.Interdiff(previousPatch, currentPatch) 275 + } 276 + 235 277 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 236 278 LoggedInUser: user, 237 279 RepoInfo: s.repoResolver.GetRepoInfo(r, user), ··· 243 285 MergeCheck: mergeCheckResponse, 244 286 ResubmitCheck: resubmitResult, 245 287 Pipelines: m, 288 + Diff: diff, 289 + DiffOpts: diffOpts, 290 + ActiveRound: roundIdInt, 291 + IsInterdiff: interdiff, 246 292 247 293 OrderedReactionKinds: models.OrderedReactionKinds, 248 294 Reactions: reactionMap, ··· 250 296 251 297 LabelDefs: defs, 252 298 }) 299 + } 300 + 301 + func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 302 + s.repoPullHelper(w, r, false) 253 303 } 254 304 255 305 func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { ··· 324 374 return nil 325 375 } 326 376 327 - user := s.oauth.GetUser(r) 377 + user := s.oauth.GetMultiAccountUser(r) 328 378 if user == nil { 329 379 return nil 330 380 } ··· 347 397 } 348 398 349 399 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 350 - perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 400 + perms := s.enforcer.GetPermissionsInRepo(user.Active.Did, repo.Knot, repo.DidSlashRepo()) 351 401 if !slices.Contains(perms, "repo:push") { 352 402 return nil 353 403 } ··· 434 484 } 435 485 436 486 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 437 - user := s.oauth.GetUser(r) 438 - 439 - var diffOpts types.DiffOpts 440 - if d := r.URL.Query().Get("diff"); d == "split" { 441 - diffOpts.Split = true 442 - } 443 - 444 - pull, ok := r.Context().Value("pull").(*models.Pull) 445 - if !ok { 446 - log.Println("failed to get pull") 447 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 448 - return 449 - } 450 - 451 - stack, _ := r.Context().Value("stack").(models.Stack) 452 - 453 - roundId := chi.URLParam(r, "round") 454 - roundIdInt, err := strconv.Atoi(roundId) 455 - if err != nil || roundIdInt >= len(pull.Submissions) { 456 - http.Error(w, "bad round id", http.StatusBadRequest) 457 - log.Println("failed to parse round id", err) 458 - return 459 - } 460 - 461 - patch := pull.Submissions[roundIdInt].CombinedPatch() 462 - diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 463 - 464 - s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 465 - LoggedInUser: user, 466 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 467 - Pull: pull, 468 - Stack: stack, 469 - Round: roundIdInt, 470 - Submission: pull.Submissions[roundIdInt], 471 - Diff: &diff, 472 - DiffOpts: diffOpts, 473 - }) 474 - 487 + s.repoPullHelper(w, r, false) 475 488 } 476 489 477 490 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 478 - user := s.oauth.GetUser(r) 479 - 480 - var diffOpts types.DiffOpts 481 - if d := r.URL.Query().Get("diff"); d == "split" { 482 - diffOpts.Split = true 483 - } 484 - 485 - pull, ok := r.Context().Value("pull").(*models.Pull) 486 - if !ok { 487 - log.Println("failed to get pull") 488 - s.pages.Notice(w, "pull-error", "Failed to get pull.") 489 - return 490 - } 491 - 492 - roundId := chi.URLParam(r, "round") 493 - roundIdInt, err := strconv.Atoi(roundId) 494 - if err != nil || roundIdInt >= len(pull.Submissions) { 495 - http.Error(w, "bad round id", http.StatusBadRequest) 496 - log.Println("failed to parse round id", err) 497 - return 498 - } 499 - 500 - if roundIdInt == 0 { 501 - http.Error(w, "bad round id", http.StatusBadRequest) 502 - log.Println("cannot interdiff initial submission") 503 - return 504 - } 505 - 506 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 507 - if err != nil { 508 - log.Println("failed to interdiff; current patch malformed") 509 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 510 - return 511 - } 512 - 513 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 514 - if err != nil { 515 - log.Println("failed to interdiff; previous patch malformed") 516 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 517 - return 518 - } 519 - 520 - interdiff := patchutil.Interdiff(previousPatch, currentPatch) 521 - 522 - s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 523 - LoggedInUser: s.oauth.GetUser(r), 524 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 525 - Pull: pull, 526 - Round: roundIdInt, 527 - Interdiff: interdiff, 528 - DiffOpts: diffOpts, 529 - }) 491 + s.repoPullHelper(w, r, true) 530 492 } 531 493 532 494 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 552 514 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 553 515 l := s.logger.With("handler", "RepoPulls") 554 516 555 - user := s.oauth.GetUser(r) 517 + user := s.oauth.GetMultiAccountUser(r) 556 518 params := r.URL.Query() 557 519 558 520 state := models.PullOpen ··· 563 525 state = models.PullMerged 564 526 } 565 527 528 + page := pagination.FromContext(r.Context()) 529 + 566 530 f, err := s.repoResolver.Resolve(r) 567 531 if err != nil { 568 532 log.Println("failed to get repo and knot", err) 569 533 return 570 534 } 571 535 536 + var totalPulls int 537 + switch state { 538 + case models.PullOpen: 539 + totalPulls = f.RepoStats.PullCount.Open 540 + case models.PullMerged: 541 + totalPulls = f.RepoStats.PullCount.Merged 542 + case models.PullClosed: 543 + totalPulls = f.RepoStats.PullCount.Closed 544 + } 545 + 572 546 keyword := params.Get("q") 573 547 574 - var ids []int64 548 + var pulls []*models.Pull 575 549 searchOpts := models.PullSearchOptions{ 576 550 Keyword: keyword, 577 551 RepoAt: f.RepoAt().String(), 578 552 State: state, 579 - // Page: page, 553 + Page: page, 580 554 } 581 555 l.Debug("searching with", "searchOpts", searchOpts) 582 556 if keyword != "" { ··· 585 559 l.Error("failed to search for pulls", "err", err) 586 560 return 587 561 } 588 - ids = res.Hits 589 - l.Debug("searched pulls with indexer", "count", len(ids)) 562 + totalPulls = int(res.Total) 563 + l.Debug("searched pulls with indexer", "count", len(res.Hits)) 564 + 565 + pulls, err = db.GetPulls( 566 + s.db, 567 + orm.FilterIn("id", res.Hits), 568 + ) 569 + if err != nil { 570 + log.Println("failed to get pulls", err) 571 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 572 + return 573 + } 590 574 } else { 591 - ids, err = db.GetPullIDs(s.db, searchOpts) 575 + pulls, err = db.GetPullsPaginated( 576 + s.db, 577 + page, 578 + orm.FilterEq("repo_at", f.RepoAt()), 579 + orm.FilterEq("state", searchOpts.State), 580 + ) 592 581 if err != nil { 593 - l.Error("failed to get all pull ids", "err", err) 582 + log.Println("failed to get pulls", err) 583 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 594 584 return 595 585 } 596 - l.Debug("indexed all pulls from the db", "count", len(ids)) 597 - } 598 - 599 - pulls, err := db.GetPulls( 600 - s.db, 601 - orm.FilterIn("id", ids), 602 - ) 603 - if err != nil { 604 - log.Println("failed to get pulls", err) 605 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 606 - return 607 586 } 608 587 609 588 for _, p := range pulls { ··· 680 659 } 681 660 682 661 s.pages.RepoPulls(w, pages.RepoPullsParams{ 683 - LoggedInUser: s.oauth.GetUser(r), 662 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 684 663 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 685 664 Pulls: pulls, 686 665 LabelDefs: defs, ··· 688 667 FilterQuery: keyword, 689 668 Stacks: stacks, 690 669 Pipelines: m, 670 + Page: page, 671 + PullCount: totalPulls, 691 672 }) 692 673 } 693 674 694 675 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 695 - user := s.oauth.GetUser(r) 676 + user := s.oauth.GetMultiAccountUser(r) 696 677 f, err := s.repoResolver.Resolve(r) 697 678 if err != nil { 698 679 log.Println("failed to get repo and knot", err) ··· 751 732 } 752 733 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 753 734 Collection: tangled.RepoPullCommentNSID, 754 - Repo: user.Did, 735 + Repo: user.Active.Did, 755 736 Rkey: tid.TID(), 756 737 Record: &lexutil.LexiconTypeDecoder{ 757 738 Val: &tangled.RepoPullComment{ ··· 768 749 } 769 750 770 751 comment := &models.PullComment{ 771 - OwnerDid: user.Did, 752 + OwnerDid: user.Active.Did, 772 753 RepoAt: f.RepoAt().String(), 773 754 PullId: pull.PullId, 774 755 Body: body, ··· 802 783 } 803 784 804 785 func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 805 - user := s.oauth.GetUser(r) 786 + user := s.oauth.GetMultiAccountUser(r) 806 787 f, err := s.repoResolver.Resolve(r) 807 788 if err != nil { 808 789 log.Println("failed to get repo and knot", err) ··· 870 851 } 871 852 872 853 // Determine PR type based on input parameters 873 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 854 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 874 855 isPushAllowed := roles.IsPushAllowed() 875 856 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 876 857 isForkBased := fromFork != "" && sourceBranch != "" ··· 970 951 w http.ResponseWriter, 971 952 r *http.Request, 972 953 repo *models.Repo, 973 - user *oauth.User, 954 + user *oauth.MultiAccountUser, 974 955 title, 975 956 body, 976 957 targetBranch, ··· 1027 1008 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1028 1009 } 1029 1010 1030 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1011 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1031 1012 if err := s.validator.ValidatePatch(&patch); err != nil { 1032 1013 s.logger.Error("patch validation failed", "err", err) 1033 1014 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") ··· 1037 1018 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1038 1019 } 1039 1020 1040 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1021 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1041 1022 repoString := strings.SplitN(forkRepo, "/", 2) 1042 1023 forkOwnerDid := repoString[0] 1043 1024 repoName := repoString[1] ··· 1146 1127 w http.ResponseWriter, 1147 1128 r *http.Request, 1148 1129 repo *models.Repo, 1149 - user *oauth.User, 1130 + user *oauth.MultiAccountUser, 1150 1131 title, body, targetBranch string, 1151 1132 patch string, 1152 1133 combined string, ··· 1218 1199 Title: title, 1219 1200 Body: body, 1220 1201 TargetBranch: targetBranch, 1221 - OwnerDid: user.Did, 1202 + OwnerDid: user.Active.Did, 1222 1203 RepoAt: repo.RepoAt(), 1223 1204 Rkey: rkey, 1224 1205 Mentions: mentions, ··· 1241 1222 return 1242 1223 } 1243 1224 1225 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1226 + if err != nil { 1227 + log.Println("failed to upload patch", err) 1228 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1229 + return 1230 + } 1231 + 1244 1232 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1245 1233 Collection: tangled.RepoPullNSID, 1246 - Repo: user.Did, 1234 + Repo: user.Active.Did, 1247 1235 Rkey: rkey, 1248 1236 Record: &lexutil.LexiconTypeDecoder{ 1249 1237 Val: &tangled.RepoPull{ ··· 1252 1240 Repo: string(repo.RepoAt()), 1253 1241 Branch: targetBranch, 1254 1242 }, 1255 - Patch: patch, 1243 + PatchBlob: blob.Blob, 1256 1244 Source: recordPullSource, 1257 1245 CreatedAt: time.Now().Format(time.RFC3339), 1258 1246 }, ··· 1280 1268 w http.ResponseWriter, 1281 1269 r *http.Request, 1282 1270 repo *models.Repo, 1283 - user *oauth.User, 1271 + user *oauth.MultiAccountUser, 1284 1272 targetBranch string, 1285 1273 patch string, 1286 1274 sourceRev string, ··· 1328 1316 // apply all record creations at once 1329 1317 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1330 1318 for _, p := range stack { 1319 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch())) 1320 + if err != nil { 1321 + log.Println("failed to upload patch blob", err) 1322 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1323 + return 1324 + } 1325 + 1331 1326 record := p.AsRecord() 1332 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1327 + record.PatchBlob = blob.Blob 1328 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1333 1329 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1334 1330 Collection: tangled.RepoPullNSID, 1335 1331 Rkey: &p.Rkey, ··· 1337 1333 Val: &record, 1338 1334 }, 1339 1335 }, 1340 - } 1341 - writes = append(writes, &write) 1336 + }) 1342 1337 } 1343 1338 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1344 - Repo: user.Did, 1339 + Repo: user.Active.Did, 1345 1340 Writes: writes, 1346 1341 }) 1347 1342 if err != nil { ··· 1413 1408 } 1414 1409 1415 1410 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1416 - user := s.oauth.GetUser(r) 1411 + user := s.oauth.GetMultiAccountUser(r) 1417 1412 1418 1413 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1419 1414 RepoInfo: s.repoResolver.GetRepoInfo(r, user), ··· 1421 1416 } 1422 1417 1423 1418 func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1424 - user := s.oauth.GetUser(r) 1419 + user := s.oauth.GetMultiAccountUser(r) 1425 1420 f, err := s.repoResolver.Resolve(r) 1426 1421 if err != nil { 1427 1422 log.Println("failed to get repo and knot", err) ··· 1476 1471 } 1477 1472 1478 1473 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1479 - user := s.oauth.GetUser(r) 1474 + user := s.oauth.GetMultiAccountUser(r) 1480 1475 1481 - forks, err := db.GetForksByDid(s.db, user.Did) 1476 + forks, err := db.GetForksByDid(s.db, user.Active.Did) 1482 1477 if err != nil { 1483 1478 log.Println("failed to get forks", err) 1484 1479 return ··· 1492 1487 } 1493 1488 1494 1489 func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1495 - user := s.oauth.GetUser(r) 1490 + user := s.oauth.GetMultiAccountUser(r) 1496 1491 1497 1492 f, err := s.repoResolver.Resolve(r) 1498 1493 if err != nil { ··· 1585 1580 } 1586 1581 1587 1582 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1588 - user := s.oauth.GetUser(r) 1583 + user := s.oauth.GetMultiAccountUser(r) 1589 1584 1590 1585 pull, ok := r.Context().Value("pull").(*models.Pull) 1591 1586 if !ok { ··· 1616 1611 } 1617 1612 1618 1613 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1619 - user := s.oauth.GetUser(r) 1614 + user := s.oauth.GetMultiAccountUser(r) 1620 1615 1621 1616 pull, ok := r.Context().Value("pull").(*models.Pull) 1622 1617 if !ok { ··· 1631 1626 return 1632 1627 } 1633 1628 1634 - if user.Did != pull.OwnerDid { 1629 + if user.Active.Did != pull.OwnerDid { 1635 1630 log.Println("unauthorized user") 1636 1631 w.WriteHeader(http.StatusUnauthorized) 1637 1632 return ··· 1643 1638 } 1644 1639 1645 1640 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1646 - user := s.oauth.GetUser(r) 1641 + user := s.oauth.GetMultiAccountUser(r) 1647 1642 1648 1643 pull, ok := r.Context().Value("pull").(*models.Pull) 1649 1644 if !ok { ··· 1658 1653 return 1659 1654 } 1660 1655 1661 - if user.Did != pull.OwnerDid { 1656 + if user.Active.Did != pull.OwnerDid { 1662 1657 log.Println("unauthorized user") 1663 1658 w.WriteHeader(http.StatusUnauthorized) 1664 1659 return 1665 1660 } 1666 1661 1667 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1662 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 1668 1663 if !roles.IsPushAllowed() { 1669 1664 log.Println("unauthorized user") 1670 1665 w.WriteHeader(http.StatusUnauthorized) ··· 1708 1703 } 1709 1704 1710 1705 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1711 - user := s.oauth.GetUser(r) 1706 + user := s.oauth.GetMultiAccountUser(r) 1712 1707 1713 1708 pull, ok := r.Context().Value("pull").(*models.Pull) 1714 1709 if !ok { ··· 1723 1718 return 1724 1719 } 1725 1720 1726 - if user.Did != pull.OwnerDid { 1721 + if user.Active.Did != pull.OwnerDid { 1727 1722 log.Println("unauthorized user") 1728 1723 w.WriteHeader(http.StatusUnauthorized) 1729 1724 return ··· 1808 1803 w http.ResponseWriter, 1809 1804 r *http.Request, 1810 1805 repo *models.Repo, 1811 - user *oauth.User, 1806 + user *oauth.MultiAccountUser, 1812 1807 pull *models.Pull, 1813 1808 patch string, 1814 1809 combined string, ··· 1864 1859 return 1865 1860 } 1866 1861 1867 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1862 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Active.Did, pull.Rkey) 1868 1863 if err != nil { 1869 1864 // failed to get record 1870 1865 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1871 1866 return 1872 1867 } 1873 1868 1874 - var recordPullSource *tangled.RepoPull_Source 1875 - if pull.IsBranchBased() { 1876 - recordPullSource = &tangled.RepoPull_Source{ 1877 - Branch: pull.PullSource.Branch, 1878 - Sha: sourceRev, 1879 - } 1869 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 1870 + if err != nil { 1871 + log.Println("failed to upload patch blob", err) 1872 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1873 + return 1880 1874 } 1881 - if pull.IsForkBased() { 1882 - repoAt := pull.PullSource.RepoAt.String() 1883 - recordPullSource = &tangled.RepoPull_Source{ 1884 - Branch: pull.PullSource.Branch, 1885 - Repo: &repoAt, 1886 - Sha: sourceRev, 1887 - } 1888 - } 1875 + record := pull.AsRecord() 1876 + record.PatchBlob = blob.Blob 1877 + record.CreatedAt = time.Now().Format(time.RFC3339) 1889 1878 1890 1879 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1891 1880 Collection: tangled.RepoPullNSID, 1892 - Repo: user.Did, 1881 + Repo: user.Active.Did, 1893 1882 Rkey: pull.Rkey, 1894 1883 SwapRecord: ex.Cid, 1895 1884 Record: &lexutil.LexiconTypeDecoder{ 1896 - Val: &tangled.RepoPull{ 1897 - Title: pull.Title, 1898 - Target: &tangled.RepoPull_Target{ 1899 - Repo: string(repo.RepoAt()), 1900 - Branch: pull.TargetBranch, 1901 - }, 1902 - Patch: patch, // new patch 1903 - Source: recordPullSource, 1904 - CreatedAt: time.Now().Format(time.RFC3339), 1905 - }, 1885 + Val: &record, 1906 1886 }, 1907 1887 }) 1908 1888 if err != nil { ··· 1925 1905 w http.ResponseWriter, 1926 1906 r *http.Request, 1927 1907 repo *models.Repo, 1928 - user *oauth.User, 1908 + user *oauth.MultiAccountUser, 1929 1909 pull *models.Pull, 1930 1910 patch string, 1931 1911 stackId string, ··· 1988 1968 } 1989 1969 defer tx.Rollback() 1990 1970 1971 + client, err := s.oauth.AuthorizedClient(r) 1972 + if err != nil { 1973 + log.Println("failed to authorize client") 1974 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1975 + return 1976 + } 1977 + 1991 1978 // pds updates to make 1992 1979 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1993 1980 ··· 2021 2008 return 2022 2009 } 2023 2010 2011 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2012 + if err != nil { 2013 + log.Println("failed to upload patch blob", err) 2014 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2015 + return 2016 + } 2024 2017 record := p.AsRecord() 2018 + record.PatchBlob = blob.Blob 2025 2019 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2026 2020 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2027 2021 Collection: tangled.RepoPullNSID, ··· 2056 2050 return 2057 2051 } 2058 2052 2053 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 2054 + if err != nil { 2055 + log.Println("failed to upload patch blob", err) 2056 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2057 + return 2058 + } 2059 2059 record := np.AsRecord() 2060 - 2060 + record.PatchBlob = blob.Blob 2061 2061 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2062 2062 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2063 2063 Collection: tangled.RepoPullNSID, ··· 2094 2094 return 2095 2095 } 2096 2096 2097 - client, err := s.oauth.AuthorizedClient(r) 2098 - if err != nil { 2099 - log.Println("failed to authorize client") 2100 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2101 - return 2102 - } 2103 - 2104 2097 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2105 - Repo: user.Did, 2098 + Repo: user.Active.Did, 2106 2099 Writes: writes, 2107 2100 }) 2108 2101 if err != nil { ··· 2116 2109 } 2117 2110 2118 2111 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2119 - user := s.oauth.GetUser(r) 2112 + user := s.oauth.GetMultiAccountUser(r) 2120 2113 f, err := s.repoResolver.Resolve(r) 2121 2114 if err != nil { 2122 2115 log.Println("failed to resolve repo:", err) ··· 2227 2220 2228 2221 // notify about the pull merge 2229 2222 for _, p := range pullsToMerge { 2230 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2223 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2231 2224 } 2232 2225 2233 2226 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2235 2228 } 2236 2229 2237 2230 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2238 - user := s.oauth.GetUser(r) 2231 + user := s.oauth.GetMultiAccountUser(r) 2239 2232 2240 2233 f, err := s.repoResolver.Resolve(r) 2241 2234 if err != nil { ··· 2251 2244 } 2252 2245 2253 2246 // auth filter: only owner or collaborators can close 2254 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2247 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2255 2248 isOwner := roles.IsOwner() 2256 2249 isCollaborator := roles.IsCollaborator() 2257 - isPullAuthor := user.Did == pull.OwnerDid 2250 + isPullAuthor := user.Active.Did == pull.OwnerDid 2258 2251 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2259 2252 if !isCloseAllowed { 2260 2253 log.Println("failed to close pull") ··· 2300 2293 } 2301 2294 2302 2295 for _, p := range pullsToClose { 2303 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2296 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2304 2297 } 2305 2298 2306 2299 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 2308 2301 } 2309 2302 2310 2303 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2311 - user := s.oauth.GetUser(r) 2304 + user := s.oauth.GetMultiAccountUser(r) 2312 2305 2313 2306 f, err := s.repoResolver.Resolve(r) 2314 2307 if err != nil { ··· 2325 2318 } 2326 2319 2327 2320 // auth filter: only owner or collaborators can close 2328 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2321 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2329 2322 isOwner := roles.IsOwner() 2330 2323 isCollaborator := roles.IsCollaborator() 2331 - isPullAuthor := user.Did == pull.OwnerDid 2324 + isPullAuthor := user.Active.Did == pull.OwnerDid 2332 2325 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2333 2326 if !isCloseAllowed { 2334 2327 log.Println("failed to close pull") ··· 2374 2367 } 2375 2368 2376 2369 for _, p := range pullsToReopen { 2377 - s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2370 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2378 2371 } 2379 2372 2380 2373 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2381 2374 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2382 2375 } 2383 2376 2384 - func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2377 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.MultiAccountUser, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2385 2378 formatPatches, err := patchutil.ExtractPatches(patch) 2386 2379 if err != nil { 2387 2380 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2417 2410 Title: title, 2418 2411 Body: body, 2419 2412 TargetBranch: targetBranch, 2420 - OwnerDid: user.Did, 2413 + OwnerDid: user.Active.Did, 2421 2414 RepoAt: repo.RepoAt(), 2422 2415 Rkey: rkey, 2423 2416 Mentions: mentions, ··· 2440 2433 2441 2434 return stack, nil 2442 2435 } 2436 + 2437 + func gz(s string) io.Reader { 2438 + var b bytes.Buffer 2439 + w := gzip.NewWriter(&b) 2440 + w.Write([]byte(s)) 2441 + w.Close() 2442 + return &b 2443 + }
+1 -1
appview/pulls/router.go
··· 9 9 10 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 - r.Get("/", s.RepoPulls) 12 + r.With(middleware.Paginate).Get("/", s.RepoPulls) 13 13 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 14 14 r.Get("/", s.NewPull) 15 15 r.Get("/patch-upload", s.PatchUploadFragment)
+1
appview/repo/archive.go
··· 18 18 l := rp.logger.With("handler", "DownloadArchive") 19 19 ref := chi.URLParam(r, "ref") 20 20 ref, _ = url.PathUnescape(ref) 21 + ref = strings.TrimSuffix(ref, ".tar.gz") 21 22 f, err := rp.repoResolver.Resolve(r) 22 23 if err != nil { 23 24 l.Error("failed to get repo and knot", "err", err)
+6 -6
appview/repo/artifact.go
··· 30 30 31 31 // TODO: proper statuses here on early exit 32 32 func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { 33 - user := rp.oauth.GetUser(r) 33 + user := rp.oauth.GetMultiAccountUser(r) 34 34 tagParam := chi.URLParam(r, "tag") 35 35 f, err := rp.repoResolver.Resolve(r) 36 36 if err != nil { ··· 75 75 76 76 putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 77 Collection: tangled.RepoArtifactNSID, 78 - Repo: user.Did, 78 + Repo: user.Active.Did, 79 79 Rkey: rkey, 80 80 Record: &lexutil.LexiconTypeDecoder{ 81 81 Val: &tangled.RepoArtifact{ ··· 104 104 defer tx.Rollback() 105 105 106 106 artifact := models.Artifact{ 107 - Did: user.Did, 107 + Did: user.Active.Did, 108 108 Rkey: rkey, 109 109 RepoAt: f.RepoAt(), 110 110 Tag: tag.Tag.Hash, ··· 220 220 221 221 // TODO: proper statuses here on early exit 222 222 func (rp *Repo) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 223 - user := rp.oauth.GetUser(r) 223 + user := rp.oauth.GetMultiAccountUser(r) 224 224 tagParam := chi.URLParam(r, "tag") 225 225 filename := chi.URLParam(r, "file") 226 226 f, err := rp.repoResolver.Resolve(r) ··· 251 251 252 252 artifact := artifacts[0] 253 253 254 - if user.Did != artifact.Did { 254 + if user.Active.Did != artifact.Did { 255 255 log.Println("user not authorized to delete artifact", err) 256 256 rp.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 257 257 return ··· 259 259 260 260 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 261 261 Collection: tangled.RepoArtifactNSID, 262 - Repo: user.Did, 262 + Repo: user.Active.Did, 263 263 Rkey: artifact.Rkey, 264 264 }) 265 265 if err != nil {
+1 -1
appview/repo/blob.go
··· 76 76 // Create the blob view 77 77 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 78 79 - user := rp.oauth.GetUser(r) 79 + user := rp.oauth.GetMultiAccountUser(r) 80 80 81 81 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 82 LoggedInUser: user,
+1 -1
appview/repo/branches.go
··· 43 43 return 44 44 } 45 45 sortBranches(result.Branches) 46 - user := rp.oauth.GetUser(r) 46 + user := rp.oauth.GetMultiAccountUser(r) 47 47 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 48 LoggedInUser: user, 49 49 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
+2 -2
appview/repo/compare.go
··· 20 20 func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 21 l := rp.logger.With("handler", "RepoCompareNew") 22 22 23 - user := rp.oauth.GetUser(r) 23 + user := rp.oauth.GetMultiAccountUser(r) 24 24 f, err := rp.repoResolver.Resolve(r) 25 25 if err != nil { 26 26 l.Error("failed to get repo and knot", "err", err) ··· 101 101 func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 102 102 l := rp.logger.With("handler", "RepoCompare") 103 103 104 - user := rp.oauth.GetUser(r) 104 + user := rp.oauth.GetMultiAccountUser(r) 105 105 f, err := rp.repoResolver.Resolve(r) 106 106 if err != nil { 107 107 l.Error("failed to get repo and knot", "err", err)
+3 -3
appview/repo/feed.go
··· 19 19 ) 20 20 21 21 func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 22 - const feedLimitPerType = 100 22 + feedPagePerType := pagination.Page{Limit: 100} 23 23 24 - pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt())) 24 + pulls, err := db.GetPullsPaginated(rp.db, feedPagePerType, orm.FilterEq("repo_at", repo.RepoAt())) 25 25 if err != nil { 26 26 return nil, err 27 27 } 28 28 29 29 issues, err := db.GetIssuesPaginated( 30 30 rp.db, 31 - pagination.Page{Limit: feedLimitPerType}, 31 + feedPagePerType, 32 32 orm.FilterEq("repo_at", repo.RepoAt()), 33 33 ) 34 34 if err != nil {
+1 -1
appview/repo/index.go
··· 51 51 Host: host, 52 52 } 53 53 54 - user := rp.oauth.GetUser(r) 54 + user := rp.oauth.GetMultiAccountUser(r) 55 55 56 56 // Build index response from multiple XRPC calls 57 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
+2 -2
appview/repo/log.go
··· 109 109 } 110 110 } 111 111 112 - user := rp.oauth.GetUser(r) 112 + user := rp.oauth.GetMultiAccountUser(r) 113 113 114 114 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 115 if err != nil { ··· 197 197 l.Error("failed to GetVerifiedCommits", "err", err) 198 198 } 199 199 200 - user := rp.oauth.GetUser(r) 200 + user := rp.oauth.GetMultiAccountUser(r) 201 201 pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 202 202 if err != nil { 203 203 l.Error("failed to getPipelineStatuses", "err", err)
+1 -1
appview/repo/opengraph.go
··· 237 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 240 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 241 241 if err != nil { 242 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 243 243 }
+34 -34
appview/repo/repo.go
··· 81 81 82 82 // modify the spindle configured for this repo 83 83 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 84 - user := rp.oauth.GetUser(r) 84 + user := rp.oauth.GetMultiAccountUser(r) 85 85 l := rp.logger.With("handler", "EditSpindle") 86 - l = l.With("did", user.Did) 86 + l = l.With("did", user.Active.Did) 87 87 88 88 errorId := "operation-error" 89 89 fail := func(msg string, err error) { ··· 107 107 108 108 if !removingSpindle { 109 109 // ensure that this is a valid spindle for this user 110 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 110 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Active.Did) 111 111 if err != nil { 112 112 fail("Failed to find spindles. Try again later.", err) 113 113 return ··· 168 168 } 169 169 170 170 func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { 171 - user := rp.oauth.GetUser(r) 171 + user := rp.oauth.GetMultiAccountUser(r) 172 172 l := rp.logger.With("handler", "AddLabel") 173 - l = l.With("did", user.Did) 173 + l = l.With("did", user.Active.Did) 174 174 175 175 f, err := rp.repoResolver.Resolve(r) 176 176 if err != nil { ··· 216 216 } 217 217 218 218 label := models.LabelDefinition{ 219 - Did: user.Did, 219 + Did: user.Active.Did, 220 220 Rkey: tid.TID(), 221 221 Name: name, 222 222 ValueType: valueType, ··· 327 327 } 328 328 329 329 func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { 330 - user := rp.oauth.GetUser(r) 330 + user := rp.oauth.GetMultiAccountUser(r) 331 331 l := rp.logger.With("handler", "DeleteLabel") 332 - l = l.With("did", user.Did) 332 + l = l.With("did", user.Active.Did) 333 333 334 334 f, err := rp.repoResolver.Resolve(r) 335 335 if err != nil { ··· 435 435 } 436 436 437 437 func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 438 - user := rp.oauth.GetUser(r) 438 + user := rp.oauth.GetMultiAccountUser(r) 439 439 l := rp.logger.With("handler", "SubscribeLabel") 440 - l = l.With("did", user.Did) 440 + l = l.With("did", user.Active.Did) 441 441 442 442 f, err := rp.repoResolver.Resolve(r) 443 443 if err != nil { ··· 521 521 } 522 522 523 523 func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 524 - user := rp.oauth.GetUser(r) 524 + user := rp.oauth.GetMultiAccountUser(r) 525 525 l := rp.logger.With("handler", "UnsubscribeLabel") 526 - l = l.With("did", user.Did) 526 + l = l.With("did", user.Active.Did) 527 527 528 528 f, err := rp.repoResolver.Resolve(r) 529 529 if err != nil { ··· 633 633 } 634 634 state := states[subject] 635 635 636 - user := rp.oauth.GetUser(r) 636 + user := rp.oauth.GetMultiAccountUser(r) 637 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 638 638 LoggedInUser: user, 639 639 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 681 681 } 682 682 state := states[subject] 683 683 684 - user := rp.oauth.GetUser(r) 684 + user := rp.oauth.GetMultiAccountUser(r) 685 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 686 686 LoggedInUser: user, 687 687 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), ··· 692 692 } 693 693 694 694 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 695 - user := rp.oauth.GetUser(r) 695 + user := rp.oauth.GetMultiAccountUser(r) 696 696 l := rp.logger.With("handler", "AddCollaborator") 697 - l = l.With("did", user.Did) 697 + l = l.With("did", user.Active.Did) 698 698 699 699 f, err := rp.repoResolver.Resolve(r) 700 700 if err != nil { ··· 723 723 return 724 724 } 725 725 726 - if collaboratorIdent.DID.String() == user.Did { 726 + if collaboratorIdent.DID.String() == user.Active.Did { 727 727 fail("You seem to be adding yourself as a collaborator.", nil) 728 728 return 729 729 } ··· 738 738 } 739 739 740 740 // emit a record 741 - currentUser := rp.oauth.GetUser(r) 741 + currentUser := rp.oauth.GetMultiAccountUser(r) 742 742 rkey := tid.TID() 743 743 createdAt := time.Now() 744 744 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 745 745 Collection: tangled.RepoCollaboratorNSID, 746 - Repo: currentUser.Did, 746 + Repo: currentUser.Active.Did, 747 747 Rkey: rkey, 748 748 Record: &lexutil.LexiconTypeDecoder{ 749 749 Val: &tangled.RepoCollaborator{ ··· 792 792 } 793 793 794 794 err = db.AddCollaborator(tx, models.Collaborator{ 795 - Did: syntax.DID(currentUser.Did), 795 + Did: syntax.DID(currentUser.Active.Did), 796 796 Rkey: rkey, 797 797 SubjectDid: collaboratorIdent.DID, 798 798 RepoAt: f.RepoAt(), ··· 822 822 } 823 823 824 824 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 825 - user := rp.oauth.GetUser(r) 825 + user := rp.oauth.GetMultiAccountUser(r) 826 826 l := rp.logger.With("handler", "DeleteRepo") 827 827 828 828 noticeId := "operation-error" ··· 840 840 } 841 841 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 842 842 Collection: tangled.RepoNSID, 843 - Repo: user.Did, 843 + Repo: user.Active.Did, 844 844 Rkey: f.Rkey, 845 845 }) 846 846 if err != nil { ··· 940 940 ref := chi.URLParam(r, "ref") 941 941 ref, _ = url.PathUnescape(ref) 942 942 943 - user := rp.oauth.GetUser(r) 943 + user := rp.oauth.GetMultiAccountUser(r) 944 944 f, err := rp.repoResolver.Resolve(r) 945 945 if err != nil { 946 946 l.Error("failed to resolve source repo", "err", err) ··· 969 969 r.Context(), 970 970 client, 971 971 &tangled.RepoForkSync_Input{ 972 - Did: user.Did, 972 + Did: user.Active.Did, 973 973 Name: f.Name, 974 974 Source: f.Source, 975 975 Branch: ref, ··· 988 988 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 989 989 l := rp.logger.With("handler", "ForkRepo") 990 990 991 - user := rp.oauth.GetUser(r) 991 + user := rp.oauth.GetMultiAccountUser(r) 992 992 f, err := rp.repoResolver.Resolve(r) 993 993 if err != nil { 994 994 l.Error("failed to resolve source repo", "err", err) ··· 997 997 998 998 switch r.Method { 999 999 case http.MethodGet: 1000 - user := rp.oauth.GetUser(r) 1001 - knots, err := rp.enforcer.GetKnotsForUser(user.Did) 1000 + user := rp.oauth.GetMultiAccountUser(r) 1001 + knots, err := rp.enforcer.GetKnotsForUser(user.Active.Did) 1002 1002 if err != nil { 1003 1003 rp.pages.Notice(w, "repo", "Invalid user account.") 1004 1004 return ··· 1020 1020 } 1021 1021 l = l.With("targetKnot", targetKnot) 1022 1022 1023 - ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1023 + ok, err := rp.enforcer.E.Enforce(user.Active.Did, targetKnot, targetKnot, "repo:create") 1024 1024 if err != nil || !ok { 1025 1025 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1026 1026 return ··· 1037 1037 // in the user's account. 1038 1038 existingRepo, err := db.GetRepo( 1039 1039 rp.db, 1040 - orm.FilterEq("did", user.Did), 1040 + orm.FilterEq("did", user.Active.Did), 1041 1041 orm.FilterEq("name", forkName), 1042 1042 ) 1043 1043 if err != nil { ··· 1066 1066 // create an atproto record for this fork 1067 1067 rkey := tid.TID() 1068 1068 repo := &models.Repo{ 1069 - Did: user.Did, 1069 + Did: user.Active.Did, 1070 1070 Name: forkName, 1071 1071 Knot: targetKnot, 1072 1072 Rkey: rkey, ··· 1086 1086 1087 1087 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 1088 1088 Collection: tangled.RepoNSID, 1089 - Repo: user.Did, 1089 + Repo: user.Active.Did, 1090 1090 Rkey: rkey, 1091 1091 Record: &lexutil.LexiconTypeDecoder{ 1092 1092 Val: &record, ··· 1165 1165 } 1166 1166 1167 1167 // acls 1168 - p, _ := securejoin.SecureJoin(user.Did, forkName) 1169 - err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1168 + p, _ := securejoin.SecureJoin(user.Active.Did, forkName) 1169 + err = rp.enforcer.AddRepo(user.Active.Did, targetKnot, p) 1170 1170 if err != nil { 1171 1171 l.Error("failed to add ACLs", "err", err) 1172 1172 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1191 1191 aturi = "" 1192 1192 1193 1193 rp.notifier.NewRepo(r.Context(), repo) 1194 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 1194 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, forkName)) 1195 1195 } 1196 1196 } 1197 1197
+5 -5
appview/repo/settings.go
··· 79 79 } 80 80 81 81 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 82 - user := rp.oauth.GetUser(r) 82 + user := rp.oauth.GetMultiAccountUser(r) 83 83 l := rp.logger.With("handler", "Secrets") 84 - l = l.With("did", user.Did) 84 + l = l.With("did", user.Active.Did) 85 85 86 86 f, err := rp.repoResolver.Resolve(r) 87 87 if err != nil { ··· 185 185 l := rp.logger.With("handler", "generalSettings") 186 186 187 187 f, err := rp.repoResolver.Resolve(r) 188 - user := rp.oauth.GetUser(r) 188 + user := rp.oauth.GetMultiAccountUser(r) 189 189 190 190 scheme := "http" 191 191 if !rp.config.Core.Dev { ··· 271 271 l := rp.logger.With("handler", "accessSettings") 272 272 273 273 f, err := rp.repoResolver.Resolve(r) 274 - user := rp.oauth.GetUser(r) 274 + user := rp.oauth.GetMultiAccountUser(r) 275 275 276 276 collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 277 277 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) ··· 318 318 l := rp.logger.With("handler", "pipelineSettings") 319 319 320 320 f, err := rp.repoResolver.Resolve(r) 321 - user := rp.oauth.GetUser(r) 321 + user := rp.oauth.GetMultiAccountUser(r) 322 322 323 323 // all spindles that the repo owner is a member of 324 324 spindles, err := rp.enforcer.GetSpindlesForUser(f.Did)
+1 -1
appview/repo/tags.go
··· 69 69 danglingArtifacts = append(danglingArtifacts, a) 70 70 } 71 71 } 72 - user := rp.oauth.GetUser(r) 72 + user := rp.oauth.GetMultiAccountUser(r) 73 73 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 74 LoggedInUser: user, 75 75 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
+1 -1
appview/repo/tree.go
··· 88 88 http.Redirect(w, r, redirectTo, http.StatusFound) 89 89 return 90 90 } 91 - user := rp.oauth.GetUser(r) 91 + user := rp.oauth.GetMultiAccountUser(r) 92 92 var breadcrumbs [][]string 93 93 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 94 94 if treePath != "" {
+30 -5
appview/reporesolver/resolver.go
··· 55 55 // 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo` 56 56 // 3. [x] remove `ResolvedRepo` 57 57 // 4. [ ] replace reporesolver to reposervice 58 - func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.User) repoinfo.RepoInfo { 58 + func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.MultiAccountUser) repoinfo.RepoInfo { 59 59 ownerId, ook := r.Context().Value("resolvedId").(identity.Identity) 60 60 repo, rok := r.Context().Value("repo").(*models.Repo) 61 61 if !ook || !rok { ··· 63 63 } 64 64 65 65 // get dir/ref 66 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 66 + currentDir := extractCurrentDir(r.URL.EscapedPath()) 67 67 ref := chi.URLParam(r, "ref") 68 68 69 69 repoAt := repo.RepoAt() 70 70 isStarred := false 71 71 roles := repoinfo.RolesInRepo{} 72 - if user != nil { 73 - isStarred = db.GetStarStatus(rr.execer, user.Did, repoAt) 74 - roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 72 + if user != nil && user.Active != nil { 73 + isStarred = db.GetStarStatus(rr.execer, user.Active.Did, repoAt) 74 + roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Active.Did, repo.Knot, repo.DidSlashRepo()) 75 75 } 76 76 77 77 stats := repo.RepoStats ··· 130 130 } 131 131 132 132 return repoInfo 133 + } 134 + 135 + // extractCurrentDir gets the current directory for markdown link resolution. 136 + // for blob paths, returns the parent dir. for tree paths, returns the path itself. 137 + // 138 + // /@user/repo/blob/main/docs/README.md => docs 139 + // /@user/repo/tree/main/docs => docs 140 + func extractCurrentDir(fullPath string) string { 141 + fullPath = strings.TrimPrefix(fullPath, "/") 142 + 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 + if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 + return path.Dir(matches[1]) 146 + } 147 + 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 + if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 + dir := strings.TrimSuffix(matches[1], "/") 151 + if dir == "" { 152 + return "." 153 + } 154 + return dir 155 + } 156 + 157 + return "." 133 158 } 134 159 135 160 // extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
··· 1 + package reporesolver 2 + 3 + import "testing" 4 + 5 + func TestExtractCurrentDir(t *testing.T) { 6 + tests := []struct { 7 + path string 8 + want string 9 + }{ 10 + {"/@user/repo/blob/main/docs/README.md", "docs"}, 11 + {"/@user/repo/blob/main/README.md", "."}, 12 + {"/@user/repo/tree/main/docs", "docs"}, 13 + {"/@user/repo/tree/main/docs/", "docs"}, 14 + {"/@user/repo/tree/main", "."}, 15 + } 16 + 17 + for _, tt := range tests { 18 + if got := extractCurrentDir(tt.path); got != tt.want { 19 + t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want) 20 + } 21 + } 22 + }
+6 -6
appview/settings/settings.go
··· 81 81 } 82 82 83 83 func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 84 - user := s.OAuth.GetUser(r) 84 + user := s.OAuth.GetMultiAccountUser(r) 85 85 86 86 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 87 87 LoggedInUser: user, ··· 91 91 } 92 92 93 93 func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 94 - user := s.OAuth.GetUser(r) 94 + user := s.OAuth.GetMultiAccountUser(r) 95 95 did := s.OAuth.GetDid(r) 96 96 97 97 prefs, err := db.GetNotificationPreference(s.Db, did) ··· 137 137 } 138 138 139 139 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 140 - user := s.OAuth.GetUser(r) 141 - pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 140 + user := s.OAuth.GetMultiAccountUser(r) 141 + pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Active.Did) 142 142 if err != nil { 143 143 log.Println(err) 144 144 } ··· 152 152 } 153 153 154 154 func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 155 - user := s.OAuth.GetUser(r) 156 - emails, err := db.GetAllEmails(s.Db, user.Did) 155 + user := s.OAuth.GetMultiAccountUser(r) 156 + emails, err := db.GetAllEmails(s.Db, user.Active.Did) 157 157 if err != nil { 158 158 log.Println(err) 159 159 }
+41 -46
appview/spindles/spindles.go
··· 69 69 } 70 70 71 71 func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { 72 - user := s.OAuth.GetUser(r) 72 + user := s.OAuth.GetMultiAccountUser(r) 73 73 all, err := db.GetSpindles( 74 74 s.Db, 75 - orm.FilterEq("owner", user.Did), 75 + orm.FilterEq("owner", user.Active.Did), 76 76 ) 77 77 if err != nil { 78 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 91 91 func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { 92 92 l := s.Logger.With("handler", "dashboard") 93 93 94 - user := s.OAuth.GetUser(r) 95 - l = l.With("user", user.Did) 94 + user := s.OAuth.GetMultiAccountUser(r) 95 + l = l.With("user", user.Active.Did) 96 96 97 97 instance := chi.URLParam(r, "instance") 98 98 if instance == "" { ··· 103 103 spindles, err := db.GetSpindles( 104 104 s.Db, 105 105 orm.FilterEq("instance", instance), 106 - orm.FilterEq("owner", user.Did), 106 + orm.FilterEq("owner", user.Active.Did), 107 107 orm.FilterIsNot("verified", "null"), 108 108 ) 109 109 if err != nil || len(spindles) != 1 { ··· 155 155 // 156 156 // if the spindle is not up yet, the user is free to retry verification at a later point 157 157 func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { 158 - user := s.OAuth.GetUser(r) 158 + user := s.OAuth.GetMultiAccountUser(r) 159 159 l := s.Logger.With("handler", "register") 160 160 161 161 noticeId := "register-error" ··· 176 176 return 177 177 } 178 178 l = l.With("instance", instance) 179 - l = l.With("user", user.Did) 179 + l = l.With("user", user.Active.Did) 180 180 181 181 tx, err := s.Db.Begin() 182 182 if err != nil { ··· 190 190 }() 191 191 192 192 err = db.AddSpindle(tx, models.Spindle{ 193 - Owner: syntax.DID(user.Did), 193 + Owner: syntax.DID(user.Active.Did), 194 194 Instance: instance, 195 195 }) 196 196 if err != nil { ··· 214 214 return 215 215 } 216 216 217 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 217 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Active.Did, instance) 218 218 var exCid *string 219 219 if ex != nil { 220 220 exCid = ex.Cid ··· 223 223 // re-announce by registering under same rkey 224 224 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 225 225 Collection: tangled.SpindleNSID, 226 - Repo: user.Did, 226 + Repo: user.Active.Did, 227 227 Rkey: instance, 228 228 Record: &lexutil.LexiconTypeDecoder{ 229 229 Val: &tangled.Spindle{ ··· 254 254 } 255 255 256 256 // begin verification 257 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 257 + err = serververify.RunVerification(r.Context(), instance, user.Active.Did, s.Config.Core.Dev) 258 258 if err != nil { 259 259 l.Error("verification failed", "err", err) 260 260 s.Pages.HxRefresh(w) 261 261 return 262 262 } 263 263 264 - _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 264 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Active.Did) 265 265 if err != nil { 266 266 l.Error("failed to mark verified", "err", err) 267 267 s.Pages.HxRefresh(w) ··· 273 273 } 274 274 275 275 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 276 - user := s.OAuth.GetUser(r) 276 + user := s.OAuth.GetMultiAccountUser(r) 277 277 l := s.Logger.With("handler", "delete") 278 278 279 279 noticeId := "operation-error" ··· 291 291 292 292 spindles, err := db.GetSpindles( 293 293 s.Db, 294 - orm.FilterEq("owner", user.Did), 294 + orm.FilterEq("owner", user.Active.Did), 295 295 orm.FilterEq("instance", instance), 296 296 ) 297 297 if err != nil || len(spindles) != 1 { ··· 300 300 return 301 301 } 302 302 303 - if string(spindles[0].Owner) != user.Did { 304 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 303 + if string(spindles[0].Owner) != user.Active.Did { 304 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 305 305 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 306 306 return 307 307 } ··· 320 320 // remove spindle members first 321 321 err = db.RemoveSpindleMember( 322 322 tx, 323 - orm.FilterEq("did", user.Did), 323 + orm.FilterEq("did", user.Active.Did), 324 324 orm.FilterEq("instance", instance), 325 325 ) 326 326 if err != nil { ··· 331 331 332 332 err = db.DeleteSpindle( 333 333 tx, 334 - orm.FilterEq("owner", user.Did), 334 + orm.FilterEq("owner", user.Active.Did), 335 335 orm.FilterEq("instance", instance), 336 336 ) 337 337 if err != nil { ··· 359 359 360 360 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 361 361 Collection: tangled.SpindleNSID, 362 - Repo: user.Did, 362 + Repo: user.Active.Did, 363 363 Rkey: instance, 364 364 }) 365 365 if err != nil { ··· 391 391 } 392 392 393 393 func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 394 - user := s.OAuth.GetUser(r) 394 + user := s.OAuth.GetMultiAccountUser(r) 395 395 l := s.Logger.With("handler", "retry") 396 396 397 397 noticeId := "operation-error" ··· 407 407 return 408 408 } 409 409 l = l.With("instance", instance) 410 - l = l.With("user", user.Did) 410 + l = l.With("user", user.Active.Did) 411 411 412 412 spindles, err := db.GetSpindles( 413 413 s.Db, 414 - orm.FilterEq("owner", user.Did), 414 + orm.FilterEq("owner", user.Active.Did), 415 415 orm.FilterEq("instance", instance), 416 416 ) 417 417 if err != nil || len(spindles) != 1 { ··· 420 420 return 421 421 } 422 422 423 - if string(spindles[0].Owner) != user.Did { 424 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 423 + if string(spindles[0].Owner) != user.Active.Did { 424 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 425 425 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 426 426 return 427 427 } 428 428 429 429 // begin verification 430 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 430 + err = serververify.RunVerification(r.Context(), instance, user.Active.Did, s.Config.Core.Dev) 431 431 if err != nil { 432 432 l.Error("verification failed", "err", err) 433 433 ··· 445 445 return 446 446 } 447 447 448 - rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 448 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Active.Did) 449 449 if err != nil { 450 450 l.Error("failed to mark verified", "err", err) 451 451 s.Pages.Notice(w, noticeId, err.Error()) ··· 473 473 } 474 474 475 475 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 476 - user := s.OAuth.GetUser(r) 476 + user := s.OAuth.GetMultiAccountUser(r) 477 477 l := s.Logger.With("handler", "addMember") 478 478 479 479 instance := chi.URLParam(r, "instance") ··· 483 483 return 484 484 } 485 485 l = l.With("instance", instance) 486 - l = l.With("user", user.Did) 486 + l = l.With("user", user.Active.Did) 487 487 488 488 spindles, err := db.GetSpindles( 489 489 s.Db, 490 - orm.FilterEq("owner", user.Did), 490 + orm.FilterEq("owner", user.Active.Did), 491 491 orm.FilterEq("instance", instance), 492 492 ) 493 493 if err != nil || len(spindles) != 1 { ··· 502 502 s.Pages.Notice(w, noticeId, defaultErr) 503 503 } 504 504 505 - if string(spindles[0].Owner) != user.Did { 506 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 505 + if string(spindles[0].Owner) != user.Active.Did { 506 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 507 507 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 508 508 return 509 509 } ··· 552 552 553 553 // add member to db 554 554 if err = db.AddSpindleMember(tx, models.SpindleMember{ 555 - Did: syntax.DID(user.Did), 555 + Did: syntax.DID(user.Active.Did), 556 556 Rkey: rkey, 557 557 Instance: instance, 558 558 Subject: memberId.DID, ··· 570 570 571 571 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 572 572 Collection: tangled.SpindleMemberNSID, 573 - Repo: user.Did, 573 + Repo: user.Active.Did, 574 574 Rkey: rkey, 575 575 Record: &lexutil.LexiconTypeDecoder{ 576 576 Val: &tangled.SpindleMember{ ··· 603 603 } 604 604 605 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 606 - user := s.OAuth.GetUser(r) 606 + user := s.OAuth.GetMultiAccountUser(r) 607 607 l := s.Logger.With("handler", "removeMember") 608 608 609 609 noticeId := "operation-error" ··· 619 619 return 620 620 } 621 621 l = l.With("instance", instance) 622 - l = l.With("user", user.Did) 622 + l = l.With("user", user.Active.Did) 623 623 624 624 spindles, err := db.GetSpindles( 625 625 s.Db, 626 - orm.FilterEq("owner", user.Did), 626 + orm.FilterEq("owner", user.Active.Did), 627 627 orm.FilterEq("instance", instance), 628 628 ) 629 629 if err != nil || len(spindles) != 1 { ··· 632 632 return 633 633 } 634 634 635 - if string(spindles[0].Owner) != user.Did { 636 - l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 635 + if string(spindles[0].Owner) != user.Active.Did { 636 + l.Error("unauthorized", "user", user.Active.Did, "owner", spindles[0].Owner) 637 637 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 638 638 return 639 639 } ··· 653 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 654 return 655 655 } 656 - if memberId.Handle.IsInvalidHandle() { 657 - l.Error("failed to resolve member identity to handle") 658 - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 659 - return 660 - } 661 656 662 657 tx, err := s.Db.Begin() 663 658 if err != nil { ··· 673 668 // get the record from the DB first: 674 669 members, err := db.GetSpindleMembers( 675 670 s.Db, 676 - orm.FilterEq("did", user.Did), 671 + orm.FilterEq("did", user.Active.Did), 677 672 orm.FilterEq("instance", instance), 678 673 orm.FilterEq("subject", memberId.DID), 679 674 ) ··· 686 681 // remove from db 687 682 if err = db.RemoveSpindleMember( 688 683 tx, 689 - orm.FilterEq("did", user.Did), 684 + orm.FilterEq("did", user.Active.Did), 690 685 orm.FilterEq("instance", instance), 691 686 orm.FilterEq("subject", memberId.DID), 692 687 ); err != nil { ··· 712 707 // remove from pds 713 708 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 714 709 Collection: tangled.SpindleMemberNSID, 715 - Repo: user.Did, 710 + Repo: user.Active.Did, 716 711 Rkey: members[0].Rkey, 717 712 }) 718 713 if err != nil {
+83
appview/state/accounts.go
··· 1 + package state 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + ) 8 + 9 + func (s *State) SwitchAccount(w http.ResponseWriter, r *http.Request) { 10 + l := s.logger.With("handler", "SwitchAccount") 11 + 12 + if err := r.ParseForm(); err != nil { 13 + l.Error("failed to parse form", "err", err) 14 + http.Error(w, "invalid request", http.StatusBadRequest) 15 + return 16 + } 17 + 18 + did := r.FormValue("did") 19 + if did == "" { 20 + http.Error(w, "missing did", http.StatusBadRequest) 21 + return 22 + } 23 + 24 + if err := s.oauth.SwitchAccount(w, r, did); err != nil { 25 + l.Error("failed to switch account", "err", err) 26 + s.pages.HxRedirect(w, "/login?error=session") 27 + return 28 + } 29 + 30 + l.Info("switched account", "did", did) 31 + s.pages.HxRedirect(w, "/") 32 + } 33 + 34 + func (s *State) RemoveAccount(w http.ResponseWriter, r *http.Request) { 35 + l := s.logger.With("handler", "RemoveAccount") 36 + 37 + did := chi.URLParam(r, "did") 38 + if did == "" { 39 + http.Error(w, "missing did", http.StatusBadRequest) 40 + return 41 + } 42 + 43 + currentUser := s.oauth.GetMultiAccountUser(r) 44 + isCurrentAccount := currentUser != nil && currentUser.Active.Did == did 45 + 46 + var remainingAccounts []string 47 + if currentUser != nil { 48 + for _, acc := range currentUser.Accounts { 49 + if acc.Did != did { 50 + remainingAccounts = append(remainingAccounts, acc.Did) 51 + } 52 + } 53 + } 54 + 55 + if err := s.oauth.RemoveAccount(w, r, did); err != nil { 56 + l.Error("failed to remove account", "err", err) 57 + http.Error(w, "failed to remove account", http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + l.Info("removed account", "did", did) 62 + 63 + if isCurrentAccount { 64 + if len(remainingAccounts) > 0 { 65 + nextDid := remainingAccounts[0] 66 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 67 + l.Error("failed to switch to next account", "err", err) 68 + s.pages.HxRedirect(w, "/login") 69 + return 70 + } 71 + s.pages.HxRefresh(w) 72 + return 73 + } 74 + 75 + if err := s.oauth.DeleteSession(w, r); err != nil { 76 + l.Error("failed to delete session", "err", err) 77 + } 78 + s.pages.HxRedirect(w, "/login") 79 + return 80 + } 81 + 82 + s.pages.HxRefresh(w) 83 + }
+23 -11
appview/state/follow.go
··· 15 15 ) 16 16 17 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { 18 - currentUser := s.oauth.GetUser(r) 18 + currentUser := s.oauth.GetMultiAccountUser(r) 19 19 20 20 subject := r.URL.Query().Get("subject") 21 21 if subject == "" { ··· 29 29 return 30 30 } 31 31 32 - if currentUser.Did == subjectIdent.DID.String() { 32 + if currentUser.Active.Did == subjectIdent.DID.String() { 33 33 log.Println("cant follow or unfollow yourself") 34 34 return 35 35 } ··· 46 46 rkey := tid.TID() 47 47 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 48 Collection: tangled.GraphFollowNSID, 49 - Repo: currentUser.Did, 49 + Repo: currentUser.Active.Did, 50 50 Rkey: rkey, 51 51 Record: &lexutil.LexiconTypeDecoder{ 52 52 Val: &tangled.GraphFollow{ ··· 62 62 log.Println("created atproto record: ", resp.Uri) 63 63 64 64 follow := &models.Follow{ 65 - UserDid: currentUser.Did, 65 + UserDid: currentUser.Active.Did, 66 66 SubjectDid: subjectIdent.DID.String(), 67 67 Rkey: rkey, 68 68 } ··· 75 75 76 76 s.notifier.NewFollow(r.Context(), follow) 77 77 78 + followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 79 + if err != nil { 80 + log.Println("failed to get follow stats", err) 81 + } 82 + 78 83 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 79 - UserDid: subjectIdent.DID.String(), 80 - FollowStatus: models.IsFollowing, 84 + UserDid: subjectIdent.DID.String(), 85 + FollowStatus: models.IsFollowing, 86 + FollowersCount: followStats.Followers, 81 87 }) 82 88 83 89 return 84 90 case http.MethodDelete: 85 91 // find the record in the db 86 - follow, err := db.GetFollow(s.db, currentUser.Did, subjectIdent.DID.String()) 92 + follow, err := db.GetFollow(s.db, currentUser.Active.Did, subjectIdent.DID.String()) 87 93 if err != nil { 88 94 log.Println("failed to get follow relationship") 89 95 return ··· 91 97 92 98 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 93 99 Collection: tangled.GraphFollowNSID, 94 - Repo: currentUser.Did, 100 + Repo: currentUser.Active.Did, 95 101 Rkey: follow.Rkey, 96 102 }) 97 103 ··· 100 106 return 101 107 } 102 108 103 - err = db.DeleteFollowByRkey(s.db, currentUser.Did, follow.Rkey) 109 + err = db.DeleteFollowByRkey(s.db, currentUser.Active.Did, follow.Rkey) 104 110 if err != nil { 105 111 log.Println("failed to delete follow from DB") 106 112 // this is not an issue, the firehose event might have already done this 107 113 } 108 114 115 + followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 116 + if err != nil { 117 + log.Println("failed to get follow stats", err) 118 + } 119 + 109 120 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 110 - UserDid: subjectIdent.DID.String(), 111 - FollowStatus: models.IsNotFollowing, 121 + UserDid: subjectIdent.DID.String(), 122 + FollowStatus: models.IsNotFollowing, 123 + FollowersCount: followStats.Followers, 112 124 }) 113 125 114 126 s.notifier.DeleteFollow(r.Context(), follow)
+1 -1
appview/state/gfi.go
··· 15 15 ) 16 16 17 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 18 - user := s.oauth.GetUser(r) 18 + user := s.oauth.GetMultiAccountUser(r) 19 19 20 20 page := pagination.FromContext(r.Context()) 21 21
+86
appview/state/knotstream.go
··· 18 18 "tangled.org/core/log" 19 19 "tangled.org/core/orm" 20 20 "tangled.org/core/rbac" 21 + "tangled.org/core/workflow" 21 22 23 + "github.com/bluesky-social/indigo/atproto/syntax" 22 24 "github.com/go-git/go-git/v5/plumbing" 23 25 "github.com/posthog/posthog-go" 24 26 ) ··· 65 67 switch msg.Nsid { 66 68 case tangled.GitRefUpdateNSID: 67 69 return ingestRefUpdate(d, enforcer, posthog, dev, source, msg) 70 + case tangled.PipelineNSID: 71 + return ingestPipeline(d, source, msg) 68 72 } 69 73 70 74 return nil ··· 186 190 187 191 return tx.Commit() 188 192 } 193 + 194 + func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { 195 + var record tangled.Pipeline 196 + err := json.Unmarshal(msg.EventJson, &record) 197 + if err != nil { 198 + return err 199 + } 200 + 201 + if record.TriggerMetadata == nil { 202 + return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 203 + } 204 + 205 + if record.TriggerMetadata.Repo == nil { 206 + return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 207 + } 208 + 209 + // does this repo have a spindle configured? 210 + repos, err := db.GetRepos( 211 + d, 212 + 0, 213 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 215 + ) 216 + if err != nil { 217 + return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 218 + } 219 + if len(repos) != 1 { 220 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 221 + } 222 + if repos[0].Spindle == "" { 223 + return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 224 + } 225 + 226 + // trigger info 227 + var trigger models.Trigger 228 + var sha string 229 + trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 230 + switch trigger.Kind { 231 + case workflow.TriggerKindPush: 232 + trigger.PushRef = &record.TriggerMetadata.Push.Ref 233 + trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 234 + trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 235 + sha = *trigger.PushNewSha 236 + case workflow.TriggerKindPullRequest: 237 + trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 238 + trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 239 + trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 240 + trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 241 + sha = *trigger.PRSourceSha 242 + } 243 + 244 + tx, err := d.Begin() 245 + if err != nil { 246 + return fmt.Errorf("failed to start txn: %w", err) 247 + } 248 + 249 + triggerId, err := db.AddTrigger(tx, trigger) 250 + if err != nil { 251 + return fmt.Errorf("failed to add trigger entry: %w", err) 252 + } 253 + 254 + pipeline := models.Pipeline{ 255 + Rkey: msg.Rkey, 256 + Knot: source.Key(), 257 + RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 258 + RepoName: record.TriggerMetadata.Repo.Repo, 259 + TriggerId: int(triggerId), 260 + Sha: sha, 261 + } 262 + 263 + err = db.AddPipeline(tx, pipeline) 264 + if err != nil { 265 + return fmt.Errorf("failed to add pipeline: %w", err) 266 + } 267 + 268 + err = tx.Commit() 269 + if err != nil { 270 + return fmt.Errorf("failed to commit txn: %w", err) 271 + } 272 + 273 + return nil 274 + }
+57 -7
appview/state/login.go
··· 5 5 "net/http" 6 6 "strings" 7 7 8 + "tangled.org/core/appview/oauth" 8 9 "tangled.org/core/appview/pages" 9 10 ) 10 11 ··· 15 16 case http.MethodGet: 16 17 returnURL := r.URL.Query().Get("return_url") 17 18 errorCode := r.URL.Query().Get("error") 19 + addAccount := r.URL.Query().Get("mode") == "add_account" 20 + 21 + user := s.oauth.GetMultiAccountUser(r) 22 + if user == nil { 23 + registry := s.oauth.GetAccounts(r) 24 + if len(registry.Accounts) > 0 { 25 + user = &oauth.MultiAccountUser{ 26 + Active: nil, 27 + Accounts: registry.Accounts, 28 + } 29 + } 30 + } 18 31 s.pages.Login(w, pages.LoginParams{ 19 - ReturnUrl: returnURL, 20 - ErrorCode: errorCode, 32 + ReturnUrl: returnURL, 33 + ErrorCode: errorCode, 34 + AddAccount: addAccount, 35 + LoggedInUser: user, 21 36 }) 22 37 case http.MethodPost: 23 38 handle := r.FormValue("handle") 39 + returnURL := r.FormValue("return_url") 40 + addAccount := r.FormValue("add_account") == "true" 24 41 25 42 // when users copy their handle from bsky.app, it tends to have these characters around it: 26 43 // ··· 44 61 return 45 62 } 46 63 64 + if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil { 65 + l.Error("failed to set auth return", "err", err) 66 + } 67 + 47 68 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 69 if err != nil { 49 70 l.Error("failed to start auth", "err", err) ··· 58 79 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 59 80 l := s.logger.With("handler", "Logout") 60 81 61 - err := s.oauth.DeleteSession(w, r) 62 - if err != nil { 63 - l.Error("failed to logout", "err", err) 64 - } else { 65 - l.Info("logged out successfully") 82 + currentUser := s.oauth.GetMultiAccountUser(r) 83 + if currentUser == nil || currentUser.Active == nil { 84 + s.pages.HxRedirect(w, "/login") 85 + return 66 86 } 67 87 88 + currentDid := currentUser.Active.Did 89 + 90 + var remainingAccounts []string 91 + for _, acc := range currentUser.Accounts { 92 + if acc.Did != currentDid { 93 + remainingAccounts = append(remainingAccounts, acc.Did) 94 + } 95 + } 96 + 97 + if err := s.oauth.RemoveAccount(w, r, currentDid); err != nil { 98 + l.Error("failed to remove account from registry", "err", err) 99 + } 100 + 101 + if err := s.oauth.DeleteSession(w, r); err != nil { 102 + l.Error("failed to delete session", "err", err) 103 + } 104 + 105 + if len(remainingAccounts) > 0 { 106 + nextDid := remainingAccounts[0] 107 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 108 + l.Error("failed to switch to next account", "err", err) 109 + s.pages.HxRedirect(w, "/login") 110 + return 111 + } 112 + l.Info("switched to next account after logout", "did", nextDid) 113 + s.pages.HxRefresh(w) 114 + return 115 + } 116 + 117 + l.Info("logged out last account") 68 118 s.pages.HxRedirect(w, "/login") 69 119 }
+29
appview/state/manifest.go
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 9 + // https://www.w3.org/TR/appmanifest/ 10 + var manifestData = map[string]any{ 11 + "name": "tangled", 12 + "description": "tightly-knit social coding.", 13 + "icons": []map[string]string{ 14 + { 15 + "src": "/static/logos/dolly.svg", 16 + "sizes": "144x144", 17 + }, 18 + }, 19 + "start_url": "/", 20 + "id": "https://tangled.org", 21 + "display": "standalone", 22 + "background_color": "#111827", 23 + "theme_color": "#111827", 24 + } 25 + 26 + func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) { 27 + w.Header().Set("Content-Type", "application/manifest+json") 28 + json.NewEncoder(w).Encode(manifestData) 29 + }
+38 -36
appview/state/profile.go
··· 77 77 return nil, fmt.Errorf("failed to get follower stats: %w", err) 78 78 } 79 79 80 - loggedInUser := s.oauth.GetUser(r) 80 + loggedInUser := s.oauth.GetMultiAccountUser(r) 81 81 followStatus := models.IsNotFollowing 82 82 if loggedInUser != nil { 83 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 83 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Active.Did, did) 84 84 } 85 85 86 86 now := time.Now() ··· 163 163 } 164 164 165 165 // populate commit counts in the timeline, using the punchcard 166 - currentMonth := time.Now().Month() 166 + now := time.Now() 167 167 for _, p := range profile.Punchcard.Punches { 168 - idx := currentMonth - p.Date.Month() 169 - if int(idx) < len(timeline.ByMonth) { 170 - timeline.ByMonth[idx].Commits += p.Count 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 171 173 } 172 174 } 173 175 174 176 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 175 - LoggedInUser: s.oauth.GetUser(r), 177 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 176 178 Card: profile, 177 179 Repos: pinnedRepos, 178 180 CollaboratingRepos: pinnedCollaboratingRepos, ··· 203 205 } 204 206 205 207 err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 206 - LoggedInUser: s.oauth.GetUser(r), 208 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 207 209 Repos: repos, 208 210 Card: profile, 209 211 }) ··· 232 234 } 233 235 234 236 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 235 - LoggedInUser: s.oauth.GetUser(r), 237 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 236 238 Repos: repos, 237 239 Card: profile, 238 240 }) ··· 257 259 } 258 260 259 261 err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 260 - LoggedInUser: s.oauth.GetUser(r), 262 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 261 263 Strings: strings, 262 264 Card: profile, 263 265 }) ··· 281 283 } 282 284 l = l.With("profileDid", profile.UserDid) 283 285 284 - loggedInUser := s.oauth.GetUser(r) 286 + loggedInUser := s.oauth.GetMultiAccountUser(r) 285 287 params := FollowsPageParams{ 286 288 Card: profile, 287 289 } ··· 314 316 315 317 loggedInUserFollowing := make(map[string]struct{}) 316 318 if loggedInUser != nil { 317 - following, err := db.GetFollowing(s.db, loggedInUser.Did) 319 + following, err := db.GetFollowing(s.db, loggedInUser.Active.Did) 318 320 if err != nil { 319 - l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Active.Did) 320 322 return &params, err 321 323 } 322 324 loggedInUserFollowing = make(map[string]struct{}, len(following)) ··· 331 333 followStatus := models.IsNotFollowing 332 334 if _, exists := loggedInUserFollowing[did]; exists { 333 335 followStatus = models.IsFollowing 334 - } else if loggedInUser != nil && loggedInUser.Did == did { 336 + } else if loggedInUser != nil && loggedInUser.Active.Did == did { 335 337 followStatus = models.IsSelf 336 338 } 337 339 ··· 365 367 } 366 368 367 369 s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 - LoggedInUser: s.oauth.GetUser(r), 370 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 369 371 Followers: followPage.Follows, 370 372 Card: followPage.Card, 371 373 }) ··· 379 381 } 380 382 381 383 s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 - LoggedInUser: s.oauth.GetUser(r), 384 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 383 385 Following: followPage.Follows, 384 386 Card: followPage.Card, 385 387 }) ··· 528 530 } 529 531 530 532 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 531 - user := s.oauth.GetUser(r) 533 + user := s.oauth.GetMultiAccountUser(r) 532 534 533 535 err := r.ParseForm() 534 536 if err != nil { ··· 537 539 return 538 540 } 539 541 540 - profile, err := db.GetProfile(s.db, user.Did) 542 + profile, err := db.GetProfile(s.db, user.Active.Did) 541 543 if err != nil { 542 - log.Printf("getting profile data for %s: %s", user.Did, err) 544 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 543 545 } 544 546 545 547 profile.Description = r.FormValue("description") ··· 576 578 } 577 579 578 580 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 579 - user := s.oauth.GetUser(r) 581 + user := s.oauth.GetMultiAccountUser(r) 580 582 581 583 err := r.ParseForm() 582 584 if err != nil { ··· 585 587 return 586 588 } 587 589 588 - profile, err := db.GetProfile(s.db, user.Did) 590 + profile, err := db.GetProfile(s.db, user.Active.Did) 589 591 if err != nil { 590 - log.Printf("getting profile data for %s: %s", user.Did, err) 592 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 591 593 } 592 594 593 595 i := 0 ··· 615 617 } 616 618 617 619 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 618 - user := s.oauth.GetUser(r) 620 + user := s.oauth.GetMultiAccountUser(r) 619 621 tx, err := s.db.BeginTx(r.Context(), nil) 620 622 if err != nil { 621 623 log.Println("failed to start transaction", err) ··· 642 644 vanityStats = append(vanityStats, string(v.Kind)) 643 645 } 644 646 645 - ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 647 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Active.Did, "self") 646 648 var cid *string 647 649 if ex != nil { 648 650 cid = ex.Cid ··· 650 652 651 653 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 652 654 Collection: tangled.ActorProfileNSID, 653 - Repo: user.Did, 655 + Repo: user.Active.Did, 654 656 Rkey: "self", 655 657 Record: &lexutil.LexiconTypeDecoder{ 656 658 Val: &tangled.ActorProfile{ ··· 679 681 680 682 s.notifier.UpdateProfile(r.Context(), profile) 681 683 682 - s.pages.HxRedirect(w, "/"+user.Did) 684 + s.pages.HxRedirect(w, "/"+user.Active.Did) 683 685 } 684 686 685 687 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 686 - user := s.oauth.GetUser(r) 688 + user := s.oauth.GetMultiAccountUser(r) 687 689 688 - profile, err := db.GetProfile(s.db, user.Did) 690 + profile, err := db.GetProfile(s.db, user.Active.Did) 689 691 if err != nil { 690 - log.Printf("getting profile data for %s: %s", user.Did, err) 692 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 691 693 } 692 694 693 695 s.pages.EditBioFragment(w, pages.EditBioParams{ ··· 697 699 } 698 700 699 701 func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 700 - user := s.oauth.GetUser(r) 702 + user := s.oauth.GetMultiAccountUser(r) 701 703 702 - profile, err := db.GetProfile(s.db, user.Did) 704 + profile, err := db.GetProfile(s.db, user.Active.Did) 703 705 if err != nil { 704 - log.Printf("getting profile data for %s: %s", user.Did, err) 706 + log.Printf("getting profile data for %s: %s", user.Active.Did, err) 705 707 } 706 708 707 - repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did)) 709 + repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Active.Did)) 708 710 if err != nil { 709 - log.Printf("getting repos for %s: %s", user.Did, err) 711 + log.Printf("getting repos for %s: %s", user.Active.Did, err) 710 712 } 711 713 712 - collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 714 + collaboratingRepos, err := db.CollaboratingIn(s.db, user.Active.Did) 713 715 if err != nil { 714 - log.Printf("getting collaborating repos for %s: %s", user.Did, err) 716 + log.Printf("getting collaborating repos for %s: %s", user.Active.Did, err) 715 717 } 716 718 717 719 allRepos := []pages.PinnedRepo{}
+7 -7
appview/state/reaction.go
··· 17 17 ) 18 18 19 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { 20 - currentUser := s.oauth.GetUser(r) 20 + currentUser := s.oauth.GetMultiAccountUser(r) 21 21 22 22 subject := r.URL.Query().Get("subject") 23 23 if subject == "" { ··· 49 49 rkey := tid.TID() 50 50 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 - Repo: currentUser.Did, 52 + Repo: currentUser.Active.Did, 53 53 Rkey: rkey, 54 54 Record: &lexutil.LexiconTypeDecoder{ 55 55 Val: &tangled.FeedReaction{ ··· 64 64 return 65 65 } 66 66 67 - err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey) 67 + err = db.AddReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind, rkey) 68 68 if err != nil { 69 69 log.Println("failed to react", err) 70 70 return ··· 87 87 88 88 return 89 89 case http.MethodDelete: 90 - reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 90 + reaction, err := db.GetReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind) 91 91 if err != nil { 92 - log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri) 92 + log.Println("failed to get reaction relationship for", currentUser.Active.Did, subjectUri) 93 93 return 94 94 } 95 95 96 96 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 97 Collection: tangled.FeedReactionNSID, 98 - Repo: currentUser.Did, 98 + Repo: currentUser.Active.Did, 99 99 Rkey: reaction.Rkey, 100 100 }) 101 101 ··· 104 104 return 105 105 } 106 106 107 - err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 107 + err = db.DeleteReactionByRkey(s.db, currentUser.Active.Did, reaction.Rkey) 108 108 if err != nil { 109 109 log.Println("failed to delete reaction from DB") 110 110 // this is not an issue, the firehose event might have already done this
+6 -3
appview/state/router.go
··· 32 32 s.pages, 33 33 ) 34 34 35 - router.Get("/favicon.svg", s.Favicon) 36 - router.Get("/favicon.ico", s.Favicon) 37 - router.Get("/pwa-manifest.json", s.PWAManifest) 35 + router.Get("/pwa-manifest.json", s.WebAppManifest) 38 36 router.Get("/robots.txt", s.RobotsTxt) 39 37 40 38 userRouter := s.UserRouter(&middleware) ··· 109 107 }) 110 108 111 109 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 110 + w.WriteHeader(http.StatusNotFound) 112 111 s.pages.Error404(w) 113 112 }) 114 113 ··· 131 130 r.Post("/login", s.Login) 132 131 r.Post("/logout", s.Logout) 133 132 133 + r.Post("/account/switch", s.SwitchAccount) 134 + r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount) 135 + 134 136 r.Route("/repo", func(r chi.Router) { 135 137 r.Route("/new", func(r chi.Router) { 136 138 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 182 184 r.Get("/brand", s.Brand) 183 185 184 186 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 187 + w.WriteHeader(http.StatusNotFound) 185 188 s.pages.Error404(w) 186 189 }) 187 190 return r
-89
appview/state/spindlestream.go
··· 20 20 "tangled.org/core/orm" 21 21 "tangled.org/core/rbac" 22 22 spindle "tangled.org/core/spindle/models" 23 - "tangled.org/core/workflow" 24 23 ) 25 24 26 25 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 63 62 func spindleIngester(ctx context.Context, logger *slog.Logger, d *db.DB) ec.ProcessFunc { 64 63 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 65 64 switch msg.Nsid { 66 - case tangled.PipelineNSID: 67 - return ingestPipeline(logger, d, source, msg) 68 65 case tangled.PipelineStatusNSID: 69 66 return ingestPipelineStatus(ctx, logger, d, source, msg) 70 67 } 71 68 72 69 return nil 73 70 } 74 - } 75 - 76 - func ingestPipeline(l *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error { 77 - var record tangled.Pipeline 78 - err := json.Unmarshal(msg.EventJson, &record) 79 - if err != nil { 80 - return err 81 - } 82 - 83 - if record.TriggerMetadata == nil { 84 - return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 85 - } 86 - 87 - if record.TriggerMetadata.Repo == nil { 88 - return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 89 - } 90 - 91 - // does this repo have a spindle configured? 92 - repos, err := db.GetRepos( 93 - d, 94 - 0, 95 - orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 96 - orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 97 - ) 98 - if err != nil { 99 - return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 100 - } 101 - if len(repos) != 1 { 102 - return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 103 - } 104 - if repos[0].Spindle == "" { 105 - return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 106 - } 107 - 108 - // trigger info 109 - var trigger models.Trigger 110 - var sha string 111 - trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 112 - switch trigger.Kind { 113 - case workflow.TriggerKindPush: 114 - trigger.PushRef = &record.TriggerMetadata.Push.Ref 115 - trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 116 - trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 117 - sha = *trigger.PushNewSha 118 - case workflow.TriggerKindPullRequest: 119 - trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 120 - trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 121 - trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 122 - trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 123 - sha = *trigger.PRSourceSha 124 - } 125 - 126 - tx, err := d.Begin() 127 - if err != nil { 128 - return fmt.Errorf("failed to start txn: %w", err) 129 - } 130 - 131 - triggerId, err := db.AddTrigger(tx, trigger) 132 - if err != nil { 133 - return fmt.Errorf("failed to add trigger entry: %w", err) 134 - } 135 - 136 - // TODO: we shouldn't even use knot to identify pipelines 137 - knot := record.TriggerMetadata.Repo.Knot 138 - pipeline := models.Pipeline{ 139 - Rkey: msg.Rkey, 140 - Knot: knot, 141 - RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 142 - RepoName: record.TriggerMetadata.Repo.Repo, 143 - TriggerId: int(triggerId), 144 - Sha: sha, 145 - } 146 - 147 - err = db.AddPipeline(tx, pipeline) 148 - if err != nil { 149 - return fmt.Errorf("failed to add pipeline: %w", err) 150 - } 151 - 152 - err = tx.Commit() 153 - if err != nil { 154 - return fmt.Errorf("failed to commit txn: %w", err) 155 - } 156 - 157 - l.Info("added pipeline", "pipeline", pipeline) 158 - 159 - return nil 160 71 } 161 72 162 73 func ingestPipelineStatus(ctx context.Context, logger *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
+6 -6
appview/state/star.go
··· 16 16 ) 17 17 18 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { 19 - currentUser := s.oauth.GetUser(r) 19 + currentUser := s.oauth.GetMultiAccountUser(r) 20 20 21 21 subject := r.URL.Query().Get("subject") 22 22 if subject == "" { ··· 42 42 rkey := tid.TID() 43 43 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 - Repo: currentUser.Did, 45 + Repo: currentUser.Active.Did, 46 46 Rkey: rkey, 47 47 Record: &lexutil.LexiconTypeDecoder{ 48 48 Val: &tangled.FeedStar{ ··· 57 57 log.Println("created atproto record: ", resp.Uri) 58 58 59 59 star := &models.Star{ 60 - Did: currentUser.Did, 60 + Did: currentUser.Active.Did, 61 61 RepoAt: subjectUri, 62 62 Rkey: rkey, 63 63 } ··· 84 84 return 85 85 case http.MethodDelete: 86 86 // find the record in the db 87 - star, err := db.GetStar(s.db, currentUser.Did, subjectUri) 87 + star, err := db.GetStar(s.db, currentUser.Active.Did, subjectUri) 88 88 if err != nil { 89 89 log.Println("failed to get star relationship") 90 90 return ··· 92 92 93 93 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 94 94 Collection: tangled.FeedStarNSID, 95 - Repo: currentUser.Did, 95 + Repo: currentUser.Active.Did, 96 96 Rkey: star.Rkey, 97 97 }) 98 98 ··· 101 101 return 102 102 } 103 103 104 - err = db.DeleteStarByRkey(s.db, currentUser.Did, star.Rkey) 104 + err = db.DeleteStarByRkey(s.db, currentUser.Active.Did, star.Rkey) 105 105 if err != nil { 106 106 log.Println("failed to delete star from DB") 107 107 // this is not an issue, the firehose event might have already done this
+22 -58
appview/state/state.go
··· 202 202 return s.db.Close() 203 203 } 204 204 205 - func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 206 - w.Header().Set("Content-Type", "image/svg+xml") 207 - w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 208 - w.Header().Set("ETag", `"favicon-svg-v1"`) 209 - 210 - if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 211 - w.WriteHeader(http.StatusNotModified) 212 - return 213 - } 214 - 215 - s.pages.Favicon(w) 216 - } 217 - 218 205 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 219 206 w.Header().Set("Content-Type", "text/plain") 220 207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day ··· 225 212 w.Write([]byte(robotsTxt)) 226 213 } 227 214 228 - // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 229 - const manifestJson = `{ 230 - "name": "tangled", 231 - "description": "tightly-knit social coding.", 232 - "icons": [ 233 - { 234 - "src": "/favicon.svg", 235 - "sizes": "144x144" 236 - } 237 - ], 238 - "start_url": "/", 239 - "id": "org.tangled", 240 - 241 - "display": "standalone", 242 - "background_color": "#111827", 243 - "theme_color": "#111827" 244 - }` 245 - 246 - func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 247 - w.Header().Set("Content-Type", "application/json") 248 - w.Write([]byte(manifestJson)) 249 - } 250 - 251 215 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 252 - user := s.oauth.GetUser(r) 216 + user := s.oauth.GetMultiAccountUser(r) 253 217 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 254 218 LoggedInUser: user, 255 219 }) 256 220 } 257 221 258 222 func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 259 - user := s.oauth.GetUser(r) 223 + user := s.oauth.GetMultiAccountUser(r) 260 224 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 261 225 LoggedInUser: user, 262 226 }) 263 227 } 264 228 265 229 func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 266 - user := s.oauth.GetUser(r) 230 + user := s.oauth.GetMultiAccountUser(r) 267 231 s.pages.Brand(w, pages.BrandParams{ 268 232 LoggedInUser: user, 269 233 }) 270 234 } 271 235 272 236 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 273 - if s.oauth.GetUser(r) != nil { 237 + if s.oauth.GetMultiAccountUser(r) != nil { 274 238 s.Timeline(w, r) 275 239 return 276 240 } ··· 278 242 } 279 243 280 244 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 281 - user := s.oauth.GetUser(r) 245 + user := s.oauth.GetMultiAccountUser(r) 282 246 283 247 // TODO: set this flag based on the UI 284 248 filtered := false 285 249 286 250 var userDid string 287 - if user != nil { 288 - userDid = user.Did 251 + if user != nil && user.Active != nil { 252 + userDid = user.Active.Did 289 253 } 290 254 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 291 255 if err != nil { ··· 314 278 } 315 279 316 280 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 317 - user := s.oauth.GetUser(r) 281 + user := s.oauth.GetMultiAccountUser(r) 318 282 if user == nil { 319 283 return 320 284 } 321 285 322 286 l := s.logger.With("handler", "UpgradeBanner") 323 - l = l.With("did", user.Did) 287 + l = l.With("did", user.Active.Did) 324 288 325 289 regs, err := db.GetRegistrations( 326 290 s.db, 327 - orm.FilterEq("did", user.Did), 291 + orm.FilterEq("did", user.Active.Did), 328 292 orm.FilterEq("needs_upgrade", 1), 329 293 ) 330 294 if err != nil { ··· 333 297 334 298 spindles, err := db.GetSpindles( 335 299 s.db, 336 - orm.FilterEq("owner", user.Did), 300 + orm.FilterEq("owner", user.Active.Did), 337 301 orm.FilterEq("needs_upgrade", 1), 338 302 ) 339 303 if err != nil { ··· 447 411 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 448 412 switch r.Method { 449 413 case http.MethodGet: 450 - user := s.oauth.GetUser(r) 451 - knots, err := s.enforcer.GetKnotsForUser(user.Did) 414 + user := s.oauth.GetMultiAccountUser(r) 415 + knots, err := s.enforcer.GetKnotsForUser(user.Active.Did) 452 416 if err != nil { 453 417 s.pages.Notice(w, "repo", "Invalid user account.") 454 418 return ··· 462 426 case http.MethodPost: 463 427 l := s.logger.With("handler", "NewRepo") 464 428 465 - user := s.oauth.GetUser(r) 466 - l = l.With("did", user.Did) 429 + user := s.oauth.GetMultiAccountUser(r) 430 + l = l.With("did", user.Active.Did) 467 431 468 432 // form validation 469 433 domain := r.FormValue("domain") ··· 495 459 description := r.FormValue("description") 496 460 497 461 // ACL validation 498 - ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 462 + ok, err := s.enforcer.E.Enforce(user.Active.Did, domain, domain, "repo:create") 499 463 if err != nil || !ok { 500 464 l.Info("unauthorized") 501 465 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") ··· 505 469 // Check for existing repos 506 470 existingRepo, err := db.GetRepo( 507 471 s.db, 508 - orm.FilterEq("did", user.Did), 472 + orm.FilterEq("did", user.Active.Did), 509 473 orm.FilterEq("name", repoName), 510 474 ) 511 475 if err == nil && existingRepo != nil { ··· 517 481 // create atproto record for this repo 518 482 rkey := tid.TID() 519 483 repo := &models.Repo{ 520 - Did: user.Did, 484 + Did: user.Active.Did, 521 485 Name: repoName, 522 486 Knot: domain, 523 487 Rkey: rkey, ··· 536 500 537 501 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 538 502 Collection: tangled.RepoNSID, 539 - Repo: user.Did, 503 + Repo: user.Active.Did, 540 504 Rkey: rkey, 541 505 Record: &lexutil.LexiconTypeDecoder{ 542 506 Val: &record, ··· 613 577 } 614 578 615 579 // acls 616 - p, _ := securejoin.SecureJoin(user.Did, repoName) 617 - err = s.enforcer.AddRepo(user.Did, domain, p) 580 + p, _ := securejoin.SecureJoin(user.Active.Did, repoName) 581 + err = s.enforcer.AddRepo(user.Active.Did, domain, p) 618 582 if err != nil { 619 583 l.Error("acl setup failed", "err", err) 620 584 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 639 603 aturi = "" 640 604 641 605 s.notifier.NewRepo(r.Context(), repo) 642 - s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 606 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Active.Did, repoName)) 643 607 } 644 608 } 645 609
+19 -19
appview/strings/strings.go
··· 82 82 } 83 83 84 84 s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 85 - LoggedInUser: s.OAuth.GetUser(r), 85 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 86 86 Strings: strings, 87 87 }) 88 88 } ··· 153 153 if err != nil { 154 154 l.Error("failed to get star count", "err", err) 155 155 } 156 - user := s.OAuth.GetUser(r) 156 + user := s.OAuth.GetMultiAccountUser(r) 157 157 isStarred := false 158 158 if user != nil { 159 - isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 159 + isStarred = db.GetStarStatus(s.Db, user.Active.Did, string.AtUri()) 160 160 } 161 161 162 162 s.Pages.SingleString(w, pages.SingleStringParams{ ··· 178 178 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 179 179 l := s.Logger.With("handler", "edit") 180 180 181 - user := s.OAuth.GetUser(r) 181 + user := s.OAuth.GetMultiAccountUser(r) 182 182 183 183 id, ok := r.Context().Value("resolvedId").(identity.Identity) 184 184 if !ok { ··· 216 216 first := all[0] 217 217 218 218 // verify that the logged in user owns this string 219 - if user.Did != id.DID.String() { 220 - l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 219 + if user.Active.Did != id.DID.String() { 220 + l.Error("unauthorized request", "expected", id.DID, "got", user.Active.Did) 221 221 w.WriteHeader(http.StatusUnauthorized) 222 222 return 223 223 } ··· 226 226 case http.MethodGet: 227 227 // return the form with prefilled fields 228 228 s.Pages.PutString(w, pages.PutStringParams{ 229 - LoggedInUser: s.OAuth.GetUser(r), 229 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 230 230 Action: "edit", 231 231 String: first, 232 232 }) ··· 299 299 s.Notifier.EditString(r.Context(), &entry) 300 300 301 301 // if that went okay, redir to the string 302 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 302 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+entry.Rkey) 303 303 } 304 304 305 305 } 306 306 307 307 func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 308 308 l := s.Logger.With("handler", "create") 309 - user := s.OAuth.GetUser(r) 309 + user := s.OAuth.GetMultiAccountUser(r) 310 310 311 311 switch r.Method { 312 312 case http.MethodGet: 313 313 s.Pages.PutString(w, pages.PutStringParams{ 314 - LoggedInUser: s.OAuth.GetUser(r), 314 + LoggedInUser: s.OAuth.GetMultiAccountUser(r), 315 315 Action: "new", 316 316 }) 317 317 case http.MethodPost: ··· 335 335 description := r.FormValue("description") 336 336 337 337 string := models.String{ 338 - Did: syntax.DID(user.Did), 338 + Did: syntax.DID(user.Active.Did), 339 339 Rkey: tid.TID(), 340 340 Filename: filename, 341 341 Description: description, ··· 353 353 354 354 resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 355 355 Collection: tangled.StringNSID, 356 - Repo: user.Did, 356 + Repo: user.Active.Did, 357 357 Rkey: string.Rkey, 358 358 Record: &lexutil.LexiconTypeDecoder{ 359 359 Val: &record, ··· 375 375 s.Notifier.NewString(r.Context(), &string) 376 376 377 377 // successful 378 - s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 378 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did+"/"+string.Rkey) 379 379 } 380 380 } 381 381 382 382 func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 383 383 l := s.Logger.With("handler", "create") 384 - user := s.OAuth.GetUser(r) 384 + user := s.OAuth.GetMultiAccountUser(r) 385 385 fail := func(msg string, err error) { 386 386 l.Error(msg, "err", err) 387 387 s.Pages.Notice(w, "error", msg) ··· 402 402 return 403 403 } 404 404 405 - if user.Did != id.DID.String() { 406 - fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 405 + if user.Active.Did != id.DID.String() { 406 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Active.Did, id.DID.String())) 407 407 return 408 408 } 409 409 410 410 if err := db.DeleteString( 411 411 s.Db, 412 - orm.FilterEq("did", user.Did), 412 + orm.FilterEq("did", user.Active.Did), 413 413 orm.FilterEq("rkey", rkey), 414 414 ); err != nil { 415 415 fail("Failed to delete string.", err) 416 416 return 417 417 } 418 418 419 - s.Notifier.DeleteString(r.Context(), user.Did, rkey) 419 + s.Notifier.DeleteString(r.Context(), user.Active.Did, rkey) 420 420 421 - s.Pages.HxRedirect(w, "/strings/"+user.Did) 421 + s.Pages.HxRedirect(w, "/strings/"+user.Active.Did) 422 422 } 423 423 424 424 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+182
cmd/dolly/main.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "flag" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "os" 11 + "path/filepath" 12 + "strconv" 13 + "strings" 14 + "text/template" 15 + 16 + "github.com/srwiley/oksvg" 17 + "github.com/srwiley/rasterx" 18 + "golang.org/x/image/draw" 19 + "tangled.org/core/appview/pages" 20 + "tangled.org/core/ico" 21 + ) 22 + 23 + func main() { 24 + var ( 25 + size string 26 + fillColor string 27 + output string 28 + ) 29 + 30 + flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)") 31 + flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") 32 + flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") 33 + flag.Parse() 34 + 35 + width, height, err := parseSize(size) 36 + if err != nil { 37 + fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err) 38 + os.Exit(1) 39 + } 40 + 41 + // Detect format from file extension 42 + ext := strings.ToLower(filepath.Ext(output)) 43 + format := strings.TrimPrefix(ext, ".") 44 + 45 + if format != "svg" && format != "png" && format != "ico" { 46 + fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext) 47 + os.Exit(1) 48 + } 49 + 50 + if fillColor != "currentColor" && !isValidHexColor(fillColor) { 51 + fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor) 52 + os.Exit(1) 53 + } 54 + 55 + svgData, err := dolly(fillColor) 56 + if err != nil { 57 + fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) 58 + os.Exit(1) 59 + } 60 + 61 + // Create output directory if it doesn't exist 62 + dir := filepath.Dir(output) 63 + if dir != "" && dir != "." { 64 + if err := os.MkdirAll(dir, 0755); err != nil { 65 + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) 66 + os.Exit(1) 67 + } 68 + } 69 + 70 + switch format { 71 + case "svg": 72 + err = saveSVG(svgData, output, width, height) 73 + case "png": 74 + err = savePNG(svgData, output, width, height) 75 + case "ico": 76 + err = saveICO(svgData, output, width, height) 77 + } 78 + 79 + if err != nil { 80 + fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) 81 + os.Exit(1) 82 + } 83 + 84 + fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) 85 + } 86 + 87 + func dolly(hexColor string) ([]byte, error) { 88 + tpl, err := template.New("dolly"). 89 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 90 + if err != nil { 91 + return nil, err 92 + } 93 + 94 + var svgData bytes.Buffer 95 + if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{ 96 + FillColor: hexColor, 97 + }); err != nil { 98 + return nil, err 99 + } 100 + 101 + return svgData.Bytes(), nil 102 + } 103 + 104 + func svgToImage(svgData []byte, w, h int) (image.Image, error) { 105 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 106 + if err != nil { 107 + return nil, fmt.Errorf("error parsing SVG: %v", err) 108 + } 109 + 110 + icon.SetTarget(0, 0, float64(w), float64(h)) 111 + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) 112 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) 113 + scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) 114 + raster := rasterx.NewDasher(w, h, scanner) 115 + icon.Draw(raster, 1.0) 116 + 117 + return rgba, nil 118 + } 119 + 120 + func parseSize(size string) (int, int, error) { 121 + parts := strings.Split(size, "x") 122 + if len(parts) != 2 { 123 + return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT") 124 + } 125 + 126 + width, err := strconv.Atoi(parts[0]) 127 + if err != nil { 128 + return 0, 0, fmt.Errorf("invalid width: %v", err) 129 + } 130 + 131 + height, err := strconv.Atoi(parts[1]) 132 + if err != nil { 133 + return 0, 0, fmt.Errorf("invalid height: %v", err) 134 + } 135 + 136 + if width <= 0 || height <= 0 { 137 + return 0, 0, fmt.Errorf("width and height must be positive") 138 + } 139 + 140 + return width, height, nil 141 + } 142 + 143 + func isValidHexColor(hex string) bool { 144 + if len(hex) != 7 || hex[0] != '#' { 145 + return false 146 + } 147 + _, err := strconv.ParseUint(hex[1:], 16, 32) 148 + return err == nil 149 + } 150 + 151 + func saveSVG(svgData []byte, filepath string, _, _ int) error { 152 + return os.WriteFile(filepath, svgData, 0644) 153 + } 154 + 155 + func savePNG(svgData []byte, filepath string, width, height int) error { 156 + img, err := svgToImage(svgData, width, height) 157 + if err != nil { 158 + return err 159 + } 160 + 161 + f, err := os.Create(filepath) 162 + if err != nil { 163 + return err 164 + } 165 + defer f.Close() 166 + 167 + return png.Encode(f, img) 168 + } 169 + 170 + func saveICO(svgData []byte, filepath string, width, height int) error { 171 + img, err := svgToImage(svgData, width, height) 172 + if err != nil { 173 + return err 174 + } 175 + 176 + icoData, err := ico.ImageToIco(img) 177 + if err != nil { 178 + return err 179 + } 180 + 181 + return os.WriteFile(filepath, icoData, 0644) 182 + }
+26 -25
docs/DOCS.md
··· 2 2 title: Tangled docs 3 3 author: The Tangled Contributors 4 4 date: 21 Sun, Dec 2025 5 - --- 6 - 7 - # Introduction 8 - 9 - Tangled is a decentralized code hosting and collaboration 10 - platform. Every component of Tangled is open-source and 11 - self-hostable. [tangled.org](https://tangled.org) also 12 - provides hosting and CI services that are free to use. 5 + abstract: | 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 13 10 14 - There are several models for decentralized code 15 - collaboration platforms, ranging from ActivityPubโ€™s 16 - (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 17 - Our approach attempts to be the best of both worlds by 18 - adopting the AT Protocolโ€”a protocol for building decentralized 19 - social applications with a central identity 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPubโ€™s 13 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocolโ€”a protocol for building decentralized 16 + social applications with a central identity 20 17 21 - Our approach to this is the idea of โ€œknotsโ€. Knots are 22 - lightweight, headless servers that enable users to host Git 23 - repositories with ease. Knots are designed for either single 24 - or multi-tenant use which is perfect for self-hosting on a 25 - Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 26 - default, Tangled provides managed knots where you can host 27 - your repositories for free. 18 + Our approach to this is the idea of โ€œknotsโ€. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 28 25 29 - The appview at tangled.org acts as a consolidated "view" 30 - into the whole network, allowing users to access, clone and 31 - contribute to repositories hosted across different knots 32 - seamlessly. 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 + --- 33 31 34 32 # Quick start guide 35 33 ··· 665 663 nixpkgs: 666 664 - nodejs 667 665 - go 666 + # unstable 667 + nixpkgs/nixpkgs-unstable: 668 + - bun 668 669 # custom registry 669 670 git+https://tangled.org/@example.com/my_pkg: 670 671 - my_pkg
+6
docs/logo.html
··· 1 + <div class="flex items-center gap-2 w-fit mx-auto"> 2 + <span class="w-16 h-16 [&>svg]:w-full [&>svg]:h-full text-black dark:text-white"> 3 + ${ dolly.svg() } 4 + </span> 5 + <span class="font-bold text-4xl not-italic text-black dark:text-white">tangled</span> 6 + </div>
+3
docs/mode.html
··· 1 + <a class="px-4 py-2 mt-8 block text-center w-full rounded-sm shadow-sm border border-gray-200 dark:border-gray-700 no-underline hover:no-underline" href="$if(single-page)$/$else$/single-page.html$endif$"> 2 + $if(single-page)$View as multi-page$else$View as single-page$endif$ 3 + </a>
+7
docs/search.html
··· 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
+76 -33
docs/template.html
··· 35 35 $endfor$ 36 36 37 37 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 39 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 40 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 38 41 39 42 </head> 40 - <body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen"> 43 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 44 $for(include-before)$ 42 45 $include-before$ 43 46 $endfor$ 44 47 45 48 $if(toc)$ 46 - <!-- mobile topbar toc --> 47 - <details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4"> 48 - <summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white"> 49 + <!-- mobile TOC trigger --> 50 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 51 + <button 52 + type="button" 53 + popovertarget="mobile-toc-popover" 54 + popovertargetaction="toggle" 55 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 56 + > 57 + ${ menu.svg() } 49 58 $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 50 - <span class="group-open:hidden inline">${ menu.svg() }</span> 51 - <span class="hidden group-open:inline">${ x.svg() }</span> 52 - </summary> 53 - ${ table-of-contents:toc.html() } 54 - </details> 59 + </button> 60 + </div> 61 + 62 + <div 63 + id="mobile-toc-popover" 64 + popover 65 + class="mobile-toc-popover 66 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 67 + h-full overflow-y-auto shadow-sm 68 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 69 + > 70 + <div class="flex flex-col min-h-full"> 71 + <div class="flex-1 space-y-4"> 72 + <button 73 + type="button" 74 + popovertarget="mobile-toc-popover" 75 + popovertargetaction="toggle" 76 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 77 + ${ x.svg() } 78 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 79 + </button> 80 + ${ logo.html() } 81 + ${ search.html() } 82 + ${ table-of-contents:toc.html() } 83 + </div> 84 + ${ single-page:mode.html() } 85 + </div> 86 + </div> 87 + 55 88 <!-- desktop sidebar toc --> 56 - <nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50"> 57 - $if(toc-title)$ 58 - <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 59 - $endif$ 60 - ${ table-of-contents:toc.html() } 89 + <nav 90 + id="$idprefix$TOC" 91 + role="doc-toc" 92 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 93 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 94 + p-4 z-50 overflow-y-auto"> 95 + ${ logo.html() } 96 + ${ search.html() } 97 + <div class="flex-1"> 98 + $if(toc-title)$ 99 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 100 + $endif$ 101 + ${ table-of-contents:toc.html() } 102 + </div> 103 + ${ single-page:mode.html() } 61 104 </nav> 62 105 $endif$ 63 106 64 107 <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 65 108 <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 66 109 $if(top)$ 67 - $-- only print title block if this is NOT the top page 110 + $-- only print title block if this is NOT the top page 68 111 $else$ 69 112 $if(title)$ 70 - <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 71 - <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 72 - $if(subtitle)$ 73 - <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 74 - $endif$ 75 - $for(author)$ 76 - <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 77 - $endfor$ 78 - $if(date)$ 79 - <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 80 - $endif$ 113 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 114 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 115 + $if(subtitle)$ 116 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 117 + $endif$ 118 + $for(author)$ 119 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 120 + $endfor$ 121 + $if(date)$ 122 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 123 + $endif$ 124 + $endif$ 125 + </header> 81 126 $if(abstract)$ 82 - <div class="mt-6 p-4 bg-gray-50 rounded-lg"> 83 - <div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div> 84 - <div class="text-gray-700">$abstract$</div> 85 - </div> 127 + <article class="prose dark:prose-invert max-w-none"> 128 + $abstract$ 129 + </article> 86 130 $endif$ 87 - $endif$ 88 - </header> 89 131 $endif$ 132 + 90 133 <article class="prose dark:prose-invert max-w-none"> 91 134 $body$ 92 135 </article> 93 136 </main> 94 - <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 "> 137 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 95 138 <div class="max-w-4xl mx-auto px-8 py-4"> 96 139 <div class="flex justify-between gap-4"> 97 140 <span class="flex-1">
+18 -32
flake.nix
··· 76 76 }; 77 77 buildGoApplication = 78 78 (self.callPackage "${gomod2nix}/builder" { 79 - gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 79 + gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix; 80 80 }).buildGoApplication; 81 81 modules = ./nix/gomod2nix.toml; 82 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { ··· 94 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 95 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 96 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 - did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; 98 - bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; 99 - bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; 100 - tap = self.callPackage ./nix/pkgs/tap.nix {}; 97 + dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 101 98 }); 102 99 in { 103 100 overlays.default = final: prev: { 104 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs did-method-plc bluesky-jetstream bluesky-relay tap; 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 105 102 }; 106 103 107 104 packages = forAllSystems (system: let ··· 110 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 111 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 112 109 in { 113 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs did-method-plc bluesky-jetstream bluesky-relay tap; 110 + inherit 111 + (packages) 112 + appview 113 + appview-static-files 114 + lexgen 115 + goat 116 + spindle 117 + knot 118 + knot-unwrapped 119 + sqlite-lib 120 + docs 121 + dolly 122 + ; 114 123 115 124 pkgsStatic-appview = staticPackages.appview; 116 125 pkgsStatic-knot = staticPackages.knot; 117 126 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 118 127 pkgsStatic-spindle = staticPackages.spindle; 119 128 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 129 + pkgsStatic-dolly = staticPackages.dolly; 120 130 121 131 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 122 132 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 123 133 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 124 134 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 135 + pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly; 125 136 126 137 treefmt-wrapper = pkgs.treefmt.withConfig { 127 138 settings.formatter = { ··· 309 320 imports = [./nix/modules/spindle.nix]; 310 321 311 322 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 312 - services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap; 313 - }; 314 - nixosModules.did-method-plc = { 315 - lib, 316 - pkgs, 317 - ... 318 - }: { 319 - imports = [./nix/modules/did-method-plc.nix]; 320 - services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 321 - }; 322 - nixosModules.bluesky-relay = { 323 - lib, 324 - pkgs, 325 - ... 326 - }: { 327 - imports = [./nix/modules/bluesky-relay.nix]; 328 - services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 329 - }; 330 - nixosModules.bluesky-jetstream = { 331 - lib, 332 - pkgs, 333 - ... 334 - }: { 335 - imports = [./nix/modules/bluesky-jetstream.nix]; 336 - services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 337 323 }; 338 324 }; 339 325 }
-1
go.mod
··· 29 29 github.com/gorilla/feeds v1.2.0 30 30 github.com/gorilla/sessions v1.4.0 31 31 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 32 - github.com/hashicorp/go-version v1.8.0 33 32 github.com/hiddeco/sshsig v0.2.0 34 33 github.com/hpcloud/tail v1.0.0 35 34 github.com/ipfs/go-cid v0.5.0
-2
go.sum
··· 264 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 - github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 268 - github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 269 267 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 270 268 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 271 269 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+88
ico/ico.go
··· 1 + package ico 2 + 3 + import ( 4 + "bytes" 5 + "encoding/binary" 6 + "fmt" 7 + "image" 8 + "image/png" 9 + ) 10 + 11 + type IconDir struct { 12 + Reserved uint16 // must be 0 13 + Type uint16 // 1 for ICO, 2 for CUR 14 + Count uint16 // number of images 15 + } 16 + 17 + type IconDirEntry struct { 18 + Width uint8 // 0 means 256 19 + Height uint8 // 0 means 256 20 + ColorCount uint8 21 + Reserved uint8 // must be 0 22 + ColorPlanes uint16 // 0 or 1 23 + BitsPerPixel uint16 24 + SizeInBytes uint32 25 + Offset uint32 26 + } 27 + 28 + func ImageToIco(img image.Image) ([]byte, error) { 29 + // encode image as png 30 + var pngBuf bytes.Buffer 31 + if err := png.Encode(&pngBuf, img); err != nil { 32 + return nil, fmt.Errorf("failed to encode PNG: %w", err) 33 + } 34 + pngData := pngBuf.Bytes() 35 + 36 + // get image dimensions 37 + bounds := img.Bounds() 38 + width := bounds.Dx() 39 + height := bounds.Dy() 40 + 41 + // prepare output buffer 42 + var icoBuf bytes.Buffer 43 + 44 + iconDir := IconDir{ 45 + Reserved: 0, 46 + Type: 1, // ICO format 47 + Count: 1, // One image 48 + } 49 + 50 + w := uint8(width) 51 + h := uint8(height) 52 + 53 + // width/height of 256 should be stored as 0 54 + if width == 256 { 55 + w = 0 56 + } 57 + if height == 256 { 58 + h = 0 59 + } 60 + 61 + iconDirEntry := IconDirEntry{ 62 + Width: w, 63 + Height: h, 64 + ColorCount: 0, // 0 for PNG (32-bit) 65 + Reserved: 0, 66 + ColorPlanes: 1, 67 + BitsPerPixel: 32, // PNG with alpha 68 + SizeInBytes: uint32(len(pngData)), 69 + Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY 70 + } 71 + 72 + // write IconDir 73 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil { 74 + return nil, fmt.Errorf("failed to write ICONDIR: %w", err) 75 + } 76 + 77 + // write IconDirEntry 78 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil { 79 + return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err) 80 + } 81 + 82 + // write PNG data directly 83 + if _, err := icoBuf.Write(pngData); err != nil { 84 + return nil, fmt.Errorf("failed to write PNG data: %w", err) 85 + } 86 + 87 + return icoBuf.Bytes(), nil 88 + }
+18
input.css
··· 124 124 dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 125 125 } 126 126 127 + .btn-flat { 128 + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 129 + bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 130 + before:absolute before:inset-0 before:-z-10 before:block before:rounded 131 + before:border before:border-gray-200 before:bg-white 132 + before:content-[''] before:transition-all before:duration-150 before:ease-in-out 133 + hover:before:bg-gray-50 134 + dark:hover:before:bg-gray-700 135 + focus:outline-none focus-visible:before:outline focus-visible:before:outline-2 focus-visible:before:outline-gray-400 136 + disabled:cursor-not-allowed disabled:opacity-50 137 + dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 138 + } 139 + 127 140 .btn-create { 128 141 @apply btn text-white 129 142 before:bg-green-600 hover:before:bg-green-700 ··· 131 144 before:border before:border-green-700 hover:before:border-green-800 132 145 focus-visible:before:outline-green-500 133 146 disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 147 + } 148 + 149 + .prose { 150 + overflow-wrap: anywhere; 134 151 } 135 152 136 153 .prose hr { ··· 255 272 @apply py-1 text-gray-900 dark:text-gray-100; 256 273 } 257 274 } 275 + 258 276 } 259 277 260 278 /* Background */
+3 -8
knotserver/git/diff.go
··· 64 64 65 65 for _, tf := range d.TextFragments { 66 66 ndiff.TextFragments = append(ndiff.TextFragments, *tf) 67 - for _, l := range tf.Lines { 68 - switch l.Op { 69 - case gitdiff.OpAdd: 70 - nd.Stat.Insertions += 1 71 - case gitdiff.OpDelete: 72 - nd.Stat.Deletions += 1 73 - } 74 - } 67 + nd.Stat.Insertions += tf.LinesAdded 68 + nd.Stat.Deletions += tf.LinesDeleted 75 69 } 76 70 77 71 nd.Diff = append(nd.Diff, ndiff) 78 72 } 79 73 74 + nd.Stat.FilesChanged += len(diffs) 80 75 nd.Commit.FromGoGitCommit(c) 81 76 82 77 return &nd, nil
+136
knotserver/ingester.go
··· 7 7 "io" 8 8 "net/http" 9 9 "net/url" 10 + "path/filepath" 10 11 "strings" 11 12 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 16 17 securejoin "github.com/cyphar/filepath-securejoin" 17 18 "tangled.org/core/api/tangled" 18 19 "tangled.org/core/knotserver/db" 20 + "tangled.org/core/knotserver/git" 19 21 "tangled.org/core/log" 20 22 "tangled.org/core/rbac" 23 + "tangled.org/core/workflow" 21 24 ) 22 25 23 26 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 82 85 return nil 83 86 } 84 87 88 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 89 + raw := json.RawMessage(event.Commit.Record) 90 + did := event.Did 91 + 92 + var record tangled.RepoPull 93 + if err := json.Unmarshal(raw, &record); err != nil { 94 + return fmt.Errorf("failed to unmarshal record: %w", err) 95 + } 96 + 97 + l := log.FromContext(ctx) 98 + l = l.With("handler", "processPull") 99 + l = l.With("did", did) 100 + 101 + if record.Target == nil { 102 + return fmt.Errorf("ignoring pull record: target repo is nil") 103 + } 104 + 105 + l = l.With("target_repo", record.Target.Repo) 106 + l = l.With("target_branch", record.Target.Branch) 107 + 108 + if record.Source == nil { 109 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 110 + } 111 + 112 + if record.Source.Repo != nil { 113 + return fmt.Errorf("ignoring pull record: fork based pull") 114 + } 115 + 116 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 117 + if err != nil { 118 + return fmt.Errorf("failed to parse ATURI: %w", err) 119 + } 120 + 121 + // resolve this aturi to extract the repo record 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 + if err != nil || ident.Handle.IsInvalidHandle() { 124 + return fmt.Errorf("failed to resolve handle: %w", err) 125 + } 126 + 127 + xrpcc := xrpc.Client{ 128 + Host: ident.PDSEndpoint(), 129 + } 130 + 131 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 132 + if err != nil { 133 + return fmt.Errorf("failed to resolver repo: %w", err) 134 + } 135 + 136 + repo := resp.Value.Val.(*tangled.Repo) 137 + 138 + if repo.Knot != h.c.Server.Hostname { 139 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 140 + } 141 + 142 + didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 143 + if err != nil { 144 + return fmt.Errorf("failed to construct relative repo path: %w", err) 145 + } 146 + 147 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 148 + if err != nil { 149 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 150 + } 151 + 152 + gr, err := git.Open(repoPath, record.Source.Sha) 153 + if err != nil { 154 + return fmt.Errorf("failed to open git repository: %w", err) 155 + } 156 + 157 + workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 158 + if err != nil { 159 + return fmt.Errorf("failed to open workflow directory: %w", err) 160 + } 161 + 162 + var pipeline workflow.RawPipeline 163 + for _, e := range workflowDir { 164 + if !e.IsFile() { 165 + continue 166 + } 167 + 168 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 169 + contents, err := gr.RawContent(fpath) 170 + if err != nil { 171 + continue 172 + } 173 + 174 + pipeline = append(pipeline, workflow.RawWorkflow{ 175 + Name: e.Name, 176 + Contents: contents, 177 + }) 178 + } 179 + 180 + trigger := tangled.Pipeline_PullRequestTriggerData{ 181 + Action: "create", 182 + SourceBranch: record.Source.Branch, 183 + SourceSha: record.Source.Sha, 184 + TargetBranch: record.Target.Branch, 185 + } 186 + 187 + compiler := workflow.Compiler{ 188 + Trigger: tangled.Pipeline_TriggerMetadata{ 189 + Kind: string(workflow.TriggerKindPullRequest), 190 + PullRequest: &trigger, 191 + Repo: &tangled.Pipeline_TriggerRepo{ 192 + Did: ident.DID.String(), 193 + Knot: repo.Knot, 194 + Repo: repo.Name, 195 + }, 196 + }, 197 + } 198 + 199 + cp := compiler.Compile(compiler.Parse(pipeline)) 200 + eventJson, err := json.Marshal(cp) 201 + if err != nil { 202 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 203 + } 204 + 205 + // do not run empty pipelines 206 + if cp.Workflows == nil { 207 + return nil 208 + } 209 + 210 + ev := db.Event{ 211 + Rkey: TID(), 212 + Nsid: tangled.PipelineNSID, 213 + EventJson: string(eventJson), 214 + } 215 + 216 + return h.db.InsertEvent(ev, h.n) 217 + } 218 + 85 219 // duplicated from add collaborator 86 220 func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 87 221 raw := json.RawMessage(event.Commit.Record) ··· 204 338 err = h.processPublicKey(ctx, event) 205 339 case tangled.KnotMemberNSID: 206 340 err = h.processKnotMember(ctx, event) 341 + case tangled.RepoPullNSID: 342 + err = h.processPull(ctx, event) 207 343 case tangled.RepoCollaboratorNSID: 208 344 err = h.processCollaborator(ctx, event) 209 345 }
+109 -1
knotserver/internal.go
··· 23 23 "tangled.org/core/log" 24 24 "tangled.org/core/notifier" 25 25 "tangled.org/core/rbac" 26 + "tangled.org/core/workflow" 26 27 ) 27 28 28 29 type InternalHandle struct { ··· 175 176 } 176 177 177 178 for _, line := range lines { 178 - // TODO: pass pushOptions to refUpdate 179 179 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 180 180 if err != nil { 181 181 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 185 185 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 186 if err != nil { 187 187 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 + // non-fatal 189 + } 190 + 191 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 192 + if err != nil { 193 + l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 194 // non-fatal 189 195 } 190 196 } ··· 235 241 } 236 242 237 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 244 + } 245 + 246 + func (h *InternalHandle) triggerPipeline( 247 + clientMsgs *[]string, 248 + line git.PostReceiveLine, 249 + gitUserDid string, 250 + repoDid string, 251 + repoName string, 252 + pushOptions PushOptions, 253 + ) error { 254 + if pushOptions.skipCi { 255 + return nil 256 + } 257 + 258 + didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 259 + if err != nil { 260 + return err 261 + } 262 + 263 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 264 + if err != nil { 265 + return err 266 + } 267 + 268 + gr, err := git.Open(repoPath, line.Ref) 269 + if err != nil { 270 + return err 271 + } 272 + 273 + workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 274 + if err != nil { 275 + return err 276 + } 277 + 278 + var pipeline workflow.RawPipeline 279 + for _, e := range workflowDir { 280 + if !e.IsFile() { 281 + continue 282 + } 283 + 284 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 285 + contents, err := gr.RawContent(fpath) 286 + if err != nil { 287 + continue 288 + } 289 + 290 + pipeline = append(pipeline, workflow.RawWorkflow{ 291 + Name: e.Name, 292 + Contents: contents, 293 + }) 294 + } 295 + 296 + trigger := tangled.Pipeline_PushTriggerData{ 297 + Ref: line.Ref, 298 + OldSha: line.OldSha.String(), 299 + NewSha: line.NewSha.String(), 300 + } 301 + 302 + compiler := workflow.Compiler{ 303 + Trigger: tangled.Pipeline_TriggerMetadata{ 304 + Kind: string(workflow.TriggerKindPush), 305 + Push: &trigger, 306 + Repo: &tangled.Pipeline_TriggerRepo{ 307 + Did: repoDid, 308 + Knot: h.c.Server.Hostname, 309 + Repo: repoName, 310 + }, 311 + }, 312 + } 313 + 314 + cp := compiler.Compile(compiler.Parse(pipeline)) 315 + eventJson, err := json.Marshal(cp) 316 + if err != nil { 317 + return err 318 + } 319 + 320 + for _, e := range compiler.Diagnostics.Errors { 321 + *clientMsgs = append(*clientMsgs, e.String()) 322 + } 323 + 324 + if pushOptions.verboseCi { 325 + if compiler.Diagnostics.IsEmpty() { 326 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 327 + } 328 + 329 + for _, w := range compiler.Diagnostics.Warnings { 330 + *clientMsgs = append(*clientMsgs, w.String()) 331 + } 332 + } 333 + 334 + // do not run empty pipelines 335 + if cp.Workflows == nil { 336 + return nil 337 + } 338 + 339 + event := db.Event{ 340 + Rkey: TID(), 341 + Nsid: tangled.PipelineNSID, 342 + EventJson: string(eventJson), 343 + } 344 + 345 + return h.db.InsertEvent(event, h.n) 238 346 } 239 347 240 348 func (h *InternalHandle) emitCompareLink(
+1
knotserver/server.go
··· 79 79 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 80 80 tangled.PublicKeyNSID, 81 81 tangled.KnotMemberNSID, 82 + tangled.RepoPullNSID, 82 83 tangled.RepoCollaboratorNSID, 83 84 }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil {
+10 -2
lexicons/pulls/pull.json
··· 12 12 "required": [ 13 13 "target", 14 14 "title", 15 - "patch", 15 + "patchBlob", 16 16 "createdAt" 17 17 ], 18 18 "properties": { ··· 27 27 "type": "string" 28 28 }, 29 29 "patch": { 30 - "type": "string" 30 + "type": "string", 31 + "description": "(deprecated) use patchBlob instead" 32 + }, 33 + "patchBlob": { 34 + "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 + "description": "patch content" 31 39 }, 32 40 "source": { 33 41 "type": "ref",
-3
nix/gomod2nix.toml
··· 304 304 [mod."github.com/hashicorp/go-sockaddr"] 305 305 version = "v1.0.7" 306 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 - [mod."github.com/hashicorp/go-version"] 308 - version = "v1.8.0" 309 - hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 310 307 [mod."github.com/hashicorp/golang-lru"] 311 308 version = "v1.0.2" 312 309 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
+3
nix/modules/appview.nix
··· 1 1 { 2 + pkgs, 2 3 config, 3 4 lib, 4 5 ... ··· 259 260 after = ["redis-appview.service" "network-online.target"]; 260 261 requires = ["redis-appview.service"]; 261 262 wants = ["network-online.target"]; 263 + 264 + path = [pkgs.diffutils]; 262 265 263 266 serviceConfig = { 264 267 Type = "simple";
-64
nix/modules/bluesky-jetstream.nix
··· 1 - { 2 - config, 3 - pkgs, 4 - lib, 5 - ... 6 - }: let 7 - cfg = config.services.bluesky-jetstream; 8 - in 9 - with lib; { 10 - options.services.bluesky-jetstream = { 11 - enable = mkEnableOption "jetstream server"; 12 - package = mkPackageOption pkgs "bluesky-jetstream" {}; 13 - 14 - # dataDir = mkOption { 15 - # type = types.str; 16 - # default = "/var/lib/jetstream"; 17 - # description = "directory to store data (pebbleDB)"; 18 - # }; 19 - livenessTtl = mkOption { 20 - type = types.int; 21 - default = 15; 22 - description = "time to restart when no event detected (seconds)"; 23 - }; 24 - websocketUrl = mkOption { 25 - type = types.str; 26 - default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; 27 - description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; 28 - }; 29 - }; 30 - config = mkIf cfg.enable { 31 - systemd.services.bluesky-jetstream = { 32 - description = "bluesky jetstream"; 33 - after = ["network.target" "pds.service"]; 34 - wantedBy = ["multi-user.target"]; 35 - 36 - serviceConfig = { 37 - User = "jetstream"; 38 - Group = "jetstream"; 39 - StateDirectory = "jetstream"; 40 - StateDirectoryMode = "0755"; 41 - # preStart = '' 42 - # mkdir -p "${cfg.dataDir}" 43 - # chown -R jetstream:jetstream "${cfg.dataDir}" 44 - # ''; 45 - # WorkingDirectory = cfg.dataDir; 46 - Environment = [ 47 - "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" 48 - "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" 49 - "JETSTREAM_WS_URL=${cfg.websocketUrl}" 50 - ]; 51 - ExecStart = getExe cfg.package; 52 - Restart = "always"; 53 - RestartSec = 5; 54 - }; 55 - }; 56 - users = { 57 - users.jetstream = { 58 - group = "jetstream"; 59 - isSystemUser = true; 60 - }; 61 - groups.jetstream = {}; 62 - }; 63 - }; 64 - }
-48
nix/modules/bluesky-relay.nix
··· 1 - { 2 - config, 3 - pkgs, 4 - lib, 5 - ... 6 - }: let 7 - cfg = config.services.bluesky-relay; 8 - in 9 - with lib; { 10 - options.services.bluesky-relay = { 11 - enable = mkEnableOption "relay server"; 12 - package = mkPackageOption pkgs "bluesky-relay" {}; 13 - }; 14 - config = mkIf cfg.enable { 15 - systemd.services.bluesky-relay = { 16 - description = "bluesky relay"; 17 - after = ["network.target" "pds.service"]; 18 - wantedBy = ["multi-user.target"]; 19 - 20 - serviceConfig = { 21 - User = "relay"; 22 - Group = "relay"; 23 - StateDirectory = "relay"; 24 - StateDirectoryMode = "0755"; 25 - Environment = [ 26 - "RELAY_ADMIN_PASSWORD=password" 27 - "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" 28 - "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" 29 - "RELAY_IP_BIND=:2470" 30 - "RELAY_PERSIST_DIR=/var/lib/relay" 31 - "RELAY_DISABLE_REQUEST_CRAWL=0" 32 - "RELAY_INITIAL_SEQ_NUMBER=1" 33 - "RELAY_ALLOW_INSECURE_HOSTS=1" 34 - ]; 35 - ExecStart = "${getExe cfg.package} serve"; 36 - Restart = "always"; 37 - RestartSec = 5; 38 - }; 39 - }; 40 - users = { 41 - users.relay = { 42 - group = "relay"; 43 - isSystemUser = true; 44 - }; 45 - groups.relay = {}; 46 - }; 47 - }; 48 - }
-76
nix/modules/did-method-plc.nix
··· 1 - { 2 - config, 3 - pkgs, 4 - lib, 5 - ... 6 - }: let 7 - cfg = config.services.did-method-plc; 8 - in 9 - with lib; { 10 - options.services.did-method-plc = { 11 - enable = mkEnableOption "did-method-plc server"; 12 - package = mkPackageOption pkgs "did-method-plc" {}; 13 - }; 14 - config = mkIf cfg.enable { 15 - services.postgresql = { 16 - enable = true; 17 - package = pkgs.postgresql_14; 18 - ensureDatabases = ["plc"]; 19 - ensureUsers = [ 20 - { 21 - name = "pg"; 22 - # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; 23 - } 24 - ]; 25 - authentication = '' 26 - local all all trust 27 - host all all 127.0.0.1/32 trust 28 - ''; 29 - }; 30 - systemd.services.did-method-plc = { 31 - description = "did-method-plc"; 32 - 33 - after = ["postgresql.service"]; 34 - wants = ["postgresql.service"]; 35 - wantedBy = ["multi-user.target"]; 36 - 37 - environment = let 38 - db_creds_json = builtins.toJSON { 39 - username = "pg"; 40 - password = ""; 41 - host = "127.0.0.1"; 42 - port = 5432; 43 - }; 44 - in { 45 - # TODO: inherit from config 46 - DEBUG_MODE = "1"; 47 - LOG_ENABLED = "true"; 48 - LOG_LEVEL = "debug"; 49 - LOG_DESTINATION = "1"; 50 - ENABLE_MIGRATIONS = "true"; 51 - DB_CREDS_JSON = db_creds_json; 52 - DB_MIGRATE_CREDS_JSON = db_creds_json; 53 - PLC_VERSION = "0.0.1"; 54 - PORT = "8080"; 55 - }; 56 - 57 - serviceConfig = { 58 - ExecStart = getExe cfg.package; 59 - User = "plc"; 60 - Group = "plc"; 61 - StateDirectory = "plc"; 62 - StateDirectoryMode = "0755"; 63 - Restart = "always"; 64 - 65 - # Hardening 66 - }; 67 - }; 68 - users = { 69 - users.plc = { 70 - group = "plc"; 71 - isSystemUser = true; 72 - }; 73 - groups.plc = {}; 74 - }; 75 - }; 76 - }
+12 -46
nix/modules/spindle.nix
··· 1 1 { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 5 }: let ··· 18 17 type = types.package; 19 18 description = "Package to use for the spindle"; 20 19 }; 21 - tap-package = mkOption { 22 - type = types.package; 23 - description = "Package to use for the spindle"; 24 - }; 25 - 26 - atpRelayUrl = mkOption { 27 - type = types.str; 28 - default = "https://relay1.us-east.bsky.network"; 29 - description = "atproto relay"; 30 - }; 31 20 32 21 server = { 33 22 listenAddr = mkOption { ··· 36 25 description = "Address to listen on"; 37 26 }; 38 27 39 - stateDir = mkOption { 28 + dbPath = mkOption { 40 29 type = types.path; 41 - default = "/var/lib/spindle"; 42 - description = "Tangled spindle data directory"; 30 + default = "/var/lib/spindle/spindle.db"; 31 + description = "Path to the database file"; 43 32 }; 44 33 45 34 hostname = mkOption { ··· 52 41 type = types.str; 53 42 default = "https://plc.directory"; 54 43 description = "atproto PLC directory"; 44 + }; 45 + 46 + jetstreamEndpoint = mkOption { 47 + type = types.str; 48 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 49 + description = "Jetstream endpoint to subscribe to"; 55 50 }; 56 51 57 52 dev = mkOption { ··· 119 114 config = mkIf cfg.enable { 120 115 virtualisation.docker.enable = true; 121 116 122 - systemd.services.spindle-tap = { 123 - description = "spindle tap service"; 124 - after = ["network.target" "docker.service"]; 125 - wantedBy = ["multi-user.target"]; 126 - serviceConfig = { 127 - LogsDirectory = "spindle-tap"; 128 - StateDirectory = "spindle-tap"; 129 - Environment = [ 130 - "TAP_BIND=:2480" 131 - "TAP_PLC_URL=${cfg.server.plcUrl}" 132 - "TAP_RELAY_URL=${cfg.atpRelayUrl}" 133 - "TAP_DATABASE_URL=sqlite:///var/lib/spindle-tap/tap.db" 134 - "TAP_RETRY_TIMEOUT=3s" 135 - "TAP_COLLECTION_FILTERS=${concatStringsSep "," [ 136 - "sh.tangled.repo" 137 - "sh.tangled.repo.collaborator" 138 - "sh.tangled.spindle.member" 139 - "sh.tangled.repo.pull" 140 - ]}" 141 - # temporary hack to listen for repo.pull from non-tangled users 142 - "TAP_SIGNAL_COLLECTION=sh.tangled.repo.pull" 143 - ]; 144 - ExecStart = "${getExe cfg.tap-package} run"; 145 - }; 146 - }; 147 - 148 117 systemd.services.spindle = { 149 118 description = "spindle service"; 150 - after = ["network.target" "docker.service" "spindle-tap.service"]; 119 + after = ["network.target" "docker.service"]; 151 120 wantedBy = ["multi-user.target"]; 152 - path = [ 153 - pkgs.git 154 - ]; 155 121 serviceConfig = { 156 122 LogsDirectory = "spindle"; 157 123 StateDirectory = "spindle"; 158 124 Environment = [ 159 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 160 - "SPINDLE_SERVER_DATA_DIR=${cfg.server.stateDir}" 126 + "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 161 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 162 128 "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 163 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 164 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 165 132 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" ··· 167 134 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 168 135 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 169 136 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 170 - "SPINDLE_SERVER_TAP_URL=http://localhost:2480" 171 137 "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 172 138 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 173 139 ];
+6 -1
nix/pkgs/appview-static-files.nix
··· 8 8 actor-typeahead-src, 9 9 sqlite-lib, 10 10 tailwindcss, 11 + dolly, 11 12 src, 12 13 }: 13 14 runCommandLocal "appview-static-files" { ··· 17 18 (allow file-read* (subpath "/System/Library/OpenSSL")) 18 19 ''; 19 20 } '' 20 - mkdir -p $out/{fonts,icons} && cd $out 21 + mkdir -p $out/{fonts,icons,logos} && cd $out 21 22 cp -f ${htmx-src} htmx.min.js 22 23 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 23 24 cp -rf ${lucide-src}/*.svg icons/ ··· 26 27 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 27 28 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 29 cp -f ${actor-typeahead-src}/actor-typeahead.js . 30 + 31 + ${dolly}/bin/dolly -output logos/dolly.png -size 180x180 32 + ${dolly}/bin/dolly -output logos/dolly.ico -size 48x48 33 + ${dolly}/bin/dolly -output logos/dolly.svg -color currentColor 29 34 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 30 35 # for whatever reason (produces broken css), so we are doing this instead 31 36 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
-20
nix/pkgs/bluesky-jetstream.nix
··· 1 - { 2 - buildGoModule, 3 - fetchFromGitHub, 4 - }: 5 - buildGoModule { 6 - pname = "bluesky-jetstream"; 7 - version = "0.1.0"; 8 - src = fetchFromGitHub { 9 - owner = "bluesky-social"; 10 - repo = "jetstream"; 11 - rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; 12 - sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; 13 - }; 14 - subPackages = ["cmd/jetstream"]; 15 - vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; 16 - doCheck = false; 17 - meta = { 18 - mainProgram = "jetstream"; 19 - }; 20 - }
-20
nix/pkgs/bluesky-relay.nix
··· 1 - { 2 - buildGoModule, 3 - fetchFromGitHub, 4 - }: 5 - buildGoModule { 6 - pname = "bluesky-relay"; 7 - version = "0.1.0"; 8 - src = fetchFromGitHub { 9 - owner = "boltlessengineer"; 10 - repo = "indigo"; 11 - rev = "7fe70a304d795b998f354d2b7b2050b909709c99"; 12 - sha256 = "sha256-+h34x67cqH5t30+8rua53/ucvbn3BanrmH0Og3moHok="; 13 - }; 14 - subPackages = ["cmd/relay"]; 15 - vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 - doCheck = false; 17 - meta = { 18 - mainProgram = "relay"; 19 - }; 20 - }
-65
nix/pkgs/did-method-plc.nix
··· 1 - # inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix 2 - { 3 - lib, 4 - stdenv, 5 - fetchFromGitHub, 6 - fetchYarnDeps, 7 - yarnConfigHook, 8 - yarnBuildHook, 9 - nodejs, 10 - makeBinaryWrapper, 11 - }: 12 - stdenv.mkDerivation (finalAttrs: { 13 - pname = "did-method-plc"; 14 - version = "0.0.1"; 15 - 16 - src = fetchFromGitHub { 17 - owner = "did-method-plc"; 18 - repo = "did-method-plc"; 19 - rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; 20 - sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; 21 - }; 22 - postPatch = '' 23 - # remove dd-trace dependency 24 - sed -i '3d' packages/server/service/index.js 25 - ''; 26 - 27 - yarnOfflineCache = fetchYarnDeps { 28 - yarnLock = finalAttrs.src + "/yarn.lock"; 29 - hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; 30 - }; 31 - 32 - nativeBuildInputs = [ 33 - yarnConfigHook 34 - yarnBuildHook 35 - nodejs 36 - makeBinaryWrapper 37 - ]; 38 - yarnBuildScript = "lerna"; 39 - yarnBuildFlags = [ 40 - "run" 41 - "build" 42 - "--scope" 43 - "@did-plc/server" 44 - "--include-dependencies" 45 - ]; 46 - 47 - installPhase = '' 48 - runHook preInstall 49 - 50 - mkdir -p $out/lib/node_modules/ 51 - mv packages/ $out/lib/packages/ 52 - mv node_modules/* $out/lib/node_modules/ 53 - 54 - makeWrapper ${lib.getExe nodejs} $out/bin/plc \ 55 - --add-flags $out/lib/packages/server/service/index.js \ 56 - --add-flags --enable-source-maps \ 57 - --set NODE_PATH $out/lib/node_modules 58 - 59 - runHook postInstall 60 - ''; 61 - 62 - meta = { 63 - mainProgram = "plc"; 64 - }; 65 - })
+22 -1
nix/pkgs/docs.nix
··· 5 5 inter-fonts-src, 6 6 ibm-plex-mono-src, 7 7 lucide-src, 8 + dolly, 8 9 src, 9 10 }: 10 11 runCommandLocal "docs" {} '' ··· 18 19 # icons 19 20 cp -rf ${lucide-src}/*.svg working/ 20 21 21 - # content 22 + # logo 23 + ${dolly}/bin/dolly -output working/dolly.svg -color currentColor 24 + 25 + # content - chunked 22 26 ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 27 -o $out/ \ 24 28 -t chunkedhtml \ 25 29 --variable toc \ 30 + --variable-json single-page=false \ 26 31 --toc-depth=2 \ 27 32 --css=stylesheet.css \ 28 33 --chunk-template="%i.html" \ 29 34 --highlight-style=working/highlight.theme \ 30 35 --template=working/template.html 31 36 37 + # content - single page 38 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 39 + -o $out/single-page.html \ 40 + --toc \ 41 + --variable toc \ 42 + --variable single-page \ 43 + --toc-depth=2 \ 44 + --css=stylesheet.css \ 45 + --highlight-style=working/highlight.theme \ 46 + --template=working/template.html 47 + 32 48 # fonts 33 49 mkdir -p $out/static/fonts 34 50 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/ 35 51 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/ 36 52 cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/ 37 53 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/ 54 + 55 + # favicons 56 + ${dolly}/bin/dolly -output $out/static/logos/dolly.png -size 180x180 57 + ${dolly}/bin/dolly -output $out/static/logos/dolly.ico -size 48x48 58 + ${dolly}/bin/dolly -output $out/static/logos/dolly.svg -color currentColor 38 59 39 60 # styles 40 61 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css
+21
nix/pkgs/dolly.nix
··· 1 + { 2 + buildGoApplication, 3 + modules, 4 + src, 5 + }: 6 + buildGoApplication { 7 + pname = "dolly"; 8 + version = "0.1.0"; 9 + inherit src modules; 10 + 11 + # patch the static dir 12 + postUnpack = '' 13 + pushd source 14 + mkdir -p appview/pages/static 15 + touch appview/pages/static/x 16 + popd 17 + ''; 18 + 19 + doCheck = false; 20 + subPackages = ["cmd/dolly"]; 21 + }
-20
nix/pkgs/tap.nix
··· 1 - { 2 - buildGoModule, 3 - fetchFromGitHub, 4 - }: 5 - buildGoModule { 6 - pname = "tap"; 7 - version = "0.1.0"; 8 - src = fetchFromGitHub { 9 - owner = "bluesky-social"; 10 - repo = "indigo"; 11 - rev = "498ecb9693e8ae050f73234c86f340f51ad896a9"; 12 - sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k="; 13 - }; 14 - subPackages = ["cmd/tap"]; 15 - vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 - doCheck = false; 17 - meta = { 18 - mainProgram = "tap"; 19 - }; 20 - }
+2 -8
nix/vm.nix
··· 19 19 20 20 plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 21 jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 - relayUrl = envVarOr "TANGLED_VM_RELAY_URL" "https://relay1.us-east.bsky.network"; 23 22 in 24 23 nixpkgs.lib.nixosSystem { 25 24 inherit system; ··· 58 57 host.port = 6555; 59 58 guest.port = 6555; 60 59 } 61 - { 62 - from = "host"; 63 - host.port = 6556; 64 - guest.port = 2480; 65 - } 66 60 ]; 67 61 sharedDirectories = { 68 62 # We can't use the 9p mounts directly for most of these ··· 101 95 }; 102 96 services.tangled.spindle = { 103 97 enable = true; 104 - atpRelayUrl = relayUrl; 105 98 server = { 106 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 107 100 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 108 101 plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 109 103 listenAddr = "0.0.0.0:6555"; 110 104 dev = true; 111 105 queueSize = 100; ··· 140 134 }; 141 135 in { 142 136 knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 143 - spindle = mkDataSyncScripts "/mnt/spindle-data" config.services.tangled.spindle.server.stateDir; 137 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 144 138 }; 145 139 }) 146 140 ];
-10
orm/orm.go
··· 20 20 } 21 21 defer tx.Rollback() 22 22 23 - _, err = tx.Exec(` 24 - create table if not exists migrations ( 25 - id integer primary key autoincrement, 26 - name text unique 27 - ); 28 - `) 29 - if err != nil { 30 - return fmt.Errorf("creating migrations table: %w", err) 31 - } 32 - 33 23 var exists bool 34 24 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 35 25 if err != nil {
+66 -10
patchutil/interdiff.go
··· 5 5 "strings" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/appview/filetree" 8 9 "tangled.org/core/types" 9 10 ) 10 11 ··· 12 13 Files []*InterdiffFile 13 14 } 14 15 15 - func (i *InterdiffResult) AffectedFiles() []string { 16 - files := make([]string, len(i.Files)) 17 - for _, f := range i.Files { 18 - files = append(files, f.Name) 16 + func (i *InterdiffResult) Stats() types.DiffStat { 17 + var ins, del int64 18 + for _, s := range i.ChangedFiles() { 19 + stat := s.Stats() 20 + ins += stat.Insertions 21 + del += stat.Deletions 22 + } 23 + return types.DiffStat{ 24 + Insertions: ins, 25 + Deletions: del, 26 + FilesChanged: len(i.Files), 19 27 } 20 - return files 28 + } 29 + 30 + func (i *InterdiffResult) ChangedFiles() []types.DiffFileRenderer { 31 + drs := make([]types.DiffFileRenderer, len(i.Files)) 32 + for i, s := range i.Files { 33 + drs[i] = s 34 + } 35 + return drs 36 + } 37 + 38 + func (i *InterdiffResult) FileTree() *filetree.FileTreeNode { 39 + fs := make([]string, len(i.Files)) 40 + for i, s := range i.Files { 41 + fs[i] = s.Name 42 + } 43 + return filetree.FileTree(fs) 21 44 } 22 45 23 46 func (i *InterdiffResult) String() string { ··· 36 59 Status InterdiffFileStatus 37 60 } 38 61 39 - func (s *InterdiffFile) Split() *types.SplitDiff { 62 + func (s *InterdiffFile) Id() string { 63 + return s.Name 64 + } 65 + 66 + func (s *InterdiffFile) Split() types.SplitDiff { 40 67 fragments := make([]types.SplitFragment, len(s.TextFragments)) 41 68 42 69 for i, fragment := range s.TextFragments { ··· 49 76 } 50 77 } 51 78 52 - return &types.SplitDiff{ 79 + return types.SplitDiff{ 53 80 Name: s.Id(), 54 81 TextFragments: fragments, 55 82 } 56 83 } 57 84 58 - // used by html elements as a unique ID for hrefs 59 - func (s *InterdiffFile) Id() string { 60 - return s.Name 85 + func (s *InterdiffFile) CanRender() string { 86 + if s.Status.IsUnchanged() { 87 + return "This file has not been changed." 88 + } else if s.Status.IsRebased() { 89 + return "This patch was likely rebased, as context lines do not match." 90 + } else if s.Status.IsError() { 91 + return "Failed to calculate interdiff for this file." 92 + } else { 93 + return "" 94 + } 95 + } 96 + 97 + func (s *InterdiffFile) Names() types.DiffFileName { 98 + var n types.DiffFileName 99 + n.New = s.Name 100 + return n 101 + } 102 + 103 + func (s *InterdiffFile) Stats() types.DiffFileStat { 104 + var ins, del int64 105 + 106 + if s.File != nil { 107 + for _, f := range s.TextFragments { 108 + ins += f.LinesAdded 109 + del += f.LinesDeleted 110 + } 111 + } 112 + 113 + return types.DiffFileStat{ 114 + Insertions: ins, 115 + Deletions: del, 116 + } 61 117 } 62 118 63 119 func (s *InterdiffFile) String() string {
+9
patchutil/patchutil_test.go
··· 4 4 "errors" 5 5 "reflect" 6 6 "testing" 7 + 8 + "tangled.org/core/types" 7 9 ) 8 10 9 11 func TestIsPatchValid(t *testing.T) { ··· 323 325 }) 324 326 } 325 327 } 328 + 329 + func TestImplsInterfaces(t *testing.T) { 330 + id := &InterdiffResult{} 331 + _ = isDiffsRenderer(id) 332 + } 333 + 334 + func isDiffsRenderer[S types.DiffRenderer](S) bool { return true }
-52
rbac2/bytesadapter/adapter.go
··· 1 - package bytesadapter 2 - 3 - import ( 4 - "bufio" 5 - "bytes" 6 - "errors" 7 - "strings" 8 - 9 - "github.com/casbin/casbin/v2/model" 10 - "github.com/casbin/casbin/v2/persist" 11 - ) 12 - 13 - var ( 14 - errNotImplemented = errors.New("not implemented") 15 - ) 16 - 17 - type Adapter struct { 18 - b []byte 19 - } 20 - 21 - var _ persist.Adapter = &Adapter{} 22 - 23 - func NewAdapter(b []byte) *Adapter { 24 - return &Adapter{b} 25 - } 26 - 27 - func (a *Adapter) LoadPolicy(model model.Model) error { 28 - scanner := bufio.NewScanner(bytes.NewReader(a.b)) 29 - for scanner.Scan() { 30 - line := strings.TrimSpace(scanner.Text()) 31 - if err := persist.LoadPolicyLine(line, model); err != nil { 32 - return err 33 - } 34 - } 35 - return scanner.Err() 36 - } 37 - 38 - func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { 39 - return errNotImplemented 40 - } 41 - 42 - func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { 43 - return errNotImplemented 44 - } 45 - 46 - func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { 47 - return errNotImplemented 48 - } 49 - 50 - func (a *Adapter) SavePolicy(model model.Model) error { 51 - return errNotImplemented 52 - }
-139
rbac2/rbac2.go
··· 1 - package rbac2 2 - 3 - import ( 4 - "database/sql" 5 - _ "embed" 6 - "fmt" 7 - 8 - adapter "github.com/Blank-Xu/sql-adapter" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/casbin/casbin/v2" 11 - "github.com/casbin/casbin/v2/model" 12 - "github.com/casbin/casbin/v2/util" 13 - "tangled.org/core/rbac2/bytesadapter" 14 - ) 15 - 16 - const ( 17 - Model = ` 18 - [request_definition] 19 - r = sub, dom, obj, act 20 - 21 - [policy_definition] 22 - p = sub, dom, obj, act 23 - 24 - [role_definition] 25 - g = _, _, _ 26 - 27 - [policy_effect] 28 - e = some(where (p.eft == allow)) 29 - 30 - [matchers] 31 - m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 32 - ` 33 - ) 34 - 35 - type Enforcer struct { 36 - e *casbin.Enforcer 37 - } 38 - 39 - //go:embed tangled_policy.csv 40 - var tangledPolicy []byte 41 - 42 - func NewEnforcer(path string) (*Enforcer, error) { 43 - db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 44 - if err != nil { 45 - return nil, err 46 - } 47 - return NewEnforcerWithDB(db) 48 - } 49 - 50 - func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) { 51 - m, err := model.NewModelFromString(Model) 52 - if err != nil { 53 - return nil, err 54 - } 55 - 56 - a, err := adapter.NewAdapter(db, "sqlite3", "acl") 57 - if err != nil { 58 - return nil, err 59 - } 60 - 61 - // // PATCH: create unique index to make `AddPoliciesEx` work 62 - // _, err = db.Exec(fmt.Sprintf( 63 - // `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`, 64 - // tableName, 65 - // )) 66 - // if err != nil { 67 - // return nil, err 68 - // } 69 - 70 - e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error 71 - // e.EnableLog(true) 72 - 73 - // NOTE: casbin clears the model on init, so we should intialize with temporary adapter first 74 - // and then override the adapter to sql-adapter. 75 - // `e.SetModel(m)` after init doesn't work for some reason 76 - if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil { 77 - return nil, err 78 - } 79 - 80 - // load dynamic policy from db 81 - e.EnableAutoSave(false) 82 - if err := a.LoadPolicy(e.GetModel()); err != nil { 83 - return nil, err 84 - } 85 - e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 86 - e.BuildRoleLinks() 87 - e.SetAdapter(a) 88 - e.EnableAutoSave(true) 89 - 90 - return &Enforcer{e}, nil 91 - } 92 - 93 - // CaptureModel returns copy of current model. Used for testing 94 - func (e *Enforcer) CaptureModel() model.Model { 95 - return e.e.GetModel().Copy() 96 - } 97 - 98 - func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 99 - roles, err := e.e.GetImplicitRolesForUser(name, domain...) 100 - if err != nil { 101 - return false, err 102 - } 103 - for _, r := range roles { 104 - if r == role { 105 - return true, nil 106 - } 107 - } 108 - return false, nil 109 - } 110 - 111 - // setRoleForUser sets single user role for specified domain. 112 - // All existing users with that role will be removed. 113 - func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 114 - currentUsers, err := e.e.GetUsersForRole(role, domain...) 115 - if err != nil { 116 - return err 117 - } 118 - 119 - for _, oldUser := range currentUsers { 120 - _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 121 - if err != nil { 122 - return err 123 - } 124 - } 125 - 126 - _, err = e.e.AddRoleForUser(name, role, domain...) 127 - return err 128 - } 129 - 130 - // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 131 - func validateAtUri(uri syntax.ATURI, expected string) error { 132 - if !uri.Authority().IsDID() { 133 - return fmt.Errorf("expected at-uri with did") 134 - } 135 - if expected != "" && uri.Collection().String() != expected { 136 - return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 137 - } 138 - return nil 139 - }
-150
rbac2/rbac2_test.go
··· 1 - package rbac2_test 2 - 3 - import ( 4 - "database/sql" 5 - "testing" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - _ "github.com/mattn/go-sqlite3" 9 - "github.com/stretchr/testify/assert" 10 - "tangled.org/core/rbac2" 11 - ) 12 - 13 - func setup(t *testing.T) *rbac2.Enforcer { 14 - enforcer, err := rbac2.NewEnforcer(":memory:") 15 - assert.NoError(t, err) 16 - 17 - return enforcer 18 - } 19 - 20 - func TestNewEnforcer(t *testing.T) { 21 - db, err := sql.Open("sqlite3", "/tmp/test/test.db?_foreign_keys=1") 22 - assert.NoError(t, err) 23 - 24 - enforcer1, err := rbac2.NewEnforcerWithDB(db) 25 - assert.NoError(t, err) 26 - enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")) 27 - model1 := enforcer1.CaptureModel() 28 - 29 - enforcer2, err := rbac2.NewEnforcerWithDB(db) 30 - assert.NoError(t, err) 31 - model2 := enforcer2.CaptureModel() 32 - 33 - // model1.GetLogger().EnableLog(true) 34 - // model1.PrintModel() 35 - // model1.PrintPolicy() 36 - // model1.GetLogger().EnableLog(false) 37 - 38 - model2.GetLogger().EnableLog(true) 39 - model2.PrintModel() 40 - model2.PrintPolicy() 41 - model2.GetLogger().EnableLog(false) 42 - 43 - assert.Equal(t, model1, model2) 44 - } 45 - 46 - func TestRepoOwnerPermissions(t *testing.T) { 47 - var ( 48 - e = setup(t) 49 - ok bool 50 - err error 51 - fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 52 - fooUser = syntax.DID("did:plc:foo") 53 - ) 54 - 55 - assert.NoError(t, e.AddRepo(fooRepo)) 56 - 57 - ok, err = e.IsRepoOwner(fooUser, fooRepo) 58 - assert.NoError(t, err) 59 - assert.True(t, ok, "repo author should be repo owner") 60 - 61 - ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 62 - assert.NoError(t, err) 63 - assert.True(t, ok, "repo owner should be able to modify the repo itself") 64 - 65 - ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 66 - assert.NoError(t, err) 67 - assert.True(t, ok, "repo owner should inherit role role:collaborator") 68 - 69 - ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 70 - assert.NoError(t, err) 71 - assert.True(t, ok, "repo owner should inherit collaborator permissions") 72 - } 73 - 74 - func TestRepoCollaboratorPermissions(t *testing.T) { 75 - var ( 76 - e = setup(t) 77 - ok bool 78 - err error 79 - fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 80 - barUser = syntax.DID("did:plc:bar") 81 - ) 82 - 83 - assert.NoError(t, e.AddRepo(fooRepo)) 84 - assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 85 - 86 - ok, err = e.IsRepoCollaborator(barUser, fooRepo) 87 - assert.NoError(t, err) 88 - assert.True(t, ok, "should set repo collaborator") 89 - 90 - ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 91 - assert.NoError(t, err) 92 - assert.True(t, ok, "repo collaborator should be able to edit repo settings") 93 - 94 - ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 95 - assert.NoError(t, err) 96 - assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 97 - } 98 - 99 - func TestGetByRole(t *testing.T) { 100 - var ( 101 - e = setup(t) 102 - err error 103 - fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 104 - owner = syntax.DID("did:plc:foo") 105 - collaborator1 = syntax.DID("did:plc:bar") 106 - collaborator2 = syntax.DID("did:plc:baz") 107 - ) 108 - 109 - assert.NoError(t, e.AddRepo(fooRepo)) 110 - assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 111 - assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 112 - 113 - collaborators, err := e.GetRepoCollaborators(fooRepo) 114 - assert.NoError(t, err) 115 - assert.ElementsMatch(t, []syntax.DID{ 116 - owner, 117 - collaborator1, 118 - collaborator2, 119 - }, collaborators) 120 - } 121 - 122 - func TestSpindleOwnerPermissions(t *testing.T) { 123 - var ( 124 - e = setup(t) 125 - ok bool 126 - err error 127 - spindle = syntax.DID("did:web:spindle.example.com") 128 - owner = syntax.DID("did:plc:foo") 129 - member = syntax.DID("did:plc:bar") 130 - ) 131 - 132 - assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 133 - assert.NoError(t, e.AddSpindleMember(member, spindle)) 134 - 135 - ok, err = e.IsSpindleMember(owner, spindle) 136 - assert.NoError(t, err) 137 - assert.True(t, ok, "spindle owner is spindle member") 138 - 139 - ok, err = e.IsSpindleMember(member, spindle) 140 - assert.NoError(t, err) 141 - assert.True(t, ok, "spindle member is spindle member") 142 - 143 - ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 144 - assert.NoError(t, err) 145 - assert.True(t, ok, "spindle owner can invite members") 146 - 147 - ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 148 - assert.NoError(t, err) 149 - assert.False(t, ok, "spindle member cannot invite members") 150 - }
-91
rbac2/repo.go
··· 1 - package rbac2 2 - 3 - import ( 4 - "slices" 5 - "strings" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - "tangled.org/core/api/tangled" 9 - ) 10 - 11 - // AddRepo adds new repo with its owner to rbac enforcer 12 - func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 - return err 15 - } 16 - user := repo.Authority() 17 - 18 - return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 - } 20 - 21 - // DeleteRepo deletes all policies related to the repo 22 - func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 - return err 25 - } 26 - 27 - _, err := e.e.DeleteDomains(repo.String()) 28 - return err 29 - } 30 - 31 - // AddRepoCollaborator adds new collaborator to the repo 32 - func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 - return err 35 - } 36 - 37 - _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 - return err 39 - } 40 - 41 - // RemoveRepoCollaborator removes the collaborator from the repo. 42 - // This won't remove inherited roles like repository owner. 43 - func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 - if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 - return err 46 - } 47 - 48 - _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 - return err 50 - } 51 - 52 - func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 - var collaborators []syntax.DID 54 - members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 - if err != nil { 56 - return nil, err 57 - } 58 - for _, m := range members { 59 - if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 - continue 61 - } 62 - collaborators = append(collaborators, syntax.DID(m)) 63 - } 64 - 65 - slices.Sort(collaborators) 66 - return slices.Compact(collaborators), nil 67 - } 68 - 69 - func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 - return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 - } 72 - 73 - func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 - return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 - } 76 - 77 - func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 - return e.e.Enforce(user.String(), repo.String(), "/", "write") 79 - } 80 - 81 - func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 - return e.e.Enforce(user.String(), repo.String(), "/settings", "write") 83 - } 84 - 85 - func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 - return e.e.Enforce(user.String(), repo.String(), "/collaborator", "write") 87 - } 88 - 89 - func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 - return e.e.Enforce(user.String(), repo.String(), "/git", "write") 91 - }
-29
rbac2/spindle.go
··· 1 - package rbac2 2 - 3 - import "github.com/bluesky-social/indigo/atproto/syntax" 4 - 5 - func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 - return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 - } 8 - 9 - func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 - return e.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 11 - } 12 - 13 - func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 - _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 - return err 16 - } 17 - 18 - func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 - _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 - return err 21 - } 22 - 23 - func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 - return e.e.Enforce(user.String(), intoSpindle(spindle), "/member", "write") 25 - } 26 - 27 - func intoSpindle(did syntax.DID) string { 28 - return "/spindle/" + did.String() 29 - }
-19
rbac2/tangled_policy.csv
··· 1 - #, policies 2 - #, sub, dom, obj, act 3 - p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write 4 - p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write 5 - p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write 6 - p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write 7 - 8 - p, server:owner, /knot/{did}, /member, write 9 - p, server:member, /knot/{did}, /git, write 10 - 11 - p, server:owner, /spindle/{did}, /member, write 12 - 13 - 14 - #, group policies 15 - #, sub, role, dom 16 - g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey} 17 - 18 - g, server:owner, server:member, /knot/{did} 19 - g, server:owner, server:member, /spindle/{did}
+11 -20
spindle/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "path/filepath" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/sethvargo/go-envconfig" 10 9 ) 11 10 12 11 type Server struct { 13 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 - Hostname string `env:"HOSTNAME, required"` 15 - TapUrl string `env:"TAP_URL, required"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 - Dev bool `env:"DEV, default=false"` 18 - Owner syntax.DID `env:"OWNER, required"` 19 - Secrets Secrets `env:",prefix=SECRETS_"` 20 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 - DataDir string `env:"DATA_DIR, default=/var/lib/spindle"` 22 - QueueSize int `env:"QUEUE_SIZE, default=100"` 23 - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 + DBPath string `env:"DB_PATH, default=spindle.db"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 + Dev bool `env:"DEV, default=false"` 18 + Owner string `env:"OWNER, required"` 19 + Secrets Secrets `env:",prefix=SECRETS_"` 20 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 + QueueSize int `env:"QUEUE_SIZE, default=100"` 22 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 24 23 } 25 24 26 25 func (s Server) Did() syntax.DID { 27 26 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 28 - } 29 - 30 - func (s Server) RepoDir() string { 31 - return filepath.Join(s.DataDir, "repos") 32 - } 33 - 34 - func (s Server) DBPath() string { 35 - return filepath.Join(s.DataDir, "spindle.db") 36 27 } 37 28 38 29 type Secrets struct {
+18 -73
spindle/db/db.go
··· 1 1 package db 2 2 3 3 import ( 4 - "context" 5 4 "database/sql" 6 5 "strings" 7 6 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 7 _ "github.com/mattn/go-sqlite3" 10 - "tangled.org/core/log" 11 - "tangled.org/core/orm" 12 8 ) 13 9 14 10 type DB struct { 15 11 *sql.DB 16 12 } 17 13 18 - func Make(ctx context.Context, dbPath string) (*DB, error) { 14 + func Make(dbPath string) (*DB, error) { 19 15 // https://github.com/mattn/go-sqlite3#connection-string 20 16 opts := []string{ 21 17 "_foreign_keys=1", ··· 24 20 "_auto_vacuum=incremental", 25 21 } 26 22 27 - logger := log.FromContext(ctx) 28 - logger = log.SubLogger(logger, "db") 29 - 30 23 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 24 if err != nil { 32 25 return nil, err 33 26 } 34 27 35 - conn, err := db.Conn(ctx) 36 - if err != nil { 37 - return nil, err 38 - } 39 - defer conn.Close() 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 40 31 41 32 _, err = db.Exec(` 42 33 create table if not exists _jetstream ( ··· 58 49 unique(owner, name) 59 50 ); 60 51 61 - create table if not exists repo_collaborators ( 62 - -- identifiers 63 - id integer primary key autoincrement, 64 - did text not null, 65 - rkey text not null, 66 - at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.collaborator' || '/' || rkey) stored, 67 - 68 - repo text not null, 69 - subject text not null, 70 - 71 - addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 72 - unique(did, rkey) 73 - ); 74 - 75 52 create table if not exists spindle_members ( 76 53 -- identifiers for the record 77 54 id integer primary key autoincrement, ··· 99 76 return nil, err 100 77 } 101 78 102 - // run migrations 79 + return &DB{db}, nil 80 + } 103 81 104 - // NOTE: this won't migrate existing records 105 - // they will be fetched again with tap instead 106 - orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error { 107 - // archive legacy repos (just in case) 108 - _, err = tx.Exec(`alter table repos rename to repos_old`) 109 - if err != nil { 110 - return err 111 - } 112 - 113 - _, err := tx.Exec(` 114 - create table repos ( 115 - -- identifiers 116 - id integer primary key autoincrement, 117 - did text not null, 118 - rkey text not null, 119 - at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored, 120 - 121 - name text not null, 122 - knot text not null, 123 - 124 - addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 125 - unique(did, rkey) 126 - ); 127 - `) 128 - if err != nil { 129 - return err 130 - } 131 - 132 - return nil 133 - }) 134 - 135 - return &DB{db}, nil 82 + func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 83 + _, err := d.Exec(` 84 + insert into _jetstream (id, last_time_us) 85 + values (1, ?) 86 + on conflict(id) do update set last_time_us = excluded.last_time_us 87 + `, lastTimeUs) 88 + return err 136 89 } 137 90 138 - func (d *DB) IsKnownDid(did syntax.DID) (bool, error) { 139 - // is spindle member / repo collaborator 140 - var exists bool 141 - err := d.QueryRow( 142 - `select exists ( 143 - select 1 from repo_collaborators where subject = ? 144 - union all 145 - select 1 from spindle_members where did = ? 146 - )`, 147 - did, 148 - did, 149 - ).Scan(&exists) 150 - return exists, err 91 + func (d *DB) GetLastTimeUs() (int64, error) { 92 + var lastTimeUs int64 93 + row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) 94 + err := row.Scan(&lastTimeUs) 95 + return lastTimeUs, err 151 96 }
-14
spindle/db/events.go
··· 70 70 return evts, nil 71 71 } 72 72 73 - func (d *DB) CreatePipelineEvent(rkey string, pipeline tangled.Pipeline, n *notifier.Notifier) error { 74 - eventJson, err := json.Marshal(pipeline) 75 - if err != nil { 76 - return err 77 - } 78 - event := Event{ 79 - Rkey: rkey, 80 - Nsid: tangled.PipelineNSID, 81 - Created: time.Now().UnixNano(), 82 - EventJson: string(eventJson), 83 - } 84 - return d.insertEvent(event, n) 85 - } 86 - 87 73 func (d *DB) createStatusEvent( 88 74 workflowId models.WorkflowId, 89 75 statusKind models.StatusKind,
+44
spindle/db/known_dids.go
··· 1 + package db 2 + 3 + func (d *DB) AddDid(did string) error { 4 + _, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did) 5 + return err 6 + } 7 + 8 + func (d *DB) RemoveDid(did string) error { 9 + _, err := d.Exec(`delete from known_dids where did = ?`, did) 10 + return err 11 + } 12 + 13 + func (d *DB) GetAllDids() ([]string, error) { 14 + var dids []string 15 + 16 + rows, err := d.Query(`select did from known_dids`) 17 + if err != nil { 18 + return nil, err 19 + } 20 + defer rows.Close() 21 + 22 + for rows.Next() { 23 + var did string 24 + if err := rows.Scan(&did); err != nil { 25 + return nil, err 26 + } 27 + dids = append(dids, did) 28 + } 29 + 30 + if err := rows.Err(); err != nil { 31 + return nil, err 32 + } 33 + 34 + return dids, nil 35 + } 36 + 37 + func (d *DB) HasKnownDids() bool { 38 + var count int 39 + err := d.QueryRow(`select count(*) from known_dids`).Scan(&count) 40 + if err != nil { 41 + return false 42 + } 43 + return count > 0 44 + }
+11 -119
spindle/db/repos.go
··· 1 1 package db 2 2 3 - import "github.com/bluesky-social/indigo/atproto/syntax" 4 - 5 3 type Repo struct { 6 - Did syntax.DID 7 - Rkey syntax.RecordKey 8 - Name string 9 - Knot string 4 + Knot string 5 + Owner string 6 + Name string 10 7 } 11 8 12 - type RepoCollaborator struct { 13 - Did syntax.DID 14 - Rkey syntax.RecordKey 15 - Repo syntax.ATURI 16 - Subject syntax.DID 17 - } 18 - 19 - func (d *DB) PutRepo(repo *Repo) error { 20 - _, err := d.Exec( 21 - `insert or ignore into repos (did, rkey, name, knot) 22 - values (?, ?, ?, ?) 23 - on conflict(did, rkey) do update set 24 - name = excluded.name, 25 - knot = excluded.knot`, 26 - repo.Did, 27 - repo.Rkey, 28 - repo.Name, 29 - repo.Knot, 30 - ) 31 - return err 32 - } 33 - 34 - func (d *DB) DeleteRepo(did syntax.DID, rkey syntax.RecordKey) error { 35 - _, err := d.Exec( 36 - `delete from repos where did = ? and rkey = ?`, 37 - did, 38 - rkey, 39 - ) 9 + func (d *DB) AddRepo(knot, owner, name string) error { 10 + _, err := d.Exec(`insert or ignore into repos (knot, owner, name) values (?, ?, ?)`, knot, owner, name) 40 11 return err 41 12 } 42 13 ··· 63 34 return knots, nil 64 35 } 65 36 66 - func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) { 37 + func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) { 67 38 var repo Repo 68 - err := d.DB.QueryRow( 69 - `select 70 - did, 71 - rkey, 72 - name, 73 - knot 74 - from repos where at_uri = ?`, 75 - repoAt, 76 - ).Scan( 77 - &repo.Did, 78 - &repo.Rkey, 79 - &repo.Name, 80 - &repo.Knot, 81 - ) 82 - if err != nil { 83 - return nil, err 84 - } 85 - return &repo, nil 86 - } 87 39 88 - func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) { 89 - var repo Repo 90 - err := d.DB.QueryRow( 91 - `select 92 - did, 93 - rkey, 94 - name, 95 - knot 96 - from repos where did = ? and name = ?`, 97 - did, 98 - name, 99 - ).Scan( 100 - &repo.Did, 101 - &repo.Rkey, 102 - &repo.Name, 103 - &repo.Knot, 104 - ) 40 + query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?" 41 + err := d.DB.QueryRow(query, knot, owner, name). 42 + Scan(&repo.Knot, &repo.Owner, &repo.Name) 43 + 105 44 if err != nil { 106 45 return nil, err 107 46 } 47 + 108 48 return &repo, nil 109 49 } 110 - 111 - func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error { 112 - _, err := d.Exec( 113 - `insert into repo_collaborators (did, rkey, repo, subject) 114 - values (?, ?, ?, ?) 115 - on conflict(did, rkey) do update set 116 - repo = excluded.repo, 117 - subject = excluded.subject`, 118 - collaborator.Did, 119 - collaborator.Rkey, 120 - collaborator.Repo, 121 - collaborator.Subject, 122 - ) 123 - return err 124 - } 125 - 126 - func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error { 127 - _, err := d.Exec( 128 - `delete from repo_collaborators where did = ? and rkey = ?`, 129 - did, 130 - rkey, 131 - ) 132 - return err 133 - } 134 - 135 - func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) { 136 - var collaborator RepoCollaborator 137 - err := d.DB.QueryRow( 138 - `select 139 - did, 140 - rkey, 141 - repo, 142 - subject 143 - from repo_collaborators 144 - where did = ? and rkey = ?`, 145 - did, 146 - rkey, 147 - ).Scan( 148 - &collaborator.Did, 149 - &collaborator.Rkey, 150 - &collaborator.Repo, 151 - &collaborator.Subject, 152 - ) 153 - if err != nil { 154 - return nil, err 155 - } 156 - return &collaborator, nil 157 - }
-73
spindle/git/git.go
··· 1 - package git 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "os" 8 - "os/exec" 9 - "strings" 10 - 11 - "github.com/hashicorp/go-version" 12 - ) 13 - 14 - func Version() (*version.Version, error) { 15 - var buf bytes.Buffer 16 - cmd := exec.Command("git", "version") 17 - cmd.Stdout = &buf 18 - cmd.Stderr = os.Stderr 19 - err := cmd.Run() 20 - if err != nil { 21 - return nil, err 22 - } 23 - fields := strings.Fields(buf.String()) 24 - if len(fields) < 3 { 25 - return nil, fmt.Errorf("invalid git version: %s", buf.String()) 26 - } 27 - 28 - // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" 29 - versionString := fields[2] 30 - if pos := strings.Index(versionString, "windows"); pos >= 1 { 31 - versionString = versionString[:pos-1] 32 - } 33 - return version.NewVersion(versionString) 34 - } 35 - 36 - const WorkflowDir = `/.tangled/workflows` 37 - 38 - func SparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error { 39 - exist, err := isDir(path) 40 - if err != nil { 41 - return err 42 - } 43 - if rev == "" { 44 - rev = "HEAD" 45 - } 46 - if !exist { 47 - if err := exec.Command("git", "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path).Run(); err != nil { 48 - return fmt.Errorf("git clone: %w", err) 49 - } 50 - if err := exec.Command("git", "-C", path, "sparse-checkout", "set", "--no-cone", WorkflowDir).Run(); err != nil { 51 - return fmt.Errorf("git sparse-checkout set: %w", err) 52 - } 53 - } else { 54 - if err := exec.Command("git", "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev).Run(); err != nil { 55 - return fmt.Errorf("git pull: %w", err) 56 - } 57 - } 58 - if err := exec.Command("git", "-C", path, "checkout", rev).Run(); err != nil { 59 - return fmt.Errorf("git checkout: %w", err) 60 - } 61 - return nil 62 - } 63 - 64 - func isDir(path string) (bool, error) { 65 - info, err := os.Stat(path) 66 - if err == nil && info.IsDir() { 67 - return true, nil 68 - } 69 - if os.IsNotExist(err) { 70 - return false, nil 71 - } 72 - return false, err 73 - }
+300
spindle/ingester.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "time" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/rbac" 13 + "tangled.org/core/spindle/db" 14 + 15 + comatproto "github.com/bluesky-social/indigo/api/atproto" 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/bluesky-social/jetstream/pkg/models" 20 + securejoin "github.com/cyphar/filepath-securejoin" 21 + ) 22 + 23 + type Ingester func(ctx context.Context, e *models.Event) error 24 + 25 + func (s *Spindle) ingest() Ingester { 26 + return func(ctx context.Context, e *models.Event) error { 27 + var err error 28 + defer func() { 29 + eventTime := e.TimeUS 30 + lastTimeUs := eventTime + 1 31 + if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil { 32 + err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 33 + } 34 + }() 35 + 36 + if e.Kind != models.EventKindCommit { 37 + return nil 38 + } 39 + 40 + switch e.Commit.Collection { 41 + case tangled.SpindleMemberNSID: 42 + err = s.ingestMember(ctx, e) 43 + case tangled.RepoNSID: 44 + err = s.ingestRepo(ctx, e) 45 + case tangled.RepoCollaboratorNSID: 46 + err = s.ingestCollaborator(ctx, e) 47 + } 48 + 49 + if err != nil { 50 + s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 51 + } 52 + 53 + return nil 54 + } 55 + } 56 + 57 + func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 58 + var err error 59 + did := e.Did 60 + rkey := e.Commit.RKey 61 + 62 + l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 63 + 64 + switch e.Commit.Operation { 65 + case models.CommitOperationCreate, models.CommitOperationUpdate: 66 + raw := e.Commit.Record 67 + record := tangled.SpindleMember{} 68 + err = json.Unmarshal(raw, &record) 69 + if err != nil { 70 + l.Error("invalid record", "error", err) 71 + return err 72 + } 73 + 74 + domain := s.cfg.Server.Hostname 75 + recordInstance := record.Instance 76 + 77 + if recordInstance != domain { 78 + l.Error("domain mismatch", "domain", recordInstance, "expected", domain) 79 + return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) 80 + } 81 + 82 + ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) 83 + if err != nil || !ok { 84 + l.Error("failed to add member", "did", did, "error", err) 85 + return fmt.Errorf("failed to enforce permissions: %w", err) 86 + } 87 + 88 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 89 + Did: syntax.DID(did), 90 + Rkey: rkey, 91 + Instance: recordInstance, 92 + Subject: syntax.DID(record.Subject), 93 + Created: time.Now(), 94 + }); err != nil { 95 + l.Error("failed to add member", "error", err) 96 + return fmt.Errorf("failed to add member: %w", err) 97 + } 98 + 99 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 100 + l.Error("failed to add member", "error", err) 101 + return fmt.Errorf("failed to add member: %w", err) 102 + } 103 + l.Info("added member from firehose", "member", record.Subject) 104 + 105 + if err := s.db.AddDid(record.Subject); err != nil { 106 + l.Error("failed to add did", "error", err) 107 + return fmt.Errorf("failed to add did: %w", err) 108 + } 109 + s.jc.AddDid(record.Subject) 110 + 111 + return nil 112 + 113 + case models.CommitOperationDelete: 114 + record, err := db.GetSpindleMember(s.db, did, rkey) 115 + if err != nil { 116 + l.Error("failed to find member", "error", err) 117 + return fmt.Errorf("failed to find member: %w", err) 118 + } 119 + 120 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 121 + l.Error("failed to remove member", "error", err) 122 + return fmt.Errorf("failed to remove member: %w", err) 123 + } 124 + 125 + if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 126 + l.Error("failed to add member", "error", err) 127 + return fmt.Errorf("failed to add member: %w", err) 128 + } 129 + l.Info("added member from firehose", "member", record.Subject) 130 + 131 + if err := s.db.RemoveDid(record.Subject.String()); err != nil { 132 + l.Error("failed to add did", "error", err) 133 + return fmt.Errorf("failed to add did: %w", err) 134 + } 135 + s.jc.RemoveDid(record.Subject.String()) 136 + 137 + } 138 + return nil 139 + } 140 + 141 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 + var err error 143 + did := e.Did 144 + 145 + l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 + 147 + l.Info("ingesting repo record", "did", did) 148 + 149 + switch e.Commit.Operation { 150 + case models.CommitOperationCreate, models.CommitOperationUpdate: 151 + raw := e.Commit.Record 152 + record := tangled.Repo{} 153 + err = json.Unmarshal(raw, &record) 154 + if err != nil { 155 + l.Error("invalid record", "error", err) 156 + return err 157 + } 158 + 159 + domain := s.cfg.Server.Hostname 160 + 161 + // no spindle configured for this repo 162 + if record.Spindle == nil { 163 + l.Info("no spindle configured", "name", record.Name) 164 + return nil 165 + } 166 + 167 + // this repo did not want this spindle 168 + if *record.Spindle != domain { 169 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 170 + return nil 171 + } 172 + 173 + // add this repo to the watch list 174 + if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 175 + l.Error("failed to add repo", "error", err) 176 + return fmt.Errorf("failed to add repo: %w", err) 177 + } 178 + 179 + didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 180 + if err != nil { 181 + return err 182 + } 183 + 184 + // add repo to rbac 185 + if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 186 + l.Error("failed to add repo to enforcer", "error", err) 187 + return fmt.Errorf("failed to add repo: %w", err) 188 + } 189 + 190 + // add collaborators to rbac 191 + owner, err := s.res.ResolveIdent(ctx, did) 192 + if err != nil || owner.Handle.IsInvalidHandle() { 193 + return err 194 + } 195 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 196 + return err 197 + } 198 + 199 + // add this knot to the event consumer 200 + src := eventconsumer.NewKnotSource(record.Knot) 201 + s.ks.AddSource(context.Background(), src) 202 + 203 + return nil 204 + 205 + } 206 + return nil 207 + } 208 + 209 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 210 + var err error 211 + 212 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 213 + 214 + l.Info("ingesting collaborator record") 215 + 216 + switch e.Commit.Operation { 217 + case models.CommitOperationCreate, models.CommitOperationUpdate: 218 + raw := e.Commit.Record 219 + record := tangled.RepoCollaborator{} 220 + err = json.Unmarshal(raw, &record) 221 + if err != nil { 222 + l.Error("invalid record", "error", err) 223 + return err 224 + } 225 + 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 + if err != nil || subjectId.Handle.IsInvalidHandle() { 228 + return err 229 + } 230 + 231 + repoAt, err := syntax.ParseATURI(record.Repo) 232 + if err != nil { 233 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 + return nil 235 + } 236 + 237 + // TODO: get rid of this entirely 238 + // resolve this aturi to extract the repo record 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 + if err != nil || owner.Handle.IsInvalidHandle() { 241 + return fmt.Errorf("failed to resolve handle: %w", err) 242 + } 243 + 244 + xrpcc := xrpc.Client{ 245 + Host: owner.PDSEndpoint(), 246 + } 247 + 248 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + repo := resp.Value.Val.(*tangled.Repo) 254 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 + 256 + // check perms for this user 257 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 + return fmt.Errorf("insufficient permissions: %w", err) 259 + } 260 + 261 + // add collaborator to rbac 262 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 + l.Error("failed to add repo to enforcer", "error", err) 264 + return fmt.Errorf("failed to add repo: %w", err) 265 + } 266 + 267 + return nil 268 + } 269 + return nil 270 + } 271 + 272 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 + 275 + l.Info("fetching and adding existing collaborators") 276 + 277 + xrpcc := xrpc.Client{ 278 + Host: owner.PDSEndpoint(), 279 + } 280 + 281 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 + if err != nil { 283 + return err 284 + } 285 + 286 + var errs error 287 + for _, r := range resp.Records { 288 + if r == nil { 289 + continue 290 + } 291 + record := r.Value.Val.(*tangled.RepoCollaborator) 292 + 293 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 + l.Error("failed to add repo to enforcer", "error", err) 295 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 + } 297 + } 298 + 299 + return errs 300 + }
+2 -2
spindle/models/models.go
··· 53 53 StatusKindRunning, 54 54 } 55 55 FinishStates [4]StatusKind = [4]StatusKind{ 56 - StatusKindCancelled, 57 56 StatusKindFailed, 58 - StatusKindSuccess, 59 57 StatusKindTimeout, 58 + StatusKindCancelled, 59 + StatusKindSuccess, 60 60 } 61 61 ) 62 62
+179 -233
spindle/server.go
··· 4 4 "context" 5 5 _ "embed" 6 6 "encoding/json" 7 - "errors" 8 7 "fmt" 9 8 "log/slog" 10 9 "maps" 11 10 "net/http" 12 - "path/filepath" 11 + "sync" 13 12 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 13 "github.com/go-chi/chi/v5" 16 - "github.com/go-git/go-git/v5/plumbing/object" 17 - "github.com/hashicorp/go-version" 18 14 "tangled.org/core/api/tangled" 19 15 "tangled.org/core/eventconsumer" 20 16 "tangled.org/core/eventconsumer/cursor" 21 17 "tangled.org/core/idresolver" 22 - kgit "tangled.org/core/knotserver/git" 18 + "tangled.org/core/jetstream" 23 19 "tangled.org/core/log" 24 20 "tangled.org/core/notifier" 25 - "tangled.org/core/rbac2" 21 + "tangled.org/core/rbac" 26 22 "tangled.org/core/spindle/config" 27 23 "tangled.org/core/spindle/db" 28 24 "tangled.org/core/spindle/engine" 29 25 "tangled.org/core/spindle/engines/nixery" 30 - "tangled.org/core/spindle/git" 31 26 "tangled.org/core/spindle/models" 32 27 "tangled.org/core/spindle/queue" 33 28 "tangled.org/core/spindle/secrets" 34 29 "tangled.org/core/spindle/xrpc" 35 - "tangled.org/core/tap" 36 - "tangled.org/core/tid" 37 - "tangled.org/core/workflow" 38 30 "tangled.org/core/xrpc/serviceauth" 39 31 ) 40 32 41 33 //go:embed motd 42 - var motd []byte 34 + var defaultMotd []byte 35 + 36 + const ( 37 + rbacDomain = "thisserver" 38 + ) 43 39 44 40 type Spindle struct { 45 - tap *tap.Client 46 - db *db.DB 47 - e *rbac2.Enforcer 48 - l *slog.Logger 49 - n *notifier.Notifier 50 - engs map[string]models.Engine 51 - jq *queue.Queue 52 - cfg *config.Config 53 - ks *eventconsumer.Consumer 54 - res *idresolver.Resolver 55 - vault secrets.Manager 41 + jc *jetstream.JetstreamClient 42 + db *db.DB 43 + e *rbac.Enforcer 44 + l *slog.Logger 45 + n *notifier.Notifier 46 + engs map[string]models.Engine 47 + jq *queue.Queue 48 + cfg *config.Config 49 + ks *eventconsumer.Consumer 50 + res *idresolver.Resolver 51 + vault secrets.Manager 52 + motd []byte 53 + motdMu sync.RWMutex 56 54 } 57 55 58 56 // New creates a new Spindle server with the provided configuration and engines. 59 57 func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 60 58 logger := log.FromContext(ctx) 61 59 62 - if err := ensureGitVersion(); err != nil { 63 - return nil, fmt.Errorf("ensuring git version: %w", err) 64 - } 65 - 66 - d, err := db.Make(ctx, cfg.Server.DBPath()) 60 + d, err := db.Make(cfg.Server.DBPath) 67 61 if err != nil { 68 62 return nil, fmt.Errorf("failed to setup db: %w", err) 69 63 } 70 64 71 - e, err := rbac2.NewEnforcer(cfg.Server.DBPath()) 65 + e, err := rbac.NewEnforcer(cfg.Server.DBPath) 72 66 if err != nil { 73 67 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 74 68 } 69 + e.E.EnableAutoSave(true) 75 70 76 71 n := notifier.New() 77 72 ··· 91 86 } 92 87 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 93 88 case "sqlite", "": 94 - vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath(), secrets.WithTableName("secrets")) 89 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 95 90 if err != nil { 96 91 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 97 92 } 98 - logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath()) 93 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 99 94 default: 100 95 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 96 } ··· 103 98 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 104 99 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 105 100 106 - tap := tap.NewClient(cfg.Server.TapUrl, "") 101 + collections := []string{ 102 + tangled.SpindleMemberNSID, 103 + tangled.RepoNSID, 104 + tangled.RepoCollaboratorNSID, 105 + } 106 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 107 + if err != nil { 108 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 109 + } 110 + jc.AddDid(cfg.Server.Owner) 111 + 112 + // Check if the spindle knows about any Dids; 113 + dids, err := d.GetAllDids() 114 + if err != nil { 115 + return nil, fmt.Errorf("failed to get all dids: %w", err) 116 + } 117 + for _, d := range dids { 118 + jc.AddDid(d) 119 + } 107 120 108 121 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 109 122 110 123 spindle := &Spindle{ 111 - tap: &tap, 124 + jc: jc, 112 125 e: e, 113 126 db: d, 114 127 l: logger, ··· 118 131 cfg: cfg, 119 132 res: resolver, 120 133 vault: vault, 134 + motd: defaultMotd, 121 135 } 122 136 123 - err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did()) 137 + err = e.AddSpindle(rbacDomain) 138 + if err != nil { 139 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 140 + } 141 + err = spindle.configureOwner() 124 142 if err != nil { 125 143 return nil, err 126 144 } 127 145 logger.Info("owner set", "did", cfg.Server.Owner) 128 146 129 - cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath()) 147 + cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 130 148 if err != nil { 131 149 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 132 150 } 133 151 134 - // spindle listen to knot stream for sh.tangled.git.refUpdate 135 - // which will sync the local workflow files in spindle and enqueues the 136 - // pipeline job for on-push workflows 152 + err = jc.StartJetstream(ctx, spindle.ingest()) 153 + if err != nil { 154 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 155 + } 156 + 157 + // for each incoming sh.tangled.pipeline, we execute 158 + // spindle.processPipeline, which in turn enqueues the pipeline 159 + // job in the above registered queue. 137 160 ccfg := eventconsumer.NewConsumerConfig() 138 161 ccfg.Logger = log.SubLogger(logger, "eventconsumer") 139 162 ccfg.Dev = cfg.Server.Dev 140 - ccfg.ProcessFunc = spindle.processKnotStream 163 + ccfg.ProcessFunc = spindle.processPipeline 141 164 ccfg.CursorStore = cursorStore 142 165 knownKnots, err := d.Knots() 143 166 if err != nil { ··· 178 201 } 179 202 180 203 // Enforcer returns the RBAC enforcer instance. 181 - func (s *Spindle) Enforcer() *rbac2.Enforcer { 204 + func (s *Spindle) Enforcer() *rbac.Enforcer { 182 205 return s.e 183 206 } 184 207 208 + // SetMotdContent sets custom MOTD content, replacing the embedded default. 209 + func (s *Spindle) SetMotdContent(content []byte) { 210 + s.motdMu.Lock() 211 + defer s.motdMu.Unlock() 212 + s.motd = content 213 + } 214 + 215 + // GetMotdContent returns the current MOTD content. 216 + func (s *Spindle) GetMotdContent() []byte { 217 + s.motdMu.RLock() 218 + defer s.motdMu.RUnlock() 219 + return s.motd 220 + } 221 + 185 222 // Start starts the Spindle server (blocking). 186 223 func (s *Spindle) Start(ctx context.Context) error { 187 224 // starts a job queue runner in the background ··· 196 233 go func() { 197 234 s.l.Info("starting knot event consumer") 198 235 s.ks.Start(ctx) 199 - }() 200 - 201 - // ensure server owner is tracked 202 - if err := s.tap.AddRepos(ctx, []syntax.DID{s.cfg.Server.Owner}); err != nil { 203 - return err 204 - } 205 - 206 - go func() { 207 - s.l.Info("starting tap stream consumer") 208 - s.tap.Connect(ctx, &tap.SimpleIndexer{ 209 - EventHandler: s.processEvent, 210 - }) 211 236 }() 212 237 213 238 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) ··· 239 264 mux := chi.NewRouter() 240 265 241 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 242 - w.Write(motd) 267 + w.Write(s.GetMotdContent()) 243 268 }) 244 269 mux.HandleFunc("/events", s.Events) 245 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) ··· 268 293 return x.Router() 269 294 } 270 295 271 - func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 272 - l := log.FromContext(ctx).With("handler", "processKnotStream") 273 - l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 274 - if msg.Nsid == tangled.GitRefUpdateNSID { 275 - event := tangled.GitRefUpdate{} 276 - if err := json.Unmarshal(msg.EventJson, &event); err != nil { 277 - l.Error("error unmarshalling", "err", err) 296 + func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 297 + if msg.Nsid == tangled.PipelineNSID { 298 + tpl := tangled.Pipeline{} 299 + err := json.Unmarshal(msg.EventJson, &tpl) 300 + if err != nil { 301 + fmt.Println("error unmarshalling", err) 278 302 return err 279 303 } 280 - l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName) 281 304 282 - // resolve repo name to rkey 283 - // TODO: git.refUpdate should respond with rkey instead of repo name 284 - repo, err := s.db.GetRepoWithName(syntax.DID(event.RepoDid), event.RepoName) 285 - if err != nil { 286 - return fmt.Errorf("get repo with did and name (%s/%s): %w", event.RepoDid, event.RepoName, err) 305 + if tpl.TriggerMetadata == nil { 306 + return fmt.Errorf("no trigger metadata found") 287 307 } 288 308 289 - // NOTE: we are blindly trusting the knot that it will return only repos it own 290 - repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName) 291 - repoPath := s.newRepoPath(repo.Did, repo.Rkey) 292 - if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 293 - return fmt.Errorf("sync git repo: %w", err) 309 + if tpl.TriggerMetadata.Repo == nil { 310 + return fmt.Errorf("no repo data found") 294 311 } 295 - l.Info("synced git repo") 296 312 297 - compiler := workflow.Compiler{ 298 - Trigger: tangled.Pipeline_TriggerMetadata{ 299 - Kind: string(workflow.TriggerKindPush), 300 - Push: &tangled.Pipeline_PushTriggerData{ 301 - Ref: event.Ref, 302 - OldSha: event.OldSha, 303 - NewSha: event.NewSha, 304 - }, 305 - Repo: &tangled.Pipeline_TriggerRepo{ 306 - Did: repo.Did.String(), 307 - Knot: repo.Knot, 308 - Repo: repo.Name, 309 - }, 310 - }, 313 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 314 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 311 315 } 312 316 313 - // load workflow definitions from rev (without spindle context) 314 - rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha) 317 + // filter by repos 318 + _, err = s.db.GetRepo( 319 + tpl.TriggerMetadata.Repo.Knot, 320 + tpl.TriggerMetadata.Repo.Did, 321 + tpl.TriggerMetadata.Repo.Repo, 322 + ) 315 323 if err != nil { 316 - return fmt.Errorf("loading pipeline: %w", err) 317 - } 318 - if len(rawPipeline) == 0 { 319 - l.Info("no workflow definition find for the repo. skipping the event") 320 - return nil 321 - } 322 - tpl := compiler.Compile(compiler.Parse(rawPipeline)) 323 - // TODO: pass compile error to workflow log 324 - for _, w := range compiler.Diagnostics.Errors { 325 - l.Error(w.String()) 326 - } 327 - for _, w := range compiler.Diagnostics.Warnings { 328 - l.Warn(w.String()) 324 + return fmt.Errorf("failed to get repo: %w", err) 329 325 } 330 326 331 327 pipelineId := models.PipelineId{ 332 - Knot: tpl.TriggerMetadata.Repo.Knot, 333 - Rkey: tid.TID(), 334 - } 335 - if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 336 - l.Error("failed to create pipeline event", "err", err) 337 - return nil 338 - } 339 - err = s.processPipeline(ctx, tpl, pipelineId) 340 - if err != nil { 341 - return err 328 + Knot: src.Key(), 329 + Rkey: msg.Rkey, 342 330 } 343 - } 344 331 345 - return nil 346 - } 332 + workflows := make(map[models.Engine][]models.Workflow) 347 333 348 - func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { 349 - if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { 350 - return nil, fmt.Errorf("syncing git repo: %w", err) 351 - } 352 - gr, err := kgit.Open(repoPath, rev) 353 - if err != nil { 354 - return nil, fmt.Errorf("opening git repo: %w", err) 355 - } 334 + // Build pipeline environment variables once for all workflows 335 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 356 336 357 - workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 358 - if errors.Is(err, object.ErrDirectoryNotFound) { 359 - // return empty RawPipeline when directory doesn't exist 360 - return nil, nil 361 - } else if err != nil { 362 - return nil, fmt.Errorf("loading file tree: %w", err) 363 - } 337 + for _, w := range tpl.Workflows { 338 + if w != nil { 339 + if _, ok := s.engs[w.Engine]; !ok { 340 + err = s.db.StatusFailed(models.WorkflowId{ 341 + PipelineId: pipelineId, 342 + Name: w.Name, 343 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 344 + if err != nil { 345 + return fmt.Errorf("db.StatusFailed: %w", err) 346 + } 364 347 365 - var rawPipeline workflow.RawPipeline 366 - for _, e := range workflowDir { 367 - if !e.IsFile() { 368 - continue 369 - } 348 + continue 349 + } 370 350 371 - fpath := filepath.Join(workflow.WorkflowDir, e.Name) 372 - contents, err := gr.RawContent(fpath) 373 - if err != nil { 374 - return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err) 375 - } 351 + eng := s.engs[w.Engine] 376 352 377 - rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 378 - Name: e.Name, 379 - Contents: contents, 380 - }) 381 - } 353 + if _, ok := workflows[eng]; !ok { 354 + workflows[eng] = []models.Workflow{} 355 + } 382 356 383 - return rawPipeline, nil 384 - } 357 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 358 + if err != nil { 359 + return fmt.Errorf("init workflow: %w", err) 360 + } 385 361 386 - func (s *Spindle) processPipeline(ctx context.Context, tpl tangled.Pipeline, pipelineId models.PipelineId) error { 387 - // Build pipeline environment variables once for all workflows 388 - pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 362 + // inject TANGLED_* env vars after InitWorkflow 363 + // This prevents user-defined env vars from overriding them 364 + if ewf.Environment == nil { 365 + ewf.Environment = make(map[string]string) 366 + } 367 + maps.Copy(ewf.Environment, pipelineEnv) 389 368 390 - // filter & init workflows 391 - workflows := make(map[models.Engine][]models.Workflow) 392 - for _, w := range tpl.Workflows { 393 - if w == nil { 394 - continue 395 - } 396 - if _, ok := s.engs[w.Engine]; !ok { 397 - err := s.db.StatusFailed(models.WorkflowId{ 398 - PipelineId: pipelineId, 399 - Name: w.Name, 400 - }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 401 - if err != nil { 402 - return fmt.Errorf("db.StatusFailed: %w", err) 403 - } 369 + workflows[eng] = append(workflows[eng], *ewf) 404 370 405 - continue 406 - } 407 - 408 - eng := s.engs[w.Engine] 409 - 410 - if _, ok := workflows[eng]; !ok { 411 - workflows[eng] = []models.Workflow{} 412 - } 413 - 414 - ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 415 - if err != nil { 416 - return fmt.Errorf("init workflow: %w", err) 371 + err = s.db.StatusPending(models.WorkflowId{ 372 + PipelineId: pipelineId, 373 + Name: w.Name, 374 + }, s.n) 375 + if err != nil { 376 + return fmt.Errorf("db.StatusPending: %w", err) 377 + } 378 + } 417 379 } 418 380 419 - // inject TANGLED_* env vars after InitWorkflow 420 - // This prevents user-defined env vars from overriding them 421 - if ewf.Environment == nil { 422 - ewf.Environment = make(map[string]string) 381 + ok := s.jq.Enqueue(queue.Job{ 382 + Run: func() error { 383 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 384 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 385 + RepoName: tpl.TriggerMetadata.Repo.Repo, 386 + Workflows: workflows, 387 + }, pipelineId) 388 + return nil 389 + }, 390 + OnFail: func(jobError error) { 391 + s.l.Error("pipeline run failed", "error", jobError) 392 + }, 393 + }) 394 + if ok { 395 + s.l.Info("pipeline enqueued successfully", "id", msg.Rkey) 396 + } else { 397 + s.l.Error("failed to enqueue pipeline: queue is full") 423 398 } 424 - maps.Copy(ewf.Environment, pipelineEnv) 425 - 426 - workflows[eng] = append(workflows[eng], *ewf) 427 399 } 428 400 429 - // enqueue pipeline 430 - ok := s.jq.Enqueue(queue.Job{ 431 - Run: func() error { 432 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 433 - RepoOwner: tpl.TriggerMetadata.Repo.Did, 434 - RepoName: tpl.TriggerMetadata.Repo.Repo, 435 - Workflows: workflows, 436 - }, pipelineId) 437 - return nil 438 - }, 439 - OnFail: func(jobError error) { 440 - s.l.Error("pipeline run failed", "error", jobError) 441 - }, 442 - }) 443 - if !ok { 444 - return fmt.Errorf("failed to enqueue pipeline: queue is full") 445 - } 446 - s.l.Info("pipeline enqueued successfully", "id", pipelineId) 447 - 448 - // emit StatusPending for all workflows here (after successful enqueue) 449 - for _, ewfs := range workflows { 450 - for _, ewf := range ewfs { 451 - err := s.db.StatusPending(models.WorkflowId{ 452 - PipelineId: pipelineId, 453 - Name: ewf.Name, 454 - }, s.n) 455 - if err != nil { 456 - return fmt.Errorf("db.StatusPending: %w", err) 457 - } 458 - } 459 - } 460 401 return nil 461 402 } 462 403 463 - // newRepoPath creates a path to store repository by its did and rkey. 464 - // The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey 465 - func (s *Spindle) newRepoPath(did syntax.DID, rkey syntax.RecordKey) string { 466 - return filepath.Join(s.cfg.Server.RepoDir(), did.String(), tangled.RepoNSID, rkey.String()) 467 - } 404 + func (s *Spindle) configureOwner() error { 405 + cfgOwner := s.cfg.Server.Owner 468 406 469 - func (s *Spindle) newRepoCloneUrl(knot, did, name string) string { 470 - scheme := "https://" 471 - if s.cfg.Server.Dev { 472 - scheme = "http://" 407 + existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 408 + if err != nil { 409 + return err 473 410 } 474 - return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name) 475 - } 411 + 412 + switch len(existing) { 413 + case 0: 414 + // no owner configured, continue 415 + case 1: 416 + // find existing owner 417 + existingOwner := existing[0] 476 418 477 - const RequiredVersion = "2.49.0" 419 + // no ownership change, this is okay 420 + if existingOwner == s.cfg.Server.Owner { 421 + break 422 + } 478 423 479 - func ensureGitVersion() error { 480 - v, err := git.Version() 481 - if err != nil { 482 - return fmt.Errorf("fetching git version: %w", err) 483 - } 484 - if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 485 - return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 424 + // remove existing owner 425 + err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 426 + if err != nil { 427 + return nil 428 + } 429 + default: 430 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 486 431 } 487 - return nil 432 + 433 + return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 488 434 }
-391
spindle/tap.go
··· 1 - package spindle 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "time" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/eventconsumer" 12 - "tangled.org/core/spindle/db" 13 - "tangled.org/core/spindle/git" 14 - "tangled.org/core/spindle/models" 15 - "tangled.org/core/tap" 16 - "tangled.org/core/tid" 17 - "tangled.org/core/workflow" 18 - ) 19 - 20 - func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error { 21 - l := s.l.With("component", "tapIndexer") 22 - 23 - var err error 24 - switch evt.Type { 25 - case tap.EvtRecord: 26 - switch evt.Record.Collection.String() { 27 - case tangled.SpindleMemberNSID: 28 - err = s.processMember(ctx, evt) 29 - case tangled.RepoNSID: 30 - err = s.processRepo(ctx, evt) 31 - case tangled.RepoCollaboratorNSID: 32 - err = s.processCollaborator(ctx, evt) 33 - case tangled.RepoPullNSID: 34 - err = s.processPull(ctx, evt) 35 - } 36 - case tap.EvtIdentity: 37 - // no-op 38 - } 39 - 40 - if err != nil { 41 - l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err) 42 - return err 43 - } 44 - return nil 45 - } 46 - 47 - // NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated) 48 - 49 - func (s *Spindle) processMember(ctx context.Context, evt tap.Event) error { 50 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 51 - 52 - l.Info("processing spindle.member record") 53 - 54 - // only listen to members 55 - if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 56 - l.Warn("forbidden request: member invite not allowed", "did", evt.Record.Did, "error", err) 57 - return nil 58 - } 59 - 60 - switch evt.Record.Action { 61 - case tap.RecordCreateAction, tap.RecordUpdateAction: 62 - record := tangled.SpindleMember{} 63 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 64 - return fmt.Errorf("parsing record: %w", err) 65 - } 66 - 67 - domain := s.cfg.Server.Hostname 68 - if record.Instance != domain { 69 - l.Info("domain mismatch", "domain", record.Instance, "expected", domain) 70 - return nil 71 - } 72 - 73 - created, err := time.Parse(record.CreatedAt, time.RFC3339) 74 - if err != nil { 75 - created = time.Now() 76 - } 77 - if err := db.AddSpindleMember(s.db, db.SpindleMember{ 78 - Did: evt.Record.Did, 79 - Rkey: evt.Record.Rkey.String(), 80 - Instance: record.Instance, 81 - Subject: syntax.DID(record.Subject), 82 - Created: created, 83 - }); err != nil { 84 - l.Error("failed to add member", "error", err) 85 - return fmt.Errorf("adding member to db: %w", err) 86 - } 87 - if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil { 88 - return fmt.Errorf("adding member to rbac: %w", err) 89 - } 90 - if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 91 - return fmt.Errorf("adding did to tap", err) 92 - } 93 - 94 - l.Info("added member", "member", record.Subject) 95 - return nil 96 - 97 - case tap.RecordDeleteAction: 98 - var ( 99 - did = evt.Record.Did.String() 100 - rkey = evt.Record.Rkey.String() 101 - ) 102 - member, err := db.GetSpindleMember(s.db, did, rkey) 103 - if err != nil { 104 - return fmt.Errorf("finding member: %w", err) 105 - } 106 - 107 - if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 108 - return fmt.Errorf("removing member from db: %w", err) 109 - } 110 - if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil { 111 - return fmt.Errorf("removing member from rbac: %w", err) 112 - } 113 - if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil { 114 - return fmt.Errorf("removing did from tap: %w", err) 115 - } 116 - 117 - l.Info("removed member", "member", member.Subject) 118 - return nil 119 - } 120 - return nil 121 - } 122 - 123 - func (s *Spindle) processCollaborator(ctx context.Context, evt tap.Event) error { 124 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 125 - 126 - l.Info("processing repo.collaborator record") 127 - 128 - // only listen to members 129 - if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 130 - l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 131 - return nil 132 - } 133 - 134 - switch evt.Record.Action { 135 - case tap.RecordCreateAction, tap.RecordUpdateAction: 136 - record := tangled.RepoCollaborator{} 137 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 138 - l.Error("invalid record", "err", err) 139 - return fmt.Errorf("parsing record: %w", err) 140 - } 141 - 142 - // retry later if target repo is not ingested yet 143 - if _, err := s.db.GetRepo(syntax.ATURI(record.Repo)); err != nil { 144 - l.Warn("target repo is not ingested yet", "repo", record.Repo, "err", err) 145 - return fmt.Errorf("target repo is unknown") 146 - } 147 - 148 - // check perms for this user 149 - if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil { 150 - l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 151 - return nil 152 - } 153 - 154 - if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{ 155 - Did: evt.Record.Did, 156 - Rkey: evt.Record.Rkey, 157 - Repo: syntax.ATURI(record.Repo), 158 - Subject: syntax.DID(record.Subject), 159 - }); err != nil { 160 - return fmt.Errorf("adding collaborator to db: %w", err) 161 - } 162 - if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil { 163 - return fmt.Errorf("adding collaborator to rbac: %w", err) 164 - } 165 - if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 166 - return fmt.Errorf("adding did to tap: %w", err) 167 - } 168 - 169 - l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo) 170 - return nil 171 - 172 - case tap.RecordDeleteAction: 173 - // get existing collaborator 174 - collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey) 175 - if err != nil { 176 - return fmt.Errorf("failed to get existing collaborator info: %w", err) 177 - } 178 - 179 - // check perms for this user 180 - if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil { 181 - l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 182 - return nil 183 - } 184 - 185 - if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil { 186 - return fmt.Errorf("removing collaborator from db: %w", err) 187 - } 188 - if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil { 189 - return fmt.Errorf("removing collaborator from rbac: %w", err) 190 - } 191 - if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil { 192 - return fmt.Errorf("removing did from tap: %w", err) 193 - } 194 - 195 - l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo) 196 - return nil 197 - } 198 - return nil 199 - } 200 - 201 - func (s *Spindle) processRepo(ctx context.Context, evt tap.Event) error { 202 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 203 - 204 - l.Info("processing repo record") 205 - 206 - // only listen to members 207 - if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 208 - l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 209 - return nil 210 - } 211 - 212 - switch evt.Record.Action { 213 - case tap.RecordCreateAction, tap.RecordUpdateAction: 214 - record := tangled.Repo{} 215 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 216 - return fmt.Errorf("parsing record: %w", err) 217 - } 218 - 219 - domain := s.cfg.Server.Hostname 220 - if record.Spindle == nil || *record.Spindle != domain { 221 - if record.Spindle == nil { 222 - l.Info("spindle isn't configured", "name", record.Name) 223 - } else { 224 - l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 225 - } 226 - if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 227 - return fmt.Errorf("deleting repo from db: %w", err) 228 - } 229 - return nil 230 - } 231 - 232 - repo := &db.Repo{ 233 - Did: evt.Record.Did, 234 - Rkey: evt.Record.Rkey, 235 - Name: record.Name, 236 - Knot: record.Knot, 237 - } 238 - 239 - if err := s.db.PutRepo(repo); err != nil { 240 - return fmt.Errorf("adding repo to db: %w", err) 241 - } 242 - 243 - if err := s.e.AddRepo(evt.Record.AtUri()); err != nil { 244 - return fmt.Errorf("adding repo to rbac") 245 - } 246 - 247 - // add this knot to the event consumer 248 - src := eventconsumer.NewKnotSource(record.Knot) 249 - s.ks.AddSource(context.Background(), src) 250 - 251 - // setup sparse sync 252 - repoCloneUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 253 - repoPath := s.newRepoPath(repo.Did, repo.Rkey) 254 - if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, ""); err != nil { 255 - return fmt.Errorf("setting up sparse-clone git repo: %w", err) 256 - } 257 - 258 - l.Info("added repo", "repo", evt.Record.AtUri()) 259 - return nil 260 - 261 - case tap.RecordDeleteAction: 262 - // check perms for this user 263 - if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil { 264 - l.Warn("forbidden request: not repo owner", "did", evt.Record.Did, "err", err) 265 - return nil 266 - } 267 - 268 - if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 269 - return fmt.Errorf("deleting repo from db: %w", err) 270 - } 271 - 272 - if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil { 273 - return fmt.Errorf("deleting repo from rbac: %w", err) 274 - } 275 - 276 - l.Info("deleted repo", "repo", evt.Record.AtUri()) 277 - return nil 278 - } 279 - return nil 280 - } 281 - 282 - func (s *Spindle) processPull(ctx context.Context, evt tap.Event) error { 283 - l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 284 - 285 - l.Info("processing pull record") 286 - 287 - // only listen to live events 288 - if !evt.Record.Live { 289 - l.Info("skipping backfill event", "event", evt.Record.AtUri()) 290 - return nil 291 - } 292 - 293 - switch evt.Record.Action { 294 - case tap.RecordCreateAction, tap.RecordUpdateAction: 295 - record := tangled.RepoPull{} 296 - if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 297 - l.Error("invalid record", "err", err) 298 - return fmt.Errorf("parsing record: %w", err) 299 - } 300 - 301 - // ignore legacy records 302 - if record.Target == nil { 303 - l.Info("ignoring pull record: target repo is nil") 304 - return nil 305 - } 306 - 307 - // ignore patch-based and fork-based PRs 308 - if record.Source == nil || record.Source.Repo != nil { 309 - l.Info("ignoring pull record: not a branch-based pull request") 310 - return nil 311 - } 312 - 313 - // skip if target repo is unknown 314 - repo, err := s.db.GetRepo(syntax.ATURI(record.Target.Repo)) 315 - if err != nil { 316 - l.Warn("target repo is not ingested yet", "repo", record.Target.Repo, "err", err) 317 - return fmt.Errorf("target repo is unknown") 318 - } 319 - 320 - compiler := workflow.Compiler{ 321 - Trigger: tangled.Pipeline_TriggerMetadata{ 322 - Kind: string(workflow.TriggerKindPullRequest), 323 - PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 324 - Action: "create", 325 - SourceBranch: record.Source.Branch, 326 - SourceSha: record.Source.Sha, 327 - TargetBranch: record.Target.Branch, 328 - }, 329 - Repo: &tangled.Pipeline_TriggerRepo{ 330 - Did: repo.Did.String(), 331 - Knot: repo.Knot, 332 - Repo: repo.Name, 333 - }, 334 - }, 335 - } 336 - 337 - repoUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 338 - repoPath := s.newRepoPath(repo.Did, repo.Rkey) 339 - 340 - // load workflow definitions from rev (without spindle context) 341 - rawPipeline, err := s.loadPipeline(ctx, repoUri, repoPath, record.Source.Sha) 342 - if err != nil { 343 - // don't retry 344 - l.Error("failed loading pipeline", "err", err) 345 - return nil 346 - } 347 - if len(rawPipeline) == 0 { 348 - l.Info("no workflow definition find for the repo. skipping the event") 349 - return nil 350 - } 351 - tpl := compiler.Compile(compiler.Parse(rawPipeline)) 352 - // TODO: pass compile error to workflow log 353 - for _, w := range compiler.Diagnostics.Errors { 354 - l.Error(w.String()) 355 - } 356 - for _, w := range compiler.Diagnostics.Warnings { 357 - l.Warn(w.String()) 358 - } 359 - 360 - pipelineId := models.PipelineId{ 361 - Knot: tpl.TriggerMetadata.Repo.Knot, 362 - Rkey: tid.TID(), 363 - } 364 - if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 365 - l.Error("failed to create pipeline event", "err", err) 366 - return nil 367 - } 368 - err = s.processPipeline(ctx, tpl, pipelineId) 369 - if err != nil { 370 - // don't retry 371 - l.Error("failed processing pipeline", "err", err) 372 - return nil 373 - } 374 - case tap.RecordDeleteAction: 375 - // no-op 376 - } 377 - return nil 378 - } 379 - 380 - func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error { 381 - known, err := s.db.IsKnownDid(syntax.DID(did)) 382 - if err != nil { 383 - return fmt.Errorf("ensuring did known state: %w", err) 384 - } 385 - if !known { 386 - if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { 387 - return fmt.Errorf("removing did from tap: %w", err) 388 - } 389 - } 390 - return nil 391 - }
+2 -1
spindle/xrpc/add_secret.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 14 15 "tangled.org/core/spindle/secrets" 15 16 xrpcerr "tangled.org/core/xrpc/errors" 16 17 ) ··· 67 68 return 68 69 } 69 70 70 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 71 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 72 l.Error("insufficent permissions", "did", actorDid.String()) 72 73 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 74 return
+2 -1
spindle/xrpc/list_secrets.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 14 15 "tangled.org/core/spindle/secrets" 15 16 xrpcerr "tangled.org/core/xrpc/errors" 16 17 ) ··· 62 63 return 63 64 } 64 65 65 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 66 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 67 l.Error("insufficent permissions", "did", actorDid.String()) 67 68 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 69 return
+1 -1
spindle/xrpc/owner.go
··· 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 - owner := x.Config.Server.Owner.String() 12 + owner := x.Config.Server.Owner 13 13 if owner == "" { 14 14 writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 15 return
+28 -3
spindle/xrpc/pipeline_cancelPipeline.go
··· 6 6 "net/http" 7 7 "strings" 8 8 9 + "github.com/bluesky-social/indigo/api/atproto" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 10 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 11 15 "tangled.org/core/spindle/models" 12 16 xrpcerr "tangled.org/core/xrpc/errors" 13 17 ) ··· 49 53 return 50 54 } 51 55 52 - isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt) 56 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 57 + if err != nil || ident.Handle.IsInvalidHandle() { 58 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 59 + return 60 + } 61 + 62 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 63 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 64 + if err != nil { 65 + fail(xrpcerr.GenericError(err)) 66 + return 67 + } 68 + 69 + repo := resp.Value.Val.(*tangled.Repo) 70 + didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 71 + if err != nil { 72 + fail(xrpcerr.GenericError(err)) 73 + return 74 + } 75 + 76 + // TODO: fine-grained role based control 77 + isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, didSlashRepo) 53 78 if err != nil || !isRepoOwner { 54 79 fail(xrpcerr.AccessControlError(actorDid.String())) 55 80 return ··· 58 83 l.Debug("destorying workflow", "wid", wid) 59 84 err = engine.DestroyWorkflow(r.Context(), wid) 60 85 if err != nil { 61 - fail(xrpcerr.GenericError(fmt.Errorf("dailed to destroy workflow: %w", err))) 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to destroy workflow: %w", err))) 62 87 return 63 88 } 64 89 err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier) 65 90 if err != nil { 66 - fail(xrpcerr.GenericError(fmt.Errorf("dailed to emit status failed: %w", err))) 91 + fail(xrpcerr.GenericError(fmt.Errorf("failed to emit status failed: %w", err))) 67 92 return 68 93 } 69 94 }
+2 -1
spindle/xrpc/remove_secret.go
··· 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "tangled.org/core/api/tangled" 13 + "tangled.org/core/rbac" 13 14 "tangled.org/core/spindle/secrets" 14 15 xrpcerr "tangled.org/core/xrpc/errors" 15 16 ) ··· 61 62 return 62 63 } 63 64 64 - if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 65 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 66 l.Error("insufficent permissions", "did", actorDid.String()) 66 67 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 68 return
+2 -2
spindle/xrpc/xrpc.go
··· 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/idresolver" 13 13 "tangled.org/core/notifier" 14 - "tangled.org/core/rbac2" 14 + "tangled.org/core/rbac" 15 15 "tangled.org/core/spindle/config" 16 16 "tangled.org/core/spindle/db" 17 17 "tangled.org/core/spindle/models" ··· 25 25 type Xrpc struct { 26 26 Logger *slog.Logger 27 27 Db *db.DB 28 - Enforcer *rbac2.Enforcer 28 + Enforcer *rbac.Enforcer 29 29 Engines map[string]models.Engine 30 30 Config *config.Config 31 31 Resolver *idresolver.Resolver
-24
tap/simpleIndexer.go
··· 1 - package tap 2 - 3 - import "context" 4 - 5 - type SimpleIndexer struct { 6 - EventHandler func(ctx context.Context, evt Event) error 7 - ErrorHandler func(ctx context.Context, err error) 8 - } 9 - 10 - var _ Handler = (*SimpleIndexer)(nil) 11 - 12 - func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error { 13 - if i.EventHandler == nil { 14 - return nil 15 - } 16 - return i.EventHandler(ctx, evt) 17 - } 18 - 19 - func (i *SimpleIndexer) OnError(ctx context.Context, err error) { 20 - if i.ErrorHandler == nil { 21 - return 22 - } 23 - i.ErrorHandler(ctx, err) 24 - }
-169
tap/tap.go
··· 1 - /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 - 3 - package tap 4 - 5 - import ( 6 - "bytes" 7 - "context" 8 - "encoding/json" 9 - "fmt" 10 - "net/http" 11 - "net/url" 12 - 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/gorilla/websocket" 15 - "tangled.org/core/log" 16 - ) 17 - 18 - // type WebsocketOptions struct { 19 - // maxReconnectSeconds int 20 - // heartbeatIntervalMs int 21 - // // onReconnectError 22 - // } 23 - 24 - type Handler interface { 25 - OnEvent(ctx context.Context, evt Event) error 26 - OnError(ctx context.Context, err error) 27 - } 28 - 29 - type Client struct { 30 - Url string 31 - AdminPassword string 32 - HTTPClient *http.Client 33 - } 34 - 35 - func NewClient(url, adminPassword string) Client { 36 - return Client{ 37 - Url: url, 38 - AdminPassword: adminPassword, 39 - HTTPClient: &http.Client{}, 40 - } 41 - } 42 - 43 - func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 44 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 45 - if err != nil { 46 - return err 47 - } 48 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 49 - if err != nil { 50 - return err 51 - } 52 - req.SetBasicAuth("admin", c.AdminPassword) 53 - req.Header.Set("Content-Type", "application/json") 54 - 55 - resp, err := c.HTTPClient.Do(req) 56 - if err != nil { 57 - return err 58 - } 59 - defer resp.Body.Close() 60 - if resp.StatusCode != http.StatusOK { 61 - return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 62 - } 63 - return nil 64 - } 65 - 66 - func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 67 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 68 - if err != nil { 69 - return err 70 - } 71 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 72 - if err != nil { 73 - return err 74 - } 75 - req.SetBasicAuth("admin", c.AdminPassword) 76 - req.Header.Set("Content-Type", "application/json") 77 - 78 - resp, err := c.HTTPClient.Do(req) 79 - if err != nil { 80 - return err 81 - } 82 - defer resp.Body.Close() 83 - if resp.StatusCode != http.StatusOK { 84 - return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 85 - } 86 - return nil 87 - } 88 - 89 - func (c *Client) Connect(ctx context.Context, handler Handler) error { 90 - l := log.FromContext(ctx) 91 - 92 - u, err := url.Parse(c.Url) 93 - if err != nil { 94 - return err 95 - } 96 - if u.Scheme == "https" { 97 - u.Scheme = "wss" 98 - } else { 99 - u.Scheme = "ws" 100 - } 101 - u.Path = "/channel" 102 - 103 - // TODO: set auth on dial 104 - 105 - url := u.String() 106 - 107 - // var backoff int 108 - // for { 109 - // select { 110 - // case <-ctx.Done(): 111 - // return ctx.Err() 112 - // default: 113 - // } 114 - // 115 - // header := http.Header{ 116 - // "Authorization": []string{""}, 117 - // } 118 - // conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 119 - // if err != nil { 120 - // l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 121 - // time.Sleep(time.Duration(5+backoff) * time.Second) 122 - // backoff++ 123 - // 124 - // continue 125 - // } else { 126 - // backoff = 0 127 - // } 128 - // 129 - // l.Info("event subscription response", "code", res.StatusCode) 130 - // } 131 - 132 - // TODO: keep websocket connection alive 133 - conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 134 - if err != nil { 135 - return err 136 - } 137 - defer conn.Close() 138 - 139 - for { 140 - select { 141 - case <-ctx.Done(): 142 - return ctx.Err() 143 - default: 144 - } 145 - _, message, err := conn.ReadMessage() 146 - if err != nil { 147 - return err 148 - } 149 - 150 - var ev Event 151 - if err := json.Unmarshal(message, &ev); err != nil { 152 - handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 153 - continue 154 - } 155 - if err := handler.OnEvent(ctx, ev); err != nil { 156 - handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 157 - continue 158 - } 159 - 160 - ack := map[string]any{ 161 - "type": "ack", 162 - "id": ev.ID, 163 - } 164 - if err := conn.WriteJSON(ack); err != nil { 165 - l.Warn("failed to send ack", "err", err) 166 - continue 167 - } 168 - } 169 - }
-62
tap/types.go
··· 1 - package tap 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type EventType string 11 - 12 - const ( 13 - EvtRecord EventType = "record" 14 - EvtIdentity EventType = "identity" 15 - ) 16 - 17 - type Event struct { 18 - ID int64 `json:"id"` 19 - Type EventType `json:"type"` 20 - Record *RecordEventData `json:"record,omitempty"` 21 - Identity *IdentityEventData `json:"identity,omitempty"` 22 - } 23 - 24 - type RecordEventData struct { 25 - Live bool `json:"live"` 26 - Did syntax.DID `json:"did"` 27 - Rev string `json:"rev"` 28 - Collection syntax.NSID `json:"collection"` 29 - Rkey syntax.RecordKey `json:"rkey"` 30 - Action RecordAction `json:"action"` 31 - Record json.RawMessage `json:"record,omitempty"` 32 - CID *syntax.CID `json:"cid,omitempty"` 33 - } 34 - 35 - func (r *RecordEventData) AtUri() syntax.ATURI { 36 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey)) 37 - } 38 - 39 - type RecordAction string 40 - 41 - const ( 42 - RecordCreateAction RecordAction = "create" 43 - RecordUpdateAction RecordAction = "update" 44 - RecordDeleteAction RecordAction = "delete" 45 - ) 46 - 47 - type IdentityEventData struct { 48 - DID syntax.DID `json:"did"` 49 - Handle string `json:"handle"` 50 - IsActive bool `json:"is_active"` 51 - Status RepoStatus `json:"status"` 52 - } 53 - 54 - type RepoStatus string 55 - 56 - const ( 57 - RepoStatusActive RepoStatus = "active" 58 - RepoStatusTakendown RepoStatus = "takendown" 59 - RepoStatusSuspended RepoStatus = "suspended" 60 - RepoStatusDeactivated RepoStatus = "deactivated" 61 - RepoStatusDeleted RepoStatus = "deleted" 62 - )
+81 -30
types/diff.go
··· 1 1 package types 2 2 3 3 import ( 4 + "net/url" 5 + 4 6 "github.com/bluekeyes/go-gitdiff/gitdiff" 7 + "tangled.org/core/appview/filetree" 5 8 ) 6 9 7 10 type DiffOpts struct { 8 11 Split bool `json:"split"` 9 12 } 10 13 11 - type TextFragment struct { 12 - Header string `json:"comment"` 13 - Lines []gitdiff.Line `json:"lines"` 14 + func (d DiffOpts) Encode() string { 15 + values := make(url.Values) 16 + if d.Split { 17 + values.Set("diff", "split") 18 + } else { 19 + values.Set("diff", "unified") 20 + } 21 + return values.Encode() 22 + } 23 + 24 + // A nicer git diff representation. 25 + type NiceDiff struct { 26 + Commit Commit `json:"commit"` 27 + Stat DiffStat `json:"stat"` 28 + Diff []Diff `json:"diff"` 14 29 } 15 30 16 31 type Diff struct { ··· 26 41 IsRename bool `json:"is_rename"` 27 42 } 28 43 29 - type DiffStat struct { 30 - Insertions int64 31 - Deletions int64 32 - } 33 - 34 - func (d *Diff) Stats() DiffStat { 35 - var stats DiffStat 44 + func (d Diff) Stats() DiffFileStat { 45 + var stats DiffFileStat 36 46 for _, f := range d.TextFragments { 37 47 stats.Insertions += f.LinesAdded 38 48 stats.Deletions += f.LinesDeleted ··· 40 50 return stats 41 51 } 42 52 43 - // A nicer git diff representation. 44 - type NiceDiff struct { 45 - Commit Commit `json:"commit"` 46 - Stat struct { 47 - FilesChanged int `json:"files_changed"` 48 - Insertions int `json:"insertions"` 49 - Deletions int `json:"deletions"` 50 - } `json:"stat"` 51 - Diff []Diff `json:"diff"` 53 + type DiffStat struct { 54 + Insertions int64 `json:"insertions"` 55 + Deletions int64 `json:"deletions"` 56 + FilesChanged int `json:"files_changed"` 57 + } 58 + 59 + type DiffFileStat struct { 60 + Insertions int64 61 + Deletions int64 52 62 } 53 63 54 64 type DiffTree struct { ··· 58 68 Diff []*gitdiff.File `json:"diff"` 59 69 } 60 70 61 - func (d *NiceDiff) ChangedFiles() []string { 62 - files := make([]string, len(d.Diff)) 71 + type DiffFileName struct { 72 + Old string 73 + New string 74 + } 75 + 76 + func (d NiceDiff) ChangedFiles() []DiffFileRenderer { 77 + drs := make([]DiffFileRenderer, len(d.Diff)) 78 + for i, s := range d.Diff { 79 + drs[i] = s 80 + } 81 + return drs 82 + } 63 83 64 - for i, f := range d.Diff { 65 - if f.IsDelete { 66 - files[i] = f.Name.Old 84 + func (d NiceDiff) FileTree() *filetree.FileTreeNode { 85 + fs := make([]string, len(d.Diff)) 86 + for i, s := range d.Diff { 87 + n := s.Names() 88 + if n.New == "" { 89 + fs[i] = n.Old 67 90 } else { 68 - files[i] = f.Name.New 91 + fs[i] = n.New 69 92 } 70 93 } 94 + return filetree.FileTree(fs) 95 + } 71 96 72 - return files 97 + func (d NiceDiff) Stats() DiffStat { 98 + return d.Stat 73 99 } 74 100 75 - // used by html elements as a unique ID for hrefs 76 - func (d *Diff) Id() string { 101 + func (d Diff) Id() string { 102 + if d.IsDelete { 103 + return d.Name.Old 104 + } 77 105 return d.Name.New 78 106 } 79 107 80 - func (d *Diff) Split() *SplitDiff { 108 + func (d Diff) Names() DiffFileName { 109 + var n DiffFileName 110 + if d.IsDelete { 111 + n.Old = d.Name.Old 112 + return n 113 + } else if d.IsCopy || d.IsRename { 114 + n.Old = d.Name.Old 115 + n.New = d.Name.New 116 + return n 117 + } else { 118 + n.New = d.Name.New 119 + return n 120 + } 121 + } 122 + 123 + func (d Diff) CanRender() string { 124 + if d.IsBinary { 125 + return "This is a binary file and will not be displayed." 126 + } 127 + 128 + return "" 129 + } 130 + 131 + func (d Diff) Split() SplitDiff { 81 132 fragments := make([]SplitFragment, len(d.TextFragments)) 82 133 for i, fragment := range d.TextFragments { 83 134 leftLines, rightLines := SeparateLines(&fragment) ··· 88 139 } 89 140 } 90 141 91 - return &SplitDiff{ 142 + return SplitDiff{ 92 143 Name: d.Id(), 93 144 TextFragments: fragments, 94 145 }
+31
types/diff_renderer.go
··· 1 + package types 2 + 3 + import "tangled.org/core/appview/filetree" 4 + 5 + type DiffRenderer interface { 6 + // list of file affected by these diffs 7 + ChangedFiles() []DiffFileRenderer 8 + 9 + // filetree 10 + FileTree() *filetree.FileTreeNode 11 + 12 + Stats() DiffStat 13 + } 14 + 15 + type DiffFileRenderer interface { 16 + // html ID for each file in the diff 17 + Id() string 18 + 19 + // produce a splitdiff 20 + Split() SplitDiff 21 + 22 + // stats for this single file 23 + Stats() DiffFileStat 24 + 25 + // old and new name of file 26 + Names() DiffFileName 27 + 28 + // whether this diff can be displayed, 29 + // returns a reason if not, and the empty string if it can 30 + CanRender() string 31 + }
+121
types/diff_test.go
··· 1 + package types 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestDiffId(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + diff Diff 11 + expected string 12 + }{ 13 + { 14 + name: "regular file uses new name", 15 + diff: Diff{ 16 + Name: struct { 17 + Old string `json:"old"` 18 + New string `json:"new"` 19 + }{Old: "", New: "src/main.go"}, 20 + }, 21 + expected: "src/main.go", 22 + }, 23 + { 24 + name: "new file uses new name", 25 + diff: Diff{ 26 + Name: struct { 27 + Old string `json:"old"` 28 + New string `json:"new"` 29 + }{Old: "", New: "src/new.go"}, 30 + IsNew: true, 31 + }, 32 + expected: "src/new.go", 33 + }, 34 + { 35 + name: "deleted file uses old name", 36 + diff: Diff{ 37 + Name: struct { 38 + Old string `json:"old"` 39 + New string `json:"new"` 40 + }{Old: "src/deleted.go", New: ""}, 41 + IsDelete: true, 42 + }, 43 + expected: "src/deleted.go", 44 + }, 45 + { 46 + name: "renamed file uses new name", 47 + diff: Diff{ 48 + Name: struct { 49 + Old string `json:"old"` 50 + New string `json:"new"` 51 + }{Old: "src/old.go", New: "src/renamed.go"}, 52 + IsRename: true, 53 + }, 54 + expected: "src/renamed.go", 55 + }, 56 + } 57 + 58 + for _, tt := range tests { 59 + t.Run(tt.name, func(t *testing.T) { 60 + if got := tt.diff.Id(); got != tt.expected { 61 + t.Errorf("Diff.Id() = %q, want %q", got, tt.expected) 62 + } 63 + }) 64 + } 65 + } 66 + 67 + func TestChangedFilesMatchesDiffId(t *testing.T) { 68 + // ChangedFiles() must return values matching each Diff's Id() 69 + // so that sidebar links point to the correct anchors. 70 + // Tests existing, deleted, new, and renamed files. 71 + nd := NiceDiff{ 72 + Diff: []Diff{ 73 + { 74 + Name: struct { 75 + Old string `json:"old"` 76 + New string `json:"new"` 77 + }{Old: "", New: "src/modified.go"}, 78 + }, 79 + { 80 + Name: struct { 81 + Old string `json:"old"` 82 + New string `json:"new"` 83 + }{Old: "src/deleted.go", New: ""}, 84 + IsDelete: true, 85 + }, 86 + { 87 + Name: struct { 88 + Old string `json:"old"` 89 + New string `json:"new"` 90 + }{Old: "", New: "src/new.go"}, 91 + IsNew: true, 92 + }, 93 + { 94 + Name: struct { 95 + Old string `json:"old"` 96 + New string `json:"new"` 97 + }{Old: "src/old.go", New: "src/renamed.go"}, 98 + IsRename: true, 99 + }, 100 + }, 101 + } 102 + 103 + changedFiles := nd.ChangedFiles() 104 + 105 + if len(changedFiles) != len(nd.Diff) { 106 + t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff)) 107 + } 108 + 109 + for i, diff := range nd.Diff { 110 + if changedFiles[i].Id() != diff.Id() { 111 + t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id()) 112 + } 113 + } 114 + } 115 + 116 + func TestImplsInterfaces(t *testing.T) { 117 + nd := NiceDiff{} 118 + _ = isDiffsRenderer(nd) 119 + } 120 + 121 + func isDiffsRenderer[S DiffRenderer](S) bool { return true }
+1 -2
types/split.go
··· 22 22 TextFragments []SplitFragment `json:"fragments"` 23 23 } 24 24 25 - // used by html elements as a unique ID for hrefs 26 - func (d *SplitDiff) Id() string { 25 + func (d SplitDiff) Id() string { 27 26 return d.Name 28 27 } 29 28