Skip to content

Commit

Permalink
feat: add ctx.redirect() helper (#2358)
Browse files Browse the repository at this point in the history
This PR adds a `ctx.redirect(path, status)` helper that aims to reduce
the verbosity of having to manually create a `Response` obejct for a
mere redirect.

```ts
// Before
new Response(null, {
  status: 307,
  headers: {
    Location: "/foo/bar"
  },
})

// After
ctx.redirect("/foo/bar", 307)
```

If the second parameter is not passed, it will default to `302`.
  • Loading branch information
marvinhagemeister committed Mar 12, 2024
1 parent 671751d commit e3508c3
Show file tree
Hide file tree
Showing 8 changed files with 82 additions and 20 deletions.
33 changes: 27 additions & 6 deletions src/server/context.ts
Expand Up @@ -114,6 +114,31 @@ export async function getServerContext(state: InternalFreshState) {
);
}

function redirectTo(pathOrUrl: string = "/", status = 302): Response {
let location = pathOrUrl;

// Disallow protocol relative URLs
if (pathOrUrl !== "/" && pathOrUrl.startsWith("/")) {
let idx = pathOrUrl.indexOf("?");
if (idx === -1) {
idx = pathOrUrl.indexOf("#");
}

const pathname = idx > -1 ? pathOrUrl.slice(0, idx) : pathOrUrl;
const search = idx > -1 ? pathOrUrl.slice(idx) : "";

// Remove double slashes to prevent open redirect vulnerability.
location = `${pathname.replaceAll(/\/+/g, "/")}${search}`;
}

return new Response(null, {
status,
headers: {
location,
},
});
}

