From 75179e5077d9557c63cc1af940313eb3fbc7a55c Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:12:15 +0300 Subject: [PATCH] Zod validated config for security and ease of getting started --- .env.example | 4 +- README.md | 8 + app/components/layout/Footer.tsx | 3 +- app/components/layout/TopNavMenus.tsx | 3 +- app/components/layout/index.tsx | 3 +- app/components/layout/nav-items.ts | 4 +- app/config-helpers.ts | 35 +++++ app/config.server.ts | 141 ++++++++++++++++++ app/config.ts | 76 ++++++++++ app/db/sql.ts | 19 +-- app/entry.client.tsx | 5 +- app/entry.server.tsx | 6 +- app/features/admin/actions/admin.server.ts | 12 -- .../admin/core/admin-notifications.server.ts | 29 ---- app/features/admin/routes/admin.tsx | 18 --- .../auth/core/DiscordStrategy.server.ts | 36 +---- app/features/auth/core/routes.server.ts | 11 +- app/features/auth/core/session.server.ts | 13 +- app/features/chat/ChatProvider.tsx | 3 +- app/features/chat/ChatSystemMessage.server.ts | 25 ++-- app/features/front-page/routes/index.tsx | 3 +- app/features/img-upload/s3.server.ts | 49 +----- .../layout/core/sidenav-session.server.ts | 11 +- app/features/mmr/core/Seasons.ts | 7 +- .../notifications/core/webPush.server.ts | 14 +- .../settings/components/PreferencesTab.tsx | 3 +- .../team/routes/t.$customUrl.roster.tsx | 3 +- .../theme/core/theme-session.server.ts | 11 +- .../core/Tournament.server.ts | 5 + .../tournament/routes/to.$id.register.tsx | 3 +- .../tournament/tournament-constants.ts | 4 +- app/modules/patreon/updater.ts | 5 +- app/modules/twitch/fetch.ts | 2 +- app/modules/twitch/token.ts | 2 +- app/modules/twitch/utils.server.ts | 16 ++ app/modules/twitch/utils.ts | 21 --- app/root.tsx | 6 +- app/routines/routine.server.ts | 3 +- app/routines/syncLiveStreams.test.ts | 2 +- app/routines/syncLiveStreams.ts | 2 +- app/routines/syncTournamentVods.test.ts | 2 +- app/routines/syncTournamentVods.ts | 2 +- app/utils/cache.server.ts | 4 +- app/utils/kysely.server.ts | 6 +- app/utils/remix.server.ts | 4 +- scripts/setup.ts | 17 +-- 46 files changed, 386 insertions(+), 275 deletions(-) create mode 100644 app/config-helpers.ts create mode 100644 app/config.server.ts create mode 100644 app/config.ts delete mode 100644 app/features/admin/core/admin-notifications.server.ts create mode 100644 app/modules/twitch/utils.server.ts diff --git a/.env.example b/.env.example index ee4e0dda7..179cb8931 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/README.md b/README.md index d46c337ac..dbe2b6b74 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/components/layout/Footer.tsx b/app/components/layout/Footer.tsx index 65ee64ed7..0222bf313 100644 --- a/app/components/layout/Footer.tsx +++ b/app/components/layout/Footer.tsx @@ -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(); diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx index 85e17aa41..b6b132bdb 100644 --- a/app/components/layout/TopNavMenus.tsx +++ b/app/components/layout/TopNavMenus.tsx @@ -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 }] : []), ], diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index 4215e6d91..3c7ac7d3e 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -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"); diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts index 5a7949362..5f88b604d 100644 --- a/app/components/layout/nav-items.ts +++ b/app/components/layout/nav-items.ts @@ -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", diff --git a/app/config-helpers.ts b/app/config-helpers.ts new file mode 100644 index 000000000..1bbce4fe5 --- /dev/null +++ b/app/config-helpers.ts @@ -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); +} diff --git a/app/config.server.ts b/app/config.server.ts new file mode 100644 index 000000000..022a1aef4 --- /dev/null +++ b/app/config.server.ts @@ -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, + 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}`, + }); +} diff --git a/app/config.ts b/app/config.ts new file mode 100644 index 000000000..a4fc219b7 --- /dev/null +++ b/app/config.ts @@ -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) + : {}; + +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, + }, +}; diff --git a/app/db/sql.ts b/app/db/sql.ts index e77f02806..7974f3fdc 100644 --- a/app/db/sql.ts +++ b/app/db/sql.ts @@ -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({ }); 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); } diff --git a/app/entry.client.tsx b/app/entry.client.tsx index a5f5653f1..43951786a 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -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!, diff --git a/app/entry.server.tsx b/app/entry.server.tsx index e97c9b758..e571f19d6 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -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 () => { diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index 0292d339e..be285ae14 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -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"), - }), ]); diff --git a/app/features/admin/core/admin-notifications.server.ts b/app/features/admin/core/admin-notifications.server.ts deleted file mode 100644 index 4f96a65db..000000000 --- a/app/features/admin/core/admin-notifications.server.ts +++ /dev/null @@ -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 { - 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); - } -} diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 26de2f186..7b15be7bb 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -126,7 +126,6 @@ function AdminActions() { return (
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null} - {DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null} {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin || isDev ? ( ) : null} @@ -487,21 +486,4 @@ function Seed() { ); } -function TestAdminNotification() { - const fetcher = useFetcher(); - - return ( - -

Test Admin Notification

- - Send Test - -
- ); -} - export const ErrorBoundary = Catcher; diff --git a/app/features/auth/core/DiscordStrategy.server.ts b/app/features/auth/core/DiscordStrategy.server.ts index 6517cb755..125107ac6 100644 --- a/app/features/auth/core/DiscordStrategy.server.ts +++ b/app/features/auth/core/DiscordStrategy.server.ts @@ -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 ?? "", - }; -} diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts index 4fec817a0..40b900cc0 100644 --- a/app/features/auth/core/routes.server.ts +++ b/app/features/auth/core/routes.server.ts @@ -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); }; diff --git a/app/features/auth/core/session.server.ts b/app/features/auth/core/session.server.ts index 2ab463081..d5cd08e29 100644 --- a/app/features/auth/core/session.server.ts +++ b/app/features/auth/core/session.server.ts @@ -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, }, }); diff --git a/app/features/chat/ChatProvider.tsx b/app/features/chat/ChatProvider.tsx index 87d96f4fb..a50a934c2 100644 --- a/app/features/chat/ChatProvider.tsx +++ b/app/features/chat/ChatProvider.tsx @@ -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"); diff --git a/app/features/chat/ChatSystemMessage.server.ts b/app/features/chat/ChatSystemMessage.server.ts index 8fa6bc90d..b27933a7b 100644 --- a/app/features/chat/ChatSystemMessage.server.ts +++ b/app/features/chat/ChatSystemMessage.server.ts @@ -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(); 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")); diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index 330aee54d..61a986ff6 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -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 ( diff --git a/app/features/img-upload/s3.server.ts b/app/features/img-upload/s3.server.ts index b4b4a3a1f..4fabfdd00 100644 --- a/app/features/img-upload/s3.server.ts +++ b/app/features/img-upload/s3.server.ts @@ -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) => { - 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(), }; }; diff --git a/app/features/layout/core/sidenav-session.server.ts b/app/features/layout/core/sidenav-session.server.ts index 957908cbf..e7608acd0 100644 --- a/app/features/layout/core/sidenav-session.server.ts +++ b/app/features/layout/core/sidenav-session.server.ts @@ -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, diff --git a/app/features/mmr/core/Seasons.ts b/app/features/mmr/core/Seasons.ts index c0b6e7cac..63d223678 100644 --- a/app/features/mmr/core/Seasons.ts +++ b/app/features/mmr/core/Seasons.ts @@ -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, diff --git a/app/features/notifications/core/webPush.server.ts b/app/features/notifications/core/webPush.server.ts index 2e813ed38..095a4856d 100644 --- a/app/features/notifications/core/webPush.server.ts +++ b/app/features/notifications/core/webPush.server.ts @@ -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 { diff --git a/app/features/settings/components/PreferencesTab.tsx b/app/features/settings/components/PreferencesTab.tsx index 3b93cac24..0c46f2b01 100644 --- a/app/features/settings/components/PreferencesTab.tsx +++ b/app/features/settings/components/PreferencesTab.tsx @@ -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); } diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index 7d5685004..b06a33377 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -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!, })}`; diff --git a/app/features/theme/core/theme-session.server.ts b/app/features/theme/core/theme-session.server.ts index 25c6d8e99..633f9336e 100644 --- a/app/features/theme/core/theme-session.server.ts +++ b/app/features/theme/core/theme-session.server.ts @@ -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, diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 7f6442104..81e961420 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -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)); } diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 20e0af291..54742f89b 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -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() {
+ 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 }; +}; diff --git a/app/modules/twitch/utils.ts b/app/modules/twitch/utils.ts index 76600edf9..9c414ee32 100644 --- a/app/modules/twitch/utils.ts +++ b/app/modules/twitch/utils.ts @@ -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"); diff --git a/app/root.tsx b/app/root.tsx index 487eee4af..4d259805f 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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({ > - {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") ? ( diff --git a/app/routines/routine.server.ts b/app/routines/routine.server.ts index ed664051d..5c853b99d 100644 --- a/app/routines/routine.server.ts +++ b/app/routines/routine.server.ts @@ -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; diff --git a/app/routines/syncLiveStreams.test.ts b/app/routines/syncLiveStreams.test.ts index 87e32e177..a982966f2 100644 --- a/app/routines/syncLiveStreams.test.ts +++ b/app/routines/syncLiveStreams.test.ts @@ -17,7 +17,7 @@ vi.mock("~/modules/twitch", () => ({ getStreams: mockGetStreams, })); -vi.mock("~/modules/twitch/utils", () => ({ +vi.mock("~/modules/twitch/utils.server", () => ({ hasTwitchEnvVars: () => true, })); diff --git a/app/routines/syncLiveStreams.ts b/app/routines/syncLiveStreams.ts index 6f931cdbe..6ed8d4012 100644 --- a/app/routines/syncLiveStreams.ts +++ b/app/routines/syncLiveStreams.ts @@ -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; diff --git a/app/routines/syncTournamentVods.test.ts b/app/routines/syncTournamentVods.test.ts index 75bd84dcf..4c772bef0 100644 --- a/app/routines/syncTournamentVods.test.ts +++ b/app/routines/syncTournamentVods.test.ts @@ -17,7 +17,7 @@ vi.mock("~/modules/twitch/vods", async (importOriginal) => { }; }); -vi.mock("~/modules/twitch/utils", () => ({ +vi.mock("~/modules/twitch/utils.server", () => ({ hasTwitchEnvVars: () => true, })); diff --git a/app/routines/syncTournamentVods.ts b/app/routines/syncTournamentVods.ts index 82dcae6ef..76017a0f2 100644 --- a/app/routines/syncTournamentVods.ts +++ b/app/routines/syncTournamentVods.ts @@ -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, diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts index bc35ca66c..2d33a126a 100644 --- a/app/utils/cache.server.ts +++ b/app/utils/cache.server.ts @@ -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>({ 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(key: string, getFreshValue: () => T) { if (cache.has(key)) { diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index 2037de75a..e84dceae5 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -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) { } 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), ), ); } diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 0441ccfca..4ed7a0e65 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -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) { diff --git a/scripts/setup.ts b/scripts/setup.ts index 1fd4a0379..7ead2456d 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -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();