Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,152 @@
import { add } from "date-fns";
import { OAuth2Strategy } from "remix-auth-oauth2";
import { z } from "zod";
import { Config } from "~/config";
import { ServerConfig } from "~/config.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
let discordApiCooldownUntil: number | null = null;
const partialDiscordUserSchema = z.object({
avatar: z.string().nullish(),
discriminator: z.string(),
id: z.string(),
username: z.string(),
global_name: z.string().nullish(),
verified: z.boolean().nullish(),
});
const partialDiscordConnectionsSchema = z.array(
z.object({
visibility: z.number(),
verified: z.boolean(),
name: z.string(),
id: z.string(),
type: z.string(),
}),
);
const discordUserDetailsSchema = z.tuple([
partialDiscordUserSchema,
partialDiscordConnectionsSchema,
]);
const discordRateLimitSchema = z.object({
retry_after: z.number(),
});
export const DiscordStrategy = () => {
const jsonIfOk = async (res: Response) => {
if (res.status === 429) {
const body = discordRateLimitSchema.safeParse(await res.clone().json());
const retryAfterSeconds = body.success ? body.data.retry_after : 60;
discordApiCooldownUntil = add(new Date(), {
seconds: retryAfterSeconds,
}).getTime();
logger.warn(
`Discord API rate limited, cooldown for ${retryAfterSeconds}s${body.success ? "" : " (failed to parse retry_after)"}`,
);
}
if (!res.ok) {
throw new Error(
`Auth related call failed with status code ${res.status}`,
);
}
return res.json();
};
const fetchProfileViaDiscordApi = (token: string) => {
if (discordApiCooldownUntil && Date.now() < discordApiCooldownUntil) {
throw new Error("Discord API is rate limited");
}
const authHeader: [string, string] = ["Authorization", `Bearer ${token}`];
return Promise.all([
fetch("https://discord.com/api/users/@me", {
headers: [authHeader],
}).then(jsonIfOk),
fetch("https://discord.com/api/users/@me/connections", {
headers: [authHeader],
}).then(jsonIfOk),
]);
};
return new OAuth2Strategy(
{
clientId: ServerConfig.discord.clientId,
clientSecret: ServerConfig.discord.clientSecret,
authorizationEndpoint: "https://discord.com/api/oauth2/authorize",
tokenEndpoint: "https://discord.com/api/oauth2/token",
redirectURI: new URL("/auth/callback", Config.siteDomain).toString(),
scopes: ["identify", "connections", "email"],
},
async ({ tokens }) => {
try {
const discordResponses = await fetchProfileViaDiscordApi(
tokens.accessToken(),
);
const [user, connections] =
discordUserDetailsSchema.parse(discordResponses);
const isAlreadyRegistered = Boolean(
await UserRepository.findIdByIdentifier(user.id),
);
if (!isAlreadyRegistered && !user.verified) {
logger.info(`User is not verified with id: ${user.id}`);
throw new Error("Unverified user");
}
const userFromDb = await UserRepository.upsert({
discordAvatar: user.avatar ?? null,
discordId: user.id,
discordName: user.global_name ?? user.username,
discordUniqueName: user.global_name ? user.username : null,
...parseConnections(connections),
});
return userFromDb.id;
} catch (e) {
logger.error("Failed to finish authentication:\n", e);
throw e;
}
},
);
};
function parseConnections(
connections: z.infer<typeof partialDiscordConnectionsSchema>,
) {
if (!connections) throw new Error("No connections");
const result: {
twitch: string | null;
youtubeId: string | null;
bsky: string | null;
} = {
twitch: null,
youtubeId: null,
bsky: null,
};
for (const connection of connections) {
if (connection.visibility !== 1 || !connection.verified) continue;
switch (connection.type) {
case "twitch":
result.twitch = connection.name;
break;
case "youtube":
result.youtubeId = connection.id;
break;
case "bluesky":
result.bsky = connection.name;
}
}
return result;
}

View File

@@ -0,0 +1,9 @@
import { Authenticator } from "remix-auth";
import { DiscordStrategy } from "./DiscordStrategy.server";
export const SESSION_KEY = "user";
export const IMPERSONATED_SESSION_KEY = "impersonated_user";
export const authenticator = new Authenticator<number>();
authenticator.use(DiscordStrategy(), "discord");

View File

@@ -0,0 +1,6 @@
export type AuthErrorCode =
| "aborted"
| "discordOverloaded"
| "unverifiedEmail"
| "browserPrivacy"
| "unknown";

View File

