package state
import (
"context"
"database/sql"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"tangled.org/core/appview/db"
"tangled.org/core/appview/focus"
"tangled.org/core/appview/issues"
"tangled.org/core/appview/knotacl"
"tangled.org/core/appview/knots"
"tangled.org/core/appview/labels"
"tangled.org/core/appview/metrics"
"tangled.org/core/appview/middleware"
"tangled.org/core/appview/migration"
"tangled.org/core/appview/notifications"
whnotify "tangled.org/core/appview/notify/webhook"
"tangled.org/core/appview/pipelines"
"tangled.org/core/appview/pulls"
"tangled.org/core/appview/repo"
"tangled.org/core/appview/settings"
"tangled.org/core/appview/signup"
"tangled.org/core/appview/spindles"
"tangled.org/core/appview/state/userutil"
avstrings "tangled.org/core/appview/strings"
avtimeline "tangled.org/core/appview/timeline"
avxrpc "tangled.org/core/appview/xrpc"
"tangled.org/core/blog"
"tangled.org/core/log"
"tangled.org/core/xrpc/serviceauth"
)
func (s *State) Router() http.Handler {
router := chi.NewRouter()
middleware := middleware.New(
s.oauth,
s.db,
s.enforcer,
s.aclService,
s.repoResolver,
s.idResolver,
s.pages,
s.rdb,
s.logger,
)
router.Use(metrics.Middleware)
router.Use(knotacl.MemoMiddleware)
if err := db.ReapStaleRunningMigrations(context.Background(), s.db); err != nil {
s.logger.Warn("failed to reap stale running migrations", "err", err)
}
go func() {
if n, err := db.EnqueueEntityStateBackfill(context.Background(), s.db); err != nil {
s.logger.Warn("failed to enqueue entity state backfill", "err", err)
} else if n > 0 {
s.logger.Info("enqueued entity state backfill", "owners", n)
}
}()
m := migration.NewMigration(s.db, s.oauth, s.idResolver.Directory(), s.logger)
router.Use(m.BackgroundMigrationMiddleware)
router.Get("/pwa-manifest.json", s.WebAppManifest)
router.Get("/robots.txt", s.RobotsTxt)
router.Get("/.well-known/security.txt", s.SecurityTxt)
userRouter := s.UserRouter(&middleware)
standardRouter := s.StandardRouter(&middleware)
keysRouter := s.KeysRouter(&middleware)
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
pat := chi.URLParam(r, "*")
pathParts := strings.SplitN(pat, "/", 2)
if len(pathParts) > 0 {
firstPart := pathParts[0]
if userutil.IsDid(firstPart) {
repo, err := db.GetRepoByDid(s.db, firstPart)
switch {
case err == nil:
remaining := ""
if len(pathParts) > 1 {
remaining = "/" + pathParts[1]
}
rewritten := "/" + repo.Did + "/" + repo.Rkey + remaining
r2 := r.Clone(r.Context())
r2.URL.Path = rewritten
r2.URL.RawPath = rewritten
userRouter.ServeHTTP(w, r2)
case errors.Is(err, sql.ErrNoRows):
userRouter.ServeHTTP(w, r)
default:
s.logger.Error("db error looking up repo DID", "repoDid", firstPart, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
return
}
if strings.HasSuffix(firstPart, ".keys") {
keysRouter.ServeHTTP(w, r)
return
}
if userutil.IsHandle(firstPart) {
userRouter.ServeHTTP(w, r)
return
}
// 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
}
}
standardRouter.ServeHTTP(w, r)
})
return router
}
func (s *State) KeysRouter(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
r.Use(mw.InjectBaseParams)
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
r.Get("/", s.Keys)
})
return r
}
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
r.Use(mw.InjectBaseParams)
// TODO: workflow status update requests (30s polling)
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
r.Get("/", s.Profile)
r.Get("/feed.atom", s.AtomFeedPage)
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
r.Use(mw.GoImport())
// These routes get proxied to the knot
r.Get("/info/refs", s.InfoRefs)
r.Post("/git-upload-archive", s.UploadArchive)
r.Post("/git-upload-pack", s.UploadPack)
r.Post("/git-receive-pack", s.ReceivePack)
r.Group(func(r chi.Router) {
r.Use(mw.CanonicalizeRepoURL())
r.Mount("/issues", s.IssuesRouter(mw))
r.Mount("/pulls", s.PullsRouter(mw))
r.Mount("/pipelines", s.PipelinesRouter(mw))
r.Mount("/labels", s.LabelsRouter())
r.Mount("/", s.RepoRouter(mw))
})
})
})
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
s.pages.Error404(w)
})
return r
}
func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
r.Use(mw.InjectBaseParams)
r.Handle("/static/*", s.pages.Static())
tl := avtimeline.New(s.oauth, s.db, s.config, s.pages, s.logger, blog.PostsFS)
r.Get("/", tl.HomeOrTimeline)
r.Get("/home", tl.Home)
r.Get("/timeline", tl.Timeline)
r.Get("/upgradeBanner", s.UpgradeBanner)
r.Post("/newsletter/signup", s.NewsletterSignup)
r.Post("/newsletter/dismiss", s.NewsletterDismiss)
// 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.With(middleware.Paginate).Get("/search", s.Search)
r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick", s.SearchQuick)
r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick/mobile", s.SearchQuickMobile)
r.Post("/account/switch", s.SwitchAccount)
r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount)
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
r.Use(middleware.AuthMiddleware(s.oauth))
r.Get("/", s.NewRepo)
r.Post("/", s.NewRepo)
})
// r.Post("/import", s.ImportRepo)
})
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
r.Post("/", s.Follow)
r.Delete("/", s.Follow)
})
r.With(middleware.AuthMiddleware(s.oauth)).Route("/vouch", func(r chi.Router) {
r.Post("/", s.Vouch)
r.Post("/skip", s.SkipVouchSuggestion)
})
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
r.Post("/", s.Star)
r.Delete("/", s.Star)
})
r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) {
r.Post("/", s.React)
r.Delete("/", s.React)
})
r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) {
r.Get("/", s.CommentBodyFragment)
r.Get("/edit", s.EditCommentFragment)
r.Get("/reply", s.NewReplyCommentFragment)
r.Get("/reply/placeholder", s.ReplyPlaceholderFragment)
r.Post("/", s.NewComment)
r.Patch("/", s.EditComment)
r.Delete("/", s.DeleteComment)
})
r.With(middleware.AuthMiddleware(s.oauth)).Route("/markup", func(r chi.Router) {
r.Post("/preview", s.MarkdownPreview)
r.Post("/upload", s.MarkupUpload)
})
r.Get("/profile/popover", s.ProfilePopover)
r.Route("/profile", func(r chi.Router) {
r.Use(middleware.AuthMiddleware(s.oauth))
r.Get("/edit-bio", s.EditBioFragment)
r.Get("/edit-pins", s.EditPinsFragment)
r.Post("/bio", s.UpdateProfileBio)
r.Post("/pins", s.UpdateProfilePins)
r.Post("/avatar", s.UploadProfileAvatar)
r.Delete("/avatar", s.RemoveProfileAvatar)
r.Post("/punchcard", s.UpdateProfilePunchcardSetting)
r.Post("/theme", s.UpdateProfileThemeSetting)
})
r.With(middleware.AuthMiddleware(s.oauth)).Route("/welcome", func(r chi.Router) {
r.Get("/", s.OnboardingResume)
r.Get("/profile", s.OnboardingProfile)
r.Post("/profile", s.OnboardingSaveProfile)
r.Get("/keys", s.OnboardingKeys)
r.Get("/social", s.OnboardingSocial)
r.Get("/repo", s.OnboardingRepo)
r.Post("/next", s.OnboardingNext)
r.Post("/skip", s.OnboardingSkip)
r.Post("/complete", s.OnboardingComplete)
})
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("/focus", s.FocusRouter(mw))
r.Mount("/signup", s.SignupRouter())
if s.config.Core.XrpcEnabled {
r.Mount("/xrpc", s.XrpcRouter())
}
r.Mount("/", s.oauth.Router())
r.Get("/terms", s.TermsOfService)
r.Get("/privacy", s.PrivacyPolicy)
r.Get("/brand", s.Brand)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
s.pages.Error404(w)
})
return r
}
// Core serves tangled.org/core go-import meta tags, and redirects
// to the core repository if accessed normally.
func (s *State) Core() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("go-get") == "1" {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(``))
return
}
http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
}
}
func (s *State) SettingsRouter() http.Handler {
settings := &settings.Settings{
Db: s.db,
OAuth: s.oauth,
Pages: s.pages,
Config: s.config,
CfClient: s.cfClient,
Logger: log.SubLogger(s.logger, "settings"),
IdResolver: s.idResolver,
}
return settings.Router()
}
func (s *State) SpindlesRouter() http.Handler {
logger := log.SubLogger(s.logger, "spindles")
spindles := &spindles.Spindles{
Db: s.db,
OAuth: s.oauth,
Pages: s.pages,
Config: s.config,
Enforcer: s.enforcer,
IdResolver: s.idResolver,
Logger: logger,
}
return spindles.Router()
}
func (s *State) KnotsRouter() http.Handler {
logger := log.SubLogger(s.logger, "knots")
knots := &knots.Knots{
Db: s.db,
OAuth: s.oauth,
Pages: s.pages,
Config: s.config,
Enforcer: s.enforcer,
Acl: s.aclService,
IdResolver: s.idResolver,
Knotstream: s.knotstream,
Logger: logger,
}
return knots.Router()
}
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
logger := log.SubLogger(s.logger, "strings")
strs := &avstrings.Strings{
Db: s.db,
OAuth: s.oauth,
Pages: s.pages,
IdResolver: s.idResolver,
Notifier: s.notifier,
Logger: logger,
}
return strs.Router(mw)
}
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
issues := issues.New(
s.oauth,
s.repoResolver,
s.aclService,
s.pages,
s.idResolver,
s.mentionsResolver,
s.db,
s.config,
s.notifier,
s.indexer.Issues,
log.SubLogger(s.logger, "issues"),
)
return issues.Router(mw)
}
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
pulls := pulls.New(
s.oauth,
s.repoResolver,
s.pages,
s.idResolver,
s.mentionsResolver,
s.db,
s.config,
s.notifier,
s.aclService,
s.indexer.Pulls,
log.SubLogger(s.logger, "pulls"),
)
return pulls.Router(mw)
}
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
repo := repo.New(
s.oauth,
s.repoResolver,
s.pages,
s.idResolver,
s.db,
s.config,
s.notifier,
s.enforcer,
s.aclService,
log.SubLogger(s.logger, "repo"),
s.cfClient,
s.codesearch,
)
return repo.Router(mw)
}
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
pipes := pipelines.New(
s.oauth,
s.repoResolver,
s.pages,
s.idResolver,
s.db,
s.config,
s.enforcer,
log.SubLogger(s.logger, "pipelines"),
)
return pipes.Router(mw)
}
func (s *State) LabelsRouter() http.Handler {
ls := labels.New(
s.oauth,
s.pages,
s.db,
s.idResolver.Directory(),
s.aclService,
s.notifier,
log.SubLogger(s.logger, "labels"),
)
return ls.Router()
}
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications"))
return notifs.Router(mw)
}
func (s *State) FocusRouter(mw *middleware.Middleware) http.Handler {
f := focus.New(s.db, s.oauth, s.idResolver, s.pages, log.SubLogger(s.logger, "focus"))
return f.Router(mw)
}
func (s *State) SignupRouter() http.Handler {
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup"))
return sig.Router()
}
// XrpcRouter serves the org.tangled.* methods owned by the go service; callers
// authenticate with atproto service auth, audience did:web:
func (s *State) XrpcRouter() http.Handler {
audience := serviceauth.DidWeb(s.config.Core.AppviewHost).String()
sa := serviceauth.NewServiceAuth(s.logger, s.idResolver.Directory(), audience)
xlogger := log.SubLogger(s.logger, "xrpc")
x := &avxrpc.Xrpc{
DB: s.db,
Config: s.config,
Logger: xlogger,
ServiceAuth: sa,
IdResolver: s.idResolver,
Cloudflare: s.cfClient,
CodeSearch: s.codesearch,
Webhooks: whnotify.NewNotifier(s.db, s.config.Core.BaseUrl(), s.config.Core.Dev),
DisallowedNicknames: userutil.LoadDisallowedNicknames(s.config.Core.DisallowedNicknamesFile, xlogger),
}
return x.Router()
}