From cd2ebf418f595396c50c183d3369bc5a5a810ea2 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 12 Nov 2025 18:42:33 +0900 Subject: [PATCH] draft: appview: service layer Change-Id: uvpzuszrulvqkmnoqqwrwzqkxqykslzm Obviously file naming of appview/web/handler/*.go files are directly against to go convention. Though I think flattening all handler files can significantly reduce the effort involved in file naming and structuring. We are already grouping core services by domains, and doing same for web handers is just over-complicating. Signed-off-by: Seongmin Lee --- appview/service/issue/context.go | 18 ++ appview/service/issue/issue.go | 213 ++++++++++++++++++ appview/service/issue/state.go | 15 ++ appview/service/owner/context.go | 18 ++ appview/service/repo/context.go | 18 ++ appview/service/repo/repo.go | 71 ++++++ appview/state/router.go | 12 + appview/web/handler/issues.go | 12 + appview/web/handler/issues_issue.go | 35 +++ appview/web/handler/issues_issue_close.go | 13 ++ appview/web/handler/issues_issue_edit.go | 19 ++ appview/web/handler/issues_issue_opengraph.go | 13 ++ appview/web/handler/issues_issue_reopen.go | 13 ++ appview/web/handler/issues_new.go | 48 ++++ appview/web/handler/repos_new.go | 1 + appview/web/handler/repos_repo.go | 2 + appview/web/handler/repos_repo_opengraph.go | 2 + appview/web/middleware/auth.go | 51 +++++ appview/web/middleware/log.go | 18 ++ appview/web/middleware/middleware.go | 7 + appview/web/middleware/paginate.go | 38 ++++ appview/web/middleware/resolve.go | 114 ++++++++++ appview/web/routes.go | 88 ++++++++ 23 files changed, 839 insertions(+) create mode 100644 appview/service/issue/context.go create mode 100644 appview/service/issue/issue.go create mode 100644 appview/service/issue/state.go create mode 100644 appview/service/owner/context.go create mode 100644 appview/service/repo/context.go create mode 100644 appview/service/repo/repo.go create mode 100644 appview/web/handler/issues.go create mode 100644 appview/web/handler/issues_issue.go create mode 100644 appview/web/handler/issues_issue_close.go create mode 100644 appview/web/handler/issues_issue_edit.go create mode 100644 appview/web/handler/issues_issue_opengraph.go create mode 100644 appview/web/handler/issues_issue_reopen.go create mode 100644 appview/web/handler/issues_new.go create mode 100644 appview/web/handler/repos_new.go create mode 100644 appview/web/handler/repos_repo.go create mode 100644 appview/web/handler/repos_repo_opengraph.go create mode 100644 appview/web/middleware/auth.go create mode 100644 appview/web/middleware/log.go create mode 100644 appview/web/middleware/middleware.go create mode 100644 appview/web/middleware/paginate.go create mode 100644 appview/web/middleware/resolve.go create mode 100644 appview/web/routes.go diff --git a/appview/service/issue/context.go b/appview/service/issue/context.go new file mode 100644 index 00000000..32f71ba7 --- /dev/null +++ b/appview/service/issue/context.go @@ -0,0 +1,18 @@ +package issue + +import ( + "context" + + "tangled.org/core/appview/models" +) + +type ctxKey struct{} + +func IntoContext(ctx context.Context, repo *models.Issue) context.Context { + return context.WithValue(ctx, ctxKey{}, repo) +} + +func FromContext(ctx context.Context) (*models.Issue, bool) { + repo, ok := ctx.Value(ctxKey{}).(*models.Issue) + return repo, ok +} diff --git a/appview/service/issue/issue.go b/appview/service/issue/issue.go new file mode 100644 index 00000000..9f6e7557 --- /dev/null +++ b/appview/service/issue/issue.go @@ -0,0 +1,213 @@ +package issue + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + 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/notify" + "tangled.org/core/appview/refresolver" + "tangled.org/core/tid" +) + +type IssueService struct { + logger *slog.Logger + config *config.Config + db *db.DB + notifier notify.Notifier + refResolver *refresolver.Resolver +} + +func NewService( + logger *slog.Logger, + config *config.Config, + db *db.DB, + notifier notify.Notifier, + refResolver *refresolver.Resolver, +) IssueService { + return IssueService{ + logger, + config, + db, + notifier, + refResolver, + } +} + +var ( + ErrCtxMissing = errors.New("context values are missing") + ErrDatabaseFail = errors.New("db op fail") + ErrPDSFail = errors.New("pds op fail") + ErrValidationFail = errors.New("issue validation fail") +) + +// TODO: NewIssue should return typed errors +func (s *IssueService) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) { + l := s.logger.With("method", "NewIssue") + sess, ok := fromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return nil, ErrCtxMissing + } + authorDid := sess.Data.AccountDID + l = l.With("did", authorDid) + + mentions, references := s.refResolver.Resolve(ctx, body) + + issue := models.Issue{ + RepoAt: repo.RepoAt(), + Rkey: tid.TID(), + Title: title, + Body: body, + Open: true, + Did: authorDid.String(), + Created: time.Now(), + Mentions: mentions, + References: references, + Repo: repo, + } + // TODO: validate the issue + + 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 + } + + atpclient := sess.APIClient() + record := issue.AsRecord() + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ + Repo: authorDid.String(), + 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 *IssueService) EditIssue(ctx context.Context, issue *models.Issue) error { + l := s.logger.With("method", "EditIssue") + sess, ok := fromContext(ctx) + if !ok { + l.Error("user session is missing in context") + return ErrCtxMissing + } + authorDid := sess.Data.AccountDID + l = l.With("did", authorDid) + + // TODO: validate issue + + 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 + } + + atpclient := sess.APIClient() + 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{ + Collection: tangled.RepoIssueNSID, + 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 PutIssue + + return nil +} + +func (s *IssueService) DeleteIssue(ctx context.Context, issue *models.Issue) error { + l := s.logger.With("method", "DeleteIssue") + sess, ok := fromContext(ctx) + if !ok { + return ErrCtxMissing + } + authorDid := sess.Data.AccountDID + l = l.With("did", authorDid) + + 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 + } + + atpclient := sess.APIClient() + _, 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 +} + +// TODO: remove this +func fromContext(ctx context.Context) (*oauth.ClientSession, bool) { + sess, ok := ctx.Value("sess").(*oauth.ClientSession) + return sess, ok +} diff --git a/appview/service/issue/state.go b/appview/service/issue/state.go new file mode 100644 index 00000000..040af73a --- /dev/null +++ b/appview/service/issue/state.go @@ -0,0 +1,15 @@ +package issue + +import ( + "context" + + "tangled.org/core/appview/models" +) + +func (s *IssueService) CloseIssue(ctx context.Context, iusse *models.Issue) error { + panic("unimplemented") +} + +func (s *IssueService) ReopenIssue(ctx context.Context, iusse *models.Issue) error { + panic("unimplemented") +} diff --git a/appview/service/owner/context.go b/appview/service/owner/context.go new file mode 100644 index 00000000..853cb298 --- /dev/null +++ b/appview/service/owner/context.go @@ -0,0 +1,18 @@ +package owner + +import ( + "context" + + "github.com/bluesky-social/indigo/atproto/identity" +) + +type ctxKey struct{} + +func IntoContext(ctx context.Context, id *identity.Identity) context.Context { + return context.WithValue(ctx, ctxKey{}, id) +} + +func FromContext(ctx context.Context) (*identity.Identity, bool) { + repo, ok := ctx.Value(ctxKey{}).(*identity.Identity) + return repo, ok +} diff --git a/appview/service/repo/context.go b/appview/service/repo/context.go new file mode 100644 index 00000000..b3612971 --- /dev/null +++ b/appview/service/repo/context.go @@ -0,0 +1,18 @@ +package repo + +import ( + "context" + + "tangled.org/core/appview/models" +) + +type ctxKey struct{} + +func IntoContext(ctx context.Context, repo *models.Repo) context.Context { + return context.WithValue(ctx, ctxKey{}, repo) +} + +func FromContext(ctx context.Context) (*models.Repo, bool) { + repo, ok := ctx.Value(ctxKey{}).(*models.Repo) + return repo, ok +} diff --git a/appview/service/repo/repo.go b/appview/service/repo/repo.go new file mode 100644 index 00000000..985c866a --- /dev/null +++ b/appview/service/repo/repo.go @@ -0,0 +1,71 @@ +package repo + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/rbac" + "tangled.org/core/tid" +) + +type RepoService struct { + logger *slog.Logger + config *config.Config + db *db.DB + enforcer *rbac.Enforcer +} + +// NewRepo creates a repository +// It expects atproto session to be passed in `ctx` +func (s *RepoService) NewRepo(ctx context.Context, name, description, knot string) error { + l := s.logger.With("method", "NewRepo") + sess := fromContext(ctx) + + ownerDid := sess.Data.AccountDID + l = l.With("did", ownerDid) + + repo := models.Repo{ + Did: ownerDid.String(), + 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 { + return fmt.Errorf("db.BeginTx: %w", err) + } + defer tx.Rollback() + + + atpclient := sess.APIClient() + _, err = atproto.RepoPutRecord(ctx, atpclient, &atproto.RepoPutRecord_Input{ + Collection: tangled.RepoNSID, + Repo: repo.Did, + }) + if err != nil { + return fmt.Errorf("atproto.RepoPutRecord: %w", err) + } + l.Info("wrote to PDS") + + // knotclient, err := s.oauth.ServiceClient( + // ) + + return nil +} + +func fromContext(ctx context.Context) oauth.ClientSession { + panic("todo") +} diff --git a/appview/state/router.go b/appview/state/router.go index fd2c4e84..b2c2920a 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -18,6 +18,7 @@ import ( "tangled.org/core/appview/spindles" "tangled.org/core/appview/state/userutil" avstrings "tangled.org/core/appview/strings" + "tangled.org/core/appview/web" "tangled.org/core/log" ) @@ -40,6 +41,17 @@ func (s *State) Router() http.Handler { userRouter := s.UserRouter(&middleware) standardRouter := s.StandardRouter(&middleware) + _ = web.UserRouter( + s.logger, + s.config, + s.db, + s.idResolver, + s.refResolver, + s.notifier, + s.oauth, + s.pages, + ) + router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { pat := chi.URLParam(r, "*") pathParts := strings.SplitN(pat, "/", 2) diff --git a/appview/web/handler/issues.go b/appview/web/handler/issues.go new file mode 100644 index 00000000..4b5de824 --- /dev/null +++ b/appview/web/handler/issues.go @@ -0,0 +1,12 @@ +package handler + +import ( + "net/http" + + "tangled.org/core/appview/service/issue" +) + +func RepoIssues(s issue.IssueService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + } +} diff --git a/appview/web/handler/issues_issue.go b/appview/web/handler/issues_issue.go new file mode 100644 index 00000000..b1a471bc --- /dev/null +++ b/appview/web/handler/issues_issue.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + + "tangled.org/core/appview/pages" + isvc "tangled.org/core/appview/service/issue" + "tangled.org/core/log" +) + +func Issue(s isvc.IssueService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} + +func IssueDelete(s isvc.IssueService, 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 := isvc.FromContext(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") + } + p.HxLocation(w, "/") + } +} diff --git a/appview/web/handler/issues_issue_close.go b/appview/web/handler/issues_issue_close.go new file mode 100644 index 00000000..7f57a7d2 --- /dev/null +++ b/appview/web/handler/issues_issue_close.go @@ -0,0 +1,13 @@ +package handler + +import ( + "net/http" + + "tangled.org/core/appview/service/issue" +) + +func CloseIssue(s issue.IssueService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/appview/web/handler/issues_issue_edit.go b/appview/web/handler/issues_issue_edit.go new file mode 100644 index 00000000..b34896d4 --- /dev/null +++ b/appview/web/handler/issues_issue_edit.go @@ -0,0 +1,19 @@ +package handler + +import ( + "net/http" + + "tangled.org/core/appview/service/issue" +) + +func IssueEdit(s issue.IssueService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} + +func IssueEditPost(s issue.IssueService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/appview/web/handler/issues_issue_opengraph.go b/appview/web/handler/issues_issue_opengraph.go new file mode 100644 index 00000000..5cd73dd0 --- /dev/null +++ b/appview/web/handler/issues_issue_opengraph.go @@ -0,0 +1,13 @@ +package handler + +import ( + "net/http" + + "tangled.org/core/appview/service/issue" +) + +func IssueOpenGraph(s issue.IssueService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/appview/web/handler/issues_issue_reopen.go b/appview/web/handler/issues_issue_reopen.go new file mode 100644 index 00000000..0640b9f8 --- /dev/null +++ b/appview/web/handler/issues_issue_reopen.go @@ -0,0 +1,13 @@ +package handler + +import ( + "net/http" + + "tangled.org/core/appview/service/issue" +) + +func ReopenIssue(s issue.IssueService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") + } +} diff --git a/appview/web/handler/issues_new.go b/appview/web/handler/issues_new.go new file mode 100644 index 00000000..68d5bd28 --- /dev/null +++ b/appview/web/handler/issues_new.go @@ -0,0 +1,48 @@ +package handler + +import ( + "errors" + "net/http" + + "tangled.org/core/appview/pages" + isvc "tangled.org/core/appview/service/issue" + "tangled.org/core/appview/service/repo" + "tangled.org/core/log" +) + +func NewIssue(p *pages.Pages) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // TODO: render page + } +} + +func NewIssuePost(is isvc.IssueService, 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 := repo.FromContext(ctx) + if !ok { + l.Error("failed to get repo") + // TODO: 503 error with more detailed messages + p.Error503(w) + return + } + var ( + title = r.FormValue("title") + body = r.FormValue("body") + ) + + _, 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.") + } + } + p.HxLocation(w, "/") + } +} diff --git a/appview/web/handler/repos_new.go b/appview/web/handler/repos_new.go new file mode 100644 index 00000000..abeebd16 --- /dev/null +++ b/appview/web/handler/repos_new.go @@ -0,0 +1 @@ +package handler diff --git a/appview/web/handler/repos_repo.go b/appview/web/handler/repos_repo.go new file mode 100644 index 00000000..51cbcd33 --- /dev/null +++ b/appview/web/handler/repos_repo.go @@ -0,0 +1,2 @@ +package handler + diff --git a/appview/web/handler/repos_repo_opengraph.go b/appview/web/handler/repos_repo_opengraph.go new file mode 100644 index 00000000..51cbcd33 --- /dev/null +++ b/appview/web/handler/repos_repo_opengraph.go @@ -0,0 +1,2 @@ +package handler + diff --git a/appview/web/middleware/auth.go b/appview/web/middleware/auth.go new file mode 100644 index 00000000..ec2c8b45 --- /dev/null +++ b/appview/web/middleware/auth.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + + "tangled.org/core/appview/oauth" +) + +func AuthMiddleware(o *oauth.OAuth) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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)) + + redirectFunc := func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) + } + if r.Header.Get("HX-Request") == "true" { + redirectFunc = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("HX-Redirect", loginURL) + w.WriteHeader(http.StatusOK) + } + } + + sess, err := o.ResumeSession(r) + if err != nil { + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) + redirectFunc(w, r) + return + } + + if sess == nil { + log.Printf("session is nil, redirecting...") + redirectFunc(w, r) + return + } + + // TODO: use IntoContext instead + ctx := context.WithValue(r.Context(), "sess", sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} 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/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..4894db77 --- /dev/null +++ b/appview/web/middleware/resolve.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "log" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/db" + "tangled.org/core/appview/pages" + issue_service "tangled.org/core/appview/service/issue" + owner_service "tangled.org/core/appview/service/owner" + repo_service "tangled.org/core/appview/service/repo" + "tangled.org/core/idresolver" +) + +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) { + didOrHandle := chi.URLParam(r, "user") + didOrHandle = strings.TrimPrefix(didOrHandle, "@") + + id, err := idResolver.ResolveIdent(r.Context(), didOrHandle) + if err != nil { + // invalid did or handle + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) + pages.Error404(w) + return + } + + ctx := owner_service.IntoContext(r.Context(), id) + log.Println("ident resolved") + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func ResolveRepo( + e *db.DB, + idResolver *idresolver.Resolver, + pages *pages.Pages, +) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + repoName := chi.URLParam(r, "repo") + repoOwner, ok := owner_service.FromContext(r.Context()) + if !ok { + log.Println("malformed middleware") + w.WriteHeader(http.StatusInternalServerError) + return + } + + repo, err := db.GetRepo( + e, + db.FilterEq("did", repoOwner.DID.String()), + db.FilterEq("name", repoName), + ) + if err != nil { + log.Println("failed to resolve repo", "err", err) + pages.ErrorKnot404(w) + return + } + + // TODO: pass owner id into repository object + + ctx := repo_service.IntoContext(r.Context(), repo) + log.Println("repo resolved") + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func ResolveIssue( + e *db.DB, + idResolver *idresolver.Resolver, + pages *pages.Pages, +) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issueIdStr := chi.URLParam(r, "issue") + issueId, err := strconv.Atoi(issueIdStr) + if err != nil { + log.Println("failed to fully resolve issue ID", err) + pages.Error404(w) + return + } + repo, ok := repo_service.FromContext(r.Context()) + if !ok { + log.Println("malformed middleware") + w.WriteHeader(http.StatusInternalServerError) + return + } + + issue, err := db.GetIssue(e, repo.RepoAt(), issueId) + if err != nil { + log.Println("failed to resolve repo", "err", err) + pages.ErrorKnot404(w) + return + } + issue.Repo = repo + + ctx := issue_service.IntoContext(r.Context(), issue) + log.Println("issue resolved") + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/appview/web/routes.go b/appview/web/routes.go new file mode 100644 index 00000000..893f9339 --- /dev/null +++ b/appview/web/routes.go @@ -0,0 +1,88 @@ +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/notify" + "tangled.org/core/appview/oauth" + "tangled.org/core/appview/pages" + "tangled.org/core/appview/refresolver" + "tangled.org/core/appview/service/issue" + "tangled.org/core/appview/web/handler" + "tangled.org/core/appview/web/middleware" + "tangled.org/core/idresolver" +) + +// Rules +// - Use single function for each endpoints (unless it doesn't make sense.) +// - Name handler files following the related path (ancestor paths can be +// trimmed.) +// - Uass dependencies to each handlers, don't create structs with shared +// dependencies unless it serves some domain-specific roles like +// service/issue. Same rule goes to middlewares. + +func UserRouter( + // NOTE: put base dependencies (db, idResolver, oauth etc) + logger *slog.Logger, + config *config.Config, + db *db.DB, + idResolver *idresolver.Resolver, + refResolver *refresolver.Resolver, + notifier notify.Notifier, + oauth *oauth.OAuth, + pages *pages.Pages, +) http.Handler { + r := chi.NewRouter() + + auth := middleware.AuthMiddleware(oauth) + + issue := issue.NewService( + logger, + config, + db, + notifier, + refResolver, + ) + + r.Use(middleware.WithLogger(logger)) + + r.Route("/{user}", func(r chi.Router) { + r.Use(middleware.ResolveIdent(idResolver, pages)) + + // r.Get("/", Profile) + // r.Get("/feed.atom", AtomFeedPage) + + r.Route("/{repo}", func(r chi.Router) { + r.Use(middleware.ResolveRepo(db, idResolver, pages)) + + // /{user}/{repo}/issues/* + r.With(middleware.Paginate).Get("/issues", handler.RepoIssues(issue)) + r.With(auth).Get("/issues/new", handler.NewIssue(pages)) + r.With(auth).Post("/issues/new", handler.NewIssuePost(issue, pages)) + r.Route("/issues/{issue}", func(r chi.Router) { + r.Use(middleware.ResolveIssue(db, idResolver, pages)) + + r.Get("/", handler.Issue(issue)) + r.Get("/opengraph", handler.IssueOpenGraph(issue)) + + r.With(auth).Delete("/", handler.IssueDelete(issue, pages)) + + r.With(auth).Get("/edit", handler.IssueEdit(issue)) + r.With(auth).Post("/edit", handler.IssueEditPost(issue)) + + r.With(auth).Post("/close", handler.CloseIssue(issue)) + r.With(auth).Post("/reopen", handler.ReopenIssue(issue)) + + // TODO: comments + }) + + // TODO: put more routes + }) + }) + + return r +} -- 2.43.0