diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index d77a8e8a..180ee0d9 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -28,7 +28,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 } @@ -54,7 +54,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..5c0f0f34 --- /dev/null +++ b/appview/service/issue/issue.go @@ -0,0 +1,275 @@ +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.Keyword != "" { + 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 { + openInt := 0 + if searchOpts.IsOpen { + openInt = 1 + } + issues, err = db.GetIssuesPaginated( + s.db, + searchOpts.Page, + orm.FilterEq("repo_at", repo.RepoAt()), + orm.FilterEq("open", openInt), + ) + 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..801cd1fb --- /dev/null +++ b/appview/service/repo/repo.go @@ -0,0 +1,94 @@ +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, + } +} + +// NewRepo creates a repository +// It expects atproto session to be passed in `ctx` +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..7d8e05c9 --- /dev/null +++ b/appview/session/session.go @@ -0,0 +1,11 @@ +package session + +import ( + "github.com/bluesky-social/indigo/atproto/client" + "tangled.org/core/appview/oauth" +) + +type Session struct { + User *oauth.MultiAccountUser // TODO: move MultiAccountUser def to here + AtpClient *client.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..83f2def4 --- /dev/null +++ b/appview/web/handler/user_repo_issues.go @@ -0,0 +1,357 @@ +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" + 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 + } + + query := r.URL.Query() + searchOpts := models.IssueSearchOptions{ + RepoAt: repo.RepoAt().String(), + Keyword: query.Get("q"), + IsOpen: query.Get("state") != "closed", + Page: pagination.FromContext(ctx), + } + + 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, + FilteringByOpen: searchOpts.IsOpen, + FilterQuery: searchOpts.Keyword, + 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) } }