Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ This is a combined RMS (Resource Management System) backend + marketing site for
- **tRPC caller constraint:** When calling procedures from server components, always verify the Zod input schema's runtime constraints (min, max, regex, enum). TypeScript only checks the base type (`number`, `string`), not Zod refinements. Open the router file and read the `.input()` block before writing the call.
- **Audit logging:** mutations use `withAudit` middleware — do not bypass
- **Public pages:** require 4 additions: proxy.ts PUBLIC_PATHS, [slug]/page.tsx RESERVED, sitemap.ts, footer link in site-footer.tsx
- **Middleware is default-deny:** any page or endpoint a logged-out client must reach — auth flows, webhooks, cron — needs a `PUBLIC_PATHS` entry, or it silently redirects to `/login`. Verify with a logged-out `curl -I`.
- **All public pages:** use `export const dynamic = "force-dynamic"` and fetch company name with try/catch fallback
- **Settings:** stored in DB as key/value (Setting model), fetched via `db.setting.findUnique`
- **CMS blocks:** JSON array of { type, content, level, src, alt, ctaText, ctaHref } objects
Expand Down Expand Up @@ -168,8 +169,11 @@ This is a combined RMS (Resource Management System) backend + marketing site for
- **Admin password:** seed now generates a random 24-char password on first run, writes to `.first-admin-credentials` (0600). No more hardcoded `changeme123`. Founder rotated production password manually — confirm by reading the file or by attempting login with the old default (should fail)
- **No CSRF on public API routes:** `/api/tickets/reply` and `/api/files/upload` use POST but no CSRF token — relies on SameSite cookies which is sufficient for browser clients but not for API-style access
- **Rate limiting is in-memory:** `server/rate-limit.ts` uses a Map — resets on server restart, doesn't work across multiple instances. Acceptable for single-instance VPS deployment.
- **No email queue retry backoff:** `app/api/cron/process-emails/route.ts` retries failed emails up to 3 times with no delay between attempts — should add exponential backoff if email failures become common
- **Portal token is permanent:** Client portal tokens (cuid) never expire and can't be rotated without DB manual intervention — add rotation mechanism before handling sensitive client data
- **Cron jobs have never run:** `proxy.ts` redirects `/api/cron/*` to `/login` before the handlers' `CRON_SECRET` check, `CRON_SECRET` is never set (not in `.env.example` or `deploy.sh`), and no scheduler is installed. So appointment reminders and overdue-invoice flagging (`/api/cron/overdue` is the only code that sets `OVERDUE`) never happen. `EmailQueue` has no producer, so `process-emails` has nothing to do. Fix: generate `CRON_SECRET` in `deploy.sh`, let `/api/cron` through `proxy.ts` but `deny` it in nginx (a systemd timer calls `127.0.0.1:3000` directly), and compare the secret with `timingSafeEqual`.
- **Booking slots for visitors east of America/New_York:** `scheduling.availableSlots` receives the browser's local-midnight instant, takes the weekday from the UTC server's `getDay()`, and places the window on that instant's ET date. Visitors east of UTC see the previous day's slots; visitors between ET and UTC (Atlantic Canada, Puerto Rico/USVI in winter, Brazil, UK in winter) are offered slots that `book` then rejects. Every US state is unaffected. Fix: send the picked day as a `YYYY-MM-DD` string.
- **Uploads aren't backed up:** `db-backup.yml` dumps Postgres only. The `uploads_data` volume (writable only since the Dockerfile `chown` fix) has no backup, so restoring a dump leaves `File` rows pointing at missing files.
- **Root-level CMS pages sit behind login:** `proxy.ts` only exempts listed prefixes, so a published `CmsPage` served by `app/[slug]` redirects anonymous visitors to `/login`. Latent — no CMS pages are published.
- **Prod DB password is the public dev default:** `docker-compose.yml` hardcodes `simplerms_dev` for dev *and* prod, and this repo is public. Mitigated by loopback-only port binding plus the cloud security group (only 22/80/443 reachable). Rotate it (move `DATABASE_URL` into the server's `.env.local`) if this box outlives the Holt migration.
- **nodemailer forced via `overrides`:** `"nodemailer": "$nodemailer"` forces the whole tree to our direct nodemailer (now 10). next-auth's `@auth/core` declares an **optional** `nodemailer@^7` peer used only by its Email provider, which we don't use (Credentials-only in `server/auth/index.ts`) — runtime-safe, no `--legacy-peer-deps`. Side effect: `npm audit fix` and `npm update` crash on the `$` reference — raise the override floors in `package.json` by hand instead.

Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

# Code stays root-owned (read-only to the app); only the dirs it writes are
# node's. An empty named volume takes this dir's owner whenever a container
# is created with it.
RUN mkdir -p uploads .next/cache && chown node:node uploads .next/cache

# Run as non-root for defense in depth.
USER node
EXPOSE 3000
Expand Down
36 changes: 36 additions & 0 deletions __tests__/content-disposition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// __tests__/content-disposition.test.ts
import { describe, it, expect } from "vitest";
import { contentDisposition } from "@/lib/content-disposition";

// The real failure: undici rejects any header value that isn't a ByteString.
const isByteString = (v: string) => [...v].every((c) => c.charCodeAt(0) <= 0xff);

describe("contentDisposition", () => {
it("passes plain ASCII names through", () => {
expect(contentDisposition("inline", "report.pdf")).toBe(
`inline; filename="report.pdf"; filename*=UTF-8''report.pdf`
);
});

it("encodes a macOS screenshot name (U+202F before AM)", () => {
const name = "Screenshot 2026-09-30 at 10.52.11\u202fAM.png";
const header = contentDisposition("inline", name);
expect(isByteString(header)).toBe(true);
expect(header).toContain(`filename="Screenshot 2026-09-30 at 10.52.11_AM.png"`);
expect(header).toContain("filename*=UTF-8''Screenshot%202026-09-30%20at%2010.52.11%E2%80%AFAM.png");
expect(() => new Response("x", { headers: { "Content-Disposition": header } })).not.toThrow();
});

it("encodes CJK and emoji names", () => {
const header = contentDisposition("attachment", "請求書 🧾.pdf");
expect(isByteString(header)).toBe(true);
expect(decodeURIComponent(header.split("UTF-8''")[1])).toBe("請求書 🧾.pdf");
});

it("cannot be broken out of by quotes, backslashes, CR/LF, or RFC 5987 delimiters", () => {
const header = contentDisposition("inline", `a"b\\c\r\nSet-Cookie: x=1'(*).txt`);
expect(header).not.toMatch(/[\r\n]/);
expect(header).toContain(`filename="a_b_c__Set-Cookie: x=1'(*).txt"`);
expect(header.split("UTF-8''")[1]).not.toMatch(/['()*"\\]/);
});
});
42 changes: 42 additions & 0 deletions __tests__/safe-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// __tests__/safe-redirect.test.ts
import { describe, it, expect } from "vitest";
import { safeRedirectPath } from "@/lib/safe-redirect";

const ORIGIN = "https://akritos.com";

describe("safeRedirectPath", () => {
it("keeps same-origin paths, including query and hash", () => {
expect(safeRedirectPath("/dashboard/invoices?status=open#top", ORIGIN)).toBe(
"/dashboard/invoices?status=open#top"
);
});

it("reduces a same-origin absolute URL to its path", () => {
expect(safeRedirectPath("https://akritos.com/dashboard/settings", ORIGIN)).toBe("/dashboard/settings");
});

it.each([
["protocol-relative", "//evil.example"],
["backslash host", "/\\evil.example"],
["double backslash", "/\\\\evil.example"],
["mixed slashes", "\\/evil.example"],
["tab-split slashes", "/\t/evil.example"],
["newline-split slashes", "/\n/evil.example"],
["absolute foreign URL", "https://evil.example/dashboard"],
["lookalike host", "https://akritos.com.evil.example/"],
["other scheme on our host", "http://akritos.com/dashboard"],
["javascript URL", "javascript:alert(1)"],
["data URL", "data:text/html,<script>alert(1)</script>"],
])("rejects %s", (_label, raw) => {
expect(safeRedirectPath(raw, ORIGIN)).toBe("/dashboard");
});

it("falls back when missing or empty", () => {
expect(safeRedirectPath(null, ORIGIN)).toBe("/dashboard");
expect(safeRedirectPath("", ORIGIN)).toBe("/dashboard");
});

it("uses the caller's fallback", () => {
expect(safeRedirectPath("//evil.example", ORIGIN, "/")).toBe("/");
});
});
34 changes: 19 additions & 15 deletions app/api/files/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
import { getFile } from "@/server/storage/local";
import { contentDisposition } from "@/lib/content-disposition";

export async function GET(
_request: NextRequest,
Expand All @@ -16,21 +17,24 @@ export async function GET(
const file = await db.file.findUnique({ where: { id } });
if (!file) return NextResponse.json({ error: "Not found" }, { status: 404 });

let buffer: Buffer;
try {
const buffer = await getFile(file.storagePath);
// Strip quotes/CR/LF from the client-supplied name so it can't break the
// header, and send nosniff so a browser won't MIME-sniff a spoofed
// Content-Type into something executable (the upload MIME is client-set).
const safeName = file.originalName.replace(/[\r\n"\\]/g, "_");
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": file.mimeType,
"Content-Disposition": `inline; filename="${safeName}"`,
"Content-Length": String(file.sizeBytes),
"X-Content-Type-Options": "nosniff",
},
});
} catch {
return NextResponse.json({ error: "File not found on disk" }, { status: 404 });
buffer = await getFile(file.storagePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return NextResponse.json({ error: "File not found on disk" }, { status: 404 });
}
throw err;
}

return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": file.mimeType,
"Content-Disposition": contentDisposition("inline", file.originalName),
"Content-Length": String(file.sizeBytes),
// The upload MIME type is client-set; nosniff stops a browser from
// sniffing a spoofed type into something executable.
"X-Content-Type-Options": "nosniff",
},
});
}
8 changes: 8 additions & 0 deletions app/api/files/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
import { saveFile } from "@/server/storage/local";
import { rateLimit } from "@/server/rate-limit";