@@ -0,0 +1,265 @@
import { isbot } from "isbot";
import type { ActionFunction, LoaderFunction } from "react-router";
import { redirect } from "react-router";
import { z } from "zod";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isAdmin, isStaff } from "~/modules/permissions/utils";
import { logger } from "~/utils/logger";
import {
canAccessLohiEndpoint,
errorToastRedirect,
} from "~/utils/remix.server";
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
import * as LogInLinkRepository from "../LogInLinkRepository.server";
import {
authenticator,
IMPERSONATED_SESSION_KEY,
SESSION_KEY,
} from "./authenticator.server";
import type { AuthErrorCode } from "./errors";
import { authSessionStorage } from "./session.server";
import { getUser } from "./user.server";
export const callbackLoader: LoaderFunction = async ({ request, url }) => {
// biome-ignore lint/plugin: OAuth callback param, its name and values defined by the provider
if (url.searchParams.get("error") === "access_denied") {
// The user denied the authentication request
// https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/
throw redirect(authErrorUrl("aborted"));
}
try {
const userId = await authenticator.authenticate("discord", request);
const session = await authSessionStorage.getSession(
request.headers.get(SESSION_KEY),
);
session.set(SESSION_KEY, userId);
return redirect("/", {
headers: {
"Set-Cookie": await authSessionStorage.commitSession(session),
},
});
} catch (error) {
if (error instanceof Error) {
logger.error(
`Error during authentication (${classifyAuthError(error)}):`,
error,
);
throw redirect(authErrorUrl(classifyAuthError(error)));
}
throw error;
}
};
export const logOutAction: ActionFunction = async ({ request }) => {
const session = await authSessionStorage.getSession(
request.headers.get(SESSION_KEY),
);
return redirect("/", {
headers: { "Set-Cookie": await authSessionStorage.destroySession(session) },
});
};
export const logInAction: ActionFunction = async ({ request }) => {
return await authenticator.authenticate("discord", request);
};
export const impersonateAction: ActionFunction = async ({ request, url }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = requireUser();
if (!user.roles.includes("ADMIN") && !user.roles.includes("DEV")) {
throw new Response("Forbidden", { status: 403 });
}
if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) {
// biome-ignore lint/plugin: a missing or malformed `id` must 400, not fall back to a default
const targetId = Number(url.searchParams.get("id"));
if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {
throw new Response("Forbidden", { status: 403 });
}
}
}
const returnTo = await safeReturnTo(request);
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
const realUserId = session.get(SESSION_KEY);
// biome-ignore-start lint/plugin: a missing or malformed `id` must 400, not fall back to a default
const rawId = url.searchParams.get("id");
const userId = Number(url.searchParams.get("id"));
// biome-ignore-end lint/plugin: a missing or malformed `id` must 400, not fall back to a default
if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 });
logger.info(
`Impersonation: user ${realUserId} started impersonating user ${userId}`,
);
session.set(IMPERSONATED_SESSION_KEY, userId);
throw redirect(returnTo ?? ADMIN_PAGE, {
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
});
};
export const stopImpersonatingAction: ActionFunction = async ({ request }) => {
const returnTo = await safeReturnTo(request);
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
const realUserId = session.get(SESSION_KEY);
const impersonatedUserId = session.get(IMPERSONATED_SESSION_KEY);
logger.info(
`Impersonation: user ${realUserId} stopped impersonating user ${impersonatedUserId}`,
);
session.unset(IMPERSONATED_SESSION_KEY);
throw redirect(returnTo ?? ADMIN_PAGE, {
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
});
};
async function safeReturnTo(request: Request): Promise<string | null> {
if (!request.headers.get("Content-Type")?.includes("form")) return null;
const value = (await request.formData()).get("returnTo");
if (typeof value !== "string") return null;
if (!value.startsWith("/") || value.startsWith("//")) return null;
return value;
}
// below is alternative log-in flow that is operated via the Lohi Discord bot
// this is intended primarily as a workaround when website is having problems communicating
// with the Discord due to rate limits or other reasons
// only light validation here as we generally trust Lohi
// auth flow params are infrastructure conventions and intentionally do not go
// through app/modules/search-params/
function parseSearchParams<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): z.infer<T> {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
try {
return schema.parse(searchParams);
} catch (e) {
logger.error("Error parsing search params", e);
throw errorToastRedirect("Validation failed");
}
}
const createLogInLinkActionSchema = z.object({
discordId: z.string(),
discordAvatar: z.string().nullish(),
discordName: z.string(),
discordUniqueName: z.string(),
updateOnly: z.enum(["true", "false"]),
});
export const createLogInLinkAction: ActionFunction = async ({ request }) => {
const data = parseSearchParams({
request,
schema: createLogInLinkActionSchema,
});
if (!canAccessLohiEndpoint(request)) {
throw new Response(null, { status: 403 });
}
const user = await UserRepository.upsert({
discordAvatar: data.discordAvatar ?? null,
discordId: data.discordId,
discordName: data.discordName,
discordUniqueName: data.discordUniqueName,
});
if (data.updateOnly === "true") return null;
const createdLink = await LogInLinkRepository.insert(user.id);
return {
code: createdLink.code,
};
};
const logInViaLinkActionSchema = z.object({
code: z.string(),
});
export const logInViaLinkLoader: LoaderFunction = async ({ request }) => {
// make sure Discord link preview doesn't consume the login link
const userAgent = request.headers.get("user-agent");
if (userAgent && isbot(userAgent)) {
return null;
}
const data = parseSearchParams({
request,
schema: logInViaLinkActionSchema,
});
const user = getUser();
if (user) {
throw redirect("/");
}
const result = await LogInLinkRepository.findValidByCode(data.code);
if (!result) {
throw new Response("Invalid log in link", { status: 400 });
}
const userId = result.userId;
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
session.set(SESSION_KEY, userId);
await LogInLinkRepository.deleteByCode(data.code);
throw redirect("/", {
headers: { "Set-Cookie": await authSessionStorage.commitSession(session) },
});
};
function classifyAuthError(error: Error): AuthErrorCode {
const message = error.message;
if (
message.includes("rate limited") ||
("status" in error && error.status === 429)
) {
return "discordOverloaded";
}
if (message === "Unverified user") {
return "unverifiedEmail";
}
if (message.includes("Missing state")) {
return "browserPrivacy";
}
return "unknown";
}

