Skip to content

Commit e3de7f7

Browse files
committed
fix: serve public assets on self-hosted deployments
When NEXT_PUBLIC_IS_CAP is not "true", proxy.ts redirects every path outside its allowlist to /login, and the matcher only exempts favicon.ico, robots.txt and sitemap.xml. Every other file under apps/web/public therefore 307s to /login on a self-hosted instance and renders broken. Let any path with a file extension through the self-hosted redirect. Such a path is either a static file or a 404, never a page, so the login gate has nothing to protect there, and new asset types work without a matcher edit.
1 parent 284ae24 commit e3de7f7

2 files changed

Lines changed: 57 additions & 1 deletion

File tree

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,62 @@
11
import { readFileSync } from "node:fs";
22
import { join } from "node:path";
3-
import { describe, expect, it } from "vitest";
3+
import { NextRequest } from "next/server";
4+
import { describe, expect, it, vi } from "vitest";
5+
import { proxy } from "../../proxy";
6+
7+
vi.mock("@cap/database", () => ({
8+
db: () => {
9+
throw new Error("Database should not be reached on self-hosted routes");
10+
},
11+
}));
12+
13+
vi.mock("@cap/database/schema", () => ({ organizations: {} }));
14+
15+
vi.mock("@cap/env", () => ({
16+
buildEnv: { NEXT_PUBLIC_IS_CAP: "false" },
17+
serverEnv: () => ({
18+
WEB_URL: "https://cap.example.com",
19+
VERCEL_URL_HOST: undefined,
20+
VERCEL_BRANCH_URL_HOST: undefined,
21+
VERCEL_PROJECT_PRODUCTION_URL_HOST: undefined,
22+
}),
23+
}));
24+
25+
const request = (path: string) =>
26+
proxy(new NextRequest(`https://cap.example.com${path}`));
427

528
describe("self-hosted proxy routes", () => {
629
it("allows browser-based CLI authorization pages", () => {
730
const source = readFileSync(join(process.cwd(), "proxy.ts"), "utf8");
831
expect(source).toContain('path.startsWith("/cli/")');
932
});
33+
34+
it.each([
35+
"/logos/browsers/google-chrome.svg",
36+
"/illustrations/mask-bg.webp",
37+
"/sounds/recording-start.mp3",
38+
"/rive/main.riv",
39+
"/fonts/Inter.woff2",
40+
])("serves the public asset %s instead of redirecting", async (path) => {
41+
const response = await request(path);
42+
43+
expect(response.status).toBe(200);
44+
expect(response.headers.get("location")).toBeNull();
45+
});
46+
47+
it("still redirects unauthenticated page routes to /login", async () => {
48+
const response = await request("/pricing");
49+
50+
expect(response.status).toBe(307);
51+
expect(response.headers.get("location")).toBe(
52+
"https://cap.example.com/login",
53+
);
54+
});
55+
56+
it("does not treat a share link as a static asset", async () => {
57+
const response = await request("/s/video123");
58+
59+
expect(response.status).toBe(200);
60+
expect(response.headers.get("location")).toBeNull();
61+
});
1062
});

apps/web/proxy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,12 @@ export async function proxy(request: NextRequest) {
5454
const hostname = url.hostname;
5555

5656
if (buildEnv.NEXT_PUBLIC_IS_CAP !== "true") {
57+
// Files under public/ have no route of their own, so without this every
58+
// <img src="/logos/..."> on a self-hosted instance redirects to /login.
59+
const isStaticAsset = /\.[a-z0-9]+$/i.test(path);
5760
if (
5861
!(
62+
isStaticAsset ||
5963
path.startsWith("/s/") ||
6064
path.startsWith("/c/") ||
6165
path.startsWith("/cli/") ||

0 commit comments

Comments
 (0)