export class ServerContext {
#renderFn: RenderFunction;
#plugins: Plugin[];
Expand Down Expand Up @@ -283,6 +308,7 @@ export class ServerContext {
ctx.data = data;
return await renderNotFound(req, ctx);
},
redirect: redirectTo,
route: "",
get pattern() {
return ctx.route;
Expand Down Expand Up @@ -609,12 +635,7 @@ export class ServerContext {
if (key !== null && BUILD_ID !== key) {
url.searchParams.delete(ASSET_CACHE_BUST_KEY);
const location = url.pathname + url.search;
return new Response(null, {
status: 307,
headers: {
location,
},
});
return redirectTo(location, 307);
}
const headers = new Headers({
"content-type": contentType,
Expand Down
3 changes: 2 additions & 1 deletion src/server/types.ts
Expand Up @@ -157,7 +157,7 @@ export type PageProps<T = any, S = Record<string, unknown>> = Omit<
S,
T
>,
"render" | "next" | "renderNotFound"
"render" | "next" | "renderNotFound" | "redirect"
>;

export interface StaticFile {
Expand Down Expand Up @@ -206,6 +206,7 @@ export interface FreshContext<
) => Response | Promise<Response>;
Component: ComponentType<unknown>;
next: () => Promise<Response>;
redirect: (path: string, statusCode?: number) => Response;
}
/**
* Context passed to async route components.
Expand Down
2 changes: 2 additions & 0 deletions tests/fixture/fresh.gen.ts
Expand Up @@ -65,6 +65,7 @@ import * as $not_found from "./routes/not_found.ts";
import * as $params from "./routes/params.tsx";
import * as $preact_boolean_attrs from "./routes/preact/boolean_attrs.tsx";
import * as $props_id_ from "./routes/props/[id].tsx";
import * as $redirect from "./routes/redirect.tsx";
import * as $route_groups_islands_index from "./routes/route-groups-islands/index.tsx";
import * as $route_groups_bar_baz_layout from "./routes/route-groups/(bar)/(baz)/_layout.tsx";
import * as $route_groups_bar_baz_baz from "./routes/route-groups/(bar)/(baz)/baz.tsx";
Expand Down Expand Up @@ -186,6 +187,7 @@ const manifest = {
"./routes/params.tsx": $params,
"./routes/preact/boolean_attrs.tsx": $preact_boolean_attrs,
"./routes/props/[id].tsx": $props_id_,
"./routes/redirect.tsx": $redirect,
"./routes/route-groups-islands/index.tsx": $route_groups_islands_index,
"./routes/route-groups/(bar)/(baz)/_layout.tsx":
$route_groups_bar_baz_layout,
Expand Down
10 changes: 10 additions & 0 deletions tests/fixture/routes/redirect.tsx
@@ -0,0 +1,10 @@
import { FreshContext } from "$fresh/server.ts";

export const handler = {
GET(_req: Request, ctx: FreshContext) {
const rawStatus = ctx.url.searchParams.get("status");
const status = rawStatus !== null ? Number(rawStatus) : undefined;
const location = ctx.url.searchParams.get("path") ?? "/";
return ctx.redirect(location, status);
},
};
10 changes: 2 additions & 8 deletions tests/fixture_base_path/routes/api/rewrite.ts
@@ -1,13 +1,7 @@
import { Handlers } from "$fresh/server.ts";

export const handler: Handlers<unknown, { data: string }> = {
GET(req) {
const url = new URL(req.url);
return new Response(null, {
status: 302,
headers: {
"Location": url.origin,
},
});
GET(_req, ctx) {
return ctx.redirect(ctx.url.origin, 302);
},
};
37 changes: 37 additions & 0 deletions tests/main_test.ts
Expand Up @@ -273,6 +273,42 @@ Deno.test("no open redirect when passing double slashes", async () => {
assertEquals(resp.headers.get("location"), "/evil.com");
});

Deno.test("ctx.redirect() - relative urls", async () => {
let resp = await handler(
new Request("https://fresh.deno.dev/redirect?path=//evil.com/"),
);
assertEquals(resp.status, 302);
assertEquals(resp.headers.get("location"), "/evil.com/");

resp = await handler(
new Request(
"https://fresh.deno.dev/redirect?path=//evil.com//foo&status=307",
),
);
assertEquals(resp.status, 307);
assertEquals(resp.headers.get("location"), "/evil.com/foo");
});

Deno.test("ctx.redirect() - absolute urls", async () => {
const resp = await handler(
new Request("https://fresh.deno.dev/redirect?path=https://example.com/"),
);
assertEquals(resp.status, 302);
assertEquals(resp.headers.get("location"), "https://example.com/");
});

Deno.test("ctx.redirect() - with search and hash", async () => {
const resp = await handler(
new Request(
`https://fresh.deno.dev/redirect?path=${
encodeURIComponent("/foo/bar?baz=123#foo")
}`,
),
);
assertEquals(resp.status, 302);
assertEquals(resp.headers.get("location"), "/foo/bar?baz=123#foo");
});

Deno.test("/failure", async () => {
const resp = await handler(new Request("https://fresh.deno.dev/failure"));
assert(resp);
Expand Down Expand Up @@ -1169,6 +1205,7 @@ Deno.test("Expose config in ctx", async () => {
next: "Function",
render: "AsyncFunction",
renderNotFound: "AsyncFunction",
redirect: "Function",
localAddr: "<undefined>",
pattern: "/ctx_config",
data: "<undefined>",
Expand Down
1 change: 1 addition & 0 deletions tests/server_components_test.ts
Expand Up @@ -117,6 +117,7 @@ Deno.test("passes context to server component", async () => {
params: {
id: "foo",
},
redirect: "Function",
state: {},
isPartial: false,
},
Expand Down
6 changes: 1 addition & 5 deletions www/routes/docs/_middleware.ts
Expand Up @@ -12,11 +12,7 @@ export async function handler(
// Redirect from old doc URLs to new ones
const redirect = REDIRECTS[ctx.url.pathname];
if (redirect) {
const url = new URL(redirect, ctx.url.origin);
return new Response("", {
status: 307,
headers: { location: url.href },
});
return ctx.redirect(redirect, 307);
}

return await ctx.next();
Expand Down

0 comments on commit e3508c3

Please sign in to comment.