this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+4787 -3800
api
appview
db
issues
knots
middleware
models
notify
oauth
ogcard
pages
pulls
repo
reporesolver
service
session
spindles
state
web
cmd
appview
dolly
docs
hook
ico
knotserver
lexicons
pulls
nix
sets
spindle
types
+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.
+2
appview/db/follow.go
··· 167 167 if err != nil { 168 168 return nil, err 169 169 } 170 + defer rows.Close() 171 + 170 172 for rows.Next() { 171 173 var follow models.Follow 172 174 var followedAt string
+1
appview/db/issues.go
··· 452 452 if err != nil { 453 453 return nil, err 454 454 } 455 + defer rows.Close() 455 456 456 457 for rows.Next() { 457 458 var comment models.IssueComment
+1 -1
appview/db/language.go
··· 28 28 whereClause, 29 29 ) 30 30 rows, err := e.Query(query, args...) 31 - 32 31 if err != nil { 33 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 34 33 } 34 + defer rows.Close() 35 35 36 36 var langs []models.RepoLanguage 37 37 for rows.Next() {
+23 -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 { ··· 230 237 if err != nil { 231 238 return nil, err 232 239 } 240 + defer rows.Close() 233 241 234 242 profileMap := make(map[string]*models.Profile) 235 243 for rows.Next() { ··· 270 278 if err != nil { 271 279 return nil, err 272 280 } 281 + defer rows.Close() 282 + 273 283 idxs := make(map[string]int) 274 284 for did := range profileMap { 275 285 idxs[did] = 0 ··· 290 300 if err != nil { 291 301 return nil, err 292 302 } 303 + defer rows.Close() 304 + 293 305 idxs = make(map[string]int) 294 306 for did := range profileMap { 295 307 idxs[did] = 0
+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
+1
appview/db/registration.go
··· 38 38 if err != nil { 39 39 return nil, err 40 40 } 41 + defer rows.Close() 41 42 42 43 for rows.Next() { 43 44 var createdAt string
+12 -1
appview/db/repos.go
··· 56 56 limitClause, 57 57 ) 58 58 rows, err := e.Query(repoQuery, args...) 59 - 60 59 if err != nil { 61 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 62 61 } 62 + defer rows.Close() 63 63 64 64 for rows.Next() { 65 65 var repo models.Repo ··· 128 128 if err != nil { 129 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 130 130 } 131 + defer rows.Close() 132 + 131 133 for rows.Next() { 132 134 var repoat, labelat string 133 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 156 158 from repo_languages 157 159 where repo_at in (%s) 158 160 and is_default_ref = 1 161 + and language <> '' 159 162 ) 160 163 where rn = 1 161 164 `, ··· 165 168 if err != nil { 166 169 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 167 170 } 171 + defer rows.Close() 172 + 168 173 for rows.Next() { 169 174 var repoat, lang string 170 175 if err := rows.Scan(&repoat, &lang); err != nil { ··· 191 196 if err != nil { 192 197 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 193 198 } 199 + defer rows.Close() 200 + 194 201 for rows.Next() { 195 202 var repoat string 196 203 var count int ··· 220 227 if err != nil { 221 228 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 222 229 } 230 + defer rows.Close() 231 + 223 232 for rows.Next() { 224 233 var repoat string 225 234 var open, closed int ··· 261 270 if err != nil { 262 271 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 263 272 } 273 + defer rows.Close() 274 + 264 275 for rows.Next() { 265 276 var repoat string 266 277 var open, merged, closed, deleted int
+1
appview/db/star.go
··· 165 165 if err != nil { 166 166 return nil, err 167 167 } 168 + defer rows.Close() 168 169 169 170 starMap := make(map[string][]models.Star) 170 171 for rows.Next() {
+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
-5
appview/knots/knots.go
··· 666 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 667 return 668 668 } 669 - if memberId.Handle.IsInvalidHandle() { 670 - l.Error("failed to resolve member identity to handle") 671 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 672 - return 673 - } 674 669 675 670 // remove from enforcer 676 671 err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
+4
appview/middleware/middleware.go
··· 223 223 ) 224 224 if err != nil { 225 225 log.Println("failed to resolve repo", "err", err) 226 + w.WriteHeader(http.StatusNotFound) 226 227 mw.pages.ErrorKnot404(w) 227 228 return 228 229 } ··· 240 241 f, err := mw.repoResolver.Resolve(r) 241 242 if err != nil { 242 243 log.Println("failed to fully resolve repo", err) 244 + w.WriteHeader(http.StatusNotFound) 243 245 mw.pages.ErrorKnot404(w) 244 246 return 245 247 } ··· 288 290 f, err := mw.repoResolver.Resolve(r) 289 291 if err != nil { 290 292 log.Println("failed to fully resolve repo", err) 293 + w.WriteHeader(http.StatusNotFound) 291 294 mw.pages.ErrorKnot404(w) 292 295 return 293 296 } ··· 324 327 f, err := mw.repoResolver.Resolve(r) 325 328 if err != nil { 326 329 log.Println("failed to fully resolve repo", err) 330 + w.WriteHeader(http.StatusNotFound) 327 331 mw.pages.ErrorKnot404(w) 328 332 return 329 333 }
+39
appview/models/pipeline.go
··· 1 1 package models 2 2 3 3 import ( 4 + "fmt" 4 5 "slices" 6 + "strings" 5 7 "time" 6 8 7 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 50 52 } 51 53 52 54 return 0 55 + } 56 + 57 + // produces short summary of successes: 58 + // - "0/4" when zero successes of 4 workflows 59 + // - "4/4" when all successes of 4 workflows 60 + // - "0/0" when no workflows run in this pipeline 61 + func (p Pipeline) ShortStatusSummary() string { 62 + counts := make(map[spindle.StatusKind]int) 63 + for _, w := range p.Statuses { 64 + counts[w.Latest().Status] += 1 65 + } 66 + 67 + total := len(p.Statuses) 68 + successes := counts[spindle.StatusKindSuccess] 69 + 70 + return fmt.Sprintf("%d/%d", successes, total) 71 + } 72 + 73 + // produces a string of the form "3/4 success, 2/4 failed, 1/4 pending" 74 + func (p Pipeline) LongStatusSummary() string { 75 + counts := make(map[spindle.StatusKind]int) 76 + for _, w := range p.Statuses { 77 + counts[w.Latest().Status] += 1 78 + } 79 + 80 + total := len(p.Statuses) 81 + 82 + var result []string 83 + // finish states first, followed by start states 84 + states := append(spindle.FinishStates[:], spindle.StartStates[:]...) 85 + for _, state := range states { 86 + if count, ok := counts[state]; ok { 87 + result = append(result, fmt.Sprintf("%d/%d %s", count, total, state.String())) 88 + } 89 + } 90 + 91 + return strings.Join(result, ", ") 53 92 } 54 93 55 94 func (p Pipeline) Counts() map[string]int {
+1 -1
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
+67 -57
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 - "maps" 7 6 "slices" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 13 12 "tangled.org/core/appview/notify" 14 13 "tangled.org/core/idresolver" 15 14 "tangled.org/core/orm" 15 + "tangled.org/core/sets" 16 16 ) 17 17 18 18 const ( 19 - maxMentions = 5 19 + maxMentions = 8 20 20 ) 21 21 22 22 type databaseNotifier struct { ··· 50 50 } 51 51 52 52 actorDid := syntax.DID(star.Did) 53 - recipients := []syntax.DID{syntax.DID(repo.Did)} 53 + recipients := sets.Singleton(syntax.DID(repo.Did)) 54 54 eventType := models.NotificationTypeRepoStarred 55 55 entityType := "repo" 56 56 entityId := star.RepoAt.String() ··· 75 75 } 76 76 77 77 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 - 79 - // build the recipients list 80 - // - owner of the repo 81 - // - collaborators in the repo 82 - var recipients []syntax.DID 83 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 84 78 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 85 79 if err != nil { 86 80 log.Printf("failed to fetch collaborators: %v", err) 87 81 return 88 82 } 83 + 84 + // build the recipients list 85 + // - owner of the repo 86 + // - collaborators in the repo 87 + // - remove users already mentioned 88 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 89 89 for _, c := range collaborators { 90 - recipients = append(recipients, c.SubjectDid) 90 + recipients.Insert(c.SubjectDid) 91 + } 92 + for _, m := range mentions { 93 + recipients.Remove(m) 91 94 } 92 95 93 96 actorDid := syntax.DID(issue.Did) ··· 109 112 ) 110 113 n.notifyEvent( 111 114 actorDid, 112 - mentions, 115 + sets.Collect(slices.Values(mentions)), 113 116 models.NotificationTypeUserMentioned, 114 117 entityType, 115 118 entityId, ··· 131 134 } 132 135 issue := issues[0] 133 136 134 - var recipients []syntax.DID 135 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 137 + // built the recipients list: 138 + // - the owner of the repo 139 + // - | if the comment is a reply -> everybody on that thread 140 + // | if the comment is a top level -> just the issue owner 141 + // - remove mentioned users from the recipients list 142 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 136 143 137 144 if comment.IsReply() { 138 145 // if this comment is a reply, then notify everybody in that thread 139 146 parentAtUri := *comment.ReplyTo 140 - allThreads := issue.CommentList() 141 147 142 148 // find the parent thread, and add all DIDs from here to the recipient list 143 - for _, t := range allThreads { 149 + for _, t := range issue.CommentList() { 144 150 if t.Self.AtUri().String() == parentAtUri { 145 - recipients = append(recipients, t.Participants()...) 151 + for _, p := range t.Participants() { 152 + recipients.Insert(p) 153 + } 146 154 } 147 155 } 148 156 } else { 149 157 // not a reply, notify just the issue author 150 - recipients = append(recipients, syntax.DID(issue.Did)) 158 + recipients.Insert(syntax.DID(issue.Did)) 159 + } 160 + 161 + for _, m := range mentions { 162 + recipients.Remove(m) 151 163 } 152 164 153 165 actorDid := syntax.DID(comment.Did) ··· 169 181 ) 170 182 n.notifyEvent( 171 183 actorDid, 172 - mentions, 184 + sets.Collect(slices.Values(mentions)), 173 185 models.NotificationTypeUserMentioned, 174 186 entityType, 175 187 entityId, ··· 185 197 186 198 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 187 199 actorDid := syntax.DID(follow.UserDid) 188 - recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 200 + recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) 189 201 eventType := models.NotificationTypeFollowed 190 202 entityType := "follow" 191 203 entityId := follow.UserDid ··· 213 225 log.Printf("NewPull: failed to get repos: %v", err) 214 226 return 215 227 } 216 - 217 - // build the recipients list 218 - // - owner of the repo 219 - // - collaborators in the repo 220 - var recipients []syntax.DID 221 - recipients = append(recipients, syntax.DID(repo.Did)) 222 228 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 223 229 if err != nil { 224 230 log.Printf("failed to fetch collaborators: %v", err) 225 231 return 226 232 } 233 + 234 + // build the recipients list 235 + // - owner of the repo 236 + // - collaborators in the repo 237 + recipients := sets.Singleton(syntax.DID(repo.Did)) 227 238 for _, c := range collaborators { 228 - recipients = append(recipients, c.SubjectDid) 239 + recipients.Insert(c.SubjectDid) 229 240 } 230 241 231 242 actorDid := syntax.DID(pull.OwnerDid) ··· 268 279 // build up the recipients list: 269 280 // - repo owner 270 281 // - all pull participants 271 - var recipients []syntax.DID 272 - recipients = append(recipients, syntax.DID(repo.Did)) 282 + // - remove those already mentioned 283 + recipients := sets.Singleton(syntax.DID(repo.Did)) 273 284 for _, p := range pull.Participants() { 274 - recipients = append(recipients, syntax.DID(p)) 285 + recipients.Insert(syntax.DID(p)) 286 + } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 275 289 } 276 290 277 291 actorDid := syntax.DID(comment.OwnerDid) ··· 295 309 ) 296 310 n.notifyEvent( 297 311 actorDid, 298 - mentions, 312 + sets.Collect(slices.Values(mentions)), 299 313 models.NotificationTypeUserMentioned, 300 314 entityType, 301 315 entityId, ··· 322 336 } 323 337 324 338 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 325 - // build up the recipients list: 326 - // - repo owner 327 - // - repo collaborators 328 - // - all issue participants 329 - var recipients []syntax.DID 330 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 331 339 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 332 340 if err != nil { 333 341 log.Printf("failed to fetch collaborators: %v", err) 334 342 return 335 343 } 344 + 345 + // build up the recipients list: 346 + // - repo owner 347 + // - repo collaborators 348 + // - all issue participants 349 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 336 350 for _, c := range collaborators { 337 - recipients = append(recipients, c.SubjectDid) 351 + recipients.Insert(c.SubjectDid) 338 352 } 339 353 for _, p := range issue.Participants() { 340 - recipients = append(recipients, syntax.DID(p)) 354 + recipients.Insert(syntax.DID(p)) 341 355 } 342 356 343 357 entityType := "pull" ··· 373 387 return 374 388 } 375 389 376 - // build up the recipients list: 377 - // - repo owner 378 - // - all pull participants 379 - var recipients []syntax.DID 380 - recipients = append(recipients, syntax.DID(repo.Did)) 381 390 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 382 391 if err != nil { 383 392 log.Printf("failed to fetch collaborators: %v", err) 384 393 return 385 394 } 395 + 396 + // build up the recipients list: 397 + // - repo owner 398 + // - all pull participants 399 + recipients := sets.Singleton(syntax.DID(repo.Did)) 386 400 for _, c := range collaborators { 387 - recipients = append(recipients, c.SubjectDid) 401 + recipients.Insert(c.SubjectDid) 388 402 } 389 403 for _, p := range pull.Participants() { 390 - recipients = append(recipients, syntax.DID(p)) 404 + recipients.Insert(syntax.DID(p)) 391 405 } 392 406 393 407 entityType := "pull" ··· 423 437 424 438 func (n *databaseNotifier) notifyEvent( 425 439 actorDid syntax.DID, 426 - recipients []syntax.DID, 440 + recipients sets.Set[syntax.DID], 427 441 eventType models.NotificationType, 428 442 entityType string, 429 443 entityId string, ··· 431 445 issueId *int64, 432 446 pullId *int64, 433 447 ) { 434 - if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 435 - recipients = recipients[:maxMentions] 448 + // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody 449 + if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { 450 + return 436 451 } 437 - recipientSet := make(map[syntax.DID]struct{}) 438 - for _, did := range recipients { 439 - // everybody except actor themselves 440 - if did != actorDid { 441 - recipientSet[did] = struct{}{} 442 - } 443 - } 452 + 453 + recipients.Remove(actorDid) 444 454 445 455 prefMap, err := db.GetNotificationPreferences( 446 456 n.db, 447 - orm.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 457 + orm.FilterIn("user_did", slices.Collect(recipients.All())), 448 458 ) 449 459 if err != nil { 450 460 // failed to get prefs for users ··· 460 470 defer tx.Rollback() 461 471 462 472 // filter based on preferences 463 - for recipientDid := range recipientSet { 473 + for recipientDid := range recipients.All() { 464 474 prefs, ok := prefMap[recipientDid] 465 475 if !ok { 466 476 prefs = models.DefaultNotificationPreferences(recipientDid)
-1
appview/notify/merged_notifier.go
··· 39 39 v.Call(in) 40 40 }(n) 41 41 } 42 - wg.Wait() 43 42 } 44 43 45 44 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+2 -2
appview/oauth/handler.go
··· 25 25 26 26 r.Get("/oauth/client-metadata.json", o.clientMetadata) 27 27 r.Get("/oauth/jwks.json", o.jwks) 28 - r.Get("/oauth/callback", o.Callback) 28 + r.Get("/oauth/callback", o.callback) 29 29 return r 30 30 } 31 31 ··· 51 51 } 52 52 } 53 53 54 - func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { 54 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 55 55 ctx := r.Context() 56 56 l := o.Logger.With("query", r.URL.Query()) 57 57
-10
appview/oauth/session.go
··· 1 - package oauth 2 - 3 - import ( 4 - "net/http" 5 - 6 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 7 - ) 8 - 9 - func (o *OAuth) SaveSession2(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) { 10 - }
+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)
+26 -8
appview/pages/funcmap.go
··· 25 25 "github.com/dustin/go-humanize" 26 26 "github.com/go-enry/go-enry/v2" 27 27 "github.com/yuin/goldmark" 28 + emoji "github.com/yuin/goldmark-emoji" 28 29 "tangled.org/core/appview/filetree" 29 30 "tangled.org/core/appview/models" 30 31 "tangled.org/core/appview/pages/markup" ··· 261 262 }, 262 263 "description": func(text string) template.HTML { 263 264 p.rctx.RendererType = markup.RendererTypeDefault 264 - htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 265 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New( 266 + goldmark.WithExtensions( 267 + emoji.Emoji, 268 + ), 269 + )) 265 270 sanitized := p.rctx.SanitizeDescription(htmlString) 266 271 return template.HTML(sanitized) 267 272 }, ··· 329 334 }, 330 335 "deref": func(v any) any { 331 336 val := reflect.ValueOf(v) 332 - if val.Kind() == reflect.Ptr && !val.IsNil() { 337 + if val.Kind() == reflect.Pointer && !val.IsNil() { 333 338 return val.Elem().Interface() 334 339 } 335 340 return nil ··· 361 366 return p.AvatarUrl(handle, "") 362 367 }, 363 368 "langColor": enry.GetColor, 364 - "layoutSide": func() string { 365 - return "col-span-1 md:col-span-2 lg:col-span-3" 366 - }, 367 - "layoutCenter": func() string { 368 - return "col-span-1 md:col-span-8 lg:col-span-6" 369 - }, 369 + "reverse": func(s any) any { 370 + if s == nil { 371 + return nil 372 + } 373 + 374 + v := reflect.ValueOf(s) 375 + 376 + if v.Kind() != reflect.Slice { 377 + return s 378 + } 379 + 380 + length := v.Len() 381 + reversed := reflect.MakeSlice(v.Type(), length, length) 370 382 383 + for i := range length { 384 + reversed.Index(i).Set(v.Index(length - 1 - i)) 385 + } 386 + 387 + return reversed.Interface() 388 + }, 371 389 "normalizeForHtmlId": func(s string) string { 372 390 normalized := strings.ReplaceAll(s, ":", "_") 373 391 normalized = strings.ReplaceAll(normalized, ".", "_")
+13 -3
appview/pages/markup/extension/atlink.go
··· 35 35 return KindAt 36 36 } 37 37 38 - var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`) 39 + var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`) 39 40 40 41 type atParser struct{} 41 42 ··· 55 56 if m == nil { 56 57 return nil 57 58 } 59 + 60 + // Check for all links in the markdown to see if the handle found is inside one 61 + linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1) 62 + for _, linkMatch := range linksIndexes { 63 + if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] { 64 + return nil 65 + } 66 + } 67 + 58 68 atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 59 69 block.Advance(m[1]) 60 70 node := &AtNode{} ··· 87 97 88 98 func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 99 if entering { 90 - w.WriteString(`<a href="/@`) 100 + w.WriteString(`<a href="/`) 91 101 w.WriteString(n.(*AtNode).Handle) 92 - w.WriteString(`" class="mention font-bold">`) 102 + w.WriteString(`" class="mention">`) 93 103 } else { 94 104 w.WriteString("</a>") 95 105 }
+2
appview/pages/markup/markdown.go
··· 13 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 14 "github.com/alecthomas/chroma/v2/styles" 15 15 "github.com/yuin/goldmark" 16 + "github.com/yuin/goldmark-emoji" 16 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 17 18 "github.com/yuin/goldmark/ast" 18 19 "github.com/yuin/goldmark/extension" ··· 66 67 ), 67 68 callout.CalloutExtention, 68 69 textension.AtExt, 70 + emoji.Emoji, 69 71 ), 70 72 goldmark.WithParserOptions( 71 73 parser.WithAutoHeadingID(),
+121
appview/pages/markup/markdown_test.go
··· 1 + package markup 2 + 3 + import ( 4 + "bytes" 5 + "testing" 6 + ) 7 + 8 + func TestAtExtension_Rendering(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + markdown string 12 + expected string 13 + }{ 14 + { 15 + name: "renders simple at mention", 16 + markdown: "Hello @user.tngl.sh!", 17 + expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`, 18 + }, 19 + { 20 + name: "renders multiple at mentions", 21 + markdown: "Hi @alice.tngl.sh and @bob.example.com", 22 + expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`, 23 + }, 24 + { 25 + name: "renders at mention in parentheses", 26 + markdown: "Check this out (@user.tngl.sh)", 27 + expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`, 28 + }, 29 + { 30 + name: "does not render email", 31 + markdown: "Contact me at test@example.com", 32 + expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`, 33 + }, 34 + { 35 + name: "renders at mention with hyphen", 36 + markdown: "Follow @user-name.tngl.sh", 37 + expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`, 38 + }, 39 + { 40 + name: "renders at mention with numbers", 41 + markdown: "@user123.test456.social", 42 + expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`, 43 + }, 44 + { 45 + name: "at mention at start of line", 46 + markdown: "@user.tngl.sh is cool", 47 + expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`, 48 + }, 49 + } 50 + 51 + for _, tt := range tests { 52 + t.Run(tt.name, func(t *testing.T) { 53 + md := NewMarkdown() 54 + 55 + var buf bytes.Buffer 56 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 57 + t.Fatalf("failed to convert markdown: %v", err) 58 + } 59 + 60 + result := buf.String() 61 + if result != tt.expected+"\n" { 62 + t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestAtExtension_WithOtherMarkdown(t *testing.T) { 69 + tests := []struct { 70 + name string 71 + markdown string 72 + contains string 73 + }{ 74 + { 75 + name: "at mention with bold", 76 + markdown: "**Hello @user.tngl.sh**", 77 + contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`, 78 + }, 79 + { 80 + name: "at mention with italic", 81 + markdown: "*Check @user.tngl.sh*", 82 + contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`, 83 + }, 84 + { 85 + name: "at mention in list", 86 + markdown: "- Item 1\n- @user.tngl.sh\n- Item 3", 87 + contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`, 88 + }, 89 + { 90 + name: "at mention in link", 91 + markdown: "[@regnault.dev](https://regnault.dev)", 92 + contains: `<a href="https://regnault.dev">@regnault.dev</a>`, 93 + }, 94 + { 95 + name: "at mention in link again", 96 + markdown: "[check out @regnault.dev](https://regnault.dev)", 97 + contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`, 98 + }, 99 + { 100 + name: "at mention in link again, multiline", 101 + markdown: "[\ncheck out @regnault.dev](https://regnault.dev)", 102 + contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + md := NewMarkdown() 109 + 110 + var buf bytes.Buffer 111 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 112 + t.Fatalf("failed to convert markdown: %v", err) 113 + } 114 + 115 + result := buf.String() 116 + if !bytes.Contains([]byte(result), []byte(tt.contains)) { 117 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 118 + } 119 + }) 120 + } 121 + }
+15 -2
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 { ··· 640 651 } 641 652 642 653 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 - return p.executePlain("fragments/starBtn", w, params) 654 + return p.executePlain("fragments/starBtn-oob", w, params) 644 655 } 645 656 646 657 type RepoIndexParams struct { ··· 1092 1103 MergeCheck types.MergeCheckResponse 1093 1104 ResubmitCheck ResubmitResult 1094 1105 Pipelines map[string]models.Pipeline 1106 + Diff *types.NiceDiff 1107 + DiffOpts types.DiffOpts 1095 1108 1096 1109 OrderedReactionKinds []models.ReactionKind 1097 1110 Reactions map[models.ReactionKind]models.ReactionDisplayData
+1 -1
appview/pages/templates/banner.html
··· 30 30 <div class="mx-6"> 31 31 These services may not be fully accessible until upgraded. 32 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 33 + href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles"> 34 34 Click to read the upgrade guide</a>. 35 35 </div> 36 36 </details>
+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
+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 -3
appview/pages/templates/fragments/starBtn.html
··· 1 1 {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 2 3 <button 3 4 id="starBtn" 4 5 class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" ··· 10 11 {{ end }} 11 12 12 13 hx-trigger="click" 13 - hx-target="this" 14 - hx-swap="outerHTML" 15 - hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]' 16 14 hx-disabled-elt="#starBtn" 17 15 > 18 16 {{ if .IsStarred }}
+1
appview/pages/templates/fragments/tabSelector.html
··· 9 9 {{ range $index, $value := $all }} 10 10 {{ $isActive := eq $value.Key $active }} 11 11 <a href="?{{ $name }}={{ $value.Key }}" 12 + hx-boost=true 12 13 {{ if $include }} 13 14 hx-get="?{{ $name }}={{ $value.Key }}" 14 15 hx-include="{{ $include }}"
+1 -1
appview/pages/templates/knots/index.html
··· 105 105 {{ define "docsButton" }} 106 106 <a 107 107 class="btn flex items-center gap-2" 108 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 108 + href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide"> 109 109 {{ i "book" "size-4" }} 110 110 docs 111 111 </a>
+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
··· 26 26 <div class="flex flex-col gap-1"> 27 27 <div class="{{ $headerStyle }}">resources</div> 28 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 29 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 32 </div> ··· 73 73 <div class="flex flex-col gap-1"> 74 74 <div class="{{ $headerStyle }}">resources</div> 75 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 76 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 79 </div>
+1 -5
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
+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 -1
appview/pages/templates/repo/empty.html
··· 26 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 27 {{ $knot := .RepoInfo.Knot }} 28 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 30 {{ end }} 31 31 <div class="w-full flex place-content-center"> 32 32 <div class="py-6 w-fit flex flex-col gap-4">
+6 -6
appview/pages/templates/repo/fragments/backlinks.html
··· 14 14 <div class="flex gap-2 items-center"> 15 15 {{ if .State.IsClosed }} 16 16 <span class="text-gray-500 dark:text-gray-400"> 17 - {{ i "ban" "w-4 h-4" }} 17 + {{ i "ban" "size-3" }} 18 18 </span> 19 19 {{ else if eq .Kind.String "issues" }} 20 20 <span class="text-green-600 dark:text-green-500"> 21 - {{ i "circle-dot" "w-4 h-4" }} 21 + {{ i "circle-dot" "size-3" }} 22 22 </span> 23 23 {{ else if .State.IsOpen }} 24 24 <span class="text-green-600 dark:text-green-500"> 25 - {{ i "git-pull-request" "w-4 h-4" }} 25 + {{ i "git-pull-request" "size-3" }} 26 26 </span> 27 27 {{ else if .State.IsMerged }} 28 28 <span class="text-purple-600 dark:text-purple-500"> 29 - {{ i "git-merge" "w-4 h-4" }} 29 + {{ i "git-merge" "size-3" }} 30 30 </span> 31 31 {{ else }} 32 32 <span class="text-gray-600 dark:text-gray-300"> 33 - {{ i "git-pull-request-closed" "w-4 h-4" }} 33 + {{ i "git-pull-request-closed" "size-3" }} 34 34 </span> 35 35 {{ end }} 36 - <a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 37 </div> 38 38 {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 39 <div>
+2 -2
appview/pages/templates/repo/fragments/diff.html
··· 17 17 {{ else }} 18 18 {{ range $idx, $hunk := $diff }} 19 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"> 20 + <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 }}"> 21 + <summary class="list-none cursor-pointer sticky top-12"> 22 22 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 23 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 24 24 <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span>
+1 -8
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 1 {{ define "repo/fragments/diffChangedFiles" }} 2 - {{ $stat := .Stat }} 3 2 {{ $fileTree := fileTree .ChangedFiles }} 4 3 <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> 4 + {{ template "repo/fragments/fileTree" $fileTree }} 12 5 </section> 13 6 {{ 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
+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:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 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:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 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 -
+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 }}
+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>
+1 -1
appview/pages/templates/repo/pipelines/pipelines.html
··· 23 23 </p> 24 24 <p> 25 25 <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 26 + <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>. 27 27 </p> 28 28 <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 29 </div>
+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 +
+20
appview/pages/templates/repo/pulls/fragments/replyPullCommentPlaceholder.html
··· 1 + {{ define "repo/pulls/fragments/replyPullCommentPlaceholder" }} 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full p-0 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .Submission.ID }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 18 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 19 {{ $commentCount := len $lastSubmission.Comments }} 20 20 {{ if and $pipeline $pipeline.Id }} 21 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 22 22 <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 23 {{ end }} 24 24 <span>
+334 -77
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 col-span-full flex-grow flex flex-col gap-4"> 11 + {{ block "contentLayout" . }} 12 + {{ block "content" . }}{{ end }} 13 + {{ end }} 14 + </div> 15 + {{ end }} 16 + 9 17 {{ 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"> 18 + <div class="grid grid-cols-1 md:grid-cols-10 gap-y-2 gap-x-4 w-full"> 19 + <div class="col-span-1 md:col-span-7"> 20 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full"> 13 21 {{ block "repoContent" . }}{{ end }} 14 22 </section> 15 23 {{ block "repoAfter" . }}{{ end }} 16 24 </div> 17 - <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 25 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 18 26 {{ template "repo/fragments/labelPanel" 19 27 (dict "RepoInfo" $.RepoInfo 20 28 "Defs" $.LabelDefs ··· 26 34 "Backlinks" $.Backlinks) }} 27 35 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 28 36 </div> 37 + 38 + <style> 39 + #filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; } 40 + #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 41 + #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 42 + 43 + #filesToggle:checked ~ div div#files { width: 10vw; margin-right: 1rem; } 44 + #filesToggle:not(:checked) ~ div div#files { width: 0; display: hidden; margin-right: 0; } 45 + 46 + #subsToggle:checked ~ div div#subs { width: 25vw; margin-left: 1rem; } 47 + #subsToggle:not(:checked) ~ div div#subs { width: 0; display: hidden; margin-left: 0; } 48 + </style> 49 + 50 + <!-- Checkboxes must come first as siblings --> 51 + <input type="checkbox" id="filesToggle" class="peer/files hidden" checked/> 52 + <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 53 + 54 + <!-- Top bar with controls --> 55 + <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12"> 56 + <label for="filesToggle" class="inline-flex items-center justify-center rounded cursor-pointer p-2 text-normal font-normal normalcase"> 57 + <span class="show-text">{{ i "panel-left-open" "size-5" }}</span> 58 + <span class="hide-text">{{ i "panel-left-close" "size-5" }}</span> 59 + </label> 60 + {{ template "repo/fragments/diffStatPill" .Diff.Stat }} 61 + {{ .Diff.Stat.FilesChanged }} changed file{{ if ne .Diff.Stat.FilesChanged 1 }}s{{ end }} 62 + <div class="flex-grow"></div> 63 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 64 + <label for="subsToggle" class="inline-flex items-center justify-center rounded cursor-pointer p-2"> 65 + {{ i "message-square-more" "size-5" }} 66 + </label> 67 + </div> 68 + 69 + <div class="flex col-span-full"> 70 + <!-- left panel --> 71 + <div id="files" class="w-0 overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 72 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 73 + </div> 74 + 75 + <!-- main content --> 76 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 77 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 78 + </div> 79 + 80 + <!-- right panel --> 81 + <div id="subs" class="w-0 overflow-hidden max-h-screen flex flex-col sticky top-12 pb-12"> 82 + <div class="z-20 sticky top-0 rounded-t p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"> 83 + <h2 class="font-bold uppercase">history</h2> 84 + </div> 85 + <div class="flex flex-col-reverse gap-4 overflow-y-auto"> 86 + {{ template "submissions2" . }} 87 + </div> 88 + </div> 89 + </div> 29 90 </div> 30 91 {{ end }} 31 92 32 93 {{ define "repoContent" }} 33 94 {{ template "repo/pulls/fragments/pullHeader" . }} 34 - 35 95 {{ if .Pull.IsStacked }} 36 96 <div class="mt-8"> 37 97 {{ template "repo/pulls/fragments/pullStack" . }} ··· 40 100 {{ end }} 41 101 42 102 {{ define "repoAfter" }} 43 - <section id="submissions" class="mt-4"> 44 - <div class="flex flex-col gap-4"> 45 - {{ block "submissions" . }} {{ end }} 103 + <div id="pull-close"></div> 104 + <div id="pull-reopen"></div> 105 + {{ end }} 106 + 107 + {{ define "submissions2" }} 108 + {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 109 + {{ range $ridx, $item := reverse .Pull.Submissions }} 110 + {{ $idx := sub $lastIdx $ridx }} 111 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-800/50"> 112 + {{ with $item }} 113 + {{ $patches := .AsFormatPatch }} 114 + {{ $round := .RoundNumber }} 115 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2"> 116 + <div class="flex-shrink-0"> 117 + <img 118 + src="{{ tinyAvatar $.Pull.OwnerDid }}" 119 + alt="" 120 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 121 + /> 122 + </div> 123 + <!-- right column: name and body in two rows --> 124 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 125 + <div class="flex gap-2 items-center justify-between mb-1"> 126 + <span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 127 + {{ resolve $.Pull.OwnerDid }} submitted v{{ $round }} 128 + <span class="select-none before:content-['\00B7']"></span> 129 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}">{{ template "repo/fragments/shortTimeAgo" .Created }}</a> 130 + </span> 131 + {{ if ne $idx 0 }} 132 + <a class="flex items-center gap-2 no-underline hover:no-underline text-sm" 133 + hx-boost="true" 134 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{$round}}/interdiff"> 135 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 136 + <span class="hidden md:inline">interdiff</span> 137 + </a> 138 + {{ end }} 139 + </div> 140 + <details class="group"> 141 + <summary class="list-none cursor-pointer flex items-center gap-2"> 142 + <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 143 + {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 144 + </summary> 145 + {{ range $patches }} 146 + <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"> 147 + <div class="flex items-baseline gap-2"> 148 + <div> 149 + <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 150 + {{ $fullRepo := "" }} 151 + {{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }} 152 + {{ $fullRepo = printf "%s/%s" $.Pull.OwnerDid $.Pull.PullSource.Repo.Name }} 153 + {{ else if $.Pull.IsBranchBased }} 154 + {{ $fullRepo = $.RepoInfo.FullName }} 155 + {{ end }} 156 + 157 + <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 158 + {{ if $fullRepo }} 159 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 160 + {{ else }} 161 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 162 + {{ end }} 163 + </div> 164 + 165 + <div> 166 + <span>{{ .Title | description }}</span> 167 + {{ if gt (len .Body) 0 }} 168 + <button 169 + 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" 170 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 171 + > 172 + {{ i "ellipsis" "w-3 h-3" }} 173 + </button> 174 + {{ end }} 175 + {{ if gt (len .Body) 0 }} 176 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">{{ nl2br .Body }}</p> 177 + {{ end }} 178 + </div> 179 + </div> 180 + </div> 181 + {{ end }} 182 + </details> 183 + <div> 184 + {{ block "pipelineStatus" (list $ .) }} {{ end }} 185 + </div> 186 + {{ if eq $lastIdx .RoundNumber }} 187 + {{ block "mergeCheck" $ }} {{ end }} 188 + {{ end }} 189 + </div> 190 + </div> 191 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 192 + {{ range $cidx, $c := .Comments }} 193 + <div id="comment-{{$c.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 194 + <!-- left column: profile picture --> 195 + <div class="flex-shrink-0"> 196 + <img 197 + src="{{ tinyAvatar $c.OwnerDid }}" 198 + alt="" 199 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 200 + /> 201 + </div> 202 + <!-- right column: name and body in two rows --> 203 + <div class="flex-1 min-w-0"> 204 + <!-- Row 1: Author and timestamp --> 205 + <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 206 + <span>{{ resolve $c.OwnerDid }}</span> 207 + <span class="before:content-['ยท']"></span> 208 + <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> 209 + </div> 210 + <!-- Row 2: Body text --> 211 + <div class="prose dark:prose-invert mt-1"> 212 + {{ $c.Body | markdown }} 213 + </div> 214 + </div> 215 + </div> 216 + {{ end }} 217 + </div> 218 + {{ end }} 219 + {{ if eq $lastIdx .RoundNumber }} 220 + {{ block "mergeStatus" $ }} {{ end }} 221 + {{ block "resubmitStatus" $ }} {{ end }} 222 + {{ end }} 223 + {{ if $.LoggedInUser }} 224 + {{ template "repo/pulls/fragments/pullActions" 225 + (dict 226 + "LoggedInUser" $.LoggedInUser 227 + "Pull" $.Pull 228 + "RepoInfo" $.RepoInfo 229 + "RoundNumber" .RoundNumber 230 + "MergeCheck" $.MergeCheck 231 + "ResubmitCheck" $.ResubmitCheck 232 + "BranchDeleteStatus" $.BranchDeleteStatus 233 + "Stack" $.Stack) }} 234 + {{ else }} 235 + <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"> 236 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 237 + sign up 238 + </a> 239 + <span class="text-gray-500 dark:text-gray-400">or</span> 240 + <a href="/login" class="underline">login</a> 241 + to add to the discussion 242 + </div> 243 + {{ end }} 244 + </div> 245 + {{ end }} 246 + {{ end }} 247 + 248 + {{ define "newComment" }} 249 + {{ $root := index . 0 }} 250 + {{ $submission := index . 1 }} 251 + <form 252 + id="comment-form" 253 + hx-post="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $submission.RoundNumber }}/comment" 254 + hx-on::after-request="if(event.detail.successful) this.reset()" 255 + > 256 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 257 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 258 + {{ template "user/fragments/picHandleLink" $root.LoggedInUser.Did }} 46 259 </div> 47 - </section> 260 + <textarea 261 + id="comment-textarea" 262 + name="body" 263 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 264 + placeholder="Add to the discussion" 265 + rows="8" 266 + ></textarea> 267 + <div id="pull-comment"></div> 268 + </div> 269 + {{ template "replyActions" . }} 270 + </form> 271 + {{ end }} 48 272 49 - <div id="pull-close"></div> 50 - <div id="pull-reopen"></div> 273 + {{ define "replyActions" }} 274 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 275 + {{ template "cancel" . }} 276 + {{ template "reply" . }} 277 + </div> 278 + {{ end }} 279 + 280 + {{ define "cancel" }} 281 + <button 282 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 283 + hx-get="TODO" 284 + hx-target="TODO" 285 + hx-swap="outerHTML"> 286 + {{ i "x" "size-4" }} 287 + cancel 288 + </button> 289 + {{ end }} 290 + 291 + {{ define "reply" }} 292 + <button 293 + id="TODO" 294 + type="submit" 295 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 296 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 297 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 298 + reply 299 + </button> 51 300 {{ end }} 52 301 53 302 {{ define "submissions" }} ··· 214 463 {{ end }} 215 464 {{ end }} 216 465 466 + {{ define "mergeCheck" }} 467 + {{ $isOpen := .Pull.State.IsOpen }} 468 + {{ if and $isOpen .MergeCheck .MergeCheck.Error }} 469 + <div class="flex items-center gap-2"> 470 + {{ i "triangle-alert" "w-4 h-4 text-red-500 dark:text-red-300" }} 471 + {{ .MergeCheck.Error }} 472 + </div> 473 + {{ else if and $isOpen .MergeCheck .MergeCheck.IsConflicted }} 474 + <details class="group"> 475 + <summary class="flex items-center justify-between cursor-pointer list-none"> 476 + <div class="flex items-center gap-2 "> 477 + {{ i "triangle-alert" "w-4 h-4" }} 478 + <span class="font-medium">merge conflicts detected</span> 479 + </div> 480 + <div> 481 + <span class="group-open:hidden inline">{{ i "chevrons-up-down" "w-4 h-4" }}</span> 482 + <span class="hidden group-open:inline">{{ i "chevrons-down-up" "w-4 h-4" }}</span> 483 + </div> 484 + </summary> 485 + {{ if gt (len .MergeCheck.Conflicts) 0 }} 486 + <ul class="space-y-1 mt-2"> 487 + {{ range .MergeCheck.Conflicts }} 488 + {{ if .Filename }} 489 + <li class="flex items-center"> 490 + {{ i "file-warning" "inline-flex w-4 h-4 mr-1.5 text-red-500 dark:text-red-300 flex-shrink-0" }} 491 + <span class="font-mono" style="word-break: keep-all; overflow-wrap: break-word;">{{ .Filename }}</span> 492 + </li> 493 + {{ else if .Reason }} 494 + <li class="flex items-center"> 495 + {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 496 + <span>{{.Reason}}</span> 497 + </li> 498 + {{ end }} 499 + {{ end }} 500 + </ul> 501 + {{ end }} 502 + </details> 503 + {{ else if and $isOpen .MergeCheck }} 504 + <div class="flex items-center gap-2"> 505 + {{ i "check" "w-4 h-4 text-green-600 dark:text-green-500" }} 506 + <span>no conflicts, ready to merge</span> 507 + </div> 508 + {{ end }} 509 + {{ end }} 510 + 217 511 {{ define "mergeStatus" }} 218 512 {{ 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"> 513 + <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 514 <div class="flex items-center gap-2 text-black dark:text-white"> 221 515 {{ i "ban" "w-4 h-4" }} 222 516 <span class="font-medium">closed without merging</span ··· 224 518 </div> 225 519 </div> 226 520 {{ 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"> 521 + <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative"> 228 522 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 229 523 {{ i "git-merge" "w-4 h-4" }} 230 524 <span class="font-medium">pull request successfully merged</span ··· 232 526 </div> 233 527 </div> 234 528 {{ 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"> 529 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative"> 236 530 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 237 531 {{ i "git-pull-request-closed" "w-4 h-4" }} 238 532 <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 239 533 </div> 240 534 </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 535 {{ end }} 282 536 {{ end }} 283 537 284 538 {{ define "resubmitStatus" }} 285 539 {{ 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"> 540 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative"> 287 541 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 288 542 {{ i "triangle-alert" "w-4 h-4" }} 289 543 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 299 553 {{ with $pipeline }} 300 554 {{ $id := .Id }} 301 555 {{ 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 }} 556 + <details> 557 + <summary class="cursor-pointer list-none">{{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }}</summary> 558 + <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"> 559 + {{ range $name, $all := .Statuses }} 560 + <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"> 561 + <div 562 + class="flex gap-2 items-center justify-between p-2"> 563 + {{ $lastStatus := $all.Latest }} 564 + {{ $kind := $lastStatus.Status.String }} 309 565 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> 566 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 567 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 568 + {{ $name }} 569 + </div> 570 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 571 + <span class="font-bold">{{ $kind }}</span> 572 + {{ if .TimeTaken }} 573 + {{ template "repo/fragments/duration" .TimeTaken }} 574 + {{ else }} 575 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 576 + {{ end }} 577 + </div> 578 + </div> 579 + </a> 580 + {{ end }} 322 581 </div> 323 - </a> 324 - {{ end }} 325 - </div> 582 + </details> 326 583 {{ end }} 327 584 {{ end }} 328 585 {{ end }}
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 136 136 {{ $pipeline := index $.Pipelines .LatestSha }} 137 137 {{ if and $pipeline $pipeline.Id }} 138 138 <span class="before:content-['ยท']"></span> 139 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 139 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 140 140 {{ end }} 141 141 142 142 {{ $state := .Labels }}
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 23 Choose a spindle to execute your workflows on. Only repository owners 24 24 can configure spindles. Spindles can be selfhosted, 25 - <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 26 26 click to learn more. 27 27 </a> 28 28 </p>
+1 -1
appview/pages/templates/spindles/index.html
··· 102 102 {{ define "docsButton" }} 103 103 <a 104 104 class="btn flex items-center gap-2" 105 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 105 + href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 106 106 {{ i "book" "size-4" }} 107 107 docs 108 108 </a>
+1 -1
appview/pages/templates/strings/string.html
··· 17 17 <span class="select-none">/</span> 18 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 19 </div> 20 - <div class="flex gap-2 text-base"> 20 + <div class="flex gap-2 items-stretch text-base"> 21 21 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 23 hx-boost="true"
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 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 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 13 </a> 14 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 16 {{ end }} 17 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+9 -6
appview/pages/templates/user/signup.html
··· 43 43 page to complete your registration. 44 44 </span> 45 45 <div class="w-full mt-4 text-center"> 46 - <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 47 47 </div> 48 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 49 <span>join now</span> 50 50 </button> 51 + <p class="text-sm text-gray-500"> 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 53 + </p> 54 + 55 + <p id="signup-msg" class="error w-full"></p> 56 + <p class="text-sm text-gray-500 pt-4"> 57 + By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>. 58 + </p> 51 59 </form> 52 - <p class="text-sm text-gray-500"> 53 - Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 - </p> 55 - 56 - <p id="signup-msg" class="error w-full"></p> 57 60 </main> 58 61 </body> 59 62 </html>
+1 -1
appview/pulls/opengraph.go
··· 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 }
+67 -38
appview/pulls/pulls.go
··· 232 232 defs[l.AtUri().String()] = &l 233 233 } 234 234 235 - s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 235 + patch := pull.LatestSubmission().CombinedPatch() 236 + diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 237 + var diffOpts types.DiffOpts 238 + if d := r.URL.Query().Get("diff"); d == "split" { 239 + diffOpts.Split = true 240 + } 241 + 242 + log.Println(s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 236 243 LoggedInUser: user, 237 244 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 238 245 Pull: pull, ··· 243 250 MergeCheck: mergeCheckResponse, 244 251 ResubmitCheck: resubmitResult, 245 252 Pipelines: m, 253 + Diff: &diff, 254 + DiffOpts: diffOpts, 246 255 247 256 OrderedReactionKinds: models.OrderedReactionKinds, 248 257 Reactions: reactionMap, 249 258 UserReacted: userReactions, 250 259 251 260 LabelDefs: defs, 252 - }) 261 + })) 253 262 } 254 263 255 264 func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { ··· 1241 1250 return 1242 1251 } 1243 1252 1253 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1254 + if err != nil { 1255 + log.Println("failed to upload patch", err) 1256 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1257 + return 1258 + } 1259 + 1244 1260 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1245 1261 Collection: tangled.RepoPullNSID, 1246 1262 Repo: user.Did, ··· 1252 1268 Repo: string(repo.RepoAt()), 1253 1269 Branch: targetBranch, 1254 1270 }, 1255 - Patch: patch, 1271 + PatchBlob: blob.Blob, 1256 1272 Source: recordPullSource, 1257 1273 CreatedAt: time.Now().Format(time.RFC3339), 1258 1274 }, ··· 1328 1344 // apply all record creations at once 1329 1345 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1330 1346 for _, p := range stack { 1347 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch())) 1348 + if err != nil { 1349 + log.Println("failed to upload patch blob", err) 1350 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1351 + return 1352 + } 1353 + 1331 1354 record := p.AsRecord() 1332 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1355 + record.PatchBlob = blob.Blob 1356 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1333 1357 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1334 1358 Collection: tangled.RepoPullNSID, 1335 1359 Rkey: &p.Rkey, ··· 1337 1361 Val: &record, 1338 1362 }, 1339 1363 }, 1340 - } 1341 - writes = append(writes, &write) 1364 + }) 1342 1365 } 1343 1366 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1344 1367 Repo: user.Did, ··· 1366 1389 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 1390 return 1368 1391 } 1392 + 1369 1393 } 1370 1394 1371 1395 if err = tx.Commit(); err != nil { 1372 1396 log.Println("failed to create pull request", err) 1373 1397 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1374 1398 return 1399 + } 1400 + 1401 + // notify about each pull 1402 + // 1403 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1404 + for _, p := range stack { 1405 + s.notifier.NewPull(r.Context(), p) 1375 1406 } 1376 1407 1377 1408 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) ··· 1863 1894 return 1864 1895 } 1865 1896 1866 - var recordPullSource *tangled.RepoPull_Source 1867 - if pull.IsBranchBased() { 1868 - recordPullSource = &tangled.RepoPull_Source{ 1869 - Branch: pull.PullSource.Branch, 1870 - Sha: sourceRev, 1871 - } 1897 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1898 + if err != nil { 1899 + log.Println("failed to upload patch blob", err) 1900 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1901 + return 1872 1902 } 1873 - if pull.IsForkBased() { 1874 - repoAt := pull.PullSource.RepoAt.String() 1875 - recordPullSource = &tangled.RepoPull_Source{ 1876 - Branch: pull.PullSource.Branch, 1877 - Repo: &repoAt, 1878 - Sha: sourceRev, 1879 - } 1880 - } 1903 + record := pull.AsRecord() 1904 + record.PatchBlob = blob.Blob 1905 + record.CreatedAt = time.Now().Format(time.RFC3339) 1881 1906 1882 1907 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1883 1908 Collection: tangled.RepoPullNSID, ··· 1885 1910 Rkey: pull.Rkey, 1886 1911 SwapRecord: ex.Cid, 1887 1912 Record: &lexutil.LexiconTypeDecoder{ 1888 - Val: &tangled.RepoPull{ 1889 - Title: pull.Title, 1890 - Target: &tangled.RepoPull_Target{ 1891 - Repo: string(repo.RepoAt()), 1892 - Branch: pull.TargetBranch, 1893 - }, 1894 - Patch: patch, // new patch 1895 - Source: recordPullSource, 1896 - CreatedAt: time.Now().Format(time.RFC3339), 1897 - }, 1913 + Val: &record, 1898 1914 }, 1899 1915 }) 1900 1916 if err != nil { ··· 1980 1996 } 1981 1997 defer tx.Rollback() 1982 1998 1999 + client, err := s.oauth.AuthorizedClient(r) 2000 + if err != nil { 2001 + log.Println("failed to authorize client") 2002 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2003 + return 2004 + } 2005 + 1983 2006 // pds updates to make 1984 2007 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1985 2008 ··· 2013 2036 return 2014 2037 } 2015 2038 2039 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2040 + if err != nil { 2041 + log.Println("failed to upload patch blob", err) 2042 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2043 + return 2044 + } 2016 2045 record := p.AsRecord() 2046 + record.PatchBlob = blob.Blob 2017 2047 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2018 2048 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2019 2049 Collection: tangled.RepoPullNSID, ··· 2048 2078 return 2049 2079 } 2050 2080 2081 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2082 + if err != nil { 2083 + log.Println("failed to upload patch blob", err) 2084 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2085 + return 2086 + } 2051 2087 record := np.AsRecord() 2052 - 2088 + record.PatchBlob = blob.Blob 2053 2089 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2054 2090 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2055 2091 Collection: tangled.RepoPullNSID, ··· 2083 2119 if err != nil { 2084 2120 log.Println("failed to resubmit pull", err) 2085 2121 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2086 - return 2087 - } 2088 - 2089 - client, err := s.oauth.AuthorizedClient(r) 2090 - if err != nil { 2091 - log.Println("failed to authorize client") 2092 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2093 2122 return 2094 2123 } 2095 2124
+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)
+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 }
+26 -1
appview/reporesolver/resolver.go
··· 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() ··· 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 + }
-12
appview/service/issue/errors.go
··· 1 - package issue 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrUnAuthenticated = errors.New("user session missing") 7 - ErrForbidden = errors.New("unauthorized operation") 8 - ErrDatabaseFail = errors.New("db op fail") 9 - ErrPDSFail = errors.New("pds op fail") 10 - ErrIndexerFail = errors.New("indexer fail") 11 - ErrValidationFail = errors.New("issue validation fail") 12 - )
-275
appview/service/issue/issue.go
··· 1 - package issue 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - "time" 7 - 8 - "github.com/bluesky-social/indigo/api/atproto" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "tangled.org/core/api/tangled" 12 - "tangled.org/core/appview/config" 13 - "tangled.org/core/appview/db" 14 - issues_indexer "tangled.org/core/appview/indexer/issues" 15 - "tangled.org/core/appview/mentions" 16 - "tangled.org/core/appview/models" 17 - "tangled.org/core/appview/notify" 18 - "tangled.org/core/appview/session" 19 - "tangled.org/core/appview/validator" 20 - "tangled.org/core/idresolver" 21 - "tangled.org/core/orm" 22 - "tangled.org/core/rbac" 23 - "tangled.org/core/tid" 24 - ) 25 - 26 - type Service struct { 27 - config *config.Config 28 - db *db.DB 29 - enforcer *rbac.Enforcer 30 - indexer *issues_indexer.Indexer 31 - logger *slog.Logger 32 - notifier notify.Notifier 33 - idResolver *idresolver.Resolver 34 - refResolver *mentions.Resolver 35 - validator *validator.Validator 36 - } 37 - 38 - func NewService( 39 - logger *slog.Logger, 40 - config *config.Config, 41 - db *db.DB, 42 - enforcer *rbac.Enforcer, 43 - notifier notify.Notifier, 44 - idResolver *idresolver.Resolver, 45 - refResolver *mentions.Resolver, 46 - indexer *issues_indexer.Indexer, 47 - validator *validator.Validator, 48 - ) Service { 49 - return Service{ 50 - config, 51 - db, 52 - enforcer, 53 - indexer, 54 - logger, 55 - notifier, 56 - idResolver, 57 - refResolver, 58 - validator, 59 - } 60 - } 61 - 62 - func (s *Service) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) { 63 - l := s.logger.With("method", "NewIssue") 64 - sess := session.FromContext(ctx) 65 - if sess == nil { 66 - l.Error("user session is missing in context") 67 - return nil, ErrForbidden 68 - } 69 - authorDid := sess.Data.AccountDID 70 - l = l.With("did", authorDid) 71 - 72 - mentions, references := s.refResolver.Resolve(ctx, body) 73 - 74 - issue := models.Issue{ 75 - RepoAt: repo.RepoAt(), 76 - Rkey: tid.TID(), 77 - Title: title, 78 - Body: body, 79 - Open: true, 80 - Did: authorDid.String(), 81 - Created: time.Now(), 82 - Mentions: mentions, 83 - References: references, 84 - Repo: repo, 85 - } 86 - 87 - if err := s.validator.ValidateIssue(&issue); err != nil { 88 - l.Error("validation error", "err", err) 89 - return nil, ErrValidationFail 90 - } 91 - 92 - tx, err := s.db.BeginTx(ctx, nil) 93 - if err != nil { 94 - l.Error("db.BeginTx failed", "err", err) 95 - return nil, ErrDatabaseFail 96 - } 97 - defer tx.Rollback() 98 - 99 - if err := db.PutIssue(tx, &issue); err != nil { 100 - l.Error("db.PutIssue failed", "err", err) 101 - return nil, ErrDatabaseFail 102 - } 103 - 104 - atpclient := sess.APIClient() 105 - record := issue.AsRecord() 106 - _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 107 - Repo: authorDid.String(), 108 - Collection: tangled.RepoIssueNSID, 109 - Rkey: issue.Rkey, 110 - Record: &lexutil.LexiconTypeDecoder{ 111 - Val: &record, 112 - }, 113 - }) 114 - if err != nil { 115 - l.Error("atproto.RepoPutRecord failed", "err", err) 116 - return nil, ErrPDSFail 117 - } 118 - if err = tx.Commit(); err != nil { 119 - l.Error("tx.Commit failed", "err", err) 120 - return nil, ErrDatabaseFail 121 - } 122 - 123 - s.notifier.NewIssue(ctx, &issue, mentions) 124 - return &issue, nil 125 - } 126 - 127 - func (s *Service) GetIssues(ctx context.Context, repo *models.Repo, searchOpts models.IssueSearchOptions) ([]models.Issue, error) { 128 - l := s.logger.With("method", "GetIssues") 129 - 130 - var issues []models.Issue 131 - var err error 132 - if searchOpts.Keyword != "" { 133 - res, err := s.indexer.Search(ctx, searchOpts) 134 - if err != nil { 135 - l.Error("failed to search for issues", "err", err) 136 - return nil, ErrIndexerFail 137 - } 138 - l.Debug("searched issues with indexer", "count", len(res.Hits)) 139 - issues, err = db.GetIssues(s.db, orm.FilterIn("id", res.Hits)) 140 - if err != nil { 141 - l.Error("failed to get issues", "err", err) 142 - return nil, ErrDatabaseFail 143 - } 144 - } else { 145 - openInt := 0 146 - if searchOpts.IsOpen { 147 - openInt = 1 148 - } 149 - issues, err = db.GetIssuesPaginated( 150 - s.db, 151 - searchOpts.Page, 152 - orm.FilterEq("repo_at", repo.RepoAt()), 153 - orm.FilterEq("open", openInt), 154 - ) 155 - if err != nil { 156 - l.Error("failed to get issues", "err", err) 157 - return nil, ErrDatabaseFail 158 - } 159 - } 160 - 161 - return issues, nil 162 - } 163 - 164 - func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error { 165 - l := s.logger.With("method", "EditIssue") 166 - sess := session.FromContext(ctx) 167 - if sess == nil { 168 - l.Error("user session is missing in context") 169 - return ErrForbidden 170 - } 171 - sessDid := sess.Data.AccountDID 172 - l = l.With("did", sessDid) 173 - 174 - mentions, references := s.refResolver.Resolve(ctx, issue.Body) 175 - issue.Mentions = mentions 176 - issue.References = references 177 - 178 - if sessDid != syntax.DID(issue.Did) { 179 - l.Error("only author can edit the issue") 180 - return ErrForbidden 181 - } 182 - 183 - if err := s.validator.ValidateIssue(issue); err != nil { 184 - l.Error("validation error", "err", err) 185 - return ErrValidationFail 186 - } 187 - 188 - tx, err := s.db.BeginTx(ctx, nil) 189 - if err != nil { 190 - l.Error("db.BeginTx failed", "err", err) 191 - return ErrDatabaseFail 192 - } 193 - defer tx.Rollback() 194 - 195 - if err := db.PutIssue(tx, issue); err != nil { 196 - l.Error("db.PutIssue failed", "err", err) 197 - return ErrDatabaseFail 198 - } 199 - 200 - atpclient := sess.APIClient() 201 - record := issue.AsRecord() 202 - 203 - ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 204 - if err != nil { 205 - l.Error("atproto.RepoGetRecord failed", "err", err) 206 - return ErrPDSFail 207 - } 208 - _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 209 - Collection: tangled.RepoIssueNSID, 210 - SwapRecord: ex.Cid, 211 - Record: &lexutil.LexiconTypeDecoder{ 212 - Val: &record, 213 - }, 214 - }) 215 - if err != nil { 216 - l.Error("atproto.RepoPutRecord failed", "err", err) 217 - return ErrPDSFail 218 - } 219 - 220 - if err = tx.Commit(); err != nil { 221 - l.Error("tx.Commit failed", "err", err) 222 - return ErrDatabaseFail 223 - } 224 - 225 - // TODO: notify PutIssue 226 - 227 - return nil 228 - } 229 - 230 - func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error { 231 - l := s.logger.With("method", "DeleteIssue") 232 - sess := session.FromContext(ctx) 233 - if sess == nil { 234 - l.Error("user session is missing in context") 235 - return ErrForbidden 236 - } 237 - sessDid := sess.Data.AccountDID 238 - l = l.With("did", sessDid) 239 - 240 - if sessDid != syntax.DID(issue.Did) { 241 - l.Error("only author can edit the issue") 242 - return ErrForbidden 243 - } 244 - 245 - tx, err := s.db.BeginTx(ctx, nil) 246 - if err != nil { 247 - l.Error("db.BeginTx failed", "err", err) 248 - return ErrDatabaseFail 249 - } 250 - defer tx.Rollback() 251 - 252 - if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 253 - l.Error("db.DeleteIssues failed", "err", err) 254 - return ErrDatabaseFail 255 - } 256 - 257 - atpclient := sess.APIClient() 258 - _, err = atproto.RepoDeleteRecord(ctx, atpclient, &atproto.RepoDeleteRecord_Input{ 259 - Collection: tangled.RepoIssueNSID, 260 - Repo: issue.Did, 261 - Rkey: issue.Rkey, 262 - }) 263 - if err != nil { 264 - l.Error("atproto.RepoDeleteRecord failed", "err", err) 265 - return ErrPDSFail 266 - } 267 - 268 - if err := tx.Commit(); err != nil { 269 - l.Error("tx.Commit failed", "err", err) 270 - return ErrDatabaseFail 271 - } 272 - 273 - s.notifier.DeleteIssue(ctx, issue) 274 - return nil 275 - }
-84
appview/service/issue/state.go
··· 1 - package issue 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/atproto/syntax" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/pages/repoinfo" 10 - "tangled.org/core/appview/session" 11 - "tangled.org/core/orm" 12 - ) 13 - 14 - func (s *Service) CloseIssue(ctx context.Context, issue *models.Issue) error { 15 - l := s.logger.With("method", "CloseIssue") 16 - sess := session.FromContext(ctx) 17 - if sess == nil { 18 - l.Error("user session is missing in context") 19 - return ErrUnAuthenticated 20 - } 21 - sessDid := sess.Data.AccountDID 22 - l = l.With("did", sessDid) 23 - 24 - // TODO: make this more granular 25 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} 26 - isRepoOwner := roles.IsOwner() 27 - isCollaborator := roles.IsCollaborator() 28 - isIssueOwner := sessDid == syntax.DID(issue.Did) 29 - if !(isRepoOwner || isCollaborator || isIssueOwner) { 30 - l.Error("user is not authorized") 31 - return ErrForbidden 32 - } 33 - 34 - err := db.CloseIssues( 35 - s.db, 36 - orm.FilterEq("id", issue.Id), 37 - ) 38 - if err != nil { 39 - l.Error("db.CloseIssues failed", "err", err) 40 - return ErrDatabaseFail 41 - } 42 - 43 - // change the issue state (this will pass down to the notifiers) 44 - issue.Open = false 45 - 46 - s.notifier.NewIssueState(ctx, sessDid, issue) 47 - return nil 48 - } 49 - 50 - func (s *Service) ReopenIssue(ctx context.Context, issue *models.Issue) error { 51 - l := s.logger.With("method", "ReopenIssue") 52 - sess := session.FromContext(ctx) 53 - if sess == nil { 54 - l.Error("user session is missing in context") 55 - return ErrUnAuthenticated 56 - } 57 - sessDid := sess.Data.AccountDID 58 - l = l.With("did", sessDid) 59 - 60 - // TODO: make this more granular 61 - roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} 62 - isRepoOwner := roles.IsOwner() 63 - isCollaborator := roles.IsCollaborator() 64 - isIssueOwner := sessDid == syntax.DID(issue.Did) 65 - if !(isRepoOwner || isCollaborator || isIssueOwner) { 66 - l.Error("user is not authorized") 67 - return ErrForbidden 68 - } 69 - 70 - err := db.ReopenIssues( 71 - s.db, 72 - orm.FilterEq("id", issue.Id), 73 - ) 74 - if err != nil { 75 - l.Error("db.ReopenIssues failed", "err", err) 76 - return ErrDatabaseFail 77 - } 78 - 79 - // change the issue state (this will pass down to the notifiers) 80 - issue.Open = true 81 - 82 - s.notifier.NewIssueState(ctx, sessDid, issue) 83 - return nil 84 - }
-11
appview/service/repo/errors.go
··· 1 - package repo 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrUnAuthenticated = errors.New("user session missing") 7 - ErrForbidden = errors.New("unauthorized operation") 8 - ErrDatabaseFail = errors.New("db op fail") 9 - ErrPDSFail = errors.New("pds op fail") 10 - ErrValidationFail = errors.New("repo validation fail") 11 - )
-89
appview/service/repo/repo.go
··· 1 - package repo 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - "time" 7 - 8 - "github.com/bluesky-social/indigo/api/atproto" 9 - "tangled.org/core/api/tangled" 10 - "tangled.org/core/appview/config" 11 - "tangled.org/core/appview/db" 12 - "tangled.org/core/appview/models" 13 - "tangled.org/core/appview/session" 14 - "tangled.org/core/rbac" 15 - "tangled.org/core/tid" 16 - ) 17 - 18 - type Service struct { 19 - logger *slog.Logger 20 - config *config.Config 21 - db *db.DB 22 - enforcer *rbac.Enforcer 23 - } 24 - 25 - func NewService( 26 - logger *slog.Logger, 27 - config *config.Config, 28 - db *db.DB, 29 - enforcer *rbac.Enforcer, 30 - ) Service { 31 - return Service{ 32 - logger, 33 - config, 34 - db, 35 - enforcer, 36 - } 37 - } 38 - 39 - // NewRepo creates a repository 40 - // It expects atproto session to be passed in `ctx` 41 - func (s *Service) NewRepo(ctx context.Context, name, description, knot string) (*models.Repo, error) { 42 - l := s.logger.With("method", "NewRepo") 43 - sess := session.FromContext(ctx) 44 - if sess == nil { 45 - l.Error("user session is missing in context") 46 - return nil, ErrForbidden 47 - } 48 - 49 - ownerDid := sess.Data.AccountDID 50 - l = l.With("did", ownerDid) 51 - 52 - repo := models.Repo{ 53 - Did: ownerDid.String(), 54 - Name: name, 55 - Knot: knot, 56 - Rkey: tid.TID(), 57 - Description: description, 58 - Created: time.Now(), 59 - Labels: s.config.Label.DefaultLabelDefs, 60 - } 61 - l = l.With("aturi", repo.RepoAt()) 62 - 63 - tx, err := s.db.BeginTx(ctx, nil) 64 - if err != nil { 65 - l.Error("db.BeginTx failed", "err", err) 66 - return nil, ErrDatabaseFail 67 - } 68 - defer tx.Rollback() 69 - 70 - if err = db.AddRepo(tx, &repo); err != nil { 71 - l.Error("db.AddRepo failed", "err", err) 72 - return nil, ErrDatabaseFail 73 - } 74 - 75 - atpclient := sess.APIClient() 76 - _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 77 - Collection: tangled.RepoNSID, 78 - Repo: repo.Did, 79 - }) 80 - if err != nil { 81 - l.Error("atproto.RepoPutRecord failed", "err", err) 82 - return nil, ErrPDSFail 83 - } 84 - l.Info("wrote to PDS") 85 - 86 - // knotclient, err := s.oauth.ServiceClient( 87 - // ) 88 - panic("unimplemented") 89 - }
-90
appview/service/repo/repoinfo.go
··· 1 - package repo 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/atproto/identity" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/oauth" 10 - "tangled.org/core/appview/pages/repoinfo" 11 - ) 12 - 13 - // GetRepoInfo converts given `Repo` to `RepoInfo` object. 14 - // The `user` can be nil. 15 - // NOTE: RepoInfo is bad design and should be removed in future. 16 - // avoid using this method if you can. 17 - func (s *Service) GetRepoInfo( 18 - ctx context.Context, 19 - ownerId *identity.Identity, 20 - baseRepo *models.Repo, 21 - currentDir, ref string, 22 - user *oauth.User, 23 - ) (*repoinfo.RepoInfo, error) { 24 - var ( 25 - repoAt = baseRepo.RepoAt() 26 - isStarred = false 27 - roles = repoinfo.RolesInRepo{} 28 - ) 29 - if user != nil { 30 - isStarred = db.GetStarStatus(s.db, user.Did, repoAt) 31 - roles.Roles = s.enforcer.GetPermissionsInRepo(user.Did, baseRepo.Knot, baseRepo.DidSlashRepo()) 32 - } 33 - 34 - stats := baseRepo.RepoStats 35 - if stats == nil { 36 - starCount, err := db.GetStarCount(s.db, repoAt) 37 - if err != nil { 38 - return nil, err 39 - } 40 - issueCount, err := db.GetIssueCount(s.db, repoAt) 41 - if err != nil { 42 - return nil, err 43 - } 44 - pullCount, err := db.GetPullCount(s.db, repoAt) 45 - if err != nil { 46 - return nil, err 47 - } 48 - stats = &models.RepoStats{ 49 - StarCount: starCount, 50 - IssueCount: issueCount, 51 - PullCount: pullCount, 52 - } 53 - } 54 - 55 - var sourceRepo *models.Repo 56 - var err error 57 - if baseRepo.Source != "" { 58 - sourceRepo, err = db.GetRepoByAtUri(s.db, baseRepo.Source) 59 - if err != nil { 60 - return nil, err 61 - } 62 - } 63 - 64 - repoInfo := &repoinfo.RepoInfo{ 65 - // ok this is basically a models.Repo 66 - OwnerDid: baseRepo.Did, 67 - OwnerHandle: ownerId.Handle.String(), // TODO: shouldn't use 68 - Name: baseRepo.Name, 69 - Rkey: baseRepo.Rkey, 70 - Description: baseRepo.Description, 71 - Website: baseRepo.Website, 72 - Topics: baseRepo.Topics, 73 - Knot: baseRepo.Knot, 74 - Spindle: baseRepo.Spindle, 75 - Stats: *stats, 76 - 77 - // fork repo upstream 78 - Source: sourceRepo, 79 - 80 - // repo path (context) 81 - CurrentDir: currentDir, 82 - Ref: ref, 83 - 84 - // info related to the session 85 - IsStarred: isStarred, 86 - Roles: roles, 87 - } 88 - 89 - return repoInfo, nil 90 - }
-29
appview/session/context.go
··· 1 - package session 2 - 3 - import ( 4 - "context" 5 - 6 - toauth "tangled.org/core/appview/oauth" 7 - ) 8 - 9 - type ctxKey struct{} 10 - 11 - func IntoContext(ctx context.Context, sess Session) context.Context { 12 - return context.WithValue(ctx, ctxKey{}, &sess) 13 - } 14 - 15 - func FromContext(ctx context.Context) *Session { 16 - sess, ok := ctx.Value(ctxKey{}).(*Session) 17 - if !ok { 18 - return nil 19 - } 20 - return sess 21 - } 22 - 23 - func UserFromContext(ctx context.Context) *toauth.User { 24 - sess := FromContext(ctx) 25 - if sess == nil { 26 - return nil 27 - } 28 - return sess.User() 29 - }
-24
appview/session/session.go
··· 1 - package session 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 5 - toauth "tangled.org/core/appview/oauth" 6 - ) 7 - 8 - // Session is a lightweight wrapper over indigo-oauth ClientSession 9 - type Session struct { 10 - *oauth.ClientSession 11 - } 12 - 13 - func New(atSess *oauth.ClientSession) Session { 14 - return Session{ 15 - atSess, 16 - } 17 - } 18 - 19 - func (s *Session) User() *toauth.User { 20 - return &toauth.User{ 21 - Did: string(s.Data.AccountDID), 22 - Pds: s.Data.HostURL, 23 - } 24 - }
-5
appview/spindles/spindles.go
··· 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 {
+17
appview/state/git_http.go
··· 25 25 26 26 } 27 27 28 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 + if !ok { 31 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 + return 33 + } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 + 36 + scheme := "https" 37 + if s.config.Core.Dev { 38 + scheme = "http" 39 + } 40 + 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 + s.proxyRequest(w, r, targetURL) 43 + } 44 + 28 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 29 46 user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 47 if !ok {
-66
appview/state/legacy_bridge.go
··· 1 - package state 2 - 3 - import ( 4 - "log/slog" 5 - 6 - "tangled.org/core/appview/config" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/indexer" 9 - "tangled.org/core/appview/issues" 10 - "tangled.org/core/appview/mentions" 11 - "tangled.org/core/appview/middleware" 12 - "tangled.org/core/appview/notify" 13 - "tangled.org/core/appview/oauth" 14 - "tangled.org/core/appview/pages" 15 - "tangled.org/core/appview/validator" 16 - "tangled.org/core/idresolver" 17 - "tangled.org/core/log" 18 - "tangled.org/core/rbac" 19 - ) 20 - 21 - // Expose exposes private fields in `State`. This is used to bridge between 22 - // legacy web routers and new architecture 23 - func (s *State) Expose() ( 24 - *config.Config, 25 - *db.DB, 26 - *rbac.Enforcer, 27 - *idresolver.Resolver, 28 - *mentions.Resolver, 29 - *indexer.Indexer, 30 - *slog.Logger, 31 - notify.Notifier, 32 - *oauth.OAuth, 33 - *pages.Pages, 34 - *validator.Validator, 35 - ) { 36 - return s.config, s.db, s.enforcer, s.idResolver, s.mentionsResolver, s.indexer, s.logger, s.notifier, s.oauth, s.pages, s.validator 37 - } 38 - 39 - func (s *State) ExposeIssue() *issues.Issues { 40 - return issues.New( 41 - s.oauth, 42 - s.repoResolver, 43 - s.enforcer, 44 - s.pages, 45 - s.idResolver, 46 - s.mentionsResolver, 47 - s.db, 48 - s.config, 49 - s.notifier, 50 - s.validator, 51 - s.indexer.Issues, 52 - log.SubLogger(s.logger, "issues"), 53 - ) 54 - } 55 - 56 - func (s *State) Middleware() *middleware.Middleware { 57 - mw := middleware.New( 58 - s.oauth, 59 - s.db, 60 - s.enforcer, 61 - s.repoResolver, 62 - s.idResolver, 63 - s.pages, 64 - ) 65 - return &mw 66 - }
+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 + }
+6 -4
appview/state/profile.go
··· 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
+4 -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) ··· 101 99 102 100 // These routes get proxied to the knot 103 101 r.Get("/info/refs", s.InfoRefs) 102 + r.Post("/git-upload-archive", s.UploadArchive) 104 103 r.Post("/git-upload-pack", s.UploadPack) 105 104 r.Post("/git-receive-pack", s.ReceivePack) 106 105 ··· 108 107 }) 109 108 110 109 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 110 + w.WriteHeader(http.StatusNotFound) 111 111 s.pages.Error404(w) 112 112 }) 113 113 ··· 181 181 r.Get("/brand", s.Brand) 182 182 183 183 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 184 + w.WriteHeader(http.StatusNotFound) 184 185 s.pages.Error404(w) 185 186 }) 186 187 return r
-36
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 ··· 223 210 Allow: / 224 211 ` 225 212 w.Write([]byte(robotsTxt)) 226 - } 227 - 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 213 } 250 214 251 215 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
-23
appview/web/handler/oauth_client_metadata.go
··· 1 - package handler 2 - 3 - import ( 4 - "encoding/json" 5 - "net/http" 6 - 7 - "tangled.org/core/appview/oauth" 8 - ) 9 - 10 - func OauthClientMetadata(o *oauth.OAuth) http.HandlerFunc { 11 - return func(w http.ResponseWriter, r *http.Request) { 12 - doc := o.ClientApp.Config.ClientMetadata() 13 - doc.JWKSURI = &o.JwksUri 14 - doc.ClientName = &o.ClientName 15 - doc.ClientURI = &o.ClientUri 16 - 17 - w.Header().Set("Content-Type", "application/json") 18 - if err := json.NewEncoder(w).Encode(doc); err != nil { 19 - http.Error(w, err.Error(), http.StatusInternalServerError) 20 - return 21 - } 22 - } 23 - }
-19
appview/web/handler/oauth_jwks.go
··· 1 - package handler 2 - 3 - import ( 4 - "encoding/json" 5 - "net/http" 6 - 7 - "tangled.org/core/appview/oauth" 8 - ) 9 - 10 - func OauthJwks(o *oauth.OAuth) http.HandlerFunc { 11 - return func(w http.ResponseWriter, r *http.Request) { 12 - w.Header().Set("Content-Type", "application/json") 13 - body := o.ClientApp.Config.PublicJWKS() 14 - if err := json.NewEncoder(w).Encode(body); err != nil { 15 - http.Error(w, err.Error(), http.StatusInternalServerError) 16 - return 17 - } 18 - } 19 - }
-87
appview/web/handler/user_repo_issues.go
··· 1 - package handler 2 - 3 - import ( 4 - "net/http" 5 - 6 - "tangled.org/core/api/tangled" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/pages" 10 - "tangled.org/core/appview/pagination" 11 - isvc "tangled.org/core/appview/service/issue" 12 - rsvc "tangled.org/core/appview/service/repo" 13 - "tangled.org/core/appview/session" 14 - "tangled.org/core/appview/web/request" 15 - "tangled.org/core/log" 16 - "tangled.org/core/orm" 17 - ) 18 - 19 - func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 20 - return func(w http.ResponseWriter, r *http.Request) { 21 - ctx := r.Context() 22 - l := log.FromContext(ctx).With("handler", "RepoIssues") 23 - repo, ok := request.RepoFromContext(ctx) 24 - if !ok { 25 - l.Error("malformed request") 26 - p.Error503(w) 27 - return 28 - } 29 - repoOwnerId, ok := request.OwnerFromContext(ctx) 30 - if !ok { 31 - l.Error("malformed request") 32 - p.Error503(w) 33 - return 34 - } 35 - 36 - query := r.URL.Query() 37 - searchOpts := models.IssueSearchOptions{ 38 - RepoAt: repo.RepoAt().String(), 39 - Keyword: query.Get("q"), 40 - IsOpen: query.Get("state") != "closed", 41 - Page: pagination.FromContext(ctx), 42 - } 43 - 44 - issues, err := is.GetIssues(ctx, repo, searchOpts) 45 - if err != nil { 46 - l.Error("failed to get issues") 47 - p.Error503(w) 48 - return 49 - } 50 - 51 - // render page 52 - err = func() error { 53 - user := session.UserFromContext(ctx) 54 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, repo, "", "", user) 55 - if err != nil { 56 - return err 57 - } 58 - labelDefs, err := db.GetLabelDefinitions( 59 - d, 60 - orm.FilterIn("at_uri", repo.Labels), 61 - orm.FilterContains("scope", tangled.RepoIssueNSID), 62 - ) 63 - if err != nil { 64 - return err 65 - } 66 - defs := make(map[string]*models.LabelDefinition) 67 - for _, l := range labelDefs { 68 - defs[l.AtUri().String()] = &l 69 - } 70 - return p.RepoIssues(w, pages.RepoIssuesParams{ 71 - LoggedInUser: user, 72 - RepoInfo: *repoinfo, 73 - 74 - Issues: issues, 75 - LabelDefs: defs, 76 - FilteringByOpen: searchOpts.IsOpen, 77 - FilterQuery: searchOpts.Keyword, 78 - Page: searchOpts.Page, 79 - }) 80 - }() 81 - if err != nil { 82 - l.Error("failed to render", "err", err) 83 - p.Error503(w) 84 - return 85 - } 86 - } 87 - }
-115
appview/web/handler/user_repo_issues_issue.go
··· 1 - package handler 2 - 3 - import ( 4 - "net/http" 5 - 6 - "tangled.org/core/api/tangled" 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/pages" 10 - isvc "tangled.org/core/appview/service/issue" 11 - rsvc "tangled.org/core/appview/service/repo" 12 - "tangled.org/core/appview/session" 13 - "tangled.org/core/appview/web/request" 14 - "tangled.org/core/log" 15 - "tangled.org/core/orm" 16 - ) 17 - 18 - func Issue(s isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 19 - return func(w http.ResponseWriter, r *http.Request) { 20 - ctx := r.Context() 21 - l := log.FromContext(ctx).With("handler", "Issue") 22 - issue, ok := request.IssueFromContext(ctx) 23 - if !ok { 24 - l.Error("malformed request, failed to get issue") 25 - p.Error503(w) 26 - return 27 - } 28 - repoOwnerId, ok := request.OwnerFromContext(ctx) 29 - if !ok { 30 - l.Error("malformed request") 31 - p.Error503(w) 32 - return 33 - } 34 - 35 - // render 36 - err := func() error { 37 - user := session.UserFromContext(ctx) 38 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, issue.Repo, "", "", user) 39 - if err != nil { 40 - l.Error("failed to load repo", "err", err) 41 - return err 42 - } 43 - 44 - reactionMap, err := db.GetReactionMap(d, 20, issue.AtUri()) 45 - if err != nil { 46 - l.Error("failed to get issue reactions", "err", err) 47 - return err 48 - } 49 - 50 - userReactions := map[models.ReactionKind]bool{} 51 - if user != nil { 52 - userReactions = db.GetReactionStatusMap(d, user.Did, issue.AtUri()) 53 - } 54 - 55 - backlinks, err := db.GetBacklinks(d, issue.AtUri()) 56 - if err != nil { 57 - l.Error("failed to fetch backlinks", "err", err) 58 - return err 59 - } 60 - 61 - labelDefs, err := db.GetLabelDefinitions( 62 - d, 63 - orm.FilterIn("at_uri", issue.Repo.Labels), 64 - orm.FilterContains("scope", tangled.RepoIssueNSID), 65 - ) 66 - if err != nil { 67 - l.Error("failed to fetch label defs", "err", err) 68 - return err 69 - } 70 - 71 - defs := make(map[string]*models.LabelDefinition) 72 - for _, l := range labelDefs { 73 - defs[l.AtUri().String()] = &l 74 - } 75 - 76 - return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 77 - LoggedInUser: user, 78 - RepoInfo: *repoinfo, 79 - Issue: issue, 80 - CommentList: issue.CommentList(), 81 - Backlinks: backlinks, 82 - OrderedReactionKinds: models.OrderedReactionKinds, 83 - Reactions: reactionMap, 84 - UserReacted: userReactions, 85 - LabelDefs: defs, 86 - }) 87 - }() 88 - if err != nil { 89 - l.Error("failed to render", "err", err) 90 - p.Error503(w) 91 - return 92 - } 93 - } 94 - } 95 - 96 - func IssueDelete(s isvc.Service, p *pages.Pages) http.HandlerFunc { 97 - noticeId := "issue-actions-error" 98 - return func(w http.ResponseWriter, r *http.Request) { 99 - ctx := r.Context() 100 - l := log.FromContext(ctx).With("handler", "IssueDelete") 101 - issue, ok := request.IssueFromContext(ctx) 102 - if !ok { 103 - l.Error("failed to get issue") 104 - // TODO: 503 error with more detailed messages 105 - p.Error503(w) 106 - return 107 - } 108 - err := s.DeleteIssue(ctx, issue) 109 - if err != nil { 110 - p.Notice(w, noticeId, "failed to delete issue") 111 - return 112 - } 113 - p.HxLocation(w, "/") 114 - } 115 - }
-40
appview/web/handler/user_repo_issues_issue_close.go
··· 1 - package handler 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "net/http" 7 - 8 - "tangled.org/core/appview/pages" 9 - "tangled.org/core/appview/reporesolver" 10 - isvc "tangled.org/core/appview/service/issue" 11 - "tangled.org/core/appview/web/request" 12 - "tangled.org/core/log" 13 - ) 14 - 15 - func CloseIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 16 - noticeId := "issue-action" 17 - return func(w http.ResponseWriter, r *http.Request) { 18 - ctx := r.Context() 19 - l := log.FromContext(ctx).With("handler", "CloseIssue") 20 - issue, ok := request.IssueFromContext(ctx) 21 - if !ok { 22 - l.Error("malformed request, failed to get issue") 23 - p.Error503(w) 24 - return 25 - } 26 - 27 - err := is.CloseIssue(ctx, issue) 28 - if err != nil { 29 - if errors.Is(err, isvc.ErrForbidden) { 30 - http.Error(w, "forbidden", http.StatusUnauthorized) 31 - } else { 32 - p.Notice(w, noticeId, "Failed to close issue. Try again later.") 33 - } 34 - return 35 - } 36 - 37 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 38 - p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 39 - } 40 - }
-84
appview/web/handler/user_repo_issues_issue_edit.go
··· 1 - package handler 2 - 3 - import ( 4 - "errors" 5 - "net/http" 6 - 7 - "tangled.org/core/appview/pages" 8 - isvc "tangled.org/core/appview/service/issue" 9 - rsvc "tangled.org/core/appview/service/repo" 10 - "tangled.org/core/appview/session" 11 - "tangled.org/core/appview/web/request" 12 - "tangled.org/core/log" 13 - ) 14 - 15 - func IssueEdit(is isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 16 - return func(w http.ResponseWriter, r *http.Request) { 17 - ctx := r.Context() 18 - l := log.FromContext(ctx).With("handler", "IssueEdit") 19 - issue, ok := request.IssueFromContext(ctx) 20 - if !ok { 21 - l.Error("malformed request, failed to get issue") 22 - p.Error503(w) 23 - return 24 - } 25 - repoOwnerId, ok := request.OwnerFromContext(ctx) 26 - if !ok { 27 - l.Error("malformed request") 28 - p.Error503(w) 29 - return 30 - } 31 - 32 - // render 33 - err := func() error { 34 - user := session.UserFromContext(ctx) 35 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, issue.Repo, "", "", user) 36 - if err != nil { 37 - return err 38 - } 39 - return p.EditIssueFragment(w, pages.EditIssueParams{ 40 - LoggedInUser: user, 41 - RepoInfo: *repoinfo, 42 - 43 - Issue: issue, 44 - }) 45 - }() 46 - if err != nil { 47 - l.Error("failed to render", "err", err) 48 - p.Error503(w) 49 - return 50 - } 51 - } 52 - } 53 - 54 - func IssueEditPost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 55 - noticeId := "issues" 56 - return func(w http.ResponseWriter, r *http.Request) { 57 - ctx := r.Context() 58 - l := log.FromContext(ctx).With("handler", "IssueEdit") 59 - issue, ok := request.IssueFromContext(ctx) 60 - if !ok { 61 - l.Error("malformed request, failed to get issue") 62 - p.Error503(w) 63 - return 64 - } 65 - 66 - newIssue := *issue 67 - newIssue.Title = r.FormValue("title") 68 - newIssue.Body = r.FormValue("body") 69 - 70 - err := is.EditIssue(ctx, &newIssue) 71 - if err != nil { 72 - if errors.Is(err, isvc.ErrDatabaseFail) { 73 - p.Notice(w, noticeId, "Failed to edit issue.") 74 - } else if errors.Is(err, isvc.ErrPDSFail) { 75 - p.Notice(w, noticeId, "Failed to edit issue.") 76 - } else { 77 - p.Notice(w, noticeId, "Failed to edit issue.") 78 - } 79 - return 80 - } 81 - 82 - p.HxRefresh(w) 83 - } 84 - }
-40
appview/web/handler/user_repo_issues_issue_reopen.go
··· 1 - package handler 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "net/http" 7 - 8 - "tangled.org/core/appview/pages" 9 - "tangled.org/core/appview/reporesolver" 10 - isvc "tangled.org/core/appview/service/issue" 11 - "tangled.org/core/appview/web/request" 12 - "tangled.org/core/log" 13 - ) 14 - 15 - func ReopenIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 16 - noticeId := "issue-action" 17 - return func(w http.ResponseWriter, r *http.Request) { 18 - ctx := r.Context() 19 - l := log.FromContext(ctx).With("handler", "ReopenIssue") 20 - issue, ok := request.IssueFromContext(ctx) 21 - if !ok { 22 - l.Error("malformed request, failed to get issue") 23 - p.Error503(w) 24 - return 25 - } 26 - 27 - err := is.ReopenIssue(ctx, issue) 28 - if err != nil { 29 - if errors.Is(err, isvc.ErrForbidden) { 30 - http.Error(w, "forbidden", http.StatusUnauthorized) 31 - } else { 32 - p.Notice(w, noticeId, "Failed to reopen issue. Try again later.") 33 - } 34 - return 35 - } 36 - 37 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 38 - p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 39 - } 40 - }
-79
appview/web/handler/user_repo_issues_new.go
··· 1 - package handler 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "net/http" 7 - 8 - "tangled.org/core/appview/pages" 9 - isvc "tangled.org/core/appview/service/issue" 10 - rsvc "tangled.org/core/appview/service/repo" 11 - "tangled.org/core/appview/session" 12 - "tangled.org/core/appview/web/request" 13 - "tangled.org/core/log" 14 - ) 15 - 16 - func NewIssue(rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 17 - return func(w http.ResponseWriter, r *http.Request) { 18 - ctx := r.Context() 19 - l := log.FromContext(ctx).With("handler", "NewIssue") 20 - 21 - // render 22 - err := func() error { 23 - user := session.UserFromContext(ctx) 24 - repo, ok := request.RepoFromContext(ctx) 25 - if !ok { 26 - return fmt.Errorf("malformed request") 27 - } 28 - repoOwnerId, ok := request.OwnerFromContext(ctx) 29 - if !ok { 30 - return fmt.Errorf("malformed request") 31 - } 32 - repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, repo, "", "", user) 33 - if err != nil { 34 - return err 35 - } 36 - return p.RepoNewIssue(w, pages.RepoNewIssueParams{ 37 - LoggedInUser: user, 38 - RepoInfo: *repoinfo, 39 - }) 40 - }() 41 - if err != nil { 42 - l.Error("failed to render", "err", err) 43 - p.Error503(w) 44 - return 45 - } 46 - } 47 - } 48 - 49 - func NewIssuePost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 50 - noticeId := "issues" 51 - return func(w http.ResponseWriter, r *http.Request) { 52 - ctx := r.Context() 53 - l := log.FromContext(ctx).With("handler", "NewIssuePost") 54 - repo, ok := request.RepoFromContext(ctx) 55 - if !ok { 56 - l.Error("malformed request, failed to get repo") 57 - // TODO: 503 error with more detailed messages 58 - p.Error503(w) 59 - return 60 - } 61 - var ( 62 - title = r.FormValue("title") 63 - body = r.FormValue("body") 64 - ) 65 - 66 - _, err := is.NewIssue(ctx, repo, title, body) 67 - if err != nil { 68 - if errors.Is(err, isvc.ErrDatabaseFail) { 69 - p.Notice(w, noticeId, "Failed to create issue.") 70 - } else if errors.Is(err, isvc.ErrPDSFail) { 71 - p.Notice(w, noticeId, "Failed to create issue.") 72 - } else { 73 - p.Notice(w, noticeId, "Failed to create issue.") 74 - } 75 - return 76 - } 77 - p.HxLocation(w, "/") 78 - } 79 - }
-67
appview/web/middleware/auth.go
··· 1 - package middleware 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "net/url" 7 - 8 - "tangled.org/core/appview/oauth" 9 - "tangled.org/core/appview/session" 10 - "tangled.org/core/log" 11 - ) 12 - 13 - // WithSession resumes atp session from cookie, ensure it's not malformed and 14 - // pass the session through context 15 - func WithSession(o *oauth.OAuth) middlewareFunc { 16 - return func(next http.Handler) http.Handler { 17 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 - atSess, err := o.ResumeSession(r) 19 - if err != nil { 20 - next.ServeHTTP(w, r) 21 - return 22 - } 23 - 24 - sess := session.New(atSess) 25 - 26 - ctx := session.IntoContext(r.Context(), sess) 27 - next.ServeHTTP(w, r.WithContext(ctx)) 28 - }) 29 - } 30 - } 31 - 32 - // AuthMiddleware ensures the request is authorized and redirect to login page 33 - // when unauthorized 34 - func AuthMiddleware() middlewareFunc { 35 - return func(next http.Handler) http.Handler { 36 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 - ctx := r.Context() 38 - l := log.FromContext(ctx) 39 - 40 - returnURL := "/" 41 - if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 42 - returnURL = u.RequestURI() 43 - } 44 - 45 - loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 46 - 47 - redirectFunc := func(w http.ResponseWriter, r *http.Request) { 48 - http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 49 - } 50 - if r.Header.Get("HX-Request") == "true" { 51 - redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 52 - w.Header().Set("HX-Redirect", loginURL) 53 - w.WriteHeader(http.StatusOK) 54 - } 55 - } 56 - 57 - sess := session.FromContext(ctx) 58 - if sess == nil { 59 - l.Debug("no session, redirecting...") 60 - redirectFunc(w, r) 61 - return 62 - } 63 - 64 - next.ServeHTTP(w, r) 65 - }) 66 - } 67 - }
-27
appview/web/middleware/ensuredidorhandle.go
··· 1 - package middleware 2 - 3 - import ( 4 - "net/http" 5 - 6 - "github.com/go-chi/chi/v5" 7 - "tangled.org/core/appview/pages" 8 - "tangled.org/core/appview/state/userutil" 9 - ) 10 - 11 - // EnsureDidOrHandle ensures the "user" url param is valid did/handle format. 12 - // If not, respond with 404 13 - func EnsureDidOrHandle(p *pages.Pages) middlewareFunc { 14 - return func(next http.Handler) http.Handler { 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - user := chi.URLParam(r, "user") 17 - 18 - // if using a DID or handle, just continue as per usual 19 - if userutil.IsDid(user) || userutil.IsHandle(user) { 20 - next.ServeHTTP(w, r) 21 - return 22 - } 23 - 24 - p.Error404(w) 25 - }) 26 - } 27 - }
-18
appview/web/middleware/log.go
··· 1 - package middleware 2 - 3 - import ( 4 - "log/slog" 5 - "net/http" 6 - 7 - "tangled.org/core/log" 8 - ) 9 - 10 - func WithLogger(l *slog.Logger) middlewareFunc { 11 - return func(next http.Handler) http.Handler { 12 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 - // NOTE: can add some metadata here 14 - ctx := log.IntoContext(r.Context(), l) 15 - next.ServeHTTP(w, r.WithContext(ctx)) 16 - }) 17 - } 18 - }
-7
appview/web/middleware/middleware.go
··· 1 - package middleware 2 - 3 - import ( 4 - "net/http" 5 - ) 6 - 7 - type middlewareFunc func(http.Handler) http.Handler
-50
appview/web/middleware/normalize.go
··· 1 - package middleware 2 - 3 - import ( 4 - "net/http" 5 - "strings" 6 - 7 - "github.com/go-chi/chi/v5" 8 - "tangled.org/core/appview/state/userutil" 9 - ) 10 - 11 - func Normalize() middlewareFunc { 12 - return func(next http.Handler) http.Handler { 13 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 - pat := chi.URLParam(r, "*") 15 - pathParts := strings.SplitN(pat, "/", 2) 16 - if len(pathParts) == 0 { 17 - next.ServeHTTP(w, r) 18 - return 19 - } 20 - 21 - firstPart := pathParts[0] 22 - 23 - // if using a flattened DID (like you would in go modules), unflatten 24 - if userutil.IsFlattenedDid(firstPart) { 25 - unflattenedDid := userutil.UnflattenDid(firstPart) 26 - redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 27 - 28 - redirectURL := *r.URL 29 - redirectURL.Path = "/" + redirectPath 30 - 31 - http.Redirect(w, r, redirectURL.String(), http.StatusFound) 32 - return 33 - } 34 - 35 - // if using a handle with @, rewrite to work without @ 36 - if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 37 - redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 38 - 39 - redirectURL := *r.URL 40 - redirectURL.Path = "/" + redirectPath 41 - 42 - http.Redirect(w, r, redirectURL.String(), http.StatusFound) 43 - return 44 - } 45 - 46 - next.ServeHTTP(w, r) 47 - return 48 - }) 49 - } 50 - }
-38
appview/web/middleware/paginate.go
··· 1 - package middleware 2 - 3 - import ( 4 - "log" 5 - "net/http" 6 - "strconv" 7 - 8 - "tangled.org/core/appview/pagination" 9 - ) 10 - 11 - func Paginate(next http.Handler) http.Handler { 12 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 - page := pagination.FirstPage() 14 - 15 - offsetVal := r.URL.Query().Get("offset") 16 - if offsetVal != "" { 17 - offset, err := strconv.Atoi(offsetVal) 18 - if err != nil { 19 - log.Println("invalid offset") 20 - } else { 21 - page.Offset = offset 22 - } 23 - } 24 - 25 - limitVal := r.URL.Query().Get("limit") 26 - if limitVal != "" { 27 - limit, err := strconv.Atoi(limitVal) 28 - if err != nil { 29 - log.Println("invalid limit") 30 - } else { 31 - page.Limit = limit 32 - } 33 - } 34 - 35 - ctx := pagination.IntoContext(r.Context(), page) 36 - next.ServeHTTP(w, r.WithContext(ctx)) 37 - }) 38 - }
-121
appview/web/middleware/resolve.go
··· 1 - package middleware 2 - 3 - import ( 4 - "context" 5 - "net/http" 6 - "strconv" 7 - "strings" 8 - 9 - "github.com/go-chi/chi/v5" 10 - "tangled.org/core/appview/db" 11 - "tangled.org/core/appview/pages" 12 - "tangled.org/core/appview/web/request" 13 - "tangled.org/core/idresolver" 14 - "tangled.org/core/log" 15 - "tangled.org/core/orm" 16 - ) 17 - 18 - func ResolveIdent( 19 - idResolver *idresolver.Resolver, 20 - pages *pages.Pages, 21 - ) middlewareFunc { 22 - return func(next http.Handler) http.Handler { 23 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 - ctx := r.Context() 25 - l := log.FromContext(ctx) 26 - didOrHandle := chi.URLParam(r, "user") 27 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 28 - 29 - id, err := idResolver.ResolveIdent(ctx, didOrHandle) 30 - if err != nil { 31 - // invalid did or handle 32 - l.Warn("failed to resolve did/handle", "handle", didOrHandle, "err", err) 33 - pages.Error404(w) 34 - return 35 - } 36 - 37 - ctx = request.WithOwner(ctx, id) 38 - // TODO: reomove this later 39 - ctx = context.WithValue(ctx, "resolvedId", *id) 40 - 41 - next.ServeHTTP(w, r.WithContext(ctx)) 42 - }) 43 - } 44 - } 45 - 46 - func ResolveRepo( 47 - e *db.DB, 48 - pages *pages.Pages, 49 - ) middlewareFunc { 50 - return func(next http.Handler) http.Handler { 51 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 - ctx := r.Context() 53 - l := log.FromContext(ctx) 54 - repoName := chi.URLParam(r, "repo") 55 - repoOwner, ok := request.OwnerFromContext(ctx) 56 - if !ok { 57 - l.Error("malformed middleware") 58 - w.WriteHeader(http.StatusInternalServerError) 59 - return 60 - } 61 - 62 - repo, err := db.GetRepo( 63 - e, 64 - orm.FilterEq("did", repoOwner.DID.String()), 65 - orm.FilterEq("name", repoName), 66 - ) 67 - if err != nil { 68 - l.Warn("failed to resolve repo", "err", err) 69 - pages.ErrorKnot404(w) 70 - return 71 - } 72 - 73 - // TODO: pass owner id into repository object 74 - 75 - ctx = request.WithRepo(ctx, repo) 76 - // TODO: reomove this later 77 - ctx = context.WithValue(ctx, "repo", repo) 78 - 79 - next.ServeHTTP(w, r.WithContext(ctx)) 80 - }) 81 - } 82 - } 83 - 84 - func ResolveIssue( 85 - e *db.DB, 86 - pages *pages.Pages, 87 - ) middlewareFunc { 88 - return func(next http.Handler) http.Handler { 89 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 - ctx := r.Context() 91 - l := log.FromContext(ctx) 92 - issueIdStr := chi.URLParam(r, "issue") 93 - issueId, err := strconv.Atoi(issueIdStr) 94 - if err != nil { 95 - l.Warn("failed to fully resolve issue ID", "err", err) 96 - pages.Error404(w) 97 - return 98 - } 99 - repo, ok := request.RepoFromContext(ctx) 100 - if !ok { 101 - l.Error("malformed middleware") 102 - w.WriteHeader(http.StatusInternalServerError) 103 - return 104 - } 105 - 106 - issue, err := db.GetIssue(e, repo.RepoAt(), issueId) 107 - if err != nil { 108 - l.Warn("failed to resolve issue", "err", err) 109 - pages.ErrorKnot404(w) 110 - return 111 - } 112 - issue.Repo = repo 113 - 114 - ctx = request.WithIssue(ctx, issue) 115 - // TODO: reomove this later 116 - ctx = context.WithValue(ctx, "issue", issue) 117 - 118 - next.ServeHTTP(w, r.WithContext(ctx)) 119 - }) 120 - } 121 - }
-39
appview/web/request/context.go
··· 1 - package request 2 - 3 - import ( 4 - "context" 5 - 6 - "github.com/bluesky-social/indigo/atproto/identity" 7 - "tangled.org/core/appview/models" 8 - ) 9 - 10 - type ctxKeyOwner struct{} 11 - type ctxKeyRepo struct{} 12 - type ctxKeyIssue struct{} 13 - 14 - func WithOwner(ctx context.Context, owner *identity.Identity) context.Context { 15 - return context.WithValue(ctx, ctxKeyOwner{}, owner) 16 - } 17 - 18 - func OwnerFromContext(ctx context.Context) (*identity.Identity, bool) { 19 - owner, ok := ctx.Value(ctxKeyOwner{}).(*identity.Identity) 20 - return owner, ok 21 - } 22 - 23 - func WithRepo(ctx context.Context, repo *models.Repo) context.Context { 24 - return context.WithValue(ctx, ctxKeyRepo{}, repo) 25 - } 26 - 27 - func RepoFromContext(ctx context.Context) (*models.Repo, bool) { 28 - repo, ok := ctx.Value(ctxKeyRepo{}).(*models.Repo) 29 - return repo, ok 30 - } 31 - 32 - func WithIssue(ctx context.Context, issue *models.Issue) context.Context { 33 - return context.WithValue(ctx, ctxKeyIssue{}, issue) 34 - } 35 - 36 - func IssueFromContext(ctx context.Context) (*models.Issue, bool) { 37 - issue, ok := ctx.Value(ctxKeyIssue{}).(*models.Issue) 38 - return issue, ok 39 - }
-215
appview/web/routes.go
··· 1 - package web 2 - 3 - import ( 4 - "log/slog" 5 - "net/http" 6 - 7 - "github.com/go-chi/chi/v5" 8 - "tangled.org/core/appview/config" 9 - "tangled.org/core/appview/db" 10 - "tangled.org/core/appview/indexer" 11 - "tangled.org/core/appview/mentions" 12 - "tangled.org/core/appview/notify" 13 - "tangled.org/core/appview/oauth" 14 - "tangled.org/core/appview/pages" 15 - isvc "tangled.org/core/appview/service/issue" 16 - rsvc "tangled.org/core/appview/service/repo" 17 - "tangled.org/core/appview/state" 18 - "tangled.org/core/appview/validator" 19 - "tangled.org/core/appview/web/handler" 20 - "tangled.org/core/appview/web/middleware" 21 - "tangled.org/core/idresolver" 22 - "tangled.org/core/rbac" 23 - ) 24 - 25 - // Rules 26 - // - Use single function for each endpoints (unless it doesn't make sense.) 27 - // - Name handler files following the related path (ancestor paths can be 28 - // trimmed.) 29 - // - Pass dependencies to each handlers, don't create structs with shared 30 - // dependencies unless it serves some domain-specific roles like 31 - // service/issue. Same rule goes to middlewares. 32 - 33 - // RouterFromState creates a web router from `state.State`. This exist to 34 - // bridge between legacy web routers under `State` and new architecture 35 - func RouterFromState(s *state.State) http.Handler { 36 - config, db, enforcer, idResolver, refResolver, indexer, logger, notifier, oauth, pages, validator := s.Expose() 37 - 38 - return Router( 39 - logger, 40 - config, 41 - db, 42 - enforcer, 43 - idResolver, 44 - refResolver, 45 - indexer, 46 - notifier, 47 - oauth, 48 - pages, 49 - validator, 50 - s, 51 - ) 52 - } 53 - 54 - func Router( 55 - // NOTE: put base dependencies (db, idResolver, oauth etc) 56 - logger *slog.Logger, 57 - config *config.Config, 58 - db *db.DB, 59 - enforcer *rbac.Enforcer, 60 - idResolver *idresolver.Resolver, 61 - mentionsResolver *mentions.Resolver, 62 - indexer *indexer.Indexer, 63 - notifier notify.Notifier, 64 - oauth *oauth.OAuth, 65 - pages *pages.Pages, 66 - validator *validator.Validator, 67 - // to use legacy web handlers. will be removed later 68 - s *state.State, 69 - ) http.Handler { 70 - repo := rsvc.NewService( 71 - logger, 72 - config, 73 - db, 74 - enforcer, 75 - ) 76 - issue := isvc.NewService( 77 - logger, 78 - config, 79 - db, 80 - enforcer, 81 - notifier, 82 - idResolver, 83 - mentionsResolver, 84 - indexer.Issues, 85 - validator, 86 - ) 87 - 88 - i := s.ExposeIssue() 89 - 90 - r := chi.NewRouter() 91 - 92 - mw := s.Middleware() 93 - auth := middleware.AuthMiddleware() 94 - 95 - r.Use(middleware.WithLogger(logger)) 96 - r.Use(middleware.WithSession(oauth)) 97 - 98 - r.Use(middleware.Normalize()) 99 - 100 - r.Get("/favicon.svg", s.Favicon) 101 - r.Get("/favicon.ico", s.Favicon) 102 - r.Get("/pwa-manifest.json", s.PWAManifest) 103 - r.Get("/robots.txt", s.RobotsTxt) 104 - 105 - r.Handle("/static/*", pages.Static()) 106 - 107 - r.Get("/", s.HomeOrTimeline) 108 - r.Get("/timeline", s.Timeline) 109 - r.Get("/upgradeBanner", s.UpgradeBanner) 110 - 111 - r.Get("/terms", s.TermsOfService) 112 - r.Get("/privacy", s.PrivacyPolicy) 113 - r.Get("/brand", s.Brand) 114 - // special-case handler for serving tangled.org/core 115 - r.Get("/core", s.Core()) 116 - 117 - r.Get("/login", s.Login) 118 - r.Post("/login", s.Login) 119 - r.Post("/logout", s.Logout) 120 - 121 - r.Get("/goodfirstissues", s.GoodFirstIssues) 122 - 123 - r.With(auth).Get("/repo/new", s.NewRepo) 124 - r.With(auth).Post("/repo/new", s.NewRepo) 125 - 126 - r.With(auth).Post("/follow", s.Follow) 127 - r.With(auth).Delete("/follow", s.Follow) 128 - 129 - r.With(auth).Post("/star", s.Star) 130 - r.With(auth).Delete("/star", s.Star) 131 - 132 - r.With(auth).Post("/react", s.React) 133 - r.With(auth).Delete("/react", s.React) 134 - 135 - r.With(auth).Get("/profile/edit-bio", s.EditBioFragment) 136 - r.With(auth).Get("/profile/edit-pins", s.EditPinsFragment) 137 - r.With(auth).Post("/profile/bio", s.UpdateProfileBio) 138 - r.With(auth).Post("/profile/pins", s.UpdateProfilePins) 139 - 140 - r.Mount("/settings", s.SettingsRouter()) 141 - r.Mount("/strings", s.StringsRouter(mw)) 142 - r.Mount("/settings/knots", s.KnotsRouter()) 143 - r.Mount("/settings/spindles", s.SpindlesRouter()) 144 - r.Mount("/notifications", s.NotificationsRouter(mw)) 145 - 146 - r.Mount("/signup", s.SignupRouter()) 147 - r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(oauth)) 148 - r.Get("/oauth/jwks.json", handler.OauthJwks(oauth)) 149 - r.Get("/oauth/callback", oauth.Callback) 150 - 151 - // special-case handler. should replace with xrpc later 152 - r.Get("/keys/{user}", s.Keys) 153 - 154 - r.HandleFunc("/@*", func(w http.ResponseWriter, r *http.Request) { 155 - http.Redirect(w, r, "/"+chi.URLParam(r, "*"), http.StatusFound) 156 - }) 157 - 158 - r.Route("/{user}", func(r chi.Router) { 159 - r.Use(middleware.EnsureDidOrHandle(pages)) 160 - r.Use(middleware.ResolveIdent(idResolver, pages)) 161 - 162 - r.Get("/", s.Profile) 163 - r.Get("/feed.atom", s.AtomFeedPage) 164 - 165 - r.Route("/{repo}", func(r chi.Router) { 166 - r.Use(middleware.ResolveRepo(db, pages)) 167 - 168 - r.Mount("/", s.RepoRouter(mw)) 169 - 170 - // /{user}/{repo}/issues/* 171 - r.With(middleware.Paginate).Get("/issues", handler.RepoIssues(issue, repo, pages, db)) 172 - r.With(auth).Get("/issues/new", handler.NewIssue(repo, pages)) 173 - r.With(auth).Post("/issues/new", handler.NewIssuePost(issue, pages)) 174 - r.Route("/issues/{issue}", func(r chi.Router) { 175 - r.Use(middleware.ResolveIssue(db, pages)) 176 - 177 - r.Get("/", handler.Issue(issue, repo, pages, db)) 178 - r.Get("/opengraph", i.IssueOpenGraphSummary) 179 - 180 - r.With(auth).Delete("/", handler.IssueDelete(issue, pages)) 181 - 182 - r.With(auth).Get("/edit", handler.IssueEdit(issue, repo, pages)) 183 - r.With(auth).Post("/edit", handler.IssueEditPost(issue, pages)) 184 - 185 - r.With(auth).Post("/close", handler.CloseIssue(issue, pages)) 186 - r.With(auth).Post("/reopen", handler.ReopenIssue(issue, pages)) 187 - 188 - r.With(auth).Post("/comment", i.NewIssueComment) 189 - r.With(auth).Route("/comment/{commentId}/", func(r chi.Router) { 190 - r.Get("/", i.IssueComment) 191 - r.Delete("/", i.DeleteIssueComment) 192 - r.Get("/edit", i.EditIssueComment) 193 - r.Post("/edit", i.EditIssueComment) 194 - r.Get("/reply", i.ReplyIssueComment) 195 - r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 196 - }) 197 - }) 198 - 199 - r.Mount("/pulls", s.PullsRouter(mw)) 200 - r.Mount("/pipelines", s.PipelinesRouter()) 201 - r.Mount("/labels", s.LabelsRouter()) 202 - 203 - // These routes get proxied to the knot 204 - r.Get("/info/refs", s.InfoRefs) 205 - r.Post("/git-upload-pack", s.UploadPack) 206 - r.Post("/git-receive-pack", s.ReceivePack) 207 - }) 208 - }) 209 - 210 - r.NotFound(func(w http.ResponseWriter, r *http.Request) { 211 - pages.Error404(w) 212 - }) 213 - 214 - return r 215 - }
+1 -2
cmd/appview/main.go
··· 7 7 8 8 "tangled.org/core/appview/config" 9 9 "tangled.org/core/appview/state" 10 - "tangled.org/core/appview/web" 11 10 tlog "tangled.org/core/log" 12 11 ) 13 12 ··· 36 35 37 36 logger.Info("starting server", "address", c.Core.ListenAddr) 38 37 39 - if err := http.ListenAndServe(c.Core.ListenAddr, web.RouterFromState(state)); err != nil { 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 40 39 logger.Error("failed to start appview", "err", err) 41 40 } 42 41 }
+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 + }
+1527
docs/DOCS.md
··· 1 + --- 2 + title: Tangled docs 3 + author: The Tangled Contributors 4 + date: 21 Sun, Dec 2025 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. 10 + 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 17 + 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. 25 + 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 + --- 31 + 32 + # Quick start guide 33 + 34 + ## Login or sign up 35 + 36 + You can [login](https://tangled.org) by using your AT Protocol 37 + account. If you are unclear on what that means, simply head 38 + to the [signup](https://tangled.org/signup) page and create 39 + an account. By doing so, you will be choosing Tangled as 40 + your account provider (you will be granted a handle of the 41 + form `user.tngl.sh`). 42 + 43 + In the AT Protocol network, users are free to choose their account 44 + provider (known as a "Personal Data Service", or PDS), and 45 + login to applications that support AT accounts. 46 + 47 + You can think of it as "one account for all of the atmosphere"! 48 + 49 + If you already have an AT account (you may have one if you 50 + signed up to Bluesky, for example), you can login with the 51 + same handle on Tangled (so just use `user.bsky.social` on 52 + the login page). 53 + 54 + ## Add an SSH key 55 + 56 + Once you are logged in, you can start creating repositories 57 + and pushing code. Tangled supports pushing git repositories 58 + over SSH. 59 + 60 + First, you'll need to generate an SSH key if you don't 61 + already have one: 62 + 63 + ```bash 64 + ssh-keygen -t ed25519 -C "foo@bar.com" 65 + ``` 66 + 67 + When prompted, save the key to the default location 68 + (`~/.ssh/id_ed25519`) and optionally set a passphrase. 69 + 70 + Copy your public key to your clipboard: 71 + 72 + ```bash 73 + # on X11 74 + cat ~/.ssh/id_ed25519.pub | xclip -sel c 75 + 76 + # on wayland 77 + cat ~/.ssh/id_ed25519.pub | wl-copy 78 + 79 + # on macos 80 + cat ~/.ssh/id_ed25519.pub | pbcopy 81 + ``` 82 + 83 + Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key', 84 + paste your public key, give it a descriptive name, and hit 85 + save. 86 + 87 + ## Create a repository 88 + 89 + Once your SSH key is added, create your first repository: 90 + 91 + 1. Hit the green `+` icon on the topbar, and select 92 + repository 93 + 2. Enter a repository name 94 + 3. Add a description 95 + 4. Choose a knotserver to host this repository on 96 + 5. Hit create 97 + 98 + Knots are self-hostable, lightweight Git servers that can 99 + host your repository. Unlike traditional code forges, your 100 + code can live on any server. Read the [Knots](TODO) section 101 + for more. 102 + 103 + ## Configure SSH 104 + 105 + To ensure Git uses the correct SSH key and connects smoothly 106 + to Tangled, add this configuration to your `~/.ssh/config` 107 + file: 108 + 109 + ``` 110 + Host tangled.org 111 + Hostname tangled.org 112 + User git 113 + IdentityFile ~/.ssh/id_ed25519 114 + AddressFamily inet 115 + ``` 116 + 117 + This tells SSH to use your specific key when connecting to 118 + Tangled and prevents authentication issues if you have 119 + multiple SSH keys. 120 + 121 + Note that this configuration only works for knotservers that 122 + are hosted by tangled.org. If you use a custom knot, refer 123 + to the [Knots](TODO) section. 124 + 125 + ## Push your first repository 126 + 127 + Initialize a new Git repository: 128 + 129 + ```bash 130 + mkdir my-project 131 + cd my-project 132 + 133 + git init 134 + echo "# My Project" > README.md 135 + ``` 136 + 137 + Add some content and push! 138 + 139 + ```bash 140 + git add README.md 141 + git commit -m "Initial commit" 142 + git remote add origin git@tangled.org:user.tngl.sh/my-project 143 + git push -u origin main 144 + ``` 145 + 146 + That's it! Your code is now hosted on Tangled. 147 + 148 + ## Migrating an existing repository 149 + 150 + Moving your repositories from GitHub, GitLab, Bitbucket, or 151 + any other Git forge to Tangled is straightforward. You'll 152 + simply change your repository's remote URL. At the moment, 153 + Tangled does not have any tooling to migrate data such as 154 + GitHub issues or pull requests. 155 + 156 + First, create a new repository on tangled.org as described 157 + in the [Quick Start Guide](#create-a-repository). 158 + 159 + Navigate to your existing local repository: 160 + 161 + ```bash 162 + cd /path/to/your/existing/repo 163 + ``` 164 + 165 + You can inspect your existing Git remote like so: 166 + 167 + ```bash 168 + git remote -v 169 + ``` 170 + 171 + You'll see something like: 172 + 173 + ``` 174 + origin git@github.com:username/my-project (fetch) 175 + origin git@github.com:username/my-project (push) 176 + ``` 177 + 178 + Update the remote URL to point to tangled: 179 + 180 + ```bash 181 + git remote set-url origin git@tangled.org:user.tngl.sh/my-project 182 + ``` 183 + 184 + Verify the change: 185 + 186 + ```bash 187 + git remote -v 188 + ``` 189 + 190 + You should now see: 191 + 192 + ``` 193 + origin git@tangled.org:user.tngl.sh/my-project (fetch) 194 + origin git@tangled.org:user.tngl.sh/my-project (push) 195 + ``` 196 + 197 + Push all your branches and tags to Tangled: 198 + 199 + ```bash 200 + git push -u origin --all 201 + git push -u origin --tags 202 + ``` 203 + 204 + Your repository is now migrated to Tangled! All commit 205 + history, branches, and tags have been preserved. 206 + 207 + ## Mirroring a repository to Tangled 208 + 209 + If you want to maintain your repository on multiple forges 210 + simultaneously, for example, keeping your primary repository 211 + on GitHub while mirroring to Tangled for backup or 212 + redundancy, you can do so by adding multiple remotes. 213 + 214 + You can configure your local repository to push to both 215 + Tangled and, say, GitHub. You may already have the following 216 + setup: 217 + 218 + ``` 219 + $ git remote -v 220 + origin git@github.com:username/my-project (fetch) 221 + origin git@github.com:username/my-project (push) 222 + ``` 223 + 224 + Now add Tangled as an additional push URL to the same 225 + remote: 226 + 227 + ```bash 228 + git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project 229 + ``` 230 + 231 + You also need to re-add the original URL as a push 232 + destination (Git replaces the push URL when you use `--add` 233 + the first time): 234 + 235 + ```bash 236 + git remote set-url --add --push origin git@github.com:username/my-project 237 + ``` 238 + 239 + Verify your configuration: 240 + 241 + ``` 242 + $ git remote -v 243 + origin git@github.com:username/repo (fetch) 244 + origin git@tangled.org:username/my-project (push) 245 + origin git@github.com:username/repo (push) 246 + ``` 247 + 248 + Notice that there's one fetch URL (the primary remote) and 249 + two push URLs. Now, whenever you push, Git will 250 + automatically push to both remotes: 251 + 252 + ```bash 253 + git push origin main 254 + ``` 255 + 256 + This single command pushes your `main` branch to both GitHub 257 + and Tangled simultaneously. 258 + 259 + To push all branches and tags: 260 + 261 + ```bash 262 + git push origin --all 263 + git push origin --tags 264 + ``` 265 + 266 + If you prefer more control over which remote you push to, 267 + you can maintain separate remotes: 268 + 269 + ```bash 270 + git remote add github git@github.com:username/my-project 271 + git remote add tangled git@tangled.org:username/my-project 272 + ``` 273 + 274 + Then push to each explicitly: 275 + 276 + ```bash 277 + git push github main 278 + git push tangled main 279 + ``` 280 + 281 + # Knot self-hosting guide 282 + 283 + So you want to run your own knot server? Great! Here are a few prerequisites: 284 + 285 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 286 + 2. A (sub)domain name. People generally use `knot.example.com`. 287 + 3. A valid SSL certificate for your domain. 288 + 289 + ## NixOS 290 + 291 + Refer to the [knot 292 + module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix) 293 + for a full list of options. Sample configurations: 294 + 295 + - [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85) 296 + - [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25) 297 + 298 + ## Docker 299 + 300 + Refer to 301 + [@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 302 + Note that this is community maintained. 303 + 304 + ## Manual setup 305 + 306 + First, clone this repository: 307 + 308 + ``` 309 + git clone https://tangled.org/@tangled.org/core 310 + ``` 311 + 312 + Then, build the `knot` CLI. This is the knot administration 313 + and operation tool. For the purpose of this guide, we're 314 + only concerned with these subcommands: 315 + 316 + * `knot server`: the main knot server process, typically 317 + run as a supervised service 318 + * `knot guard`: handles role-based access control for git 319 + over SSH (you'll never have to run this yourself) 320 + * `knot keys`: fetches SSH keys associated with your knot; 321 + we'll use this to generate the SSH 322 + `AuthorizedKeysCommand` 323 + 324 + ``` 325 + cd core 326 + export CGO_ENABLED=1 327 + go build -o knot ./cmd/knot 328 + ``` 329 + 330 + Next, move the `knot` binary to a location owned by `root` -- 331 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 332 + 333 + ``` 334 + sudo mv knot /usr/local/bin/knot 335 + sudo chown root:root /usr/local/bin/knot 336 + ``` 337 + 338 + This is necessary because SSH `AuthorizedKeysCommand` requires [really 339 + specific permissions](https://stackoverflow.com/a/27638306). The 340 + `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 341 + retrieve a user's public SSH keys dynamically for authentication. Let's 342 + set that up. 343 + 344 + ``` 345 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 346 + Match User git 347 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 348 + AuthorizedKeysCommandUser nobody 349 + EOF 350 + ``` 351 + 352 + Then, reload `sshd`: 353 + 354 + ``` 355 + sudo systemctl reload ssh 356 + ``` 357 + 358 + Next, create the `git` user. We'll use the `git` user's home directory 359 + to store repositories: 360 + 361 + ``` 362 + sudo adduser git 363 + ``` 364 + 365 + Create `/home/git/.knot.env` with the following, updating the values as 366 + necessary. The `KNOT_SERVER_OWNER` should be set to your 367 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 368 + 369 + ``` 370 + KNOT_REPO_SCAN_PATH=/home/git 371 + KNOT_SERVER_HOSTNAME=knot.example.com 372 + APPVIEW_ENDPOINT=https://tangled.org 373 + KNOT_SERVER_OWNER=did:plc:foobar 374 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 375 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 376 + ``` 377 + 378 + If you run a Linux distribution that uses systemd, you can use the provided 379 + service file to run the server. Copy 380 + [`knotserver.service`](/systemd/knotserver.service) 381 + to `/etc/systemd/system/`. Then, run: 382 + 383 + ``` 384 + systemctl enable knotserver 385 + systemctl start knotserver 386 + ``` 387 + 388 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 389 + knot. Here's an example configuration for Nginx: 390 + 391 + ``` 392 + server { 393 + listen 80; 394 + listen [::]:80; 395 + server_name knot.example.com; 396 + 397 + location / { 398 + proxy_pass http://localhost:5555; 399 + proxy_set_header Host $host; 400 + proxy_set_header X-Real-IP $remote_addr; 401 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 402 + proxy_set_header X-Forwarded-Proto $scheme; 403 + } 404 + 405 + # wss endpoint for git events 406 + location /events { 407 + proxy_set_header X-Forwarded-For $remote_addr; 408 + proxy_set_header Host $http_host; 409 + proxy_set_header Upgrade websocket; 410 + proxy_set_header Connection Upgrade; 411 + proxy_pass http://localhost:5555; 412 + } 413 + # additional config for SSL/TLS go here. 414 + } 415 + 416 + ``` 417 + 418 + Remember to use Let's Encrypt or similar to procure a certificate for your 419 + knot domain. 420 + 421 + You should now have a running knot server! You can finalize 422 + your registration by hitting the `verify` button on the 423 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 424 + a record on your PDS to announce the existence of the knot. 425 + 426 + ### Custom paths 427 + 428 + (This section applies to manual setup only. Docker users should edit the mounts 429 + in `docker-compose.yml` instead.) 430 + 431 + Right now, the database and repositories of your knot lives in `/home/git`. You 432 + can move these paths if you'd like to store them in another folder. Be careful 433 + when adjusting these paths: 434 + 435 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 436 + any possible side effects. Remember to restart it once you're done. 437 + * Make backups before moving in case something goes wrong. 438 + * Make sure the `git` user can read and write from the new paths. 439 + 440 + #### Database 441 + 442 + As an example, let's say the current database is at `/home/git/knotserver.db`, 443 + and we want to move it to `/home/git/database/knotserver.db`. 444 + 445 + Copy the current database to the new location. Make sure to copy the `.db-shm` 446 + and `.db-wal` files if they exist. 447 + 448 + ``` 449 + mkdir /home/git/database 450 + cp /home/git/knotserver.db* /home/git/database 451 + ``` 452 + 453 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 454 + the new file path (_not_ the directory): 455 + 456 + ``` 457 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 458 + ``` 459 + 460 + #### Repositories 461 + 462 + As an example, let's say the repositories are currently in `/home/git`, and we 463 + want to move them into `/home/git/repositories`. 464 + 465 + Create the new folder, then move the existing repositories (if there are any): 466 + 467 + ``` 468 + mkdir /home/git/repositories 469 + # move all DIDs into the new folder; these will vary for you! 470 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 471 + ``` 472 + 473 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 474 + to the new directory: 475 + 476 + ``` 477 + KNOT_REPO_SCAN_PATH=/home/git/repositories 478 + ``` 479 + 480 + Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 481 + repository path: 482 + 483 + ``` 484 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 485 + Match User git 486 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 487 + AuthorizedKeysCommandUser nobody 488 + EOF 489 + ``` 490 + 491 + Make sure to restart your SSH server! 492 + 493 + #### MOTD (message of the day) 494 + 495 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 496 + `/home/git/motd` file: 497 + 498 + ``` 499 + printf "Hi from this knot!\n" > /home/git/motd 500 + ``` 501 + 502 + Note that you should add a newline at the end if setting a non-empty message 503 + since the knot won't do this for you. 504 + 505 + # Spindles 506 + 507 + ## Pipelines 508 + 509 + Spindle workflows allow you to write CI/CD pipelines in a 510 + simple format. They're located in the `.tangled/workflows` 511 + directory at the root of your repository, and are defined 512 + using YAML. 513 + 514 + The fields are: 515 + 516 + - [Trigger](#trigger): A **required** field that defines 517 + when a workflow should be triggered. 518 + - [Engine](#engine): A **required** field that defines which 519 + engine a workflow should run on. 520 + - [Clone options](#clone-options): An **optional** field 521 + that defines how the repository should be cloned. 522 + - [Dependencies](#dependencies): An **optional** field that 523 + allows you to list dependencies you may need. 524 + - [Environment](#environment): An **optional** field that 525 + allows you to define environment variables. 526 + - [Steps](#steps): An **optional** field that allows you to 527 + define what steps should run in the workflow. 528 + 529 + ### Trigger 530 + 531 + The first thing to add to a workflow is the trigger, which 532 + defines when a workflow runs. This is defined using a `when` 533 + field, which takes in a list of conditions. Each condition 534 + has the following fields: 535 + 536 + - `event`: This is a **required** field that defines when 537 + your workflow should run. It's a list that can take one or 538 + more of the following values: 539 + - `push`: The workflow should run every time a commit is 540 + pushed to the repository. 541 + - `pull_request`: The workflow should run every time a 542 + pull request is made or updated. 543 + - `manual`: The workflow can be triggered manually. 544 + - `branch`: Defines which branches the workflow should run 545 + for. If used with the `push` event, commits to the 546 + branch(es) listed here will trigger the workflow. If used 547 + with the `pull_request` event, updates to pull requests 548 + targeting the branch(es) listed here will trigger the 549 + workflow. This field has no effect with the `manual` 550 + event. Supports glob patterns using `*` and `**` (e.g., 551 + `main`, `develop`, `release-*`). Either `branch` or `tag` 552 + (or both) must be specified for `push` events. 553 + - `tag`: Defines which tags the workflow should run for. 554 + Only used with the `push` event - when tags matching the 555 + pattern(s) listed here are pushed, the workflow will 556 + trigger. This field has no effect with `pull_request` or 557 + `manual` events. Supports glob patterns using `*` and `**` 558 + (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or 559 + `tag` (or both) must be specified for `push` events. 560 + 561 + For example, if you'd like to define a workflow that runs 562 + when commits are pushed to the `main` and `develop` 563 + branches, or when pull requests that target the `main` 564 + branch are updated, or manually, you can do so with: 565 + 566 + ```yaml 567 + when: 568 + - event: ["push", "manual"] 569 + branch: ["main", "develop"] 570 + - event: ["pull_request"] 571 + branch: ["main"] 572 + ``` 573 + 574 + You can also trigger workflows on tag pushes. For instance, 575 + to run a deployment workflow when tags matching `v*` are 576 + pushed: 577 + 578 + ```yaml 579 + when: 580 + - event: ["push"] 581 + tag: ["v*"] 582 + ``` 583 + 584 + You can even combine branch and tag patterns in a single 585 + constraint (the workflow triggers if either matches): 586 + 587 + ```yaml 588 + when: 589 + - event: ["push"] 590 + branch: ["main", "release-*"] 591 + tag: ["v*", "stable"] 592 + ``` 593 + 594 + ### Engine 595 + 596 + Next is the engine on which the workflow should run, defined 597 + using the **required** `engine` field. The currently 598 + supported engines are: 599 + 600 + - `nixery`: This uses an instance of 601 + [Nixery](https://nixery.dev) to run steps, which allows 602 + you to add [dependencies](#dependencies) from 603 + Nixpkgs (https://github.com/NixOS/nixpkgs). You can 604 + search for packages on https://search.nixos.org, and 605 + there's a pretty good chance the package(s) you're looking 606 + for will be there. 607 + 608 + Example: 609 + 610 + ```yaml 611 + engine: "nixery" 612 + ``` 613 + 614 + ### Clone options 615 + 616 + When a workflow starts, the first step is to clone the 617 + repository. You can customize this behavior using the 618 + **optional** `clone` field. It has the following fields: 619 + 620 + - `skip`: Setting this to `true` will skip cloning the 621 + repository. This can be useful if your workflow is doing 622 + something that doesn't require anything from the 623 + repository itself. This is `false` by default. 624 + - `depth`: This sets the number of commits, or the "clone 625 + depth", to fetch from the repository. For example, if you 626 + set this to 2, the last 2 commits will be fetched. By 627 + default, the depth is set to 1, meaning only the most 628 + recent commit will be fetched, which is the commit that 629 + triggered the workflow. 630 + - `submodules`: If you use Git submodules 631 + (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 632 + in your repository, setting this field to `true` will 633 + recursively fetch all submodules. This is `false` by 634 + default. 635 + 636 + The default settings are: 637 + 638 + ```yaml 639 + clone: 640 + skip: false 641 + depth: 1 642 + submodules: false 643 + ``` 644 + 645 + ### Dependencies 646 + 647 + Usually when you're running a workflow, you'll need 648 + additional dependencies. The `dependencies` field lets you 649 + define which dependencies to get, and from where. It's a 650 + key-value map, with the key being the registry to fetch 651 + dependencies from, and the value being the list of 652 + dependencies to fetch. 653 + 654 + Say you want to fetch Node.js and Go from `nixpkgs`, and a 655 + package called `my_pkg` you've made from your own registry 656 + at your repository at 657 + `https://tangled.org/@example.com/my_pkg`. You can define 658 + those dependencies like so: 659 + 660 + ```yaml 661 + dependencies: 662 + # nixpkgs 663 + nixpkgs: 664 + - nodejs 665 + - go 666 + # custom registry 667 + git+https://tangled.org/@example.com/my_pkg: 668 + - my_pkg 669 + ``` 670 + 671 + Now these dependencies are available to use in your 672 + workflow! 673 + 674 + ### Environment 675 + 676 + The `environment` field allows you define environment 677 + variables that will be available throughout the entire 678 + workflow. **Do not put secrets here, these environment 679 + variables are visible to anyone viewing the repository. You 680 + can add secrets for pipelines in your repository's 681 + settings.** 682 + 683 + Example: 684 + 685 + ```yaml 686 + environment: 687 + GOOS: "linux" 688 + GOARCH: "arm64" 689 + NODE_ENV: "production" 690 + MY_ENV_VAR: "MY_ENV_VALUE" 691 + ``` 692 + 693 + ### Steps 694 + 695 + The `steps` field allows you to define what steps should run 696 + in the workflow. It's a list of step objects, each with the 697 + following fields: 698 + 699 + - `name`: This field allows you to give your step a name. 700 + This name is visible in your workflow runs, and is used to 701 + describe what the step is doing. 702 + - `command`: This field allows you to define a command to 703 + run in that step. The step is run in a Bash shell, and the 704 + logs from the command will be visible in the pipelines 705 + page on the Tangled website. The 706 + [dependencies](#dependencies) you added will be available 707 + to use here. 708 + - `environment`: Similar to the global 709 + [environment](#environment) config, this **optional** 710 + field is a key-value map that allows you to set 711 + environment variables for the step. **Do not put secrets 712 + here, these environment variables are visible to anyone 713 + viewing the repository. You can add secrets for pipelines 714 + in your repository's settings.** 715 + 716 + Example: 717 + 718 + ```yaml 719 + steps: 720 + - name: "Build backend" 721 + command: "go build" 722 + environment: 723 + GOOS: "darwin" 724 + GOARCH: "arm64" 725 + - name: "Build frontend" 726 + command: "npm run build" 727 + environment: 728 + NODE_ENV: "production" 729 + ``` 730 + 731 + ### Complete workflow 732 + 733 + ```yaml 734 + # .tangled/workflows/build.yml 735 + 736 + when: 737 + - event: ["push", "manual"] 738 + branch: ["main", "develop"] 739 + - event: ["pull_request"] 740 + branch: ["main"] 741 + 742 + engine: "nixery" 743 + 744 + # using the default values 745 + clone: 746 + skip: false 747 + depth: 1 748 + submodules: false 749 + 750 + dependencies: 751 + # nixpkgs 752 + nixpkgs: 753 + - nodejs 754 + - go 755 + # custom registry 756 + git+https://tangled.org/@example.com/my_pkg: 757 + - my_pkg 758 + 759 + environment: 760 + GOOS: "linux" 761 + GOARCH: "arm64" 762 + NODE_ENV: "production" 763 + MY_ENV_VAR: "MY_ENV_VALUE" 764 + 765 + steps: 766 + - name: "Build backend" 767 + command: "go build" 768 + environment: 769 + GOOS: "darwin" 770 + GOARCH: "arm64" 771 + - name: "Build frontend" 772 + command: "npm run build" 773 + environment: 774 + NODE_ENV: "production" 775 + ``` 776 + 777 + If you want another example of a workflow, you can look at 778 + the one [Tangled uses to build the 779 + project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 780 + 781 + ## Self-hosting guide 782 + 783 + ### Prerequisites 784 + 785 + * Go 786 + * Docker (the only supported backend currently) 787 + 788 + ### Configuration 789 + 790 + Spindle is configured using environment variables. The following environment variables are available: 791 + 792 + * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 793 + * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 794 + * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 795 + * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 796 + * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 797 + * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 798 + * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 799 + * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 800 + * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 801 + 802 + ### Running spindle 803 + 804 + 1. **Set the environment variables.** For example: 805 + 806 + ```shell 807 + export SPINDLE_SERVER_HOSTNAME="your-hostname" 808 + export SPINDLE_SERVER_OWNER="your-did" 809 + ``` 810 + 811 + 2. **Build the Spindle binary.** 812 + 813 + ```shell 814 + cd core 815 + go mod download 816 + go build -o cmd/spindle/spindle cmd/spindle/main.go 817 + ``` 818 + 819 + 3. **Create the log directory.** 820 + 821 + ```shell 822 + sudo mkdir -p /var/log/spindle 823 + sudo chown $USER:$USER -R /var/log/spindle 824 + ``` 825 + 826 + 4. **Run the Spindle binary.** 827 + 828 + ```shell 829 + ./cmd/spindle/spindle 830 + ``` 831 + 832 + Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 833 + 834 + ## Architecture 835 + 836 + Spindle is a small CI runner service. Here's a high-level overview of how it operates: 837 + 838 + * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 839 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 840 + * When a new repo record comes through (typically when you add a spindle to a 841 + repo from the settings), spindle then resolves the underlying knot and 842 + subscribes to repo events (see: 843 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 844 + * The spindle engine then handles execution of the pipeline, with results and 845 + logs beamed on the spindle event stream over WebSocket 846 + 847 + ### The engine 848 + 849 + At present, the only supported backend is Docker (and Podman, if Docker 850 + compatibility is enabled, so that `/run/docker.sock` is created). spindle 851 + executes each step in the pipeline in a fresh container, with state persisted 852 + across steps within the `/tangled/workspace` directory. 853 + 854 + The base image for the container is constructed on the fly using 855 + [Nixery](https://nixery.dev), which is handy for caching layers for frequently 856 + used packages. 857 + 858 + The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 859 + 860 + ## Secrets with openbao 861 + 862 + This document covers setting up spindle to use OpenBao for secrets 863 + management via OpenBao Proxy instead of the default SQLite backend. 864 + 865 + ### Overview 866 + 867 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 868 + authentication automatically using AppRole credentials, while spindle 869 + connects to the local proxy instead of directly to the OpenBao server. 870 + 871 + This approach provides better security, automatic token renewal, and 872 + simplified application code. 873 + 874 + ### Installation 875 + 876 + Install OpenBao from Nixpkgs: 877 + 878 + ```bash 879 + nix shell nixpkgs#openbao # for a local server 880 + ``` 881 + 882 + ### Setup 883 + 884 + The setup process can is documented for both local development and production. 885 + 886 + #### Local development 887 + 888 + Start OpenBao in dev mode: 889 + 890 + ```bash 891 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 892 + ``` 893 + 894 + This starts OpenBao on `http://localhost:8201` with a root token. 895 + 896 + Set up environment for bao CLI: 897 + 898 + ```bash 899 + export BAO_ADDR=http://localhost:8200 900 + export BAO_TOKEN=root 901 + ``` 902 + 903 + #### Production 904 + 905 + You would typically use a systemd service with a 906 + configuration file. Refer to 907 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) 908 + for how this can be achieved using Nix. 909 + 910 + Then, initialize the bao server: 911 + 912 + ```bash 913 + bao operator init -key-shares=1 -key-threshold=1 914 + ``` 915 + 916 + This will print out an unseal key and a root key. Save them 917 + somewhere (like a password manager). Then unseal the vault 918 + to begin setting it up: 919 + 920 + ```bash 921 + bao operator unseal <unseal_key> 922 + ``` 923 + 924 + All steps below remain the same across both dev and 925 + production setups. 926 + 927 + #### Configure openbao server 928 + 929 + Create the spindle KV mount: 930 + 931 + ```bash 932 + bao secrets enable -path=spindle -version=2 kv 933 + ``` 934 + 935 + Set up AppRole authentication and policy: 936 + 937 + Create a policy file `spindle-policy.hcl`: 938 + 939 + ```hcl 940 + # Full access to spindle KV v2 data 941 + path "spindle/data/*" { 942 + capabilities = ["create", "read", "update", "delete"] 943 + } 944 + 945 + # Access to metadata for listing and management 946 + path "spindle/metadata/*" { 947 + capabilities = ["list", "read", "delete", "update"] 948 + } 949 + 950 + # Allow listing at root level 951 + path "spindle/" { 952 + capabilities = ["list"] 953 + } 954 + 955 + # Required for connection testing and health checks 956 + path "auth/token/lookup-self" { 957 + capabilities = ["read"] 958 + } 959 + ``` 960 + 961 + Apply the policy and create an AppRole: 962 + 963 + ```bash 964 + bao policy write spindle-policy spindle-policy.hcl 965 + bao auth enable approle 966 + bao write auth/approle/role/spindle \ 967 + token_policies="spindle-policy" \ 968 + token_ttl=1h \ 969 + token_max_ttl=4h \ 970 + bind_secret_id=true \ 971 + secret_id_ttl=0 \ 972 + secret_id_num_uses=0 973 + ``` 974 + 975 + Get the credentials: 976 + 977 + ```bash 978 + # Get role ID (static) 979 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 980 + 981 + # Generate secret ID 982 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 983 + 984 + echo "Role ID: $ROLE_ID" 985 + echo "Secret ID: $SECRET_ID" 986 + ``` 987 + 988 + #### Create proxy configuration 989 + 990 + Create the credential files: 991 + 992 + ```bash 993 + # Create directory for OpenBao files 994 + mkdir -p /tmp/openbao 995 + 996 + # Save credentials 997 + echo "$ROLE_ID" > /tmp/openbao/role-id 998 + echo "$SECRET_ID" > /tmp/openbao/secret-id 999 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 1000 + ``` 1001 + 1002 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 1003 + 1004 + ```hcl 1005 + # OpenBao server connection 1006 + vault { 1007 + address = "http://localhost:8200" 1008 + } 1009 + 1010 + # Auto-Auth using AppRole 1011 + auto_auth { 1012 + method "approle" { 1013 + mount_path = "auth/approle" 1014 + config = { 1015 + role_id_file_path = "/tmp/openbao/role-id" 1016 + secret_id_file_path = "/tmp/openbao/secret-id" 1017 + } 1018 + } 1019 + 1020 + # Optional: write token to file for debugging 1021 + sink "file" { 1022 + config = { 1023 + path = "/tmp/openbao/token" 1024 + mode = 0640 1025 + } 1026 + } 1027 + } 1028 + 1029 + # Proxy listener for spindle 1030 + listener "tcp" { 1031 + address = "127.0.0.1:8201" 1032 + tls_disable = true 1033 + } 1034 + 1035 + # Enable API proxy with auto-auth token 1036 + api_proxy { 1037 + use_auto_auth_token = true 1038 + } 1039 + 1040 + # Enable response caching 1041 + cache { 1042 + use_auto_auth_token = true 1043 + } 1044 + 1045 + # Logging 1046 + log_level = "info" 1047 + ``` 1048 + 1049 + #### Start the proxy 1050 + 1051 + Start OpenBao Proxy: 1052 + 1053 + ```bash 1054 + bao proxy -config=/tmp/openbao/proxy.hcl 1055 + ``` 1056 + 1057 + The proxy will authenticate with OpenBao and start listening on 1058 + `127.0.0.1:8201`. 1059 + 1060 + #### Configure spindle 1061 + 1062 + Set these environment variables for spindle: 1063 + 1064 + ```bash 1065 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 1066 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 1067 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1068 + ``` 1069 + 1070 + On startup, spindle will now connect to the local proxy, 1071 + which handles all authentication automatically. 1072 + 1073 + ### Production setup for proxy 1074 + 1075 + For production, you'll want to run the proxy as a service: 1076 + 1077 + Place your production configuration in 1078 + `/etc/openbao/proxy.hcl` with proper TLS settings for the 1079 + vault connection. 1080 + 1081 + ### Verifying setup 1082 + 1083 + Test the proxy directly: 1084 + 1085 + ```bash 1086 + # Check proxy health 1087 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 1088 + 1089 + # Test token lookup through proxy 1090 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 1091 + ``` 1092 + 1093 + Test OpenBao operations through the server: 1094 + 1095 + ```bash 1096 + # List all secrets 1097 + bao kv list spindle/ 1098 + 1099 + # Add a test secret via the spindle API, then check it exists 1100 + bao kv list spindle/repos/ 1101 + 1102 + # Get a specific secret 1103 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 1104 + ``` 1105 + 1106 + ### How it works 1107 + 1108 + - Spindle connects to OpenBao Proxy on localhost (typically 1109 + port 8200 or 8201) 1110 + - The proxy authenticates with OpenBao using AppRole 1111 + credentials 1112 + - All spindle requests go through the proxy, which injects 1113 + authentication tokens 1114 + - Secrets are stored at 1115 + `spindle/repos/{sanitized_repo_path}/{secret_key}` 1116 + - Repository paths like `did:plc:alice/myrepo` become 1117 + `did_plc_alice_myrepo` 1118 + - The proxy handles all token renewal automatically 1119 + - Spindle no longer manages tokens or authentication 1120 + directly 1121 + 1122 + ### Troubleshooting 1123 + 1124 + **Connection refused**: Check that the OpenBao Proxy is 1125 + running and listening on the configured address. 1126 + 1127 + **403 errors**: Verify the AppRole credentials are correct 1128 + and the policy has the necessary permissions. 1129 + 1130 + **404 route errors**: The spindle KV mount probably doesn't 1131 + existโ€”run the mount creation step again. 1132 + 1133 + **Proxy authentication failures**: Check the proxy logs and 1134 + verify the role-id and secret-id files are readable and 1135 + contain valid credentials. 1136 + 1137 + **Secret not found after writing**: This can indicate policy 1138 + permission issues. Verify the policy includes both 1139 + `spindle/data/*` and `spindle/metadata/*` paths with 1140 + appropriate capabilities. 1141 + 1142 + Check proxy logs: 1143 + 1144 + ```bash 1145 + # If running as systemd service 1146 + journalctl -u openbao-proxy -f 1147 + 1148 + # If running directly, check the console output 1149 + ``` 1150 + 1151 + Test AppRole authentication manually: 1152 + 1153 + ```bash 1154 + bao write auth/approle/login \ 1155 + role_id="$(cat /tmp/openbao/role-id)" \ 1156 + secret_id="$(cat /tmp/openbao/secret-id)" 1157 + ``` 1158 + 1159 + # Migrating knots and spindles 1160 + 1161 + Sometimes, non-backwards compatible changes are made to the 1162 + knot/spindle XRPC APIs. If you host a knot or a spindle, you 1163 + will need to follow this guide to upgrade. Typically, this 1164 + only requires you to deploy the newest version. 1165 + 1166 + This document is laid out in reverse-chronological order. 1167 + Newer migration guides are listed first, and older guides 1168 + are further down the page. 1169 + 1170 + ## Upgrading from v1.8.x 1171 + 1172 + After v1.8.2, the HTTP API for knots and spindles has been 1173 + deprecated and replaced with XRPC. Repositories on outdated 1174 + knots will not be viewable from the appview. Upgrading is 1175 + straightforward however. 1176 + 1177 + For knots: 1178 + 1179 + - Upgrade to the latest tag (v1.9.0 or above) 1180 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1181 + hit the "retry" button to verify your knot 1182 + 1183 + For spindles: 1184 + 1185 + - Upgrade to the latest tag (v1.9.0 or above) 1186 + - Head to the [spindle 1187 + dashboard](https://tangled.org/settings/spindles) and hit the 1188 + "retry" button to verify your spindle 1189 + 1190 + ## Upgrading from v1.7.x 1191 + 1192 + After v1.7.0, knot secrets have been deprecated. You no 1193 + longer need a secret from the appview to run a knot. All 1194 + authorized commands to knots are managed via [Inter-Service 1195 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 1196 + Knots will be read-only until upgraded. 1197 + 1198 + Upgrading is quite easy, in essence: 1199 + 1200 + - `KNOT_SERVER_SECRET` is no more, you can remove this 1201 + environment variable entirely 1202 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 1203 + your DID. You can find your DID in the 1204 + [settings](https://tangled.org/settings) page. 1205 + - Restart your knot once you have replaced the environment 1206 + variable 1207 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1208 + hit the "retry" button to verify your knot. This simply 1209 + writes a `sh.tangled.knot` record to your PDS. 1210 + 1211 + If you use the nix module, simply bump the flake to the 1212 + latest revision, and change your config block like so: 1213 + 1214 + ```diff 1215 + services.tangled.knot = { 1216 + enable = true; 1217 + server = { 1218 + - secretFile = /path/to/secret; 1219 + + owner = "did:plc:foo"; 1220 + }; 1221 + }; 1222 + ``` 1223 + 1224 + # Hacking on Tangled 1225 + 1226 + We highly recommend [installing 1227 + Nix](https://nixos.org/download/) (the package manager) 1228 + before working on the codebase. The Nix flake provides a lot 1229 + of helpers to get started and most importantly, builds and 1230 + dev shells are entirely deterministic. 1231 + 1232 + To set up your dev environment: 1233 + 1234 + ```bash 1235 + nix develop 1236 + ``` 1237 + 1238 + Non-Nix users can look at the `devShell` attribute in the 1239 + `flake.nix` file to determine necessary dependencies. 1240 + 1241 + ## Running the appview 1242 + 1243 + The Nix flake also exposes a few `app` attributes (run `nix 1244 + flake show` to see a full list of what the flake provides), 1245 + one of the apps runs the appview with the `air` 1246 + live-reloader: 1247 + 1248 + ```bash 1249 + TANGLED_DEV=true nix run .#watch-appview 1250 + 1251 + # TANGLED_DB_PATH might be of interest to point to 1252 + # different sqlite DBs 1253 + 1254 + # in a separate shell, you can live-reload tailwind 1255 + nix run .#watch-tailwind 1256 + ``` 1257 + 1258 + To authenticate with the appview, you will need Redis and 1259 + OAuth JWKs to be set up: 1260 + 1261 + ``` 1262 + # OAuth JWKs should already be set up by the Nix devshell: 1263 + echo $TANGLED_OAUTH_CLIENT_SECRET 1264 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 1265 + 1266 + echo $TANGLED_OAUTH_CLIENT_KID 1267 + 1761667908 1268 + 1269 + # if not, you can set it up yourself: 1270 + goat key generate -t P-256 1271 + Key Type: P-256 / secp256r1 / ES256 private key 1272 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 1273 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 1274 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 1275 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 1276 + 1277 + # the secret key from above 1278 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 1279 + 1280 + # Run Redis in a new shell to store OAuth sessions 1281 + redis-server 1282 + ``` 1283 + 1284 + ## Running knots and spindles 1285 + 1286 + An end-to-end knot setup requires setting up a machine with 1287 + `sshd`, `AuthorizedKeysCommand`, and a Git user, which is 1288 + quite cumbersome. So the Nix flake provides a 1289 + `nixosConfiguration` to do so. 1290 + 1291 + <details> 1292 + <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1293 + 1294 + In order to build Tangled's dev VM on macOS, you will 1295 + first need to set up a Linux Nix builder. The recommended 1296 + way to do so is to run a [`darwin.linux-builder` 1297 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1298 + and to register it in `nix.conf` as a builder for Linux 1299 + with the same architecture as your Mac (`linux-aarch64` if 1300 + you are using Apple Silicon). 1301 + 1302 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1303 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1304 + > you can do 1305 + > 1306 + > ```shell 1307 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1308 + > ``` 1309 + > 1310 + > to store the builder VM in a temporary dir. 1311 + > 1312 + > You should read and follow [all the other intructions][darwin builder vm] to 1313 + > avoid subtle problems. 1314 + 1315 + Alternatively, you can use any other method to set up a 1316 + Linux machine with Nix installed that you can `sudo ssh` 1317 + into (in other words, root user on your Mac has to be able 1318 + to ssh into the Linux machine without entering a password) 1319 + and that has the same architecture as your Mac. See 1320 + [remote builder 1321 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1322 + for how to register such a builder in `nix.conf`. 1323 + 1324 + > WARNING: If you'd like to use 1325 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1326 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1327 + > ssh` works can be tricky. It seems to be [possible with 1328 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1329 + 1330 + </details> 1331 + 1332 + To begin, grab your DID from http://localhost:3000/settings. 1333 + Then, set `TANGLED_VM_KNOT_OWNER` and 1334 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 1335 + lightweight NixOS VM like so: 1336 + 1337 + ```bash 1338 + nix run --impure .#vm 1339 + 1340 + # type `poweroff` at the shell to exit the VM 1341 + ``` 1342 + 1343 + This starts a knot on port 6444, a spindle on port 6555 1344 + with `ssh` exposed on port 2222. 1345 + 1346 + Once the services are running, head to 1347 + http://localhost:3000/settings/knots and hit "Verify". It should 1348 + verify the ownership of the services instantly if everything 1349 + went smoothly. 1350 + 1351 + You can push repositories to this VM with this ssh config 1352 + block on your main machine: 1353 + 1354 + ```bash 1355 + Host nixos-shell 1356 + Hostname localhost 1357 + Port 2222 1358 + User git 1359 + IdentityFile ~/.ssh/my_tangled_key 1360 + ``` 1361 + 1362 + Set up a remote called `local-dev` on a git repo: 1363 + 1364 + ```bash 1365 + git remote add local-dev git@nixos-shell:user/repo 1366 + git push local-dev main 1367 + ``` 1368 + 1369 + The above VM should already be running a spindle on 1370 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 1371 + hit "Verify". You can then configure each repository to use 1372 + this spindle and run CI jobs. 1373 + 1374 + Of interest when debugging spindles: 1375 + 1376 + ``` 1377 + # Service logs from journald: 1378 + journalctl -xeu spindle 1379 + 1380 + # CI job logs from disk: 1381 + ls /var/log/spindle 1382 + 1383 + # Debugging spindle database: 1384 + sqlite3 /var/lib/spindle/spindle.db 1385 + 1386 + # litecli has a nicer REPL interface: 1387 + litecli /var/lib/spindle/spindle.db 1388 + ``` 1389 + 1390 + If for any reason you wish to disable either one of the 1391 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 1392 + `services.tangled.spindle.enable` (or 1393 + `services.tangled.knot.enable`) to `false`. 1394 + 1395 + # Contribution guide 1396 + 1397 + ## Commit guidelines 1398 + 1399 + We follow a commit style similar to the Go project. Please keep commits: 1400 + 1401 + * **atomic**: each commit should represent one logical change 1402 + * **descriptive**: the commit message should clearly describe what the 1403 + change does and why it's needed 1404 + 1405 + ### Message format 1406 + 1407 + ``` 1408 + <service/top-level directory>/<affected package/directory>: <short summary of change> 1409 + 1410 + Optional longer description can go here, if necessary. Explain what the 1411 + change does and why, especially if not obvious. Reference relevant 1412 + issues or PRs when applicable. These can be links for now since we don't 1413 + auto-link issues/PRs yet. 1414 + ``` 1415 + 1416 + Here are some examples: 1417 + 1418 + ``` 1419 + appview/state: fix token expiry check in middleware 1420 + 1421 + The previous check did not account for clock drift, leading to premature 1422 + token invalidation. 1423 + ``` 1424 + 1425 + ``` 1426 + knotserver/git/service: improve error checking in upload-pack 1427 + ``` 1428 + 1429 + 1430 + ### General notes 1431 + 1432 + - PRs get merged "as-is" (fast-forward)โ€”like applying a patch-series 1433 + using `git am`. At present, there is no squashingโ€”so please author 1434 + your commits as they would appear on `master`, following the above 1435 + guidelines. 1436 + - If there is a lot of nesting, for example "appview: 1437 + pages/templates/repo/fragments: ...", these can be truncated down to 1438 + just "appview: repo/fragments: ...". If the change affects a lot of 1439 + subdirectories, you may abbreviate to just the top-level names, e.g. 1440 + "appview: ..." or "knotserver: ...". 1441 + - Keep commits lowercased with no trailing period. 1442 + - Use the imperative mood in the summary line (e.g., "fix bug" not 1443 + "fixed bug" or "fixes bug"). 1444 + - Try to keep the summary line under 72 characters, but we aren't too 1445 + fussed about this. 1446 + - Follow the same formatting for PR titles if filled manually. 1447 + - Don't include unrelated changes in the same commit. 1448 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 1449 + before submitting if necessary. 1450 + 1451 + ## Code formatting 1452 + 1453 + We use a variety of tools to format our code, and multiplex them with 1454 + [`treefmt`](https://treefmt.com). All you need to do to format your changes 1455 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 1456 + 1457 + ## Proposals for bigger changes 1458 + 1459 + Small fixes like typos, minor bugs, or trivial refactors can be 1460 + submitted directly as PRs. 1461 + 1462 + For larger changesโ€”especially those introducing new features, significant 1463 + refactoring, or altering system behaviorโ€”please open a proposal first. This 1464 + helps us evaluate the scope, design, and potential impact before implementation. 1465 + 1466 + Create a new issue titled: 1467 + 1468 + ``` 1469 + proposal: <affected scope>: <summary of change> 1470 + ``` 1471 + 1472 + In the description, explain: 1473 + 1474 + - What the change is 1475 + - Why it's needed 1476 + - How you plan to implement it (roughly) 1477 + - Any open questions or tradeoffs 1478 + 1479 + We'll use the issue thread to discuss and refine the idea before moving 1480 + forward. 1481 + 1482 + ## Developer Certificate of Origin (DCO) 1483 + 1484 + We require all contributors to certify that they have the right to 1485 + submit the code they're contributing. To do this, we follow the 1486 + [Developer Certificate of Origin 1487 + (DCO)](https://developercertificate.org/). 1488 + 1489 + By signing your commits, you're stating that the contribution is your 1490 + own work, or that you have the right to submit it under the project's 1491 + license. This helps us keep things clean and legally sound. 1492 + 1493 + To sign your commit, just add the `-s` flag when committing: 1494 + 1495 + ```sh 1496 + git commit -s -m "your commit message" 1497 + ``` 1498 + 1499 + This appends a line like: 1500 + 1501 + ``` 1502 + Signed-off-by: Your Name <your.email@example.com> 1503 + ``` 1504 + 1505 + We won't merge commits if they aren't signed off. If you forget, you can 1506 + amend the last commit like this: 1507 + 1508 + ```sh 1509 + git commit --amend -s 1510 + ``` 1511 + 1512 + If you're submitting a PR with multiple commits, make sure each one is 1513 + signed. 1514 + 1515 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 1516 + to make it sign off commits in the tangled repo: 1517 + 1518 + ```shell 1519 + # Safety check, should say "No matching config key..." 1520 + jj config list templates.commit_trailers 1521 + # The command below may need to be adjusted if the command above returned something. 1522 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 1523 + ``` 1524 + 1525 + Refer to the [jujutsu 1526 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1527 + for more information.
-136
docs/contributing.md
··· 1 - # tangled contributing guide 2 - 3 - ## commit guidelines 4 - 5 - We follow a commit style similar to the Go project. Please keep commits: 6 - 7 - * **atomic**: each commit should represent one logical change 8 - * **descriptive**: the commit message should clearly describe what the 9 - change does and why it's needed 10 - 11 - ### message format 12 - 13 - ``` 14 - <service/top-level directory>/<affected package/directory>: <short summary of change> 15 - 16 - 17 - Optional longer description can go here, if necessary. Explain what the 18 - change does and why, especially if not obvious. Reference relevant 19 - issues or PRs when applicable. These can be links for now since we don't 20 - auto-link issues/PRs yet. 21 - ``` 22 - 23 - Here are some examples: 24 - 25 - ``` 26 - appview/state: fix token expiry check in middleware 27 - 28 - The previous check did not account for clock drift, leading to premature 29 - token invalidation. 30 - ``` 31 - 32 - ``` 33 - knotserver/git/service: improve error checking in upload-pack 34 - ``` 35 - 36 - 37 - ### general notes 38 - 39 - - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 40 - using `git am`. At present, there is no squashing -- so please author 41 - your commits as they would appear on `master`, following the above 42 - guidelines. 43 - - If there is a lot of nesting, for example "appview: 44 - pages/templates/repo/fragments: ...", these can be truncated down to 45 - just "appview: repo/fragments: ...". If the change affects a lot of 46 - subdirectories, you may abbreviate to just the top-level names, e.g. 47 - "appview: ..." or "knotserver: ...". 48 - - Keep commits lowercased with no trailing period. 49 - - Use the imperative mood in the summary line (e.g., "fix bug" not 50 - "fixed bug" or "fixes bug"). 51 - - Try to keep the summary line under 72 characters, but we aren't too 52 - fussed about this. 53 - - Follow the same formatting for PR titles if filled manually. 54 - - Don't include unrelated changes in the same commit. 55 - - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 - before submitting if necessary. 57 - 58 - ## code formatting 59 - 60 - We use a variety of tools to format our code, and multiplex them with 61 - [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 - is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 - 64 - ## proposals for bigger changes 65 - 66 - Small fixes like typos, minor bugs, or trivial refactors can be 67 - submitted directly as PRs. 68 - 69 - For larger changesโ€”especially those introducing new features, significant 70 - refactoring, or altering system behaviorโ€”please open a proposal first. This 71 - helps us evaluate the scope, design, and potential impact before implementation. 72 - 73 - ### proposal format 74 - 75 - Create a new issue titled: 76 - 77 - ``` 78 - proposal: <affected scope>: <summary of change> 79 - ``` 80 - 81 - In the description, explain: 82 - 83 - - What the change is 84 - - Why it's needed 85 - - How you plan to implement it (roughly) 86 - - Any open questions or tradeoffs 87 - 88 - We'll use the issue thread to discuss and refine the idea before moving 89 - forward. 90 - 91 - ## developer certificate of origin (DCO) 92 - 93 - We require all contributors to certify that they have the right to 94 - submit the code they're contributing. To do this, we follow the 95 - [Developer Certificate of Origin 96 - (DCO)](https://developercertificate.org/). 97 - 98 - By signing your commits, you're stating that the contribution is your 99 - own work, or that you have the right to submit it under the project's 100 - license. This helps us keep things clean and legally sound. 101 - 102 - To sign your commit, just add the `-s` flag when committing: 103 - 104 - ```sh 105 - git commit -s -m "your commit message" 106 - ``` 107 - 108 - This appends a line like: 109 - 110 - ``` 111 - Signed-off-by: Your Name <your.email@example.com> 112 - ``` 113 - 114 - We won't merge commits if they aren't signed off. If you forget, you can 115 - amend the last commit like this: 116 - 117 - ```sh 118 - git commit --amend -s 119 - ``` 120 - 121 - If you're submitting a PR with multiple commits, make sure each one is 122 - signed. 123 - 124 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 - to make it sign off commits in the tangled repo: 126 - 127 - ```shell 128 - # Safety check, should say "No matching config key..." 129 - jj config list templates.commit_trailers 130 - # The command below may need to be adjusted if the command above returned something. 131 - jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 132 - ``` 133 - 134 - Refer to the [jj 135 - documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 136 - for more information.
-172
docs/hacking.md
··· 1 - # hacking on tangled 2 - 3 - We highly recommend [installing 4 - nix](https://nixos.org/download/) (the package manager) 5 - before working on the codebase. The nix flake provides a lot 6 - of helpers to get started and most importantly, builds and 7 - dev shells are entirely deterministic. 8 - 9 - To set up your dev environment: 10 - 11 - ```bash 12 - nix develop 13 - ``` 14 - 15 - Non-nix users can look at the `devShell` attribute in the 16 - `flake.nix` file to determine necessary dependencies. 17 - 18 - ## running the appview 19 - 20 - The nix flake also exposes a few `app` attributes (run `nix 21 - flake show` to see a full list of what the flake provides), 22 - one of the apps runs the appview with the `air` 23 - live-reloader: 24 - 25 - ```bash 26 - TANGLED_DEV=true nix run .#watch-appview 27 - 28 - # TANGLED_DB_PATH might be of interest to point to 29 - # different sqlite DBs 30 - 31 - # in a separate shell, you can live-reload tailwind 32 - nix run .#watch-tailwind 33 - ``` 34 - 35 - To authenticate with the appview, you will need redis and 36 - OAUTH JWKs to be setup: 37 - 38 - ``` 39 - # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_CLIENT_SECRET 41 - z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 - 43 - echo $TANGLED_OAUTH_CLIENT_KID 44 - 1761667908 45 - 46 - # if not, you can set it up yourself: 47 - goat key generate -t P-256 48 - Key Type: P-256 / secp256r1 / ES256 private key 49 - Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 - z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 - Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 - did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 - 54 - # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 - 57 - # run redis in at a new shell to store oauth sessions 58 - redis-server 59 - ``` 60 - 61 - ## running knots and spindles 62 - 63 - An end-to-end knot setup requires setting up a machine with 64 - `sshd`, `AuthorizedKeysCommand`, and git user, which is 65 - quite cumbersome. So the nix flake provides a 66 - `nixosConfiguration` to do so. 67 - 68 - <details> 69 - <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 70 - 71 - In order to build Tangled's dev VM on macOS, you will 72 - first need to set up a Linux Nix builder. The recommended 73 - way to do so is to run a [`darwin.linux-builder` 74 - VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 75 - and to register it in `nix.conf` as a builder for Linux 76 - with the same architecture as your Mac (`linux-aarch64` if 77 - you are using Apple Silicon). 78 - 79 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 80 - > the tangled repo so that it doesn't conflict with the other VM. For example, 81 - > you can do 82 - > 83 - > ```shell 84 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 85 - > ``` 86 - > 87 - > to store the builder VM in a temporary dir. 88 - > 89 - > You should read and follow [all the other intructions][darwin builder vm] to 90 - > avoid subtle problems. 91 - 92 - Alternatively, you can use any other method to set up a 93 - Linux machine with `nix` installed that you can `sudo ssh` 94 - into (in other words, root user on your Mac has to be able 95 - to ssh into the Linux machine without entering a password) 96 - and that has the same architecture as your Mac. See 97 - [remote builder 98 - instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 99 - for how to register such a builder in `nix.conf`. 100 - 101 - > WARNING: If you'd like to use 102 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 103 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 104 - > ssh` works can be tricky. It seems to be [possible with 105 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 106 - 107 - </details> 108 - 109 - To begin, grab your DID from http://localhost:3000/settings. 110 - Then, set `TANGLED_VM_KNOT_OWNER` and 111 - `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 112 - lightweight NixOS VM like so: 113 - 114 - ```bash 115 - nix run --impure .#vm 116 - 117 - # type `poweroff` at the shell to exit the VM 118 - ``` 119 - 120 - This starts a knot on port 6444, a spindle on port 6555 121 - with `ssh` exposed on port 2222. 122 - 123 - Once the services are running, head to 124 - http://localhost:3000/settings/knots and hit verify. It should 125 - verify the ownership of the services instantly if everything 126 - went smoothly. 127 - 128 - You can push repositories to this VM with this ssh config 129 - block on your main machine: 130 - 131 - ```bash 132 - Host nixos-shell 133 - Hostname localhost 134 - Port 2222 135 - User git 136 - IdentityFile ~/.ssh/my_tangled_key 137 - ``` 138 - 139 - Set up a remote called `local-dev` on a git repo: 140 - 141 - ```bash 142 - git remote add local-dev git@nixos-shell:user/repo 143 - git push local-dev main 144 - ``` 145 - 146 - ### running a spindle 147 - 148 - The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/settings/spindles and 150 - hit verify. You can then configure each repository to use 151 - this spindle and run CI jobs. 152 - 153 - Of interest when debugging spindles: 154 - 155 - ``` 156 - # service logs from journald: 157 - journalctl -xeu spindle 158 - 159 - # CI job logs from disk: 160 - ls /var/log/spindle 161 - 162 - # debugging spindle db: 163 - sqlite3 /var/lib/spindle/spindle.db 164 - 165 - # litecli has a nicer REPL interface: 166 - litecli /var/lib/spindle/spindle.db 167 - ``` 168 - 169 - If for any reason you wish to disable either one of the 170 - services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled.spindle.enable` (or 172 - `services.tangled.knot.enable`) to `false`.
+93
docs/highlight.theme
··· 1 + { 2 + "text-color": null, 3 + "background-color": null, 4 + "line-number-color": null, 5 + "line-number-background-color": null, 6 + "text-styles": { 7 + "Annotation": { 8 + "text-color": null, 9 + "background-color": null, 10 + "bold": false, 11 + "italic": true, 12 + "underline": false 13 + }, 14 + "ControlFlow": { 15 + "text-color": null, 16 + "background-color": null, 17 + "bold": true, 18 + "italic": false, 19 + "underline": false 20 + }, 21 + "Error": { 22 + "text-color": null, 23 + "background-color": null, 24 + "bold": true, 25 + "italic": false, 26 + "underline": false 27 + }, 28 + "Alert": { 29 + "text-color": null, 30 + "background-color": null, 31 + "bold": true, 32 + "italic": false, 33 + "underline": false 34 + }, 35 + "Preprocessor": { 36 + "text-color": null, 37 + "background-color": null, 38 + "bold": true, 39 + "italic": false, 40 + "underline": false 41 + }, 42 + "Information": { 43 + "text-color": null, 44 + "background-color": null, 45 + "bold": false, 46 + "italic": true, 47 + "underline": false 48 + }, 49 + "Warning": { 50 + "text-color": null, 51 + "background-color": null, 52 + "bold": false, 53 + "italic": true, 54 + "underline": false 55 + }, 56 + "Documentation": { 57 + "text-color": null, 58 + "background-color": null, 59 + "bold": false, 60 + "italic": true, 61 + "underline": false 62 + }, 63 + "DataType": { 64 + "text-color": "#8f4e8b", 65 + "background-color": null, 66 + "bold": false, 67 + "italic": false, 68 + "underline": false 69 + }, 70 + "Comment": { 71 + "text-color": null, 72 + "background-color": null, 73 + "bold": false, 74 + "italic": true, 75 + "underline": false 76 + }, 77 + "CommentVar": { 78 + "text-color": null, 79 + "background-color": null, 80 + "bold": false, 81 + "italic": true, 82 + "underline": false 83 + }, 84 + "Keyword": { 85 + "text-color": null, 86 + "background-color": null, 87 + "bold": true, 88 + "italic": false, 89 + "underline": false 90 + } 91 + } 92 + } 93 +
-214
docs/knot-hosting.md
··· 1 - # knot self-hosting guide 2 - 3 - So you want to run your own knot server? Great! Here are a few prerequisites: 4 - 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 - 2. A (sub)domain name. People generally use `knot.example.com`. 7 - 3. A valid SSL certificate for your domain. 8 - 9 - There's a couple of ways to get started: 10 - * NixOS: refer to 11 - [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 12 - * Docker: Documented at 13 - [@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker) 14 - (community maintained: support is not guaranteed!) 15 - * Manual: Documented below. 16 - 17 - ## manual setup 18 - 19 - First, clone this repository: 20 - 21 - ``` 22 - git clone https://tangled.org/@tangled.org/core 23 - ``` 24 - 25 - Then, build the `knot` CLI. This is the knot administration and operation tool. 26 - For the purpose of this guide, we're only concerned with these subcommands: 27 - 28 - * `knot server`: the main knot server process, typically run as a 29 - supervised service 30 - * `knot guard`: handles role-based access control for git over SSH 31 - (you'll never have to run this yourself) 32 - * `knot keys`: fetches SSH keys associated with your knot; we'll use 33 - this to generate the SSH `AuthorizedKeysCommand` 34 - 35 - ``` 36 - cd core 37 - export CGO_ENABLED=1 38 - go build -o knot ./cmd/knot 39 - ``` 40 - 41 - Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 - 44 - ``` 45 - sudo mv knot /usr/local/bin/knot 46 - sudo chown root:root /usr/local/bin/knot 47 - ``` 48 - 49 - This is necessary because SSH `AuthorizedKeysCommand` requires [really 50 - specific permissions](https://stackoverflow.com/a/27638306). The 51 - `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 52 - retrieve a user's public SSH keys dynamically for authentication. Let's 53 - set that up. 54 - 55 - ``` 56 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 57 - Match User git 58 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 59 - AuthorizedKeysCommandUser nobody 60 - EOF 61 - ``` 62 - 63 - Then, reload `sshd`: 64 - 65 - ``` 66 - sudo systemctl reload ssh 67 - ``` 68 - 69 - Next, create the `git` user. We'll use the `git` user's home directory 70 - to store repositories: 71 - 72 - ``` 73 - sudo adduser git 74 - ``` 75 - 76 - Create `/home/git/.knot.env` with the following, updating the values as 77 - necessary. The `KNOT_SERVER_OWNER` should be set to your 78 - DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 79 - 80 - ``` 81 - KNOT_REPO_SCAN_PATH=/home/git 82 - KNOT_SERVER_HOSTNAME=knot.example.com 83 - APPVIEW_ENDPOINT=https://tangled.sh 84 - KNOT_SERVER_OWNER=did:plc:foobar 85 - KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 86 - KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 87 - ``` 88 - 89 - If you run a Linux distribution that uses systemd, you can use the provided 90 - service file to run the server. Copy 91 - [`knotserver.service`](/systemd/knotserver.service) 92 - to `/etc/systemd/system/`. Then, run: 93 - 94 - ``` 95 - systemctl enable knotserver 96 - systemctl start knotserver 97 - ``` 98 - 99 - The last step is to configure a reverse proxy like Nginx or Caddy to front your 100 - knot. Here's an example configuration for Nginx: 101 - 102 - ``` 103 - server { 104 - listen 80; 105 - listen [::]:80; 106 - server_name knot.example.com; 107 - 108 - location / { 109 - proxy_pass http://localhost:5555; 110 - proxy_set_header Host $host; 111 - proxy_set_header X-Real-IP $remote_addr; 112 - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 113 - proxy_set_header X-Forwarded-Proto $scheme; 114 - } 115 - 116 - # wss endpoint for git events 117 - location /events { 118 - proxy_set_header X-Forwarded-For $remote_addr; 119 - proxy_set_header Host $http_host; 120 - proxy_set_header Upgrade websocket; 121 - proxy_set_header Connection Upgrade; 122 - proxy_pass http://localhost:5555; 123 - } 124 - # additional config for SSL/TLS go here. 125 - } 126 - 127 - ``` 128 - 129 - Remember to use Let's Encrypt or similar to procure a certificate for your 130 - knot domain. 131 - 132 - You should now have a running knot server! You can finalize 133 - your registration by hitting the `verify` button on the 134 - [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 135 - a record on your PDS to announce the existence of the knot. 136 - 137 - ### custom paths 138 - 139 - (This section applies to manual setup only. Docker users should edit the mounts 140 - in `docker-compose.yml` instead.) 141 - 142 - Right now, the database and repositories of your knot lives in `/home/git`. You 143 - can move these paths if you'd like to store them in another folder. Be careful 144 - when adjusting these paths: 145 - 146 - * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 147 - any possible side effects. Remember to restart it once you're done. 148 - * Make backups before moving in case something goes wrong. 149 - * Make sure the `git` user can read and write from the new paths. 150 - 151 - #### database 152 - 153 - As an example, let's say the current database is at `/home/git/knotserver.db`, 154 - and we want to move it to `/home/git/database/knotserver.db`. 155 - 156 - Copy the current database to the new location. Make sure to copy the `.db-shm` 157 - and `.db-wal` files if they exist. 158 - 159 - ``` 160 - mkdir /home/git/database 161 - cp /home/git/knotserver.db* /home/git/database 162 - ``` 163 - 164 - In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 165 - the new file path (_not_ the directory): 166 - 167 - ``` 168 - KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 169 - ``` 170 - 171 - #### repositories 172 - 173 - As an example, let's say the repositories are currently in `/home/git`, and we 174 - want to move them into `/home/git/repositories`. 175 - 176 - Create the new folder, then move the existing repositories (if there are any): 177 - 178 - ``` 179 - mkdir /home/git/repositories 180 - # move all DIDs into the new folder; these will vary for you! 181 - mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 182 - ``` 183 - 184 - In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 185 - to the new directory: 186 - 187 - ``` 188 - KNOT_REPO_SCAN_PATH=/home/git/repositories 189 - ``` 190 - 191 - Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 192 - repository path: 193 - 194 - ``` 195 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 196 - Match User git 197 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 198 - AuthorizedKeysCommandUser nobody 199 - EOF 200 - ``` 201 - 202 - Make sure to restart your SSH server! 203 - 204 - #### MOTD (message of the day) 205 - 206 - To configure the MOTD used ("Welcome to this knot!" by default), edit the 207 - `/home/git/motd` file: 208 - 209 - ``` 210 - printf "Hi from this knot!\n" > /home/git/motd 211 - ``` 212 - 213 - Note that you should add a newline at the end if setting a non-empty message 214 - since the knot won't do this for you.
+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>
-59
docs/migrations.md
··· 1 - # Migrations 2 - 3 - This document is laid out in reverse-chronological order. 4 - Newer migration guides are listed first, and older guides 5 - are further down the page. 6 - 7 - ## Upgrading from v1.8.x 8 - 9 - After v1.8.2, the HTTP API for knot and spindles have been 10 - deprecated and replaced with XRPC. Repositories on outdated 11 - knots will not be viewable from the appview. Upgrading is 12 - straightforward however. 13 - 14 - For knots: 15 - 16 - - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 18 - hit the "retry" button to verify your knot 19 - 20 - For spindles: 21 - 22 - - Upgrade to latest tag (v1.9.0 or above) 23 - - Head to the [spindle 24 - dashboard](https://tangled.org/settings/spindles) and hit the 25 - "retry" button to verify your spindle 26 - 27 - ## Upgrading from v1.7.x 28 - 29 - After v1.7.0, knot secrets have been deprecated. You no 30 - longer need a secret from the appview to run a knot. All 31 - authorized commands to knots are managed via [Inter-Service 32 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 - Knots will be read-only until upgraded. 34 - 35 - Upgrading is quite easy, in essence: 36 - 37 - - `KNOT_SERVER_SECRET` is no more, you can remove this 38 - environment variable entirely 39 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 - your DID. You can find your DID in the 41 - [settings](https://tangled.org/settings) page. 42 - - Restart your knot once you have replaced the environment 43 - variable 44 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 45 - hit the "retry" button to verify your knot. This simply 46 - writes a `sh.tangled.knot` record to your PDS. 47 - 48 - If you use the nix module, simply bump the flake to the 49 - latest revision, and change your config block like so: 50 - 51 - ```diff 52 - services.tangled.knot = { 53 - enable = true; 54 - server = { 55 - - secretFile = /path/to/secret; 56 - + owner = "did:plc:foo"; 57 - }; 58 - }; 59 - ```
+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>
-25
docs/spindle/architecture.md
··· 1 - # spindle architecture 2 - 3 - Spindle is a small CI runner service. Here's a high level overview of how it operates: 4 - 5 - * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 6 - [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 7 - * when a new repo record comes through (typically when you add a spindle to a 8 - repo from the settings), spindle then resolves the underlying knot and 9 - subscribes to repo events (see: 10 - [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 11 - * the spindle engine then handles execution of the pipeline, with results and 12 - logs beamed on the spindle event stream over wss 13 - 14 - ### the engine 15 - 16 - At present, the only supported backend is Docker (and Podman, if Docker 17 - compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 - executes each step in the pipeline in a fresh container, with state persisted 19 - across steps within the `/tangled/workspace` directory. 20 - 21 - The base image for the container is constructed on the fly using 22 - [Nixery](https://nixery.dev), which is handy for caching layers for frequently 23 - used packages. 24 - 25 - The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
-52
docs/spindle/hosting.md
··· 1 - # spindle self-hosting guide 2 - 3 - ## prerequisites 4 - 5 - * Go 6 - * Docker (the only supported backend currently) 7 - 8 - ## configuration 9 - 10 - Spindle is configured using environment variables. The following environment variables are available: 11 - 12 - * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 13 - * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 14 - * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 15 - * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 16 - * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 17 - * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 18 - * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 19 - * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 20 - * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 21 - 22 - ## running spindle 23 - 24 - 1. **Set the environment variables.** For example: 25 - 26 - ```shell 27 - export SPINDLE_SERVER_HOSTNAME="your-hostname" 28 - export SPINDLE_SERVER_OWNER="your-did" 29 - ``` 30 - 31 - 2. **Build the Spindle binary.** 32 - 33 - ```shell 34 - cd core 35 - go mod download 36 - go build -o cmd/spindle/spindle cmd/spindle/main.go 37 - ``` 38 - 39 - 3. **Create the log directory.** 40 - 41 - ```shell 42 - sudo mkdir -p /var/log/spindle 43 - sudo chown $USER:$USER -R /var/log/spindle 44 - ``` 45 - 46 - 4. **Run the Spindle binary.** 47 - 48 - ```shell 49 - ./cmd/spindle/spindle 50 - ``` 51 - 52 - Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
-285
docs/spindle/openbao.md
··· 1 - # spindle secrets with openbao 2 - 3 - This document covers setting up Spindle to use OpenBao for secrets 4 - management via OpenBao Proxy instead of the default SQLite backend. 5 - 6 - ## overview 7 - 8 - Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 - authentication automatically using AppRole credentials, while Spindle 10 - connects to the local proxy instead of directly to the OpenBao server. 11 - 12 - This approach provides better security, automatic token renewal, and 13 - simplified application code. 14 - 15 - ## installation 16 - 17 - Install OpenBao from nixpkgs: 18 - 19 - ```bash 20 - nix shell nixpkgs#openbao # for a local server 21 - ``` 22 - 23 - ## setup 24 - 25 - The setup process can is documented for both local development and production. 26 - 27 - ### local development 28 - 29 - Start OpenBao in dev mode: 30 - 31 - ```bash 32 - bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 - ``` 34 - 35 - This starts OpenBao on `http://localhost:8201` with a root token. 36 - 37 - Set up environment for bao CLI: 38 - 39 - ```bash 40 - export BAO_ADDR=http://localhost:8200 41 - export BAO_TOKEN=root 42 - ``` 43 - 44 - ### production 45 - 46 - You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 - achieved using Nix. 49 - 50 - Then, initialize the bao server: 51 - ```bash 52 - bao operator init -key-shares=1 -key-threshold=1 53 - ``` 54 - 55 - This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 - ```bash 57 - bao operator unseal <unseal_key> 58 - ``` 59 - 60 - All steps below remain the same across both dev and production setups. 61 - 62 - ### configure openbao server 63 - 64 - Create the spindle KV mount: 65 - 66 - ```bash 67 - bao secrets enable -path=spindle -version=2 kv 68 - ``` 69 - 70 - Set up AppRole authentication and policy: 71 - 72 - Create a policy file `spindle-policy.hcl`: 73 - 74 - ```hcl 75 - # Full access to spindle KV v2 data 76 - path "spindle/data/*" { 77 - capabilities = ["create", "read", "update", "delete"] 78 - } 79 - 80 - # Access to metadata for listing and management 81 - path "spindle/metadata/*" { 82 - capabilities = ["list", "read", "delete", "update"] 83 - } 84 - 85 - # Allow listing at root level 86 - path "spindle/" { 87 - capabilities = ["list"] 88 - } 89 - 90 - # Required for connection testing and health checks 91 - path "auth/token/lookup-self" { 92 - capabilities = ["read"] 93 - } 94 - ``` 95 - 96 - Apply the policy and create an AppRole: 97 - 98 - ```bash 99 - bao policy write spindle-policy spindle-policy.hcl 100 - bao auth enable approle 101 - bao write auth/approle/role/spindle \ 102 - token_policies="spindle-policy" \ 103 - token_ttl=1h \ 104 - token_max_ttl=4h \ 105 - bind_secret_id=true \ 106 - secret_id_ttl=0 \ 107 - secret_id_num_uses=0 108 - ``` 109 - 110 - Get the credentials: 111 - 112 - ```bash 113 - # Get role ID (static) 114 - ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 - 116 - # Generate secret ID 117 - SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 - 119 - echo "Role ID: $ROLE_ID" 120 - echo "Secret ID: $SECRET_ID" 121 - ``` 122 - 123 - ### create proxy configuration 124 - 125 - Create the credential files: 126 - 127 - ```bash 128 - # Create directory for OpenBao files 129 - mkdir -p /tmp/openbao 130 - 131 - # Save credentials 132 - echo "$ROLE_ID" > /tmp/openbao/role-id 133 - echo "$SECRET_ID" > /tmp/openbao/secret-id 134 - chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 - ``` 136 - 137 - Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 - 139 - ```hcl 140 - # OpenBao server connection 141 - vault { 142 - address = "http://localhost:8200" 143 - } 144 - 145 - # Auto-Auth using AppRole 146 - auto_auth { 147 - method "approle" { 148 - mount_path = "auth/approle" 149 - config = { 150 - role_id_file_path = "/tmp/openbao/role-id" 151 - secret_id_file_path = "/tmp/openbao/secret-id" 152 - } 153 - } 154 - 155 - # Optional: write token to file for debugging 156 - sink "file" { 157 - config = { 158 - path = "/tmp/openbao/token" 159 - mode = 0640 160 - } 161 - } 162 - } 163 - 164 - # Proxy listener for Spindle 165 - listener "tcp" { 166 - address = "127.0.0.1:8201" 167 - tls_disable = true 168 - } 169 - 170 - # Enable API proxy with auto-auth token 171 - api_proxy { 172 - use_auto_auth_token = true 173 - } 174 - 175 - # Enable response caching 176 - cache { 177 - use_auto_auth_token = true 178 - } 179 - 180 - # Logging 181 - log_level = "info" 182 - ``` 183 - 184 - ### start the proxy 185 - 186 - Start OpenBao Proxy: 187 - 188 - ```bash 189 - bao proxy -config=/tmp/openbao/proxy.hcl 190 - ``` 191 - 192 - The proxy will authenticate with OpenBao and start listening on 193 - `127.0.0.1:8201`. 194 - 195 - ### configure spindle 196 - 197 - Set these environment variables for Spindle: 198 - 199 - ```bash 200 - export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 - export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 - export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 - ``` 204 - 205 - Start Spindle: 206 - 207 - Spindle will now connect to the local proxy, which handles all 208 - authentication automatically. 209 - 210 - ## production setup for proxy 211 - 212 - For production, you'll want to run the proxy as a service: 213 - 214 - Place your production configuration in `/etc/openbao/proxy.hcl` with 215 - proper TLS settings for the vault connection. 216 - 217 - ## verifying setup 218 - 219 - Test the proxy directly: 220 - 221 - ```bash 222 - # Check proxy health 223 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 - 225 - # Test token lookup through proxy 226 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 - ``` 228 - 229 - Test OpenBao operations through the server: 230 - 231 - ```bash 232 - # List all secrets 233 - bao kv list spindle/ 234 - 235 - # Add a test secret via Spindle API, then check it exists 236 - bao kv list spindle/repos/ 237 - 238 - # Get a specific secret 239 - bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 - ``` 241 - 242 - ## how it works 243 - 244 - - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 - - The proxy authenticates with OpenBao using AppRole credentials 246 - - All Spindle requests go through the proxy, which injects authentication tokens 247 - - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 - - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 - - The proxy handles all token renewal automatically 250 - - Spindle no longer manages tokens or authentication directly 251 - 252 - ## troubleshooting 253 - 254 - **Connection refused**: Check that the OpenBao Proxy is running and 255 - listening on the configured address. 256 - 257 - **403 errors**: Verify the AppRole credentials are correct and the policy 258 - has the necessary permissions. 259 - 260 - **404 route errors**: The spindle KV mount probably doesn't exist - run 261 - the mount creation step again. 262 - 263 - **Proxy authentication failures**: Check the proxy logs and verify the 264 - role-id and secret-id files are readable and contain valid credentials. 265 - 266 - **Secret not found after writing**: This can indicate policy permission 267 - issues. Verify the policy includes both `spindle/data/*` and 268 - `spindle/metadata/*` paths with appropriate capabilities. 269 - 270 - Check proxy logs: 271 - 272 - ```bash 273 - # If running as systemd service 274 - journalctl -u openbao-proxy -f 275 - 276 - # If running directly, check the console output 277 - ``` 278 - 279 - Test AppRole authentication manually: 280 - 281 - ```bash 282 - bao write auth/approle/login \ 283 - role_id="$(cat /tmp/openbao/role-id)" \ 284 - secret_id="$(cat /tmp/openbao/secret-id)" 285 - ```
-183
docs/spindle/pipeline.md
··· 1 - # spindle pipelines 2 - 3 - Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 - 5 - The fields are: 6 - 7 - - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 - - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 - - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 - - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 - - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 - - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 - 14 - ## Trigger 15 - 16 - The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 - 18 - - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - - `push`: The workflow should run every time a commit is pushed to the repository. 20 - - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - - `manual`: The workflow can be triggered manually. 22 - - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 - - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 - 25 - For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26 - 27 - ```yaml 28 - when: 29 - - event: ["push", "manual"] 30 - branch: ["main", "develop"] 31 - - event: ["pull_request"] 32 - branch: ["main"] 33 - ``` 34 - 35 - You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: 36 - 37 - ```yaml 38 - when: 39 - - event: ["push"] 40 - tag: ["v*"] 41 - ``` 42 - 43 - You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): 44 - 45 - ```yaml 46 - when: 47 - - event: ["push"] 48 - branch: ["main", "release-*"] 49 - tag: ["v*", "stable"] 50 - ``` 51 - 52 - ## Engine 53 - 54 - Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 55 - 56 - - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 57 - 58 - Example: 59 - 60 - ```yaml 61 - engine: "nixery" 62 - ``` 63 - 64 - ## Clone options 65 - 66 - When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 67 - 68 - - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 69 - - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 70 - - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 71 - 72 - The default settings are: 73 - 74 - ```yaml 75 - clone: 76 - skip: false 77 - depth: 1 78 - submodules: false 79 - ``` 80 - 81 - ## Dependencies 82 - 83 - Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 84 - 85 - Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 86 - 87 - ```yaml 88 - dependencies: 89 - # nixpkgs 90 - nixpkgs: 91 - - nodejs 92 - - go 93 - # custom registry 94 - git+https://tangled.org/@example.com/my_pkg: 95 - - my_pkg 96 - ``` 97 - 98 - Now these dependencies are available to use in your workflow! 99 - 100 - ## Environment 101 - 102 - The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 - 104 - Example: 105 - 106 - ```yaml 107 - environment: 108 - GOOS: "linux" 109 - GOARCH: "arm64" 110 - NODE_ENV: "production" 111 - MY_ENV_VAR: "MY_ENV_VALUE" 112 - ``` 113 - 114 - ## Steps 115 - 116 - The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 117 - 118 - - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 119 - - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 120 - - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 121 - 122 - Example: 123 - 124 - ```yaml 125 - steps: 126 - - name: "Build backend" 127 - command: "go build" 128 - environment: 129 - GOOS: "darwin" 130 - GOARCH: "arm64" 131 - - name: "Build frontend" 132 - command: "npm run build" 133 - environment: 134 - NODE_ENV: "production" 135 - ``` 136 - 137 - ## Complete workflow 138 - 139 - ```yaml 140 - # .tangled/workflows/build.yml 141 - 142 - when: 143 - - event: ["push", "manual"] 144 - branch: ["main", "develop"] 145 - - event: ["pull_request"] 146 - branch: ["main"] 147 - 148 - engine: "nixery" 149 - 150 - # using the default values 151 - clone: 152 - skip: false 153 - depth: 1 154 - submodules: false 155 - 156 - dependencies: 157 - # nixpkgs 158 - nixpkgs: 159 - - nodejs 160 - - go 161 - # custom registry 162 - git+https://tangled.org/@example.com/my_pkg: 163 - - my_pkg 164 - 165 - environment: 166 - GOOS: "linux" 167 - GOARCH: "arm64" 168 - NODE_ENV: "production" 169 - MY_ENV_VAR: "MY_ENV_VALUE" 170 - 171 - steps: 172 - - name: "Build backend" 173 - command: "go build" 174 - environment: 175 - GOOS: "darwin" 176 - GOARCH: "arm64" 177 - - name: "Build frontend" 178 - command: "npm run build" 179 - environment: 180 - NODE_ENV: "production" 181 - ``` 182 - 183 - If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+101
docs/styles.css
··· 1 + svg { 2 + width: 16px; 3 + height: 16px; 4 + } 5 + 6 + :root { 7 + --syntax-alert: #d20f39; 8 + --syntax-annotation: #fe640b; 9 + --syntax-attribute: #df8e1d; 10 + --syntax-basen: #40a02b; 11 + --syntax-builtin: #1e66f5; 12 + --syntax-controlflow: #8839ef; 13 + --syntax-char: #04a5e5; 14 + --syntax-constant: #fe640b; 15 + --syntax-comment: #9ca0b0; 16 + --syntax-commentvar: #7c7f93; 17 + --syntax-documentation: #9ca0b0; 18 + --syntax-datatype: #df8e1d; 19 + --syntax-decval: #40a02b; 20 + --syntax-error: #d20f39; 21 + --syntax-extension: #4c4f69; 22 + --syntax-float: #40a02b; 23 + --syntax-function: #1e66f5; 24 + --syntax-import: #40a02b; 25 + --syntax-information: #04a5e5; 26 + --syntax-keyword: #8839ef; 27 + --syntax-operator: #179299; 28 + --syntax-other: #8839ef; 29 + --syntax-preprocessor: #ea76cb; 30 + --syntax-specialchar: #04a5e5; 31 + --syntax-specialstring: #ea76cb; 32 + --syntax-string: #40a02b; 33 + --syntax-variable: #8839ef; 34 + --syntax-verbatimstring: #40a02b; 35 + --syntax-warning: #df8e1d; 36 + } 37 + 38 + @media (prefers-color-scheme: dark) { 39 + :root { 40 + --syntax-alert: #f38ba8; 41 + --syntax-annotation: #fab387; 42 + --syntax-attribute: #f9e2af; 43 + --syntax-basen: #a6e3a1; 44 + --syntax-builtin: #89b4fa; 45 + --syntax-controlflow: #cba6f7; 46 + --syntax-char: #89dceb; 47 + --syntax-constant: #fab387; 48 + --syntax-comment: #6c7086; 49 + --syntax-commentvar: #585b70; 50 + --syntax-documentation: #6c7086; 51 + --syntax-datatype: #f9e2af; 52 + --syntax-decval: #a6e3a1; 53 + --syntax-error: #f38ba8; 54 + --syntax-extension: #cdd6f4; 55 + --syntax-float: #a6e3a1; 56 + --syntax-function: #89b4fa; 57 + --syntax-import: #a6e3a1; 58 + --syntax-information: #89dceb; 59 + --syntax-keyword: #cba6f7; 60 + --syntax-operator: #94e2d5; 61 + --syntax-other: #cba6f7; 62 + --syntax-preprocessor: #f5c2e7; 63 + --syntax-specialchar: #89dceb; 64 + --syntax-specialstring: #f5c2e7; 65 + --syntax-string: #a6e3a1; 66 + --syntax-variable: #cba6f7; 67 + --syntax-verbatimstring: #a6e3a1; 68 + --syntax-warning: #f9e2af; 69 + } 70 + } 71 + 72 + /* pandoc syntax highlighting classes */ 73 + code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */ 74 + code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */ 75 + code span.at { color: var(--syntax-attribute); } /* attribute */ 76 + code span.bn { color: var(--syntax-basen); } /* basen */ 77 + code span.bu { color: var(--syntax-builtin); } /* builtin */ 78 + code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */ 79 + code span.ch { color: var(--syntax-char); } /* char */ 80 + code span.cn { color: var(--syntax-constant); } /* constant */ 81 + code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */ 82 + code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */ 83 + code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */ 84 + code span.dt { color: var(--syntax-datatype); } /* datatype */ 85 + code span.dv { color: var(--syntax-decval); } /* decval */ 86 + code span.er { color: var(--syntax-error); font-weight: bold; } /* error */ 87 + code span.ex { color: var(--syntax-extension); } /* extension */ 88 + code span.fl { color: var(--syntax-float); } /* float */ 89 + code span.fu { color: var(--syntax-function); } /* function */ 90 + code span.im { color: var(--syntax-import); font-weight: bold; } /* import */ 91 + code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */ 92 + code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */ 93 + code span.op { color: var(--syntax-operator); } /* operator */ 94 + code span.ot { color: var(--syntax-other); } /* other */ 95 + code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */ 96 + code span.sc { color: var(--syntax-specialchar); } /* specialchar */ 97 + code span.ss { color: var(--syntax-specialstring); } /* specialstring */ 98 + code span.st { color: var(--syntax-string); } /* string */ 99 + code span.va { color: var(--syntax-variable); } /* variable */ 100 + code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */ 101 + code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+158
docs/template.html
··· 1 + <!DOCTYPE html> 2 + <html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="generator" content="pandoc" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> 7 + $for(author-meta)$ 8 + <meta name="author" content="$author-meta$" /> 9 + $endfor$ 10 + 11 + $if(date-meta)$ 12 + <meta name="dcterms.date" content="$date-meta$" /> 13 + $endif$ 14 + 15 + $if(keywords)$ 16 + <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" /> 17 + $endif$ 18 + 19 + $if(description-meta)$ 20 + <meta name="description" content="$description-meta$" /> 21 + $endif$ 22 + 23 + <title>$pagetitle$</title> 24 + 25 + <style> 26 + $styles.css()$ 27 + </style> 28 + 29 + $for(css)$ 30 + <link rel="stylesheet" href="$css$" /> 31 + $endfor$ 32 + 33 + $for(header-includes)$ 34 + $header-includes$ 35 + $endfor$ 36 + 37 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + 39 + </head> 40 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 + $for(include-before)$ 42 + $include-before$ 43 + $endfor$ 44 + 45 + $if(toc)$ 46 + <!-- mobile TOC trigger --> 47 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 48 + <button 49 + type="button" 50 + popovertarget="mobile-toc-popover" 51 + popovertargetaction="toggle" 52 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 53 + > 54 + ${ menu.svg() } 55 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 56 + </button> 57 + </div> 58 + 59 + <div 60 + id="mobile-toc-popover" 61 + popover 62 + class="mobile-toc-popover 63 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 64 + h-full overflow-y-auto shadow-sm 65 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 66 + > 67 + <div class="flex flex-col min-h-full"> 68 + <div class="flex-1 space-y-4"> 69 + <button 70 + type="button" 71 + popovertarget="mobile-toc-popover" 72 + popovertargetaction="toggle" 73 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 74 + ${ x.svg() } 75 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 76 + </button> 77 + ${ logo.html() } 78 + ${ search.html() } 79 + ${ table-of-contents:toc.html() } 80 + </div> 81 + ${ single-page:mode.html() } 82 + </div> 83 + </div> 84 + 85 + <!-- desktop sidebar toc --> 86 + <nav 87 + id="$idprefix$TOC" 88 + role="doc-toc" 89 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 90 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 91 + p-4 z-50 overflow-y-auto"> 92 + ${ logo.html() } 93 + ${ search.html() } 94 + <div class="flex-1"> 95 + $if(toc-title)$ 96 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 97 + $endif$ 98 + ${ table-of-contents:toc.html() } 99 + </div> 100 + ${ single-page:mode.html() } 101 + </nav> 102 + $endif$ 103 + 104 + <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 105 + <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 106 + $if(top)$ 107 + $-- only print title block if this is NOT the top page 108 + $else$ 109 + $if(title)$ 110 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 111 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 112 + $if(subtitle)$ 113 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 114 + $endif$ 115 + $for(author)$ 116 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 117 + $endfor$ 118 + $if(date)$ 119 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 120 + $endif$ 121 + $endif$ 122 + </header> 123 + $endif$ 124 + 125 + $if(abstract)$ 126 + <article class="prose dark:prose-invert max-w-none"> 127 + $abstract$ 128 + </article> 129 + $endif$ 130 + 131 + <article class="prose dark:prose-invert max-w-none"> 132 + $body$ 133 + </article> 134 + </main> 135 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 136 + <div class="max-w-4xl mx-auto px-8 py-4"> 137 + <div class="flex justify-between gap-4"> 138 + <span class="flex-1"> 139 + $if(previous.url)$ 140 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span> 141 + <a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a> 142 + $endif$ 143 + </span> 144 + <span class="flex-1 text-right"> 145 + $if(next.url)$ 146 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span> 147 + <a href="$next.url$" accesskey="n" rel="next">$next.title$</a> 148 + $endif$ 149 + </span> 150 + </div> 151 + </div> 152 + </nav> 153 + </div> 154 + $for(include-after)$ 155 + $include-after$ 156 + $endfor$ 157 + </body> 158 + </html>
+4
docs/toc.html
··· 1 + <div class="[&_ul]:space-y-6 [&_ul]:pl-0 [&_ul]:font-bold [&_ul_ul]:pl-4 [&_ul_ul]:font-normal [&_ul_ul]:space-y-2 [&_li]:space-y-2"> 2 + $table-of-contents$ 3 + </div> 4 +
+9 -9
flake.lock
··· 35 35 "systems": "systems" 36 36 }, 37 37 "locked": { 38 - "lastModified": 1694529238, 39 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 40 40 "owner": "numtide", 41 41 "repo": "flake-utils", 42 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 43 43 "type": "github" 44 44 }, 45 45 "original": { ··· 56 56 ] 57 57 }, 58 58 "locked": { 59 - "lastModified": 1754078208, 60 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 61 61 "owner": "nix-community", 62 62 "repo": "gomod2nix", 63 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 64 64 "type": "github" 65 65 }, 66 66 "original": { ··· 150 150 }, 151 151 "nixpkgs": { 152 152 "locked": { 153 - "lastModified": 1751984180, 154 - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 155 "owner": "nixos", 156 156 "repo": "nixpkgs", 157 - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 158 "type": "github" 159 159 }, 160 160 "original": {
+21 -5
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 { 83 - inherit (pkgs) gcc; 84 83 inherit sqlite-lib-src; 85 84 }; 86 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; ··· 89 88 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 90 89 }; 91 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 + docs = self.callPackage ./nix/pkgs/docs.nix { 92 + inherit inter-fonts-src ibm-plex-mono-src lucide-src; 93 + }; 92 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 93 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 94 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 + dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 95 98 }); 96 99 in { 97 100 overlays.default = final: prev: { 98 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 99 102 }; 100 103 101 104 packages = forAllSystems (system: let ··· 104 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 105 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 106 109 in { 107 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 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 + ; 108 123 109 124 pkgsStatic-appview = staticPackages.appview; 110 125 pkgsStatic-knot = staticPackages.knot; 111 126 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 112 127 pkgsStatic-spindle = staticPackages.spindle; 113 128 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 129 + pkgsStatic-dolly = staticPackages.dolly; 114 130 115 131 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 116 132 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 117 133 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 118 134 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 135 + pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly; 119 136 120 137 treefmt-wrapper = pkgs.treefmt.withConfig { 121 138 settings.formatter = { ··· 156 173 nativeBuildInputs = [ 157 174 pkgs.go 158 175 pkgs.air 159 - pkgs.tilt 160 176 pkgs.gopls 161 177 pkgs.httpie 162 178 pkgs.litecli
+3 -2
go.mod
··· 1 1 module tangled.org/core 2 2 3 - go 1.24.4 3 + go 1.25.0 4 4 5 5 require ( 6 6 github.com/Blank-Xu/sql-adapter v1.1.1 ··· 45 45 github.com/urfave/cli/v3 v3.3.3 46 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 47 github.com/yuin/goldmark v1.7.13 48 + github.com/yuin/goldmark-emoji v1.0.6 48 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 49 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 50 51 golang.org/x/crypto v0.40.0 51 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 52 53 golang.org/x/image v0.31.0 53 54 golang.org/x/net v0.42.0 54 - golang.org/x/sync v0.17.0 55 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 56 gopkg.in/yaml.v3 v3.0.1 57 57 ) ··· 203 203 go.uber.org/atomic v1.11.0 // indirect 204 204 go.uber.org/multierr v1.11.0 // indirect 205 205 go.uber.org/zap v1.27.0 // indirect 206 + golang.org/x/sync v0.17.0 // indirect 206 207 golang.org/x/sys v0.34.0 // indirect 207 208 golang.org/x/text v0.29.0 // indirect 208 209 golang.org/x/time v0.12.0 // indirect
+2
go.sum
··· 505 505 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 506 506 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 507 507 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 508 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 509 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 508 510 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 509 511 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 510 512 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+4 -4
hook/hook.go
··· 48 48 }, 49 49 Commands: []*cli.Command{ 50 50 { 51 - Name: "post-recieve", 52 - Usage: "sends a post-recieve hook to the knot (waits for stdin)", 53 - Action: postRecieve, 51 + Name: "post-receive", 52 + Usage: "sends a post-receive hook to the knot (waits for stdin)", 53 + Action: postReceive, 54 54 }, 55 55 }, 56 56 } 57 57 } 58 58 59 - func postRecieve(ctx context.Context, cmd *cli.Command) error { 59 + func postReceive(ctx context.Context, cmd *cli.Command) error { 60 60 gitDir := cmd.String("git-dir") 61 61 userDid := cmd.String("user-did") 62 62 userHandle := cmd.String("user-handle")
+1 -1
hook/setup.go
··· 138 138 option_var="GIT_PUSH_OPTION_$i" 139 139 push_options+=(-push-option "${!option_var}") 140 140 done 141 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive 142 142 `, executablePath, config.internalApi) 143 143 144 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+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 + }
+15 -1
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 ··· 162 175 } 163 176 164 177 .prose a.mention { 165 - @apply no-underline hover:underline; 178 + @apply no-underline hover:underline font-bold; 166 179 } 167 180 168 181 .prose li { ··· 255 268 @apply py-1 text-gray-900 dark:text-gray-100; 256 269 } 257 270 } 271 + 258 272 } 259 273 260 274 /* Background */
+81
knotserver/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "log/slog" 7 + "strings" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + ) 12 + 13 + type DB struct { 14 + db *sql.DB 15 + logger *slog.Logger 16 + } 17 + 18 + func Setup(ctx context.Context, dbPath string) (*DB, error) { 19 + // https://github.com/mattn/go-sqlite3#connection-string 20 + opts := []string{ 21 + "_foreign_keys=1", 22 + "_journal_mode=WAL", 23 + "_synchronous=NORMAL", 24 + "_auto_vacuum=incremental", 25 + } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 30 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 40 + 41 + _, err = conn.ExecContext(ctx, ` 42 + create table if not exists known_dids ( 43 + did text primary key 44 + ); 45 + 46 + create table if not exists public_keys ( 47 + id integer primary key autoincrement, 48 + did text not null, 49 + key text not null, 50 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 + unique(did, key), 52 + foreign key (did) references known_dids(did) on delete cascade 53 + ); 54 + 55 + create table if not exists _jetstream ( 56 + id integer primary key autoincrement, 57 + last_time_us integer not null 58 + ); 59 + 60 + create table if not exists events ( 61 + rkey text not null, 62 + nsid text not null, 63 + event text not null, -- json 64 + created integer not null default (strftime('%s', 'now')), 65 + primary key (rkey, nsid) 66 + ); 67 + 68 + create table if not exists migrations ( 69 + id integer primary key autoincrement, 70 + name text unique 71 + ); 72 + `) 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + return &DB{ 78 + db: db, 79 + logger: logger, 80 + }, nil 81 + }
-64
knotserver/db/init.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "strings" 6 - 7 - _ "github.com/mattn/go-sqlite3" 8 - ) 9 - 10 - type DB struct { 11 - db *sql.DB 12 - } 13 - 14 - func Setup(dbPath string) (*DB, error) { 15 - // https://github.com/mattn/go-sqlite3#connection-string 16 - opts := []string{ 17 - "_foreign_keys=1", 18 - "_journal_mode=WAL", 19 - "_synchronous=NORMAL", 20 - "_auto_vacuum=incremental", 21 - } 22 - 23 - db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 - if err != nil { 25 - return nil, err 26 - } 27 - 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. 31 - 32 - _, err = db.Exec(` 33 - create table if not exists known_dids ( 34 - did text primary key 35 - ); 36 - 37 - create table if not exists public_keys ( 38 - id integer primary key autoincrement, 39 - did text not null, 40 - key text not null, 41 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 42 - unique(did, key), 43 - foreign key (did) references known_dids(did) on delete cascade 44 - ); 45 - 46 - create table if not exists _jetstream ( 47 - id integer primary key autoincrement, 48 - last_time_us integer not null 49 - ); 50 - 51 - create table if not exists events ( 52 - rkey text not null, 53 - nsid text not null, 54 - event text not null, -- json 55 - created integer not null default (strftime('%s', 'now')), 56 - primary key (rkey, nsid) 57 - ); 58 - `) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &DB{db: db}, nil 64 - }
+13 -1
knotserver/git/service/service.go
··· 95 95 return c.RunService(cmd) 96 96 } 97 97 98 + func (c *ServiceCommand) UploadArchive() error { 99 + cmd := exec.Command("git", []string{ 100 + "upload-archive", 101 + ".", 102 + }...) 103 + 104 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 105 + cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 106 + cmd.Dir = c.Dir 107 + 108 + return c.RunService(cmd) 109 + } 110 + 98 111 func (c *ServiceCommand) UploadPack() error { 99 112 cmd := exec.Command("git", []string{ 100 - "-c", "uploadpack.allowFilter=true", 101 113 "upload-pack", 102 114 "--stateless-rpc", 103 115 ".",
+47
knotserver/git.go
··· 56 56 } 57 57 } 58 58 59 + func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 60 + did := chi.URLParam(r, "did") 61 + name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 + if err != nil { 64 + gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 + return 67 + } 68 + 69 + const expectedContentType = "application/x-git-upload-archive-request" 70 + contentType := r.Header.Get("Content-Type") 71 + if contentType != expectedContentType { 72 + gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType) 73 + } 74 + 75 + var bodyReader io.ReadCloser = r.Body 76 + if r.Header.Get("Content-Encoding") == "gzip" { 77 + gzipReader, err := gzip.NewReader(r.Body) 78 + if err != nil { 79 + gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err) 81 + return 82 + } 83 + defer gzipReader.Close() 84 + bodyReader = gzipReader 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/x-git-upload-archive-result") 88 + 89 + h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo) 90 + 91 + cmd := service.ServiceCommand{ 92 + GitProtocol: r.Header.Get("Git-Protocol"), 93 + Dir: repo, 94 + Stdout: w, 95 + Stdin: bodyReader, 96 + } 97 + 98 + w.WriteHeader(http.StatusOK) 99 + 100 + if err := cmd.UploadArchive(); err != nil { 101 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 102 + return 103 + } 104 + } 105 + 59 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 107 did := chi.URLParam(r, "did") 61 108 name := chi.URLParam(r, "name")
+1
knotserver/router.go
··· 82 82 r.Route("/{name}", func(r chi.Router) { 83 83 // routes for git operations 84 84 r.Get("/info/refs", h.InfoRefs) 85 + r.Post("/git-upload-archive", h.UploadArchive) 85 86 r.Post("/git-upload-pack", h.UploadPack) 86 87 r.Post("/git-receive-pack", h.ReceivePack) 87 88 })
+1 -1
knotserver/server.go
··· 64 64 logger.Info("running in dev mode, signature verification is disabled") 65 65 } 66 66 67 - db, err := db.Setup(c.Server.DBPath) 67 + db, err := db.Setup(ctx, c.Server.DBPath) 68 68 if err != nil { 69 69 return fmt.Errorf("failed to load db: %w", err) 70 70 }
+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
··· 530 530 [mod."github.com/yuin/goldmark"] 531 531 version = "v1.7.13" 532 532 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 533 + [mod."github.com/yuin/goldmark-emoji"] 534 + version = "v1.0.6" 535 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 533 536 [mod."github.com/yuin/goldmark-highlighting/v2"] 534 537 version = "v2.0.0-20230729083705-37449abec8cc" 535 538 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+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
+57
nix/pkgs/docs.nix
··· 1 + { 2 + pandoc, 3 + tailwindcss, 4 + runCommandLocal, 5 + inter-fonts-src, 6 + ibm-plex-mono-src, 7 + lucide-src, 8 + dolly, 9 + src, 10 + }: 11 + runCommandLocal "docs" {} '' 12 + mkdir -p working 13 + 14 + # copy templates, themes, styles, filters to working directory 15 + cp ${src}/docs/*.html working/ 16 + cp ${src}/docs/*.theme working/ 17 + cp ${src}/docs/*.css working/ 18 + 19 + # icons 20 + cp -rf ${lucide-src}/*.svg working/ 21 + 22 + # logo 23 + ${dolly}/bin/dolly -output working/dolly.svg -color currentColor 24 + 25 + # content - chunked 26 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 27 + -o $out/ \ 28 + -t chunkedhtml \ 29 + --variable toc \ 30 + --variable-json single-page=false \ 31 + --toc-depth=2 \ 32 + --css=stylesheet.css \ 33 + --chunk-template="%i.html" \ 34 + --highlight-style=working/highlight.theme \ 35 + --template=working/template.html 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 + 48 + # fonts 49 + mkdir -p $out/static/fonts 50 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/ 51 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/ 52 + cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/ 53 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/ 54 + 55 + # styles 56 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css 57 + ''
+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 + }
+7 -5
nix/pkgs/sqlite-lib.nix
··· 1 1 { 2 - gcc, 3 2 stdenv, 4 3 sqlite-lib-src, 5 4 }: 6 5 stdenv.mkDerivation { 7 6 name = "sqlite-lib"; 8 7 src = sqlite-lib-src; 9 - nativeBuildInputs = [gcc]; 8 + 10 9 buildPhase = '' 11 - gcc -c sqlite3.c 12 - ar rcs libsqlite3.a sqlite3.o 13 - ranlib libsqlite3.a 10 + $CC -c sqlite3.c 11 + $AR rcs libsqlite3.a sqlite3.o 12 + $RANLIB libsqlite3.a 13 + ''; 14 + 15 + installPhase = '' 14 16 mkdir -p $out/include $out/lib 15 17 cp *.h $out/include 16 18 cp libsqlite3.a $out/lib
+1 -1
nix/vm.nix
··· 8 8 var = builtins.getEnv name; 9 9 in 10 10 if var == "" 11 - then throw "\$${name} must be defined, see docs/hacking.md for more details" 11 + then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" 12 12 else var; 13 13 envVarOr = name: default: let 14 14 var = builtins.getEnv name;
+3 -3
readme.md
··· 10 10 11 11 ## docs 12 12 13 - * [knot hosting guide](/docs/knot-hosting.md) 14 - * [contributing guide](/docs/contributing.md) **please read before opening a PR!** 15 - * [hacking on tangled](/docs/hacking.md) 13 + - [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide) 14 + - [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!** 15 + - [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled) 16 16 17 17 ## security 18 18
+31
sets/gen.go
··· 1 + package sets 2 + 3 + import ( 4 + "math/rand" 5 + "reflect" 6 + "testing/quick" 7 + ) 8 + 9 + func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value { 10 + s := New[T]() 11 + 12 + var zero T 13 + itemType := reflect.TypeOf(zero) 14 + 15 + for { 16 + if s.Len() >= size { 17 + break 18 + } 19 + 20 + item, ok := quick.Value(itemType, rand) 21 + if !ok { 22 + continue 23 + } 24 + 25 + if val, ok := item.Interface().(T); ok { 26 + s.Insert(val) 27 + } 28 + } 29 + 30 + return reflect.ValueOf(s) 31 + }
+35
sets/readme.txt
··· 1 + sets 2 + ---- 3 + set datastructure for go with generics and iterators. the 4 + api is supposed to mimic rust's std::collections::HashSet api. 5 + 6 + s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4})) 7 + s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6})) 8 + 9 + union := sets.Collect(s1.Union(s2)) 10 + intersect := sets.Collect(s1.Intersection(s2)) 11 + diff := sets.Collect(s1.Difference(s2)) 12 + symdiff := sets.Collect(s1.SymmetricDifference(s2)) 13 + 14 + s1.Len() // 4 15 + s1.Contains(1) // true 16 + s1.IsEmpty() // false 17 + s1.IsSubset(s2) // true 18 + s1.IsSuperset(s2) // false 19 + s1.IsDisjoint(s2) // false 20 + 21 + if exists := s1.Insert(1); exists { 22 + // already existed in set 23 + } 24 + 25 + if existed := s1.Remove(1); existed { 26 + // existed in set, now removed 27 + } 28 + 29 + 30 + testing 31 + ------- 32 + includes property-based tests using the wonderful 33 + testing/quick module! 34 + 35 + go test -v
+174
sets/set.go
··· 1 + package sets 2 + 3 + import ( 4 + "iter" 5 + "maps" 6 + ) 7 + 8 + type Set[T comparable] struct { 9 + data map[T]struct{} 10 + } 11 + 12 + func New[T comparable]() Set[T] { 13 + return Set[T]{ 14 + data: make(map[T]struct{}), 15 + } 16 + } 17 + 18 + func (s *Set[T]) Insert(item T) bool { 19 + _, exists := s.data[item] 20 + s.data[item] = struct{}{} 21 + return !exists 22 + } 23 + 24 + func Singleton[T comparable](item T) Set[T] { 25 + n := New[T]() 26 + _ = n.Insert(item) 27 + return n 28 + } 29 + 30 + func (s *Set[T]) Remove(item T) bool { 31 + _, exists := s.data[item] 32 + if exists { 33 + delete(s.data, item) 34 + } 35 + return exists 36 + } 37 + 38 + func (s Set[T]) Contains(item T) bool { 39 + _, exists := s.data[item] 40 + return exists 41 + } 42 + 43 + func (s Set[T]) Len() int { 44 + return len(s.data) 45 + } 46 + 47 + func (s Set[T]) IsEmpty() bool { 48 + return len(s.data) == 0 49 + } 50 + 51 + func (s *Set[T]) Clear() { 52 + s.data = make(map[T]struct{}) 53 + } 54 + 55 + func (s Set[T]) All() iter.Seq[T] { 56 + return func(yield func(T) bool) { 57 + for item := range s.data { 58 + if !yield(item) { 59 + return 60 + } 61 + } 62 + } 63 + } 64 + 65 + func (s Set[T]) Clone() Set[T] { 66 + return Set[T]{ 67 + data: maps.Clone(s.data), 68 + } 69 + } 70 + 71 + func (s Set[T]) Union(other Set[T]) iter.Seq[T] { 72 + if s.Len() >= other.Len() { 73 + return chain(s.All(), other.Difference(s)) 74 + } else { 75 + return chain(other.All(), s.Difference(other)) 76 + } 77 + } 78 + 79 + func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] { 80 + return func(yield func(T) bool) { 81 + for _, seq := range seqs { 82 + for item := range seq { 83 + if !yield(item) { 84 + return 85 + } 86 + } 87 + } 88 + } 89 + } 90 + 91 + func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] { 92 + return func(yield func(T) bool) { 93 + for item := range s.data { 94 + if other.Contains(item) { 95 + if !yield(item) { 96 + return 97 + } 98 + } 99 + } 100 + } 101 + } 102 + 103 + func (s Set[T]) Difference(other Set[T]) iter.Seq[T] { 104 + return func(yield func(T) bool) { 105 + for item := range s.data { 106 + if !other.Contains(item) { 107 + if !yield(item) { 108 + return 109 + } 110 + } 111 + } 112 + } 113 + } 114 + 115 + func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] { 116 + return func(yield func(T) bool) { 117 + for item := range s.data { 118 + if !other.Contains(item) { 119 + if !yield(item) { 120 + return 121 + } 122 + } 123 + } 124 + for item := range other.data { 125 + if !s.Contains(item) { 126 + if !yield(item) { 127 + return 128 + } 129 + } 130 + } 131 + } 132 + } 133 + 134 + func (s Set[T]) IsSubset(other Set[T]) bool { 135 + for item := range s.data { 136 + if !other.Contains(item) { 137 + return false 138 + } 139 + } 140 + return true 141 + } 142 + 143 + func (s Set[T]) IsSuperset(other Set[T]) bool { 144 + return other.IsSubset(s) 145 + } 146 + 147 + func (s Set[T]) IsDisjoint(other Set[T]) bool { 148 + for item := range s.data { 149 + if other.Contains(item) { 150 + return false 151 + } 152 + } 153 + return true 154 + } 155 + 156 + func (s Set[T]) Equal(other Set[T]) bool { 157 + if s.Len() != other.Len() { 158 + return false 159 + } 160 + for item := range s.data { 161 + if !other.Contains(item) { 162 + return false 163 + } 164 + } 165 + return true 166 + } 167 + 168 + func Collect[T comparable](seq iter.Seq[T]) Set[T] { 169 + result := New[T]() 170 + for item := range seq { 171 + result.Insert(item) 172 + } 173 + return result 174 + }
+411
sets/set_test.go
··· 1 + package sets 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + "testing/quick" 7 + ) 8 + 9 + func TestNew(t *testing.T) { 10 + s := New[int]() 11 + if s.Len() != 0 { 12 + t.Errorf("New set should be empty, got length %d", s.Len()) 13 + } 14 + if !s.IsEmpty() { 15 + t.Error("New set should be empty") 16 + } 17 + } 18 + 19 + func TestFromSlice(t *testing.T) { 20 + s := Collect(slices.Values([]int{1, 2, 3, 2, 1})) 21 + if s.Len() != 3 { 22 + t.Errorf("Expected length 3, got %d", s.Len()) 23 + } 24 + if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) { 25 + t.Error("Set should contain all unique elements from slice") 26 + } 27 + } 28 + 29 + func TestInsert(t *testing.T) { 30 + s := New[string]() 31 + 32 + if !s.Insert("hello") { 33 + t.Error("First insert should return true") 34 + } 35 + if s.Insert("hello") { 36 + t.Error("Duplicate insert should return false") 37 + } 38 + if s.Len() != 1 { 39 + t.Errorf("Expected length 1, got %d", s.Len()) 40 + } 41 + } 42 + 43 + func TestRemove(t *testing.T) { 44 + s := Collect(slices.Values([]int{1, 2, 3})) 45 + 46 + if !s.Remove(2) { 47 + t.Error("Remove existing element should return true") 48 + } 49 + if s.Remove(2) { 50 + t.Error("Remove non-existing element should return false") 51 + } 52 + if s.Contains(2) { 53 + t.Error("Element should be removed") 54 + } 55 + if s.Len() != 2 { 56 + t.Errorf("Expected length 2, got %d", s.Len()) 57 + } 58 + } 59 + 60 + func TestContains(t *testing.T) { 61 + s := Collect(slices.Values([]int{1, 2, 3})) 62 + 63 + if !s.Contains(1) { 64 + t.Error("Should contain 1") 65 + } 66 + if s.Contains(4) { 67 + t.Error("Should not contain 4") 68 + } 69 + } 70 + 71 + func TestClear(t *testing.T) { 72 + s := Collect(slices.Values([]int{1, 2, 3})) 73 + s.Clear() 74 + 75 + if !s.IsEmpty() { 76 + t.Error("Set should be empty after clear") 77 + } 78 + if s.Len() != 0 { 79 + t.Errorf("Expected length 0, got %d", s.Len()) 80 + } 81 + } 82 + 83 + func TestIterator(t *testing.T) { 84 + s := Collect(slices.Values([]int{1, 2, 3})) 85 + var items []int 86 + 87 + for item := range s.All() { 88 + items = append(items, item) 89 + } 90 + 91 + slices.Sort(items) 92 + expected := []int{1, 2, 3} 93 + if !slices.Equal(items, expected) { 94 + t.Errorf("Expected %v, got %v", expected, items) 95 + } 96 + } 97 + 98 + func TestClone(t *testing.T) { 99 + s1 := Collect(slices.Values([]int{1, 2, 3})) 100 + s2 := s1.Clone() 101 + 102 + if !s1.Equal(s2) { 103 + t.Error("Cloned set should be equal to original") 104 + } 105 + 106 + s2.Insert(4) 107 + if s1.Contains(4) { 108 + t.Error("Modifying clone should not affect original") 109 + } 110 + } 111 + 112 + func TestUnion(t *testing.T) { 113 + s1 := Collect(slices.Values([]int{1, 2})) 114 + s2 := Collect(slices.Values([]int{2, 3})) 115 + 116 + result := Collect(s1.Union(s2)) 117 + expected := Collect(slices.Values([]int{1, 2, 3})) 118 + 119 + if !result.Equal(expected) { 120 + t.Errorf("Expected %v, got %v", expected, result) 121 + } 122 + } 123 + 124 + func TestIntersection(t *testing.T) { 125 + s1 := Collect(slices.Values([]int{1, 2, 3})) 126 + s2 := Collect(slices.Values([]int{2, 3, 4})) 127 + 128 + expected := Collect(slices.Values([]int{2, 3})) 129 + result := Collect(s1.Intersection(s2)) 130 + 131 + if !result.Equal(expected) { 132 + t.Errorf("Expected %v, got %v", expected, result) 133 + } 134 + } 135 + 136 + func TestDifference(t *testing.T) { 137 + s1 := Collect(slices.Values([]int{1, 2, 3})) 138 + s2 := Collect(slices.Values([]int{2, 3, 4})) 139 + 140 + expected := Collect(slices.Values([]int{1})) 141 + result := Collect(s1.Difference(s2)) 142 + 143 + if !result.Equal(expected) { 144 + t.Errorf("Expected %v, got %v", expected, result) 145 + } 146 + } 147 + 148 + func TestSymmetricDifference(t *testing.T) { 149 + s1 := Collect(slices.Values([]int{1, 2, 3})) 150 + s2 := Collect(slices.Values([]int{2, 3, 4})) 151 + 152 + expected := Collect(slices.Values([]int{1, 4})) 153 + result := Collect(s1.SymmetricDifference(s2)) 154 + 155 + if !result.Equal(expected) { 156 + t.Errorf("Expected %v, got %v", expected, result) 157 + } 158 + } 159 + 160 + func TestSymmetricDifferenceCommutativeProperty(t *testing.T) { 161 + s1 := Collect(slices.Values([]int{1, 2, 3})) 162 + s2 := Collect(slices.Values([]int{2, 3, 4})) 163 + 164 + result1 := Collect(s1.SymmetricDifference(s2)) 165 + result2 := Collect(s2.SymmetricDifference(s1)) 166 + 167 + if !result1.Equal(result2) { 168 + t.Errorf("Expected %v, got %v", result1, result2) 169 + } 170 + } 171 + 172 + func TestIsSubset(t *testing.T) { 173 + s1 := Collect(slices.Values([]int{1, 2})) 174 + s2 := Collect(slices.Values([]int{1, 2, 3})) 175 + 176 + if !s1.IsSubset(s2) { 177 + t.Error("s1 should be subset of s2") 178 + } 179 + if s2.IsSubset(s1) { 180 + t.Error("s2 should not be subset of s1") 181 + } 182 + } 183 + 184 + func TestIsSuperset(t *testing.T) { 185 + s1 := Collect(slices.Values([]int{1, 2, 3})) 186 + s2 := Collect(slices.Values([]int{1, 2})) 187 + 188 + if !s1.IsSuperset(s2) { 189 + t.Error("s1 should be superset of s2") 190 + } 191 + if s2.IsSuperset(s1) { 192 + t.Error("s2 should not be superset of s1") 193 + } 194 + } 195 + 196 + func TestIsDisjoint(t *testing.T) { 197 + s1 := Collect(slices.Values([]int{1, 2})) 198 + s2 := Collect(slices.Values([]int{3, 4})) 199 + s3 := Collect(slices.Values([]int{2, 3})) 200 + 201 + if !s1.IsDisjoint(s2) { 202 + t.Error("s1 and s2 should be disjoint") 203 + } 204 + if s1.IsDisjoint(s3) { 205 + t.Error("s1 and s3 should not be disjoint") 206 + } 207 + } 208 + 209 + func TestEqual(t *testing.T) { 210 + s1 := Collect(slices.Values([]int{1, 2, 3})) 211 + s2 := Collect(slices.Values([]int{3, 2, 1})) 212 + s3 := Collect(slices.Values([]int{1, 2})) 213 + 214 + if !s1.Equal(s2) { 215 + t.Error("s1 and s2 should be equal") 216 + } 217 + if s1.Equal(s3) { 218 + t.Error("s1 and s3 should not be equal") 219 + } 220 + } 221 + 222 + func TestCollect(t *testing.T) { 223 + s1 := Collect(slices.Values([]int{1, 2})) 224 + s2 := Collect(slices.Values([]int{2, 3})) 225 + 226 + unionSet := Collect(s1.Union(s2)) 227 + if unionSet.Len() != 3 { 228 + t.Errorf("Expected union set length 3, got %d", unionSet.Len()) 229 + } 230 + if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) { 231 + t.Error("Union set should contain 1, 2, and 3") 232 + } 233 + 234 + diffSet := Collect(s1.Difference(s2)) 235 + if diffSet.Len() != 1 { 236 + t.Errorf("Expected difference set length 1, got %d", diffSet.Len()) 237 + } 238 + if !diffSet.Contains(1) { 239 + t.Error("Difference set should contain 1") 240 + } 241 + } 242 + 243 + func TestPropertySingleonLen(t *testing.T) { 244 + f := func(item int) bool { 245 + single := Singleton(item) 246 + return single.Len() == 1 247 + } 248 + 249 + if err := quick.Check(f, nil); err != nil { 250 + t.Error(err) 251 + } 252 + } 253 + 254 + func TestPropertyInsertIdempotent(t *testing.T) { 255 + f := func(s Set[int], item int) bool { 256 + clone := s.Clone() 257 + 258 + clone.Insert(item) 259 + firstLen := clone.Len() 260 + 261 + clone.Insert(item) 262 + secondLen := clone.Len() 263 + 264 + return firstLen == secondLen 265 + } 266 + 267 + if err := quick.Check(f, nil); err != nil { 268 + t.Error(err) 269 + } 270 + } 271 + 272 + func TestPropertyUnionCommutative(t *testing.T) { 273 + f := func(s1 Set[int], s2 Set[int]) bool { 274 + union1 := Collect(s1.Union(s2)) 275 + union2 := Collect(s2.Union(s1)) 276 + return union1.Equal(union2) 277 + } 278 + 279 + if err := quick.Check(f, nil); err != nil { 280 + t.Error(err) 281 + } 282 + } 283 + 284 + func TestPropertyIntersectionCommutative(t *testing.T) { 285 + f := func(s1 Set[int], s2 Set[int]) bool { 286 + inter1 := Collect(s1.Intersection(s2)) 287 + inter2 := Collect(s2.Intersection(s1)) 288 + return inter1.Equal(inter2) 289 + } 290 + 291 + if err := quick.Check(f, nil); err != nil { 292 + t.Error(err) 293 + } 294 + } 295 + 296 + func TestPropertyCloneEquals(t *testing.T) { 297 + f := func(s Set[int]) bool { 298 + clone := s.Clone() 299 + return s.Equal(clone) 300 + } 301 + 302 + if err := quick.Check(f, nil); err != nil { 303 + t.Error(err) 304 + } 305 + } 306 + 307 + func TestPropertyIntersectionIsSubset(t *testing.T) { 308 + f := func(s1 Set[int], s2 Set[int]) bool { 309 + inter := Collect(s1.Intersection(s2)) 310 + return inter.IsSubset(s1) && inter.IsSubset(s2) 311 + } 312 + 313 + if err := quick.Check(f, nil); err != nil { 314 + t.Error(err) 315 + } 316 + } 317 + 318 + func TestPropertyUnionIsSuperset(t *testing.T) { 319 + f := func(s1 Set[int], s2 Set[int]) bool { 320 + union := Collect(s1.Union(s2)) 321 + return union.IsSuperset(s1) && union.IsSuperset(s2) 322 + } 323 + 324 + if err := quick.Check(f, nil); err != nil { 325 + t.Error(err) 326 + } 327 + } 328 + 329 + func TestPropertyDifferenceDisjoint(t *testing.T) { 330 + f := func(s1 Set[int], s2 Set[int]) bool { 331 + diff := Collect(s1.Difference(s2)) 332 + return diff.IsDisjoint(s2) 333 + } 334 + 335 + if err := quick.Check(f, nil); err != nil { 336 + t.Error(err) 337 + } 338 + } 339 + 340 + func TestPropertySymmetricDifferenceCommutative(t *testing.T) { 341 + f := func(s1 Set[int], s2 Set[int]) bool { 342 + symDiff1 := Collect(s1.SymmetricDifference(s2)) 343 + symDiff2 := Collect(s2.SymmetricDifference(s1)) 344 + return symDiff1.Equal(symDiff2) 345 + } 346 + 347 + if err := quick.Check(f, nil); err != nil { 348 + t.Error(err) 349 + } 350 + } 351 + 352 + func TestPropertyRemoveWorks(t *testing.T) { 353 + f := func(s Set[int], item int) bool { 354 + clone := s.Clone() 355 + clone.Insert(item) 356 + clone.Remove(item) 357 + return !clone.Contains(item) 358 + } 359 + 360 + if err := quick.Check(f, nil); err != nil { 361 + t.Error(err) 362 + } 363 + } 364 + 365 + func TestPropertyClearEmpty(t *testing.T) { 366 + f := func(s Set[int]) bool { 367 + s.Clear() 368 + return s.IsEmpty() && s.Len() == 0 369 + } 370 + 371 + if err := quick.Check(f, nil); err != nil { 372 + t.Error(err) 373 + } 374 + } 375 + 376 + func TestPropertyIsSubsetReflexive(t *testing.T) { 377 + f := func(s Set[int]) bool { 378 + return s.IsSubset(s) 379 + } 380 + 381 + if err := quick.Check(f, nil); err != nil { 382 + t.Error(err) 383 + } 384 + } 385 + 386 + func TestPropertyDeMorganUnion(t *testing.T) { 387 + f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool { 388 + // create a universe that contains both sets 389 + u := universe.Clone() 390 + for item := range s1.All() { 391 + u.Insert(item) 392 + } 393 + for item := range s2.All() { 394 + u.Insert(item) 395 + } 396 + 397 + // (A u B)' = A' n B' 398 + union := Collect(s1.Union(s2)) 399 + complementUnion := Collect(u.Difference(union)) 400 + 401 + complementS1 := Collect(u.Difference(s1)) 402 + complementS2 := Collect(u.Difference(s2)) 403 + intersectionComplements := Collect(complementS1.Intersection(complementS2)) 404 + 405 + return complementUnion.Equal(intersectionComplements) 406 + } 407 + 408 + if err := quick.Check(f, nil); err != nil { 409 + t.Error(err) 410 + } 411 + }
+1
spindle/db/repos.go
··· 16 16 if err != nil { 17 17 return nil, err 18 18 } 19 + defer rows.Close() 19 20 20 21 var knots []string 21 22 for rows.Next() {
+22 -21
spindle/engine/engine.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 - "fmt" 7 6 "log/slog" 7 + "sync" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "golang.org/x/sync/errgroup" 11 10 "tangled.org/core/notifier" 12 11 "tangled.org/core/spindle/config" 13 12 "tangled.org/core/spindle/db" ··· 31 30 } 32 31 } 33 32 34 - eg, ctx := errgroup.WithContext(ctx) 33 + var wg sync.WaitGroup 35 34 for eng, wfs := range pipeline.Workflows { 36 35 workflowTimeout := eng.WorkflowTimeout() 37 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 38 37 39 38 for _, w := range wfs { 40 - eg.Go(func() error { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 41 43 wid := models.WorkflowId{ 42 44 PipelineId: pipelineId, 43 45 Name: w.Name, ··· 45 47 46 48 err := db.StatusRunning(wid, n) 47 49 if err != nil { 48 - return err 50 + l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 + return 49 52 } 50 53 51 54 err = eng.SetupWorkflow(ctx, wid, &w) ··· 61 64 62 65 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 66 if dbErr != nil { 64 - return dbErr 67 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 65 68 } 66 - return err 69 + return 67 70 } 68 71 defer eng.DestroyWorkflow(ctx, wid) 69 72 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 71 78 if err != nil { 72 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 80 wfLogger = nil ··· 99 106 if errors.Is(err, ErrTimedOut) { 100 107 dbErr := db.StatusTimeout(wid, n) 101 108 if dbErr != nil { 102 - return dbErr 109 + l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr) 103 110 } 104 111 } else { 105 112 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 106 113 if dbErr != nil { 107 - return dbErr 114 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 108 115 } 109 116 } 110 - 111 - return fmt.Errorf("starting steps image: %w", err) 117 + return 112 118 } 113 119 } 114 120 115 121 err = db.StatusSuccess(wid, n) 116 122 if err != nil { 117 - return err 123 + l.Error("failed to set workflow status to success", "wid", wid, "err", err) 118 124 } 119 - 120 - return nil 121 - }) 125 + }() 122 126 } 123 127 } 124 128 125 - if err := eg.Wait(); err != nil { 126 - l.Error("failed to run one or more workflows", "err", err) 127 - } else { 128 - l.Info("successfully ran full pipeline") 129 - } 129 + wg.Wait() 130 + l.Info("all workflows completed") 130 131 }
+5 -3
spindle/engines/nixery/engine.go
··· 294 294 workflowEnvs.AddEnv(s.Key, s.Value) 295 295 } 296 296 297 - step := w.Steps[idx].(Step) 297 + step := w.Steps[idx] 298 298 299 299 select { 300 300 case <-ctx.Done(): ··· 303 303 } 304 304 305 305 envs := append(EnvVars(nil), workflowEnvs...) 306 - for k, v := range step.environment { 307 - envs.AddEnv(k, v) 306 + if nixStep, ok := step.(Step); ok { 307 + for k, v := range nixStep.environment { 308 + envs.AddEnv(k, v) 309 + } 308 310 } 309 311 envs.AddEnv("HOME", homeDir) 310 312
+6 -1
spindle/models/logger.go
··· 12 12 type WorkflowLogger struct { 13 13 file *os.File 14 14 encoder *json.Encoder 15 + mask *SecretMask 15 16 } 16 17 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 18 19 path := LogFilePath(baseDir, wid) 19 20 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 26 return &WorkflowLogger{ 26 27 file: file, 27 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 28 30 }, nil 29 31 } 30 32 ··· 62 64 63 65 func (w *dataWriter) Write(p []byte) (int, error) { 64 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 65 70 entry := NewDataLogLine(w.idx, line, w.stream) 66 71 if err := w.logger.encoder.Encode(entry); err != nil { 67 72 return 0, err
+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
+51
spindle/models/secret_mask.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+1 -1
spindle/motd
··· 20 20 ** 21 21 ******** 22 22 23 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 23 + This is a spindle server. More info at https://docs.tangled.org/spindles.html#spindles 24 24 25 25 Most API routes are under /xrpc/
+31 -13
spindle/server.go
··· 8 8 "log/slog" 9 9 "maps" 10 10 "net/http" 11 + "sync" 11 12 12 13 "github.com/go-chi/chi/v5" 13 14 "tangled.org/core/api/tangled" ··· 30 31 ) 31 32 32 33 //go:embed motd 33 - var motd []byte 34 + var defaultMotd []byte 34 35 35 36 const ( 36 37 rbacDomain = "thisserver" 37 38 ) 38 39 39 40 type Spindle struct { 40 - jc *jetstream.JetstreamClient 41 - db *db.DB 42 - e *rbac.Enforcer 43 - l *slog.Logger 44 - n *notifier.Notifier 45 - engs map[string]models.Engine 46 - jq *queue.Queue 47 - cfg *config.Config 48 - ks *eventconsumer.Consumer 49 - res *idresolver.Resolver 50 - 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 51 54 } 52 55 53 56 // New creates a new Spindle server with the provided configuration and engines. ··· 128 131 cfg: cfg, 129 132 res: resolver, 130 133 vault: vault, 134 + motd: defaultMotd, 131 135 } 132 136 133 137 err = e.AddSpindle(rbacDomain) ··· 201 205 return s.e 202 206 } 203 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 + 204 222 // Start starts the Spindle server (blocking). 205 223 func (s *Spindle) Start(ctx context.Context) error { 206 224 // starts a job queue runner in the background ··· 246 264 mux := chi.NewRouter() 247 265 248 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 249 - w.Write(motd) 267 + w.Write(s.GetMotdContent()) 250 268 }) 251 269 mux.HandleFunc("/events", s.Events) 252 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
+1 -1
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 6 darkMode: "media", 7 7 theme: { 8 8 container: {
+6 -1
types/commit.go
··· 174 174 175 175 func (commit Commit) CoAuthors() []object.Signature { 176 176 var coAuthors []object.Signature 177 - 177 + seen := make(map[string]bool) 178 178 matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1) 179 179 180 180 for _, match := range matches { 181 181 if len(match) >= 3 { 182 182 name := strings.TrimSpace(match[1]) 183 183 email := strings.TrimSpace(match[2]) 184 + 185 + if seen[email] { 186 + continue 187 + } 188 + seen[email] = true 184 189 185 190 coAuthors = append(coAuthors, object.Signature{ 186 191 Name: name,
+7 -2
types/diff.go
··· 27 27 } 28 28 29 29 type DiffStat struct { 30 - Insertions int64 31 - Deletions int64 30 + Insertions int64 31 + Deletions int64 32 + FilesChanged int 32 33 } 33 34 34 35 func (d *Diff) Stats() DiffStat { ··· 37 38 stats.Insertions += f.LinesAdded 38 39 stats.Deletions += f.LinesDeleted 39 40 } 41 + stats.FilesChanged = len(d.TextFragments) 40 42 return stats 41 43 } 42 44 ··· 74 76 75 77 // used by html elements as a unique ID for hrefs 76 78 func (d *Diff) Id() string { 79 + if d.IsDelete { 80 + return d.Name.Old 81 + } 77 82 return d.Name.New 78 83 } 79 84
+112
types/diff_test.go
··· 1 + package types 2 + 3 + import "testing" 4 + 5 + func TestDiffId(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + diff Diff 9 + expected string 10 + }{ 11 + { 12 + name: "regular file uses new name", 13 + diff: Diff{ 14 + Name: struct { 15 + Old string `json:"old"` 16 + New string `json:"new"` 17 + }{Old: "", New: "src/main.go"}, 18 + }, 19 + expected: "src/main.go", 20 + }, 21 + { 22 + name: "new file uses new name", 23 + diff: Diff{ 24 + Name: struct { 25 + Old string `json:"old"` 26 + New string `json:"new"` 27 + }{Old: "", New: "src/new.go"}, 28 + IsNew: true, 29 + }, 30 + expected: "src/new.go", 31 + }, 32 + { 33 + name: "deleted file uses old name", 34 + diff: Diff{ 35 + Name: struct { 36 + Old string `json:"old"` 37 + New string `json:"new"` 38 + }{Old: "src/deleted.go", New: ""}, 39 + IsDelete: true, 40 + }, 41 + expected: "src/deleted.go", 42 + }, 43 + { 44 + name: "renamed file uses new name", 45 + diff: Diff{ 46 + Name: struct { 47 + Old string `json:"old"` 48 + New string `json:"new"` 49 + }{Old: "src/old.go", New: "src/renamed.go"}, 50 + IsRename: true, 51 + }, 52 + expected: "src/renamed.go", 53 + }, 54 + } 55 + 56 + for _, tt := range tests { 57 + t.Run(tt.name, func(t *testing.T) { 58 + if got := tt.diff.Id(); got != tt.expected { 59 + t.Errorf("Diff.Id() = %q, want %q", got, tt.expected) 60 + } 61 + }) 62 + } 63 + } 64 + 65 + func TestChangedFilesMatchesDiffId(t *testing.T) { 66 + // ChangedFiles() must return values matching each Diff's Id() 67 + // so that sidebar links point to the correct anchors. 68 + // Tests existing, deleted, new, and renamed files. 69 + nd := NiceDiff{ 70 + Diff: []Diff{ 71 + { 72 + Name: struct { 73 + Old string `json:"old"` 74 + New string `json:"new"` 75 + }{Old: "", New: "src/modified.go"}, 76 + }, 77 + { 78 + Name: struct { 79 + Old string `json:"old"` 80 + New string `json:"new"` 81 + }{Old: "src/deleted.go", New: ""}, 82 + IsDelete: true, 83 + }, 84 + { 85 + Name: struct { 86 + Old string `json:"old"` 87 + New string `json:"new"` 88 + }{Old: "", New: "src/new.go"}, 89 + IsNew: true, 90 + }, 91 + { 92 + Name: struct { 93 + Old string `json:"old"` 94 + New string `json:"new"` 95 + }{Old: "src/old.go", New: "src/renamed.go"}, 96 + IsRename: true, 97 + }, 98 + }, 99 + } 100 + 101 + changedFiles := nd.ChangedFiles() 102 + 103 + if len(changedFiles) != len(nd.Diff) { 104 + t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff)) 105 + } 106 + 107 + for i, diff := range nd.Diff { 108 + if changedFiles[i] != diff.Id() { 109 + t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id()) 110 + } 111 + } 112 + }