diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..3a9b4de --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,27 @@ +# Required +DATABASE_URL=postgres://user:pass@host:5432/dbname + +# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh. +AUTH_SECRET=replace-me + +# Bump to invalidate all live sessions instantly. Leave at 1 normally. +OPERATOR_TOKEN_VERSION=1 + +# File-storage paths inside the bot container +DATA_DIR=/data +SESSIONS_DIR=/data/sessions +MEDIA_DIR=/data/media + +# Bot fan-out tuning (see apps/bot/src/env.ts) +BOT_HEALTH_PORT=8081 +BOT_LOG_LEVEL=info +BOT_FIRE_CONCURRENCY=8 +BOT_GROUP_CONCURRENCY=3 +BOT_MAX_SEND_PER_MINUTE=40 + +# Web +WEB_PORT=9000 + +# Seed (runs once via scripts/db.sh seed) +SEED_OPERATOR_USERNAME=admin +SEED_OPERATOR_NAME=Operator diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 254f983..aa54c62 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -21,6 +21,7 @@ const nextConfig: NextConfig = { experimental: { typedRoutes: true, serverActions: { + allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"], // Default Server Action body limit is 1 MB — way under WhatsApp's // 100 MB document cap. Lifted to 100 MB so document uploads reach // the action; the per-kind WhatsApp validator diff --git a/apps/web/src/actions/groups.ts b/apps/web/src/actions/groups.ts index be9843d..3902cb0 100644 --- a/apps/web/src/actions/groups.ts +++ b/apps/web/src/actions/groups.ts @@ -33,6 +33,12 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; } + const groupId = parsed.data.groupId; + const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 }); + if (groupRl.limited) { + return { ok: false, error: "Too many tests for this group. Try again later." }; + } + const op = await getSeededOperator(); const group = await db.query.whatsappGroups.findFirst({ where: (g, { eq }) => eq(g.id, parsed.data.groupId), diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index 5bf4885..c8d1264 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -563,6 +563,12 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string export async function resumeReminderRunAction(input: { runId: string; }): Promise { + const ip = + (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 }); + if (rl.limited) { + return { ok: false, error: "Too many requests. Try again later." }; + } const op = await getSeededOperator(); const parsed = runIdSchema.safeParse(input); if (!parsed.success) { @@ -613,6 +619,12 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string export async function cancelReminderRunAction(input: { runId: string; }): Promise { + const ip = + (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 }); + if (rl.limited) { + return { ok: false, error: "Too many requests. Try again later." }; + } const op = await getSeededOperator(); const parsed = runIdSchema.safeParse(input); if (!parsed.success) { diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index bb240a5..cca40f8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -10,6 +10,7 @@ export const metadata: Metadata = { title: "cm WhatsApp Bot", description: "Self-hosted WhatsApp reminder bot", applicationName: "cm WhatsApp Bot", + robots: { index: false, follow: false }, // PWA wiring: the manifest comes from the dynamic route at // src/app/manifest.webmanifest/route.ts, the apple-touch-icon is // emitted from public/, and `appleWebApp.capable` lets iOS treat the diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts new file mode 100644 index 0000000..a5971dd --- /dev/null +++ b/apps/web/src/app/robots.ts @@ -0,0 +1,5 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { rules: [{ userAgent: "*", disallow: "/" }] }; +} diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile index 1c4d0a5..6e0073e 100644 --- a/docker/bot.Dockerfile +++ b/docker/bot.Dockerfile @@ -26,5 +26,11 @@ COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/apps/bot /app/apps/bot COPY --from=build /app/packages/db /app/packages/db COPY --from=build /app/packages/shared /app/packages/shared +RUN addgroup -g 1000 app && \ + adduser -D -u 1000 -G app -s /sbin/nologin app && \ + mkdir -p /data/sessions /data/media /app && \ + chown -R app:app /app /data && \ + chmod 700 /data/sessions +USER app EXPOSE 8081 CMD ["node", "apps/bot/dist/index.js"] diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index b05ece1..5432f24 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -29,5 +29,9 @@ ENV HOSTNAME=0.0.0.0 COPY --from=build /app/apps/web/.next/standalone ./ COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static COPY --from=build /app/apps/web/public ./apps/web/public +RUN addgroup -g 1000 app && \ + adduser -D -u 1000 -G app -s /sbin/nologin app && \ + chown -R app:app /app +USER app EXPOSE 3000 CMD ["node", "apps/web/server.js"] diff --git a/packages/db/src/scripts/create-user.ts b/packages/db/src/scripts/create-user.ts new file mode 100644 index 0000000..66a9bf2 --- /dev/null +++ b/packages/db/src/scripts/create-user.ts @@ -0,0 +1,42 @@ +import bcrypt from "bcryptjs"; +import { sql } from "drizzle-orm"; +import { createInterface } from "node:readline/promises"; +import { Writable } from "node:stream"; +import { createClient } from "../index.js"; + +async function main() { + const username = process.argv[2]; + const role = process.argv[3]; + if (!username || (role !== "admin" && role !== "user")) { + console.error("Usage: create-user "); + process.exit(2); + } + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL not set"); + process.exit(2); + } + const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + const rl = createInterface({ input: process.stdin, output: muted, terminal: true }); + process.stdout.write("Password: "); + const password = await rl.question(""); + rl.close(); + process.stdout.write("\n"); + if (password.length < 10) { + console.error("Password must be at least 10 characters."); + process.exit(2); + } + const hash = await bcrypt.hash(password, 12); + const { db, pool } = createClient(url); + await db.execute( + sql`INSERT INTO operators (username, password_hash, display_name, role, default_timezone) + VALUES (${username}, ${hash}, ${username}, ${role}, 'Asia/Kuala_Lumpur')`, + ); + await pool.end(); + console.log(`Created ${role} ${username}.`); + process.exit(0); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/db/src/scripts/set-password.ts b/packages/db/src/scripts/set-password.ts new file mode 100644 index 0000000..53130f5 --- /dev/null +++ b/packages/db/src/scripts/set-password.ts @@ -0,0 +1,45 @@ +import bcrypt from "bcryptjs"; +import { sql } from "drizzle-orm"; +import { createInterface } from "node:readline/promises"; +import { Writable } from "node:stream"; +import { createClient } from "../index.js"; + +async function main() { + const username = process.argv[2]; + if (!username) { + console.error("Usage: set-password "); + process.exit(2); + } + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL not set"); + process.exit(2); + } + // Silenced password prompt. + const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + const rl = createInterface({ input: process.stdin, output: muted, terminal: true }); + process.stdout.write("Password: "); + const password = await rl.question(""); + rl.close(); + process.stdout.write("\n"); + if (password.length < 10) { + console.error("Password must be at least 10 characters."); + process.exit(2); + } + const hash = await bcrypt.hash(password, 12); + const { db, pool } = createClient(url); + const result = await db.execute( + sql`UPDATE operators SET password_hash = ${hash} WHERE lower(username) = lower(${username}) RETURNING id`, + ); + await pool.end(); + if (result.rows.length === 0) { + console.error(`No user with username ${username}`); + process.exit(1); + } + console.log("Password updated."); + process.exit(0); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/create-user.sh b/scripts/create-user.sh new file mode 100755 index 0000000..281074a --- /dev/null +++ b/scripts/create-user.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/create-user.ts "$@" diff --git a/scripts/set-password.sh b/scripts/set-password.sh new file mode 100755 index 0000000..cace16f --- /dev/null +++ b/scripts/set-password.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/set-password.ts "$@"