feat: improve form results and harden secrets

This commit is contained in:
2026-07-30 14:48:46 +07:00 Unverified
parent 6b0440602b
commit a014f72d0f
32 changed files with 471 additions and 11 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
node_modules
.next
dist
.env.local
.env*
.git
.gitignore
npm-debug.log
+3
View File
@@ -42,4 +42,7 @@ next-env.d.ts
public/form
drizzle/*
!drizzle/0010_rainy_speedball.sql
!drizzle/0012_clammy_the_stranger.sql
!drizzle/0013_regular_unus.sql
!drizzle/0014_lethal_pepper_potts.sql
+25
View File
@@ -34,4 +34,29 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Secret safety
- Keep credentials in `.env` or `.env.local`. Never prefix a credential with
`NEXT_PUBLIC_`.
- Restrict local access before starting the app:
```bash
chmod 600 .env .env.local
```
- Scan tracked files and Git history before deployment:
```bash
bun run secrets:scan
```
- Deploy with Docker Compose so `.env` is injected only when the container
starts. Environment files are excluded from the Docker build context.
- Give the Discord bot only the permissions it needs. Avoid the Administrator
permission.
- Rotate every credential from `.env` if an environment file, build context,
builder cache, log, or host may have been shared. If prior exposure cannot be
ruled out, treat the credentials as exposed.
# erika
@@ -1,8 +1,15 @@
"use client";
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import {
ContextMenu,
ContextMenuTrigger,
@@ -12,7 +19,15 @@ import {
ContextMenuItem,
ContextMenuGroup
} from "@/components/ui/context-menu";
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
import {
AlertCircle,
Calendar,
Check,
Clock,
Copy,
Loader2,
Users,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
@@ -27,6 +42,11 @@ import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
import { formatStoredAnswer } from "@/lib/form-answer-values";
import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake";
import { updateSubmissionAdminNote } from "@/app/admin/form/actions";
import {
useDebouncedAutoSave,
type AutoSaveStatus,
} from "@/hooks/use-debounced-autosave";
import {
DiscordProfileDropdown,
type DiscordMemberProfile,
@@ -42,10 +62,12 @@ interface Question {
interface SubmissionDetailClientProps {
submission: {
id: string;
formId: string;
userName: string | null;
userDiscordId: string | null;
userProfile: DiscordMemberProfile | null;
submittedAt: Date;
adminNote: string | null;
editHistory?: { editedAt: string; oldAnswers: Record<string, string | { value: string; imageCdnId: string | null }> }[] | null;
};
questions: Question[];
@@ -58,6 +80,7 @@ export default function SubmissionDetailClient({
answers,
}: SubmissionDetailClientProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [adminNote, setAdminNote] = useState(submission.adminNote ?? "");
const handleCopy = (text: string, id: string) => {
if (!text) return;
@@ -72,7 +95,19 @@ export default function SubmissionDetailClient({
const editHistory = Array.isArray(submission.editHistory) ? submission.editHistory : [];
const saveAdminNote = useCallback(
() =>
updateSubmissionAdminNote(
submission.formId,
submission.id,
adminNote
).then(() => undefined),
[adminNote, submission.formId, submission.id]
);
const noteAutoSave = useDebouncedAutoSave({
save: saveAdminNote,
delay: 1000,
});
return (
<div className="space-y-6">
@@ -120,6 +155,42 @@ export default function SubmissionDetailClient({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Admin note</CardTitle>
<CardDescription>
Private context for administrators. Respondents cannot see this note.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={noteAutoSave.status === "error"}>
<FieldLabel htmlFor="admin-note">Note</FieldLabel>
<Textarea
id="admin-note"
value={adminNote}
onChange={(event) => {
setAdminNote(event.target.value);
noteAutoSave.schedule();
}}
placeholder="Add a private note about this submission..."
maxLength={10_000}
aria-invalid={noteAutoSave.status === "error"}
/>
<FieldDescription>
Autosaves after you stop typing. Only administrators can view
this note.
</FieldDescription>
<SaveStatusIndicator
status={noteAutoSave.status}
error={noteAutoSave.error}
onRetry={noteAutoSave.retry}
/>
</Field>
</FieldGroup>
</CardContent>
</Card>
{/* Answers List */}
<div className="space-y-4">
{questions.map((q) => {
@@ -289,6 +360,54 @@ export default function SubmissionDetailClient({
);
}
function SaveStatusIndicator({
status,
error,
onRetry,
}: {
status: AutoSaveStatus;
error: string | null;
onRetry: () => Promise<void>;
}) {
if (status === "clean") return null;
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-live="polite"
>
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save the note."}</span>
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
function AnswerImagePreview({ src, label }: { src: string; label: string }) {
const [unavailable, setUnavailable] = useState(false);
@@ -82,6 +82,7 @@ export default async function SubmissionDetailPage({
</div>
<SubmissionDetailClient
key={submission.id}
submission={{
...submission,
userProfile: respondentProfile,
+27 -1
View File
@@ -142,6 +142,7 @@ export default async function ResultsListPage({
<TableHead>Respondent</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead>Admin note</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -181,6 +182,17 @@ export default async function ResultsListPage({
{edited ? "Edited" : "Original"}
</Badge>
</TableCell>
<TableCell className="max-w-80">
{submission.adminNote ? (
<p className="line-clamp-2 whitespace-pre-wrap text-sm">
{submission.adminNote}
</p>
) : (
<span className="text-sm italic text-muted-foreground">
No note
</span>
)}
</TableCell>
<TableCell className="relative z-10 text-right">
<DeleteSubmissionButton id={submission.id} compact />
</TableCell>
@@ -222,7 +234,21 @@ export default async function ResultsListPage({
{formatDate(submission.submittedAt)}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<CardContent className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">
Admin note
</p>
{submission.adminNote ? (
<p className="line-clamp-3 whitespace-pre-wrap text-sm">
{submission.adminNote}
</p>
) : (
<p className="text-sm italic text-muted-foreground">
No note
</p>
)}
</div>
<div className="relative z-10">
<DeleteSubmissionButton id={submission.id} compact />
</div>
+40 -2
View File
@@ -1,8 +1,8 @@
"use server";
import { db } from "@/db";
import { forms } from "@/db/schema";
import { eq } from "drizzle-orm";
import { forms, submissions } from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth";
@@ -78,6 +78,44 @@ export async function updateForm(id: string, data: { title?: string; description
revalidatePath("/admin/form");
}
export async function updateSubmissionAdminNote(
formId: string,
submissionId: string,
note: string
) {
await requireAdmin();
if (
typeof formId !== "string" ||
!formId ||
typeof submissionId !== "string" ||
!submissionId ||
typeof note !== "string" ||
note.length > 10_000
) {
throw new Error("Invalid admin note.");
}
const [updatedSubmission] = await db
.update(submissions)
.set({ adminNote: note.trim() || null })
.where(
and(
eq(submissions.id, submissionId),
eq(submissions.formId, formId)
)
)
.returning({ id: submissions.id });
if (!updatedSubmission) {
throw new Error("Submission not found.");
}
revalidatePath(`/admin/form/${formId}/result`);
revalidatePath(`/admin/form/${formId}/result/${submissionId}`);
return { ok: true };
}
export async function testDiscordWebhook(url: string, content: string) {
await requireAdmin();
const trimmedUrl = url.trim();
+4
View File
@@ -50,6 +50,10 @@ export default async function FormsSubmitPage(
where: (s, { eq, and }) =>
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
orderBy: (s, { desc }) => [desc(s.submittedAt)],
columns: {
id: true,
submittedAt: true,
},
}),
getFormSubmissionStats([form.id], discordId),
]);
+3
View File
@@ -5,11 +5,14 @@ import { getFollowerCounts } from "@/lib/followers";
import { LinkCard } from "@/components/LinkCard";
import { getRedisClient } from "@/lib/redis";
import { headers } from "next/headers";
import { connection } from "next/server";
import { Users } from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
import Background from "@/public/background/space.webp";
export default async function Home() {
await connection();
const { counts } = await getFollowerCounts();
const headersList = await headers();
+3
View File
@@ -34,6 +34,7 @@
"react-dom": "19.2.7",
"react-hook-form": "^7.81.0",
"reconnecting-eventsource": "^1.6.5",
"server-only": "^0.0.1",
"shadcn": "^4.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@@ -1434,6 +1435,8 @@
"serve-static": ["[email protected]", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"server-only": ["[email protected]", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"set-function-length": ["[email protected]", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["[email protected]", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./test/setup.ts"]
+1
View File
@@ -1,3 +1,4 @@
import "server-only";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
+1
View File
@@ -85,6 +85,7 @@ export const submissions = formSchema.table(
.references(() => forms.id, { onDelete: "cascade" }),
userDiscordId: text("user_discord_id"),
userName: text("user_name"),
adminNote: text("admin_note"),
submittedAt: timestamp("submitted_at", { mode: "date" }).defaultNow().notNull(),
editHistory: jsonb("edit_history")
.$type<{ editedAt: string; oldAnswers: Record<string, { value: string; imageCdnId: string | null }> }[]>()
+14 -1
View File
@@ -7,7 +7,7 @@ services:
expose:
- "3000"
env_file:
.env
- .env
nginx:
image: nginx:alpine
@@ -17,3 +17,16 @@ services:
- app
network_mode: "service:app"
restart: unless-stopped
secret-scan:
image: ghcr.io/gitleaks/gitleaks:v8.30.1
profiles:
- tools
working_dir: /repo
volumes:
- .:/repo:ro
command:
- git
- --redact
- --no-banner
- .
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "form"."form" ADD COLUMN "allowed_roles" jsonb DEFAULT '[]'::jsonb;--> statement-breakpoint
ALTER TABLE "form"."form" ADD COLUMN "denied_roles" jsonb DEFAULT '[]'::jsonb;
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "form"."cdn" (
"id" text PRIMARY KEY NOT NULL,
"data" "bytea" NOT NULL,
"name" text,
"type" text NOT NULL,
"size" integer NOT NULL,
"owner_discord_id" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DROP INDEX "form"."submission_form_user_discord_id_unique";--> statement-breakpoint
ALTER TABLE "form"."answer" ADD COLUMN "image_cdn_id" text;--> statement-breakpoint
ALTER TABLE "form"."form" ADD COLUMN "allow_multiple_submissions" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "form"."question" ADD COLUMN "image_answer_mode" text DEFAULT 'none' NOT NULL;--> statement-breakpoint
ALTER TABLE "form"."answer" ADD CONSTRAINT "answer_image_cdn_id_cdn_id_fk" FOREIGN KEY ("image_cdn_id") REFERENCES "form"."cdn"("id") ON DELETE set null ON UPDATE cascade;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "form"."submission" ADD COLUMN "admin_note" text;
+7
View File
@@ -99,6 +99,13 @@
"when": 1785169071234,
"tag": "0013_regular_unus",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1785332675051,
"tag": "0014_lethal_pepper_potts",
"breakpoints": true
}
]
}
+12
View File
@@ -0,0 +1,12 @@
[extend]
useDefault = true
[[allowlists]]
description = "Exclude local secrets and generated or private directories"
paths = [
'''(^|/)\.env($|\..*$)''',
'''(^|/)\.git/''',
'''(^|/)\.next/''',
'''(^|/)node_modules/''',
'''(^|/)public/form/''',
]
+1
View File
@@ -1,3 +1,4 @@
import "server-only";
import type { NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
+1
View File
@@ -1,3 +1,4 @@
import "server-only";
import { getServerSession } from "next-auth";
import type { Session } from "next-auth";
import { db } from "@/db";
+2
View File
@@ -1,3 +1,5 @@
import "server-only";
export interface DiscordRole {
id: string;
name: string;
+2
View File
@@ -1,3 +1,5 @@
import "server-only";
export async function getDiscordMemberCount(): Promise<number | null> {
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
+2
View File
@@ -1,3 +1,5 @@
import "server-only";
import { getYoutubeSubscriberCount } from "./youtube";
import { getRobloxFollowerCount } from "./roblox";
import { getDiscordMemberCount } from "./discord";
+2
View File
@@ -1,3 +1,5 @@
import "server-only";
export async function getRobloxFollowerCount(): Promise<number | null> {
const userId = process.env.ROBLOX_USER_ID;
if (!userId) return null;
+2
View File
@@ -1,3 +1,5 @@
import "server-only";
let cachedAccessToken: string | null = null;
let tokenExpiresAt = 0;
+2
View File
@@ -1,3 +1,5 @@
import "server-only";
export async function getYoutubeSubscriberCount(): Promise<number | null> {
const apiKey = process.env.YOUTUBE_API_KEY;
const channelId = process.env.YOUTUBE_CHANNEL_ID;
+2
View File
@@ -1,3 +1,5 @@
import "server-only";
export type DiscordVoiceChannel = {
id: string;
name: string;
+2
View File
@@ -11,6 +11,7 @@
"up": "docker compose up -d --build",
"logs": "docker compose logs -f app",
"log": "docker compose logs -f",
"secrets:scan": "./scripts/scan-secrets.sh",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db": "drizzle-kit",
@@ -46,6 +47,7 @@
"react-dom": "19.2.7",
"react-hook-form": "^7.81.0",
"reconnecting-eventsource": "^1.6.5",
"server-only": "^0.0.1",
"shadcn": "^4.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
+155
View File
@@ -0,0 +1,155 @@
import postgres from "postgres";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is not set");
}
const expectedMigrations = [
{
hash: "418d5afb86b4866e697a0abfc992b913da1533e8df41cc0b618a9e23cbb70ba5",
createdAt: 1782731728406,
},
{
hash: "5e060f0fdac0296f4953f707182eb1c872f69d44e9ef89ba75c69fbc9c895851",
createdAt: 1782731936083,
},
{
hash: "b3d35909be61ab7fbffc6c36916d6444833385db517a302abe28cfea2b0ef544",
createdAt: 1782731954122,
},
{
hash: "1ad2b86e381f0d8ef17839a952c30f0ea6f0452367d1dcd19aa11a65d8eeecbd",
createdAt: 1783089081925,
},
{
hash: "f2c279f21375de159981c439e622e4ddcd8415384f7ca7287c3f1598bcb42fc4",
createdAt: 1783613113217,
},
{
hash: "38e26a4313f207b04bb1646f6a303c0f216ad687062d16100e4ca01db5fa74c2",
createdAt: 1785152117830,
},
{
hash: "8a65ae9a4fe4c7933d0d40182dab6da9654c8cb0f1275a8ebb43e1e0f8719256",
createdAt: 1785169071234,
},
] as const;
const sql = postgres(databaseUrl, { max: 1 });
try {
const [schemaState] = await sql<{
form_tables: number;
allowed_roles: boolean;
denied_roles: boolean;
cdn_table: boolean;
image_cdn_id: boolean;
allow_multiple_submissions: boolean;
image_answer_mode: boolean;
submitted_at_index: boolean;
answer_image_fk: boolean;
unique_submission_index: boolean;
legacy_public_tables: boolean;
}[]>`
select
(
select count(*)::integer
from information_schema.tables
where table_schema = 'form'
and table_name in ('answer', 'form', 'question', 'submission')
) as form_tables,
exists (
select 1 from information_schema.columns
where table_schema = 'form'
and table_name = 'form'
and column_name = 'allowed_roles'
) as allowed_roles,
exists (
select 1 from information_schema.columns
where table_schema = 'form'
and table_name = 'form'
and column_name = 'denied_roles'
) as denied_roles,
to_regclass('form.cdn') is not null as cdn_table,
exists (
select 1 from information_schema.columns
where table_schema = 'form'
and table_name = 'answer'
and column_name = 'image_cdn_id'
) as image_cdn_id,
exists (
select 1 from information_schema.columns
where table_schema = 'form'
and table_name = 'form'
and column_name = 'allow_multiple_submissions'
) as allow_multiple_submissions,
exists (
select 1 from information_schema.columns
where table_schema = 'form'
and table_name = 'question'
and column_name = 'image_answer_mode'
) as image_answer_mode,
to_regclass('form.submission_form_submitted_at_id_idx') is not null
as submitted_at_index,
exists (
select 1
from information_schema.table_constraints
where constraint_schema = 'form'
and table_name = 'answer'
and constraint_name = 'answer_image_cdn_id_cdn_id_fk'
) as answer_image_fk,
to_regclass('form.submission_form_user_discord_id_unique') is not null
as unique_submission_index,
exists (
select 1
from information_schema.tables
where table_schema = 'public'
and table_name in ('answer', 'form', 'question', 'submission')
) as legacy_public_tables
`;
const matchesExpectedSchema =
schemaState?.form_tables === 4 &&
schemaState.allowed_roles &&
schemaState.denied_roles &&
schemaState.cdn_table &&
schemaState.image_cdn_id &&
schemaState.allow_multiple_submissions &&
schemaState.image_answer_mode &&
schemaState.submitted_at_index &&
schemaState.answer_image_fk &&
!schemaState.unique_submission_index &&
!schemaState.legacy_public_tables;
if (!matchesExpectedSchema) {
throw new Error(
"The database does not match the expected schema through migration 0013. Refusing to change the migration ledger."
);
}
await sql.begin(async (transaction) => {
await transaction`lock table drizzle.__drizzle_migrations in exclusive mode`;
for (const migration of expectedMigrations) {
await transaction`
insert into drizzle.__drizzle_migrations (hash, created_at)
select ${migration.hash}, ${migration.createdAt}
where not exists (
select 1
from drizzle.__drizzle_migrations
where hash = ${migration.hash}
)
`;
await transaction`
update drizzle.__drizzle_migrations
set created_at = ${migration.createdAt}
where hash = ${migration.hash}
`;
}
});
console.log("Reconciled the Drizzle migration ledger through 0013.");
} finally {
await sql.end();
}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
docker compose --profile tools run --rm secret-scan \
git --redact --no-banner .
docker compose --profile tools run --rm secret-scan \
dir --config /repo/gitleaks-dir.toml --redact --no-banner .
+3
View File
@@ -0,0 +1,3 @@
import { mock } from "bun:test";
mock.module("server-only", () => ({}));