diff --git a/.dockerignore b/.dockerignore index 816d8ee3..74bedb17 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,3 +26,4 @@ install/ bruno/ LICENSE CONTRIBUTING.md +dist diff --git a/Dockerfile b/Dockerfile index adfe2597..c2ce2eb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN echo 'export * from "./sqlite";' > server/db/index.ts RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema.ts --out init RUN npm run build:sqlite +RUN npm run build:cli FROM node:20-alpine AS runner @@ -30,6 +31,9 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/dist ./dist COPY --from=builder /app/init ./dist/init +COPY ./cli/wrapper.sh /usr/local/bin/pangctl +RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs + COPY server/db/names.json ./dist/names.json COPY public ./public diff --git a/Dockerfile.pg b/Dockerfile.pg index 58c54d8c..10d440d4 100644 --- a/Dockerfile.pg +++ b/Dockerfile.pg @@ -13,6 +13,7 @@ RUN echo 'export * from "./pg";' > server/db/index.ts RUN npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema.ts --out init RUN npm run build:pg +RUN npm run build:cli FROM node:20-alpine AS runner @@ -30,6 +31,9 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/dist ./dist COPY --from=builder /app/init ./dist/init +COPY ./cli/wrapper.sh /usr/local/bin/pangctl +RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs + COPY server/db/names.json ./dist/names.json COPY public ./public diff --git a/cli/commands/setAdminCredentials.ts b/cli/commands/setAdminCredentials.ts new file mode 100644 index 00000000..72ff8bff --- /dev/null +++ b/cli/commands/setAdminCredentials.ts @@ -0,0 +1,141 @@ +import { CommandModule } from "yargs"; +import { hashPassword, verifyPassword } from "@server/auth/password"; +import { db, resourceSessions, sessions } from "@server/db"; +import { users } from "@server/db"; +import { eq, inArray } from "drizzle-orm"; +import moment from "moment"; +import { fromError } from "zod-validation-error"; +import { passwordSchema } from "@server/auth/passwordSchema"; +import { UserType } from "@server/types/UserTypes"; +import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; + +type SetAdminCredentialsArgs = { + email: string; + password: string; +}; + +export const setAdminCredentials: CommandModule<{}, SetAdminCredentialsArgs> = { + command: "set-admin-credentials", + describe: "Set the server admin credentials", + builder: (yargs) => { + return yargs + .option("email", { + type: "string", + demandOption: true, + describe: "Admin email address" + }) + .option("password", { + type: "string", + demandOption: true, + describe: "Admin password" + }); + }, + handler: async (argv: { email: string; password: string }) => { + try { + const { email, password } = argv; + + const parsed = passwordSchema.safeParse(password); + + if (!parsed.success) { + throw Error( + `Invalid server admin password: ${fromError(parsed.error).toString()}` + ); + } + + const passwordHash = await hashPassword(password); + + await db.transaction(async (trx) => { + try { + const [existing] = await trx + .select() + .from(users) + .where(eq(users.serverAdmin, true)); + + if (existing) { + const passwordChanged = !(await verifyPassword( + password, + existing.passwordHash! + )); + + if (passwordChanged) { + await trx + .update(users) + .set({ passwordHash }) + .where(eq(users.userId, existing.userId)); + + await invalidateAllSessions(existing.userId); + console.log("Server admin password updated"); + } + + if (existing.email !== email) { + await trx + .update(users) + .set({ email, username: email }) + .where(eq(users.userId, existing.userId)); + + console.log("Server admin email updated"); + } + } else { + const userId = generateId(15); + + await trx.update(users).set({ serverAdmin: false }); + + await db.insert(users).values({ + userId: userId, + email: email, + type: UserType.Internal, + username: email, + passwordHash, + dateCreated: moment().toISOString(), + serverAdmin: true, + emailVerified: true + }); + + console.log("Server admin created"); + } + } catch (e) { + console.error("Failed to set admin credentials", e); + trx.rollback(); + throw e; + } + }); + + console.log("Admin credentials updated successfully"); + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } + } +}; + +export async function invalidateAllSessions(userId: string): Promise { + try { + await db.transaction(async (trx) => { + const userSessions = await trx + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); + await trx.delete(resourceSessions).where( + inArray( + resourceSessions.userSessionId, + userSessions.map((s) => s.sessionId) + ) + ); + await trx.delete(sessions).where(eq(sessions.userId, userId)); + }); + } catch (e) { + console.log("Failed to all invalidate user sessions", e); + } +} + +const random: RandomReader = { + read(bytes: Uint8Array): void { + crypto.getRandomValues(bytes); + } +}; + +export function generateId(length: number): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + return generateRandomString(random, alphabet, length); +} diff --git a/cli/index.ts b/cli/index.ts new file mode 100644 index 00000000..db76dbf9 --- /dev/null +++ b/cli/index.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; + +yargs(hideBin(process.argv)) + .scriptName("pangctl") + .command(setAdminCredentials) + .demandCommand() + .help().argv; diff --git a/cli/wrapper.sh b/cli/wrapper.sh new file mode 100644 index 00000000..0f65092b --- /dev/null +++ b/cli/wrapper.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd /app/ +./dist/cli.mjs "$@" diff --git a/package.json b/package.json index 040a5453..1b0d6620 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", "start:sqlite": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", "start:pg": "DB_TYPE=pg NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", - "email": "email dev --dir server/emails/templates --port 3005" + "email": "email dev --dir server/emails/templates --port 3005", + "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.2", @@ -106,7 +107,8 @@ "winston-daily-rotate-file": "5.0.0", "ws": "8.18.2", "zod": "3.25.56", - "zod-validation-error": "3.4.1" + "zod-validation-error": "3.4.1", + "yargs": "18.0.0" }, "devDependencies": { "@dotenvx/dotenvx": "1.44.1", @@ -137,8 +139,7 @@ "tsc-alias": "1.8.16", "tsx": "4.19.4", "typescript": "^5", - "typescript-eslint": "^8.34.0", - "yargs": "18.0.0" + "typescript-eslint": "^8.34.0" }, "overrides": { "emblor": { diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts index 5dcf3760..f93ea22d 100644 --- a/server/setup/setupServerAdmin.ts +++ b/server/setup/setupServerAdmin.ts @@ -53,7 +53,7 @@ export async function setupServerAdmin() { if (existing.email !== email) { await trx .update(users) - .set({ email }) + .set({ email, username: email }) .where(eq(users.userId, existing.userId)); logger.info(`Server admin email updated`); @@ -77,6 +77,7 @@ export async function setupServerAdmin() { logger.info(`Server admin created`); } } catch (e) { + console.error("Failed to setup server admin"); logger.error(e); trx.rollback(); } diff --git a/tsconfig.json b/tsconfig.json index 94729399..24a1cf09 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "@server/*": ["../server/*"], "@test/*": ["../test/*"], "@app/*": ["*"], + "@cli/*": ["../cli/*"], "@/*": ["./*"] }, "plugins": [