Skip to content
157 linesCodeBlameRaw
1import {
2 type MiddlewareFunction,
3 type RouterContextProvider,
4 createContext,
5 data,
6 redirect,
7} from "react-router";
8
9import { type Result, type Role, type User, type Viewer, httpStatus } from "@g1t/contracts";
10
11import { confirmGate } from "./confirm-gate";
12import { safeNext } from "./next";
13import { identity } from "./services.server";
14
15const SESSION_COOKIE = "g1t_session";
16const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
17
18const viewerContext = createContext<Viewer>(null);
19
20function sessionToken(request: Request): string | null {
21 const cookies = request.headers.get("cookie") ?? "";
22 const match = new RegExp(`(?:^|; )${SESSION_COOKIE}=([0-9a-f]{64})`).exec(cookies);
23 return match ? match[1] : null;
24}
25
26function sessionCookie(value: string, maxAge: number): string {
27 return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
28}
29
30/** Pages a signed-in person can use before they have a workspace. */
31const BEFORE_WORKSPACE = ["/workspaces/new", "/settings", "/verify", "/logout", "/auth/github", "/auth/github/callback"];
32
33/**
34 * Root middleware: resolves the signed-in user once per request.
35 *
36 * An account that has not confirmed its email address is sent to confirm
37 * it, from any page but the few that needs (lib/confirm-gate.ts).
38 *
39 * Everything on g1t lives in a workspace, so a confirmed account with none
40 * is sent to create one, from wherever it was going, and returned there
41 * afterwards.
42 */
43export const viewerMiddleware: MiddlewareFunction<Response> = async ({
44 request,
45 context,
46}) => {
47 const token = sessionToken(request);
48 if (!token) return;
49 const viewer = await identity.userForSession(token);
50 context.set(viewerContext, viewer);
51
52 const { pathname, search } = new URL(request.url);
53 // An account that has not confirmed its email address does that first,
54 // from wherever it was going (lib/confirm-gate.ts).
55 const gated = confirmGate(pathname, search, viewer);
56 if (gated) throw redirect(gated);
57 if (
58 request.method === "GET" &&
59 viewer?.verified &&
60 (viewer.workspaces ?? []).length === 0 &&
61 // Someone a repository is shared with can use it without a workspace.
62 (viewer.grants ?? []).length === 0 &&
63 // Someone held out of their workspaces until they meet its policy is
64 // told so, and sent to turn on two-factor authentication, not to make one.
65 (viewer.held ?? []).length === 0 &&
66 !BEFORE_WORKSPACE.includes(pathname) &&
67 !pathname.startsWith("/settings/") &&
68 // An invite to a workspace is how someone without one gets one, and an
69 // invitation to a repository is answered before anything else.
70 !pathname.startsWith("/invite/") &&
71 !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(pathname) &&
72 !pathname.endsWith(".data")
73 ) {
74 const next = pathname === "/" ? "" : `?next=${encodeURIComponent(pathname + search)}`;
75 throw redirect(`/workspaces/new${next}`);
76 }
77};
78
79type Context = Readonly<RouterContextProvider>;
80
81export function getViewer(context: Context): Viewer {
82 return context.get(viewerContext);
83}
84
85/** The viewer's role in a workspace, or null if they are not a member. */
86export function roleIn(viewer: Viewer, slug: string): Role | null {
87 const wanted = slug.toLowerCase();
88 return (
89 viewer?.workspaces?.find((membership) => membership.slug === wanted)?.role ?? null
90 );
91}
92
93/** Whether the viewer may manage a workspace's billing: an owner or a billing manager. */
94export function managesBilling(viewer: Viewer, slug: string): boolean {
95 const membership = viewer?.workspaces?.find((m) => m.slug === slug.toLowerCase());
96 return membership?.role === "owner" || !!membership?.org_roles?.includes("billing_manager");
97}
98
99/** Whether the viewer may manage security across a workspace: an owner or a security manager. */
100export function managesSecurity(viewer: Viewer, slug: string): boolean {
101 const membership = viewer?.workspaces?.find((m) => m.slug === slug.toLowerCase());
102 return membership?.role === "owner" || !!membership?.org_roles?.includes("security_manager");
103}
104
105export function requireUser(context: Context, request: Request): User {
106 const viewer = getViewer(context);
107 if (!viewer) {
108 // Keep the query string: a device sign-in link carries its code there.
109 const { pathname, search } = new URL(request.url);
110 throw redirect(`/login?next=${encodeURIComponent(pathname + search)}`);
111 }
112 return viewer;
113}
114
115/**
116 * Where to go after signing in. Only same-site paths are honoured, so
117 * `next` cannot redirect off g1t.
118 */
119export function nextPath(request: Request): string {
120 return safeNext(new URL(request.url).searchParams.get("next"));
121}
122
123/** `Set-Cookie` value that starts a session. */
124export function startSession(token: string): string {
125 return sessionCookie(token, SESSION_TTL_SECONDS);
126}
127
128/** Ends the session and returns the `Set-Cookie` value that clears it. */
129export async function endSession(request: Request): Promise<string> {
130 const token = sessionToken(request);
131 if (token) await identity.signOut(token);
132 return sessionCookie("", 0);
133}
134
135/** The session token the request carries, for proof of a recent sign-in. */
136export function sessionTokenOf(request: Request): string | null {
137 return sessionToken(request);
138}
139
140/** The visitor's IP address, as Cloudflare saw it, for rate limits. */
141export function clientOf(request: Request): string | null {
142 return request.headers.get("cf-connecting-ip");
143}
144
145/** Rejects cross-site form posts; call at the top of every action. */
146export function assertSameOrigin(request: Request): void {
147 const origin = request.headers.get("origin");
148 if (origin && origin !== new URL(request.url).origin) {
149 throw new Response("Cross-origin request rejected", { status: 403 });
150 }
151}
152
153/** The value of a service result, or the matching HTTP error. */
154export function unwrap<T>(result: Result<T>): T {
155 if (result.ok) return result.value;
156 throw data(result.error.message, { status: httpStatus(result.error) });
157}