Zod validated config for security and ease of getting started

This commit is contained in:
Kalle 2026-06-15 14:12:15 +03:00
parent 215243e071
commit 75179e5077
46 changed files with 386 additions and 275 deletions

View File

@ -1,3 +1,5 @@
PORT=5173
DB_PATH=db.sqlite3
LOHI_TOKEN=salmon
SESSION_SECRET=secret
@ -5,7 +7,6 @@ SESSION_SECRET=secret
// Auth https://discord.com/developers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_ADMIN_WEBHOOK_URL=
// Patreon integration to sync supporter status https://www.patreon.com/portal/registration/register-clients
PATREON_ACCESS_TOKEN=
@ -16,7 +17,6 @@ STORAGE_ACCESS_KEY=minio-user
STORAGE_SECRET=minio-password
STORAGE_REGION=us-east-1
STORAGE_BUCKET=sendou
STORAGE_URL=http://127.0.0.1:9000
VITE_TOURNAMENT_DEFAULT_LOGO="tournament-logo-default.avif"

View File

@ -92,6 +92,14 @@ You should then be able to access the application by visiting http://localhost:5
Use the admin panel at http://localhost:5173/admin to log in (impersonate) as the admin user "Sendou" or as a regular user "N-ZAP" as well as re-seed the database if needed.
#### Environment variables
You don't need a `.env` file to get started. Default values for every environment variable are defined in [`app/config.ts`](./app/config.ts) (client `VITE_*` variables) and [`app/config.server.ts`](./app/config.server.ts) (server variables), and the development defaults are enough to run the app.
To override any of them, create a `.env` file in the project root — see [`.env.example`](./.env.example) for the available variables. Some features (Discord login, image upload, chat) need real values or local services to actually work; without them the app still runs with those features disabled.
In production these variables are read from the host environment, and the ones marked as required will fail fast at startup with a clear error if they are missing.
#### Docker
Optionally, if you want to develop image upload, real-time features or chat, you can use Docker to spin up the Skalop service and Minio for image hosting. You will need [Docker](https://www.docker.com/) up and running and then run the following command:

View File

@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { usePatrons } from "~/hooks/swr";
import {
@ -27,7 +28,7 @@ export function Footer() {
const user = useUser();
const showPrivacySettings =
import.meta.env.VITE_FUSE_ENABLED && !user?.roles.includes("MINOR_SUPPORT");
Config.fuseEnabled && !user?.roles.includes("MINOR_SUPPORT");
const currentYear = new Date().getFullYear();

View File

@ -2,6 +2,7 @@ import { useState } from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { navIconUrl } from "~/utils/urls";
import { SendouPopover } from "../elements/Popover";
@ -17,7 +18,7 @@ const NAV_CATEGORIES = [
{ name: "lfg", url: "lfg" },
{ name: "calendar", url: "calendar" },
{ name: "leaderboards", url: "leaderboards" },
...(import.meta.env.VITE_SHOW_LUTI_NAV_ITEM === "true"
...(Config.showLutiNavItem
? [{ name: "luti" as const, url: "luti" as const }]
: []),
],

View File

@ -21,6 +21,7 @@ import {
import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
@ -269,7 +270,7 @@ export function Layout({
const isFrontPage = location.pathname === "/";
const showLeaderboard =
import.meta.env.VITE_FUSE_ENABLED &&
Config.fuseEnabled &&
!data?.user?.roles.includes("MINOR_SUPPORT") &&
!location.pathname.includes("plans");

View File

@ -1,10 +1,12 @@
import { Config } from "~/config";
export const navItems = [
{
name: "settings",
url: "settings",
prefetch: true,
},
import.meta.env.VITE_SHOW_LUTI_NAV_ITEM === "true"
Config.showLutiNavItem
? {
name: "luti",
url: "luti",

35
app/config-helpers.ts Normal file
View File

@ -0,0 +1,35 @@
import { z } from "zod";
/**
* Builds an `Error` with a readable, multi-line message describing every invalid
* environment variable. Schemas are keyed by the literal env var name so the
* issue path points straight at the variable a contributor needs to fix.
*/
export function formatEnvErrors(
scope: "client" | "server",
error: z.ZodError,
): Error {
const lines = error.issues.map((issue) => {
const name = issue.path.join(".") || "(unknown)";
return ` - ${name}: ${issue.message}`;
});
return new Error(
`Invalid ${scope} environment configuration:\n${lines.join(
"\n",
)}\n\nSee .env.example for the full list of variables and how to set them.`,
);
}
/**
* String schema that must be set to a non-empty value in production, but falls
* back to `devFallback` outside of production so contributors can run the app
* without configuring every integration.
*/
export function requiredInProd(isProd: boolean, devFallback: string) {
return isProd
? z.string({ message: "required in production" }).min(1, {
message: "required in production (cannot be empty)",
})
: z.string().default(devFallback);
}

141
app/config.server.ts Normal file
View File

@ -0,0 +1,141 @@
import { z } from "zod";
import { formatEnvErrors, requiredInProd } from "./config-helpers";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
/**
* Server (`process.env`) configuration. Import with
* `import { ServerConfig } from "~/config.server"` and read values like
* `ServerConfig.dbPath` or `ServerConfig.storage.endpoint`.
*
* Values are validated once when this module is first imported, surfacing a
* single clear error for any misconfigured variable. Variables required in
* production fall back to development defaults outside of production.
*/
const isProd = process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN;
const schema = z
.object({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
DB_PATH: requiredInProd(isProd, "db.sqlite3"),
SESSION_SECRET: requiredInProd(isProd, "secret"),
LOHI_TOKEN: requiredInProd(isProd, "salmon"),
SQL_LOG: z.enum(["none", "trunc", "full"]).default("none"),
DISABLE_CACHE: z.stringbool().default(false),
DISCORD_CLIENT_ID: requiredInProd(isProd, ""),
DISCORD_CLIENT_SECRET: requiredInProd(isProd, ""),
STORAGE_END_POINT: requiredInProd(isProd, "http://127.0.0.1:9000"),
STORAGE_ACCESS_KEY: requiredInProd(isProd, "minio-user"),
STORAGE_SECRET: requiredInProd(isProd, "minio-password"),
STORAGE_REGION: requiredInProd(isProd, "us-east-1"),
STORAGE_BUCKET: requiredInProd(isProd, "sendou"),
SKALOP_SYSTEM_MESSAGE_URL: z.string().optional(),
SKALOP_TOKEN: z.string().optional(),
TWITCH_CLIENT_ID: z.string().optional(),
TWITCH_CLIENT_SECRET: z.string().optional(),
PATREON_ACCESS_TOKEN: z.string().optional(),
// The VAPID public key (VITE_VAPID_PUBLIC_KEY) lives in `~/config` since
// it is client-readable; the full three-var coupling is completed by the
// runtime check in webPush.server.ts.
VAPID_PRIVATE_KEY: z.string().optional(),
VAPID_EMAIL: z.string().optional(),
})
.superRefine((val, ctx) => {
requireTogether(ctx, val, "TWITCH_CLIENT_ID", "TWITCH_CLIENT_SECRET");
requireTogether(ctx, val, "VAPID_EMAIL", "VAPID_PRIVATE_KEY");
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
throw formatEnvErrors("server", parsed.error);
}
const values = parsed.data;
export const ServerConfig = {
/**
* Whether `NODE_ENV` is `"production"`. Note: this is `true` during e2e tests
* (which run a production build), so combine it with `IS_E2E_TEST_RUN` when
* you specifically need to exclude the e2e environment (as the session
* cookies do).
*/
isProduction: values.NODE_ENV === "production",
/** Whether the app is running under the test runner. */
isTest: values.NODE_ENV === "test",
/** Path to the SQLite database file. */
dbPath: values.DB_PATH,
/** Secret used to sign session cookies. */
sessionSecret: values.SESSION_SECRET,
/** Token authorizing internal Lohi (bot/cron) requests. */
lohiToken: values.LOHI_TOKEN,
/** SQL query logging level. */
sqlLog: values.SQL_LOG,
/** Whether response caching is disabled. */
disableCache: values.DISABLE_CACHE,
/** Discord OAuth configuration. */
discord: {
clientId: values.DISCORD_CLIENT_ID,
clientSecret: values.DISCORD_CLIENT_SECRET,
},
/** S3-compatible object storage configuration. */
storage: {
endpoint: values.STORAGE_END_POINT,
accessKey: values.STORAGE_ACCESS_KEY,
secret: values.STORAGE_SECRET,
region: values.STORAGE_REGION,
bucket: values.STORAGE_BUCKET,
},
/** Skalop (chat) server configuration. Optional — chat features no-op when unset. */
skalop: {
systemMessageUrl: values.SKALOP_SYSTEM_MESSAGE_URL,
token: values.SKALOP_TOKEN,
},
/** Twitch integration credentials. Optional — streams are hidden when unset. */
twitch: {
clientId: values.TWITCH_CLIENT_ID,
clientSecret: values.TWITCH_CLIENT_SECRET,
},
/** Patreon integration configuration. */
patreon: {
accessToken: values.PATREON_ACCESS_TOKEN,
},
/** Web push (VAPID) server configuration. */
vapid: {
privateKey: values.VAPID_PRIVATE_KEY,
email: values.VAPID_EMAIL,
},
};
/** Adds a validation issue unless `a` and `b` are both set or both unset. */
function requireTogether(
ctx: z.RefinementCtx,
values: Record<string, unknown>,
a: string,
b: string,
) {
const aSet = Boolean(values[a]);
const bSet = Boolean(values[b]);
if (aSet === bSet) return;
const present = aSet ? a : b;
const missing = aSet ? b : a;
ctx.addIssue({
code: "custom",
path: [missing],
message: `must be set together with ${present}`,
});
}

76
app/config.ts Normal file
View File

@ -0,0 +1,76 @@
import { z } from "zod";
import { formatEnvErrors, requiredInProd } from "./config-helpers";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
/**
* Client (`VITE_*`) configuration. Import with `import { Config } from "~/config"`
* and read values like `Config.siteDomain` or `Config.sentry.enabled`.
*
* Values are validated once when this module is first imported, surfacing a
* single clear error for any misconfigured variable. Variables required in
* production fall back to development defaults outside of production.
*/
// `import.meta.env` is undefined when Playwright bundles test code, so guard the
// access and treat that environment as non-production (see `~/utils/e2e`).
const env =
typeof import.meta.env !== "undefined"
? (import.meta.env as Record<string, string | undefined>)
: {};
const isProd =
typeof import.meta.env !== "undefined" &&
import.meta.env.PROD === true &&
!IS_E2E_TEST_RUN;
const schema = z.object({
VITE_SITE_DOMAIN: requiredInProd(isProd, "http://localhost:5173"),
VITE_TOURNAMENT_DEFAULT_LOGO: requiredInProd(
isProd,
"tournament-logo-default.avif",
),
VITE_PROD_MODE: z.stringbool().default(false),
VITE_SHOW_LUTI_NAV_ITEM: z.stringbool().default(false),
VITE_FUSE_ENABLED: z.stringbool().default(false),
VITE_LEAGUE_GOOGLE_FORM_URL: z.string().optional(),
VITE_SHOW_BANNER_FOR_SEASON: z.string().optional(),
VITE_SENTRY_DSN: z.string().optional(),
VITE_SENTRY_ENABLED: z.stringbool().default(false),
VITE_SKALOP_WS_URL: z.string().optional(),
VITE_VAPID_PUBLIC_KEY: z.string().optional(),
});
const parsed = schema.safeParse(env);
if (!parsed.success) {
throw formatEnvErrors("client", parsed.error);
}
const values = parsed.data;
export const Config = {
/** Base URL of the site, e.g. `https://sendou.ink`. */
siteDomain: values.VITE_SITE_DOMAIN,
/** Filename of the default tournament logo asset. */
tournamentDefaultLogo: values.VITE_TOURNAMENT_DEFAULT_LOGO,
/** Whether to use real seasons & league data (used when developing against the production database). */
prodMode: values.VITE_PROD_MODE,
/** Whether to show the LUTI navigation item. */
showLutiNavItem: values.VITE_SHOW_LUTI_NAV_ITEM,
fuseEnabled: values.VITE_FUSE_ENABLED,
/** Google Form URL for league registration, if configured. */
leagueGoogleFormUrl: values.VITE_LEAGUE_GOOGLE_FORM_URL,
/** Season identifier to show the registration banner for, if any. */
showBannerForSeason: values.VITE_SHOW_BANNER_FOR_SEASON,
/** Sentry client configuration. */
sentry: {
dsn: values.VITE_SENTRY_DSN,
enabled: values.VITE_SENTRY_ENABLED,
},
/** Skalop (chat) client configuration. */
skalop: {
wsUrl: values.VITE_SKALOP_WS_URL,
},
/** Web push (VAPID) client configuration. */
vapid: {
publicKey: values.VITE_VAPID_PUBLIC_KEY,
},
};

View File

@ -8,23 +8,16 @@ import {
SqliteDialect,
} from "kysely";
import { format } from "sql-formatter";
import invariant from "~/utils/invariant";
import { Config } from "~/config";
import { ServerConfig } from "~/config.server";
import { logger } from "~/utils/logger";
import { roundToNDecimalPlaces } from "~/utils/number";
import type { DB } from "./tables";
const LOG_LEVEL = (["trunc", "full", "none"] as const).find(
(val) => val === process.env.SQL_LOG,
);
const SENTRY_ENABLED = import.meta.env.VITE_SENTRY_ENABLED === "true";
const migratedEmptyDb = new Database("db-test.sqlite3").serialize();
invariant(process.env.DB_PATH, "DB_PATH env variable must be set");
export const sql = new Database(
process.env.NODE_ENV === "test" ? migratedEmptyDb : process.env.DB_PATH,
ServerConfig.isTest ? migratedEmptyDb : ServerConfig.dbPath,
);
sql.pragma("journal_mode = WAL");
@ -53,7 +46,7 @@ export const db = new Kysely<DB>({
});
function log(event: LogEvent) {
if (SENTRY_ENABLED && event.level === "query") {
if (Config.sentry.enabled && event.level === "query") {
// Backdated span so the query nests under the active loader/action span
// in Sentry's waterfall. `onlyIfParent: true` skips emission when there's
// no active trace (e.g. cron routines), avoiding orphan root spans.
@ -65,7 +58,7 @@ function log(event: LogEvent) {
}).end();
}
if (LOG_LEVEL === "trunc" || LOG_LEVEL === "full") {
if (ServerConfig.sqlLog === "trunc" || ServerConfig.sqlLog === "full") {
logQuery(event);
} else {
logError(event);
@ -129,7 +122,7 @@ function formatSql(sql: string, params: readonly unknown[]) {
const lines = formatted.split("\n");
if (LOG_LEVEL === "full" || lines.length <= 11) {
if (ServerConfig.sqlLog === "full" || lines.length <= 11) {
return addParams(formatted, params);
}

View File

@ -4,11 +4,12 @@ import i18next from "i18next";
import { hydrateRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import { HydratedRouter } from "react-router/dom";
import { Config } from "~/config";
import { i18nLoader } from "./modules/i18n/loader";
import { logger } from "./utils/logger";
import { getSessionId } from "./utils/session-id";
const SENTRY_ENABLED = import.meta.env.VITE_SENTRY_ENABLED === "true";
const SENTRY_ENABLED = Config.sentry.enabled;
const tracing = SENTRY_ENABLED
? Sentry.reactRouterTracingIntegration({
@ -18,7 +19,7 @@ const tracing = SENTRY_ENABLED
if (SENTRY_ENABLED) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
dsn: Config.sentry.dsn,
sendDefaultPii: false,
integrations: [
tracing!,

View File

@ -11,6 +11,8 @@ import {
type HandleErrorFunction,
ServerRouter,
} from "react-router";
import { Config } from "~/config";
import { ServerConfig } from "~/config.server";
import { config } from "~/modules/i18n/config"; // your i18n configuration file
import { i18next } from "~/modules/i18n/i18next.server";
import { resources } from "./modules/i18n/resources.server";
@ -25,7 +27,7 @@ import { logger } from "./utils/logger";
// Reject/cancel all pending promises after 5 seconds
export const streamTimeout = 5000;
const SENTRY_ENABLED = import.meta.env.VITE_SENTRY_ENABLED === "true";
const SENTRY_ENABLED = Config.sentry.enabled;
async function handleRequest(
request: Request,
@ -94,7 +96,7 @@ declare global {
var appStartSignal: undefined | true;
}
if (!global.appStartSignal && process.env.NODE_ENV === "production") {
if (!global.appStartSignal && ServerConfig.isProduction) {
global.appStartSignal = true;
cron.schedule("0 */1 * * *", async () => {

View File

@ -14,7 +14,6 @@ import {
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { _action, actualNumber, friendCode } from "~/utils/zod";
import * as AdminNotifications from "../core/admin-notifications.server";
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
export const action = async ({ request }: ActionFunctionArgs) => {
@ -170,14 +169,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
message = "API access granted";
break;
}
case "TEST_ADMIN_NOTIFICATION": {
requireRole("ADMIN");
await AdminNotifications.send("Test notification from admin panel");
message = "Test notification sent";
break;
}
default: {
assertUnreachable(data);
}
@ -240,7 +231,4 @@ export const adminActionSchema = z.union([
_action: _action("API_ACCESS"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("TEST_ADMIN_NOTIFICATION"),
}),
]);

View File

@ -1,29 +0,0 @@
import { logger } from "~/utils/logger";
const DISCORD_ADMIN_WEBHOOK_URL = process.env.DISCORD_ADMIN_WEBHOOK_URL;
if (!DISCORD_ADMIN_WEBHOOK_URL) {
logger.info(
"DISCORD_ADMIN_WEBHOOK_URL not set, admin notifications disabled",
);
}
export async function send(message: string): Promise<void> {
if (!DISCORD_ADMIN_WEBHOOK_URL) {
return;
}
try {
const response = await fetch(DISCORD_ADMIN_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: message }),
});
if (!response.ok) {
logger.error(`Failed to send admin notification: ${response.status}`);
}
} catch (error) {
logger.error("Failed to send admin notification", error);
}
}

View File

@ -126,7 +126,6 @@ function AdminActions() {
return (
<div className="stack lg">
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <Seed /> : null}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? <TestAdminNotification /> : null}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin || isDev ? (
<Impersonate />
) : null}
@ -487,21 +486,4 @@ function Seed() {
);
}
function TestAdminNotification() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<h2>Test Admin Notification</h2>
<SubmitButton
type="submit"
_action="TEST_ADMIN_NOTIFICATION"
state={fetcher.state}
>
Send Test
</SubmitButton>
</fetcher.Form>
);
}
export const ErrorBoundary = Catcher;

View File

@ -1,9 +1,9 @@
import { add } from "date-fns";
import { OAuth2Strategy } from "remix-auth-oauth2";
import { z } from "zod";
import * as AdminNotifications from "~/features/admin/core/admin-notifications.server";
import { Config } from "~/config";
import { ServerConfig } from "~/config.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
let discordApiCooldownUntil: number | null = null;
@ -34,8 +34,6 @@ const discordRateLimitSchema = z.object({
});
export const DiscordStrategy = () => {
const envVars = authEnvVars();
const jsonIfOk = async (res: Response) => {
if (res.status === 429) {
const body = discordRateLimitSchema.safeParse(await res.clone().json());
@ -46,9 +44,6 @@ export const DiscordStrategy = () => {
logger.warn(
`Discord API rate limited, cooldown for ${retryAfterSeconds}s${body.success ? "" : " (failed to parse retry_after)"}`,
);
AdminNotifications.send(
`Discord API rate limited, cooldown for ${retryAfterSeconds}s`,
);
}
if (!res.ok) {
@ -79,12 +74,12 @@ export const DiscordStrategy = () => {
return new OAuth2Strategy(
{
clientId: envVars.DISCORD_CLIENT_ID,
clientSecret: envVars.DISCORD_CLIENT_SECRET,
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", envVars.BASE_URL).toString(),
redirectURI: new URL("/auth/callback", Config.siteDomain).toString(),
scopes: ["identify", "connections", "email"],
},
@ -155,24 +150,3 @@ function parseConnections(
return result;
}
function authEnvVars() {
if (process.env.NODE_ENV === "production") {
invariant(process.env.DISCORD_CLIENT_ID);
invariant(process.env.DISCORD_CLIENT_SECRET);
invariant(import.meta.env.VITE_SITE_DOMAIN);
return {
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
BASE_URL: import.meta.env.VITE_SITE_DOMAIN,
};
}
// allow running the project in development without setting auth env vars
return {
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID ?? "",
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET ?? "",
BASE_URL: import.meta.env.VITE_SITE_DOMAIN ?? "",
};
}

View File

@ -7,11 +7,7 @@ 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,
errorToastIfFalsy,
parseSearchParams,
} from "~/utils/remix.server";
import { canAccessLohiEndpoint, parseSearchParams } from "~/utils/remix.server";
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
import * as LogInLinkRepository from "../LogInLinkRepository.server";
import {
@ -68,11 +64,6 @@ export const logOutAction: ActionFunction = async ({ request }) => {
};
export const logInAction: ActionFunction = async ({ request }) => {
errorToastIfFalsy(
process.env.LOGIN_DISABLED !== "true",
"Login is temporarily disabled",
);
return await authenticator.authenticate("discord", request);
};

View File

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

View File

@ -7,6 +7,7 @@ import {
useMatches,
useRevalidator,
} from "react-router";
import { Config } from "~/config";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { logger } from "~/utils/logger";
import { soundPath } from "~/utils/urls";
@ -349,7 +350,7 @@ function ChatProviderInner({
// WebSocket connection
React.useEffect(() => {
const wsUrl = import.meta.env.VITE_SKALOP_WS_URL;
const wsUrl = Config.skalop.wsUrl;
if (!wsUrl) {
logger.warn("No WS URL provided, ChatProvider not connecting");
setReadyState("CLOSED");

View File

@ -1,5 +1,6 @@
import { add } from "date-fns";
import { nanoid } from "nanoid";
import { ServerConfig } from "~/config.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import invariant from "~/utils/invariant";
@ -15,7 +16,7 @@ function logSkalpError(action: string) {
if (code === "ECONNREFUSED") {
logger.error(
`Skalop "${action}" failed: connection refused at ${process.env.SKALOP_SYSTEM_MESSAGE_URL} — is the skalop service running?`,
`Skalop "${action}" failed: connection refused at ${ServerConfig.skalop.systemMessageUrl} — is the skalop service running?`,
);
} else {
logger.error(`Skalop "${action}" failed:`, err);
@ -35,13 +36,13 @@ let systemMessagesDisabled = false;
if (!IS_E2E_TEST_RUN) {
invariant(
process.env.SKALOP_SYSTEM_MESSAGE_URL,
ServerConfig.skalop.systemMessageUrl,
"Missing env var: SKALOP_SYSTEM_MESSAGE_URL",
);
invariant(process.env.SKALOP_TOKEN, "Missing env var: SKALOP_TOKEN");
invariant(ServerConfig.skalop.token, "Missing env var: SKALOP_TOKEN");
} else if (
!process.env.SKALOP_SYSTEM_MESSAGE_URL ||
!process.env.SKALOP_TOKEN
!ServerConfig.skalop.systemMessageUrl ||
!ServerConfig.skalop.token
) {
systemMessagesDisabled = true;
}
@ -63,14 +64,14 @@ export const send: ChatSystemMessageService["send"] = (partialMsg) => {
};
});
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL!, {
return void fetch(ServerConfig.skalop.systemMessageUrl!, {
method: "POST",
body: JSON.stringify({
action: "sendMessage",
messages: fullMessages,
}),
headers: [
[SKALOP_TOKEN_HEADER_NAME, process.env.SKALOP_TOKEN!],
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
["Content-Type", "application/json"],
],
}).catch(logSkalpError("sendMessage"));
@ -79,14 +80,14 @@ export const send: ChatSystemMessageService["send"] = (partialMsg) => {
export function removeRoom(chatCode: string) {
if (systemMessagesDisabled) return;
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL!, {
return void fetch(ServerConfig.skalop.systemMessageUrl!, {
method: "POST",
body: JSON.stringify({
action: "removeRoom",
chatCode,
}),
headers: [
[SKALOP_TOKEN_HEADER_NAME, process.env.SKALOP_TOKEN!],
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
["Content-Type", "application/json"],
],
}).catch(logSkalpError("removeRoom"));
@ -109,7 +110,7 @@ const metadataDedup = new Map<string, string>();
export async function setMetadata(args: SetMetadataArgs) {
if (systemMessagesDisabled) return;
if (!process.env.SKALOP_SYSTEM_MESSAGE_URL) return;
if (!ServerConfig.skalop.systemMessageUrl) return;
const participantsKey = args.participantUserIds
.slice()
@ -146,7 +147,7 @@ export async function setMetadata(args: SetMetadataArgs) {
`Setting chat room metadata for ${args.chatCode} (participants: ${participantsKey})`,
);
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL, {
return void fetch(ServerConfig.skalop.systemMessageUrl, {
method: "POST",
body: JSON.stringify({
action: "setMetadata",
@ -162,7 +163,7 @@ export async function setMetadata(args: SetMetadataArgs) {
},
}),
headers: [
[SKALOP_TOKEN_HEADER_NAME, process.env.SKALOP_TOKEN!],
[SKALOP_TOKEN_HEADER_NAME, ServerConfig.skalop.token!],
["Content-Type", "application/json"],
],
}).catch(logSkalpError("setMetadata"));

View File

@ -13,6 +13,7 @@ import { ExternalIcon } from "~/components/icons/External";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { navItems } from "~/components/layout/nav-items";
import { Main } from "~/components/Main";
import { Config } from "~/config";
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import { PWAInstallBanner } from "~/features/front-page/components/PWAInstallBanner";
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
@ -139,7 +140,7 @@ function SeasonCard() {
}
function LeagueBanner() {
const showBannerFor = import.meta.env.VITE_SHOW_BANNER_FOR_SEASON;
const showBannerFor = Config.showBannerForSeason;
if (!showBannerFor) return null;
return (

View File

@ -5,61 +5,26 @@ import type { PutObjectCommandInput } from "@aws-sdk/client-s3";
import { S3 } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { writeAsyncIterableToWritable } from "@react-router/node";
const envVars = () => {
const {
STORAGE_END_POINT,
STORAGE_ACCESS_KEY,
STORAGE_SECRET,
STORAGE_REGION,
STORAGE_BUCKET,
} = process.env;
if (
!(
STORAGE_ACCESS_KEY &&
STORAGE_END_POINT &&
STORAGE_SECRET &&
STORAGE_REGION &&
STORAGE_BUCKET
)
) {
throw new Error("Storage is missing required configuration.");
}
return {
STORAGE_END_POINT,
STORAGE_ACCESS_KEY,
STORAGE_SECRET,
STORAGE_REGION,
STORAGE_BUCKET,
};
};
import { ServerConfig } from "~/config.server";
const uploadStream = ({ Key }: Pick<PutObjectCommandInput, "Key">) => {
const {
STORAGE_END_POINT,
STORAGE_ACCESS_KEY,
STORAGE_SECRET,
STORAGE_REGION,
STORAGE_BUCKET,
} = envVars();
const { endpoint, accessKey, secret, region, bucket } = ServerConfig.storage;
const s3 = new S3({
endpoint: STORAGE_END_POINT,
endpoint,
forcePathStyle: false,
credentials: {
accessKeyId: STORAGE_ACCESS_KEY,
secretAccessKey: STORAGE_SECRET,
accessKeyId: accessKey,
secretAccessKey: secret,
},
region: STORAGE_REGION,
region,
});
const pass = new PassThrough();
return {
writeStream: pass,
promise: new Upload({
client: s3,
params: { Bucket: STORAGE_BUCKET, Key, Body: pass, ACL: "public-read" },
params: { Bucket: bucket, Key, Body: pass, ACL: "public-read" },
}).done(),
};
};

View File

@ -1,19 +1,14 @@
import { createCookieSessionStorage } from "react-router";
import { ServerConfig } from "~/config.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import invariant from "~/utils/invariant";
const TEN_YEARS_IN_SECONDS = 315_360_000;
if (process.env.NODE_ENV === "production") {
invariant(process.env.SESSION_SECRET, "SESSION_SECRET is required");
}
const sessionSecret = process.env.SESSION_SECRET ?? "secret";
const sidenavStorage = createCookieSessionStorage({
cookie: {
name: "sidenav",
secure: process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN,
secrets: [sessionSecret],
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
secrets: [ServerConfig.sessionSecret],
sameSite: "lax",
path: "/",
httpOnly: true,

View File

@ -1,3 +1,4 @@
import { Config } from "~/config";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
/**
@ -18,11 +19,7 @@ export const list =
// when we do pnpm run setup NODE_ENV is not set -> use test seasons
!process.env.NODE_ENV ||
IS_E2E_TEST_RUN ||
// this gets checked when the project is running
// import.meta.env is undefined when Playwright bundles test code
(process.env.NODE_ENV === "development" &&
(typeof import.meta.env === "undefined" ||
import.meta.env.VITE_PROD_MODE !== "true"))
(process.env.NODE_ENV === "development" && !Config.prodMode)
? ([
{
nth: 0,

View File

@ -1,17 +1,19 @@
import webPush from "web-push";
import { Config } from "~/config";
import { ServerConfig } from "~/config.server";
import { logger } from "~/utils/logger";
export let webPushEnabled = false;
if (
process.env.VAPID_EMAIL &&
process.env.VITE_VAPID_PUBLIC_KEY &&
process.env.VAPID_PRIVATE_KEY
ServerConfig.vapid.email &&
Config.vapid.publicKey &&
ServerConfig.vapid.privateKey
) {
webPush.setVapidDetails(
process.env.VAPID_EMAIL,
process.env.VITE_VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY,
ServerConfig.vapid.email,
Config.vapid.publicKey,
ServerConfig.vapid.privateKey,
);
webPushEnabled = true;
} else {

View File

@ -5,6 +5,7 @@ import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { SendouForm } from "~/form/SendouForm";
import {
@ -110,7 +111,7 @@ function PushNotificationsEnabler() {
} else {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
applicationServerKey: Config.vapid.publicKey,
});
sendSubscriptionToServer(subscription);
}

View File

@ -8,6 +8,7 @@ import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { SendouForm } from "~/form/SendouForm";
@ -67,7 +68,7 @@ function InviteCodeSection() {
);
}
const inviteLink = `${import.meta.env.VITE_SITE_DOMAIN}${joinTeamPage({
const inviteLink = `${Config.siteDomain}${joinTeamPage({
customUrl: team.customUrl,
inviteCode: team.inviteCode!,
})}`;

View File

@ -1,21 +1,16 @@
import { createCookieSessionStorage } from "react-router";
import { ServerConfig } from "~/config.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import invariant from "~/utils/invariant";
import type { Theme } from "./provider";
import { isTheme } from "./provider";
const TEN_YEARS_IN_SECONDS = 315_360_000;
if (process.env.NODE_ENV === "production") {
invariant(process.env.SESSION_SECRET, "SESSION_SECRET is required");
}
const sessionSecret = process.env.SESSION_SECRET ?? "secret";
const themeStorage = createCookieSessionStorage({
cookie: {
name: "theme",
secure: process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN,
secrets: [sessionSecret],
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
secrets: [ServerConfig.sessionSecret],
sameSite: "lax",
path: "/",
httpOnly: true,

View File

@ -1,4 +1,5 @@
import { sub } from "date-fns";
import { ServerConfig } from "~/config.server";
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
@ -120,6 +121,10 @@ export async function tournamentDataCached({
user?: { id: number };
tournamentId: number;
}) {
if (ServerConfig.disableCache) {
return notFoundIfFalsy(await tournamentData({ user, tournamentId }));
}
if (!tournamentDataCache.has(tournamentId)) {
tournamentDataCache.set(tournamentId, combinedTournamentData(tournamentId));
}

View File

@ -13,6 +13,7 @@ import { FriendCodePopover } from "~/components/FriendCodePopover";
import { Label } from "~/components/Label";
import { containerClassName } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker";
@ -563,7 +564,7 @@ function GoogleFormsLink() {
</h3>
<section className={clsx(styles.section, "stack lg items-center")}>
<a
href={import.meta.env.VITE_LEAGUE_GOOGLE_FORM_URL}
href={Config.leagueGoogleFormUrl}
className="py-4 font-bold"
target="_blank"
rel="noopener noreferrer"

View File

@ -1,3 +1,4 @@
import { Config } from "~/config";
import { TEAM } from "../team/team-constants";
export const TOURNAMENT = {
@ -35,8 +36,7 @@ export const TOURNAMENT = {
} as const;
export const LEAGUES =
process.env.NODE_ENV === "development" &&
import.meta.env.VITE_PROD_MODE !== "true"
process.env.NODE_ENV === "development" && !Config.prodMode
? {
LUTI: [
{

View File

@ -1,4 +1,5 @@
import type { z } from "zod";
import { ServerConfig } from "~/config.server";
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
@ -52,7 +53,7 @@ const DEFAULT_RETRY_AFTER_SECONDS = 10;
const MAX_RETRY_AFTER_SECONDS = 60;
async function fetchPatronData(urlToFetch: string) {
if (!process.env.PATREON_ACCESS_TOKEN) {
if (!ServerConfig.patreon.accessToken) {
throw new Error("Missing Patreon access token");
}
@ -61,7 +62,7 @@ async function fetchPatronData(urlToFetch: string) {
urlToFetch,
{
headers: {
Authorization: `Bearer ${process.env.PATREON_ACCESS_TOKEN}`,
Authorization: `Bearer ${ServerConfig.patreon.accessToken}`,
},
},
30_000,

View File

@ -1,6 +1,6 @@
import { logger } from "~/utils/logger";
import { getToken, purgeCachedToken } from "./token";
import { getTwitchEnvVars } from "./utils";
import { getTwitchEnvVars } from "./utils.server";
const MAX_RATE_LIMIT_RETRIES = 5;
const MAX_RATE_LIMIT_WAIT_MS = 60_000;

View File

@ -1,7 +1,7 @@
import { cachified } from "@epic-web/cachified";
import { cache } from "~/utils/cache.server";
import { tokenResponseSchema } from "./schemas";
import { getTwitchEnvVars } from "./utils";
import { getTwitchEnvVars } from "./utils.server";
async function getFreshToken() {
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = getTwitchEnvVars();

View File

@ -0,0 +1,16 @@
import { ServerConfig } from "~/config.server";
import invariant from "~/utils/invariant";
export const hasTwitchEnvVars = () =>
Boolean(ServerConfig.twitch.clientId && ServerConfig.twitch.clientSecret);
export const getTwitchEnvVars = () => {
const { clientId, clientSecret } = ServerConfig.twitch;
invariant(clientId, "Missing TWITCH_CLIENT_ID env var, showing no streams");
invariant(
clientSecret,
"Missing TWITCH_CLIENT_SECRET env var, showing no streams",
);
return { TWITCH_CLIENT_ID: clientId, TWITCH_CLIENT_SECRET: clientSecret };
};

View File

@ -1,23 +1,2 @@
import invariant from "~/utils/invariant";
export const hasTwitchEnvVars = () => {
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = process.env;
return Boolean(TWITCH_CLIENT_ID && TWITCH_CLIENT_SECRET);
};
export const getTwitchEnvVars = () => {
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = process.env;
invariant(
TWITCH_CLIENT_ID,
"Missing TWITCH_CLIENT_ID env var, showing no streams",
);
invariant(
TWITCH_CLIENT_SECRET,
"Missing TWITCH_CLIENT_SECRET env var, showing no streams",
);
return { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET };
};
export const twitchThumbnailUrlToSrc = (url: string) =>
url.replace("{width}", "640").replace("{height}", "360");

View File

@ -28,6 +28,7 @@ import {
useSearchParams,
} from "react-router";
import { useChangeLanguage } from "remix-i18next/react";
import { Config } from "~/config";
import type { CustomTheme } from "~/db/tables";
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
@ -185,8 +186,7 @@ function Document({
className={clsx(htmlThemeClass, "scrollbar")}
style={htmlStyle}
data-fuse={
import.meta.env.VITE_FUSE_ENABLED &&
!data?.user?.roles.includes("MINOR_SUPPORT")
Config.fuseEnabled && !data?.user?.roles.includes("MINOR_SUPPORT")
? "true"
: undefined
}
@ -194,7 +194,7 @@ function Document({
>
<head>
<meta charSet="utf-8" />
{import.meta.env.VITE_FUSE_ENABLED &&
{Config.fuseEnabled &&
// check for data so supporters don't see ads on error page
data &&
!data.user?.roles.includes("MINOR_SUPPORT") ? (

View File

@ -1,7 +1,8 @@
import * as Sentry from "@sentry/react-router";
import { Config } from "~/config";
import { logger } from "../utils/logger";
const SENTRY_ENABLED = import.meta.env.VITE_SENTRY_ENABLED === "true";
const SENTRY_ENABLED = Config.sentry.enabled;
export class Routine {
private name;

View File

@ -17,7 +17,7 @@ vi.mock("~/modules/twitch", () => ({
getStreams: mockGetStreams,
}));
vi.mock("~/modules/twitch/utils", () => ({
vi.mock("~/modules/twitch/utils.server", () => ({
hasTwitchEnvVars: () => true,
}));

View File

@ -3,7 +3,7 @@ import * as LiveStreamRepository from "~/features/live-streams/LiveStreamReposit
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getStreams } from "~/modules/twitch";
import { hasTwitchEnvVars } from "~/modules/twitch/utils";
import { hasTwitchEnvVars } from "~/modules/twitch/utils.server";
import { Routine } from "./routine.server";
const TOURNAMENT_STREAM_SYNC_INTERVAL_MINS = 30;

View File

@ -17,7 +17,7 @@ vi.mock("~/modules/twitch/vods", async (importOriginal) => {
};
});
vi.mock("~/modules/twitch/utils", () => ({
vi.mock("~/modules/twitch/utils.server", () => ({
hasTwitchEnvVars: () => true,
}));

View File

@ -1,7 +1,7 @@
import type { Insertable } from "kysely";
import type { DB } from "~/db/tables";
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
import { hasTwitchEnvVars } from "~/modules/twitch/utils";
import { hasTwitchEnvVars } from "~/modules/twitch/utils.server";
import {
getArchiveVideos,
getUsersByLogin,

View File

@ -1,4 +1,5 @@
import type { CacheEntry } from "@epic-web/cachified";
import { ServerConfig } from "~/config.server";
import { LRUCache } from "~/modules/cache";
declare global {
@ -11,8 +12,7 @@ export const cache = (global.__lruCache = global.__lruCache
? global.__lruCache
: new LRUCache<string, CacheEntry<unknown>>({ max: 5000 }));
export const ttl = (ms: number) =>
process.env.DISABLE_CACHE === "true" ? 0 : ms;
export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms);
export function syncCached<T>(key: string, getFreshValue: () => T) {
if (cache.has(key)) {

View File

@ -6,6 +6,7 @@ import {
sql,
} from "kysely";
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
import { Config } from "~/config";
import type { DB, Tables } from "~/db/tables";
import { IS_E2E_TEST_RUN } from "./e2e";
@ -72,8 +73,7 @@ export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
}
const USER_SUBMITTED_IMAGE_ROOT =
(process.env.NODE_ENV === "development" &&
import.meta.env.VITE_PROD_MODE !== "true") ||
(process.env.NODE_ENV === "development" && !Config.prodMode) ||
IS_E2E_TEST_RUN ||
process.env.NODE_ENV === "test"
? "http://127.0.0.1:9000/sendou"
@ -127,7 +127,7 @@ export function tournamentLogoWithDefault(
"UnvalidatedUserSubmittedImage.id",
)
.$asScalar(),
sql.lit(`${import.meta.env.VITE_TOURNAMENT_DEFAULT_LOGO}`),
sql.lit(Config.tournamentDefaultLogo),
),
);
}

View File

@ -7,6 +7,7 @@ import type { Params, UIMatch } from "react-router";
import { data, redirect } from "react-router";
import type { z } from "zod";
import type { navItems } from "~/components/layout/nav-items";
import { ServerConfig } from "~/config.server";
import { uploadStreamToS3 } from "~/features/img-upload/s3.server";
import invariant from "./invariant";
import { logger } from "./logger";
@ -224,8 +225,7 @@ const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
/** Some endpoints can only be accessed with an auth token. Used by Lohi bot and cron jobs. */
export function canAccessLohiEndpoint(request: Request) {
invariant(process.env.LOHI_TOKEN, "LOHI_TOKEN is required");
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === process.env.LOHI_TOKEN;
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === ServerConfig.lohiToken;
}
function errorToastRedirect(message: string) {

View File

@ -1,25 +1,12 @@
import fs from "node:fs";
import { seed } from "~/db/seed";
import { db } from "~/db/sql";
import { logger } from "~/utils/logger";
import { seedImages } from "./seed-images";
async function main() {
// Step 1: Create .env if it doesn't exist
if (!fs.existsSync(".env")) {
logger.info("📄 .env not found. Creating from .env.example...");
const envContent = fs.readFileSync(".env.example", "utf-8");
const filteredEnv = envContent
.split("\n")
.filter((line) => !line.trim().startsWith("//")) // remove comments to prevent issues with Docker
.join("\n");
fs.writeFileSync(".env", filteredEnv);
logger.info(".env created with default values");
}
const dbEmpty = !(await db.selectFrom("User").selectAll().executeTakeFirst());
// Step 2: Run migration and seed if db.sqlite3 doesn't exist
// Run migration and seed if db.sqlite3 doesn't exist
if (dbEmpty) {
logger.info("🌱 Seeding database...");
try {
@ -34,7 +21,7 @@ async function main() {
}
}
// Step 3: Seed images to Minio
// Seed images to Minio
logger.info("🖼️ Seeding images to Minio...");
try {
await seedImages();