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)