feat: production hardening — robots, allowedOrigins, container non-root, rate limits, CLI bootstrap
robots.ts + metadata.robots blocks indexing.
serverActions.allowedOrigins gates cross-origin Server Action posts.
Bot + web Dockerfiles add a non-root 'app' user (uid 1000) with
chmod 700 on /data/sessions.
sendTestAction grows a per-group rate limit (3/60s).
resumeReminderRunAction + cancelReminderRunAction get a per-IP
rate limit (30/10s).
.env.example documents every required key.
packages/db/src/scripts/{set-password,create-user}.ts + thin shell
wrappers in scripts/ — first admin sets their password via
./scripts/set-password.sh admin before signing in.
This commit is contained in:
parent
67091c294a
commit
b29d137c84
27
apps/web/.env.example
Normal file
27
apps/web/.env.example
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -563,6 +563,12 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string
|
||||
export async function resumeReminderRunAction(input: {
|
||||
runId: string;
|
||||
}): Promise<ResumeReminderRunResult> {
|
||||
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<CancelReminderRunResult> {
|
||||
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) {
|
||||
|
||||
@ -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
|
||||
|
||||
5
apps/web/src/app/robots.ts
Normal file
5
apps/web/src/app/robots.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
||||
}
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
|
||||
42
packages/db/src/scripts/create-user.ts
Normal file
42
packages/db/src/scripts/create-user.ts
Normal file
@ -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 <username> <admin|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);
|
||||
});
|
||||
45
packages/db/src/scripts/set-password.ts
Normal file
45
packages/db/src/scripts/set-password.ts
Normal file
@ -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 <username>");
|
||||
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);
|
||||
});
|
||||
3
scripts/create-user.sh
Executable file
3
scripts/create-user.sh
Executable file
@ -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 "$@"
|
||||
3
scripts/set-password.sh
Executable file
3
scripts/set-password.sh
Executable file
@ -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 "$@"
|
||||
Loading…
x
Reference in New Issue
Block a user