Monorepo for Tangled tangled.org

draft: appview: service layer #800

open opened by boltless.me targeting master from sl/uvpzuszrulvq

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.

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

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:xasnlahkri4ewmbuzly2rlc5/sh.tangled.repo.pull/3m5jyyj76xa22
+1690 -3
Diff #13
+2 -2
appview/oauth/handler.go
··· 28 29 r.Get("/oauth/client-metadata.json", o.clientMetadata) 30 r.Get("/oauth/jwks.json", o.jwks) 31 - r.Get("/oauth/callback", o.callback) 32 return r 33 } 34 ··· 54 } 55 } 56 57 - func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 58 ctx := r.Context() 59 l := o.Logger.With("query", r.URL.Query()) 60
··· 28 29 r.Get("/oauth/client-metadata.json", o.clientMetadata) 30 r.Get("/oauth/jwks.json", o.jwks) 31 + r.Get("/oauth/callback", o.Callback) 32 return r 33 } 34 ··· 54 } 55 } 56 57 + func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { 58 ctx := r.Context() 59 l := o.Logger.With("query", r.URL.Query()) 60
+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, 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.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, ok := session.FromContext(ctx) 167 + if !ok { 168 + l.Error("user session is missing in context") 169 + return ErrForbidden 170 + } 171 + atpclient := sess.AtpClient 172 + l = l.With("did", sess.User.Did) 173 + 174 + mentions, references := s.refResolver.Resolve(ctx, issue.Body) 175 + issue.Mentions = mentions 176 + issue.References = references 177 + 178 + if sess.User.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 + record := issue.AsRecord() 201 + 202 + ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 203 + if err != nil { 204 + l.Error("atproto.RepoGetRecord failed", "err", err) 205 + return ErrPDSFail 206 + } 207 + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 208 + Repo: issue.Did, 209 + Collection: tangled.RepoIssueNSID, 210 + Rkey: issue.Rkey, 211 + SwapRecord: ex.Cid, 212 + Record: &lexutil.LexiconTypeDecoder{ 213 + Val: &record, 214 + }, 215 + }) 216 + if err != nil { 217 + l.Error("atproto.RepoPutRecord failed", "err", err) 218 + return ErrPDSFail 219 + } 220 + 221 + if err = tx.Commit(); err != nil { 222 + l.Error("tx.Commit failed", "err", err) 223 + return ErrDatabaseFail 224 + } 225 + 226 + // TODO: notify EditIssue 227 + 228 + return nil 229 + } 230 + 231 + func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error { 232 + l := s.logger.With("method", "DeleteIssue") 233 + sess, ok := session.FromContext(ctx) 234 + if !ok { 235 + l.Error("user session is missing in context") 236 + return ErrForbidden 237 + } 238 + atpclient := sess.AtpClient 239 + l = l.With("did", sess.User.Did) 240 + 241 + if sess.User.Did != issue.Did { 242 + l.Error("only author can edit the issue") 243 + return ErrForbidden 244 + } 245 + 246 + tx, err := s.db.BeginTx(ctx, nil) 247 + if err != nil { 248 + l.Error("db.BeginTx failed", "err", err) 249 + return ErrDatabaseFail 250 + } 251 + defer tx.Rollback() 252 + 253 + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 254 + l.Error("db.DeleteIssues failed", "err", err) 255 + return ErrDatabaseFail 256 + } 257 + 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, 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 + }
+357
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 + isvc "tangled.org/core/appview/service/issue" 15 + rsvc "tangled.org/core/appview/service/repo" 16 + "tangled.org/core/appview/session" 17 + "tangled.org/core/appview/web/request" 18 + "tangled.org/core/log" 19 + "tangled.org/core/orm" 20 + ) 21 + 22 + func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 23 + return func(w http.ResponseWriter, r *http.Request) { 24 + ctx := r.Context() 25 + l := log.FromContext(ctx).With("handler", "RepoIssues") 26 + repo, ok := request.RepoFromContext(ctx) 27 + if !ok { 28 + l.Error("malformed request") 29 + p.Error503(w) 30 + return 31 + } 32 + repoOwnerId, ok := request.OwnerFromContext(ctx) 33 + if !ok { 34 + l.Error("malformed request") 35 + p.Error503(w) 36 + return 37 + } 38 + 39 + query := r.URL.Query() 40 + searchOpts := models.IssueSearchOptions{ 41 + RepoAt: repo.RepoAt().String(), 42 + Keyword: query.Get("q"), 43 + IsOpen: query.Get("state") != "closed", 44 + Page: pagination.FromContext(ctx), 45 + } 46 + 47 + issues, err := is.GetIssues(ctx, repo, searchOpts) 48 + if err != nil { 49 + l.Error("failed to get issues") 50 + p.Error503(w) 51 + return 52 + } 53 + 54 + // render page 55 + err = func() error { 56 + labelDefs, err := db.GetLabelDefinitions( 57 + d, 58 + orm.FilterIn("at_uri", repo.Labels), 59 + orm.FilterContains("scope", tangled.RepoIssueNSID), 60 + ) 61 + if err != nil { 62 + return err 63 + } 64 + defs := make(map[string]*models.LabelDefinition) 65 + for _, l := range labelDefs { 66 + defs[l.AtUri().String()] = &l 67 + } 68 + return p.RepoIssues(w, pages.RepoIssuesParams{ 69 + LoggedInUser: session.UserFromContext(ctx), 70 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, repo, "", ""), 71 + 72 + Issues: issues, 73 + LabelDefs: defs, 74 + FilteringByOpen: searchOpts.IsOpen, 75 + FilterQuery: searchOpts.Keyword, 76 + Page: searchOpts.Page, 77 + }) 78 + }() 79 + if err != nil { 80 + l.Error("failed to render", "err", err) 81 + p.Error503(w) 82 + return 83 + } 84 + } 85 + } 86 + 87 + func Issue(s isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 88 + return func(w http.ResponseWriter, r *http.Request) { 89 + ctx := r.Context() 90 + l := log.FromContext(ctx).With("handler", "Issue") 91 + issue, ok := request.IssueFromContext(ctx) 92 + if !ok { 93 + l.Error("malformed request, failed to get issue") 94 + p.Error503(w) 95 + return 96 + } 97 + repoOwnerId, ok := request.OwnerFromContext(ctx) 98 + if !ok { 99 + l.Error("malformed request") 100 + p.Error503(w) 101 + return 102 + } 103 + 104 + // render 105 + err := func() error { 106 + reactionMap, err := db.GetReactionMap(d, 20, issue.AtUri()) 107 + if err != nil { 108 + l.Error("failed to get issue reactions", "err", err) 109 + return err 110 + } 111 + 112 + userReactions := map[models.ReactionKind]bool{} 113 + if sess, ok := session.FromContext(ctx); ok { 114 + userReactions = db.GetReactionStatusMap(d, sess.User.Did, issue.AtUri()) 115 + } 116 + 117 + backlinks, err := db.GetBacklinks(d, issue.AtUri()) 118 + if err != nil { 119 + l.Error("failed to fetch backlinks", "err", err) 120 + return err 121 + } 122 + 123 + labelDefs, err := db.GetLabelDefinitions( 124 + d, 125 + orm.FilterIn("at_uri", issue.Repo.Labels), 126 + orm.FilterContains("scope", tangled.RepoIssueNSID), 127 + ) 128 + if err != nil { 129 + l.Error("failed to fetch label defs", "err", err) 130 + return err 131 + } 132 + 133 + defs := make(map[string]*models.LabelDefinition) 134 + for _, l := range labelDefs { 135 + defs[l.AtUri().String()] = &l 136 + } 137 + 138 + return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 139 + LoggedInUser: session.UserFromContext(ctx), 140 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, issue.Repo, "", ""), 141 + Issue: issue, 142 + CommentList: issue.CommentList(), 143 + Backlinks: backlinks, 144 + Reactions: reactionMap, 145 + UserReacted: userReactions, 146 + LabelDefs: defs, 147 + }) 148 + }() 149 + if err != nil { 150 + l.Error("failed to render", "err", err) 151 + p.Error503(w) 152 + return 153 + } 154 + } 155 + } 156 + 157 + func NewIssue(rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 158 + return func(w http.ResponseWriter, r *http.Request) { 159 + ctx := r.Context() 160 + l := log.FromContext(ctx).With("handler", "NewIssue") 161 + 162 + // render 163 + err := func() error { 164 + repo, ok := request.RepoFromContext(ctx) 165 + if !ok { 166 + return fmt.Errorf("malformed request") 167 + } 168 + repoOwnerId, ok := request.OwnerFromContext(ctx) 169 + if !ok { 170 + return fmt.Errorf("malformed request") 171 + } 172 + return p.RepoNewIssue(w, pages.RepoNewIssueParams{ 173 + LoggedInUser: session.UserFromContext(ctx), 174 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, repo, "", ""), 175 + }) 176 + }() 177 + if err != nil { 178 + l.Error("failed to render", "err", err) 179 + p.Error503(w) 180 + return 181 + } 182 + } 183 + } 184 + 185 + func NewIssuePost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 186 + noticeId := "issues" 187 + return func(w http.ResponseWriter, r *http.Request) { 188 + ctx := r.Context() 189 + l := log.FromContext(ctx).With("handler", "NewIssuePost") 190 + repo, ok := request.RepoFromContext(ctx) 191 + if !ok { 192 + l.Error("malformed request, failed to get repo") 193 + // TODO: 503 error with more detailed messages 194 + p.Error503(w) 195 + return 196 + } 197 + var ( 198 + title = r.FormValue("title") 199 + body = r.FormValue("body") 200 + ) 201 + 202 + issue, err := is.NewIssue(ctx, repo, title, body) 203 + if err != nil { 204 + if errors.Is(err, isvc.ErrDatabaseFail) { 205 + p.Notice(w, noticeId, "Failed to create issue.") 206 + } else if errors.Is(err, isvc.ErrPDSFail) { 207 + p.Notice(w, noticeId, "Failed to create issue.") 208 + } else { 209 + p.Notice(w, noticeId, "Failed to create issue.") 210 + } 211 + return 212 + } 213 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 214 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 215 + } 216 + } 217 + 218 + func IssueEdit(is isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 219 + return func(w http.ResponseWriter, r *http.Request) { 220 + ctx := r.Context() 221 + l := log.FromContext(ctx).With("handler", "IssueEdit") 222 + issue, ok := request.IssueFromContext(ctx) 223 + if !ok { 224 + l.Error("malformed request, failed to get issue") 225 + p.Error503(w) 226 + return 227 + } 228 + repoOwnerId, ok := request.OwnerFromContext(ctx) 229 + if !ok { 230 + l.Error("malformed request") 231 + p.Error503(w) 232 + return 233 + } 234 + 235 + // render 236 + err := func() error { 237 + return p.EditIssueFragment(w, pages.EditIssueParams{ 238 + LoggedInUser: session.UserFromContext(ctx), 239 + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, issue.Repo, "", ""), 240 + 241 + Issue: issue, 242 + }) 243 + }() 244 + if err != nil { 245 + l.Error("failed to render", "err", err) 246 + p.Error503(w) 247 + return 248 + } 249 + } 250 + } 251 + 252 + func IssueEditPost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 253 + noticeId := "issues" 254 + return func(w http.ResponseWriter, r *http.Request) { 255 + ctx := r.Context() 256 + l := log.FromContext(ctx).With("handler", "IssueEdit") 257 + issue, ok := request.IssueFromContext(ctx) 258 + if !ok { 259 + l.Error("malformed request, failed to get issue") 260 + p.Error503(w) 261 + return 262 + } 263 + 264 + newIssue := *issue 265 + newIssue.Title = r.FormValue("title") 266 + newIssue.Body = r.FormValue("body") 267 + 268 + err := is.EditIssue(ctx, &newIssue) 269 + if err != nil { 270 + if errors.Is(err, isvc.ErrDatabaseFail) { 271 + p.Notice(w, noticeId, "Failed to edit issue.") 272 + } else if errors.Is(err, isvc.ErrPDSFail) { 273 + p.Notice(w, noticeId, "Failed to edit issue.") 274 + } else { 275 + p.Notice(w, noticeId, "Failed to edit issue.") 276 + } 277 + return 278 + } 279 + 280 + p.HxRefresh(w) 281 + } 282 + } 283 + 284 + func CloseIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 285 + noticeId := "issue-action" 286 + return func(w http.ResponseWriter, r *http.Request) { 287 + ctx := r.Context() 288 + l := log.FromContext(ctx).With("handler", "CloseIssue") 289 + issue, ok := request.IssueFromContext(ctx) 290 + if !ok { 291 + l.Error("malformed request, failed to get issue") 292 + p.Error503(w) 293 + return 294 + } 295 + 296 + err := is.CloseIssue(ctx, issue) 297 + if err != nil { 298 + if errors.Is(err, isvc.ErrForbidden) { 299 + http.Error(w, "forbidden", http.StatusUnauthorized) 300 + } else { 301 + p.Notice(w, noticeId, "Failed to close issue. Try again later.") 302 + } 303 + return 304 + } 305 + 306 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 307 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 308 + } 309 + } 310 + 311 + func ReopenIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 312 + noticeId := "issue-action" 313 + return func(w http.ResponseWriter, r *http.Request) { 314 + ctx := r.Context() 315 + l := log.FromContext(ctx).With("handler", "ReopenIssue") 316 + issue, ok := request.IssueFromContext(ctx) 317 + if !ok { 318 + l.Error("malformed request, failed to get issue") 319 + p.Error503(w) 320 + return 321 + } 322 + 323 + err := is.ReopenIssue(ctx, issue) 324 + if err != nil { 325 + if errors.Is(err, isvc.ErrForbidden) { 326 + http.Error(w, "forbidden", http.StatusUnauthorized) 327 + } else { 328 + p.Notice(w, noticeId, "Failed to reopen issue. Try again later.") 329 + } 330 + return 331 + } 332 + 333 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 334 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 335 + } 336 + } 337 + 338 + func IssueDelete(s isvc.Service, p *pages.Pages) http.HandlerFunc { 339 + noticeId := "issue-actions-error" 340 + return func(w http.ResponseWriter, r *http.Request) { 341 + ctx := r.Context() 342 + l := log.FromContext(ctx).With("handler", "IssueDelete") 343 + issue, ok := request.IssueFromContext(ctx) 344 + if !ok { 345 + l.Error("failed to get issue") 346 + // TODO: 503 error with more detailed messages 347 + p.Error503(w) 348 + return 349 + } 350 + err := s.DeleteIssue(ctx, issue) 351 + if err != nil { 352 + p.Notice(w, noticeId, "failed to delete issue") 353 + return 354 + } 355 + p.HxLocation(w, "/") 356 + } 357 + }
+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 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 }
··· 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 }

