Monorepo for Tangled tangled.org

wip: appview/{service,web}: service layer

Obviously file naming of appview/web/handler/*.go files are directly
against to go convention. Though I think flattening all handler files
can significantly reduce the effort involved in file naming and
structuring. We are already grouping core services by domains, and doing
same for web handers is just over-complicating.

```
- appview/web/routes.go : define all web page routes
- appview/web/middleware.go : define middlewares related to web routes
- appview/web/handler/*.go : http handlers, named as path pattern
- appview/service/* : domain-level services
```

Each handlers are pure by receiving all required dependencies as
parameters. Ideally we should not pass base dependencies like `db`, but
that's how it works for now.

Now we can test:

- http handlers with mocked services/renderer
- internal service logic without http handlers

Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me bc79d65e 1cbac20e

verified
+1728 -3
+2 -2
appview/oauth/handler.go
··· 31 31 32 32 r.Get("/oauth/client-metadata.json", o.clientMetadata) 33 33 r.Get("/oauth/jwks.json", o.jwks) 34 - r.Get("/oauth/callback", o.callback) 34 + r.Get("/oauth/callback", o.Callback) 35 35 return r 36 36 } 37 37 ··· 57 57 } 58 58 } 59 59 60 - func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 60 + func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { 61 61 ctx := r.Context() 62 62 l := o.Logger.With("query", r.URL.Query()) 63 63
+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 + )
+280
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, ok := session.FromContext(ctx) 65 + if !ok { 66 + l.Error("user session is missing in context") 67 + return nil, ErrForbidden 68 + } 69 + authorDid := syntax.DID(sess.User.Did) 70 + atpclient := sess.AtpClient 71 + l = l.With("did", authorDid) 72 + 73 + mentions, references := s.refResolver.Resolve(ctx, body) 74 + 75 + issue := models.Issue{ 76 + Did: authorDid.String(), 77 + Rkey: tid.TID(), 78 + RepoAt: repo.RepoAt(), 79 + Title: title, 80 + Body: body, 81 + Created: time.Now(), 82 + Mentions: mentions, 83 + References: references, 84 + Open: true, 85 + Repo: repo, 86 + } 87 + 88 + if err := s.validator.ValidateIssue(&issue); err != nil { 89 + l.Error("validation error", "err", err) 90 + return nil, ErrValidationFail 91 + } 92 + 93 + tx, err := s.db.BeginTx(ctx, nil) 94 + if err != nil { 95 + l.Error("db.BeginTx failed", "err", err) 96 + return nil, ErrDatabaseFail 97 + } 98 + defer tx.Rollback() 99 + 100 + if err := db.PutIssue(tx, &issue); err != nil { 101 + l.Error("db.PutIssue failed", "err", err) 102 + return nil, ErrDatabaseFail 103 + } 104 + 105 + record := issue.AsRecord() 106 + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 107 + Repo: issue.Did, 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.HasSearchFilters() { 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 + filters := []orm.Filter{ 146 + orm.FilterEq("repo_at", repo.RepoAt()), 147 + } 148 + if searchOpts.IsOpen != nil { 149 + openInt := 0 150 + if *searchOpts.IsOpen { 151 + openInt = 1 152 + } 153 + filters = append(filters, orm.FilterEq("open", openInt)) 154 + } 155 + issues, err = db.GetIssuesPaginated( 156 + s.db, 157 + searchOpts.Page, 158 + filters..., 159 + ) 160 + if err != nil { 161 + l.Error("failed to get issues", "err", err) 162 + return nil, ErrDatabaseFail 163 + } 164 + } 165 + 166 + return issues, nil 167 + } 168 + 169 + func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error { 170 + l := s.logger.With("method", "EditIssue") 171 + sess, ok := session.FromContext(ctx) 172 + if !ok { 173 + l.Error("user session is missing in context") 174 + return ErrForbidden 175 + } 176 + atpclient := sess.AtpClient 177 + l = l.With("did", sess.User.Did) 178 + 179 + mentions, references := s.refResolver.Resolve(ctx, issue.Body) 180 + issue.Mentions = mentions 181 + issue.References = references 182 + 183 + if sess.User.Did != issue.Did { 184 + l.Error("only author can edit the issue") 185 + return ErrForbidden 186 + } 187 + 188 + if err := s.validator.ValidateIssue(issue); err != nil { 189 + l.Error("validation error", "err", err) 190 + return ErrValidationFail 191 + } 192 + 193 + tx, err := s.db.BeginTx(ctx, nil) 194 + if err != nil { 195 + l.Error("db.BeginTx failed", "err", err) 196 + return ErrDatabaseFail 197 + } 198 + defer tx.Rollback() 199 + 200 + if err := db.PutIssue(tx, issue); err != nil { 201 + l.Error("db.PutIssue failed", "err", err) 202 + return ErrDatabaseFail 203 + } 204 + 205 + record := issue.AsRecord() 206 + 207 + ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 208 + if err != nil { 209 + l.Error("atproto.RepoGetRecord failed", "err", err) 210 + return ErrPDSFail 211 + } 212 + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 213 + Repo: issue.Did, 214 + Collection: tangled.RepoIssueNSID, 215 + Rkey: issue.Rkey, 216 + SwapRecord: ex.Cid, 217 + Record: &lexutil.LexiconTypeDecoder{ 218 + Val: &record, 219 + }, 220 + }) 221 + if err != nil { 222 + l.Error("atproto.RepoPutRecord failed", "err", err) 223 + return ErrPDSFail 224 + } 225 + 226 + if err = tx.Commit(); err != nil { 227 + l.Error("tx.Commit failed", "err", err) 228 + return ErrDatabaseFail 229 + } 230 + 231 + // TODO: notify EditIssue 232 + 233 + return nil 234 + } 235 + 236 + func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error { 237 + l := s.logger.With("method", "DeleteIssue") 238 + sess, ok := session.FromContext(ctx) 239 + if !ok { 240 + l.Error("user session is missing in context") 241 + return ErrForbidden 242 + } 243 + atpclient := sess.AtpClient 244 + l = l.With("did", sess.User.Did) 245 + 246 + if sess.User.Did != issue.Did { 247 + l.Error("only author can edit the issue") 248 + return ErrForbidden 249 + } 250 + 251 + tx, err := s.db.BeginTx(ctx, nil) 252 + if err != nil { 253 + l.Error("db.BeginTx failed", "err", err) 254 + return ErrDatabaseFail 255 + } 256 + defer tx.Rollback() 257 + 258 + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 259 + l.Error("db.DeleteIssues failed", "err", err) 260 + return ErrDatabaseFail 261 + } 262 + 263 + _, err = atproto.RepoDeleteRecord(ctx, atpclient, &atproto.RepoDeleteRecord_Input{ 264 + Collection: tangled.RepoIssueNSID, 265 + Repo: issue.Did, 266 + Rkey: issue.Rkey, 267 + }) 268 + if err != nil { 269 + l.Error("atproto.RepoDeleteRecord failed", "err", err) 270 + return ErrPDSFail 271 + } 272 + 273 + if err := tx.Commit(); err != nil { 274 + l.Error("tx.Commit failed", "err", err) 275 + return ErrDatabaseFail 276 + } 277 + 278 + s.notifier.DeleteIssue(ctx, issue) 279 + return nil 280 + }
+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, ok := session.FromContext(ctx) 17 + if !ok { 18 + l.Error("user session is missing in context") 19 + return ErrUnAuthenticated 20 + } 21 + sessDid := syntax.DID(sess.User.Did) 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, ok := session.FromContext(ctx) 53 + if !ok { 54 + l.Error("user session is missing in context") 55 + return ErrUnAuthenticated 56 + } 57 + sessDid := syntax.DID(sess.User.Did) 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 + )
+94
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 + lexutil "github.com/bluesky-social/indigo/lex/util" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/config" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/session" 15 + "tangled.org/core/rbac" 16 + "tangled.org/core/tid" 17 + ) 18 + 19 + type Service struct { 20 + logger *slog.Logger 21 + config *config.Config 22 + db *db.DB 23 + enforcer *rbac.Enforcer 24 + } 25 + 26 + func NewService( 27 + logger *slog.Logger, 28 + config *config.Config, 29 + db *db.DB, 30 + enforcer *rbac.Enforcer, 31 + ) Service { 32 + return Service{ 33 + logger, 34 + config, 35 + db, 36 + enforcer, 37 + } 38 + } 39 + 40 + // NewRepo creates a repository 41 + // It expects atproto session to be passed in `ctx` 42 + func (s *Service) NewRepo(ctx context.Context, name, description, knot string) (*models.Repo, error) { 43 + l := s.logger.With("method", "NewRepo") 44 + sess, ok := session.FromContext(ctx) 45 + if !ok { 46 + l.Error("user session is missing in context") 47 + return nil, ErrForbidden 48 + } 49 + 50 + atpclient := sess.AtpClient 51 + l = l.With("did", sess.User.Did) 52 + 53 + repo := models.Repo{ 54 + Did: sess.User.Did, 55 + Name: name, 56 + Knot: knot, 57 + Rkey: tid.TID(), 58 + Description: description, 59 + Created: time.Now(), 60 + Labels: s.config.Label.DefaultLabelDefs, 61 + } 62 + l = l.With("aturi", repo.RepoAt()) 63 + 64 + tx, err := s.db.BeginTx(ctx, nil) 65 + if err != nil { 66 + l.Error("db.BeginTx failed", "err", err) 67 + return nil, ErrDatabaseFail 68 + } 69 + defer tx.Rollback() 70 + 71 + if err = db.AddRepo(tx, &repo); err != nil { 72 + l.Error("db.AddRepo failed", "err", err) 73 + return nil, ErrDatabaseFail 74 + } 75 + 76 + record := repo.AsRecord() 77 + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 78 + Repo: repo.Did, 79 + Collection: tangled.RepoNSID, 80 + Rkey: repo.Rkey, 81 + Record: &lexutil.LexiconTypeDecoder{ 82 + Val: &record, 83 + }, 84 + }) 85 + if err != nil { 86 + l.Error("atproto.RepoPutRecord failed", "err", err) 87 + return nil, ErrPDSFail 88 + } 89 + l.Info("wrote to PDS") 90 + 91 + // knotclient, err := s.oauth.ServiceClient( 92 + // ) 93 + panic("unimplemented") 94 + }
+89
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/pages/repoinfo" 10 + "tangled.org/core/appview/session" 11 + ) 12 + 13 + // MakeRepoInfo constructs [repoinfo.RepoInfo] object from given [models.Repo]. 14 + // 15 + // NOTE: [repoinfo.RepoInfo] is bad design and should be removed in future. 16 + // Avoid using this method if you can. 17 + func (s *Service) MakeRepoInfo( 18 + ctx context.Context, 19 + ownerId *identity.Identity, 20 + baseRepo *models.Repo, 21 + currentDir, ref string, 22 + ) repoinfo.RepoInfo { 23 + var ( 24 + repoAt = baseRepo.RepoAt() 25 + isStarred = false 26 + roles = repoinfo.RolesInRepo{} 27 + l = s.logger.With("method", "MakeRepoInfo").With("repoAt", repoAt) 28 + ) 29 + sess, ok := session.FromContext(ctx) 30 + if ok { 31 + isStarred = db.GetStarStatus(s.db, sess.User.Did, repoAt) 32 + roles.Roles = s.enforcer.GetPermissionsInRepo(sess.User.Did, baseRepo.Knot, baseRepo.DidSlashRepo()) 33 + } 34 + 35 + stats := baseRepo.RepoStats 36 + if stats == nil { 37 + starCount, err := db.GetStarCount(s.db, repoAt) 38 + if err != nil { 39 + l.Error("failed to get star count", "err", err) 40 + } 41 + issueCount, err := db.GetIssueCount(s.db, repoAt) 42 + if err != nil { 43 + l.Error("failed to get issue count", "err", err) 44 + } 45 + pullCount, err := db.GetPullCount(s.db, repoAt) 46 + if err != nil { 47 + l.Error("failed to get pull count", "err", err) 48 + } 49 + stats = &models.RepoStats{ 50 + StarCount: starCount, 51 + IssueCount: issueCount, 52 + PullCount: pullCount, 53 + } 54 + } 55 + 56 + var sourceRepo *models.Repo 57 + var err error 58 + if baseRepo.Source != "" { 59 + sourceRepo, err = db.GetRepoByAtUri(s.db, baseRepo.Source) 60 + if err != nil { 61 + l.Error("failed to get source repo", "source", baseRepo.Source, "err", err) 62 + } 63 + } 64 + 65 + return repoinfo.RepoInfo{ 66 + // this is basically a models.Repo 67 + OwnerDid: baseRepo.Did, 68 + OwnerHandle: ownerId.Handle.String(), // TODO: shouldn't use 69 + Name: baseRepo.Name, 70 + Rkey: baseRepo.Rkey, 71 + Description: baseRepo.Description, 72 + Website: baseRepo.Website, 73 + Topics: baseRepo.Topics, 74 + Knot: baseRepo.Knot, 75 + Spindle: baseRepo.Spindle, 76 + Stats: *stats, 77 + 78 + // fork repo upstream 79 + Source: sourceRepo, 80 + 81 + // repo path (context) 82 + CurrentDir: currentDir, 83 + Ref: ref, 84 + 85 + // info related to the session 86 + IsStarred: isStarred, 87 + Roles: roles, 88 + } 89 + }
+27
appview/session/context.go
··· 1 + package session 2 + 3 + import ( 4 + "context" 5 + 6 + "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, bool) { 16 + sess, ok := ctx.Value(ctxKey{}).(*Session) 17 + return sess, ok 18 + } 19 + 20 + // UserFromContext returns optional MultiAccountUser from context. 21 + func UserFromContext(ctx context.Context) *oauth.MultiAccountUser { 22 + sess, ok := ctx.Value(ctxKey{}).(*Session) 23 + if !ok { 24 + return nil 25 + } 26 + return sess.User 27 + }
+11
appview/session/session.go
··· 1 + package session 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/client" 5 + "tangled.org/core/appview/oauth" 6 + ) 7 + 8 + type Session struct { 9 + User *oauth.MultiAccountUser // TODO: move MultiAccountUser def to here 10 + AtpClient *client.APIClient 11 + }
+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 + }
+34
appview/web/handler/oauth.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 + } 24 + 25 + func OauthJwks(o *oauth.OAuth) http.HandlerFunc { 26 + return func(w http.ResponseWriter, r *http.Request) { 27 + w.Header().Set("Content-Type", "application/json") 28 + body := o.ClientApp.Config.PublicJWKS() 29 + if err := json.NewEncoder(w).Encode(body); err != nil { 30 + http.Error(w, err.Error(), http.StatusInternalServerError) 31 + return 32 + } 33 + } 34 + }
+390
appview/web/handler/user_repo_issues.go
··· 1 + package handler 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages" 12 + "tangled.org/core/appview/pagination" 13 + "tangled.org/core/appview/reporesolver" 14 + "tangled.org/core/appview/searchquery" 15 + isvc "tangled.org/core/appview/service/issue" 16 + rsvc "tangled.org/core/appview/service/repo" 17 + "tangled.org/core/appview/session" 18 + "tangled.org/core/appview/web/request" 19 + "tangled.org/core/log" 20 + "tangled.org/core/orm" 21 + ) 22 + 23 + func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 24 + return func(w http.ResponseWriter, r *http.Request) { 25 + ctx := r.Context() 26 + l := log.FromContext(ctx).With("handler", "RepoIssues") 27 + repo, ok := request.RepoFromContext(ctx) 28 + if !ok { 29 + l.Error("malformed request") 30 + p.Error503(w) 31 + return 32 + } 33 + repoOwnerId, ok := request.OwnerFromContext(ctx) 34 + if !ok { 35 + l.Error("malformed request") 36 + p.Error503(w) 37 + return 38 + } 39 + 40 + params := r.URL.Query() 41 + page := pagination.FromContext(r.Context()) 42 + 43 + query := searchquery.Parse(params.Get("q")) 44 + 45 + // resolve := func(ctx context.Context, ident string) (string, error) { 46 + // id, err := s.idResolver.ResolveIdent(ctx, ident) 47 + // if err != nil { 48 + // return "", err 49 + // } 50 + // return id.DID.String(), nil 51 + // } 52 + 53 + // authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 54 + 55 + labels := query.GetAll("label") 56 + negatedLabels := query.GetAllNegated("label") 57 + labelValues := query.GetDynamicTags() 58 + negatedLabelValues := query.GetNegatedDynamicTags() 59 + 60 + tf := searchquery.ExtractTextFilters(query) 61 + 62 + isOpen := true 63 + 64 + searchOpts := models.IssueSearchOptions{ 65 + Keywords: tf.Keywords, 66 + Phrases: tf.Phrases, 67 + RepoAt: repo.RepoAt().String(), 68 + IsOpen: &isOpen, 69 + AuthorDid: "", 70 + Labels: labels, 71 + LabelValues: labelValues, 72 + NegatedKeywords: tf.NegatedKeywords, 73 + NegatedPhrases: tf.NegatedPhrases, 74 + NegatedLabels: negatedLabels, 75 + NegatedLabelValues: negatedLabelValues, 76 + NegatedAuthorDids: nil, 77 + Page: page, 78 + } 79 + 80 + issues, err := is.GetIssues(ctx, repo, searchOpts) 81 + if err != nil { 82 + l.Error("failed to get issues") 83 + p.Error503(w) 84 + return 85 + } 86 + 87 + // render page 88 + err = func() error { 89 + labelDefs, err := db.GetLabelDefinitions( 90 + d, 91 + orm.FilterIn("at_uri", repo.Labels), 92 + orm.FilterContains("scope", tangled.RepoIssueNSID), 93 + ) 94 + if err != nil { 95 + return err 96 + } 97 + defs := make(map[string]*models.LabelDefinition) 98 + for _, l := range labelDefs { 99 + defs[l.AtUri().String()] = &l 100 + } 101 + return p.RepoIssues(w, pages.RepoIssuesParams{ 102 + LoggedInUser: session.UserFromContext(ctx), 103 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, repo, "", ""), 104 + 105 + Issues: issues, 106 + LabelDefs: defs, 107 + FilterState: "open", 108 + FilterQuery: query.String(), 109 + Page: searchOpts.Page, 110 + }) 111 + }() 112 + if err != nil { 113 + l.Error("failed to render", "err", err) 114 + p.Error503(w) 115 + return 116 + } 117 + } 118 + } 119 + 120 + func Issue(s isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 121 + return func(w http.ResponseWriter, r *http.Request) { 122 + ctx := r.Context() 123 + l := log.FromContext(ctx).With("handler", "Issue") 124 + issue, ok := request.IssueFromContext(ctx) 125 + if !ok { 126 + l.Error("malformed request, failed to get issue") 127 + p.Error503(w) 128 + return 129 + } 130 + repoOwnerId, ok := request.OwnerFromContext(ctx) 131 + if !ok { 132 + l.Error("malformed request") 133 + p.Error503(w) 134 + return 135 + } 136 + 137 + // render 138 + err := func() error { 139 + reactionMap, err := db.GetReactionMap(d, 20, issue.AtUri()) 140 + if err != nil { 141 + l.Error("failed to get issue reactions", "err", err) 142 + return err 143 + } 144 + 145 + userReactions := map[models.ReactionKind]bool{} 146 + if sess, ok := session.FromContext(ctx); ok { 147 + userReactions = db.GetReactionStatusMap(d, sess.User.Did, issue.AtUri()) 148 + } 149 + 150 + backlinks, err := db.GetBacklinks(d, issue.AtUri()) 151 + if err != nil { 152 + l.Error("failed to fetch backlinks", "err", err) 153 + return err 154 + } 155 + 156 + labelDefs, err := db.GetLabelDefinitions( 157 + d, 158 + orm.FilterIn("at_uri", issue.Repo.Labels), 159 + orm.FilterContains("scope", tangled.RepoIssueNSID), 160 + ) 161 + if err != nil { 162 + l.Error("failed to fetch label defs", "err", err) 163 + return err 164 + } 165 + 166 + defs := make(map[string]*models.LabelDefinition) 167 + for _, l := range labelDefs { 168 + defs[l.AtUri().String()] = &l 169 + } 170 + 171 + return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 172 + LoggedInUser: session.UserFromContext(ctx), 173 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, issue.Repo, "", ""), 174 + Issue: issue, 175 + CommentList: issue.CommentList(), 176 + Backlinks: backlinks, 177 + Reactions: reactionMap, 178 + UserReacted: userReactions, 179 + LabelDefs: defs, 180 + }) 181 + }() 182 + if err != nil { 183 + l.Error("failed to render", "err", err) 184 + p.Error503(w) 185 + return 186 + } 187 + } 188 + } 189 + 190 + func NewIssue(rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 191 + return func(w http.ResponseWriter, r *http.Request) { 192 + ctx := r.Context() 193 + l := log.FromContext(ctx).With("handler", "NewIssue") 194 + 195 + // render 196 + err := func() error { 197 + repo, ok := request.RepoFromContext(ctx) 198 + if !ok { 199 + return fmt.Errorf("malformed request") 200 + } 201 + repoOwnerId, ok := request.OwnerFromContext(ctx) 202 + if !ok { 203 + return fmt.Errorf("malformed request") 204 + } 205 + return p.RepoNewIssue(w, pages.RepoNewIssueParams{ 206 + LoggedInUser: session.UserFromContext(ctx), 207 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, repo, "", ""), 208 + }) 209 + }() 210 + if err != nil { 211 + l.Error("failed to render", "err", err) 212 + p.Error503(w) 213 + return 214 + } 215 + } 216 + } 217 + 218 + func NewIssuePost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 219 + noticeId := "issues" 220 + return func(w http.ResponseWriter, r *http.Request) { 221 + ctx := r.Context() 222 + l := log.FromContext(ctx).With("handler", "NewIssuePost") 223 + repo, ok := request.RepoFromContext(ctx) 224 + if !ok { 225 + l.Error("malformed request, failed to get repo") 226 + // TODO: 503 error with more detailed messages 227 + p.Error503(w) 228 + return 229 + } 230 + var ( 231 + title = r.FormValue("title") 232 + body = r.FormValue("body") 233 + ) 234 + 235 + issue, err := is.NewIssue(ctx, repo, title, body) 236 + if err != nil { 237 + if errors.Is(err, isvc.ErrDatabaseFail) { 238 + p.Notice(w, noticeId, "Failed to create issue.") 239 + } else if errors.Is(err, isvc.ErrPDSFail) { 240 + p.Notice(w, noticeId, "Failed to create issue.") 241 + } else { 242 + p.Notice(w, noticeId, "Failed to create issue.") 243 + } 244 + return 245 + } 246 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 247 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 248 + } 249 + } 250 + 251 + func IssueEdit(is isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 252 + return func(w http.ResponseWriter, r *http.Request) { 253 + ctx := r.Context() 254 + l := log.FromContext(ctx).With("handler", "IssueEdit") 255 + issue, ok := request.IssueFromContext(ctx) 256 + if !ok { 257 + l.Error("malformed request, failed to get issue") 258 + p.Error503(w) 259 + return 260 + } 261 + repoOwnerId, ok := request.OwnerFromContext(ctx) 262 + if !ok { 263 + l.Error("malformed request") 264 + p.Error503(w) 265 + return 266 + } 267 + 268 + // render 269 + err := func() error { 270 + return p.EditIssueFragment(w, pages.EditIssueParams{ 271 + LoggedInUser: session.UserFromContext(ctx), 272 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, issue.Repo, "", ""), 273 + 274 + Issue: issue, 275 + }) 276 + }() 277 + if err != nil { 278 + l.Error("failed to render", "err", err) 279 + p.Error503(w) 280 + return 281 + } 282 + } 283 + } 284 + 285 + func IssueEditPost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 286 + noticeId := "issues" 287 + return func(w http.ResponseWriter, r *http.Request) { 288 + ctx := r.Context() 289 + l := log.FromContext(ctx).With("handler", "IssueEdit") 290 + issue, ok := request.IssueFromContext(ctx) 291 + if !ok { 292 + l.Error("malformed request, failed to get issue") 293 + p.Error503(w) 294 + return 295 + } 296 + 297 + newIssue := *issue 298 + newIssue.Title = r.FormValue("title") 299 + newIssue.Body = r.FormValue("body") 300 + 301 + err := is.EditIssue(ctx, &newIssue) 302 + if err != nil { 303 + if errors.Is(err, isvc.ErrDatabaseFail) { 304 + p.Notice(w, noticeId, "Failed to edit issue.") 305 + } else if errors.Is(err, isvc.ErrPDSFail) { 306 + p.Notice(w, noticeId, "Failed to edit issue.") 307 + } else { 308 + p.Notice(w, noticeId, "Failed to edit issue.") 309 + } 310 + return 311 + } 312 + 313 + p.HxRefresh(w) 314 + } 315 + } 316 + 317 + func CloseIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 318 + noticeId := "issue-action" 319 + return func(w http.ResponseWriter, r *http.Request) { 320 + ctx := r.Context() 321 + l := log.FromContext(ctx).With("handler", "CloseIssue") 322 + issue, ok := request.IssueFromContext(ctx) 323 + if !ok { 324 + l.Error("malformed request, failed to get issue") 325 + p.Error503(w) 326 + return 327 + } 328 + 329 + err := is.CloseIssue(ctx, issue) 330 + if err != nil { 331 + if errors.Is(err, isvc.ErrForbidden) { 332 + http.Error(w, "forbidden", http.StatusUnauthorized) 333 + } else { 334 + p.Notice(w, noticeId, "Failed to close issue. Try again later.") 335 + } 336 + return 337 + } 338 + 339 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 340 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 341 + } 342 + } 343 + 344 + func ReopenIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 345 + noticeId := "issue-action" 346 + return func(w http.ResponseWriter, r *http.Request) { 347 + ctx := r.Context() 348 + l := log.FromContext(ctx).With("handler", "ReopenIssue") 349 + issue, ok := request.IssueFromContext(ctx) 350 + if !ok { 351 + l.Error("malformed request, failed to get issue") 352 + p.Error503(w) 353 + return 354 + } 355 + 356 + err := is.ReopenIssue(ctx, issue) 357 + if err != nil { 358 + if errors.Is(err, isvc.ErrForbidden) { 359 + http.Error(w, "forbidden", http.StatusUnauthorized) 360 + } else { 361 + p.Notice(w, noticeId, "Failed to reopen issue. Try again later.") 362 + } 363 + return 364 + } 365 + 366 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 367 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 368 + } 369 + } 370 + 371 + func IssueDelete(s isvc.Service, p *pages.Pages) http.HandlerFunc { 372 + noticeId := "issue-actions-error" 373 + return func(w http.ResponseWriter, r *http.Request) { 374 + ctx := r.Context() 375 + l := log.FromContext(ctx).With("handler", "IssueDelete") 376 + issue, ok := request.IssueFromContext(ctx) 377 + if !ok { 378 + l.Error("failed to get issue") 379 + // TODO: 503 error with more detailed messages 380 + p.Error503(w) 381 + return 382 + } 383 + err := s.DeleteIssue(ctx, issue) 384 + if err != nil { 385 + p.Notice(w, noticeId, "failed to delete issue") 386 + return 387 + } 388 + p.HxLocation(w, "/") 389 + } 390 + }
+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 + registry := o.GetAccounts(r) 25 + sess := session.Session{ 26 + User: &oauth.MultiAccountUser{ 27 + Did: atSess.Data.AccountDID.String(), 28 + Accounts: registry.Accounts, 29 + }, 30 + AtpClient: atSess.APIClient(), 31 + } 32 + ctx := session.IntoContext(r.Context(), sess) 33 + next.ServeHTTP(w, r.WithContext(ctx)) 34 + }) 35 + } 36 + } 37 + 38 + // AuthMiddleware ensures the request is authorized and redirect to login page 39 + // when unauthorized 40 + func AuthMiddleware() middlewareFunc { 41 + return func(next http.Handler) http.Handler { 42 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 + ctx := r.Context() 44 + l := log.FromContext(ctx) 45 + 46 + returnURL := "/" 47 + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 48 + returnURL = u.RequestURI() 49 + } 50 + 51 + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 52 + 53 + if _, ok := session.FromContext(ctx); !ok { 54 + l.Debug("no session, redirecting...") 55 + if r.Header.Get("HX-Request") == "true" { 56 + w.Header().Set("HX-Redirect", loginURL) 57 + w.WriteHeader(http.StatusOK) 58 + } else { 59 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 60 + } 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
+49
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 + }) 48 + } 49 + }
+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 + }
+53
appview/web/readme.md
··· 1 + # appview/web 2 + 3 + ## package structure 4 + 5 + ``` 6 + web/ 7 + |- routes.go 8 + |- handler/ 9 + | |- xrpc/ 10 + |- middleware/ 11 + |- request/ 12 + ``` 13 + 14 + - `web/routes.go` : all possible routes defined in single file 15 + - `web/handler` : general http handlers 16 + - `web/handler/xrpc` : xrpc handlers 17 + - `web/middleware` : all middlwares 18 + - `web/request` : define methods to insert/fetch values from request context. shared between middlewares and handlers. 19 + 20 + ### file name convention on `web/handler` 21 + 22 + - Follow the absolute uri path of the handlers (replace `/` to `_`.) 23 + - Trailing path segments can be omitted. 24 + - Avoid conflicts between prefix and names. 25 + - e.g. using both `user_repo_pulls.go` and `user_repo_pulls_rounds.go` (with `user_repo_pulls_` prefix) 26 + 27 + ### handler-generators instead of raw handler function 28 + 29 + instead of: 30 + ```go 31 + type Handler struct { 32 + is isvc.Service 33 + rs rsvc.Service 34 + } 35 + func (h *Handler) RepoIssues(w http.ResponseWriter, r *http.Request) { 36 + // ... 37 + } 38 + ``` 39 + 40 + prefer: 41 + ```go 42 + func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 43 + return func(w http.ResponseWriter, r *http.Request) { 44 + // ... 45 + } 46 + } 47 + ``` 48 + 49 + Pass dependencies to each handler-generators and avoid creating structs with shared dependencies unless it serves somedomain-specific roles like `service/issue.Service`. Same rule applies to middlewares too. 50 + 51 + This pattern is inspired by [the grafana blog post](https://grafana.com/blog/how-i-write-http-services-in-go-after-13-years/#maker-funcs-return-the-handler). 52 + 53 + Function name can be anything as long as it is clear.
+41
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 ( 11 + ctxKeyOwner struct{} 12 + ctxKeyRepo struct{} 13 + ctxKeyIssue struct{} 14 + ) 15 + 16 + func WithOwner(ctx context.Context, owner *identity.Identity) context.Context { 17 + return context.WithValue(ctx, ctxKeyOwner{}, owner) 18 + } 19 + 20 + func OwnerFromContext(ctx context.Context) (*identity.Identity, bool) { 21 + owner, ok := ctx.Value(ctxKeyOwner{}).(*identity.Identity) 22 + return owner, ok 23 + } 24 + 25 + func WithRepo(ctx context.Context, repo *models.Repo) context.Context { 26 + return context.WithValue(ctx, ctxKeyRepo{}, repo) 27 + } 28 + 29 + func RepoFromContext(ctx context.Context) (*models.Repo, bool) { 30 + repo, ok := ctx.Value(ctxKeyRepo{}).(*models.Repo) 31 + return repo, ok 32 + } 33 + 34 + func WithIssue(ctx context.Context, issue *models.Issue) context.Context { 35 + return context.WithValue(ctx, ctxKeyIssue{}, issue) 36 + } 37 + 38 + func IssueFromContext(ctx context.Context) (*models.Issue, bool) { 39 + issue, ok := ctx.Value(ctxKeyIssue{}).(*models.Issue) 40 + return issue, ok 41 + }
+205
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 + // RouterFromState creates a web router from `state.State`. This exist to 26 + // bridge between legacy web routers under `State` and new architecture 27 + func RouterFromState(s *state.State) http.Handler { 28 + config, db, enforcer, idResolver, refResolver, indexer, logger, notifier, oauth, pages, validator := s.Expose() 29 + 30 + return Router( 31 + logger, 32 + config, 33 + db, 34 + enforcer, 35 + idResolver, 36 + refResolver, 37 + indexer, 38 + notifier, 39 + oauth, 40 + pages, 41 + validator, 42 + s, 43 + ) 44 + } 45 + 46 + func Router( 47 + // NOTE: put base dependencies (db, idResolver, oauth etc) 48 + logger *slog.Logger, 49 + config *config.Config, 50 + db *db.DB, 51 + enforcer *rbac.Enforcer, 52 + idResolver *idresolver.Resolver, 53 + mentionsResolver *mentions.Resolver, 54 + indexer *indexer.Indexer, 55 + notifier notify.Notifier, 56 + oauth *oauth.OAuth, 57 + pages *pages.Pages, 58 + validator *validator.Validator, 59 + // to use legacy web handlers. will be removed later 60 + s *state.State, 61 + ) http.Handler { 62 + repo := rsvc.NewService( 63 + logger, 64 + config, 65 + db, 66 + enforcer, 67 + ) 68 + issue := isvc.NewService( 69 + logger, 70 + config, 71 + db, 72 + enforcer, 73 + notifier, 74 + idResolver, 75 + mentionsResolver, 76 + indexer.Issues, 77 + validator, 78 + ) 79 + 80 + i := s.ExposeIssue() 81 + 82 + r := chi.NewRouter() 83 + 84 + mw := s.Middleware() 85 + auth := middleware.AuthMiddleware() 86 + 87 + r.Use(middleware.WithLogger(logger)) 88 + r.Use(middleware.WithSession(oauth)) 89 + 90 + r.Use(middleware.Normalize()) 91 + 92 + r.Get("/pwa-manifest.json", s.WebAppManifest) 93 + r.Get("/robots.txt", s.RobotsTxt) 94 + 95 + r.Handle("/static/*", pages.Static()) 96 + 97 + r.Get("/", s.HomeOrTimeline) 98 + r.Get("/timeline", s.Timeline) 99 + r.Get("/upgradeBanner", s.UpgradeBanner) 100 + 101 + r.Get("/terms", s.TermsOfService) 102 + r.Get("/privacy", s.PrivacyPolicy) 103 + r.Get("/brand", s.Brand) 104 + // special-case handler for serving tangled.org/core 105 + r.Get("/core", s.Core()) 106 + 107 + r.Get("/login", s.Login) 108 + r.Post("/login", s.Login) 109 + r.Post("/logout", s.Logout) 110 + 111 + r.Get("/goodfirstissues", s.GoodFirstIssues) 112 + 113 + r.With(auth).Get("/repo/new", s.NewRepo) 114 + r.With(auth).Post("/repo/new", s.NewRepo) 115 + 116 + r.With(auth).Post("/follow", s.Follow) 117 + r.With(auth).Delete("/follow", s.Follow) 118 + 119 + r.With(auth).Post("/star", s.Star) 120 + r.With(auth).Delete("/star", s.Star) 121 + 122 + r.With(auth).Post("/react", s.React) 123 + r.With(auth).Delete("/react", s.React) 124 + 125 + r.With(auth).Get("/profile/edit-bio", s.EditBioFragment) 126 + r.With(auth).Get("/profile/edit-pins", s.EditPinsFragment) 127 + r.With(auth).Post("/profile/bio", s.UpdateProfileBio) 128 + r.With(auth).Post("/profile/pins", s.UpdateProfilePins) 129 + 130 + r.Mount("/settings", s.SettingsRouter()) 131 + r.Mount("/strings", s.StringsRouter(mw)) 132 + r.Mount("/settings/knots", s.KnotsRouter()) 133 + r.Mount("/settings/spindles", s.SpindlesRouter()) 134 + r.Mount("/notifications", s.NotificationsRouter(mw)) 135 + 136 + r.Mount("/signup", s.SignupRouter()) 137 + r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(oauth)) 138 + r.Get("/oauth/jwks.json", handler.OauthJwks(oauth)) 139 + r.Get("/oauth/callback", oauth.Callback) 140 + 141 + // special-case handler. should replace with xrpc later 142 + r.Get("/keys/{user}", s.Keys) 143 + 144 + r.HandleFunc("/@*", func(w http.ResponseWriter, r *http.Request) { 145 + http.Redirect(w, r, "/"+chi.URLParam(r, "*"), http.StatusFound) 146 + }) 147 + 148 + r.Route("/{user}", func(r chi.Router) { 149 + r.Use(middleware.EnsureDidOrHandle(pages)) 150 + r.Use(middleware.ResolveIdent(idResolver, pages)) 151 + 152 + r.Get("/", s.Profile) 153 + r.Get("/feed.atom", s.AtomFeedPage) 154 + 155 + r.Route("/{repo}", func(r chi.Router) { 156 + r.Use(middleware.ResolveRepo(db, pages)) 157 + 158 + r.Mount("/", s.RepoRouter(mw)) 159 + 160 + // /{user}/{repo}/issues/* 161 + r.With(middleware.Paginate).Get("/issues", handler.RepoIssues(issue, repo, pages, db)) 162 + r.With(auth).Get("/issues/new", handler.NewIssue(repo, pages)) 163 + r.With(auth).Post("/issues/new", handler.NewIssuePost(issue, pages)) 164 + r.Route("/issues/{issue}", func(r chi.Router) { 165 + r.Use(middleware.ResolveIssue(db, pages)) 166 + 167 + r.Get("/", handler.Issue(issue, repo, pages, db)) 168 + r.Get("/opengraph", i.IssueOpenGraphSummary) 169 + 170 + r.With(auth).Delete("/", handler.IssueDelete(issue, pages)) 171 + 172 + r.With(auth).Get("/edit", handler.IssueEdit(issue, repo, pages)) 173 + r.With(auth).Post("/edit", handler.IssueEditPost(issue, pages)) 174 + 175 + r.With(auth).Post("/close", handler.CloseIssue(issue, pages)) 176 + r.With(auth).Post("/reopen", handler.ReopenIssue(issue, pages)) 177 + 178 + r.With(auth).Post("/comment", i.NewIssueComment) 179 + r.With(auth).Route("/comment/{commentId}/", func(r chi.Router) { 180 + r.Get("/", i.IssueComment) 181 + r.Delete("/", i.DeleteIssueComment) 182 + r.Get("/edit", i.EditIssueComment) 183 + r.Post("/edit", i.EditIssueComment) 184 + r.Get("/reply", i.ReplyIssueComment) 185 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 186 + }) 187 + }) 188 + 189 + r.Mount("/pulls", s.PullsRouter(mw)) 190 + r.Mount("/pipelines", s.PipelinesRouter(mw)) 191 + r.Mount("/labels", s.LabelsRouter()) 192 + 193 + // These routes get proxied to the knot 194 + r.Get("/info/refs", s.InfoRefs) 195 + r.Post("/git-upload-pack", s.UploadPack) 196 + r.Post("/git-receive-pack", s.ReceivePack) 197 + }) 198 + }) 199 + 200 + r.NotFound(func(w http.ResponseWriter, r *http.Request) { 201 + pages.Error404(w) 202 + }) 203 + 204 + return r 205 + }
+2 -1
cmd/appview/main.go
··· 7 7 8 8 "tangled.org/core/appview/config" 9 9 "tangled.org/core/appview/state" 10 + "tangled.org/core/appview/web" 10 11 tlog "tangled.org/core/log" 11 12 ) 12 13 ··· 35 36 36 37 logger.Info("starting server", "address", c.Core.ListenAddr) 37 38 38 - if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + if err := http.ListenAndServe(c.Core.ListenAddr, web.RouterFromState(state)); err != nil { 39 40 logger.Error("failed to start appview", "err", err) 40 41 } 41 42 }