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