draft: appview: service layer #800

open
opened by boltless.me targeting master from sl/uvpzuszrulvq

Obviously file naming of appview/web/handler/*.go files are directly against to go convention. Though I think flattening all handler files can significantly reduce the effort involved in file naming and structuring. We are already grouping core services by domains, and doing same for web handers is just over-complicating.

Signed-off-by: Seongmin Lee git@boltless.me

+2 -2
appview/oauth/handler.go
···
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
}
···
}
}
-
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())
+10
appview/oauth/session.go
···
+
package oauth
+
+
import (
+
"net/http"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
)
+
+
func (o *OAuth) SaveSession2(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) {
+
}
+271
appview/service/issue/issue.go
···
+
package issue
+
+
import (
+
"context"
+
"errors"
+
"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/models"
+
"tangled.org/core/appview/notify"
+
"tangled.org/core/appview/pages/markup"
+
"tangled.org/core/appview/session"
+
"tangled.org/core/appview/validator"
+
"tangled.org/core/idresolver"
+
"tangled.org/core/tid"
+
)
+
+
type Service struct {
+
config *config.Config
+
db *db.DB
+
indexer *issues_indexer.Indexer
+
logger *slog.Logger
+
notifier notify.Notifier
+
idResolver *idresolver.Resolver
+
validator *validator.Validator
+
}
+
+
func NewService(
+
logger *slog.Logger,
+
config *config.Config,
+
db *db.DB,
+
notifier notify.Notifier,
+
idResolver *idresolver.Resolver,
+
indexer *issues_indexer.Indexer,
+
validator *validator.Validator,
+
) Service {
+
return Service{
+
config,
+
db,
+
indexer,
+
logger,
+
notifier,
+
idResolver,
+
validator,
+
}
+
}
+
+
var (
+
ErrUnAuthorized = errors.New("unauthorized operation")
+
ErrDatabaseFail = errors.New("db op fail")
+
ErrPDSFail = errors.New("pds op fail")
+
ErrValidationFail = errors.New("issue validation fail")
+
)
+
+
func (s *Service) NewIssue(ctx context.Context, repo *models.Repo, title, body string) (*models.Issue, error) {
+
l := s.logger.With("method", "NewIssue")
+
sess := session.FromContext(ctx)
+
if sess == nil {
+
l.Error("user session is missing in context")
+
return nil, ErrUnAuthorized
+
}
+
authorDid := sess.Data.AccountDID
+
l = l.With("did", authorDid)
+
+
// mentions, references := s.refResolver.Resolve(ctx, body)
+
mentions := func() []syntax.DID {
+
rawMentions := markup.FindUserMentions(body)
+
idents := s.idResolver.ResolveIdents(ctx, rawMentions)
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
+
var mentions []syntax.DID
+
for _, ident := range idents {
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
+
mentions = append(mentions, ident.DID)
+
}
+
}
+
return mentions
+
}()
+
+
issue := models.Issue{
+
RepoAt: repo.RepoAt(),
+
Rkey: tid.TID(),
+
Title: title,
+
Body: body,
+
Open: true,
+
Did: authorDid.String(),
+
Created: time.Now(),
+
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
+
}
+
+
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 *Service) GetIssues(ctx context.Context, repo *models.Repo, searchOpts models.IssueSearchOptions) ([]models.Issue, error) {
+
l := s.logger.With("method", "EditIssue")
+
+
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, err
+
}
+
l.Debug("searched issues with indexer", "count", len(res.Hits))
+
issues, err = db.GetIssues(s.db, db.FilterIn("id", res.Hits))
+
if err != nil {
+
l.Error("failed to get issues", "err", err)
+
return nil, err
+
}
+
} else {
+
openInt := 0
+
if searchOpts.IsOpen {
+
openInt = 1
+
}
+
issues, err = db.GetIssuesPaginated(
+
s.db,
+
searchOpts.Page,
+
db.FilterEq("repo_at", repo.RepoAt()),
+
db.FilterEq("open", openInt),
+
)
+
if err != nil {
+
l.Error("failed to get issues", "err", err)
+
return nil, err
+
}
+
}
+
+
return issues, nil
+
}
+
+
func (s *Service) EditIssue(ctx context.Context, issue *models.Issue) error {
+
l := s.logger.With("method", "EditIssue")
+
sess := session.FromContext(ctx)
+
if sess == nil {
+
l.Error("user session is missing in context")
+
return ErrUnAuthorized
+
}
+
authorDid := sess.Data.AccountDID
+
l = l.With("did", authorDid)
+
+
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
+
}
+
+
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 *Service) DeleteIssue(ctx context.Context, issue *models.Issue) error {
+
l := s.logger.With("method", "DeleteIssue")
+
sess := session.FromContext(ctx)
+
if sess == nil {
+
l.Error("user session is missing in context")
+
return ErrUnAuthorized
+
}
+
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, db.FilterEq("id", issue.Id)); 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
+
}
+15
appview/service/issue/state.go
···
+
package issue
+
+
import (
+
"context"
+
+
"tangled.org/core/appview/models"
+
)
+
+
func (s *Service) CloseIssue(ctx context.Context, iusse *models.Issue) error {
+
panic("unimplemented")
+
}
+
+
func (s *Service) ReopenIssue(ctx context.Context, iusse *models.Issue) error {
+
panic("unimplemented")
+
}
+83
appview/service/repo/repo.go
···
+
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 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) 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(
+
// )
+
panic("unimplemented")
+
}
+
+
func fromContext(ctx context.Context) oauth.ClientSession {
+
panic("todo")
+
}
+81
appview/service/repo/repoinfo.go
···
+
package repo
+
+
import (
+
"context"
+
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
+
"tangled.org/core/appview/oauth"
+
"tangled.org/core/appview/pages/repoinfo"
+
)
+
+
// GetRepoInfo converts given `Repo` to `RepoInfo` object.
+
// The `user` can be nil.
+
func (s *Service) GetRepoInfo(ctx context.Context, baseRepo *models.Repo, user *oauth.User) (*repoinfo.RepoInfo, error) {
+
var (
+
repoAt = baseRepo.RepoAt()
+
isStarred = false
+
roles = repoinfo.RolesInRepo{}
+
)
+
if user != nil {
+
isStarred = db.GetStarStatus(s.db, user.Did, repoAt)
+
roles.Roles = s.enforcer.GetPermissionsInRepo(user.Did, baseRepo.Knot, baseRepo.DidSlashRepo())
+
}
+
+
stats := baseRepo.RepoStats
+
if stats == nil {
+
starCount, err := db.GetStarCount(s.db, repoAt)
+
if err != nil {
+
return nil, err
+
}
+
issueCount, err := db.GetIssueCount(s.db, repoAt)
+
if err != nil {
+
return nil, err
+
}
+
pullCount, err := db.GetPullCount(s.db, repoAt)
+
if err != nil {
+
return nil, 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 {
+
return nil, err
+
}
+
}
+
+
repoInfo := &repoinfo.RepoInfo{
+
// ok this is basically a models.Repo
+
OwnerDid: baseRepo.Did,
+
OwnerHandle: "", // 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: "",
+
Ref: "",
+
+
// info related to the session
+
IsStarred: isStarred,
+
Roles: roles,
+
}
+
+
return repoInfo, nil
+
}
+29
appview/session/context.go
···
+
package session
+
+
import (
+
"context"
+
+
toauth "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 {
+
sess, ok := ctx.Value(ctxKey{}).(*Session)
+
if !ok {
+
return nil
+
}
+
return sess
+
}
+
+
func UserFromContext(ctx context.Context) *toauth.User {
+
sess := FromContext(ctx)
+
if sess == nil {
+
return nil
+
}
+
return sess.User()
+
}
+24
appview/session/session.go
···
+
package session
+
+
import (
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
toauth "tangled.org/core/appview/oauth"
+
)
+
+
// Session is a lightweight wrapper over indigo-oauth ClientSession
+
type Session struct {
+
*oauth.ClientSession
+
}
+
+
func New(atSess *oauth.ClientSession) Session {
+
return Session{
+
atSess,
+
}
+
}
+
+
func (s *Session) User() *toauth.User {
+
return &toauth.User{
+
Did: string(s.Data.AccountDID),
+
Pds: s.Data.HostURL,
+
}
+
}
+62
appview/state/legacy_bridge.go
···
+
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/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,
+
*indexer.Indexer,
+
*slog.Logger,
+
notify.Notifier,
+
*oauth.OAuth,
+
*pages.Pages,
+
*validator.Validator,
+
) {
+
return s.config, s.db, s.enforcer, s.idResolver, 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.pages,
+
s.idResolver,
+
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
+
}
+23
appview/web/handler/oauth_client_metadata.go
···
+
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
+
}
+
}
+
}
+19
appview/web/handler/oauth_jwks.go
···
+
package handler
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"tangled.org/core/appview/oauth"
+
)
+
+
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
+
}
+
}
+
}
+80
appview/web/handler/user_repo_issues.go
···
+
package handler
+
+
import (
+
"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"
+
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"
+
)
+
+
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
+
}
+
+
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 {
+
user := session.UserFromContext(ctx)
+
repoinfo, err := rs.GetRepoInfo(ctx, repo, user)
+
if err != nil {
+
return err
+
}
+
labelDefs, err := db.GetLabelDefinitions(
+
d,
+
db.FilterIn("at_uri", repo.Labels),
+
db.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: user,
+
RepoInfo: *repoinfo,
+
+
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
+
}
+
}
+
}
+65
appview/web/handler/user_repo_issues_issue.go
···
+
package handler
+
+
import (
+
"net/http"
+
+
"tangled.org/core/appview/pages"
+
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"
+
)
+
+
func Issue(s 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", "Issue")
+
issue, ok := request.IssueFromContext(ctx)
+
if !ok {
+
l.Error("malformed request, failed to get issue")
+
p.Error503(w)
+
return
+
}
+
+
// render
+
err := func() error {
+
user := session.UserFromContext(ctx)
+
repoinfo, err := rs.GetRepoInfo(ctx, issue.Repo, user)
+
if err != nil {
+
return err
+
}
+
return p.RepoSingleIssue(w, pages.RepoSingleIssueParams{
+
LoggedInUser: user,
+
RepoInfo: *repoinfo,
+
Issue: issue,
+
})
+
}()
+
if err != nil {
+
l.Error("failed to render", "err", err)
+
p.Error503(w)
+
return
+
}
+
}
+
}
+
+
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, "/")
+
}
+
}
+13
appview/web/handler/user_repo_issues_issue_close.go
···
+
package handler
+
+
import (
+
"net/http"
+
+
"tangled.org/core/appview/service/issue"
+
)
+
+
func CloseIssue(s issue.Service) http.HandlerFunc {
+
return func(w http.ResponseWriter, r *http.Request) {
+
panic("unimplemented")
+
}
+
}
+77
appview/web/handler/user_repo_issues_issue_edit.go
···
+
package handler
+
+
import (
+
"errors"
+
"net/http"
+
+
"tangled.org/core/appview/pages"
+
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"
+
)
+
+
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
+
}
+
+
// render
+
err := func() error {
+
user := session.UserFromContext(ctx)
+
repoinfo, err := rs.GetRepoInfo(ctx, issue.Repo, user)
+
if err != nil {
+
return err
+
}
+
return p.EditIssueFragment(w, pages.EditIssueParams{
+
LoggedInUser: user,
+
RepoInfo: *repoinfo,
+
+
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.")
+
}
+
}
+
+
p.HxRefresh(w)
+
}
+
}
+13
appview/web/handler/user_repo_issues_issue_opengraph.go
···
+
package handler
+
+
import (
+
"net/http"
+
+
"tangled.org/core/appview/service/issue"
+
)
+
+
func IssueOpenGraph(s issue.Service) http.HandlerFunc {
+
return func(w http.ResponseWriter, r *http.Request) {
+
panic("unimplemented")
+
}
+
}
+13
appview/web/handler/user_repo_issues_issue_reopen.go
···
+
package handler
+
+
import (
+
"net/http"
+
+
"tangled.org/core/appview/service/issue"
+
)
+
+
func ReopenIssue(s issue.Service) http.HandlerFunc {
+
return func(w http.ResponseWriter, r *http.Request) {
+
panic("unimplemented")
+
}
+
}
+74
appview/web/handler/user_repo_issues_new.go
···
+
package handler
+
+
import (
+
"errors"
+
"fmt"
+
"net/http"
+
+
"tangled.org/core/appview/pages"
+
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"
+
)
+
+
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 {
+
user := session.UserFromContext(ctx)
+
repo, ok := request.RepoFromContext(ctx)
+
if !ok {
+
return fmt.Errorf("malformed request")
+
}
+
repoinfo, err := rs.GetRepoInfo(ctx, repo, user)
+
if err != nil {
+
return err
+
}
+
return p.RepoNewIssue(w, pages.RepoNewIssueParams{
+
LoggedInUser: user,
+
RepoInfo: *repoinfo,
+
})
+
}()
+
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")
+
)
+
+
_, 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, "/")
+
}
+
}
+67
appview/web/middleware/auth.go
···
+
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
+
}
+
+
sess := session.New(atSess)
+
+
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))
+
+
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 := session.FromContext(ctx)
+
if sess == nil {
+
l.Debug("no session, redirecting...")
+
redirectFunc(w, r)
+
return
+
}
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
}
+30
appview/web/middleware/ensuredidorhandle.go
···
+
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
+
}
+
+
// TODO: run Normalize middleware from here
+
+
p.Error404(w)
+
return
+
})
+
}
+
}
+18
appview/web/middleware/log.go
···
+
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))
+
})
+
}
+
}
+7
appview/web/middleware/middleware.go
···
+
package middleware
+
+
import (
+
"net/http"
+
)
+
+
type middlewareFunc func(http.Handler) http.Handler
+50
appview/web/middleware/normalize.go
···
+
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)
+
return
+
})
+
}
+
}
+38
appview/web/middleware/paginate.go
···
+
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))
+
})
+
}
+120
appview/web/middleware/resolve.go
···
+
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"
+
)
+
+
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,
+
db.FilterEq("did", repoOwner.DID.String()),
+
db.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))
+
})
+
}
+
}
+39
appview/web/request/context.go
···
+
package request
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"tangled.org/core/appview/models"
+
)
+
+
type ctxKeyOwner struct{}
+
type ctxKeyRepo struct{}
+
type 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
+
}
+213
appview/web/routes.go
···
+
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/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"
+
)
+
+
// 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.)
+
// - Pass 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.
+
+
// 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, indexer, logger, notifier, oauth, pages, validator := s.Expose()
+
+
return Router(
+
logger,
+
config,
+
db,
+
enforcer,
+
idResolver,
+
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,
+
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,
+
notifier,
+
idResolver,
+
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("/favicon.svg", s.Favicon)
+
r.Get("/favicon.ico", s.Favicon)
+
r.Get("/pwa-manifest.json", s.PWAManifest)
+
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("/knots", s.KnotsRouter())
+
r.Mount("/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))
+
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))
+
// r.With(auth).Post("/reopen", handler.ReopenIssue(issue))
+
+
r.With(auth).Post("/close", i.CloseIssue)
+
r.With(auth).Post("/reopen", i.ReopenIssue)
+
+
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())
+
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
+
}
+2 -1
cmd/appview/main.go
···
"tangled.org/core/appview/config"
"tangled.org/core/appview/state"
+
"tangled.org/core/appview/web"
tlog "tangled.org/core/log"
)
···
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)
}
}