Skip to content

Commit dc52941

Browse files
authored
feat(webapp): add /_/* redirect route (#4523)
1 parent 0a44b88 commit dc52941

4 files changed

Lines changed: 455 additions & 0 deletions

File tree

.server-changes/deeplink-routes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Short links like /_/apikeys now take you straight to that page in your current project and environment, so you no longer need the full URL with your org, project and environment in it.

apps/webapp/app/routes/[_].$.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { prisma } from "~/db.server";
3+
import { getUsersInvites } from "~/models/member.server";
4+
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
5+
import { requireUser } from "~/services/session.server";
6+
import { deeplinkSuffix, resolveDeeplinkPage } from "~/utils/deeplinkPages";
7+
import {
8+
invitesPath,
9+
newOrganizationPath,
10+
newProjectPath,
11+
v3EnvironmentPath,
12+
} from "~/utils/pathBuilder";
13+
14+
//`[_]` escapes the underscore: an unescaped `_.$` is a pathless layout, mounted at `/*`.
15+
export const loader = async ({ request }: LoaderFunctionArgs) => {
16+
const user = await requireUser(request);
17+
18+
const { pathname, search } = new URL(request.url);
19+
const page = resolveDeeplinkPage(deeplinkSuffix(pathname));
20+
21+
const invites = await getUsersInvites({ email: user.email });
22+
if (invites.length > 0) {
23+
return redirect(invitesPath());
24+
}
25+
26+
const presenter = new SelectBestEnvironmentPresenter();
27+
try {
28+
const { project, organization, environment } = await presenter.call({ user });
29+
const environmentPath = v3EnvironmentPath(organization, project, environment);
30+
31+
const suffix = page ? `/${page}` : "";
32+
33+
return redirect(`${environmentPath}${suffix}${search}`);
34+
} catch (_e) {
35+
const organization = await prisma.organization.findFirst({
36+
where: {
37+
members: {
38+
some: {
39+
userId: user.id,
40+
},
41+
},
42+
deletedAt: null,
43+
},
44+
orderBy: {
45+
createdAt: "desc",
46+
},
47+
});
48+
49+
if (organization) {
50+
return redirect(newProjectPath(organization));
51+
}
52+
53+
return redirect(newOrganizationPath());
54+
}
55+
};
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { flatRoutes } from "@remix-run/dev/dist/config/flat-routes.js";
2+
import type { RouteManifest } from "@remix-run/dev/dist/config/routes.js";
3+
import { matchPath } from "@remix-run/router";
4+
import { existsSync, readdirSync, statSync } from "node:fs";
5+
import { join } from "node:path";
6+
import { describe, expect, it } from "vitest";
7+
import {
8+
DEEPLINK_PATH_PREFIX,
9+
deeplinkSuffix,
10+
ENV_PAGE_TARGETS,
11+
resolveDeeplinkPage,
12+
} from "./deeplinkPages";
13+
14+
const APP_DIR = join(__dirname, "..");
15+
const ROUTES_DIR = join(APP_DIR, "routes");
16+
17+
// The trailing dot excludes the environment layout route itself, which has no segment of its own.
18+
const ENV_ROUTE_PREFIX = "_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.";
19+
20+
// Route files that name no deeplink: the environment root, and Remix's layout-opt-out spelling.
21+
const NOT_DEEPLINK_NAMES = new Set(["_index", "queues_"]);
22+
23+
const PROBE = "probe_01ABC";
24+
25+
const DEEPLINK_ROUTE_FILE = "routes/[_].$.ts";
26+
27+
const compiledRoutes: RouteManifest = flatRoutes(APP_DIR, ["**/.*"]);
28+
29+
function compiledUrl(id: string): string {
30+
if (!compiledRoutes[id]) throw new Error(`no compiled route with id ${id}`);
31+
32+
const segments: string[] = [];
33+
let route = compiledRoutes[id];
34+
while (route) {
35+
if (route.path) segments.unshift(route.path);
36+
route = route.parentId ? compiledRoutes[route.parentId] : undefined;
37+
}
38+
return `/${segments.join("/")}`;
39+
}
40+
41+
const COMPILED_DEEPLINK_PATH = (() => {
42+
const entry = Object.values(compiledRoutes).find((route) => route.file === DEEPLINK_ROUTE_FILE);
43+
if (!entry) throw new Error(`${DEEPLINK_ROUTE_FILE} is not in the compiled route manifest`);
44+
return compiledUrl(entry.id).replace(/\/\*$/, "");
45+
})();
46+
47+
const routeEntries = readdirSync(ROUTES_DIR);
48+
49+
function isRouteModule(entry: string): boolean {
50+
const path = join(ROUTES_DIR, entry);
51+
if (!statSync(path).isDirectory()) return true;
52+
return existsSync(join(path, "route.tsx")) || existsSync(join(path, "route.ts"));
53+
}
54+
55+
// A trailing `_` only opts out of the parent layout: `queues_.$queueParam` serves `/queues/{id}`.
56+
const envRoutes: string[][] = routeEntries
57+
.filter((entry) => entry.startsWith(ENV_ROUTE_PREFIX) && isRouteModule(entry))
58+
.map((entry) =>
59+
entry
60+
.slice(ENV_ROUTE_PREFIX.length)
61+
.replace(/\.(tsx|ts)$/, "")
62+
.split(".")
63+
)
64+
.map((segments) => (segments.at(-1) === "_index" ? segments.slice(0, -1) : segments))
65+
.map((segments) => segments.map((segment) => segment.replace(/_+$/, "")));
66+
67+
function routeMatches(path: string, { allowParams }: { allowParams: boolean }): boolean {
68+
const wanted = path === "" ? [] : path.split("/");
69+
return envRoutes.some(
70+
(route) =>
71+
route.length === wanted.length &&
72+
route.every((segment, i) => (segment.startsWith("$") ? allowParams : segment === wanted[i]))
73+
);
74+
}
75+
76+
function envRouteSegments(): Set<string> {
77+
const segments = new Set<string>();
78+
for (const entry of routeEntries) {
79+
if (!entry.startsWith(ENV_ROUTE_PREFIX)) continue;
80+
const segment = entry.slice(ENV_ROUTE_PREFIX.length).split(/[./]/)[0];
81+
if (!segment || segment === "ts" || segment === "tsx") continue;
82+
segments.add(segment);
83+
}
84+
return segments;
85+
}
86+
87+
function descendantsOf(prefix: string): string[][] {
88+
const depth = prefix === "" ? 0 : prefix.split("/").length;
89+
return envRoutes
90+
.filter((route) => route.length > depth && route.slice(0, depth).join("/") === prefix)
91+
.map((route) =>
92+
route.slice(depth).map((segment) => (segment.startsWith("$") ? PROBE : segment))
93+
);
94+
}
95+
96+
describe("deeplink targets", () => {
97+
it("read enough routes for the assertions below to mean anything", () => {
98+
expect(envRouteSegments().size).toBeGreaterThan(20);
99+
expect(envRoutes.length).toBeGreaterThan(40);
100+
});
101+
102+
it("every bare name lands on a real page that needs no id", () => {
103+
const broken = [...ENV_PAGE_TARGETS.entries()]
104+
.filter(([name]) => !routeMatches(resolveDeeplinkPage(name) ?? " ", { allowParams: false }))
105+
.map(([name, { landing }]) => `${name} -> ${landing || "(environment root)"}`);
106+
107+
expect(broken).toEqual([]);
108+
});
109+
110+
it("every deep path lands on a real route, prefix graft included", () => {
111+
const broken: string[] = [];
112+
113+
for (const [name, { prefix }] of ENV_PAGE_TARGETS) {
114+
for (const rest of descendantsOf(prefix)) {
115+
const suffix = [name, ...rest].join("/");
116+
const resolved = resolveDeeplinkPage(suffix);
117+
if (!routeMatches(resolved ?? " ", { allowParams: true })) {
118+
broken.push(`${suffix} -> ${resolved}`);
119+
}
120+
}
121+
}
122+
123+
expect(broken).toEqual([]);
124+
});
125+
126+
it("has deep paths worth checking", () => {
127+
expect(descendantsOf("waitpoints/tokens").length).toBeGreaterThan(0);
128+
expect(descendantsOf("tasks").length).toBeGreaterThan(2);
129+
expect(descendantsOf("runs").length).toBeGreaterThan(0);
130+
});
131+
132+
it("every environment page has a deeplink name", () => {
133+
const missing = [...envRouteSegments()]
134+
.filter((segment) => !NOT_DEEPLINK_NAMES.has(segment))
135+
.filter(
136+
(segment) => routeMatches(segment, { allowParams: false }) && !ENV_PAGE_TARGETS.has(segment)
137+
)
138+
.sort();
139+
140+
expect(missing).toEqual([]);
141+
});
142+
143+
it("points a 404ing name elsewhere, and gives a redirect shim no name at all", () => {
144+
for (const segment of ["tasks", "waitpoints", "metrics"]) {
145+
expect(routeMatches(segment, { allowParams: false })).toBe(false);
146+
}
147+
148+
expect(ENV_PAGE_TARGETS.get("tasks")).toEqual({ landing: "", prefix: "tasks" });
149+
expect(ENV_PAGE_TARGETS.get("waitpoints")).toEqual({
150+
landing: "waitpoints/tokens",
151+
prefix: "waitpoints/tokens",
152+
});
153+
expect(ENV_PAGE_TARGETS.has("metrics")).toBe(false);
154+
});
155+
});
156+
157+
describe("resolveDeeplinkPage", () => {
158+
it("maps a bare name to its landing page", () => {
159+
expect(resolveDeeplinkPage("apikeys")).toBe("apikeys");
160+
expect(resolveDeeplinkPage("waitpoints")).toBe("waitpoints/tokens");
161+
expect(resolveDeeplinkPage("tasks")).toBe("");
162+
});
163+
164+
it("grafts deeper segments onto the prefix", () => {
165+
expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123");
166+
expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task");
167+
expect(resolveDeeplinkPage("waitpoints/waitpoint_123")).toBe("waitpoints/tokens/waitpoint_123");
168+
});
169+
170+
it("does not duplicate a prefix the caller already wrote out", () => {
171+
expect(resolveDeeplinkPage("waitpoints/tokens")).toBe("waitpoints/tokens");
172+
expect(resolveDeeplinkPage("waitpoints/tokens/waitpoint_123")).toBe(
173+
"waitpoints/tokens/waitpoint_123"
174+
);
175+
});
176+
177+
it("rejects a name that is not a page", () => {
178+
expect(resolveDeeplinkPage("")).toBeUndefined();
179+
expect(resolveDeeplinkPage("nonsense")).toBeUndefined();
180+
expect(resolveDeeplinkPage("metrics")).toBeUndefined();
181+
});
182+
183+
it("matches the page name whatever its case, and resolves it to the map's spelling", () => {
184+
expect(resolveDeeplinkPage("APIKeys")).toBe("apikeys");
185+
expect(resolveDeeplinkPage("Waitpoints")).toBe("waitpoints/tokens");
186+
expect(resolveDeeplinkPage("TASKS")).toBe("");
187+
expect(resolveDeeplinkPage("Bulk-Actions")).toBe("bulk-actions");
188+
expect(resolveDeeplinkPage("Nonsense")).toBeUndefined();
189+
expect(resolveDeeplinkPage("Metrics")).toBeUndefined();
190+
});
191+
192+
it("leaves the case of everything after the name alone", () => {
193+
expect(resolveDeeplinkPage("runs/run_ABC123")).toBe("runs/run_ABC123");
194+
expect(resolveDeeplinkPage("Runs/run_ABC123")).toBe("runs/run_ABC123");
195+
expect(resolveDeeplinkPage("TASKS/standard/My-Task")).toBe("tasks/standard/My-Task");
196+
expect(resolveDeeplinkPage("Waitpoints/waitpoint_ABC")).toBe("waitpoints/tokens/waitpoint_ABC");
197+
expect(resolveDeeplinkPage("Waitpoints/tokens/waitpoint_ABC")).toBe(
198+
"waitpoints/tokens/waitpoint_ABC"
199+
);
200+
expect(resolveDeeplinkPage("Tasks/standard/Group%2FMy-Task")).toBe(
201+
"tasks/standard/Group%2FMy-Task"
202+
);
203+
});
204+
205+
it("recognises a written-out prefix whatever its case, however many segments it spans", () => {
206+
expect(resolveDeeplinkPage("Waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123");
207+
expect(resolveDeeplinkPage("waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123");
208+
expect(resolveDeeplinkPage("WAITPOINTS/TOKENS/wp_123")).toBe("waitpoints/tokens/wp_123");
209+
expect(resolveDeeplinkPage("Waitpoints/Tokens")).toBe("waitpoints/tokens");
210+
});
211+
212+
it("holds for every multi-segment prefix in the map, not just waitpoints", () => {
213+
const multiSegment = [...ENV_PAGE_TARGETS.values()].filter(({ prefix }) =>
214+
prefix.includes("/")
215+
);
216+
217+
expect(multiSegment.length).toBeGreaterThan(0);
218+
219+
for (const { prefix } of multiSegment) {
220+
const shouted = prefix
221+
.split("/")
222+
.map((segment) => segment.toUpperCase())
223+
.join("/");
224+
expect(resolveDeeplinkPage(`${shouted}/${PROBE}`)).toBe(`${prefix}/${PROBE}`);
225+
expect(resolveDeeplinkPage(shouted)).toBe(prefix);
226+
}
227+
});
228+
229+
it("drops traversal segments, in plain and escaped spellings", () => {
230+
expect(resolveDeeplinkPage("runs/../../../etc/passwd")).toBe("runs/etc/passwd");
231+
expect(resolveDeeplinkPage("../runs")).toBe("runs");
232+
expect(resolveDeeplinkPage("runs//run_1")).toBe("runs/run_1");
233+
expect(resolveDeeplinkPage("runs/%2e%2e/%2E%2E/run_1")).toBe("runs/run_1");
234+
expect(resolveDeeplinkPage("runs/%2e/run_1")).toBe("runs/run_1");
235+
expect(resolveDeeplinkPage("runs/%ZZ/run_1")).toBe("runs/run_1");
236+
});
237+
238+
it("passes encoded segments through without re-encoding them", () => {
239+
expect(resolveDeeplinkPage("tasks/standard/group%2Fmy-task")).toBe(
240+
"tasks/standard/group%2Fmy-task"
241+
);
242+
expect(resolveDeeplinkPage("runs/a%3Fb%23c")).toBe("runs/a%3Fb%23c");
243+
// The slash stays escaped, so this addresses one odd id rather than climbing out.
244+
expect(resolveDeeplinkPage("runs/..%2f..%2fetc")).toBe("runs/..%2f..%2fetc");
245+
});
246+
});
247+
248+
describe("the route Remix compiles from the filename", () => {
249+
it("mounts the deeplink route at /_ and nowhere else", () => {
250+
expect(COMPILED_DEEPLINK_PATH).toBe("/_");
251+
expect(COMPILED_DEEPLINK_PATH).toBe(DEEPLINK_PATH_PREFIX);
252+
});
253+
254+
it("does not mount anything as a site-wide splat", () => {
255+
const siteWide = Object.values(compiledRoutes)
256+
.filter((route) => compiledUrl(route.id) === "/*")
257+
.map((route) => route.file);
258+
259+
expect(siteWide).toEqual([]);
260+
});
261+
262+
it("compiled the manifest it is reading, paths and all", () => {
263+
expect(Object.keys(compiledRoutes).length).toBeGreaterThan(400);
264+
expect(compiledUrl("routes/login.magic")).toBe("/login/magic");
265+
expect(
266+
compiledUrl(
267+
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam"
268+
)
269+
).toBe("/orgs/:organizationSlug/projects/:projectParam/env/:envParam/queues/:queueParam");
270+
});
271+
});
272+
273+
describe("deeplinkSuffix", () => {
274+
it("strips the route's own prefix", () => {
275+
expect(deeplinkSuffix("/_/tasks")).toBe("tasks");
276+
expect(deeplinkSuffix("/_/runs/run_123")).toBe("runs/run_123");
277+
});
278+
279+
it("keeps an escaped slash intact, unlike the decoded splat param", () => {
280+
expect(deeplinkSuffix("/_/tasks/standard/group%2Fmy-task")).toBe(
281+
"tasks/standard/group%2Fmy-task"
282+
);
283+
});
284+
285+
it("strips only the prefix, leaving the remainder's case alone", () => {
286+
expect(deeplinkSuffix("/_/runs/run_ABC123")).toBe("runs/run_ABC123");
287+
expect(deeplinkSuffix("/_/tasks/standard/My-Task")).toBe("tasks/standard/My-Task");
288+
});
289+
290+
it("matches the URL the router serves for it, splat case and all", () => {
291+
const route = `${COMPILED_DEEPLINK_PATH}/*`;
292+
expect(matchPath(route, "/_/apikeys")?.params["*"]).toBe("apikeys");
293+
expect(matchPath(route, "/_/runs/run_123")?.params["*"]).toBe("runs/run_123");
294+
expect(matchPath(route, "/_/APIKeys")?.params["*"]).toBe("APIKeys");
295+
expect(matchPath(route, "/deeplink/apikeys")).toBeNull();
296+
expect(matchPath(route, "/apikeys")).toBeNull();
297+
});
298+
299+
it("treats a bare prefix, a trailing slash and anything outside it as no suffix", () => {
300+
expect(deeplinkSuffix("/_")).toBe("");
301+
expect(deeplinkSuffix("/_/")).toBe("");
302+
expect(deeplinkSuffix("/etc")).toBe("");
303+
expect(deeplinkSuffix("/_app/orgs")).toBe("");
304+
});
305+
306+
it("matches what the URL parser actually produces, keeping %2F and resolving %2e%2e", () => {
307+
const encodedSlash = new URL("http://x/_/tasks/standard/group%2Fmy-task");
308+
expect(deeplinkSuffix(encodedSlash.pathname)).toBe("tasks/standard/group%2Fmy-task");
309+
expect(resolveDeeplinkPage(deeplinkSuffix(encodedSlash.pathname))).toBe(
310+
"tasks/standard/group%2Fmy-task"
311+
);
312+
313+
const traversal = new URL("http://x/_/runs/%2e%2e/%2e%2e/etc");
314+
expect(traversal.pathname).toBe("/etc");
315+
expect(resolveDeeplinkPage(deeplinkSuffix(traversal.pathname))).toBeUndefined();
316+
});
317+
});

0 commit comments

Comments
 (0)