History

14 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
appview/{service,web}: service layer
1/3 failed, 2/3 success
expand
merge conflicts detected
expand
  • appview/pages/templates/user/login.html:33
  • appview/state/profile.go:817
  • appview/pages/templates/user/login.html:31
  • appview/pages/templates/user/login.html:93
  • appview/repo/artifact.go:251
  • appview/state/profile.go:528
expand 0 comments
1 commit
expand
appview/{service,web}: service layer
1/3 failed, 2/3 success
expand
expand 0 comments
1 commit
expand
appview/{service,web}: service layer
3/3 failed
expand
expand 0 comments
1 commit
expand
appview/{service,web}: service layer
3/3 success
expand
expand 0 comments
1 commit
expand
appview/{service,web}: service layer
2/3 failed, 1/3 success
expand
expand 0 comments
1 commit
expand
appview/{service,web}: service layer
3/3 success
expand
expand 0 comments
1 commit
expand
appview/{service,web}: service layer
1/3 failed, 2/3 timeout
expand
expand 0 comments
1 commit
expand
draft: appview/service: service layer
3/3 success
expand
expand 0 comments
1 commit
expand
draft: appview/service: service layer
1/3 failed, 1/3 timeout, 1/3 success
expand
expand 0 comments
1 commit
expand
draft: appview/service: service layer
3/3 success
expand
expand 0 comments
1 commit
expand
draft: appview/service: service layer
3/3 success
expand
expand 0 comments
1 commit
expand
draft: appview/service: service layer
3/3 success
expand
expand 0 comments
1 commit
expand
draft: appview: service layer
3/3 failed
expand
expand 0 comments
1 commit
expand
draft: appview: service layer
3/3 failed
expand
expand 0 comments