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: {
|
experimental: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
serverActions: {
|
serverActions: {
|
||||||
|
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
|
||||||
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
||||||
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
||||||
// the action; the per-kind WhatsApp validator
|
// 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" };
|
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 op = await getSeededOperator();
|
||||||
const group = await db.query.whatsappGroups.findFirst({
|
const group = await db.query.whatsappGroups.findFirst({
|
||||||
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
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: {
|
export async function resumeReminderRunAction(input: {
|
||||||
runId: string;
|
runId: string;
|
||||||
}): Promise<ResumeReminderRunResult> {
|
}): 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 op = await getSeededOperator();
|
||||||
const parsed = runIdSchema.safeParse(input);
|
const parsed = runIdSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@ -613,6 +619,12 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string
|
|||||||
export async function cancelReminderRunAction(input: {
|
export async function cancelReminderRunAction(input: {
|
||||||
runId: string;
|
runId: string;
|
||||||
}): Promise<CancelReminderRunResult> {
|
}): 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 op = await getSeededOperator();
|
||||||
const parsed = runIdSchema.safeParse(input);
|
const parsed = runIdSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const metadata: Metadata = {
|
|||||||
title: "cm WhatsApp Bot",
|
title: "cm WhatsApp Bot",
|
||||||
description: "Self-hosted WhatsApp reminder bot",
|
description: "Self-hosted WhatsApp reminder bot",
|
||||||
applicationName: "cm WhatsApp Bot",
|
applicationName: "cm WhatsApp Bot",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
// PWA wiring: the manifest comes from the dynamic route at
|
// PWA wiring: the manifest comes from the dynamic route at
|
||||||
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
||||||
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
// 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/apps/bot /app/apps/bot
|
||||||
COPY --from=build /app/packages/db /app/packages/db
|
COPY --from=build /app/packages/db /app/packages/db
|
||||||
COPY --from=build /app/packages/shared /app/packages/shared
|
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
|
EXPOSE 8081
|
||||||
CMD ["node", "apps/bot/dist/index.js"]
|
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/standalone ./
|
||||||
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
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
|
EXPOSE 3000
|
||||||
CMD ["node", "apps/web/server.js"]
|
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