const MAX_SIZE = 10 * 1024 * 1024; // 10 MB

Expand All @@ -21,6 +22,13 @@ const ALLOWED_TYPES = new Set([
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// Matches staffProcedure: READONLY accounts can't mutate anything.
if (session.user.role === "READONLY") return NextResponse.json({ error: "Forbidden" }, { status: 403 });

// Checked before the body is parsed; bounds how fast one account can fill
// the disk that Postgres shares.
const { allowed } = rateLimit(`upload:${session.user.id}`, 30, 60 * 60 * 1000);
if (!allowed) return NextResponse.json({ error: "Too many uploads. Try again later." }, { status: 429 });

const formData = await request.formData();
const file = formData.get("file") as File | null;
Expand Down
5 changes: 3 additions & 2 deletions app/blog/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ export const alt = "Akritos blog post";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function OG({ params }: { params: { slug: string } }) {
export default async function OG({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await db.cmsPost.findUnique({
where: { slug: params.slug, status: "PUBLISHED" },
where: { slug, status: "PUBLISHED" },
select: { title: true, excerpt: true },
});

Expand Down
6 changes: 5 additions & 1 deletion app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { db } from "@/server/db";
import { formatDate } from "@/lib/utils";
import { ArrowRight } from "lucide-react";

export const metadata = { title: "Blog", description: "Insights and updates from our team." };
export const metadata = {
title: "Blog",
description: "Insights and updates from our team.",
alternates: { canonical: "https://akritos.com/blog" },
};

async function getData() {
const [setting, posts] = await Promise.all([
Expand Down
5 changes: 4 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { TRPCProvider } from "@/lib/trpc/provider";
import { Analytics } from "@/components/site/analytics";
import { PublicOnly } from "@/components/site/public-only";
import { db } from "@/server/db";
import "./globals.css";

Expand Down Expand Up @@ -62,7 +63,9 @@ export default function RootLayout({
<body className="h-full bg-background font-sans text-foreground">
<TRPCProvider>{children}</TRPCProvider>
{/* Analytics is provider-agnostic and noop when settings are unset */}
<Analytics />
<PublicOnly>
<Analytics />
</PublicOnly>
</body>
</html>
);
Expand Down
2 changes: 1 addition & 1 deletion app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { db } from "@/server/db";
import { ArrowRight, Check, X } from "lucide-react";

export const metadata: Metadata = {
title: "Pricing — Akritos",
title: "Pricing",
description:
"Published rates for senior IT partnership. Free 1-hour consultation. Project work from $1,500 flat. Partnership retainers from $750/month. Advisory at $250/hr. Vendor costs pass through at cost — zero markup.",
alternates: { canonical: "https://akritos.com/pricing" },
Expand Down
14 changes: 4 additions & 10 deletions components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,11 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { safeRedirectPath } from "@/lib/safe-redirect";

export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
// Only allow same-origin relative paths — a full/protocol-relative URL here
// would be an open redirect (a user who just authenticated on the real page
// gets bounced to an attacker's site). `//evil.com` is protocol-relative, so
// reject anything that doesn't start with a single "/".
const rawCallback = searchParams.get("callbackUrl");
const callbackUrl =
rawCallback && rawCallback.startsWith("/") && !rawCallback.startsWith("//")
? rawCallback
: "/dashboard";

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Expand All @@ -45,7 +37,9 @@ export function LoginForm() {
return;
}

router.push(callbackUrl);
// A just-authenticated user bounced off-site is a phishing setup, so the
// callback must resolve to this origin.
router.push(safeRedirectPath(searchParams.get("callbackUrl"), window.location.origin));
router.refresh();
}

Expand Down
12 changes: 10 additions & 2 deletions components/auth/reset-password-form.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// components/auth/reset-password-form.tsx
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand All @@ -11,7 +11,15 @@ import Link from "next/link";

export function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
// Captured once: the effect below strips it from the URL, and Next syncs
// replaceState into useSearchParams.
const [token] = useState(() => searchParams.get("token") ?? "");

// The token is a password-reset credential; keep it out of browser history
// and the Referer of every later request from this page.
useEffect(() => {
if (token) window.history.replaceState(null, "", window.location.pathname);
}, [token]);

const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
Expand Down
22 changes: 22 additions & 0 deletions components/site/public-only.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// components/site/public-only.tsx
"use client";

import { usePathname } from "next/navigation";

// Signed-in and token-bearing routes. Analytics reports the full page URL, so
// rendering it here would ship reset, portal, and ticket-tracking tokens to a
// third party.
const PRIVATE_PREFIXES = [
"/dashboard",
"/portal",
"/login",
"/forgot-password",
"/reset-password",
"/support/track",
];

export function PublicOnly({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isPrivate = PRIVATE_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
return isPrivate ? null : children;
}
51 changes: 45 additions & 6 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ set -e
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
REPO_DIR="/opt/simplerms"

# Without the newline guard, a key appended to a file that lacks a trailing
# newline fuses onto the previous line and corrupts that value.
append_env() {
if [ -s .env.local ] && [ -n "$(tail -c1 .env.local)" ]; then echo >> .env.local; fi
echo "$1" >> .env.local
}

# Replaces every KEY= line with a single KEY=value line.
set_env() {
sed -i "/^$1=/d" .env.local
append_env "$1=$2"
}

# Absent, empty, or still the .env.example placeholder. A bare presence check
# keeps the placeholder AUTH_SECRET — public in this repo — so anyone could
# forge a session.
env_missing() {
! grep -qE "^$1=[\"']?[^\"'[:space:]]" .env.local || grep -qE "^$1=.*replace-with" .env.local
}

echo "==> Changing to repo directory"
cd "$REPO_DIR"

Expand All @@ -14,21 +34,40 @@ if [ ! -f .env.local ]; then
echo " Created .env.local from .env.example"
fi

if ! grep -q "^AUTH_SECRET=" .env.local 2>/dev/null; then
echo "AUTH_SECRET=$(openssl rand -base64 32)" >> .env.local
if env_missing AUTH_SECRET; then
set_env AUTH_SECRET "$(openssl rand -base64 32)"
echo " Generated AUTH_SECRET"
fi

if ! grep -q "^APP_ENCRYPTION_KEY=" .env.local 2>/dev/null || grep -q "replace-with" .env.local 2>/dev/null; then
sed -i "s|APP_ENCRYPTION_KEY=.*|APP_ENCRYPTION_KEY=$(openssl rand -hex 32)|" .env.local
if env_missing APP_ENCRYPTION_KEY; then
set_env APP_ENCRYPTION_KEY "$(openssl rand -hex 32)"
echo " Generated APP_ENCRYPTION_KEY"
fi

if ! grep -q "^AUTH_TRUST_HOST=" .env.local 2>/dev/null; then
echo "AUTH_TRUST_HOST=true" >> .env.local
if ! grep -q "^AUTH_TRUST_HOST=" .env.local; then
append_env "AUTH_TRUST_HOST=true"
echo " Set AUTH_TRUST_HOST=true (required behind reverse proxy)"
fi

# Without AUTH_URL, Auth.js builds redirect URLs from the container hostname,
# so sign-out lands on https://<container-id>:3000. Re-synced every deploy to
# follow NEXT_PUBLIC_APP_URL, and reduced to an origin because Auth.js treats
# any path in it as its basePath.
APP_ORIGIN=$(grep -E '^NEXT_PUBLIC_APP_URL=' .env.local | tail -1 | cut -d= -f2- \
| sed 's/[[:space:]]#.*$//' | tr -d "\"' \r" \
| sed -nE 's#^(https?://[A-Za-z0-9.-]+(:[0-9]+)?)([/?#].*)?$#\1#p')
case "$APP_ORIGIN" in
"" | *://localhost | *://localhost:* | *://127.0.0.1 | *://127.0.0.1:*)
echo " WARNING: NEXT_PUBLIC_APP_URL is unset, invalid, or local — AUTH_URL not set"
;;
*)
if ! grep -qxF "AUTH_URL=$APP_ORIGIN" .env.local; then
set_env AUTH_URL "$APP_ORIGIN"
echo " Set AUTH_URL=$APP_ORIGIN"
fi
;;
esac

echo "==> Ensuring swap space (prevents OOM during build)"
if [ ! -f /swapfile ]; then
sudo fallocate -l 2G /swapfile
Expand Down
Loading
Loading