From 69f17400eae500dc072604efaf0ee9dfe5acaaa7 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 12 Nov 2025 18:42:33 +0900 Subject: [PATCH] wip: appview/{service,web}: service layer Obviously file naming of appview/web/handler/*.go files are directly against to go convention. Though I think flattening all handler files can significantly reduce the effort involved in file naming and structuring. We are already grouping core services by domains, and doing same for web handers is just over-complicating. ``` - appview/web/routes.go : define all web page routes - appview/web/middleware.go : define middlewares related to web routes - appview/web/handler/*.go : http handlers, named as path pattern - appview/service/* : domain-level services ``` Each handlers are pure by receiving all required dependencies as parameters. Ideally we should not pass base dependencies like `db`, but that's how it works for now. Now we can test: - http handlers with mocked services/renderer - internal service logic without http handlers Signed-off-by: Seongmin Lee --- appview/oauth/handler.go | 4 +- appview/service/issue/errors.go | 12 + appview/service/issue/issue.go | 280 ++++++++++++++ appview/service/issue/state.go | 84 +++++ appview/service/repo/errors.go | 11 + appview/service/repo/repo.go | 92 +++++ appview/service/repo/repoinfo.go | 89 +++++ appview/session/context.go | 27 ++ appview/session/session.go | 11 + appview/state/legacy_bridge.go | 66 ++++ appview/web/handler/oauth.go | 34 ++ appview/web/handler/user_repo_issues.go | 390 ++++++++++++++++++++ appview/web/middleware/auth.go | 67 ++++ appview/web/middleware/ensuredidorhandle.go | 27 ++ appview/web/middleware/log.go | 18 + appview/web/middleware/middleware.go | 7 + appview/web/middleware/normalize.go | 49 +++ appview/web/middleware/paginate.go | 38 ++ appview/web/middleware/resolve.go | 121 ++++++ appview/web/readme.md | 53 +++ appview/web/request/context.go | 41 ++ appview/web/routes.go | 205 ++++++++++ cmd/appview/main.go | 3 +- 23 files changed, 1726 insertions(+), 3 deletions(-) create mode 100644 appview/service/issue/errors.go create mode 100644 appview/service/issue/issue.go create mode 100644 appview/service/issue/state.go create mode 100644 appview/service/repo/errors.go create mode 100644 appview/service/repo/repo.go create mode 100644 appview/service/repo/repoinfo.go create mode 100644 appview/session/context.go create mode 100644 appview/session/session.go create mode 100644 appview/state/legacy_bridge.go create mode 100644 appview/web/handler/oauth.go create mode 100644 appview/web/handler/user_repo_issues.go create mode 100644 appview/web/middleware/auth.go create mode 100644 appview/web/middleware/ensuredidorhandle.go create mode 100644 appview/web/middleware/log.go create mode 100644 appview/web/middleware/middleware.go create mode 100644 appview/web/middleware/normalize.go create mode 100644 appview/web/middleware/paginate.go create mode 100644 appview/web/middleware/resolve.go create mode 100644 appview/web/readme.md create mode 100644 appview/web/request/context.go create mode 100644 appview/web/routes.go diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index e2d700ac..55275233 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -32,7 +32,7 @@ func (o *OAuth) Router() http.Handler { r.Get("/oauth/client-metadata.json", o.clientMetadata) r.Get("/oauth/jwks.json", o.jwks) - r.Get("/oauth/callback", o.callback) + r.Get("/oauth/callback", o.Callback) return r } @@ -59,7 +59,7 @@ func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { } } -func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { +func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { ctx := r.Context() l := o.Logger.With("query", r.URL.Query()) diff --git a/appview/service/issue/errors.go b/appview/service/issue/errors.go new file mode 100644 index 00000000..e16b1ca5 --- /dev/null +++ b/appview/service/issue/errors.go @@ -0,0 +1,12 @@ +package issue + +import "errors" + +var ( + ErrUnAuthenticated = errors.New("user session missing") + ErrForbidden = errors.New("unauthorized operation") + ErrDatabaseFail = errors.New("db op fail") + ErrPDSFail = errors.New("pds op fail") + ErrIndexerFail = errors.New("indexer fail") + ErrValidationFail = errors.New("issue validation fail") +) diff --git a/appview/service/issue/issue.go b/appview/service/issue/issue.go new file mode 100644 index 00000000..6c61b69f --- /dev/null +++ b/appview/service/issue/issue.go @@ -0,0 +1,280 @@ +package issue + +import ( + "context" + "log/slog" + "time" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + issues_indexer "tangled.org/core/appview/indexer/issues" + "tangled.org/core/appview/mentions" + "tangled.org/core/appview/models" + "tangled.org/core/appview/notify" + "tangled.org/core/appview/session" + "tangled.org/core/appview/validator" + "tangled.org/core/idresolver" + "tangled.org/core/orm" + "tangled.org/core/rbac" + "tangled.org/core/tid" +) + +type Service struct { + config *config.Config + db *db.DB + enforcer *rbac.Enforcer + indexer *issues_indexer.Indexer + logger *slog.Logger + notifier notify.Notifier + idResolver *idresolver.Resolver + refResolver *mentions.Resolver + validator *validator.Validator +} + +func NewService( + logger *slog.Logger, + config *config.Config, + db *db.DB, + enforcer *rbac.Enforcer, + notifier notify.Notifier, + idResolver *idresolver.Resolver, + refResolver *mentions.Resolver, + indexer *issues_indexer.Indexer, + validator *validator.Validator, +) Service { + return Service{ + config, + db, + enforcer, + indexer, + logger, + notifier, + idResolver, + refResolver, + validator, + } +} + +func (s *Service) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) { + l := s.logger.With("method", "NewIssue") + sess, ok := session.FromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return nil, ErrForbidden + } + authorDid := syntax.DID(sess.User.Did) + atpclient := sess.AtpClient + l = l.With("did", authorDid) + + mentions, references := s.refResolver.Resolve(ctx, body) + + issue := models.Issue{ + Did: authorDid.String(), + Rkey: tid.TID(), + RepoAt: repo.RepoAt(), + Title: title, + Body: body, + Created: time.Now(), + Mentions: mentions, + References: references, + Open: true, + Repo: repo, + } + + if err := s.validator.ValidateIssue(&issue); err != nil { + l.Error("validation error", "err", err) + return nil, ErrValidationFail + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + l.Error("db.BeginTx failed", "err", err) + return nil, ErrDatabaseFail + } + defer tx.Rollback() + + if err := db.PutIssue(tx, &issue); err != nil { + l.Error("db.PutIssue failed", "err", err) + return nil, ErrDatabaseFail + } + + record := issue.AsRecord() + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ + Repo: issue.Did, + Collection: tangled.RepoIssueNSID, + Rkey: issue.Rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + if err != nil { + l.Error("atproto.RepoPutRecord failed", "err", err) + return nil, ErrPDSFail + } + if err = tx.Commit(); err != nil { + l.Error("tx.Commit failed", "err", err) + return nil, ErrDatabaseFail + } + + s.notifier.NewIssue(ctx, &issue, mentions) + return &issue, nil +} + +func (s *Service) GetIssues(ctx context.Context, repo *models.Repo, searchOpts models.IssueSearchOptions) ([]models.Issue, error) { + l := s.logger.With("method", "GetIssues") + + var issues []models.Issue + var err error + if searchOpts.HasSearchFilters() { + res, err := s.indexer.Search(ctx, searchOpts) + if err != nil { + l.Error("failed to search for issues", "err", err) + return nil, ErrIndexerFail + } + l.Debug("searched issues with indexer", "count", len(res.Hits)) + issues, err = db.GetIssues(s.db, orm.FilterIn("id", res.Hits)) + if err != nil { + l.Error("failed to get issues", "err", err) + return nil, ErrDatabaseFail + } + } else { + filters := []orm.Filter{ + orm.FilterEq("repo_at", repo.RepoAt()), + } + if searchOpts.IsOpen != nil { + openInt := 0 + if *searchOpts.IsOpen { + openInt = 1 + } + filters = append(filters, orm.FilterEq("open", openInt)) + } + issues, err = db.GetIssuesPaginated( + s.db, + searchOpts.Page, + filters..., + ) + if err != nil { + l.Error("failed to get issues", "err", err) + return nil, ErrDatabaseFail + } + } + + return issues, nil +} + +func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error { + l := s.logger.With("method", "EditIssue") + sess, ok := session.FromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return ErrForbidden + } + atpclient := sess.AtpClient + l = l.With("did", sess.User.Did) + + mentions, references := s.refResolver.Resolve(ctx, issue.Body) + issue.Mentions = mentions + issue.References = references + + if sess.User.Did != issue.Did { + l.Error("only author can edit the issue") + return ErrForbidden + } + + if err := s.validator.ValidateIssue(issue); err != nil { + l.Error("validation error", "err", err) + return ErrValidationFail + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + l.Error("db.BeginTx failed", "err", err) + return ErrDatabaseFail + } + defer tx.Rollback() + + if err := db.PutIssue(tx, issue); err != nil { + l.Error("db.PutIssue failed", "err", err) + return ErrDatabaseFail + } + + record := issue.AsRecord() + + ex, err := atproto.RepoGetRecord(ctx, atpclient, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) + if err != nil { + l.Error("atproto.RepoGetRecord failed", "err", err) + return ErrPDSFail + } + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ + Repo: issue.Did, + Collection: tangled.RepoIssueNSID, + Rkey: issue.Rkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + if err != nil { + l.Error("atproto.RepoPutRecord failed", "err", err) + return ErrPDSFail + } + + if err = tx.Commit(); err != nil { + l.Error("tx.Commit failed", "err", err) + return ErrDatabaseFail + } + + // TODO: notify EditIssue + + return nil +} + +func (s *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error { + l := s.logger.With("method", "DeleteIssue") + sess, ok := session.FromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return ErrForbidden + } + atpclient := sess.AtpClient + l = l.With("did", sess.User.Did) + + if sess.User.Did != issue.Did { + l.Error("only author can edit the issue") + return ErrForbidden + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + l.Error("db.BeginTx failed", "err", err) + return ErrDatabaseFail + } + defer tx.Rollback() + + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { + l.Error("db.DeleteIssues failed", "err", err) + return ErrDatabaseFail + } + + _, err = atproto.RepoDeleteRecord(ctx, atpclient, &atproto.RepoDeleteRecord_Input{ + Collection: tangled.RepoIssueNSID, + Repo: issue.Did, + Rkey: issue.Rkey, + }) + if err != nil { + l.Error("atproto.RepoDeleteRecord failed", "err", err) + return ErrPDSFail + } + + if err := tx.Commit(); err != nil { + l.Error("tx.Commit failed", "err", err) + return ErrDatabaseFail + } + + s.notifier.DeleteIssue(ctx, issue) + return nil +} diff --git a/appview/service/issue/state.go b/appview/service/issue/state.go new file mode 100644 index 00000000..ee714914 --- /dev/null +++ b/appview/service/issue/state.go @@ -0,0 +1,84 @@ +package issue + +import ( + "context" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages/repoinfo" + "tangled.org/core/appview/session" + "tangled.org/core/orm" +) + +func (s *Service) CloseIssue(ctx context.Context, issue *models.Issue) error { + l := s.logger.With("method", "CloseIssue") + sess, ok := session.FromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return ErrUnAuthenticated + } + sessDid := syntax.DID(sess.User.Did) + l = l.With("did", sessDid) + + // TODO: make this more granular + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} + isRepoOwner := roles.IsOwner() + isCollaborator := roles.IsCollaborator() + isIssueOwner := sessDid == syntax.DID(issue.Did) + if !(isRepoOwner || isCollaborator || isIssueOwner) { + l.Error("user is not authorized") + return ErrForbidden + } + + err := db.CloseIssues( + s.db, + orm.FilterEq("id", issue.Id), + ) + if err != nil { + l.Error("db.CloseIssues failed", "err", err) + return ErrDatabaseFail + } + + // change the issue state (this will pass down to the notifiers) + issue.Open = false + + s.notifier.NewIssueState(ctx, sessDid, issue) + return nil +} + +func (s *Service) ReopenIssue(ctx context.Context, issue *models.Issue) error { + l := s.logger.With("method", "ReopenIssue") + sess, ok := session.FromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return ErrUnAuthenticated + } + sessDid := syntax.DID(sess.User.Did) + l = l.With("did", sessDid) + + // TODO: make this more granular + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(sessDid.String(), issue.Repo.Knot, issue.Repo.DidSlashRepo())} + isRepoOwner := roles.IsOwner() + isCollaborator := roles.IsCollaborator() + isIssueOwner := sessDid == syntax.DID(issue.Did) + if !(isRepoOwner || isCollaborator || isIssueOwner) { + l.Error("user is not authorized") + return ErrForbidden + } + + err := db.ReopenIssues( + s.db, + orm.FilterEq("id", issue.Id), + ) + if err != nil { + l.Error("db.ReopenIssues failed", "err", err) + return ErrDatabaseFail + } + + // change the issue state (this will pass down to the notifiers) + issue.Open = true + + s.notifier.NewIssueState(ctx, sessDid, issue) + return nil +} diff --git a/appview/service/repo/errors.go b/appview/service/repo/errors.go new file mode 100644 index 00000000..c4c92ac1 --- /dev/null +++ b/appview/service/repo/errors.go @@ -0,0 +1,11 @@ +package repo + +import "errors" + +var ( + ErrUnAuthenticated = errors.New("user session missing") + ErrForbidden = errors.New("unauthorized operation") + ErrDatabaseFail = errors.New("db op fail") + ErrPDSFail = errors.New("pds op fail") + ErrValidationFail = errors.New("repo validation fail") +) diff --git a/appview/service/repo/repo.go b/appview/service/repo/repo.go new file mode 100644 index 00000000..467c8531 --- /dev/null +++ b/appview/service/repo/repo.go @@ -0,0 +1,92 @@ +package repo + +import ( + "context" + "log/slog" + "time" + + "github.com/bluesky-social/indigo/api/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/session" + "tangled.org/core/rbac" + "tangled.org/core/tid" +) + +type Service struct { + logger *slog.Logger + config *config.Config + db *db.DB + enforcer *rbac.Enforcer +} + +func NewService( + logger *slog.Logger, + config *config.Config, + db *db.DB, + enforcer *rbac.Enforcer, +) Service { + return Service{ + logger, + config, + db, + enforcer, + } +} + +func (s *Service) NewRepo(ctx context.Context, name, description, knot string) (*models.Repo, error) { + l := s.logger.With("method", "NewRepo") + sess, ok := session.FromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return nil, ErrForbidden + } + + atpclient := sess.AtpClient + l = l.With("did", sess.User.Did) + + repo := models.Repo{ + Did: sess.User.Did, + Name: name, + Knot: knot, + Rkey: tid.TID(), + Description: description, + Created: time.Now(), + Labels: s.config.Label.DefaultLabelDefs, + } + l = l.With("aturi", repo.RepoAt()) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + l.Error("db.BeginTx failed", "err", err) + return nil, ErrDatabaseFail + } + defer tx.Rollback() + + if err = db.AddRepo(tx, &repo); err != nil { + l.Error("db.AddRepo failed", "err", err) + return nil, ErrDatabaseFail + } + + record := repo.AsRecord() + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ + Repo: repo.Did, + Collection: tangled.RepoNSID, + Rkey: repo.Rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }) + if err != nil { + l.Error("atproto.RepoPutRecord failed", "err", err) + return nil, ErrPDSFail + } + l.Info("wrote to PDS") + + // knotclient, err := s.oauth.ServiceClient( + // ) + panic("unimplemented") +} diff --git a/appview/service/repo/repoinfo.go b/appview/service/repo/repoinfo.go new file mode 100644 index 00000000..8d38b772 --- /dev/null +++ b/appview/service/repo/repoinfo.go @@ -0,0 +1,89 @@ +package repo + +import ( + "context" + + "github.com/bluesky-social/indigo/atproto/identity" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages/repoinfo" + "tangled.org/core/appview/session" +) + +// MakeRepoInfo constructs [repoinfo.RepoInfo] object from given [models.Repo]. +// +// NOTE: [repoinfo.RepoInfo] is bad design and should be removed in future. +// Avoid using this method if you can. +func (s *Service) MakeRepoInfo( + ctx context.Context, + ownerId *identity.Identity, + baseRepo *models.Repo, + currentDir, ref string, +) repoinfo.RepoInfo { + var ( + repoAt = baseRepo.RepoAt() + isStarred = false + roles = repoinfo.RolesInRepo{} + l = s.logger.With("method", "MakeRepoInfo").With("repoAt", repoAt) + ) + sess, ok := session.FromContext(ctx) + if ok { + isStarred = db.GetStarStatus(s.db, sess.User.Did, repoAt) + roles.Roles = s.enforcer.GetPermissionsInRepo(sess.User.Did, baseRepo.Knot, baseRepo.DidSlashRepo()) + } + + stats := baseRepo.RepoStats + if stats == nil { + starCount, err := db.GetStarCount(s.db, repoAt) + if err != nil { + l.Error("failed to get star count", "err", err) + } + issueCount, err := db.GetIssueCount(s.db, repoAt) + if err != nil { + l.Error("failed to get issue count", "err", err) + } + pullCount, err := db.GetPullCount(s.db, repoAt) + if err != nil { + l.Error("failed to get pull count", "err", err) + } + stats = &models.RepoStats{ + StarCount: starCount, + IssueCount: issueCount, + PullCount: pullCount, + } + } + + var sourceRepo *models.Repo + var err error + if baseRepo.Source != "" { + sourceRepo, err = db.GetRepoByAtUri(s.db, baseRepo.Source) + if err != nil { + l.Error("failed to get source repo", "source", baseRepo.Source, "err", err) + } + } + + return repoinfo.RepoInfo{ + // this is basically a models.Repo + OwnerDid: baseRepo.Did, + OwnerHandle: ownerId.Handle.String(), // TODO: shouldn't use + Name: baseRepo.Name, + Rkey: baseRepo.Rkey, + Description: baseRepo.Description, + Website: baseRepo.Website, + Topics: baseRepo.Topics, + Knot: baseRepo.Knot, + Spindle: baseRepo.Spindle, + Stats: *stats, + + // fork repo upstream + Source: sourceRepo, + + // repo path (context) + CurrentDir: currentDir, + Ref: ref, + + // info related to the session + IsStarred: isStarred, + Roles: roles, + } +} diff --git a/appview/session/context.go b/appview/session/context.go new file mode 100644 index 00000000..09c1da4b --- /dev/null +++ b/appview/session/context.go @@ -0,0 +1,27 @@ +package session + +import ( + "context" + + "tangled.org/core/appview/oauth" +) + +type ctxKey struct{} + +func IntoContext(ctx context.Context, sess Session) context.Context { + return context.WithValue(ctx, ctxKey{}, &sess) +} + +func FromContext(ctx context.Context) (*Session, bool) { + sess, ok := ctx.Value(ctxKey{}).(*Session) + return sess, ok +} + +// UserFromContext returns optional MultiAccountUser from context. +func UserFromContext(ctx context.Context) *oauth.MultiAccountUser { + sess, ok := ctx.Value(ctxKey{}).(*Session) + if !ok { + return nil + } + return sess.User +} diff --git a/appview/session/session.go b/appview/session/session.go new file mode 100644 index 00000000..08ab2c08 --- /dev/null +++ b/appview/session/session.go @@ -0,0 +1,11 @@ +package session + +import ( + "github.com/bluesky-social/indigo/atproto/atclient" + "tangled.org/core/appview/oauth" +) + +type Session struct { + User *oauth.MultiAccountUser // TODO: move MultiAccountUser def to here + AtpClient *atclient.APIClient +} diff --git a/appview/state/legacy_bridge.go b/appview/state/legacy_bridge.go new file mode 100644 index 00000000..048a9292 --- /dev/null +++ b/appview/state/legacy_bridge.go @@ -0,0 +1,66 @@ +package state + +import ( + "log/slog" + + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/appview/indexer" + "tangled.org/core/appview/issues" + "tangled.org/core/appview/mentions" + "tangled.org/core/appview/middleware" + "tangled.org/core/appview/notify" + "tangled.org/core/appview/oauth" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/validator" + "tangled.org/core/idresolver" + "tangled.org/core/log" + "tangled.org/core/rbac" +) + +// Expose exposes private fields in `State`. This is used to bridge between +// legacy web routers and new architecture +func (s *State) Expose() ( + *config.Config, + *db.DB, + *rbac.Enforcer, + *idresolver.Resolver, + *mentions.Resolver, + *indexer.Indexer, + *slog.Logger, + notify.Notifier, + *oauth.OAuth, + *pages.Pages, + *validator.Validator, +) { + return s.config, s.db, s.enforcer, s.idResolver, s.mentionsResolver, s.indexer, s.logger, s.notifier, s.oauth, s.pages, s.validator +} + +func (s *State) ExposeIssue() *issues.Issues { + return issues.New( + s.oauth, + s.repoResolver, + s.enforcer, + s.pages, + s.idResolver, + s.mentionsResolver, + s.db, + s.config, + s.notifier, + s.validator, + s.indexer.Issues, + log.SubLogger(s.logger, "issues"), + ) +} + +func (s *State) Middleware() *middleware.Middleware { + mw := middleware.New( + s.oauth, + s.db, + s.enforcer, + s.repoResolver, + s.idResolver, + s.pages, + ) + return &mw +} diff --git a/appview/web/handler/oauth.go b/appview/web/handler/oauth.go new file mode 100644 index 00000000..8de254e5 --- /dev/null +++ b/appview/web/handler/oauth.go @@ -0,0 +1,34 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "tangled.org/core/appview/oauth" +) + +func OauthClientMetadata(o *oauth.OAuth) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + doc := o.ClientApp.Config.ClientMetadata() + doc.JWKSURI = &o.JwksUri + doc.ClientName = &o.ClientName + doc.ClientURI = &o.ClientUri + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func OauthJwks(o *oauth.OAuth) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := o.ClientApp.Config.PublicJWKS() + if err := json.NewEncoder(w).Encode(body); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} diff --git a/appview/web/handler/user_repo_issues.go b/appview/web/handler/user_repo_issues.go new file mode 100644 index 00000000..cbe2712f --- /dev/null +++ b/appview/web/handler/user_repo_issues.go @@ -0,0 +1,390 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/pagination" + "tangled.org/core/appview/reporesolver" + "tangled.org/core/appview/searchquery" + isvc "tangled.org/core/appview/service/issue" + rsvc "tangled.org/core/appview/service/repo" + "tangled.org/core/appview/session" + "tangled.org/core/appview/web/request" + "tangled.org/core/log" + "tangled.org/core/orm" +) + +func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "RepoIssues") + repo, ok := request.RepoFromContext(ctx) + if !ok { + l.Error("malformed request") + p.Error503(w) + return + } + repoOwnerId, ok := request.OwnerFromContext(ctx) + if !ok { + l.Error("malformed request") + p.Error503(w) + return + } + + params := r.URL.Query() + page := pagination.FromContext(r.Context()) + + query := searchquery.Parse(params.Get("q")) + + // resolve := func(ctx context.Context, ident string) (string, error) { + // id, err := s.idResolver.ResolveIdent(ctx, ident) + // if err != nil { + // return "", err + // } + // return id.DID.String(), nil + // } + + // authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) + + labels := query.GetAll("label") + negatedLabels := query.GetAllNegated("label") + labelValues := query.GetDynamicTags() + negatedLabelValues := query.GetNegatedDynamicTags() + + tf := searchquery.ExtractTextFilters(query) + + isOpen := true + + searchOpts := models.IssueSearchOptions{ + Keywords: tf.Keywords, + Phrases: tf.Phrases, + RepoAt: repo.RepoAt().String(), + IsOpen: &isOpen, + AuthorDid: "", + Labels: labels, + LabelValues: labelValues, + NegatedKeywords: tf.NegatedKeywords, + NegatedPhrases: tf.NegatedPhrases, + NegatedLabels: negatedLabels, + NegatedLabelValues: negatedLabelValues, + NegatedAuthorDids: nil, + Page: page, + } + + issues, err := is.GetIssues(ctx, repo, searchOpts) + if err != nil { + l.Error("failed to get issues") + p.Error503(w) + return + } + + // render page + err = func() error { + labelDefs, err := db.GetLabelDefinitions( + d, + orm.FilterIn("at_uri", repo.Labels), + orm.FilterContains("scope", tangled.RepoIssueNSID), + ) + if err != nil { + return err + } + defs := make(map[string]*models.LabelDefinition) + for _, l := range labelDefs { + defs[l.AtUri().String()] = &l + } + return p.RepoIssues(w, pages.RepoIssuesParams{ + LoggedInUser: session.UserFromContext(ctx), + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, repo, "", ""), + + Issues: issues, + LabelDefs: defs, + FilterState: "open", + FilterQuery: query.String(), + Page: searchOpts.Page, + }) + }() + if err != nil { + l.Error("failed to render", "err", err) + p.Error503(w) + return + } + } +} + +func Issue(s isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "Issue") + issue, ok := request.IssueFromContext(ctx) + if !ok { + l.Error("malformed request, failed to get issue") + p.Error503(w) + return + } + repoOwnerId, ok := request.OwnerFromContext(ctx) + if !ok { + l.Error("malformed request") + p.Error503(w) + return + } + + // render + err := func() error { + reactionMap, err := db.GetReactionMap(d, 20, issue.AtUri()) + if err != nil { + l.Error("failed to get issue reactions", "err", err) + return err + } + + userReactions := map[models.ReactionKind]bool{} + if sess, ok := session.FromContext(ctx); ok { + userReactions = db.GetReactionStatusMap(d, sess.User.Did, issue.AtUri()) + } + + backlinks, err := db.GetBacklinks(d, issue.AtUri()) + if err != nil { + l.Error("failed to fetch backlinks", "err", err) + return err + } + + labelDefs, err := db.GetLabelDefinitions( + d, + orm.FilterIn("at_uri", issue.Repo.Labels), + orm.FilterContains("scope", tangled.RepoIssueNSID), + ) + if err != nil { + l.Error("failed to fetch label defs", "err", err) + return err + } + + defs := make(map[string]*models.LabelDefinition) + for _, l := range labelDefs { + defs[l.AtUri().String()] = &l + } + + return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{ + LoggedInUser: session.UserFromContext(ctx), + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, issue.Repo, "", ""), + Issue: issue, + CommentList: issue.CommentList(), + Backlinks: backlinks, + Reactions: reactionMap, + UserReacted: userReactions, + LabelDefs: defs, + }) + }() + if err != nil { + l.Error("failed to render", "err", err) + p.Error503(w) + return + } + } +} + +func NewIssue(rs rsvc.Service, p *pages.Pages) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "NewIssue") + + // render + err := func() error { + repo, ok := request.RepoFromContext(ctx) + if !ok { + return fmt.Errorf("malformed request") + } + repoOwnerId, ok := request.OwnerFromContext(ctx) + if !ok { + return fmt.Errorf("malformed request") + } + return p.RepoNewIssue(w, pages.RepoNewIssueParams{ + LoggedInUser: session.UserFromContext(ctx), + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, repo, "", ""), + }) + }() + if err != nil { + l.Error("failed to render", "err", err) + p.Error503(w) + return + } + } +} + +func NewIssuePost(is isvc.Service, p *pages.Pages) http.HandlerFunc { + noticeId := "issues" + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "NewIssuePost") + repo, ok := request.RepoFromContext(ctx) + if !ok { + l.Error("malformed request, failed to get repo") + // TODO: 503 error with more detailed messages + p.Error503(w) + return + } + var ( + title = r.FormValue("title") + body = r.FormValue("body") + ) + + issue, err := is.NewIssue(ctx, repo, title, body) + if err != nil { + if errors.Is(err, isvc.ErrDatabaseFail) { + p.Notice(w, noticeId, "Failed to create issue.") + } else if errors.Is(err, isvc.ErrPDSFail) { + p.Notice(w, noticeId, "Failed to create issue.") + } else { + p.Notice(w, noticeId, "Failed to create issue.") + } + return + } + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) + } +} + +func IssueEdit(is isvc.Service, rs rsvc.Service, p *pages.Pages) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "IssueEdit") + issue, ok := request.IssueFromContext(ctx) + if !ok { + l.Error("malformed request, failed to get issue") + p.Error503(w) + return + } + repoOwnerId, ok := request.OwnerFromContext(ctx) + if !ok { + l.Error("malformed request") + p.Error503(w) + return + } + + // render + err := func() error { + return p.EditIssueFragment(w, pages.EditIssueParams{ + LoggedInUser: session.UserFromContext(ctx), + RepoInfo: rs.MakeRepoInfo(ctx, repoOwnerId, issue.Repo, "", ""), + + Issue: issue, + }) + }() + if err != nil { + l.Error("failed to render", "err", err) + p.Error503(w) + return + } + } +} + +func IssueEditPost(is isvc.Service, p *pages.Pages) http.HandlerFunc { + noticeId := "issues" + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "IssueEdit") + issue, ok := request.IssueFromContext(ctx) + if !ok { + l.Error("malformed request, failed to get issue") + p.Error503(w) + return + } + + newIssue := *issue + newIssue.Title = r.FormValue("title") + newIssue.Body = r.FormValue("body") + + err := is.EditIssue(ctx, &newIssue) + if err != nil { + if errors.Is(err, isvc.ErrDatabaseFail) { + p.Notice(w, noticeId, "Failed to edit issue.") + } else if errors.Is(err, isvc.ErrPDSFail) { + p.Notice(w, noticeId, "Failed to edit issue.") + } else { + p.Notice(w, noticeId, "Failed to edit issue.") + } + return + } + + p.HxRefresh(w) + } +} + +func CloseIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { + noticeId := "issue-action" + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "CloseIssue") + issue, ok := request.IssueFromContext(ctx) + if !ok { + l.Error("malformed request, failed to get issue") + p.Error503(w) + return + } + + err := is.CloseIssue(ctx, issue) + if err != nil { + if errors.Is(err, isvc.ErrForbidden) { + http.Error(w, "forbidden", http.StatusUnauthorized) + } else { + p.Notice(w, noticeId, "Failed to close issue. Try again later.") + } + return + } + + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) + } +} + +func ReopenIssue(is isvc.Service, p *pages.Pages) http.HandlerFunc { + noticeId := "issue-action" + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "ReopenIssue") + issue, ok := request.IssueFromContext(ctx) + if !ok { + l.Error("malformed request, failed to get issue") + p.Error503(w) + return + } + + err := is.ReopenIssue(ctx, issue) + if err != nil { + if errors.Is(err, isvc.ErrForbidden) { + http.Error(w, "forbidden", http.StatusUnauthorized) + } else { + p.Notice(w, noticeId, "Failed to reopen issue. Try again later.") + } + return + } + + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, issue.Repo) + p.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) + } +} + +func IssueDelete(s isvc.Service, p *pages.Pages) http.HandlerFunc { + noticeId := "issue-actions-error" + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx).With("handler", "IssueDelete") + issue, ok := request.IssueFromContext(ctx) + if !ok { + l.Error("failed to get issue") + // TODO: 503 error with more detailed messages + p.Error503(w) + return + } + err := s.DeleteIssue(ctx, issue) + if err != nil { + p.Notice(w, noticeId, "failed to delete issue") + return + } + p.HxLocation(w, "/") + } +} diff --git a/appview/web/middleware/auth.go b/appview/web/middleware/auth.go new file mode 100644 index 00000000..2779bc63 --- /dev/null +++ b/appview/web/middleware/auth.go @@ -0,0 +1,67 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/url" + + "tangled.org/core/appview/oauth" + "tangled.org/core/appview/session" + "tangled.org/core/log" +) + +// WithSession resumes atp session from cookie, ensure it's not malformed and +// pass the session through context +func WithSession(o *oauth.OAuth) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atSess, err := o.ResumeSession(r) + if err != nil { + next.ServeHTTP(w, r) + return + } + + registry := o.GetAccounts(r) + sess := session.Session{ + User: &oauth.MultiAccountUser{ + Did: atSess.Data.AccountDID.String(), + Accounts: registry.Accounts, + }, + AtpClient: atSess.APIClient(), + } + ctx := session.IntoContext(r.Context(), sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// AuthMiddleware ensures the request is authorized and redirect to login page +// when unauthorized +func AuthMiddleware() middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx) + + returnURL := "/" + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { + returnURL = u.RequestURI() + } + + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) + + if _, ok := session.FromContext(ctx); !ok { + l.Debug("no session, redirecting...") + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", loginURL) + w.WriteHeader(http.StatusOK) + } else { + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) + } + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/appview/web/middleware/ensuredidorhandle.go b/appview/web/middleware/ensuredidorhandle.go new file mode 100644 index 00000000..0add3d9d --- /dev/null +++ b/appview/web/middleware/ensuredidorhandle.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/state/userutil" +) + +// EnsureDidOrHandle ensures the "user" url param is valid did/handle format. +// If not, respond with 404 +func EnsureDidOrHandle(p *pages.Pages) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := chi.URLParam(r, "user") + + // if using a DID or handle, just continue as per usual + if userutil.IsDid(user) || userutil.IsHandle(user) { + next.ServeHTTP(w, r) + return + } + + p.Error404(w) + }) + } +} diff --git a/appview/web/middleware/log.go b/appview/web/middleware/log.go new file mode 100644 index 00000000..4311c6c9 --- /dev/null +++ b/appview/web/middleware/log.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "log/slog" + "net/http" + + "tangled.org/core/log" +) + +func WithLogger(l *slog.Logger) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // NOTE: can add some metadata here + ctx := log.IntoContext(r.Context(), l) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/appview/web/middleware/middleware.go b/appview/web/middleware/middleware.go new file mode 100644 index 00000000..1f8e88a6 --- /dev/null +++ b/appview/web/middleware/middleware.go @@ -0,0 +1,7 @@ +package middleware + +import ( + "net/http" +) + +type middlewareFunc func(http.Handler) http.Handler diff --git a/appview/web/middleware/normalize.go b/appview/web/middleware/normalize.go new file mode 100644 index 00000000..28de2bca --- /dev/null +++ b/appview/web/middleware/normalize.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/state/userutil" +) + +func Normalize() middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pat := chi.URLParam(r, "*") + pathParts := strings.SplitN(pat, "/", 2) + if len(pathParts) == 0 { + next.ServeHTTP(w, r) + return + } + + firstPart := pathParts[0] + + // if using a flattened DID (like you would in go modules), unflatten + if userutil.IsFlattenedDid(firstPart) { + unflattenedDid := userutil.UnflattenDid(firstPart) + redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") + + redirectURL := *r.URL + redirectURL.Path = "/" + redirectPath + + http.Redirect(w, r, redirectURL.String(), http.StatusFound) + return + } + + // if using a handle with @, rewrite to work without @ + if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { + redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") + + redirectURL := *r.URL + redirectURL.Path = "/" + redirectPath + + http.Redirect(w, r, redirectURL.String(), http.StatusFound) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/appview/web/middleware/paginate.go b/appview/web/middleware/paginate.go new file mode 100644 index 00000000..877a47ea --- /dev/null +++ b/appview/web/middleware/paginate.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "log" + "net/http" + "strconv" + + "tangled.org/core/appview/pagination" +) + +func Paginate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := pagination.FirstPage() + + offsetVal := r.URL.Query().Get("offset") + if offsetVal != "" { + offset, err := strconv.Atoi(offsetVal) + if err != nil { + log.Println("invalid offset") + } else { + page.Offset = offset + } + } + + limitVal := r.URL.Query().Get("limit") + if limitVal != "" { + limit, err := strconv.Atoi(limitVal) + if err != nil { + log.Println("invalid limit") + } else { + page.Limit = limit + } + } + + ctx := pagination.IntoContext(r.Context(), page) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/appview/web/middleware/resolve.go b/appview/web/middleware/resolve.go new file mode 100644 index 00000000..4c29b57c --- /dev/null +++ b/appview/web/middleware/resolve.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "context" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/db" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/web/request" + "tangled.org/core/idresolver" + "tangled.org/core/log" + "tangled.org/core/orm" +) + +func ResolveIdent( + idResolver *idresolver.Resolver, + pages *pages.Pages, +) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx) + didOrHandle := chi.URLParam(r, "user") + didOrHandle = strings.TrimPrefix(didOrHandle, "@") + + id, err := idResolver.ResolveIdent(ctx, didOrHandle) + if err != nil { + // invalid did or handle + l.Warn("failed to resolve did/handle", "handle", didOrHandle, "err", err) + pages.Error404(w) + return + } + + ctx = request.WithOwner(ctx, id) + // TODO: reomove this later + ctx = context.WithValue(ctx, "resolvedId", *id) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func ResolveRepo( + e *db.DB, + pages *pages.Pages, +) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx) + repoName := chi.URLParam(r, "repo") + repoOwner, ok := request.OwnerFromContext(ctx) + if !ok { + l.Error("malformed middleware") + w.WriteHeader(http.StatusInternalServerError) + return + } + + repo, err := db.GetRepo( + e, + orm.FilterEq("did", repoOwner.DID.String()), + orm.FilterEq("name", repoName), + ) + if err != nil { + l.Warn("failed to resolve repo", "err", err) + pages.ErrorKnot404(w) + return + } + + // TODO: pass owner id into repository object + + ctx = request.WithRepo(ctx, repo) + // TODO: reomove this later + ctx = context.WithValue(ctx, "repo", repo) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func ResolveIssue( + e *db.DB, + pages *pages.Pages, +) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l := log.FromContext(ctx) + issueIdStr := chi.URLParam(r, "issue") + issueId, err := strconv.Atoi(issueIdStr) + if err != nil { + l.Warn("failed to fully resolve issue ID", "err", err) + pages.Error404(w) + return + } + repo, ok := request.RepoFromContext(ctx) + if !ok { + l.Error("malformed middleware") + w.WriteHeader(http.StatusInternalServerError) + return + } + + issue, err := db.GetIssue(e, repo.RepoAt(), issueId) + if err != nil { + l.Warn("failed to resolve issue", "err", err) + pages.ErrorKnot404(w) + return + } + issue.Repo = repo + + ctx = request.WithIssue(ctx, issue) + // TODO: reomove this later + ctx = context.WithValue(ctx, "issue", issue) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/appview/web/readme.md b/appview/web/readme.md new file mode 100644 index 00000000..105ca3ad --- /dev/null +++ b/appview/web/readme.md @@ -0,0 +1,53 @@ +# appview/web + +## package structure + +``` +web/ + |- routes.go + |- handler/ + | |- xrpc/ + |- middleware/ + |- request/ +``` + +- `web/routes.go` : all possible routes defined in single file +- `web/handler` : general http handlers +- `web/handler/xrpc` : xrpc handlers +- `web/middleware` : all middlwares +- `web/request` : define methods to insert/fetch values from request context. shared between middlewares and handlers. + +### file name convention on `web/handler` + +- Follow the absolute uri path of the handlers (replace `/` to `_`.) +- Trailing path segments can be omitted. +- Avoid conflicts between prefix and names. + - e.g. using both `user_repo_pulls.go` and `user_repo_pulls_rounds.go` (with `user_repo_pulls_` prefix) + +### handler-generators instead of raw handler function + +instead of: +```go +type Handler struct { + is isvc.Service + rs rsvc.Service +} +func (h *Handler) RepoIssues(w http.ResponseWriter, r *http.Request) { + // ... +} +``` + +prefer: +```go +func RepoIssues(is isvc.Service, rs rsvc.Service, p *pages.Pages, d *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // ... + } +} +``` + +Pass dependencies to each handler-generators and avoid creating structs with shared dependencies unless it serves somedomain-specific roles like `service/issue.Service`. Same rule applies to middlewares too. + +This pattern is inspired by [the grafana blog post](https://grafana.com/blog/how-i-write-http-services-in-go-after-13-years/#maker-funcs-return-the-handler). + +Function name can be anything as long as it is clear. diff --git a/appview/web/request/context.go b/appview/web/request/context.go new file mode 100644 index 00000000..6f64af25 --- /dev/null +++ b/appview/web/request/context.go @@ -0,0 +1,41 @@ +package request + +import ( + "context" + + "github.com/bluesky-social/indigo/atproto/identity" + "tangled.org/core/appview/models" +) + +type ( + ctxKeyOwner struct{} + ctxKeyRepo struct{} + ctxKeyIssue struct{} +) + +func WithOwner(ctx context.Context, owner *identity.Identity) context.Context { + return context.WithValue(ctx, ctxKeyOwner{}, owner) +} + +func OwnerFromContext(ctx context.Context) (*identity.Identity, bool) { + owner, ok := ctx.Value(ctxKeyOwner{}).(*identity.Identity) + return owner, ok +} + +func WithRepo(ctx context.Context, repo *models.Repo) context.Context { + return context.WithValue(ctx, ctxKeyRepo{}, repo) +} + +func RepoFromContext(ctx context.Context) (*models.Repo, bool) { + repo, ok := ctx.Value(ctxKeyRepo{}).(*models.Repo) + return repo, ok +} + +func WithIssue(ctx context.Context, issue *models.Issue) context.Context { + return context.WithValue(ctx, ctxKeyIssue{}, issue) +} + +func IssueFromContext(ctx context.Context) (*models.Issue, bool) { + issue, ok := ctx.Value(ctxKeyIssue{}).(*models.Issue) + return issue, ok +} diff --git a/appview/web/routes.go b/appview/web/routes.go new file mode 100644 index 00000000..d1323a4f --- /dev/null +++ b/appview/web/routes.go @@ -0,0 +1,205 @@ +package web + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/appview/indexer" + "tangled.org/core/appview/mentions" + "tangled.org/core/appview/notify" + "tangled.org/core/appview/oauth" + "tangled.org/core/appview/pages" + isvc "tangled.org/core/appview/service/issue" + rsvc "tangled.org/core/appview/service/repo" + "tangled.org/core/appview/state" + "tangled.org/core/appview/validator" + "tangled.org/core/appview/web/handler" + "tangled.org/core/appview/web/middleware" + "tangled.org/core/idresolver" + "tangled.org/core/rbac" +) + +// RouterFromState creates a web router from `state.State`. This exist to +// bridge between legacy web routers under `State` and new architecture +func RouterFromState(s *state.State) http.Handler { + config, db, enforcer, idResolver, refResolver, indexer, logger, notifier, oauth, pages, validator := s.Expose() + + return Router( + logger, + config, + db, + enforcer, + idResolver, + refResolver, + indexer, + notifier, + oauth, + pages, + validator, + s, + ) +} + +func Router( + // NOTE: put base dependencies (db, idResolver, oauth etc) + logger *slog.Logger, + config *config.Config, + db *db.DB, + enforcer *rbac.Enforcer, + idResolver *idresolver.Resolver, + mentionsResolver *mentions.Resolver, + indexer *indexer.Indexer, + notifier notify.Notifier, + oauth *oauth.OAuth, + pages *pages.Pages, + validator *validator.Validator, + // to use legacy web handlers. will be removed later + s *state.State, +) http.Handler { + repo := rsvc.NewService( + logger, + config, + db, + enforcer, + ) + issue := isvc.NewService( + logger, + config, + db, + enforcer, + notifier, + idResolver, + mentionsResolver, + indexer.Issues, + validator, + ) + + i := s.ExposeIssue() + + r := chi.NewRouter() + + mw := s.Middleware() + auth := middleware.AuthMiddleware() + + r.Use(middleware.WithLogger(logger)) + r.Use(middleware.WithSession(oauth)) + + r.Use(middleware.Normalize()) + + r.Get("/pwa-manifest.json", s.WebAppManifest) + r.Get("/robots.txt", s.RobotsTxt) + + r.Handle("/static/*", pages.Static()) + + r.Get("/", s.HomeOrTimeline) + r.Get("/timeline", s.Timeline) + r.Get("/upgradeBanner", s.UpgradeBanner) + + r.Get("/terms", s.TermsOfService) + r.Get("/privacy", s.PrivacyPolicy) + r.Get("/brand", s.Brand) + // special-case handler for serving tangled.org/core + r.Get("/core", s.Core()) + + r.Get("/login", s.Login) + r.Post("/login", s.Login) + r.Post("/logout", s.Logout) + + r.Get("/goodfirstissues", s.GoodFirstIssues) + + r.With(auth).Get("/repo/new", s.NewRepo) + r.With(auth).Post("/repo/new", s.NewRepo) + + r.With(auth).Post("/follow", s.Follow) + r.With(auth).Delete("/follow", s.Follow) + + r.With(auth).Post("/star", s.Star) + r.With(auth).Delete("/star", s.Star) + + r.With(auth).Post("/react", s.React) + r.With(auth).Delete("/react", s.React) + + r.With(auth).Get("/profile/edit-bio", s.EditBioFragment) + r.With(auth).Get("/profile/edit-pins", s.EditPinsFragment) + r.With(auth).Post("/profile/bio", s.UpdateProfileBio) + r.With(auth).Post("/profile/pins", s.UpdateProfilePins) + + r.Mount("/settings", s.SettingsRouter()) + r.Mount("/strings", s.StringsRouter(mw)) + r.Mount("/settings/knots", s.KnotsRouter()) + r.Mount("/settings/spindles", s.SpindlesRouter()) + r.Mount("/notifications", s.NotificationsRouter(mw)) + + r.Mount("/signup", s.SignupRouter()) + r.Get("/oauth/client-metadata.json", handler.OauthClientMetadata(oauth)) + r.Get("/oauth/jwks.json", handler.OauthJwks(oauth)) + r.Get("/oauth/callback", oauth.Callback) + + // special-case handler. should replace with xrpc later + r.Get("/keys/{user}", s.Keys) + + r.HandleFunc("/@*", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/"+chi.URLParam(r, "*"), http.StatusFound) + }) + + r.Route("/{user}", func(r chi.Router) { + r.Use(middleware.EnsureDidOrHandle(pages)) + r.Use(middleware.ResolveIdent(idResolver, pages)) + + r.Get("/", s.Profile) + r.Get("/feed.atom", s.AtomFeedPage) + + r.Route("/{repo}", func(r chi.Router) { + r.Use(middleware.ResolveRepo(db, pages)) + + r.Mount("/", s.RepoRouter(mw)) + + // /{user}/{repo}/issues/* + r.With(middleware.Paginate).Get("/issues", handler.RepoIssues(issue, repo, pages, db)) + r.With(auth).Get("/issues/new", handler.NewIssue(repo, pages)) + r.With(auth).Post("/issues/new", handler.NewIssuePost(issue, pages)) + r.Route("/issues/{issue}", func(r chi.Router) { + r.Use(middleware.ResolveIssue(db, pages)) + + r.Get("/", handler.Issue(issue, repo, pages, db)) + r.Get("/opengraph", i.IssueOpenGraphSummary) + + r.With(auth).Delete("/", handler.IssueDelete(issue, pages)) + + r.With(auth).Get("/edit", handler.IssueEdit(issue, repo, pages)) + r.With(auth).Post("/edit", handler.IssueEditPost(issue, pages)) + + r.With(auth).Post("/close", handler.CloseIssue(issue, pages)) + r.With(auth).Post("/reopen", handler.ReopenIssue(issue, pages)) + + r.With(auth).Post("/comment", i.NewIssueComment) + r.With(auth).Route("/comment/{commentId}/", func(r chi.Router) { + r.Get("/", i.IssueComment) + r.Delete("/", i.DeleteIssueComment) + r.Get("/edit", i.EditIssueComment) + r.Post("/edit", i.EditIssueComment) + r.Get("/reply", i.ReplyIssueComment) + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) + }) + }) + + r.Mount("/pulls", s.PullsRouter(mw)) + r.Mount("/pipelines", s.PipelinesRouter(mw)) + r.Mount("/labels", s.LabelsRouter()) + + // These routes get proxied to the knot + r.Get("/info/refs", s.InfoRefs) + r.Post("/git-upload-pack", s.UploadPack) + r.Post("/git-receive-pack", s.ReceivePack) + }) + }) + + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + pages.Error404(w) + }) + + return r +} diff --git a/cmd/appview/main.go b/cmd/appview/main.go index ae63a5f9..9d2111c4 100644 --- a/cmd/appview/main.go +++ b/cmd/appview/main.go @@ -7,6 +7,7 @@ import ( "tangled.org/core/appview/config" "tangled.org/core/appview/state" + "tangled.org/core/appview/web" tlog "tangled.org/core/log" ) @@ -35,7 +36,7 @@ func main() { logger.Info("starting server", "address", c.Core.ListenAddr) - if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { + if err := http.ListenAndServe(c.Core.ListenAddr, web.RouterFromState(state)); err != nil { logger.Error("failed to start appview", "err", err) } } -- 2.51.2