feat: improve form results and harden secrets
This commit is contained in:
+2
-2
@@ -1,7 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
.env.local
|
||||
.env*
|
||||
.git
|
||||
.gitignore
|
||||
npm-debug.log
|
||||
@@ -12,4 +12,4 @@ yarn-error.log
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
tsconfig.tsbuildinfo
|
||||
.chunk-cache
|
||||
.chunk-cache
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./test/setup.ts"]
|
||||
@@ -1,3 +1,4 @@
|
||||
import "server-only";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
@@ -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 }> }[]>()
|
||||
|
||||
+15
-2
@@ -6,8 +6,8 @@ services:
|
||||
- "8606:80"
|
||||
expose:
|
||||
- "3000"
|
||||
env_file:
|
||||
.env
|
||||
env_file:
|
||||
- .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
|
||||
- .
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "form"."submission" ADD COLUMN "admin_note" text;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,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,3 +1,4 @@
|
||||
import "server-only";
|
||||
import { getServerSession } from "next-auth";
|
||||
import type { Session } from "next-auth";
|
||||
import { db } from "@/db";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
export interface DiscordRole {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import { getYoutubeSubscriberCount } from "./youtube";
|
||||
import { getRobloxFollowerCount } from "./roblox";
|
||||
import { getDiscordMemberCount } from "./discord";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
let cachedAccessToken: string | null = null;
|
||||
let tokenExpiresAt = 0;
|
||||
|
||||
@@ -134,4 +136,4 @@ export async function getLatestTiktokVideo(): Promise<TiktokVideo | null> {
|
||||
console.error("[TikTok API] Error fetching latest video via TikWM:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
export type DiscordVoiceChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Executable
+8
@@ -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 .
|
||||
@@ -0,0 +1,3 @@
|
||||
import { mock } from "bun:test";
|
||||
|
||||
mock.module("server-only", () => ({}));
|
||||
Reference in New Issue
Block a user