From c6896ac33fb0ddc418df3ad317e0daed83b8f0f1 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Thu, 14 May 2026 08:55:21 +0300 Subject: [PATCH] appview: project mode routing Project mode, when enabled, runs the appview rooted at a specific project user like project.org. This removes the usual user-scoped routing that exists on the production appview. For example, if the custom appview is running at code.project.org: code.project.org/project.org/example-repo -> code.project.org/example-repo Signups (done via our PDS) are disabled. Signed-off-by: Anirudh Oppiliappan --- appview/config/config.go | 9 ++ appview/repo/router.go | 10 ++- appview/settings/settings.go | 12 +-- appview/state/router.go | 130 +++++++++++++++++++-------- appview/state/router_test.go | 167 +++++++++++++++++++++++++++++++++++ appview/state/state.go | 8 ++ 6 files changed, 290 insertions(+), 46 deletions(-) create mode 100644 appview/state/router_test.go diff --git a/appview/config/config.go b/appview/config/config.go index 36a66a68..4c1af165 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -177,8 +177,17 @@ func (cfg RedisConfig) ToURL() string { return u.String() } +type ProjectConfig struct { + // Enabled collapses the URL namespace so that /{repo} is served as + // /{User}/{repo}. The home page, global timeline, and signup are disabled. + Enabled bool `env:"MODE, default=false"` + User string `env:"USER"` // handle or DID; required when Enabled is true + +} + type Config struct { Core CoreConfig `env:",prefix=TANGLED_"` + Project ProjectConfig `env:",prefix=TANGLED_PROJECT_"` Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` Knotstream ConsumerConfig `env:",prefix=TANGLED_KNOTSTREAM_"` Spindlestream ConsumerConfig `env:",prefix=TANGLED_SPINDLESTREAM_"` diff --git a/appview/repo/router.go b/appview/repo/router.go index 606010d0..f5d0a2c7 100644 --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -94,10 +94,12 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { r.Put("/branches/default", rp.SetDefaultBranch) r.Put("/secrets", rp.Secrets) r.Delete("/secrets", rp.Secrets) - r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sites", func(r chi.Router) { - r.Put("/", rp.SaveRepoSiteConfig) - r.Delete("/", rp.DeleteRepoSiteConfig) - }) + if !rp.config.Project.Enabled { + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sites", func(r chi.Router) { + r.Put("/", rp.SaveRepoSiteConfig) + r.Delete("/", rp.DeleteRepoSiteConfig) + }) + } r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { r.Get("/", rp.Webhooks) r.Post("/", rp.AddWebhook) diff --git a/appview/settings/settings.go b/appview/settings/settings.go index 2f955fc0..a5549ac1 100644 --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -74,11 +74,13 @@ func (s *Settings) Router() http.Handler { r.Put("/", s.updateNotificationPreferences) }) - r.Route("/sites", func(r chi.Router) { - r.Get("/", s.sitesSettings) - r.Put("/", s.claimSitesDomain) - r.Delete("/", s.releaseSitesDomain) - }) + if !s.Config.Project.Enabled { + r.Route("/sites", func(r chi.Router) { + r.Get("/", s.sitesSettings) + r.Put("/", s.claimSitesDomain) + r.Delete("/", s.releaseSitesDomain) + }) + } r.Post("/password/request", s.requestPasswordReset) r.Post("/password/reset", s.resetPassword) diff --git a/appview/state/router.go b/appview/state/router.go index c467acb3..2e529afc 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -4,10 +4,12 @@ import ( "context" "database/sql" "errors" + "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" + "tangled.org/core/appview/config" "tangled.org/core/appview/db" "tangled.org/core/appview/focus" "tangled.org/core/appview/issues" @@ -31,37 +33,15 @@ import ( "tangled.org/core/log" ) -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) - } - 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) - - router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { +// newDispatchHandler builds the /* catch-all handler. It is a standalone +// function so that it can be tested without a full State. +func newDispatchHandler( + cfg *config.Config, + execer db.Execer, + logger *slog.Logger, + userRouter, standardRouter http.Handler, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { pat := chi.URLParam(r, "*") pathParts := strings.SplitN(pat, "/", 2) @@ -69,7 +49,7 @@ func (s *State) Router() http.Handler { firstPart := pathParts[0] if userutil.IsDid(firstPart) { - repo, err := db.GetRepoByDid(s.db, firstPart) + repo, err := db.GetRepoByDid(execer, firstPart) switch { case err == nil: remaining := "" @@ -84,7 +64,7 @@ func (s *State) Router() http.Handler { case errors.Is(err, sql.ErrNoRows): userRouter.ServeHTTP(w, r) default: - s.logger.Error("db error looking up repo DID", "repoDid", firstPart, "err", err) + logger.Error("db error looking up repo DID", "repoDid", firstPart, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) } return @@ -118,10 +98,75 @@ func (s *State) Router() http.Handler { return } + // project mode: rewrite /{repo}/... → /{projectUser}/{repo}/... + // unless the first segment is a reserved standard-route prefix. + if cfg.Project.Enabled && cfg.Project.User != "" { + if firstPart == "" { + r2 := r.Clone(r.Context()) + r2.URL.Path = "/" + cfg.Project.User + r2.URL.RawPath = "/" + cfg.Project.User + userRouter.ServeHTTP(w, r2) + return + } + if _, isStd := standardPrefixes[firstPart]; !isStd { + rewritten := "/" + cfg.Project.User + "/" + pat + r2 := r.Clone(r.Context()) + r2.URL.Path = rewritten + r2.URL.RawPath = rewritten + userRouter.ServeHTTP(w, r2) + return + } + } } standardRouter.ServeHTTP(w, r) - }) + } +} + +// standardPrefixes is the set of first path segments that belong to the +// standard (non-user) router. In project mode, any segment not in this set +// is treated as a repo name and rewritten to /{ProjectUser}/{segment}. +var standardPrefixes = map[string]struct{}{ + "static": {}, "home": {}, "timeline": {}, "upgradeBanner": {}, + "newsletter": {}, "core": {}, "login": {}, "logout": {}, + "search": {}, "account": {}, "repo": {}, "goodfirstissues": {}, + "follow": {}, "vouch": {}, "star": {}, "react": {}, + "profile": {}, "settings": {}, "strings": {}, "notifications": {}, + "signup": {}, "keys": {}, "terms": {}, "privacy": {}, "brand": {}, + "oauth": {}, +} + +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) + } + 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) + + router.HandleFunc("/*", newDispatchHandler(s.config, s.db, s.logger, userRouter, standardRouter)) return router } @@ -170,8 +215,13 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { 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) + if s.config.Project.Enabled { + r.Get("/home", http.RedirectHandler("/", http.StatusFound).ServeHTTP) + r.Get("/timeline", http.RedirectHandler("/", http.StatusFound).ServeHTTP) + } else { + 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) @@ -253,7 +303,13 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/notifications", s.NotificationsRouter(mw)) r.Mount("/focus", s.FocusRouter(mw)) - r.Mount("/signup", s.SignupRouter()) + if s.config.Project.Enabled { + r.Mount("/signup", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusFound) + })) + } else { + r.Mount("/signup", s.SignupRouter()) + } r.Mount("/", s.oauth.Router()) r.Get("/keys/{user}", s.Keys) diff --git a/appview/state/router_test.go b/appview/state/router_test.go new file mode 100644 index 00000000..2713e2bb --- /dev/null +++ b/appview/state/router_test.go @@ -0,0 +1,167 @@ +package state + +import ( + "context" + "database/sql" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "tangled.org/core/appview/config" + "tangled.org/core/appview/models" +) + +// routerFixture builds a minimal chi router wired to newDispatchHandler with +// spy handlers. userPath and stdPath are set to the URL.Path received by +// the respective spy after ServeHTTP returns. +func routerFixture(cfg *config.Config, repoDB fakeRepoDB) (router http.Handler, userPath *string, stdPath *string) { + up := "" + sp := "" + userRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + up = r.URL.Path + w.WriteHeader(http.StatusOK) + }) + stdRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sp = r.URL.Path + w.WriteHeader(http.StatusOK) + }) + + r := chi.NewRouter() + r.HandleFunc("/*", newDispatchHandler(cfg, repoDB, slog.Default(), userRouter, stdRouter)) + return r, &up, &sp +} + +// fakeRepoDB implements db.Execer. The nil value is safe to use for test +// cases that don't exercise the DID-resolution path. +type fakeRepoDB map[string]*models.Repo + +func (f fakeRepoDB) Exec(query string, args ...any) (sql.Result, error) { return nil, nil } +func (f fakeRepoDB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return nil, nil +} +func (f fakeRepoDB) Query(query string, args ...any) (*sql.Rows, error) { return nil, nil } +func (f fakeRepoDB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return nil, nil +} +func (f fakeRepoDB) QueryRow(query string, args ...any) *sql.Row { return nil } +func (f fakeRepoDB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return nil +} +func (f fakeRepoDB) Prepare(query string) (*sql.Stmt, error) { return nil, nil } +func (f fakeRepoDB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return nil, nil +} + +// normalCfg returns a config with project mode disabled. +func normalCfg() *config.Config { + return &config.Config{} +} + +// projectCfg returns a config with project mode enabled for the given user. +func projectCfg(user string) *config.Config { + return &config.Config{ + Project: config.ProjectConfig{ + Enabled: true, + User: user, + }, + } +} + +func TestDispatch_NormalMode(t *testing.T) { + cases := []struct { + name string + path string + wantUser string // expected URL seen by userRouter; empty means standardRouter should be hit + wantStd string // expected URL seen by standardRouter; empty means userRouter should be hit + wantCode int // expected HTTP status; 0 means 200 + }{ + {"handle", "/user.com/my-repo", "/user.com/my-repo", "", 0}, + {"handle root", "/user.com", "/user.com", "", 0}, + {"@handle redirect", "/@user.com/repo", "", "", http.StatusFound}, + {"flattened did redirect", "/did-plc-abc123xyz", "", "", http.StatusFound}, + {"settings", "/settings/profile", "", "/settings/profile", 0}, + {"login", "/login", "", "/login", 0}, + {"notifications", "/notifications", "", "/notifications", 0}, + {"signup", "/signup/complete", "", "/signup/complete", 0}, + {"root", "/", "", "/", 0}, + {"unknown segment", "/not-a-handle", "", "/not-a-handle", 0}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + router, userPath, stdPath := routerFixture(normalCfg(), nil) + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + wantCode := tc.wantCode + if wantCode == 0 { + wantCode = http.StatusOK + } + if rr.Code != wantCode { + t.Errorf("status = %d, want %d", rr.Code, wantCode) + } + if tc.wantUser != "" && *userPath != tc.wantUser { + t.Errorf("userRouter received %q, want %q", *userPath, tc.wantUser) + } + if tc.wantStd != "" && *stdPath != tc.wantStd { + t.Errorf("stdRouter received %q, want %q", *stdPath, tc.wantStd) + } + if tc.wantCode == http.StatusFound { + // neither spy should have been called + if *userPath != "" || *stdPath != "" { + t.Errorf("redirect case should not reach routers (user=%q std=%q)", *userPath, *stdPath) + } + } + }) + } +} + +func TestDispatch_ProjectMode(t *testing.T) { + const projectUser = "anirudh.fi" + + cases := []struct { + name string + path string + wantUser string + wantStd string + wantCode int + }{ + {"root becomes profile", "/", "/anirudh.fi", "", 0}, + {"repo", "/my-repo", "/anirudh.fi/my-repo", "", 0}, + {"repo with subpath", "/my-repo/issues/1", "/anirudh.fi/my-repo/issues/1", "", 0}, + {"settings prefix", "/settings/profile", "", "/settings/profile", 0}, + {"login prefix", "/login", "", "/login", 0}, + {"notifications prefix", "/notifications", "", "/notifications", 0}, + {"signup is standard prefix", "/signup", "", "/signup", 0}, + {"search prefix", "/search", "", "/search", 0}, + {"explicit handle still works", "/anirudh.fi/my-repo", "/anirudh.fi/my-repo", "", 0}, + {"other user still works", "/other.user/their-repo", "/other.user/their-repo", "", 0}, + {"@handle redirect", "/@anirudh.fi/repo", "", "", http.StatusFound}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + router, userPath, stdPath := routerFixture(projectCfg(projectUser), nil) + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + wantCode := tc.wantCode + if wantCode == 0 { + wantCode = http.StatusOK + } + if rr.Code != wantCode { + t.Errorf("status = %d, want %d", rr.Code, wantCode) + } + if tc.wantUser != "" && *userPath != tc.wantUser { + t.Errorf("userRouter received %q, want %q", *userPath, tc.wantUser) + } + if tc.wantStd != "" && *stdPath != tc.wantStd { + t.Errorf("stdRouter received %q, want %q", *stdPath, tc.wantStd) + } + }) + } +} diff --git a/appview/state/state.go b/appview/state/state.go index e73ff28d..4e1f3a32 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -252,6 +252,14 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { cfClient: cfClient, } + if config.Project.Enabled { + if config.Project.User == "" { + logger.Warn("project mode enabled but PROJECT_USER is not set") + } else { + logger.Info("running in project mode", "project_user", config.Project.User) + } + } + // fetch initial bluesky posts if configured go fetchBskyPosts(ctx, res, config, d, logger) -- 2.51.2