View File

@@ -0,0 +1,20 @@
import { createCookieSessionStorage } from "react-router";
import { ServerConfig } from "~/config.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
const ONE_YEAR_IN_SECONDS = 31_536_000;
export const authSessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
sameSite: "lax",
// need to specify domain so that sub-domains can access it
domain:
ServerConfig.isProduction && !IS_E2E_TEST_RUN ? "sendou.ink" : undefined,
path: "/",
httpOnly: true,
secrets: [ServerConfig.sessionSecret],
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
maxAge: ONE_YEAR_IN_SECONDS,
},
});

View File

@@ -0,0 +1,51 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { redirect } from "react-router";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { SUSPENDED_PAGE } from "~/utils/urls";
import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server";
import { authSessionStorage } from "./session.server";
export type AuthenticatedUser = NonNullable<
Awaited<ReturnType<typeof UserRepository.findLeanById>>
>;
interface UserContext {
user: AuthenticatedUser | undefined;
}
export const userAsyncLocalStorage = new AsyncLocalStorage<UserContext>();
export function getUserContext(): UserContext {
const context = userAsyncLocalStorage.getStore();
if (!context) {
throw new Error("getUserContext called outside of user middleware context");
}
return context;
}
export async function getUserFromRequest(
request: Request,
url: URL,
): Promise<AuthenticatedUser | undefined> {
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
const userId =
session.get(IMPERSONATED_SESSION_KEY) ?? session.get(SESSION_KEY);
if (!userId) return undefined;
if (userIsBanned(userId)) {
const isExemptPath =
url.pathname === SUSPENDED_PAGE ||
// needed for ban E2E tests
url.pathname.startsWith("/auth/impersonate");
if (!isExemptPath) {
throw redirect(SUSPENDED_PAGE);
}
}
return UserRepository.findLeanById(userId);
}

View File

@@ -0,0 +1,21 @@
import {
getUserFromRequest,
userAsyncLocalStorage,
} from "./user-context.server";
type MiddlewareArgs = {
request: Request;
url: URL;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
export const userMiddleware: MiddlewareFn = async ({ request, url }, next) => {
const user = await getUserFromRequest(request, url);
return userAsyncLocalStorage.run({ user }, () => next());
};

View File

@@ -0,0 +1,62 @@
import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server";
import { authSessionStorage } from "./session.server";
import {
type AuthenticatedUser,
getUserContext,
userAsyncLocalStorage,
} from "./user-context.server";
export type { AuthenticatedUser };
export function getUser(): AuthenticatedUser | undefined {
const context = getUserContext();
return context.user;
}
export function requireUser(): AuthenticatedUser {
const user = getUser();
if (!user) throw new Response(null, { status: 401 });
return user;
}
/** Id of the acting user, from request context. Throws an Error if there is no
* authenticated user (e.g. called outside a request) — repositories rely on a
* bouncer having already enforced auth, so absence here is a bug, not a 401. */
export function actorId(): number {
const id = actorIdOrNull();
if (id === null) throw new Error("No acting user in context");
return id;
}
/** Id of the acting user, or null when unauthenticated. Use for reads that
* also serve anonymous visitors, where the actor only scopes the result. */
export function actorIdOrNull(): number | null {
return getUser()?.id ?? null;
}
/** Id of the acting user, or null when there is no actor *or* no request
* context at all (e.g. cron routines). Never throws, unlike actorIdOrNull —
* use for ambient side effects that may also run outside of a request. */
export function actorIdOrNullSafe(): number | null {
return userAsyncLocalStorage.getStore()?.user?.id ?? null;
}
export async function isImpersonating(request: Request) {
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
return Boolean(session.get(IMPERSONATED_SESSION_KEY));
}
export async function getRealUserId(
request: Request,
): Promise<number | undefined> {
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
return session.get(SESSION_KEY) as number | undefined;
}

View File

@@ -0,0 +1,8 @@
import { useMatches } from "react-router";
import type { RootLoaderData } from "~/root";
export function useUser() {
const [root] = useMatches();
return (root.loaderData as RootLoaderData | undefined)?.user;
}