diff --git a/.env.example b/.env.example index 9efc9ba0..0b05f7e7 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,8 @@ APP_URL=http://localhost:3000 # NEXT_PUBLIC_APP_URL=https://yourdomain.com AUTH_URL=http://localhost:3000 -# NextAuth (>=32 chars) + Laravel APP_KEY (base64:...) for existing 2FA secrets +# NextAuth — required in production (>=32 chars). Optional in development. +# Laravel APP_KEY (base64:...) for existing 2FA secrets. AUTH_SECRET= APP_KEY= CONVERT_PASSWORDS=false @@ -88,4 +89,6 @@ SENTRY_ORG= SENTRY_PROJECT= SENTRY_AUTH_TOKEN= # Optional release tag shown in Sentry (e.g. git sha). +# Deploy sets APP_VERSION + NEXT_PUBLIC_APP_VERSION from git sha. APP_VERSION= +NEXT_PUBLIC_APP_VERSION= diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 6f25bb11..290dcdd3 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -48,27 +48,36 @@ jobs: # Preserve .next/cache so Next.js can reuse its incremental build cache. rm -rf .output dist - # 4. Configure trusted dependencies globally and install cleanly - export PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES="@parcel/watcher,@swc/core,sharp" + # Release tag for Sentry / logs (short git sha) + export APP_VERSION="$(git rev-parse --short HEAD)" + export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}" + echo "APP_VERSION=${APP_VERSION}" + + # 4. Install — onlyBuiltDependencies comes from pnpm-workspace.yaml + # (do not set a PNPM only-built-deps env override here). pnpm install --frozen-lockfile # 5. Apply versioned CMS migrations and generate the Prisma client safely pnpm db:migrate pnpm prisma:generate - # 6. Next.js Build + # 6. Pre-deploy quality gates (fail before build if broken) + pnpm typecheck + pnpm test + + # 7. Next.js Build pnpm build - # 7. Fix ownership: Build first, THEN set permissions for the web server + # 8. Fix ownership: Build first, THEN set permissions for the web server sudo chown -R www-data:www-data /var/www/atom-nexst/ - # 8. Hard restart of the Systemd service to clear memory cache + # 9. Hard restart of the Systemd service to clear memory cache echo "Hard resetting systemd service..." sudo systemctl stop atom-nexst.service || true # Kill any lingering next-server processes holding port 3000 pkill -f 'next-server' || true - + sudo systemctl start atom-nexst.service # Extra health check: Ensure the service is actually running @@ -78,4 +87,4 @@ jobs: exit 1 fi - echo "--- Deployment successfully completed ---" \ No newline at end of file + echo "--- Deployment successfully completed ---" diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index d18306d7..a8456e2f 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -1,8 +1,32 @@ +import * as Sentry from "@sentry/nextjs"; import { Cron } from "croner"; import { env } from "../src/env"; import { logger } from "../src/lib/logger"; import { prisma } from "../src/lib/prisma"; +function initWorkerSentry(): void { + const dsn = process.env.SENTRY_DSN; + if (!dsn || process.env.NODE_ENV !== "production") return; + + Sentry.init({ + dsn, + environment: process.env.NODE_ENV, + release: process.env.APP_VERSION, + tracesSampleRate: 0.05, + }); + logger.info("Sentry initialized for jobs worker", { module: "jobs" }); +} + +function captureWorkerError(err: unknown, context: string): void { + logger.error(context, { + module: "jobs", + err: err instanceof Error ? err.message : String(err), + }); + if (process.env.SENTRY_DSN) { + Sentry.captureException(err); + } +} + async function backupEmulatorJar(): Promise { if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return; @@ -41,10 +65,7 @@ async function backupEmulatorJar(): Promise { }); } } catch (err) { - logger.error("JAR backup failed", { - module: "jobs", - err: err instanceof Error ? err.message : String(err), - }); + captureWorkerError(err, "JAR backup failed"); } } @@ -56,10 +77,7 @@ async function cleanupOldLogs(): Promise { }); logger.info("Cleaned up login logs older than 30 days", { module: "jobs" }); } catch (err) { - logger.error("Log cleanup failed", { - module: "jobs", - err: err instanceof Error ? err.message : String(err), - }); + captureWorkerError(err, "Log cleanup failed"); } } @@ -73,24 +91,17 @@ async function cleanupOldSessions(): Promise { module: "jobs", }); } catch (err) { - logger.error("Session cleanup failed", { - module: "jobs", - err: err instanceof Error ? err.message : String(err), - }); + captureWorkerError(err, "Session cleanup failed"); } } async function main() { + initWorkerSentry(); logger.info("Worker started", { module: "jobs" }); if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) { new Cron("0 3 * * *", () => { - backupEmulatorJar().catch((e) => - logger.error("Backup error", { - module: "jobs", - err: e instanceof Error ? e.message : String(e), - }), - ); + backupEmulatorJar().catch((e) => captureWorkerError(e, "Backup error")); }); logger.info("Scheduled: emulator JAR backup (daily 03:00)", { module: "jobs", @@ -99,10 +110,7 @@ async function main() { new Cron("0 4 * * *", () => { Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) => - logger.error("Cleanup error", { - module: "jobs", - err: e instanceof Error ? e.message : String(e), - }), + captureWorkerError(e, "Cleanup error"), ); }); logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" }); @@ -115,9 +123,6 @@ async function main() { } main().catch((err) => { - logger.error("Fatal", { - module: "jobs", - err: err instanceof Error ? err.message : String(err), - }); + captureWorkerError(err, "Fatal"); process.exit(1); }); diff --git a/src/components/admin/confirm-dialog.tsx b/src/components/admin/confirm-dialog.tsx index 5f293bb3..90adc189 100644 --- a/src/components/admin/confirm-dialog.tsx +++ b/src/components/admin/confirm-dialog.tsx @@ -102,7 +102,9 @@ export function ConfirmDialog({ variant={variant === "danger" ? "destructive" : "default"} disabled={isLoading} onClick={handleConfirm} - className={cn(variant === "danger" && "bg-destructive text-destructive-foreground")} + className={cn( + variant === "danger" && "bg-destructive text-destructive-foreground", + )} autoFocus > {confirmLabel} diff --git a/src/components/admin/media-grid.tsx b/src/components/admin/media-grid.tsx index 9252afeb..d164814c 100644 --- a/src/components/admin/media-grid.tsx +++ b/src/components/admin/media-grid.tsx @@ -236,7 +236,7 @@ export function AdminMediaGrid() { loading="lazy" className="w-full h-[120px] object-cover block" /> - + {extOf(f.name)} diff --git a/src/env.ts b/src/env.ts index 12e954d6..e8b2d1b2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -80,6 +80,17 @@ const schema = z.object({ SENTRY_PROJECT: z.string().optional(), SENTRY_AUTH_TOKEN: z.string().optional(), APP_VERSION: z.string().optional(), + NEXT_PUBLIC_APP_VERSION: z.string().optional(), +}).superRefine((data, ctx) => { + if (data.NODE_ENV !== "production") return; + if (!data.AUTH_SECRET || data.AUTH_SECRET.length < 32) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "AUTH_SECRET (>=32 characters) is required when NODE_ENV=production", + path: ["AUTH_SECRET"], + }); + } }); type Env = z.infer; diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 23ada3ba..522cbf1a 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/nextjs"; +import { redactSentryEvent } from "@/lib/sentry-redact"; const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; @@ -6,6 +7,7 @@ if (dsn) { Sentry.init({ dsn, environment: process.env.NODE_ENV, + release: process.env.NEXT_PUBLIC_APP_VERSION || process.env.APP_VERSION, tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0, replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 1.0, @@ -16,6 +18,7 @@ if (dsn) { blockAllMedia: true, }), ], + beforeSend: redactSentryEvent, }); } diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index d232e19a..e13cb4fe 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { ZodError, type z } from "zod"; +import { reportError } from "@/lib/report-error"; /** Throwable API error with HTTP status code */ export class ApiError extends Error { @@ -46,11 +47,6 @@ export function handleApiError(error: unknown): Response { ) { return apiError("Not found", 404); } - console.error( - "[API error]", - error instanceof Error - ? { message: error.message, name: error.name } - : error, - ); + reportError(error, "API error"); return apiError("Internal server error", 500); } diff --git a/src/lib/deploy-workflow-contract.test.ts b/src/lib/deploy-workflow-contract.test.ts index ab0f6c2b..fda6036b 100644 --- a/src/lib/deploy-workflow-contract.test.ts +++ b/src/lib/deploy-workflow-contract.test.ts @@ -12,4 +12,26 @@ describe("production deploy workflow", () => { expect(workflow).not.toMatch(/rm\s+-rf[^\n]*\.next/); expect(workflow).toContain("pnpm install --frozen-lockfile"); }); + + it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => { + expect(workflow).not.toContain("PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES"); + }); + + it("runs typecheck and tests before build", () => { + expect(workflow).toContain("pnpm typecheck"); + expect(workflow).toContain("pnpm test"); + const typecheckAt = workflow.indexOf("pnpm typecheck"); + const testAt = workflow.indexOf("pnpm test"); + const buildAt = workflow.indexOf("pnpm build"); + expect(typecheckAt).toBeGreaterThan(-1); + expect(testAt).toBeGreaterThan(typecheckAt); + expect(buildAt).toBeGreaterThan(testAt); + }); + + it("exports APP_VERSION from git for Sentry releases", () => { + expect(workflow).toContain('export APP_VERSION="$(git rev-parse --short HEAD)"'); + expect(workflow).toContain( + 'export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"', + ); + }); }); diff --git a/src/lib/foundation/action.ts b/src/lib/foundation/action.ts index 525956d0..8cc238d2 100644 --- a/src/lib/foundation/action.ts +++ b/src/lib/foundation/action.ts @@ -3,6 +3,7 @@ import { logAuthorizationEvent } from "@/lib/admin/authorization-events"; import { auth } from "@/lib/auth"; import { canAccess, getApiAdminContext } from "@/lib/permissions"; import { rateLimit } from "@/lib/rate-limit"; +import { reportError } from "@/lib/report-error"; import { DatabaseError, ForbiddenError, @@ -247,11 +248,6 @@ export function handleActionError(error: unknown): ActionFailure { return fail("Not found"); } - console.error( - "[Action error]", - error instanceof Error - ? { message: error.message, name: error.name } - : error, - ); + reportError(error, "Action error"); return fail("Internal server error"); } diff --git a/src/lib/report-error.ts b/src/lib/report-error.ts new file mode 100644 index 00000000..6106ab6d --- /dev/null +++ b/src/lib/report-error.ts @@ -0,0 +1,18 @@ +import * as Sentry from "@sentry/nextjs"; +import { logger } from "@/lib/logger"; + +/** + * Log unexpected errors and forward them to Sentry when a DSN is configured. + * Domain errors (validation, auth, etc.) should NOT go through here. + */ +export function reportError(error: unknown, context = "Unhandled error"): void { + const message = error instanceof Error ? error.message : String(error); + logger.error(context, { + err: message, + name: error instanceof Error ? error.name : undefined, + }); + + if (process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN) { + Sentry.captureException(error); + } +} diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index 31c85d15..b112a280 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -204,6 +204,19 @@ export async function movePage( }); } +/** + * Delete catalog_items for the given page ids. + * Habbo DBs often store page_id as VARCHAR; Prisma Int deleteMany misses rows. + */ +async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise { + if (pageIds.length === 0) return; + const idStrs = pageIds.map(String); + await prisma.$executeRaw` + DELETE FROM catalog_items + WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)}) + `; +} + /** * Delete a page with cascade or reparent mode. */ @@ -223,7 +236,7 @@ export async function deletePage( data: { parentId: page.parentId }, }); - await prisma.catalogItems.deleteMany({ where: { pageId } }); + await deleteCatalogItemsByPageIds([pageId]); await prisma.catalogPages.delete({ where: { id: pageId } }); return { deletedPages: 1, movedChildren: result.count }; @@ -249,7 +262,7 @@ async function cascadeDelete(pageId: number): Promise { } } - await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } }); + await deleteCatalogItemsByPageIds(toDelete); for (let i = toDelete.length - 1; i >= 0; i--) { await prisma.catalogPages.delete({ where: { id: toDelete[i] } }); }