From 5e976d108f40002d3a584bd53ce5190d414bcf0a Mon Sep 17 00:00:00 2001 From: waterlemonnn Date: Sun, 9 Aug 2026 07:41:40 +0700 Subject: [PATCH] refactor(hash): dedupe edit-token hashing into shared helper Six files each reimplemented SHA-256 edit-token hashing inline or as a local hashToken()/getTokenHash() helper. Add hashEditToken() to lib/utils/hash.ts (same algorithm, output stays byte-identical since existing posts.edit_token_hash rows depend on it) and switch all six call sites to import it instead. --- app/api/admin/posts/[id]/resend-token/route.ts | 5 +++-- app/api/edit/[token]/route.ts | 10 +++------- app/api/posts/edit/[token]/route.ts | 10 +++------- app/api/posts/route.test.ts | 2 ++ app/api/posts/route.ts | 6 +++--- app/api/status/[token]/route.ts | 8 ++------ app/edit/[token]/page.tsx | 8 ++------ lib/utils/hash.test.ts | 16 +++++++++++++++- lib/utils/hash.ts | 16 +++++++++++++++- 9 files changed, 48 insertions(+), 33 deletions(-) diff --git a/app/api/admin/posts/[id]/resend-token/route.ts b/app/api/admin/posts/[id]/resend-token/route.ts index 7e0c14c..3372706 100644 --- a/app/api/admin/posts/[id]/resend-token/route.ts +++ b/app/api/admin/posts/[id]/resend-token/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { sendEditTokenEmail } from "@/lib/resend/send"; -import { randomBytes, createHash } from "crypto"; +import { randomBytes } from "crypto"; +import { hashEditToken } from "@/lib/utils/hash"; export async function POST( req: NextRequest, @@ -24,7 +25,7 @@ export async function POST( } const token = randomBytes(32).toString("hex"); - const tokenHash = createHash("sha256").update(token).digest("hex"); + const tokenHash = hashEditToken(token); const expiresAt = new Date( Date.now() + 30 * 24 * 60 * 60 * 1000, ).toISOString(); diff --git a/app/api/edit/[token]/route.ts b/app/api/edit/[token]/route.ts index 997d5c0..702ff4e 100644 --- a/app/api/edit/[token]/route.ts +++ b/app/api/edit/[token]/route.ts @@ -1,12 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { createHash } from "crypto"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { redactPii } from "@/lib/utils/pii"; - -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} +import { hashEditToken } from "@/lib/utils/hash"; interface RouteParams { params: { token: string }; @@ -16,7 +12,7 @@ export async function GET( _req: NextRequest, { params }: RouteParams, ): Promise { - const tokenHash = hashToken(params.token); + const tokenHash = hashEditToken(params.token); const supabase = createSupabaseAdminClient(); const { data, error } = await supabase @@ -58,7 +54,7 @@ export async function PATCH( req: NextRequest, { params }: RouteParams, ): Promise { - const tokenHash = hashToken(params.token); + const tokenHash = hashEditToken(params.token); const supabase = createSupabaseAdminClient(); // Verify token resolves to a post diff --git a/app/api/posts/edit/[token]/route.ts b/app/api/posts/edit/[token]/route.ts index 1a96b07..cbcb6da 100644 --- a/app/api/posts/edit/[token]/route.ts +++ b/app/api/posts/edit/[token]/route.ts @@ -1,8 +1,8 @@ -import { createHash } from "crypto"; import { NextRequest, NextResponse } from "next/server"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { submitSchema, screenshotUrlsSchema } from "@/lib/schemas/submit"; import { redactPii } from "@/lib/utils/pii"; +import { hashEditToken } from "@/lib/utils/hash"; const editSchema = submitSchema.extend({ screenshotUrls: screenshotUrlsSchema, @@ -24,10 +24,6 @@ interface EditablePostRow { post_tags: Array<{ tags: { slug: string | null } | null }> | null; } -function getTokenHash(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - interface RouteContext { params: { token: string }; } @@ -35,7 +31,7 @@ interface RouteContext { export async function GET(_req: NextRequest, { params }: RouteContext) { try { const supabase = createSupabaseAdminClient(); - const tokenHash = getTokenHash(params.token); + const tokenHash = hashEditToken(params.token); const { data, error } = await supabase .from("posts") @@ -115,7 +111,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) { ); } - const tokenHash = getTokenHash(params.token); + const tokenHash = hashEditToken(params.token); const supabase = createSupabaseAdminClient(); const { data: existing, error: existingError } = await supabase diff --git a/app/api/posts/route.test.ts b/app/api/posts/route.test.ts index e21d98d..02f28a8 100644 --- a/app/api/posts/route.test.ts +++ b/app/api/posts/route.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const sendEditTokenEmail = vi.fn(); const hashIp = vi.fn(() => "hashed-ip"); const getClientIp = vi.fn(() => "127.0.0.1"); +const hashEditToken = vi.fn(() => "hashed-edit-token"); const consumeSharedRateLimit = vi.fn(async () => ({ allowed: true, remaining: 2, @@ -23,6 +24,7 @@ vi.mock("@/lib/resend/send", () => ({ vi.mock("@/lib/utils/hash", () => ({ hashIp, getClientIp, + hashEditToken, })); vi.mock("@/lib/rate-limit/shared", () => ({ diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index e859d24..62ed768 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from "next/server"; import { submitSchema, screenshotUrlsSchema } from "@/lib/schemas/submit"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { sendEditTokenEmail } from "@/lib/resend/send"; -import { hashIp, getClientIp } from "@/lib/utils/hash"; +import { hashIp, getClientIp, hashEditToken } from "@/lib/utils/hash"; import { redactPii } from "@/lib/utils/pii"; -import { randomBytes, createHash } from "crypto"; +import { randomBytes } from "crypto"; import { logEvent } from "@/lib/observability/events"; import { consumeSharedRateLimit } from "@/lib/rate-limit/shared"; @@ -94,7 +94,7 @@ export async function POST(req: NextRequest) { // Generate edit token — raw token sent to user, hash stored in DB const rawToken = randomBytes(32).toString("hex"); - const tokenHash = createHash("sha256").update(rawToken).digest("hex"); + const tokenHash = hashEditToken(rawToken); // Insert post (status = pending, goes to moderation queue) const { data: post, error: postErr } = await supabase diff --git a/app/api/status/[token]/route.ts b/app/api/status/[token]/route.ts index a298ee9..2e2e193 100644 --- a/app/api/status/[token]/route.ts +++ b/app/api/status/[token]/route.ts @@ -1,16 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; -import { createHash } from "crypto"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; - -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} +import { hashEditToken } from "@/lib/utils/hash"; export async function GET( _req: NextRequest, { params }: { params: { token: string } }, ): Promise { - const tokenHash = hashToken(params.token); + const tokenHash = hashEditToken(params.token); const supabase = createSupabaseAdminClient(); const { data, error } = await supabase diff --git a/app/edit/[token]/page.tsx b/app/edit/[token]/page.tsx index 1127f1d..1cc7699 100644 --- a/app/edit/[token]/page.tsx +++ b/app/edit/[token]/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; -import { createHash } from "crypto"; import { notFound } from "next/navigation"; import { EditCaseForm } from "@/components/post/EditCaseForm"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import type { SubmitFormValues } from "@/lib/schemas/submit"; +import { hashEditToken } from "@/lib/utils/hash"; export const metadata: Metadata = { title: "Edit Case — AgentPostmortem", @@ -30,13 +30,9 @@ interface EditablePostRow { post_tags: Array<{ tags: { slug: string | null } | null }> | null; } -function getTokenHash(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - export default async function EditSubmissionPage({ params }: PageProps) { const supabase = createSupabaseAdminClient(); - const tokenHash = getTokenHash(params.token); + const tokenHash = hashEditToken(params.token); const [{ data: post }, { data: agents }, { data: tags }] = await Promise.all([ supabase diff --git a/lib/utils/hash.test.ts b/lib/utils/hash.test.ts index ff16f3a..01d6ffb 100644 --- a/lib/utils/hash.test.ts +++ b/lib/utils/hash.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { getClientIp, hashIp } from "./hash"; +import { getClientIp, hashEditToken, hashIp } from "./hash"; describe("hashIp", () => { const originalEnv = { ...process.env }; @@ -60,3 +60,17 @@ describe("getClientIp", () => { expect(getClientIp(headers)).toBe("unknown"); }); }); + +describe("hashEditToken", () => { + it("is deterministic for the same input", () => { + expect(hashEditToken("fixed-test-token")).toBe( + hashEditToken("fixed-test-token"), + ); + }); + + it("matches a known digest for a fixed input", () => { + expect(hashEditToken("fixed-test-token")).toBe( + "abae2c734c2b0249ef1d413fdf30c332c6875fde570f9bbeef4295966f0b4943", + ); + }); +}); diff --git a/lib/utils/hash.ts b/lib/utils/hash.ts index 3ee91a8..76ee1ca 100644 --- a/lib/utils/hash.ts +++ b/lib/utils/hash.ts @@ -1,4 +1,4 @@ -import { createHmac } from "crypto"; +import { createHash, createHmac } from "crypto"; /** * Hash an IP address using HMAC-SHA256 with a secret pepper. @@ -33,3 +33,17 @@ export function getClientIp(headers: Headers): string { } return headers.get("x-real-ip") ?? "unknown"; } + +/** + * Hash an edit token with SHA-256 for storage as `posts.edit_token_hash`. + * + * The raw token is sent to the submitter and never stored; only this hash + * lives in the database. Do not change the algorithm here without a + * migration plan, existing rows were written with this exact scheme. + * + * @param token - Raw edit token (hex string from randomBytes) + * @returns Hex-encoded SHA-256 hash + */ +export function hashEditToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +}