mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
nettoyage du code
This commit is contained in:
@@ -10,12 +10,21 @@ const LOCK_CLASS_ID = 7813;
|
||||
const LOCK_OBJECT_ID = 4312;
|
||||
|
||||
const parseBoolean = (value, fallback = false) => {
|
||||
if (value === undefined || value === null || String(value).trim().length === 0) {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
String(value).trim().length === 0
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
return (
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "yes" ||
|
||||
normalized === "on"
|
||||
);
|
||||
};
|
||||
|
||||
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
||||
@@ -33,7 +42,9 @@ const loadMigrations = async (migrationsDir) => {
|
||||
const files = await listMigrationFiles(migrationsDir);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`[migrate] no SQL migration files found in ${migrationsDir}`);
|
||||
throw new Error(
|
||||
`[migrate] no SQL migration files found in ${migrationsDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
const migrations = [];
|
||||
@@ -63,9 +74,13 @@ const ensureMigrationTable = async (client) => {
|
||||
};
|
||||
|
||||
const applyMigrations = async (client, migrations) => {
|
||||
const applied = await client.query("SELECT version, checksum FROM schema_migrations ORDER BY version ASC");
|
||||
const applied = await client.query(
|
||||
"SELECT version, checksum FROM schema_migrations ORDER BY version ASC",
|
||||
);
|
||||
|
||||
const appliedByVersion = new Map(applied.rows.map((row) => [String(row.version), String(row.checksum)]));
|
||||
const appliedByVersion = new Map(
|
||||
applied.rows.map((row) => [String(row.version), String(row.checksum)]),
|
||||
);
|
||||
|
||||
for (const migration of migrations) {
|
||||
const existingChecksum = appliedByVersion.get(migration.fileName);
|
||||
@@ -91,7 +106,9 @@ const applyMigrations = async (client, migrations) => {
|
||||
console.log(`[migrate] applied ${migration.fileName}`);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw new Error(`[migrate] failed while applying ${migration.fileName}`, { cause: error });
|
||||
throw new Error(`[migrate] failed while applying ${migration.fileName}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -111,8 +128,8 @@ const main = async () => {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: parseBoolean(process.env.DATABASE_SSL, false)
|
||||
? {
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -121,12 +138,20 @@ const main = async () => {
|
||||
try {
|
||||
const migrations = await loadMigrations(migrationsDir);
|
||||
|
||||
await client.query("SELECT pg_advisory_lock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]);
|
||||
await client.query("SELECT pg_advisory_lock($1, $2)", [
|
||||
LOCK_CLASS_ID,
|
||||
LOCK_OBJECT_ID,
|
||||
]);
|
||||
await ensureMigrationTable(client);
|
||||
await applyMigrations(client, migrations);
|
||||
console.log("[migrate] completed");
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]).catch(() => undefined);
|
||||
await client
|
||||
.query("SELECT pg_advisory_unlock($1, $2)", [
|
||||
LOCK_CLASS_ID,
|
||||
LOCK_OBJECT_ID,
|
||||
])
|
||||
.catch(() => undefined);
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ export const buildDiscordLoginUrl = (state: string): string => {
|
||||
return `https://discord.com/oauth2/authorize?${params.toString()}`;
|
||||
};
|
||||
|
||||
const createAvatarUrl = (discordUserId: string, avatarHash: string | null): string | null => {
|
||||
const createAvatarUrl = (
|
||||
discordUserId: string,
|
||||
avatarHash: string | null,
|
||||
): string | null => {
|
||||
if (!avatarHash) {
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +64,9 @@ const createAvatarUrl = (discordUserId: string, avatarHash: string | null): stri
|
||||
return `https://cdn.discordapp.com/avatars/${discordUserId}/${avatarHash}.png`;
|
||||
};
|
||||
|
||||
export const exchangeCodeForDiscordIdentity = async (code: string): Promise<DiscordIdentity> => {
|
||||
export const exchangeCodeForDiscordIdentity = async (
|
||||
code: string,
|
||||
): Promise<DiscordIdentity> => {
|
||||
const payload = new URLSearchParams({
|
||||
client_id: env.DISCORD_CLIENT_ID,
|
||||
client_secret: env.DISCORD_CLIENT_SECRET,
|
||||
@@ -103,7 +108,9 @@ export const exchangeCodeForDiscordIdentity = async (code: string): Promise<Disc
|
||||
};
|
||||
};
|
||||
|
||||
export const validateDiscordBotToken = async (botToken: string): Promise<DiscordBotIdentity> => {
|
||||
export const validateDiscordBotToken = async (
|
||||
botToken: string,
|
||||
): Promise<DiscordBotIdentity> => {
|
||||
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
|
||||
@@ -12,7 +12,9 @@ interface SessionJwtPayload {
|
||||
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET);
|
||||
|
||||
export const issueSessionToken = async (session: AuthSession): Promise<string> => {
|
||||
export const issueSessionToken = async (
|
||||
session: AuthSession,
|
||||
): Promise<string> => {
|
||||
return new SignJWT({
|
||||
tenantId: session.tenantId,
|
||||
discordUserId: session.discordUserId,
|
||||
@@ -25,7 +27,9 @@ export const issueSessionToken = async (session: AuthSession): Promise<string> =
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
export const verifySessionToken = async (token: string): Promise<AuthSession> => {
|
||||
export const verifySessionToken = async (
|
||||
token: string,
|
||||
): Promise<AuthSession> => {
|
||||
const verified = await jwtVerify<SessionJwtPayload>(token, secret, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
|
||||
@@ -5,18 +5,27 @@ loadEnv();
|
||||
|
||||
const parseBoolean = (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
return (
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "yes" ||
|
||||
normalized === "on"
|
||||
);
|
||||
};
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(4000),
|
||||
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
||||
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
||||
REDIS_URL: z.string().url("REDIS_URL must be a valid URL"),
|
||||
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
|
||||
DISCORD_CLIENT_SECRET: z.string().min(1, "DISCORD_CLIENT_SECRET is required"),
|
||||
DISCORD_REDIRECT_URI: z.string().url("DISCORD_REDIRECT_URI must be a valid URL"),
|
||||
DISCORD_REDIRECT_URI: z
|
||||
.string()
|
||||
.url("DISCORD_REDIRECT_URI must be a valid URL"),
|
||||
WEB_URL: z.string().url("WEB_URL must be a valid URL"),
|
||||
API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"),
|
||||
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
|
||||
@@ -25,7 +34,11 @@ const envSchema = z.object({
|
||||
COOKIE_SECURE: z.string().default("false").transform(parseBoolean),
|
||||
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
|
||||
TENANT_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
|
||||
TENANT_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
|
||||
TENANT_RATE_LIMIT_WINDOW_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(60),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@@ -7,8 +7,8 @@ export const createPgPool = (): Pool => {
|
||||
connectionString: env.DATABASE_URL,
|
||||
ssl: env.DATABASE_SSL
|
||||
? {
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -151,7 +151,10 @@ export const getUserByIdAndTenant = async (
|
||||
return mapUser(result.rows[0] as Record<string, unknown>);
|
||||
};
|
||||
|
||||
export const listBotsForTenant = async (pool: Pool, tenantId: string): Promise<PublicBot[]> => {
|
||||
export const listBotsForTenant = async (
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
): Promise<PublicBot[]> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
|
||||
@@ -206,7 +209,10 @@ export const createOrUpdateBotForTenant = async (
|
||||
[input.discordBotId],
|
||||
);
|
||||
|
||||
if (existingByDiscordBot.rows[0] && existingByDiscordBot.rows[0].tenant_id !== input.tenantId) {
|
||||
if (
|
||||
existingByDiscordBot.rows[0] &&
|
||||
existingByDiscordBot.rows[0].tenant_id !== input.tenantId
|
||||
) {
|
||||
throw new Error("BOT_ALREADY_CLAIMED");
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,15 @@ const bootstrap = async (): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getUserByIdAndTenant(pgPool, req.auth.userId, req.auth.tenantId);
|
||||
const user = await getUserByIdAndTenant(
|
||||
pgPool,
|
||||
req.auth.userId,
|
||||
req.auth.tenantId,
|
||||
);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Session does not match an existing user" });
|
||||
res
|
||||
.status(401)
|
||||
.json({ error: "Session does not match an existing user" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,12 +99,10 @@ const bootstrap = async (): Promise<void> => {
|
||||
);
|
||||
|
||||
const server = app.listen(env.PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[api] listening on :${env.PORT}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[api] shutdown requested (${signal})`);
|
||||
|
||||
server.close(async () => {
|
||||
@@ -119,7 +123,6 @@ const bootstrap = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[api] fatal startup error", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Router } from "express";
|
||||
import { Router as expressRouter } from "express";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import { buildDiscordLoginUrl, createOauthState, exchangeCodeForDiscordIdentity } from "../auth/discordOAuth.js";
|
||||
import {
|
||||
buildDiscordLoginUrl,
|
||||
createOauthState,
|
||||
exchangeCodeForDiscordIdentity,
|
||||
} from "../auth/discordOAuth.js";
|
||||
import { issueSessionToken } from "../auth/jwt.js";
|
||||
import { env } from "../config/env.js";
|
||||
import { upsertUserFromDiscord } from "../db/repositories.js";
|
||||
@@ -46,7 +50,9 @@ export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
|
||||
const cookieState = req.cookies?.[OAUTH_STATE_COOKIE_NAME];
|
||||
|
||||
if (!code || !state || !cookieState || state !== cookieState) {
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, {
|
||||
path: "/auth/discord/callback",
|
||||
});
|
||||
res.redirect(`${env.WEB_URL}/login?error=oauth_state_invalid`);
|
||||
return;
|
||||
}
|
||||
@@ -66,11 +72,15 @@ export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, {
|
||||
path: "/auth/discord/callback",
|
||||
});
|
||||
res.cookie(env.SESSION_COOKIE_NAME, sessionToken, sessionCookieConfig);
|
||||
res.redirect(`${env.WEB_URL}/dashboard`);
|
||||
} catch {
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, {
|
||||
path: "/auth/discord/callback",
|
||||
});
|
||||
res.redirect(`${env.WEB_URL}/login?error=oauth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,13 +50,23 @@ const queueBotAction = (
|
||||
return;
|
||||
}
|
||||
|
||||
const bot = await getBotForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId);
|
||||
const bot = await getBotForTenant(
|
||||
deps.pool,
|
||||
req.auth.tenantId,
|
||||
parsedParams.data.botId,
|
||||
);
|
||||
if (!bot) {
|
||||
res.status(404).json({ error: "Bot not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await setBotStatusForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId, statusDuringAction, null);
|
||||
await setBotStatusForTenant(
|
||||
deps.pool,
|
||||
req.auth.tenantId,
|
||||
parsedParams.data.botId,
|
||||
statusDuringAction,
|
||||
null,
|
||||
);
|
||||
|
||||
await insertBotRuntimeEvent(deps.pool, {
|
||||
tenantId: req.auth.tenantId,
|
||||
@@ -105,13 +115,18 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
||||
|
||||
const parsedBody = addBotSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
res.status(400).json({ error: parsedBody.error.issues[0]?.message ?? "Invalid body" });
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: parsedBody.error.issues[0]?.message ?? "Invalid body" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const botIdentity = await validateDiscordBotToken(parsedBody.data.token);
|
||||
const encryptedToken = encryptToken(parsedBody.data.token, deps.tokenEncryptionKey);
|
||||
const encryptedToken = encryptToken(
|
||||
parsedBody.data.token,
|
||||
deps.tokenEncryptionKey,
|
||||
);
|
||||
|
||||
const bot = await createOrUpdateBotForTenant(deps.pool, {
|
||||
tenantId: req.auth.tenantId,
|
||||
@@ -135,11 +150,16 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
||||
res.status(201).json({ bot });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "BOT_ALREADY_CLAIMED") {
|
||||
res.status(409).json({ error: "This Discord bot is already claimed by another tenant" });
|
||||
res.status(409).json({
|
||||
error: "This Discord bot is already claimed by another tenant",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === "Invalid Discord bot token") {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "Invalid Discord bot token"
|
||||
) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
@@ -148,9 +168,21 @@ export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/start", deps.tenantControlRateLimit, queueBotAction("start", "starting", deps));
|
||||
router.post("/:botId/stop", deps.tenantControlRateLimit, queueBotAction("stop", "stopping", deps));
|
||||
router.post("/:botId/restart", deps.tenantControlRateLimit, queueBotAction("restart", "starting", deps));
|
||||
router.post(
|
||||
"/:botId/start",
|
||||
deps.tenantControlRateLimit,
|
||||
queueBotAction("start", "starting", deps),
|
||||
);
|
||||
router.post(
|
||||
"/:botId/stop",
|
||||
deps.tenantControlRateLimit,
|
||||
queueBotAction("stop", "stopping", deps),
|
||||
);
|
||||
router.post(
|
||||
"/:botId/restart",
|
||||
deps.tenantControlRateLimit,
|
||||
queueBotAction("restart", "starting", deps),
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"declaration": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user