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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
branches: [main, master]
pull_request:

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint --if-present

- name: Build
run: npm run build --if-present

- name: Test
run: npm test --if-present

security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Dependency audit (high severity)
run: npm audit --audit-level=high || true

- name: Secret scan (gitleaks)
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar -xz gitleaks
./gitleaks detect --source . --no-banner --redact --verbose
4 changes: 2 additions & 2 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE, sessionToken } from "@/lib/auth";
import { SESSION_COOKIE, sessionToken, timingSafeStringEqual } from "@/lib/auth";

export const runtime = "nodejs";

Expand All @@ -14,7 +14,7 @@ export async function POST(req: NextRequest) {
const next = safeNext(form.get("next"));
const expected = process.env.SITE_PASSWORD ?? "";

if (!expected || password !== expected) {
if (!expected || !timingSafeStringEqual(password, expected)) {
const url = new URL("/login", req.url);
url.searchParams.set("error", "1");
if (next !== "/") url.searchParams.set("next", next);
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/entries/[id]/photos/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { addEntryPhoto, deleteEntryPhoto, normalizeImageExt } from "@/lib/entries";
import { requireAuth } from "@/lib/auth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -9,6 +10,9 @@ const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB

// Add a photo to an existing memory (multipart form, field name "photo").
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const unauthorized = await requireAuth(req);
if (unauthorized) return unauthorized;

const { id } = await ctx.params;

let file: File | null = null;
Expand Down Expand Up @@ -50,6 +54,9 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string

// Remove a photo from a memory (JSON body: { path }).
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const unauthorized = await requireAuth(req);
if (unauthorized) return unauthorized;

const { id } = await ctx.params;

let body: { path?: unknown };
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/entries/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { updateEntryDescription } from "@/lib/entries";
import { requireAuth } from "@/lib/auth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// Edit an existing memory. Only the free-text description is editable here;
// photos are managed via the /photos sub-route.
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const unauthorized = await requireAuth(req);
if (unauthorized) return unauthorized;

const { id } = await ctx.params;

let body: { description?: unknown };
Expand Down
41 changes: 41 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";

export const SESSION_COOKIE = "lj_session";

/**
Expand All @@ -12,3 +15,41 @@ export async function sessionToken(password: string): Promise<string> {
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* Constant-time string comparison. Guards against length mismatch first
* (timingSafeEqual throws on unequal-length Buffers), then defers to
* crypto.timingSafeEqual so the comparison does not short-circuit and leak
* timing information about the password/token.
*/
export function timingSafeStringEqual(a: string, b: string): boolean {
const ab = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}

/**
* Authorize a mutating request. WRITE endpoints (create/edit/delete) must
* always require a valid owner session, independent of whether SITE_PASSWORD
* gates public reads. When SITE_PASSWORD is unset there is no owner session to
* validate against, so writes fail closed (401) rather than falling open.
*
* Returns a 401 NextResponse when the caller is unauthenticated/invalid, or
* `null` when the request is authorized and the handler may proceed.
*/
export async function requireAuth(req: NextRequest): Promise<NextResponse | null> {
const unauthorized = () =>
NextResponse.json({ ok: false, error: "Unauthorized." }, { status: 401 });

const password = process.env.SITE_PASSWORD ?? "";
if (!password) return unauthorized();

const token = req.cookies.get(SESSION_COOKIE)?.value ?? "";
if (!token) return unauthorized();

const expected = await sessionToken(password);
if (!timingSafeStringEqual(token, expected)) return unauthorized();

return null;
}
4 changes: 2 additions & 2 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE, sessionToken } from "@/lib/auth";
import { SESSION_COOKIE, sessionToken, timingSafeStringEqual } from "@/lib/auth";

/**
* Optional site-wide password gate (Next.js 16 "proxy").
Expand All @@ -17,7 +17,7 @@ export async function proxy(req: NextRequest) {
}

const token = req.cookies.get(SESSION_COOKIE)?.value;
if (token && token === (await sessionToken(password))) {
if (token && timingSafeStringEqual(token, await sessionToken(password))) {
return NextResponse.next();
}

Expand Down
Loading