mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-21 12:03:31 -05:00
Zod validated config for security and ease of getting started
This commit is contained in:
parent
215243e071
commit
75179e5077
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
: []),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
35
app/config-helpers.ts
Normal 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
141
app/config.server.ts
Normal 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
76
app/config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
})}`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
16
app/modules/twitch/utils.server.ts
Normal file
16
app/modules/twitch/utils.server.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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") ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ vi.mock("~/modules/twitch", () => ({
|
|||
getStreams: mockGetStreams,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/twitch/utils", () => ({
|
||||
vi.mock("~/modules/twitch/utils.server", () => ({
|
||||
hasTwitchEnvVars: () => true,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ vi.mock("~/modules/twitch/vods", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("~/modules/twitch/utils", () => ({
|
||||
vi.mock("~/modules/twitch/utils.server", () => ({
|
||||
hasTwitchEnvVars: () => true,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user