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