forked from tangled.org/core
Monorepo for Tangled

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 9ab928cd a53d124e

verified
+2 -2
appview/oauth/handler.go
··· 25 25 26 26 r.Get("/oauth/client-metadata.json", o.clientMetadata) 27 27 r.Get("/oauth/jwks.json", o.jwks) 28 - r.Get("/oauth/callback", o.callback) 28 + r.Get("/oauth/callback", o.Callback) 29 29 return r 30 30 } 31 31 ··· 51 51 } 52 52 } 53 53 54 - func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 54 + func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { 55 55 ctx := r.Context() 56 56 l := o.Logger.With("query", r.URL.Query()) 57 57
+10
appview/oauth/session.go
··· 1 + package oauth 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 7 + ) 8 + 9 + func (o *OAuth) SaveSession2(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) { 10 + }
+12
appview/service/issue/errors.go
··· 1 + package issue 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrUnAuthenticated = errors.New("user session missing") 7 + ErrForbidden = errors.New("unauthorized operation") 8 + ErrDatabaseFail = errors.New("db op fail") 9 + ErrPDSFail = errors.New("pds op fail") 10 + ErrIndexerFail = errors.New("indexer fail") 11 + ErrValidationFail = errors.New("issue validation fail") 12 + )
+275
appview/service/issue/issue.go
··· 1 + package issue 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/config" 13 + "tangled.org/core/appview/db" 14 + issues_indexer "tangled.org/core/appview/indexer/issues" 15 + "tangled.org/core/appview/mentions" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/notify" 18 + "tangled.org/core/appview/session" 19 + "tangled.org/core/appview/validator" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/orm" 22 + "tangled.org/core/rbac" 23 + "tangled.org/core/tid" 24 + ) 25 + 26 + type Service struct { 27 + config *config.Config 28 + db *db.DB 29 + enforcer *rbac.Enforcer 30 + indexer *issues_indexer.Indexer 31 + logger *slog.Logger 32 + notifier notify.Notifier 33 + idResolver *idresolver.Resolver 34 + refResolver *mentions.Resolver 35 + validator *validator.Validator 36 + } 37 + 38 + func NewService( 39 + logger *slog.Logger, 40 + config *config.Config, 41 + db *db.DB, 42 + enforcer *rbac.Enforcer, 43 + notifier notify.Notifier, 44 + idResolver *idresolver.Resolver, 45 + refResolver *mentions.Resolver, 46 + indexer *issues_indexer.Indexer, 47 + validator *validator.Validator, 48 + ) Service { 49 + return Service{ 50 + config, 51 + db, 52 + enforcer, 53 + indexer, 54 + logger, 55 + notifier, 56 + idResolver, 57 + refResolver, 58 + validator, 59 + } 60 + } 61 + 62 + func (s *Service) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) { 63 + l := s.logger.With("method", "NewIssue") 64 + sess := session.FromContext(ctx) 65 + if sess == nil { 66 + l.Error("user session is missing in context") 67 + return nil, ErrForbidden 68 + } 69 + authorDid := sess.Data.AccountDID 70 + l = l.With("did", authorDid) 71 + 72 + mentions, references := s.refResolver.Resolve(ctx, body) 73 + 74 + issue := models.Issue{ 75 + RepoAt: repo.RepoAt(), 76 + Rkey: tid.TID(), 77 + Title: title, 78 + Body: body, 79 + Open: true, 80 + Did: authorDid.String(), 81 + Created: time.Now(), 82 + Mentions: mentions, 83 + References: references, 84 + Repo: repo, 85 + } 86 + 87 + if err := s.validator.ValidateIssue(&issue); err != nil { 88 + l.Error("validation error", "err", err) 89 + return nil, ErrValidationFail 90 + } 91 + 92 + tx, err := s.db.BeginTx(ctx, nil) 93 + if err != nil { 94 + l.Error("db.BeginTx failed", "err", err) 95 + return nil, ErrDatabaseFail 96 + } 97 + defer tx.Rollback() 98 + 99 + if err := db.PutIssue(tx, &issue); err != nil { 100 + l.Error("db.PutIssue failed", "err", err) 101 + return nil, ErrDatabaseFail 102 + } 103 + 104 + atpclient := sess.APIClient() 105 + record := issue.AsRecord() 106 + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 107 + Repo: authorDid.String(), 108 + Collection: tangled.RepoIssueNSID, 109 + Rkey: issue.Rkey, 110 + Record: &lexutil.LexiconTypeDecoder{ 111 + Val: &record, 112 + }, 113 + }) 114 + if err != nil { 115 + l.Error("atproto.RepoPutRecord failed", "err", err) 116 + return nil, ErrPDSFail 117 + } 118 + if err = tx.Commit(); err != nil { 119 + l.Error("tx.Commit failed", "err", err) 120 + return nil, ErrDatabaseFail 121 + } 122 + 123 + s.notifier.NewIssue(ctx, &issue, mentions) 124 + return &issue, nil 125 + } 126 + 127 + func (s *Service) GetIssues(ctx context.Context, repo *models.Repo, searchOpts models.IssueSearchOptions) ([]models.Issue, error) { 128 + l := s.logger.With("method", "GetIssues") 129 + 130 + var issues []models.Issue 131 + var err error 132 + if searchOpts.Keyword != "" { 133 + res, err := s.indexer.Search(ctx, searchOpts) 134 + if err != nil { 135 + l.Error("failed to search for issues", "err", err) 136 + return nil, ErrIndexerFail 137 + } 138 + l.Debug("searched issues with indexer", "count", len(res.Hits)) 139 + issues, err = db.GetIssues(s.db, orm.FilterIn("id", res.Hits)) 140 + if err != nil { 141 + l.Error("failed to get issues", "err", err) 142 + return nil, ErrDatabaseFail 143 + } 144 + } else { 145 + openInt := 0 146 + if searchOpts.IsOpen { 147 + openInt = 1 148 + } 149 + issues, err = db.GetIssuesPaginated( 150 + s.db, 151 + searchOpts.Page, 152 + orm.FilterEq("repo_at", repo.RepoAt()), 153 + orm.FilterEq("open", openInt), 154 + ) 155 + if err != nil { 156 + l.Error("failed to get issues", "err", err) 157 + return nil, ErrDatabaseFail 158 + } 159 + } 160 + 161 + return issues, nil 162 + } 163 + 164 + func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error { 165 + l := s.logger.With("method", "EditIssue") 166 + sess := session.FromContext(ctx) 167 + if sess == nil { 168 + l.Error("user session is missing in context") 169 + return ErrForbidden 170 + } 171 + sessDid := sess.Data.AccountDID 172 + l = l.With("did", sessDid) 173 + 174 + mentions, references := s.refResolver.Resolve(ctx, issue.Body) 175 + issue.Mentions = mentions 176 + issue.References = references 177 + 178 + if sessDid != syntax.DID(issue.Did) { 179 + l.Error("only author can edit the issue") 180 + return ErrForbidden 181 + } 182 + 183 + if err := s.validator.ValidateIssue(issue); err != nil { 184 + l.Error("validation error", "err", err) 185 + return ErrValidationFail 186 + } 187 + 188 + tx, err := s.db.BeginTx(ctx, nil) 189 + if err != nil { 190 + l.Error("db.BeginTx failed", "err", err) 191 + return ErrDatabaseFail 192 + } 193 + defer tx.Rollback() 194 + 195 + if err := db.PutIssue(tx, issue); err != nil { 196 + l.Error("db.PutIssue failed", "err", err) 197 + return ErrDatabaseFail 198 + } 199 + 200 + atpclient := sess.APIClient() 201 + record := issue.AsRecord() 202 + 203 + ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 204 + if err != nil { 205 + l.Error("atproto.RepoGetRecord failed", "err", err) 206 + return ErrPDSFail 207 + } 208 + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 209 + Collection: tangled.RepoIssueNSID, 210 + SwapRecord: ex.Cid, 211 + Record: &lexutil.LexiconTypeDecoder{ 212 + Val: &record, 213 + }, 214 + }) 215 + if err != nil { 216 + l.Error("atproto.RepoPutRecord failed", "err", err) 217 + return ErrPDSFail 218 + } 219 + 220 + if err = tx.Commit(); err != nil { 221 + l.Error("tx.Commit failed", "err", err) 222 + return ErrDatabaseFail 223 + } 224 + 225 + // TODO: notify PutIssue 226 + 227 + return nil 228 + } 229 + 230 + func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error { 231 + l := s.logger.With("method", "DeleteIssue") 232 + sess := session.FromContext(ctx) 233 + if sess == nil { 234 + l.Error("user session is missing in context") 235 + return ErrForbidden 236 + } 237 + sessDid := sess.Data.AccountDID 238 + l = l.With("did", sessDid) 239 + 240 + if sessDid != syntax.DID(issue.Did) { 241 + l.Error("only author can edit the issue") 242 + return ErrForbidden 243 + } 244 + 245 + tx, err := s.db.BeginTx(ctx, nil) 246 + if err != nil { 247 + l.Error("db.BeginTx failed", "err", err) 248 + return ErrDatabaseFail 249 + } 250 + defer tx.Rollback() 251 + 252 + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 253 + l.Error("db.DeleteIssues failed", "err", err) 254 + return ErrDatabaseFail 255 + } 256 + 257 + atpclient := sess.APIClient() 258 + _, err = atproto.RepoDeleteRecord(ctx, atpclient, &atproto.RepoDeleteRecord_Input{ 259 + Collection: tangled.RepoIssueNSID, 260 + Repo: issue.Did, 261 + Rkey: issue.Rkey, 262 + }) 263 + if err != nil { 264 + l.Error("atproto.RepoDeleteRecord failed", "err", err) 265 + return ErrPDSFail 266 + } 267 + 268 + if err := tx.Commit(); err != nil { 269 + l.Error("tx.Commit failed", "err", err) 270 + return ErrDatabaseFail 271 + } 272 + 273 + s.notifier.DeleteIssue(ctx, issue) 274 + return nil 275 + }
+84
appview/service/issue/state.go
··· 1 + package issue 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/pages/repoinfo" 10 + "tangled.org/core/appview/session" 11 + "tangled.org/core/orm" 12 + ) 13 + 14 + func (s *Service) CloseIssue(ctx context.Context, issue *models.Issue) error { 15 + l := s.logger.With("method", "CloseIssue") 16 + sess := session.FromContext(ctx) 17 + if sess == nil { 18 + l.Error("user session is missing in context") 19 + return ErrUnAuthenticated 20 + } 21 + sessDid := sess.Data.AccountDID 22 + l = l.With("did", sessDid) 23 + 24 + // TODO: make this more granular 25 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} 26 + isRepoOwner := roles.IsOwner() 27 + isCollaborator := roles.IsCollaborator() 28 + isIssueOwner := sessDid == syntax.DID(issue.Did) 29 + if !(isRepoOwner || isCollaborator || isIssueOwner) { 30 + l.Error("user is not authorized") 31 + return ErrForbidden 32 + } 33 + 34 + err := db.CloseIssues( 35 + s.db, 36 + orm.FilterEq("id", issue.Id), 37 + ) 38 + if err != nil { 39 + l.Error("db.CloseIssues failed", "err", err) 40 + return ErrDatabaseFail 41 + } 42 + 43 + // change the issue state (this will pass down to the notifiers) 44 + issue.Open = false 45 + 46 + s.notifier.NewIssueState(ctx, sessDid, issue) 47 + return nil 48 + } 49 + 50 + func (s *Service) ReopenIssue(ctx context.Context, issue *models.Issue) error { 51 + l := s.logger.With("method", "ReopenIssue") 52 + sess := session.FromContext(ctx) 53 + if sess == nil { 54 + l.Error("user session is missing in context") 55 + return ErrUnAuthenticated 56 + } 57 + sessDid := sess.Data.AccountDID 58 + l = l.With("did", sessDid) 59 + 60 + // TODO: make this more granular 61 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} 62 + isRepoOwner := roles.IsOwner() 63 + isCollaborator := roles.IsCollaborator() 64 + isIssueOwner := sessDid == syntax.DID(issue.Did) 65 + if !(isRepoOwner || isCollaborator || isIssueOwner) { 66 + l.Error("user is not authorized") 67 + return ErrForbidden 68 + } 69 + 70 + err := db.ReopenIssues( 71 + s.db, 72 + orm.FilterEq("id", issue.Id), 73 + ) 74 + if err != nil { 75 + l.Error("db.ReopenIssues failed", "err", err) 76 + return ErrDatabaseFail 77 + } 78 + 79 + // change the issue state (this will pass down to the notifiers) 80 + issue.Open = true 81 + 82 + s.notifier.NewIssueState(ctx, sessDid, issue) 83 + return nil 84 + }
+11
appview/service/repo/errors.go
··· 1 + package repo 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrUnAuthenticated = errors.New("user session missing") 7 + ErrForbidden = errors.New("unauthorized operation") 8 + ErrDatabaseFail = errors.New("db op fail") 9 + ErrPDSFail = errors.New("pds op fail") 10 + ErrValidationFail = errors.New("repo validation fail") 11 + )
+89
appview/service/repo/repo.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/session" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/tid" 16 + ) 17 + 18 + type Service struct { 19 + logger *slog.Logger 20 + config *config.Config 21 + db *db.DB 22 + enforcer *rbac.Enforcer 23 + } 24 + 25 + func NewService( 26 + logger *slog.Logger, 27 + config *config.Config, 28 + db *db.DB, 29 + enforcer *rbac.Enforcer, 30 + ) Service { 31 + return Service{ 32 + logger, 33 + config, 34 + db, 35 + enforcer, 36 + } 37 + } 38 + 39 + // NewRepo creates a repository 40 + // It expects atproto session to be passed in `ctx` 41 + func (s *Service) NewRepo(ctx context.Context, name, description, knot string) (*models.Repo, error) { 42 + l := s.logger.With("method", "NewRepo") 43 + sess := session.FromContext(ctx) 44 + if sess == nil { 45 + l.Error("user session is missing in context") 46 + return nil, ErrForbidden 47 + } 48 + 49 + ownerDid := sess.Data.AccountDID 50 + l = l.With("did", ownerDid) 51 + 52 + repo := models.Repo{ 53 + Did: ownerDid.String(), 54 + Name: name, 55 + Knot: knot, 56 + Rkey: tid.TID(), 57 + Description: description, 58 + Created: time.Now(), 59 + Labels: s.config.Label.DefaultLabelDefs, 60 + } 61 + l = l.With("aturi", repo.RepoAt()) 62 + 63 + tx, err := s.db.BeginTx(ctx, nil) 64 + if err != nil { 65 + l.Error("db.BeginTx failed", "err", err) 66 + return nil, ErrDatabaseFail 67 + } 68 + defer tx.Rollback() 69 + 70 + if err = db.AddRepo(tx, &repo); err != nil { 71 + l.Error("db.AddRepo failed", "err", err) 72 + return nil, ErrDatabaseFail 73 + } 74 + 75 + atpclient := sess.APIClient() 76 + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ 77 + Collection: tangled.RepoNSID, 78 + Repo: repo.Did, 79 + }) 80 + if err != nil { 81 + l.Error("atproto.RepoPutRecord failed", "err", err) 82 + return nil, ErrPDSFail 83 + } 84 + l.Info("wrote to PDS") 85 + 86 + // knotclient, err := s.oauth.ServiceClient( 87 + // ) 88 + panic("unimplemented") 89 + }
+90
appview/service/repo/repoinfo.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/identity" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/oauth" 10 + "tangled.org/core/appview/pages/repoinfo" 11 + ) 12 + 13 + // GetRepoInfo converts given `Repo` to `RepoInfo` object. 14 + // The `user` can be nil. 15 + // NOTE: RepoInfo is bad design and should be removed in future. 16 + // avoid using this method if you can. 17 + func (s *Service) GetRepoInfo( 18 + ctx context.Context, 19 + ownerId *identity.Identity, 20 + baseRepo *models.Repo, 21 + currentDir, ref string, 22 + user *oauth.User, 23 + ) (*repoinfo.RepoInfo, error) { 24 + var ( 25 + repoAt = baseRepo.RepoAt() 26 + isStarred = false 27 + roles = repoinfo.RolesInRepo{} 28 + ) 29 + if user != nil { 30 + isStarred = db.GetStarStatus(s.db, user.Did, repoAt) 31 + roles.Roles = s.enforcer.GetPermissionsInRepo(user.Did, baseRepo.Knot, baseRepo.DidSlashRepo()) 32 + } 33 + 34 + stats := baseRepo.RepoStats 35 + if stats == nil { 36 + starCount, err := db.GetStarCount(s.db, repoAt) 37 + if err != nil { 38 + return nil, err 39 + } 40 + issueCount, err := db.GetIssueCount(s.db, repoAt) 41 + if err != nil { 42 + return nil, err 43 + } 44 + pullCount, err := db.GetPullCount(s.db, repoAt) 45 + if err != nil { 46 + return nil, err 47 + } 48 + stats = &models.RepoStats{ 49 + StarCount: starCount, 50 + IssueCount: issueCount, 51 + PullCount: pullCount, 52 + } 53 + } 54 + 55 + var sourceRepo *models.Repo 56 + var err error 57 + if baseRepo.Source != "" { 58 + sourceRepo, err = db.GetRepoByAtUri(s.db, baseRepo.Source) 59 + if err != nil { 60 + return nil, err 61 + } 62 + } 63 + 64 + repoInfo := &repoinfo.RepoInfo{ 65 + // ok this is basically a models.Repo 66 + OwnerDid: baseRepo.Did, 67 + OwnerHandle: ownerId.Handle.String(), // TODO: shouldn't use 68 + Name: baseRepo.Name, 69 + Rkey: baseRepo.Rkey, 70 + Description: baseRepo.Description, 71 + Website: baseRepo.Website, 72 + Topics: baseRepo.Topics, 73 + Knot: baseRepo.Knot, 74 + Spindle: baseRepo.Spindle, 75 + Stats: *stats, 76 + 77 + // fork repo upstream 78 + Source: sourceRepo, 79 + 80 + // repo path (context) 81 + CurrentDir: currentDir, 82 + Ref: ref, 83 + 84 + // info related to the session 85 + IsStarred: isStarred, 86 + Roles: roles, 87 + } 88 + 89 + return repoInfo, nil 90 + }
+29
appview/session/context.go
··· 1 + package session 2 + 3 + import ( 4 + "context" 5 + 6 + toauth "tangled.org/core/appview/oauth" 7 + ) 8 + 9 + type ctxKey struct{} 10 + 11 + func IntoContext(ctx context.Context, sess Session) context.Context { 12 + return context.WithValue(ctx, ctxKey{}, &sess) 13 + } 14 + 15 + func FromContext(ctx context.Context) *Session { 16 + sess, ok := ctx.Value(ctxKey{}).(*Session) 17 + if !ok { 18 + return nil 19 + } 20 + return sess 21 + } 22 + 23 + func UserFromContext(ctx context.Context) *toauth.User { 24 + sess := FromContext(ctx) 25 + if sess == nil { 26 + return nil 27 + } 28 + return sess.User() 29 + }
+24
appview/session/session.go
··· 1 + package session 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 5 + toauth "tangled.org/core/appview/oauth" 6 + ) 7 + 8 + // Session is a lightweight wrapper over indigo-oauth ClientSession 9 + type Session struct { 10 + *oauth.ClientSession 11 + } 12 + 13 + func New(atSess *oauth.ClientSession) Session { 14 + return Session{ 15 + atSess, 16 + } 17 + } 18 + 19 + func (s *Session) User() *toauth.User { 20 + return &toauth.User{ 21 + Did: string(s.Data.AccountDID), 22 + Pds: s.Data.HostURL, 23 + } 24 + }
+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 + }
+23
appview/web/handler/oauth_client_metadata.go
··· 1 + package handler 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.org/core/appview/oauth" 8 + ) 9 + 10 + func OauthClientMetadata(o *oauth.OAuth) http.HandlerFunc { 11 + return func(w http.ResponseWriter, r *http.Request) { 12 + doc := o.ClientApp.Config.ClientMetadata() 13 + doc.JWKSURI = &o.JwksUri 14 + doc.ClientName = &o.ClientName 15 + doc.ClientURI = &o.ClientUri 16 + 17 + w.Header().Set("Content-Type", "application/json") 18 + if err := json.NewEncoder(w).Encode(doc); err != nil { 19 + http.Error(w, err.Error(), http.StatusInternalServerError) 20 + return 21 + } 22 + } 23 + }
+19
appview/web/handler/oauth_jwks.go
··· 1 + package handler 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.org/core/appview/oauth" 8 + ) 9 + 10 + func OauthJwks(o *oauth.OAuth) http.HandlerFunc { 11 + return func(w http.ResponseWriter, r *http.Request) { 12 + w.Header().Set("Content-Type", "application/json") 13 + body := o.ClientApp.Config.PublicJWKS() 14 + if err := json.NewEncoder(w).Encode(body); err != nil { 15 + http.Error(w, err.Error(), http.StatusInternalServerError) 16 + return 17 + } 18 + } 19 + }
+87
appview/web/handler/user_repo_issues.go
··· 1 + package handler 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/api/tangled" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/pages" 10 + "tangled.org/core/appview/pagination" 11 + isvc "tangled.org/core/appview/service/issue" 12 + rsvc "tangled.org/core/appview/service/repo" 13 + "tangled.org/core/appview/session" 14 + "tangled.org/core/appview/web/request" 15 + "tangled.org/core/log" 16 + "tangled.org/core/orm" 17 + ) 18 + 19 + func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 20 + return func(w http.ResponseWriter, r *http.Request) { 21 + ctx := r.Context() 22 + l := log.FromContext(ctx).With("handler", "RepoIssues") 23 + repo, ok := request.RepoFromContext(ctx) 24 + if !ok { 25 + l.Error("malformed request") 26 + p.Error503(w) 27 + return 28 + } 29 + repoOwnerId, ok := request.OwnerFromContext(ctx) 30 + if !ok { 31 + l.Error("malformed request") 32 + p.Error503(w) 33 + return 34 + } 35 + 36 + query := r.URL.Query() 37 + searchOpts := models.IssueSearchOptions{ 38 + RepoAt: repo.RepoAt().String(), 39 + Keyword: query.Get("q"), 40 + IsOpen: query.Get("state") != "closed", 41 + Page: pagination.FromContext(ctx), 42 + } 43 + 44 + issues, err := is.GetIssues(ctx, repo, searchOpts) 45 + if err != nil { 46 + l.Error("failed to get issues") 47 + p.Error503(w) 48 + return 49 + } 50 + 51 + // render page 52 + err = func() error { 53 + user := session.UserFromContext(ctx) 54 + repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, repo, "", "", user) 55 + if err != nil { 56 + return err 57 + } 58 + labelDefs, err := db.GetLabelDefinitions( 59 + d, 60 + orm.FilterIn("at_uri", repo.Labels), 61 + orm.FilterContains("scope", tangled.RepoIssueNSID), 62 + ) 63 + if err != nil { 64 + return err 65 + } 66 + defs := make(map[string]*models.LabelDefinition) 67 + for _, l := range labelDefs { 68 + defs[l.AtUri().String()] = &l 69 + } 70 + return p.RepoIssues(w, pages.RepoIssuesParams{ 71 + LoggedInUser: user, 72 + RepoInfo: *repoinfo, 73 + 74 + Issues: issues, 75 + LabelDefs: defs, 76 + FilteringByOpen: searchOpts.IsOpen, 77 + FilterQuery: searchOpts.Keyword, 78 + Page: searchOpts.Page, 79 + }) 80 + }() 81 + if err != nil { 82 + l.Error("failed to render", "err", err) 83 + p.Error503(w) 84 + return 85 + } 86 + } 87 + }
+115
appview/web/handler/user_repo_issues_issue.go
··· 1 + package handler 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/api/tangled" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/pages" 10 + isvc "tangled.org/core/appview/service/issue" 11 + rsvc "tangled.org/core/appview/service/repo" 12 + "tangled.org/core/appview/session" 13 + "tangled.org/core/appview/web/request" 14 + "tangled.org/core/log" 15 + "tangled.org/core/orm" 16 + ) 17 + 18 + func Issue(s isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { 19 + return func(w http.ResponseWriter, r *http.Request) { 20 + ctx := r.Context() 21 + l := log.FromContext(ctx).With("handler", "Issue") 22 + issue, ok := request.IssueFromContext(ctx) 23 + if !ok { 24 + l.Error("malformed request, failed to get issue") 25 + p.Error503(w) 26 + return 27 + } 28 + repoOwnerId, ok := request.OwnerFromContext(ctx) 29 + if !ok { 30 + l.Error("malformed request") 31 + p.Error503(w) 32 + return 33 + } 34 + 35 + // render 36 + err := func() error { 37 + user := session.UserFromContext(ctx) 38 + repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, issue.Repo, "", "", user) 39 + if err != nil { 40 + l.Error("failed to load repo", "err", err) 41 + return err 42 + } 43 + 44 + reactionMap, err := db.GetReactionMap(d, 20, issue.AtUri()) 45 + if err != nil { 46 + l.Error("failed to get issue reactions", "err", err) 47 + return err 48 + } 49 + 50 + userReactions := map[models.ReactionKind]bool{} 51 + if user != nil { 52 + userReactions = db.GetReactionStatusMap(d, user.Did, issue.AtUri()) 53 + } 54 + 55 + backlinks, err := db.GetBacklinks(d, issue.AtUri()) 56 + if err != nil { 57 + l.Error("failed to fetch backlinks", "err", err) 58 + return err 59 + } 60 + 61 + labelDefs, err := db.GetLabelDefinitions( 62 + d, 63 + orm.FilterIn("at_uri", issue.Repo.Labels), 64 + orm.FilterContains("scope", tangled.RepoIssueNSID), 65 + ) 66 + if err != nil { 67 + l.Error("failed to fetch label defs", "err", err) 68 + return err 69 + } 70 + 71 + defs := make(map[string]*models.LabelDefinition) 72 + for _, l := range labelDefs { 73 + defs[l.AtUri().String()] = &l 74 + } 75 + 76 + return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 77 + LoggedInUser: user, 78 + RepoInfo: *repoinfo, 79 + Issue: issue, 80 + CommentList: issue.CommentList(), 81 + Backlinks: backlinks, 82 + OrderedReactionKinds: models.OrderedReactionKinds, 83 + Reactions: reactionMap, 84 + UserReacted: userReactions, 85 + LabelDefs: defs, 86 + }) 87 + }() 88 + if err != nil { 89 + l.Error("failed to render", "err", err) 90 + p.Error503(w) 91 + return 92 + } 93 + } 94 + } 95 + 96 + func IssueDelete(s isvc.Service, p *pages.Pages) http.HandlerFunc { 97 + noticeId := "issue-actions-error" 98 + return func(w http.ResponseWriter, r *http.Request) { 99 + ctx := r.Context() 100 + l := log.FromContext(ctx).With("handler", "IssueDelete") 101 + issue, ok := request.IssueFromContext(ctx) 102 + if !ok { 103 + l.Error("failed to get issue") 104 + // TODO: 503 error with more detailed messages 105 + p.Error503(w) 106 + return 107 + } 108 + err := s.DeleteIssue(ctx, issue) 109 + if err != nil { 110 + p.Notice(w, noticeId, "failed to delete issue") 111 + return 112 + } 113 + p.HxLocation(w, "/") 114 + } 115 + }
+40
appview/web/handler/user_repo_issues_issue_close.go
··· 1 + package handler 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/appview/pages" 9 + "tangled.org/core/appview/reporesolver" 10 + isvc "tangled.org/core/appview/service/issue" 11 + "tangled.org/core/appview/web/request" 12 + "tangled.org/core/log" 13 + ) 14 + 15 + func CloseIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 16 + noticeId := "issue-action" 17 + return func(w http.ResponseWriter, r *http.Request) { 18 + ctx := r.Context() 19 + l := log.FromContext(ctx).With("handler", "CloseIssue") 20 + issue, ok := request.IssueFromContext(ctx) 21 + if !ok { 22 + l.Error("malformed request, failed to get issue") 23 + p.Error503(w) 24 + return 25 + } 26 + 27 + err := is.CloseIssue(ctx, issue) 28 + if err != nil { 29 + if errors.Is(err, isvc.ErrForbidden) { 30 + http.Error(w, "forbidden", http.StatusUnauthorized) 31 + } else { 32 + p.Notice(w, noticeId, "Failed to close issue. Try again later.") 33 + } 34 + return 35 + } 36 + 37 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 38 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 39 + } 40 + }
+84
appview/web/handler/user_repo_issues_issue_edit.go
··· 1 + package handler 2 + 3 + import ( 4 + "errors" 5 + "net/http" 6 + 7 + "tangled.org/core/appview/pages" 8 + isvc "tangled.org/core/appview/service/issue" 9 + rsvc "tangled.org/core/appview/service/repo" 10 + "tangled.org/core/appview/session" 11 + "tangled.org/core/appview/web/request" 12 + "tangled.org/core/log" 13 + ) 14 + 15 + func IssueEdit(is isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 16 + return func(w http.ResponseWriter, r *http.Request) { 17 + ctx := r.Context() 18 + l := log.FromContext(ctx).With("handler", "IssueEdit") 19 + issue, ok := request.IssueFromContext(ctx) 20 + if !ok { 21 + l.Error("malformed request, failed to get issue") 22 + p.Error503(w) 23 + return 24 + } 25 + repoOwnerId, ok := request.OwnerFromContext(ctx) 26 + if !ok { 27 + l.Error("malformed request") 28 + p.Error503(w) 29 + return 30 + } 31 + 32 + // render 33 + err := func() error { 34 + user := session.UserFromContext(ctx) 35 + repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, issue.Repo, "", "", user) 36 + if err != nil { 37 + return err 38 + } 39 + return p.EditIssueFragment(w, pages.EditIssueParams{ 40 + LoggedInUser: user, 41 + RepoInfo: *repoinfo, 42 + 43 + Issue: issue, 44 + }) 45 + }() 46 + if err != nil { 47 + l.Error("failed to render", "err", err) 48 + p.Error503(w) 49 + return 50 + } 51 + } 52 + } 53 + 54 + func IssueEditPost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 55 + noticeId := "issues" 56 + return func(w http.ResponseWriter, r *http.Request) { 57 + ctx := r.Context() 58 + l := log.FromContext(ctx).With("handler", "IssueEdit") 59 + issue, ok := request.IssueFromContext(ctx) 60 + if !ok { 61 + l.Error("malformed request, failed to get issue") 62 + p.Error503(w) 63 + return 64 + } 65 + 66 + newIssue := *issue 67 + newIssue.Title = r.FormValue("title") 68 + newIssue.Body = r.FormValue("body") 69 + 70 + err := is.EditIssue(ctx, &newIssue) 71 + if err != nil { 72 + if errors.Is(err, isvc.ErrDatabaseFail) { 73 + p.Notice(w, noticeId, "Failed to edit issue.") 74 + } else if errors.Is(err, isvc.ErrPDSFail) { 75 + p.Notice(w, noticeId, "Failed to edit issue.") 76 + } else { 77 + p.Notice(w, noticeId, "Failed to edit issue.") 78 + } 79 + return 80 + } 81 + 82 + p.HxRefresh(w) 83 + } 84 + }
+40
appview/web/handler/user_repo_issues_issue_reopen.go
··· 1 + package handler 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/appview/pages" 9 + "tangled.org/core/appview/reporesolver" 10 + isvc "tangled.org/core/appview/service/issue" 11 + "tangled.org/core/appview/web/request" 12 + "tangled.org/core/log" 13 + ) 14 + 15 + func ReopenIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { 16 + noticeId := "issue-action" 17 + return func(w http.ResponseWriter, r *http.Request) { 18 + ctx := r.Context() 19 + l := log.FromContext(ctx).With("handler", "ReopenIssue") 20 + issue, ok := request.IssueFromContext(ctx) 21 + if !ok { 22 + l.Error("malformed request, failed to get issue") 23 + p.Error503(w) 24 + return 25 + } 26 + 27 + err := is.ReopenIssue(ctx, issue) 28 + if err != nil { 29 + if errors.Is(err, isvc.ErrForbidden) { 30 + http.Error(w, "forbidden", http.StatusUnauthorized) 31 + } else { 32 + p.Notice(w, noticeId, "Failed to reopen issue. Try again later.") 33 + } 34 + return 35 + } 36 + 37 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) 38 + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 39 + } 40 + }
+79
appview/web/handler/user_repo_issues_new.go
··· 1 + package handler 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/appview/pages" 9 + isvc "tangled.org/core/appview/service/issue" 10 + rsvc "tangled.org/core/appview/service/repo" 11 + "tangled.org/core/appview/session" 12 + "tangled.org/core/appview/web/request" 13 + "tangled.org/core/log" 14 + ) 15 + 16 + func NewIssue(rs rsvc.Service, p *pages.Pages) http.HandlerFunc { 17 + return func(w http.ResponseWriter, r *http.Request) { 18 + ctx := r.Context() 19 + l := log.FromContext(ctx).With("handler", "NewIssue") 20 + 21 + // render 22 + err := func() error { 23 + user := session.UserFromContext(ctx) 24 + repo, ok := request.RepoFromContext(ctx) 25 + if !ok { 26 + return fmt.Errorf("malformed request") 27 + } 28 + repoOwnerId, ok := request.OwnerFromContext(ctx) 29 + if !ok { 30 + return fmt.Errorf("malformed request") 31 + } 32 + repoinfo, err := rs.GetRepoInfo(ctx, repoOwnerId, repo, "", "", user) 33 + if err != nil { 34 + return err 35 + } 36 + return p.RepoNewIssue(w, pages.RepoNewIssueParams{ 37 + LoggedInUser: user, 38 + RepoInfo: *repoinfo, 39 + }) 40 + }() 41 + if err != nil { 42 + l.Error("failed to render", "err", err) 43 + p.Error503(w) 44 + return 45 + } 46 + } 47 + } 48 + 49 + func NewIssuePost(is isvc.Service, p *pages.Pages) http.HandlerFunc { 50 + noticeId := "issues" 51 + return func(w http.ResponseWriter, r *http.Request) { 52 + ctx := r.Context() 53 + l := log.FromContext(ctx).With("handler", "NewIssuePost") 54 + repo, ok := request.RepoFromContext(ctx) 55 + if !ok { 56 + l.Error("malformed request, failed to get repo") 57 + // TODO: 503 error with more detailed messages 58 + p.Error503(w) 59 + return 60 + } 61 + var ( 62 + title = r.FormValue("title") 63 + body = r.FormValue("body") 64 + ) 65 + 66 + _, err := is.NewIssue(ctx, repo, title, body) 67 + if err != nil { 68 + if errors.Is(err, isvc.ErrDatabaseFail) { 69 + p.Notice(w, noticeId, "Failed to create issue.") 70 + } else if errors.Is(err, isvc.ErrPDSFail) { 71 + p.Notice(w, noticeId, "Failed to create issue.") 72 + } else { 73 + p.Notice(w, noticeId, "Failed to create issue.") 74 + } 75 + return 76 + } 77 + p.HxLocation(w, "/") 78 + } 79 + }
+67
appview/web/middleware/auth.go
··· 1 + package middleware 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.org/core/appview/oauth" 9 + "tangled.org/core/appview/session" 10 + "tangled.org/core/log" 11 + ) 12 + 13 + // WithSession resumes atp session from cookie, ensure it's not malformed and 14 + // pass the session through context 15 + func WithSession(o *oauth.OAuth) middlewareFunc { 16 + return func(next http.Handler) http.Handler { 17 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 + atSess, err := o.ResumeSession(r) 19 + if err != nil { 20 + next.ServeHTTP(w, r) 21 + return 22 + } 23 + 24 + sess := session.New(atSess) 25 + 26 + ctx := session.IntoContext(r.Context(), sess) 27 + next.ServeHTTP(w, r.WithContext(ctx)) 28 + }) 29 + } 30 + } 31 + 32 + // AuthMiddleware ensures the request is authorized and redirect to login page 33 + // when unauthorized 34 + func AuthMiddleware() middlewareFunc { 35 + return func(next http.Handler) http.Handler { 36 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 + ctx := r.Context() 38 + l := log.FromContext(ctx) 39 + 40 + returnURL := "/" 41 + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 42 + returnURL = u.RequestURI() 43 + } 44 + 45 + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 46 + 47 + redirectFunc := func(w http.ResponseWriter, r *http.Request) { 48 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 49 + } 50 + if r.Header.Get("HX-Request") == "true" { 51 + redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 52 + w.Header().Set("HX-Redirect", loginURL) 53 + w.WriteHeader(http.StatusOK) 54 + } 55 + } 56 + 57 + sess := session.FromContext(ctx) 58 + if sess == nil { 59 + l.Debug("no session, redirecting...") 60 + redirectFunc(w, r) 61 + return 62 + } 63 + 64 + next.ServeHTTP(w, r) 65 + }) 66 + } 67 + }
+27
appview/web/middleware/ensuredidorhandle.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/pages" 8 + "tangled.org/core/appview/state/userutil" 9 + ) 10 + 11 + // EnsureDidOrHandle ensures the "user" url param is valid did/handle format. 12 + // If not, respond with 404 13 + func EnsureDidOrHandle(p *pages.Pages) middlewareFunc { 14 + return func(next http.Handler) http.Handler { 15 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + user := chi.URLParam(r, "user") 17 + 18 + // if using a DID or handle, just continue as per usual 19 + if userutil.IsDid(user) || userutil.IsHandle(user) { 20 + next.ServeHTTP(w, r) 21 + return 22 + } 23 + 24 + p.Error404(w) 25 + }) 26 + } 27 + }
+18
appview/web/middleware/log.go
··· 1 + package middleware 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + 7 + "tangled.org/core/log" 8 + ) 9 + 10 + func WithLogger(l *slog.Logger) middlewareFunc { 11 + return func(next http.Handler) http.Handler { 12 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 + // NOTE: can add some metadata here 14 + ctx := log.IntoContext(r.Context(), l) 15 + next.ServeHTTP(w, r.WithContext(ctx)) 16 + }) 17 + } 18 + }
+7
appview/web/middleware/middleware.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + ) 6 + 7 + type middlewareFunc func(http.Handler) http.Handler
+50
appview/web/middleware/normalize.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + 7 + "github.com/go-chi/chi/v5" 8 + "tangled.org/core/appview/state/userutil" 9 + ) 10 + 11 + func Normalize() middlewareFunc { 12 + return func(next http.Handler) http.Handler { 13 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 + pat := chi.URLParam(r, "*") 15 + pathParts := strings.SplitN(pat, "/", 2) 16 + if len(pathParts) == 0 { 17 + next.ServeHTTP(w, r) 18 + return 19 + } 20 + 21 + firstPart := pathParts[0] 22 + 23 + // if using a flattened DID (like you would in go modules), unflatten 24 + if userutil.IsFlattenedDid(firstPart) { 25 + unflattenedDid := userutil.UnflattenDid(firstPart) 26 + redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 27 + 28 + redirectURL := *r.URL 29 + redirectURL.Path = "/" + redirectPath 30 + 31 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 32 + return 33 + } 34 + 35 + // if using a handle with @, rewrite to work without @ 36 + if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 37 + redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 38 + 39 + redirectURL := *r.URL 40 + redirectURL.Path = "/" + redirectPath 41 + 42 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 43 + return 44 + } 45 + 46 + next.ServeHTTP(w, r) 47 + return 48 + }) 49 + } 50 + }
+38
appview/web/middleware/paginate.go
··· 1 + package middleware 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.org/core/appview/pagination" 9 + ) 10 + 11 + func Paginate(next http.Handler) http.Handler { 12 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 + page := pagination.FirstPage() 14 + 15 + offsetVal := r.URL.Query().Get("offset") 16 + if offsetVal != "" { 17 + offset, err := strconv.Atoi(offsetVal) 18 + if err != nil { 19 + log.Println("invalid offset") 20 + } else { 21 + page.Offset = offset 22 + } 23 + } 24 + 25 + limitVal := r.URL.Query().Get("limit") 26 + if limitVal != "" { 27 + limit, err := strconv.Atoi(limitVal) 28 + if err != nil { 29 + log.Println("invalid limit") 30 + } else { 31 + page.Limit = limit 32 + } 33 + } 34 + 35 + ctx := pagination.IntoContext(r.Context(), page) 36 + next.ServeHTTP(w, r.WithContext(ctx)) 37 + }) 38 + }
+121
appview/web/middleware/resolve.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "strconv" 7 + "strings" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/pages" 12 + "tangled.org/core/appview/web/request" 13 + "tangled.org/core/idresolver" 14 + "tangled.org/core/log" 15 + "tangled.org/core/orm" 16 + ) 17 + 18 + func ResolveIdent( 19 + idResolver *idresolver.Resolver, 20 + pages *pages.Pages, 21 + ) middlewareFunc { 22 + return func(next http.Handler) http.Handler { 23 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 + ctx := r.Context() 25 + l := log.FromContext(ctx) 26 + didOrHandle := chi.URLParam(r, "user") 27 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 28 + 29 + id, err := idResolver.ResolveIdent(ctx, didOrHandle) 30 + if err != nil { 31 + // invalid did or handle 32 + l.Warn("failed to resolve did/handle", "handle", didOrHandle, "err", err) 33 + pages.Error404(w) 34 + return 35 + } 36 + 37 + ctx = request.WithOwner(ctx, id) 38 + // TODO: reomove this later 39 + ctx = context.WithValue(ctx, "resolvedId", *id) 40 + 41 + next.ServeHTTP(w, r.WithContext(ctx)) 42 + }) 43 + } 44 + } 45 + 46 + func ResolveRepo( 47 + e *db.DB, 48 + pages *pages.Pages, 49 + ) middlewareFunc { 50 + return func(next http.Handler) http.Handler { 51 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 + ctx := r.Context() 53 + l := log.FromContext(ctx) 54 + repoName := chi.URLParam(r, "repo") 55 + repoOwner, ok := request.OwnerFromContext(ctx) 56 + if !ok { 57 + l.Error("malformed middleware") 58 + w.WriteHeader(http.StatusInternalServerError) 59 + return 60 + } 61 + 62 + repo, err := db.GetRepo( 63 + e, 64 + orm.FilterEq("did", repoOwner.DID.String()), 65 + orm.FilterEq("name", repoName), 66 + ) 67 + if err != nil { 68 + l.Warn("failed to resolve repo", "err", err) 69 + pages.ErrorKnot404(w) 70 + return 71 + } 72 + 73 + // TODO: pass owner id into repository object 74 + 75 + ctx = request.WithRepo(ctx, repo) 76 + // TODO: reomove this later 77 + ctx = context.WithValue(ctx, "repo", repo) 78 + 79 + next.ServeHTTP(w, r.WithContext(ctx)) 80 + }) 81 + } 82 + } 83 + 84 + func ResolveIssue( 85 + e *db.DB, 86 + pages *pages.Pages, 87 + ) middlewareFunc { 88 + return func(next http.Handler) http.Handler { 89 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 + ctx := r.Context() 91 + l := log.FromContext(ctx) 92 + issueIdStr := chi.URLParam(r, "issue") 93 + issueId, err := strconv.Atoi(issueIdStr) 94 + if err != nil { 95 + l.Warn("failed to fully resolve issue ID", "err", err) 96 + pages.Error404(w) 97 + return 98 + } 99 + repo, ok := request.RepoFromContext(ctx) 100 + if !ok { 101 + l.Error("malformed middleware") 102 + w.WriteHeader(http.StatusInternalServerError) 103 + return 104 + } 105 + 106 + issue, err := db.GetIssue(e, repo.RepoAt(), issueId) 107 + if err != nil { 108 + l.Warn("failed to resolve issue", "err", err) 109 + pages.ErrorKnot404(w) 110 + return 111 + } 112 + issue.Repo = repo 113 + 114 + ctx = request.WithIssue(ctx, issue) 115 + // TODO: reomove this later 116 + ctx = context.WithValue(ctx, "issue", issue) 117 + 118 + next.ServeHTTP(w, r.WithContext(ctx)) 119 + }) 120 + } 121 + }
+39
appview/web/request/context.go
··· 1 + package request 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/identity" 7 + "tangled.org/core/appview/models" 8 + ) 9 + 10 + type ctxKeyOwner struct{} 11 + type ctxKeyRepo struct{} 12 + type ctxKeyIssue struct{} 13 + 14 + func WithOwner(ctx context.Context, owner *identity.Identity) context.Context { 15 + return context.WithValue(ctx, ctxKeyOwner{}, owner) 16 + } 17 + 18 + func OwnerFromContext(ctx context.Context) (*identity.Identity, bool) { 19 + owner, ok := ctx.Value(ctxKeyOwner{}).(*identity.Identity) 20 + return owner, ok 21 + } 22 + 23 + func WithRepo(ctx context.Context, repo *models.Repo) context.Context { 24 + return context.WithValue(ctx, ctxKeyRepo{}, repo) 25 + } 26 + 27 + func RepoFromContext(ctx context.Context) (*models.Repo, bool) { 28 + repo, ok := ctx.Value(ctxKeyRepo{}).(*models.Repo) 29 + return repo, ok 30 + } 31 + 32 + func WithIssue(ctx context.Context, issue *models.Issue) context.Context { 33 + return context.WithValue(ctx, ctxKeyIssue{}, issue) 34 + } 35 + 36 + func IssueFromContext(ctx context.Context) (*models.Issue, bool) { 37 + issue, ok := ctx.Value(ctxKeyIssue{}).(*models.Issue) 38 + return issue, ok 39 + }
+215
appview/web/routes.go
··· 1 + package web 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + 7 + "github.com/go-chi/chi/v5" 8 + "tangled.org/core/appview/config" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/indexer" 11 + "tangled.org/core/appview/mentions" 12 + "tangled.org/core/appview/notify" 13 + "tangled.org/core/appview/oauth" 14 + "tangled.org/core/appview/pages" 15 + isvc "tangled.org/core/appview/service/issue" 16 + rsvc "tangled.org/core/appview/service/repo" 17 + "tangled.org/core/appview/state" 18 + "tangled.org/core/appview/validator" 19 + "tangled.org/core/appview/web/handler" 20 + "tangled.org/core/appview/web/middleware" 21 + "tangled.org/core/idresolver" 22 + "tangled.org/core/rbac" 23 + ) 24 + 25 + // Rules 26 + // - Use single function for each endpoints (unless it doesn't make sense.) 27 + // - Name handler files following the related path (ancestor paths can be 28 + // trimmed.) 29 + // - Pass dependencies to each handlers, don't create structs with shared 30 + // dependencies unless it serves some domain-specific roles like 31 + // service/issue. Same rule goes to middlewares. 32 + 33 + // RouterFromState creates a web router from `state.State`. This exist to 34 + // bridge between legacy web routers under `State` and new architecture 35 + func RouterFromState(s *state.State) http.Handler { 36 + config, db, enforcer, idResolver, refResolver, indexer, logger, notifier, oauth, pages, validator := s.Expose() 37 + 38 + return Router( 39 + logger, 40 + config, 41 + db, 42 + enforcer, 43 + idResolver, 44 + refResolver, 45 + indexer, 46 + notifier, 47 + oauth, 48 + pages, 49 + validator, 50 + s, 51 + ) 52 + } 53 + 54 + func Router( 55 + // NOTE: put base dependencies (db, idResolver, oauth etc) 56 + logger *slog.Logger, 57 + config *config.Config, 58 + db *db.DB, 59 + enforcer *rbac.Enforcer, 60 + idResolver *idresolver.Resolver, 61 + mentionsResolver *mentions.Resolver, 62 + indexer *indexer.Indexer, 63 + notifier notify.Notifier, 64 + oauth *oauth.OAuth, 65 + pages *pages.Pages, 66 + validator *validator.Validator, 67 + // to use legacy web handlers. will be removed later 68 + s *state.State, 69 + ) http.Handler { 70 + repo := rsvc.NewService( 71 + logger, 72 + config, 73 + db, 74 + enforcer, 75 + ) 76 + issue := isvc.NewService( 77 + logger, 78 + config, 79 + db, 80 + enforcer, 81 + notifier, 82 + idResolver, 83 + mentionsResolver, 84 + indexer.Issues, 85 + validator, 86 + ) 87 + 88 + i := s.ExposeIssue() 89 + 90 + r := chi.NewRouter() 91 + 92 + mw := s.Middleware() 93 + auth := middleware.AuthMiddleware() 94 + 95 + r.Use(middleware.WithLogger(logger)) 96 + r.Use(middleware.WithSession(oauth)) 97 + 98 + r.Use(middleware.Normalize()) 99 + 100 + r.Get("/favicon.svg", s.Favicon) 101 + r.Get("/favicon.ico", s.Favicon) 102 + r.Get("/pwa-manifest.json", s.PWAManifest) 103 + r.Get("/robots.txt", s.RobotsTxt) 104 + 105 + r.Handle("/static/*", pages.Static()) 106 + 107 + r.Get("/", s.HomeOrTimeline) 108 + r.Get("/timeline", s.Timeline) 109 + r.Get("/upgradeBanner", s.UpgradeBanner) 110 + 111 + r.Get("/terms", s.TermsOfService) 112 + r.Get("/privacy", s.PrivacyPolicy) 113 + r.Get("/brand", s.Brand) 114 + // special-case handler for serving tangled.org/core 115 + r.Get("/core", s.Core()) 116 + 117 + r.Get("/login", s.Login) 118 + r.Post("/login", s.Login) 119 + r.Post("/logout", s.Logout) 120 + 121 + r.Get("/goodfirstissues", s.GoodFirstIssues) 122 + 123 + r.With(auth).Get("/repo/new", s.NewRepo) 124 + r.With(auth).Post("/repo/new", s.NewRepo) 125 + 126 + r.With(auth).Post("/follow", s.Follow) 127 + r.With(auth).Delete("/follow", s.Follow) 128 + 129 + r.With(auth).Post("/star", s.Star) 130 + r.With(auth).Delete("/star", s.Star) 131 + 132 + r.With(auth).Post("/react", s.React) 133 + r.With(auth).Delete("/react", s.React) 134 + 135 + r.With(auth).Get("/profile/edit-bio", s.EditBioFragment) 136 + r.With(auth).Get("/profile/edit-pins", s.EditPinsFragment) 137 + r.With(auth).Post("/profile/bio", s.UpdateProfileBio) 138 + r.With(auth).Post("/profile/pins", s.UpdateProfilePins) 139 + 140 + r.Mount("/settings", s.SettingsRouter()) 141 + r.Mount("/strings", s.StringsRouter(mw)) 142 + r.Mount("/settings/knots", s.KnotsRouter()) 143 + r.Mount("/settings/spindles", s.SpindlesRouter()) 144 + r.Mount("/notifications", s.NotificationsRouter(mw)) 145 + 146 + r.Mount("/signup", s.SignupRouter()) 147 + r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(oauth)) 148 + r.Get("/oauth/jwks.json", handler.OauthJwks(oauth)) 149 + r.Get("/oauth/callback", oauth.Callback) 150 + 151 + // special-case handler. should replace with xrpc later 152 + r.Get("/keys/{user}", s.Keys) 153 + 154 + r.HandleFunc("/@*", func(w http.ResponseWriter, r *http.Request) { 155 + http.Redirect(w, r, "/"+chi.URLParam(r, "*"), http.StatusFound) 156 + }) 157 + 158 + r.Route("/{user}", func(r chi.Router) { 159 + r.Use(middleware.EnsureDidOrHandle(pages)) 160 + r.Use(middleware.ResolveIdent(idResolver, pages)) 161 + 162 + r.Get("/", s.Profile) 163 + r.Get("/feed.atom", s.AtomFeedPage) 164 + 165 + r.Route("/{repo}", func(r chi.Router) { 166 + r.Use(middleware.ResolveRepo(db, pages)) 167 + 168 + r.Mount("/", s.RepoRouter(mw)) 169 + 170 + // /{user}/{repo}/issues/* 171 + r.With(middleware.Paginate).Get("/issues", handler.RepoIssues(issue, repo, pages, db)) 172 + r.With(auth).Get("/issues/new", handler.NewIssue(repo, pages)) 173 + r.With(auth).Post("/issues/new", handler.NewIssuePost(issue, pages)) 174 + r.Route("/issues/{issue}", func(r chi.Router) { 175 + r.Use(middleware.ResolveIssue(db, pages)) 176 + 177 + r.Get("/", handler.Issue(issue, repo, pages, db)) 178 + r.Get("/opengraph", i.IssueOpenGraphSummary) 179 + 180 + r.With(auth).Delete("/", handler.IssueDelete(issue, pages)) 181 + 182 + r.With(auth).Get("/edit", handler.IssueEdit(issue, repo, pages)) 183 + r.With(auth).Post("/edit", handler.IssueEditPost(issue, pages)) 184 + 185 + r.With(auth).Post("/close", handler.CloseIssue(issue, pages)) 186 + r.With(auth).Post("/reopen", handler.ReopenIssue(issue, pages)) 187 + 188 + r.With(auth).Post("/comment", i.NewIssueComment) 189 + r.With(auth).Route("/comment/{commentId}/", func(r chi.Router) { 190 + r.Get("/", i.IssueComment) 191 + r.Delete("/", i.DeleteIssueComment) 192 + r.Get("/edit", i.EditIssueComment) 193 + r.Post("/edit", i.EditIssueComment) 194 + r.Get("/reply", i.ReplyIssueComment) 195 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 196 + }) 197 + }) 198 + 199 + r.Mount("/pulls", s.PullsRouter(mw)) 200 + r.Mount("/pipelines", s.PipelinesRouter()) 201 + r.Mount("/labels", s.LabelsRouter()) 202 + 203 + // These routes get proxied to the knot 204 + r.Get("/info/refs", s.InfoRefs) 205 + r.Post("/git-upload-pack", s.UploadPack) 206 + r.Post("/git-receive-pack", s.ReceivePack) 207 + }) 208 + }) 209 + 210 + r.NotFound(func(w http.ResponseWriter, r *http.Request) { 211 + pages.Error404(w) 212 + }) 213 + 214 + return r 215 + }
+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 }