Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

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 <anirudh@tangled.org>

author
Anirudh Oppiliappan
date (Jun 24, 2026, 5:14 PM +0530) commit c6896ac3 parent 46d18d69 change-id qmlqxquy
+290 -46
+9
appview/config/config.go
··· 177 177 return u.String() 178 178 } 179 179 180 + type ProjectConfig struct { 181 + // Enabled collapses the URL namespace so that /{repo} is served as 182 + // /{User}/{repo}. The home page, global timeline, and signup are disabled. 183 + Enabled bool `env:"MODE, default=false"` 184 + User string `env:"USER"` // handle or DID; required when Enabled is true 185 + 186 + } 187 + 180 188 type Config struct { 181 189 Core CoreConfig `env:",prefix=TANGLED_"` 190 + Project ProjectConfig `env:",prefix=TANGLED_PROJECT_"` 182 191 Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` 183 192 Knotstream ConsumerConfig `env:",prefix=TANGLED_KNOTSTREAM_"` 184 193 Spindlestream ConsumerConfig `env:",prefix=TANGLED_SPINDLESTREAM_"`
+6 -4
appview/repo/router.go
··· 94 94 r.Put("/branches/default", rp.SetDefaultBranch) 95 95 r.Put("/secrets", rp.Secrets) 96 96 r.Delete("/secrets", rp.Secrets) 97 - r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sites", func(r chi.Router) { 98 - r.Put("/", rp.SaveRepoSiteConfig) 99 - r.Delete("/", rp.DeleteRepoSiteConfig) 100 - }) 97 + if !rp.config.Project.Enabled { 98 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sites", func(r chi.Router) { 99 + r.Put("/", rp.SaveRepoSiteConfig) 100 + r.Delete("/", rp.DeleteRepoSiteConfig) 101 + }) 102 + } 101 103 r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { 102 104 r.Get("/", rp.Webhooks) 103 105 r.Post("/", rp.AddWebhook)
+7 -5
appview/settings/settings.go
··· 74 74 r.Put("/", s.updateNotificationPreferences) 75 75 }) 76 76 77 - r.Route("/sites", func(r chi.Router) { 78 - r.Get("/", s.sitesSettings) 79 - r.Put("/", s.claimSitesDomain) 80 - r.Delete("/", s.releaseSitesDomain) 81 - }) 77 + if !s.Config.Project.Enabled { 78 + r.Route("/sites", func(r chi.Router) { 79 + r.Get("/", s.sitesSettings) 80 + r.Put("/", s.claimSitesDomain) 81 + r.Delete("/", s.releaseSitesDomain) 82 + }) 83 + } 82 84 83 85 r.Post("/password/request", s.requestPasswordReset) 84 86 r.Post("/password/reset", s.resetPassword)
+93 -37
appview/state/router.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "errors" 7 + "log/slog" 7 8 "net/http" 8 9 "strings" 9 10 10 11 "github.com/go-chi/chi/v5" 12 + "tangled.org/core/appview/config" 11 13 "tangled.org/core/appview/db" 12 14 "tangled.org/core/appview/focus" 13 15 "tangled.org/core/appview/issues" ··· 31 33 "tangled.org/core/log" 32 34 ) 33 35 34 - func (s *State) Router() http.Handler { 35 - router := chi.NewRouter() 36 - middleware := middleware.New( 37 - s.oauth, 38 - s.db, 39 - s.enforcer, 40 - s.aclService, 41 - s.repoResolver, 42 - s.idResolver, 43 - s.pages, 44 - s.rdb, 45 - s.logger, 46 - ) 47 - 48 - router.Use(metrics.Middleware) 49 - router.Use(knotacl.MemoMiddleware) 50 - 51 - if err := db.ReapStaleRunningMigrations(context.Background(), s.db); err != nil { 52 - s.logger.Warn("failed to reap stale running migrations", "err", err) 53 - } 54 - m := migration.NewMigration(s.db, s.oauth, s.idResolver.Directory(), s.logger) 55 - router.Use(m.BackgroundMigrationMiddleware) 56 - 57 - router.Get("/pwa-manifest.json", s.WebAppManifest) 58 - router.Get("/robots.txt", s.RobotsTxt) 59 - router.Get("/.well-known/security.txt", s.SecurityTxt) 60 - 61 - userRouter := s.UserRouter(&middleware) 62 - standardRouter := s.StandardRouter(&middleware) 63 - 64 - router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 36 + // newDispatchHandler builds the /* catch-all handler. It is a standalone 37 + // function so that it can be tested without a full State. 38 + func newDispatchHandler( 39 + cfg *config.Config, 40 + execer db.Execer, 41 + logger *slog.Logger, 42 + userRouter, standardRouter http.Handler, 43 + ) http.HandlerFunc { 44 + return func(w http.ResponseWriter, r *http.Request) { 65 45 pat := chi.URLParam(r, "*") 66 46 pathParts := strings.SplitN(pat, "/", 2) 67 47 ··· 69 49 firstPart := pathParts[0] 70 50 71 51 if userutil.IsDid(firstPart) { 72 - repo, err := db.GetRepoByDid(s.db, firstPart) 52 + repo, err := db.GetRepoByDid(execer, firstPart) 73 53 switch { 74 54 case err == nil: 75 55 remaining := "" ··· 84 64 case errors.Is(err, sql.ErrNoRows): 85 65 userRouter.ServeHTTP(w, r) 86 66 default: 87 - s.logger.Error("db error looking up repo DID", "repoDid", firstPart, "err", err) 67 + logger.Error("db error looking up repo DID", "repoDid", firstPart, "err", err) 88 68 http.Error(w, "internal server error", http.StatusInternalServerError) 89 69 } 90 70 return ··· 118 98 return 119 99 } 120 100 101 + // project mode: rewrite /{repo}/... → /{projectUser}/{repo}/... 102 + // unless the first segment is a reserved standard-route prefix. 103 + if cfg.Project.Enabled && cfg.Project.User != "" { 104 + if firstPart == "" { 105 + r2 := r.Clone(r.Context()) 106 + r2.URL.Path = "/" + cfg.Project.User 107 + r2.URL.RawPath = "/" + cfg.Project.User 108 + userRouter.ServeHTTP(w, r2) 109 + return 110 + } 111 + if _, isStd := standardPrefixes[firstPart]; !isStd { 112 + rewritten := "/" + cfg.Project.User + "/" + pat 113 + r2 := r.Clone(r.Context()) 114 + r2.URL.Path = rewritten 115 + r2.URL.RawPath = rewritten 116 + userRouter.ServeHTTP(w, r2) 117 + return 118 + } 119 + } 121 120 } 122 121 123 122 standardRouter.ServeHTTP(w, r) 124 - }) 123 + } 124 + } 125 + 126 + // standardPrefixes is the set of first path segments that belong to the 127 + // standard (non-user) router. In project mode, any segment not in this set 128 + // is treated as a repo name and rewritten to /{ProjectUser}/{segment}. 129 + var standardPrefixes = map[string]struct{}{ 130 + "static": {}, "home": {}, "timeline": {}, "upgradeBanner": {}, 131 + "newsletter": {}, "core": {}, "login": {}, "logout": {}, 132 + "search": {}, "account": {}, "repo": {}, "goodfirstissues": {}, 133 + "follow": {}, "vouch": {}, "star": {}, "react": {}, 134 + "profile": {}, "settings": {}, "strings": {}, "notifications": {}, 135 + "signup": {}, "keys": {}, "terms": {}, "privacy": {}, "brand": {}, 136 + "oauth": {}, 137 + } 138 + 139 + func (s *State) Router() http.Handler { 140 + router := chi.NewRouter() 141 + middleware := middleware.New( 142 + s.oauth, 143 + s.db, 144 + s.enforcer, 145 + s.aclService, 146 + s.repoResolver, 147 + s.idResolver, 148 + s.pages, 149 + s.rdb, 150 + s.logger, 151 + ) 152 + 153 + router.Use(metrics.Middleware) 154 + router.Use(knotacl.MemoMiddleware) 155 + 156 + if err := db.ReapStaleRunningMigrations(context.Background(), s.db); err != nil { 157 + s.logger.Warn("failed to reap stale running migrations", "err", err) 158 + } 159 + m := migration.NewMigration(s.db, s.oauth, s.idResolver.Directory(), s.logger) 160 + router.Use(m.BackgroundMigrationMiddleware) 161 + 162 + router.Get("/pwa-manifest.json", s.WebAppManifest) 163 + router.Get("/robots.txt", s.RobotsTxt) 164 + router.Get("/.well-known/security.txt", s.SecurityTxt) 165 + 166 + userRouter := s.UserRouter(&middleware) 167 + standardRouter := s.StandardRouter(&middleware) 168 + 169 + router.HandleFunc("/*", newDispatchHandler(s.config, s.db, s.logger, userRouter, standardRouter)) 125 170 126 171 return router 127 172 } ··· 170 215 171 216 tl := avtimeline.New(s.oauth, s.db, s.config, s.pages, s.logger, blog.PostsFS) 172 217 r.Get("/", tl.HomeOrTimeline) 173 - r.Get("/home", tl.Home) 174 - r.Get("/timeline", tl.Timeline) 218 + if s.config.Project.Enabled { 219 + r.Get("/home", http.RedirectHandler("/", http.StatusFound).ServeHTTP) 220 + r.Get("/timeline", http.RedirectHandler("/", http.StatusFound).ServeHTTP) 221 + } else { 222 + r.Get("/home", tl.Home) 223 + r.Get("/timeline", tl.Timeline) 224 + } 175 225 r.Get("/upgradeBanner", s.UpgradeBanner) 176 226 r.Post("/newsletter/signup", s.NewsletterSignup) 177 227 r.Post("/newsletter/dismiss", s.NewsletterDismiss) ··· 253 303 r.Mount("/notifications", s.NotificationsRouter(mw)) 254 304 r.Mount("/focus", s.FocusRouter(mw)) 255 305 256 - r.Mount("/signup", s.SignupRouter()) 306 + if s.config.Project.Enabled { 307 + r.Mount("/signup", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 308 + http.Redirect(w, r, "/", http.StatusFound) 309 + })) 310 + } else { 311 + r.Mount("/signup", s.SignupRouter()) 312 + } 257 313 r.Mount("/", s.oauth.Router()) 258 314 259 315 r.Get("/keys/{user}", s.Keys)
+167
appview/state/router_test.go
··· 1 + package state 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "log/slog" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "tangled.org/core/appview/config" 13 + "tangled.org/core/appview/models" 14 + ) 15 + 16 + // routerFixture builds a minimal chi router wired to newDispatchHandler with 17 + // spy handlers. userPath and stdPath are set to the URL.Path received by 18 + // the respective spy after ServeHTTP returns. 19 + func routerFixture(cfg *config.Config, repoDB fakeRepoDB) (router http.Handler, userPath *string, stdPath *string) { 20 + up := "" 21 + sp := "" 22 + userRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 + up = r.URL.Path 24 + w.WriteHeader(http.StatusOK) 25 + }) 26 + stdRouter := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 + sp = r.URL.Path 28 + w.WriteHeader(http.StatusOK) 29 + }) 30 + 31 + r := chi.NewRouter() 32 + r.HandleFunc("/*", newDispatchHandler(cfg, repoDB, slog.Default(), userRouter, stdRouter)) 33 + return r, &up, &sp 34 + } 35 + 36 + // fakeRepoDB implements db.Execer. The nil value is safe to use for test 37 + // cases that don't exercise the DID-resolution path. 38 + type fakeRepoDB map[string]*models.Repo 39 + 40 + func (f fakeRepoDB) Exec(query string, args ...any) (sql.Result, error) { return nil, nil } 41 + func (f fakeRepoDB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { 42 + return nil, nil 43 + } 44 + func (f fakeRepoDB) Query(query string, args ...any) (*sql.Rows, error) { return nil, nil } 45 + func (f fakeRepoDB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { 46 + return nil, nil 47 + } 48 + func (f fakeRepoDB) QueryRow(query string, args ...any) *sql.Row { return nil } 49 + func (f fakeRepoDB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { 50 + return nil 51 + } 52 + func (f fakeRepoDB) Prepare(query string) (*sql.Stmt, error) { return nil, nil } 53 + func (f fakeRepoDB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { 54 + return nil, nil 55 + } 56 + 57 + // normalCfg returns a config with project mode disabled. 58 + func normalCfg() *config.Config { 59 + return &config.Config{} 60 + } 61 + 62 + // projectCfg returns a config with project mode enabled for the given user. 63 + func projectCfg(user string) *config.Config { 64 + return &config.Config{ 65 + Project: config.ProjectConfig{ 66 + Enabled: true, 67 + User: user, 68 + }, 69 + } 70 + } 71 + 72 + func TestDispatch_NormalMode(t *testing.T) { 73 + cases := []struct { 74 + name string 75 + path string 76 + wantUser string // expected URL seen by userRouter; empty means standardRouter should be hit 77 + wantStd string // expected URL seen by standardRouter; empty means userRouter should be hit 78 + wantCode int // expected HTTP status; 0 means 200 79 + }{ 80 + {"handle", "/user.com/my-repo", "/user.com/my-repo", "", 0}, 81 + {"handle root", "/user.com", "/user.com", "", 0}, 82 + {"@handle redirect", "/@user.com/repo", "", "", http.StatusFound}, 83 + {"flattened did redirect", "/did-plc-abc123xyz", "", "", http.StatusFound}, 84 + {"settings", "/settings/profile", "", "/settings/profile", 0}, 85 + {"login", "/login", "", "/login", 0}, 86 + {"notifications", "/notifications", "", "/notifications", 0}, 87 + {"signup", "/signup/complete", "", "/signup/complete", 0}, 88 + {"root", "/", "", "/", 0}, 89 + {"unknown segment", "/not-a-handle", "", "/not-a-handle", 0}, 90 + } 91 + 92 + for _, tc := range cases { 93 + t.Run(tc.name, func(t *testing.T) { 94 + router, userPath, stdPath := routerFixture(normalCfg(), nil) 95 + req := httptest.NewRequest(http.MethodGet, tc.path, nil) 96 + rr := httptest.NewRecorder() 97 + router.ServeHTTP(rr, req) 98 + 99 + wantCode := tc.wantCode 100 + if wantCode == 0 { 101 + wantCode = http.StatusOK 102 + } 103 + if rr.Code != wantCode { 104 + t.Errorf("status = %d, want %d", rr.Code, wantCode) 105 + } 106 + if tc.wantUser != "" && *userPath != tc.wantUser { 107 + t.Errorf("userRouter received %q, want %q", *userPath, tc.wantUser) 108 + } 109 + if tc.wantStd != "" && *stdPath != tc.wantStd { 110 + t.Errorf("stdRouter received %q, want %q", *stdPath, tc.wantStd) 111 + } 112 + if tc.wantCode == http.StatusFound { 113 + // neither spy should have been called 114 + if *userPath != "" || *stdPath != "" { 115 + t.Errorf("redirect case should not reach routers (user=%q std=%q)", *userPath, *stdPath) 116 + } 117 + } 118 + }) 119 + } 120 + } 121 + 122 + func TestDispatch_ProjectMode(t *testing.T) { 123 + const projectUser = "anirudh.fi" 124 + 125 + cases := []struct { 126 + name string 127 + path string 128 + wantUser string 129 + wantStd string 130 + wantCode int 131 + }{ 132 + {"root becomes profile", "/", "/anirudh.fi", "", 0}, 133 + {"repo", "/my-repo", "/anirudh.fi/my-repo", "", 0}, 134 + {"repo with subpath", "/my-repo/issues/1", "/anirudh.fi/my-repo/issues/1", "", 0}, 135 + {"settings prefix", "/settings/profile", "", "/settings/profile", 0}, 136 + {"login prefix", "/login", "", "/login", 0}, 137 + {"notifications prefix", "/notifications", "", "/notifications", 0}, 138 + {"signup is standard prefix", "/signup", "", "/signup", 0}, 139 + {"search prefix", "/search", "", "/search", 0}, 140 + {"explicit handle still works", "/anirudh.fi/my-repo", "/anirudh.fi/my-repo", "", 0}, 141 + {"other user still works", "/other.user/their-repo", "/other.user/their-repo", "", 0}, 142 + {"@handle redirect", "/@anirudh.fi/repo", "", "", http.StatusFound}, 143 + } 144 + 145 + for _, tc := range cases { 146 + t.Run(tc.name, func(t *testing.T) { 147 + router, userPath, stdPath := routerFixture(projectCfg(projectUser), nil) 148 + req := httptest.NewRequest(http.MethodGet, tc.path, nil) 149 + rr := httptest.NewRecorder() 150 + router.ServeHTTP(rr, req) 151 + 152 + wantCode := tc.wantCode 153 + if wantCode == 0 { 154 + wantCode = http.StatusOK 155 + } 156 + if rr.Code != wantCode { 157 + t.Errorf("status = %d, want %d", rr.Code, wantCode) 158 + } 159 + if tc.wantUser != "" && *userPath != tc.wantUser { 160 + t.Errorf("userRouter received %q, want %q", *userPath, tc.wantUser) 161 + } 162 + if tc.wantStd != "" && *stdPath != tc.wantStd { 163 + t.Errorf("stdRouter received %q, want %q", *stdPath, tc.wantStd) 164 + } 165 + }) 166 + } 167 + }
+8
appview/state/state.go
··· 252 252 cfClient: cfClient, 253 253 } 254 254 255 + if config.Project.Enabled { 256 + if config.Project.User == "" { 257 + logger.Warn("project mode enabled but PROJECT_USER is not set") 258 + } else { 259 + logger.Info("running in project mode", "project_user", config.Project.User) 260 + } 261 + } 262 + 255 263 // fetch initial bluesky posts if configured 256 264 go fetchBskyPosts(ctx, res, config, d, logger) 257 265