forked from tangled.org/core
Monorepo for Tangled

Compare changes

Choose any two refs to compare.

+2
appview/db/follow.go
··· 167 if err != nil { 168 return nil, err 169 } 170 for rows.Next() { 171 var follow models.Follow 172 var followedAt string
··· 167 if err != nil { 168 return nil, err 169 } 170 + defer rows.Close() 171 + 172 for rows.Next() { 173 var follow models.Follow 174 var followedAt string
+1
appview/db/issues.go
··· 452 if err != nil { 453 return nil, err 454 } 455 456 for rows.Next() { 457 var comment models.IssueComment
··· 452 if err != nil { 453 return nil, err 454 } 455 + defer rows.Close() 456 457 for rows.Next() { 458 var comment models.IssueComment
+1 -1
appview/db/language.go
··· 28 whereClause, 29 ) 30 rows, err := e.Query(query, args...) 31 - 32 if err != nil { 33 return nil, fmt.Errorf("failed to execute query: %w ", err) 34 } 35 36 var langs []models.RepoLanguage 37 for rows.Next() {
··· 28 whereClause, 29 ) 30 rows, err := e.Query(query, args...) 31 if err != nil { 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 33 } 34 + defer rows.Close() 35 36 var langs []models.RepoLanguage 37 for rows.Next() {
+5
appview/db/profile.go
··· 230 if err != nil { 231 return nil, err 232 } 233 234 profileMap := make(map[string]*models.Profile) 235 for rows.Next() { ··· 270 if err != nil { 271 return nil, err 272 } 273 idxs := make(map[string]int) 274 for did := range profileMap { 275 idxs[did] = 0 ··· 290 if err != nil { 291 return nil, err 292 } 293 idxs = make(map[string]int) 294 for did := range profileMap { 295 idxs[did] = 0
··· 230 if err != nil { 231 return nil, err 232 } 233 + defer rows.Close() 234 235 profileMap := make(map[string]*models.Profile) 236 for rows.Next() { ··· 271 if err != nil { 272 return nil, err 273 } 274 + defer rows.Close() 275 + 276 idxs := make(map[string]int) 277 for did := range profileMap { 278 idxs[did] = 0 ··· 293 if err != nil { 294 return nil, err 295 } 296 + defer rows.Close() 297 + 298 idxs = make(map[string]int) 299 for did := range profileMap { 300 idxs[did] = 0
+1
appview/db/registration.go
··· 38 if err != nil { 39 return nil, err 40 } 41 42 for rows.Next() { 43 var createdAt string
··· 38 if err != nil { 39 return nil, err 40 } 41 + defer rows.Close() 42 43 for rows.Next() { 44 var createdAt string
+11 -1
appview/db/repos.go
··· 56 limitClause, 57 ) 58 rows, err := e.Query(repoQuery, args...) 59 - 60 if err != nil { 61 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 62 } 63 64 for rows.Next() { 65 var repo models.Repo ··· 128 if err != nil { 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 130 } 131 for rows.Next() { 132 var repoat, labelat string 133 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 165 if err != nil { 166 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 167 } 168 for rows.Next() { 169 var repoat, lang string 170 if err := rows.Scan(&repoat, &lang); err != nil { ··· 191 if err != nil { 192 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 193 } 194 for rows.Next() { 195 var repoat string 196 var count int ··· 220 if err != nil { 221 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 222 } 223 for rows.Next() { 224 var repoat string 225 var open, closed int ··· 261 if err != nil { 262 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 263 } 264 for rows.Next() { 265 var repoat string 266 var open, merged, closed, deleted int
··· 56 limitClause, 57 ) 58 rows, err := e.Query(repoQuery, args...) 59 if err != nil { 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 61 } 62 + defer rows.Close() 63 64 for rows.Next() { 65 var repo models.Repo ··· 128 if err != nil { 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 130 } 131 + defer rows.Close() 132 + 133 for rows.Next() { 134 var repoat, labelat string 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 167 if err != nil { 168 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 169 } 170 + defer rows.Close() 171 + 172 for rows.Next() { 173 var repoat, lang string 174 if err := rows.Scan(&repoat, &lang); err != nil { ··· 195 if err != nil { 196 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 197 } 198 + defer rows.Close() 199 + 200 for rows.Next() { 201 var repoat string 202 var count int ··· 226 if err != nil { 227 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 228 } 229 + defer rows.Close() 230 + 231 for rows.Next() { 232 var repoat string 233 var open, closed int ··· 269 if err != nil { 270 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 271 } 272 + defer rows.Close() 273 + 274 for rows.Next() { 275 var repoat string 276 var open, merged, closed, deleted int
+1
appview/db/star.go
··· 165 if err != nil { 166 return nil, err 167 } 168 169 starMap := make(map[string][]models.Star) 170 for rows.Next() {
··· 165 if err != nil { 166 return nil, err 167 } 168 + defer rows.Close() 169 170 starMap := make(map[string][]models.Star) 171 for rows.Next() {
+67 -57
appview/notify/db/db.go
··· 3 import ( 4 "context" 5 "log" 6 - "maps" 7 "slices" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 13 "tangled.org/core/appview/notify" 14 "tangled.org/core/idresolver" 15 "tangled.org/core/orm" 16 ) 17 18 const ( 19 - maxMentions = 5 20 ) 21 22 type databaseNotifier struct { ··· 50 } 51 52 actorDid := syntax.DID(star.Did) 53 - recipients := []syntax.DID{syntax.DID(repo.Did)} 54 eventType := models.NotificationTypeRepoStarred 55 entityType := "repo" 56 entityId := star.RepoAt.String() ··· 75 } 76 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 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 85 if err != nil { 86 log.Printf("failed to fetch collaborators: %v", err) 87 return 88 } 89 for _, c := range collaborators { 90 - recipients = append(recipients, c.SubjectDid) 91 } 92 93 actorDid := syntax.DID(issue.Did) ··· 109 ) 110 n.notifyEvent( 111 actorDid, 112 - mentions, 113 models.NotificationTypeUserMentioned, 114 entityType, 115 entityId, ··· 131 } 132 issue := issues[0] 133 134 - var recipients []syntax.DID 135 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 136 137 if comment.IsReply() { 138 // if this comment is a reply, then notify everybody in that thread 139 parentAtUri := *comment.ReplyTo 140 - allThreads := issue.CommentList() 141 142 // find the parent thread, and add all DIDs from here to the recipient list 143 - for _, t := range allThreads { 144 if t.Self.AtUri().String() == parentAtUri { 145 - recipients = append(recipients, t.Participants()...) 146 } 147 } 148 } else { 149 // not a reply, notify just the issue author 150 - recipients = append(recipients, syntax.DID(issue.Did)) 151 } 152 153 actorDid := syntax.DID(comment.Did) ··· 169 ) 170 n.notifyEvent( 171 actorDid, 172 - mentions, 173 models.NotificationTypeUserMentioned, 174 entityType, 175 entityId, ··· 185 186 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 187 actorDid := syntax.DID(follow.UserDid) 188 - recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 189 eventType := models.NotificationTypeFollowed 190 entityType := "follow" 191 entityId := follow.UserDid ··· 213 log.Printf("NewPull: failed to get repos: %v", err) 214 return 215 } 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 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 223 if err != nil { 224 log.Printf("failed to fetch collaborators: %v", err) 225 return 226 } 227 for _, c := range collaborators { 228 - recipients = append(recipients, c.SubjectDid) 229 } 230 231 actorDid := syntax.DID(pull.OwnerDid) ··· 268 // build up the recipients list: 269 // - repo owner 270 // - all pull participants 271 - var recipients []syntax.DID 272 - recipients = append(recipients, syntax.DID(repo.Did)) 273 for _, p := range pull.Participants() { 274 - recipients = append(recipients, syntax.DID(p)) 275 } 276 277 actorDid := syntax.DID(comment.OwnerDid) ··· 295 ) 296 n.notifyEvent( 297 actorDid, 298 - mentions, 299 models.NotificationTypeUserMentioned, 300 entityType, 301 entityId, ··· 322 } 323 324 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 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 332 if err != nil { 333 log.Printf("failed to fetch collaborators: %v", err) 334 return 335 } 336 for _, c := range collaborators { 337 - recipients = append(recipients, c.SubjectDid) 338 } 339 for _, p := range issue.Participants() { 340 - recipients = append(recipients, syntax.DID(p)) 341 } 342 343 entityType := "pull" ··· 373 return 374 } 375 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 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 382 if err != nil { 383 log.Printf("failed to fetch collaborators: %v", err) 384 return 385 } 386 for _, c := range collaborators { 387 - recipients = append(recipients, c.SubjectDid) 388 } 389 for _, p := range pull.Participants() { 390 - recipients = append(recipients, syntax.DID(p)) 391 } 392 393 entityType := "pull" ··· 423 424 func (n *databaseNotifier) notifyEvent( 425 actorDid syntax.DID, 426 - recipients []syntax.DID, 427 eventType models.NotificationType, 428 entityType string, 429 entityId string, ··· 431 issueId *int64, 432 pullId *int64, 433 ) { 434 - if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 435 - recipients = recipients[:maxMentions] 436 } 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 - } 444 445 prefMap, err := db.GetNotificationPreferences( 446 n.db, 447 - orm.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 448 ) 449 if err != nil { 450 // failed to get prefs for users ··· 460 defer tx.Rollback() 461 462 // filter based on preferences 463 - for recipientDid := range recipientSet { 464 prefs, ok := prefMap[recipientDid] 465 if !ok { 466 prefs = models.DefaultNotificationPreferences(recipientDid)
··· 3 import ( 4 "context" 5 "log" 6 "slices" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 12 "tangled.org/core/appview/notify" 13 "tangled.org/core/idresolver" 14 "tangled.org/core/orm" 15 + "tangled.org/core/sets" 16 ) 17 18 const ( 19 + maxMentions = 8 20 ) 21 22 type databaseNotifier struct { ··· 50 } 51 52 actorDid := syntax.DID(star.Did) 53 + recipients := sets.Singleton(syntax.DID(repo.Did)) 54 eventType := models.NotificationTypeRepoStarred 55 entityType := "repo" 56 entityId := star.RepoAt.String() ··· 75 } 76 77 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 79 if err != nil { 80 log.Printf("failed to fetch collaborators: %v", err) 81 return 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 for _, c := range collaborators { 90 + recipients.Insert(c.SubjectDid) 91 + } 92 + for _, m := range mentions { 93 + recipients.Remove(m) 94 } 95 96 actorDid := syntax.DID(issue.Did) ··· 112 ) 113 n.notifyEvent( 114 actorDid, 115 + sets.Collect(slices.Values(mentions)), 116 models.NotificationTypeUserMentioned, 117 entityType, 118 entityId, ··· 134 } 135 issue := issues[0] 136 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)) 143 144 if comment.IsReply() { 145 // if this comment is a reply, then notify everybody in that thread 146 parentAtUri := *comment.ReplyTo 147 148 // find the parent thread, and add all DIDs from here to the recipient list 149 + for _, t := range issue.CommentList() { 150 if t.Self.AtUri().String() == parentAtUri { 151 + for _, p := range t.Participants() { 152 + recipients.Insert(p) 153 + } 154 } 155 } 156 } else { 157 // not a reply, notify just the issue author 158 + recipients.Insert(syntax.DID(issue.Did)) 159 + } 160 + 161 + for _, m := range mentions { 162 + recipients.Remove(m) 163 } 164 165 actorDid := syntax.DID(comment.Did) ··· 181 ) 182 n.notifyEvent( 183 actorDid, 184 + sets.Collect(slices.Values(mentions)), 185 models.NotificationTypeUserMentioned, 186 entityType, 187 entityId, ··· 197 198 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 199 actorDid := syntax.DID(follow.UserDid) 200 + recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) 201 eventType := models.NotificationTypeFollowed 202 entityType := "follow" 203 entityId := follow.UserDid ··· 225 log.Printf("NewPull: failed to get repos: %v", err) 226 return 227 } 228 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 229 if err != nil { 230 log.Printf("failed to fetch collaborators: %v", err) 231 return 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)) 238 for _, c := range collaborators { 239 + recipients.Insert(c.SubjectDid) 240 } 241 242 actorDid := syntax.DID(pull.OwnerDid) ··· 279 // build up the recipients list: 280 // - repo owner 281 // - all pull participants 282 + // - remove those already mentioned 283 + recipients := sets.Singleton(syntax.DID(repo.Did)) 284 for _, p := range pull.Participants() { 285 + recipients.Insert(syntax.DID(p)) 286 + } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 289 } 290 291 actorDid := syntax.DID(comment.OwnerDid) ··· 309 ) 310 n.notifyEvent( 311 actorDid, 312 + sets.Collect(slices.Values(mentions)), 313 models.NotificationTypeUserMentioned, 314 entityType, 315 entityId, ··· 336 } 337 338 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 339 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 340 if err != nil { 341 log.Printf("failed to fetch collaborators: %v", err) 342 return 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)) 350 for _, c := range collaborators { 351 + recipients.Insert(c.SubjectDid) 352 } 353 for _, p := range issue.Participants() { 354 + recipients.Insert(syntax.DID(p)) 355 } 356 357 entityType := "pull" ··· 387 return 388 } 389 390 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 391 if err != nil { 392 log.Printf("failed to fetch collaborators: %v", err) 393 return 394 } 395 + 396 + // build up the recipients list: 397 + // - repo owner 398 + // - all pull participants 399 + recipients := sets.Singleton(syntax.DID(repo.Did)) 400 for _, c := range collaborators { 401 + recipients.Insert(c.SubjectDid) 402 } 403 for _, p := range pull.Participants() { 404 + recipients.Insert(syntax.DID(p)) 405 } 406 407 entityType := "pull" ··· 437 438 func (n *databaseNotifier) notifyEvent( 439 actorDid syntax.DID, 440 + recipients sets.Set[syntax.DID], 441 eventType models.NotificationType, 442 entityType string, 443 entityId string, ··· 445 issueId *int64, 446 pullId *int64, 447 ) { 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 451 } 452 + 453 + recipients.Remove(actorDid) 454 455 prefMap, err := db.GetNotificationPreferences( 456 n.db, 457 + orm.FilterIn("user_did", slices.Collect(recipients.All())), 458 ) 459 if err != nil { 460 // failed to get prefs for users ··· 470 defer tx.Rollback() 471 472 // filter based on preferences 473 + for recipientDid := range recipients.All() { 474 prefs, ok := prefMap[recipientDid] 475 if !ok { 476 prefs = models.DefaultNotificationPreferences(recipientDid)
-1
appview/notify/merged_notifier.go
··· 39 v.Call(in) 40 }(n) 41 } 42 - wg.Wait() 43 } 44 45 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
··· 39 v.Call(in) 40 }(n) 41 } 42 } 43 44 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+2 -2
appview/oauth/handler.go
··· 25 26 r.Get("/oauth/client-metadata.json", o.clientMetadata) 27 r.Get("/oauth/jwks.json", o.jwks) 28 - r.Get("/oauth/callback", o.Callback) 29 return r 30 } 31 ··· 51 } 52 } 53 54 - func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { 55 ctx := r.Context() 56 l := o.Logger.With("query", r.URL.Query()) 57
··· 25 26 r.Get("/oauth/client-metadata.json", o.clientMetadata) 27 r.Get("/oauth/jwks.json", o.jwks) 28 + r.Get("/oauth/callback", o.callback) 29 return r 30 } 31 ··· 51 } 52 } 53 54 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 55 ctx := r.Context() 56 l := o.Logger.With("query", r.URL.Query()) 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 - }
···
+6 -1
appview/pages/funcmap.go
··· 25 "github.com/dustin/go-humanize" 26 "github.com/go-enry/go-enry/v2" 27 "github.com/yuin/goldmark" 28 "tangled.org/core/appview/filetree" 29 "tangled.org/core/appview/models" 30 "tangled.org/core/appview/pages/markup" ··· 261 }, 262 "description": func(text string) template.HTML { 263 p.rctx.RendererType = markup.RendererTypeDefault 264 - htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 265 sanitized := p.rctx.SanitizeDescription(htmlString) 266 return template.HTML(sanitized) 267 },
··· 25 "github.com/dustin/go-humanize" 26 "github.com/go-enry/go-enry/v2" 27 "github.com/yuin/goldmark" 28 + emoji "github.com/yuin/goldmark-emoji" 29 "tangled.org/core/appview/filetree" 30 "tangled.org/core/appview/models" 31 "tangled.org/core/appview/pages/markup" ··· 262 }, 263 "description": func(text string) template.HTML { 264 p.rctx.RendererType = markup.RendererTypeDefault 265 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New( 266 + goldmark.WithExtensions( 267 + emoji.Emoji, 268 + ), 269 + )) 270 sanitized := p.rctx.SanitizeDescription(htmlString) 271 return template.HTML(sanitized) 272 },
+2
appview/pages/markup/markdown.go
··· 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 "github.com/alecthomas/chroma/v2/styles" 15 "github.com/yuin/goldmark" 16 highlighting "github.com/yuin/goldmark-highlighting/v2" 17 "github.com/yuin/goldmark/ast" 18 "github.com/yuin/goldmark/extension" ··· 66 ), 67 callout.CalloutExtention, 68 textension.AtExt, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(),
··· 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 "github.com/alecthomas/chroma/v2/styles" 15 "github.com/yuin/goldmark" 16 + "github.com/yuin/goldmark-emoji" 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 "github.com/yuin/goldmark/ast" 19 "github.com/yuin/goldmark/extension" ··· 67 ), 68 callout.CalloutExtention, 69 textension.AtExt, 70 + emoji.Emoji, 71 ), 72 goldmark.WithParserOptions( 73 parser.WithAutoHeadingID(),
+1 -1
appview/pages/pages.go
··· 640 } 641 642 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 - return p.executePlain("fragments/starBtn", w, params) 644 } 645 646 type RepoIndexParams struct {
··· 640 } 641 642 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 + return p.executePlain("fragments/starBtn-oob", w, params) 644 } 645 646 type RepoIndexParams struct {
+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 {{ define "fragments/starBtn" }} 2 <button 3 id="starBtn" 4 class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" ··· 10 {{ end }} 11 12 hx-trigger="click" 13 - hx-target="this" 14 - hx-swap="outerHTML" 15 - hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]' 16 hx-disabled-elt="#starBtn" 17 > 18 {{ if .IsStarred }}
··· 1 {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 3 <button 4 id="starBtn" 5 class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" ··· 11 {{ end }} 12 13 hx-trigger="click" 14 hx-disabled-elt="#starBtn" 15 > 16 {{ if .IsStarred }}
+1 -1
appview/pages/templates/knots/index.html
··· 105 {{ define "docsButton" }} 106 <a 107 class="btn flex items-center gap-2" 108 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 109 {{ i "book" "size-4" }} 110 docs 111 </a>
··· 105 {{ define "docsButton" }} 106 <a 107 class="btn flex items-center gap-2" 108 + href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md"> 109 {{ i "book" "size-4" }} 110 docs 111 </a>
+1 -1
appview/pages/templates/repo/empty.html
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4">
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4">
+6 -6
appview/pages/templates/repo/fragments/backlinks.html
··· 14 <div class="flex gap-2 items-center"> 15 {{ if .State.IsClosed }} 16 <span class="text-gray-500 dark:text-gray-400"> 17 - {{ i "ban" "w-4 h-4" }} 18 </span> 19 {{ else if eq .Kind.String "issues" }} 20 <span class="text-green-600 dark:text-green-500"> 21 - {{ i "circle-dot" "w-4 h-4" }} 22 </span> 23 {{ else if .State.IsOpen }} 24 <span class="text-green-600 dark:text-green-500"> 25 - {{ i "git-pull-request" "w-4 h-4" }} 26 </span> 27 {{ else if .State.IsMerged }} 28 <span class="text-purple-600 dark:text-purple-500"> 29 - {{ i "git-merge" "w-4 h-4" }} 30 </span> 31 {{ else }} 32 <span class="text-gray-600 dark:text-gray-300"> 33 - {{ i "git-pull-request-closed" "w-4 h-4" }} 34 </span> 35 {{ end }} 36 - <a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 </div> 38 {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 <div>
··· 14 <div class="flex gap-2 items-center"> 15 {{ if .State.IsClosed }} 16 <span class="text-gray-500 dark:text-gray-400"> 17 + {{ i "ban" "size-3" }} 18 </span> 19 {{ else if eq .Kind.String "issues" }} 20 <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "size-3" }} 22 </span> 23 {{ else if .State.IsOpen }} 24 <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "size-3" }} 26 </span> 27 {{ else if .State.IsMerged }} 28 <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "size-3" }} 30 </span> 31 {{ else }} 32 <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "size-3" }} 34 </span> 35 {{ end }} 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 </div> 38 {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 <div>
+1 -1
appview/pages/templates/strings/string.html
··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 - <div class="flex gap-2 text-base"> 21 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true"
··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 + <div class="flex gap-2 items-stretch text-base"> 21 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true"
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 </a> 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 {{ end }} 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 </a> 14 {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 {{ end }} 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+9 -6
appview/pages/templates/user/signup.html
··· 43 page to complete your registration. 44 </span> 45 <div class="w-full mt-4 text-center"> 46 - <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 </form> 52 - <p class="text-sm text-gray-500"> 53 - Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 - </p> 55 - 56 - <p id="signup-msg" class="error w-full"></p> 57 </main> 58 </body> 59 </html>
··· 43 page to complete your registration. 44 </span> 45 <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 47 </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 + <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> 59 </form> 60 </main> 61 </body> 62 </html>
+8
appview/pulls/pulls.go
··· 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 return 1368 } 1369 } 1370 1371 if err = tx.Commit(); err != nil { 1372 log.Println("failed to create pull request", err) 1373 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1374 return 1375 } 1376 1377 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
··· 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 return 1368 } 1369 + 1370 } 1371 1372 if err = tx.Commit(); err != nil { 1373 log.Println("failed to create pull request", err) 1374 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1375 return 1376 + } 1377 + 1378 + // notify about each pull 1379 + // 1380 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1381 + for _, p := range stack { 1382 + s.notifier.NewPull(r.Context(), p) 1383 } 1384 1385 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
-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 - }
···
+17
appview/state/git_http.go
··· 25 26 } 27 28 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 29 user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 if !ok {
··· 25 26 } 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 + 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 46 user, ok := r.Context().Value("resolvedId").(identity.Identity) 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 - }
···
+1
appview/state/router.go
··· 101 102 // These routes get proxied to the knot 103 r.Get("/info/refs", s.InfoRefs) 104 r.Post("/git-upload-pack", s.UploadPack) 105 r.Post("/git-receive-pack", s.ReceivePack) 106
··· 101 102 // These routes get proxied to the knot 103 r.Get("/info/refs", s.InfoRefs) 104 + r.Post("/git-upload-archive", s.UploadArchive) 105 r.Post("/git-upload-pack", s.UploadPack) 106 r.Post("/git-receive-pack", s.ReceivePack) 107
-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 8 "tangled.org/core/appview/config" 9 "tangled.org/core/appview/state" 10 - "tangled.org/core/appview/web" 11 tlog "tangled.org/core/log" 12 ) 13 ··· 36 37 logger.Info("starting server", "address", c.Core.ListenAddr) 38 39 - if err := http.ListenAndServe(c.Core.ListenAddr, web.RouterFromState(state)); err != nil { 40 logger.Error("failed to start appview", "err", err) 41 } 42 }
··· 7 8 "tangled.org/core/appview/config" 9 "tangled.org/core/appview/state" 10 tlog "tangled.org/core/log" 11 ) 12 ··· 35 36 logger.Info("starting server", "address", c.Core.ListenAddr) 37 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 logger.Error("failed to start appview", "err", err) 40 } 41 }
+9 -9
flake.lock
··· 35 "systems": "systems" 36 }, 37 "locked": { 38 - "lastModified": 1694529238, 39 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 40 "owner": "numtide", 41 "repo": "flake-utils", 42 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 43 "type": "github" 44 }, 45 "original": { ··· 56 ] 57 }, 58 "locked": { 59 - "lastModified": 1754078208, 60 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 61 "owner": "nix-community", 62 "repo": "gomod2nix", 63 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 64 "type": "github" 65 }, 66 "original": { ··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 - "lastModified": 1751984180, 154 - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 158 "type": "github" 159 }, 160 "original": {
··· 35 "systems": "systems" 36 }, 37 "locked": { 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 40 "owner": "numtide", 41 "repo": "flake-utils", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 43 "type": "github" 44 }, 45 "original": { ··· 56 ] 57 }, 58 "locked": { 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 61 "owner": "nix-community", 62 "repo": "gomod2nix", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 64 "type": "github" 65 }, 66 "original": { ··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 "type": "github" 159 }, 160 "original": {
-2
flake.nix
··· 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 83 - inherit (pkgs) gcc; 84 inherit sqlite-lib-src; 85 }; 86 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; ··· 156 nativeBuildInputs = [ 157 pkgs.go 158 pkgs.air 159 - pkgs.tilt 160 pkgs.gopls 161 pkgs.httpie 162 pkgs.litecli
··· 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 83 inherit sqlite-lib-src; 84 }; 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; ··· 155 nativeBuildInputs = [ 156 pkgs.go 157 pkgs.air 158 pkgs.gopls 159 pkgs.httpie 160 pkgs.litecli
+3 -2
go.mod
··· 1 module tangled.org/core 2 3 - go 1.24.4 4 5 require ( 6 github.com/Blank-Xu/sql-adapter v1.1.1 ··· 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 github.com/yuin/goldmark v1.7.13 48 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 49 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 50 golang.org/x/crypto v0.40.0 51 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 52 golang.org/x/image v0.31.0 53 golang.org/x/net v0.42.0 54 - golang.org/x/sync v0.17.0 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 gopkg.in/yaml.v3 v3.0.1 57 ) ··· 203 go.uber.org/atomic v1.11.0 // indirect 204 go.uber.org/multierr v1.11.0 // indirect 205 go.uber.org/zap v1.27.0 // indirect 206 golang.org/x/sys v0.34.0 // indirect 207 golang.org/x/text v0.29.0 // indirect 208 golang.org/x/time v0.12.0 // indirect
··· 1 module tangled.org/core 2 3 + go 1.25.0 4 5 require ( 6 github.com/Blank-Xu/sql-adapter v1.1.1 ··· 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 github.com/yuin/goldmark v1.7.13 48 + github.com/yuin/goldmark-emoji v1.0.6 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 golang.org/x/crypto v0.40.0 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 golang.org/x/image v0.31.0 54 golang.org/x/net v0.42.0 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 gopkg.in/yaml.v3 v3.0.1 57 ) ··· 203 go.uber.org/atomic v1.11.0 // indirect 204 go.uber.org/multierr v1.11.0 // indirect 205 go.uber.org/zap v1.27.0 // indirect 206 + golang.org/x/sync v0.17.0 // indirect 207 golang.org/x/sys v0.34.0 // indirect 208 golang.org/x/text v0.29.0 // indirect 209 golang.org/x/time v0.12.0 // indirect
+2
go.sum
··· 505 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 506 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 507 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 508 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 509 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 510 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
··· 505 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 506 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 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= 510 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 511 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 512 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+4 -4
hook/hook.go
··· 48 }, 49 Commands: []*cli.Command{ 50 { 51 - Name: "post-recieve", 52 - Usage: "sends a post-recieve hook to the knot (waits for stdin)", 53 - Action: postRecieve, 54 }, 55 }, 56 } 57 } 58 59 - func postRecieve(ctx context.Context, cmd *cli.Command) error { 60 gitDir := cmd.String("git-dir") 61 userDid := cmd.String("user-did") 62 userHandle := cmd.String("user-handle")
··· 48 }, 49 Commands: []*cli.Command{ 50 { 51 + Name: "post-receive", 52 + Usage: "sends a post-receive hook to the knot (waits for stdin)", 53 + Action: postReceive, 54 }, 55 }, 56 } 57 } 58 59 + func postReceive(ctx context.Context, cmd *cli.Command) error { 60 gitDir := cmd.String("git-dir") 61 userDid := cmd.String("user-did") 62 userHandle := cmd.String("user-handle")
+1 -1
hook/setup.go
··· 138 option_var="GIT_PUSH_OPTION_$i" 139 push_options+=(-push-option "${!option_var}") 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 142 `, executablePath, config.internalApi) 143 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
··· 138 option_var="GIT_PUSH_OPTION_$i" 139 push_options+=(-push-option "${!option_var}") 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-receive 142 `, executablePath, config.internalApi) 143 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+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 return c.RunService(cmd) 96 } 97 98 func (c *ServiceCommand) UploadPack() error { 99 cmd := exec.Command("git", []string{ 100 - "-c", "uploadpack.allowFilter=true", 101 "upload-pack", 102 "--stateless-rpc", 103 ".",
··· 95 return c.RunService(cmd) 96 } 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 + 111 func (c *ServiceCommand) UploadPack() error { 112 cmd := exec.Command("git", []string{ 113 "upload-pack", 114 "--stateless-rpc", 115 ".",
+47
knotserver/git.go
··· 56 } 57 } 58 59 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name")
··· 56 } 57 } 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 + 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 107 did := chi.URLParam(r, "did") 108 name := chi.URLParam(r, "name")
+1
knotserver/router.go
··· 82 r.Route("/{name}", func(r chi.Router) { 83 // routes for git operations 84 r.Get("/info/refs", h.InfoRefs) 85 r.Post("/git-upload-pack", h.UploadPack) 86 r.Post("/git-receive-pack", h.ReceivePack) 87 })
··· 82 r.Route("/{name}", func(r chi.Router) { 83 // routes for git operations 84 r.Get("/info/refs", h.InfoRefs) 85 + r.Post("/git-upload-archive", h.UploadArchive) 86 r.Post("/git-upload-pack", h.UploadPack) 87 r.Post("/git-receive-pack", h.ReceivePack) 88 })
+1 -1
knotserver/server.go
··· 64 logger.Info("running in dev mode, signature verification is disabled") 65 } 66 67 - db, err := db.Setup(c.Server.DBPath) 68 if err != nil { 69 return fmt.Errorf("failed to load db: %w", err) 70 }
··· 64 logger.Info("running in dev mode, signature verification is disabled") 65 } 66 67 + db, err := db.Setup(ctx, c.Server.DBPath) 68 if err != nil { 69 return fmt.Errorf("failed to load db: %w", err) 70 }
+3
nix/gomod2nix.toml
··· 530 [mod."github.com/yuin/goldmark"] 531 version = "v1.7.13" 532 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 533 [mod."github.com/yuin/goldmark-highlighting/v2"] 534 version = "v2.0.0-20230729083705-37449abec8cc" 535 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
··· 530 [mod."github.com/yuin/goldmark"] 531 version = "v1.7.13" 532 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 533 + [mod."github.com/yuin/goldmark-emoji"] 534 + version = "v1.0.6" 535 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 536 [mod."github.com/yuin/goldmark-highlighting/v2"] 537 version = "v2.0.0-20230729083705-37449abec8cc" 538 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+7 -5
nix/pkgs/sqlite-lib.nix
··· 1 { 2 - gcc, 3 stdenv, 4 sqlite-lib-src, 5 }: 6 stdenv.mkDerivation { 7 name = "sqlite-lib"; 8 src = sqlite-lib-src; 9 - nativeBuildInputs = [gcc]; 10 buildPhase = '' 11 - gcc -c sqlite3.c 12 - ar rcs libsqlite3.a sqlite3.o 13 - ranlib libsqlite3.a 14 mkdir -p $out/include $out/lib 15 cp *.h $out/include 16 cp libsqlite3.a $out/lib
··· 1 { 2 stdenv, 3 sqlite-lib-src, 4 }: 5 stdenv.mkDerivation { 6 name = "sqlite-lib"; 7 src = sqlite-lib-src; 8 + 9 buildPhase = '' 10 + $CC -c sqlite3.c 11 + $AR rcs libsqlite3.a sqlite3.o 12 + $RANLIB libsqlite3.a 13 + ''; 14 + 15 + installPhase = '' 16 mkdir -p $out/include $out/lib 17 cp *.h $out/include 18 cp libsqlite3.a $out/lib
+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 if err != nil { 17 return nil, err 18 } 19 20 var knots []string 21 for rows.Next() {
··· 16 if err != nil { 17 return nil, err 18 } 19 + defer rows.Close() 20 21 var knots []string 22 for rows.Next() {
+22 -21
spindle/engine/engine.go
··· 3 import ( 4 "context" 5 "errors" 6 - "fmt" 7 "log/slog" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "golang.org/x/sync/errgroup" 11 "tangled.org/core/notifier" 12 "tangled.org/core/spindle/config" 13 "tangled.org/core/spindle/db" ··· 31 } 32 } 33 34 - eg, ctx := errgroup.WithContext(ctx) 35 for eng, wfs := range pipeline.Workflows { 36 workflowTimeout := eng.WorkflowTimeout() 37 l.Info("using workflow timeout", "timeout", workflowTimeout) 38 39 for _, w := range wfs { 40 - eg.Go(func() error { 41 wid := models.WorkflowId{ 42 PipelineId: pipelineId, 43 Name: w.Name, ··· 45 46 err := db.StatusRunning(wid, n) 47 if err != nil { 48 - return err 49 } 50 51 err = eng.SetupWorkflow(ctx, wid, &w) ··· 61 62 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 if dbErr != nil { 64 - return dbErr 65 } 66 - return err 67 } 68 defer eng.DestroyWorkflow(ctx, wid) 69 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 if err != nil { 72 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 wfLogger = nil ··· 99 if errors.Is(err, ErrTimedOut) { 100 dbErr := db.StatusTimeout(wid, n) 101 if dbErr != nil { 102 - return dbErr 103 } 104 } else { 105 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 106 if dbErr != nil { 107 - return dbErr 108 } 109 } 110 - 111 - return fmt.Errorf("starting steps image: %w", err) 112 } 113 } 114 115 err = db.StatusSuccess(wid, n) 116 if err != nil { 117 - return err 118 } 119 - 120 - return nil 121 - }) 122 } 123 } 124 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 - } 130 }
··· 3 import ( 4 "context" 5 "errors" 6 "log/slog" 7 + "sync" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "tangled.org/core/notifier" 11 "tangled.org/core/spindle/config" 12 "tangled.org/core/spindle/db" ··· 30 } 31 } 32 33 + var wg sync.WaitGroup 34 for eng, wfs := range pipeline.Workflows { 35 workflowTimeout := eng.WorkflowTimeout() 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 37 38 for _, w := range wfs { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 43 wid := models.WorkflowId{ 44 PipelineId: pipelineId, 45 Name: w.Name, ··· 47 48 err := db.StatusRunning(wid, n) 49 if err != nil { 50 + l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 + return 52 } 53 54 err = eng.SetupWorkflow(ctx, wid, &w) ··· 64 65 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 66 if dbErr != nil { 67 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 68 } 69 + return 70 } 71 defer eng.DestroyWorkflow(ctx, wid) 72 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) 78 if err != nil { 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 80 wfLogger = nil ··· 106 if errors.Is(err, ErrTimedOut) { 107 dbErr := db.StatusTimeout(wid, n) 108 if dbErr != nil { 109 + l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr) 110 } 111 } else { 112 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 113 if dbErr != nil { 114 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 115 } 116 } 117 + return 118 } 119 } 120 121 err = db.StatusSuccess(wid, n) 122 if err != nil { 123 + l.Error("failed to set workflow status to success", "wid", wid, "err", err) 124 } 125 + }() 126 } 127 } 128 129 + wg.Wait() 130 + l.Info("all workflows completed") 131 }
+5 -3
spindle/engines/nixery/engine.go
··· 294 workflowEnvs.AddEnv(s.Key, s.Value) 295 } 296 297 - step := w.Steps[idx].(Step) 298 299 select { 300 case <-ctx.Done(): ··· 303 } 304 305 envs := append(EnvVars(nil), workflowEnvs...) 306 - for k, v := range step.environment { 307 - envs.AddEnv(k, v) 308 } 309 envs.AddEnv("HOME", homeDir) 310
··· 294 workflowEnvs.AddEnv(s.Key, s.Value) 295 } 296 297 + step := w.Steps[idx] 298 299 select { 300 case <-ctx.Done(): ··· 303 } 304 305 envs := append(EnvVars(nil), workflowEnvs...) 306 + if nixStep, ok := step.(Step); ok { 307 + for k, v := range nixStep.environment { 308 + envs.AddEnv(k, v) 309 + } 310 } 311 envs.AddEnv("HOME", homeDir) 312
+6 -1
spindle/models/logger.go
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 } 16 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 path := LogFilePath(baseDir, wid) 19 20 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 return &WorkflowLogger{ 26 file: file, 27 encoder: json.NewEncoder(file), 28 }, nil 29 } 30 ··· 62 63 func (w *dataWriter) Write(p []byte) (int, error) { 64 line := strings.TrimRight(string(p), "\r\n") 65 entry := NewDataLogLine(w.idx, line, w.stream) 66 if err := w.logger.encoder.Encode(entry); err != nil { 67 return 0, err
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 + mask *SecretMask 16 } 17 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 19 path := LogFilePath(baseDir, wid) 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 26 return &WorkflowLogger{ 27 file: file, 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 30 }, nil 31 } 32 ··· 64 65 func (w *dataWriter) Write(p []byte) (int, error) { 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 70 entry := NewDataLogLine(w.idx, line, w.stream) 71 if err := w.logger.encoder.Encode(entry); err != nil { 72 return 0, err
+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 + }
+6 -1
types/commit.go
··· 174 175 func (commit Commit) CoAuthors() []object.Signature { 176 var coAuthors []object.Signature 177 - 178 matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1) 179 180 for _, match := range matches { 181 if len(match) >= 3 { 182 name := strings.TrimSpace(match[1]) 183 email := strings.TrimSpace(match[2]) 184 185 coAuthors = append(coAuthors, object.Signature{ 186 Name: name,
··· 174 175 func (commit Commit) CoAuthors() []object.Signature { 176 var coAuthors []object.Signature 177 + seen := make(map[string]bool) 178 matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1) 179 180 for _, match := range matches { 181 if len(match) >= 3 { 182 name := strings.TrimSpace(match[1]) 183 email := strings.TrimSpace(match[2]) 184 + 185 + if seen[email] { 186 + continue 187 + } 188 + seen[email] = true 189 190 coAuthors = append(coAuthors, object.Signature{ 191 Name: name,