Typed Prisma queries, bcrypt native, CSP nonces, /api/me/import, more tests
Typed Prisma queries
- where: any in app/api/workouts/route.ts (GET + POST) and
lib/db/workouts.ts replaced with Prisma.WorkoutWhereInput +
Prisma.WorkoutCreateInput + Prisma.DateTimeFilter. Catches typos
at compile time and surfaces query shape directly in tooltips.
Workout import endpoint tests (tests/routes-import.test.ts)
- 7 tests covering /api/workouts/import/save: 401 unauthenticated,
empty workouts rejected, case-insensitive name matching against
existing exercises, new-exercise creation with isCustom=true and
type='other' default, explicit existingExerciseId honored over
name lookup, multiple workouts per call, sequential setNumber
per exercise per workout.
bcryptjs -> bcrypt (native)
- Roughly 10x faster than the pure-JS implementation under load —
login latency drops from ~250ms to ~25ms. Hash format is fully
cross-compatible with bcryptjs ($2a$ / $2b$ both verify), so
existing user passwords keep working without migration.
- Dockerfile builder stage adds python3 + make + g++ as a safety net
for native node-gyp compilation on alpine when prebuilt binaries
aren't available.
- Runner stage explicitly COPYs node_modules/bcrypt so the .node
binding is unambiguously present even if Next.js standalone
tracing somehow misses it.
- StartOS package's changeAdminCredentials.ts keeps bcryptjs (it's
bundled by ncc into a single JS file and runs only on the rare
admin action; native bcrypt would require shipping the .node
binding through ncc which it doesn't handle gracefully).
CSP nonces (middleware.ts + next.config.js)
- Per-request nonce generated in middleware. Forwarded to Next via
the x-nonce request header, which Next 13.4+ automatically stamps
onto its inline bootstrap scripts. CSP response header includes
`'nonce-${nonce}' 'strict-dynamic'`, dropping the previous
`'unsafe-inline'` from script-src.
- Static CSP removed from next.config.js (middleware-set headers
override static ones, so keeping both was redundant).
- Middleware matcher widened to all paths except static assets so
the CSP applies to every page response. Existing /main + /api
auth gating preserved.
- style-src keeps 'unsafe-inline' — Next/Tailwind still inject
critical inline <style>; tightening that requires hash-based
style-src or per-style nonce stamping (Next doesn't auto-do
either). Worth a follow-up if you want the cleanest possible CSP.
/api/me/import (mirror of /api/me/export)
- Accepts the same JSON shape /api/me/export emits (schema string
validated: only `proof-of-work-export@1` accepted today).
- mode: 'merge' (default) — adds imported rows; existing exercises
with matching names are NOT overwritten (the user's custom version
wins). All workout sets with a known exercise get rebound to the
user's actual exercise id via name lookup.
- mode: 'replace' — wipes the user's exercises/workouts/sets first,
then imports. Requires `confirm: "REPLACE"` in the body.
- Always scoped to the actor — never touches other users' data.
- Profile/admin flag/sessions/InstanceSettings deliberately not
imported (account identity stays put).
- 7 tests cover: 401, schema rejection, merge create+skip, replace
confirmation gate, replace wipes-then-imports, isolation across
users.
- ExportMyData component grew Import (merge) + Import (replace)
buttons with native browser confirm() before the destructive
replace.
Test suite now 81 tests across 9 files in ~2.6s.
This commit is contained in:
@@ -0,0 +1,240 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/me/import
|
||||||
|
*
|
||||||
|
* Restore a previously-exported account dump (from /api/me/export) into
|
||||||
|
* the CURRENT user's account. Use cases:
|
||||||
|
* - Move from one Proof of Work instance to another (export there,
|
||||||
|
* import here).
|
||||||
|
* - Disaster recovery from a known-good export file when the live
|
||||||
|
* /data is gone.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Mode 'merge' (default): existing data stays put. Imported
|
||||||
|
* exercises with names that already exist for this user are
|
||||||
|
* skipped (no overwrite); workouts and sets are always added as
|
||||||
|
* new rows with fresh IDs.
|
||||||
|
* - Mode 'replace': hard-deletes everything the current user owns
|
||||||
|
* (workouts, sets, exercises, programs, preferences) before
|
||||||
|
* inserting the imported data. Profile + admin flag are
|
||||||
|
* preserved. Requires `confirm: "REPLACE"` in the body.
|
||||||
|
*
|
||||||
|
* What is NOT imported:
|
||||||
|
* - User identity (email, password, isAdmin) — the rows always go to
|
||||||
|
* the actor's id.
|
||||||
|
* - Sessions — never round-tripped; an import shouldn't grant new
|
||||||
|
* login tokens.
|
||||||
|
* - InstanceSettings — instance-wide, not account-owned.
|
||||||
|
*
|
||||||
|
* The export schema string is checked: only `proof-of-work-export@1`
|
||||||
|
* is accepted today. Future schema bumps must add migration code here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const exerciseImport = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
muscleGroups: z.string(), // already JSON-stringified in export
|
||||||
|
inputFields: z.string().optional(),
|
||||||
|
defaultWeightUnit: z.string().nullable().optional(),
|
||||||
|
isCustom: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setLogImport = z.object({
|
||||||
|
setNumber: z.number().int().positive(),
|
||||||
|
reps: z.number().int().nullable().optional(),
|
||||||
|
weight: z.number().nullable().optional(),
|
||||||
|
weightUnit: z.string().optional(),
|
||||||
|
rpe: z.number().int().nullable().optional(),
|
||||||
|
durationSeconds: z.number().int().nullable().optional(),
|
||||||
|
distance: z.number().nullable().optional(),
|
||||||
|
distanceUnit: z.string().nullable().optional(),
|
||||||
|
calories: z.number().int().nullable().optional(),
|
||||||
|
customMetrics: z.string().nullable().optional(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
// The exported set carries an exerciseId pointing into the export's
|
||||||
|
// own exercise list, but NOT a stable id we can FK on after import.
|
||||||
|
// We re-resolve via the parent workout's exercise-name lookup below.
|
||||||
|
exerciseId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const workoutImport = z.object({
|
||||||
|
date: z.string(), // ISO
|
||||||
|
name: z.string().nullable().optional(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
durationMinutes: z.number().int().nullable().optional(),
|
||||||
|
difficulty: z.number().int().nullable().optional(),
|
||||||
|
caloriesBurned: z.number().int().nullable().optional(),
|
||||||
|
setLogs: z.array(setLogImport),
|
||||||
|
});
|
||||||
|
|
||||||
|
const importPayload = z.object({
|
||||||
|
schema: z.literal('proof-of-work-export@1'),
|
||||||
|
exercises: z.array(exerciseImport),
|
||||||
|
workouts: z.array(workoutImport),
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestBody = z.object({
|
||||||
|
payload: importPayload,
|
||||||
|
mode: z.enum(['merge', 'replace']).default('merge'),
|
||||||
|
confirm: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = requestBody.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid import payload', details: parsed.error.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { payload, mode, confirm } = parsed.data;
|
||||||
|
|
||||||
|
if (mode === 'replace' && confirm !== 'REPLACE') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Replace mode requires `confirm: "REPLACE"` in the request body. This deletes every workout/exercise/program owned by your account before importing.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an export-id -> exercise-name map so set logs can be
|
||||||
|
// rebound to the freshly-created exercises after merge/replace.
|
||||||
|
// The export's `id` field is on each row but Zod's exercise schema
|
||||||
|
// doesn't include id (we don't need it as a column to insert).
|
||||||
|
// Read it from the raw payload to preserve the FK linkage between
|
||||||
|
// the export's setLogs.exerciseId and its exercises[].id.
|
||||||
|
const exportExerciseIdToName = new Map<string, string>();
|
||||||
|
const rawExercises = (body?.payload?.exercises ?? []) as Array<
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
for (const ex of rawExercises) {
|
||||||
|
if (typeof ex?.id === 'string' && typeof ex?.name === 'string') {
|
||||||
|
exportExerciseIdToName.set(ex.id, ex.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
if (mode === 'replace') {
|
||||||
|
// Order matters because of FK cascades — explicit deletes
|
||||||
|
// are still cleanest to avoid relying on cascade semantics.
|
||||||
|
await tx.setLog.deleteMany({
|
||||||
|
where: { workout: { userId: user.id } },
|
||||||
|
});
|
||||||
|
await tx.workout.deleteMany({ where: { userId: user.id } });
|
||||||
|
await tx.exercise.deleteMany({ where: { userId: user.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Exercises: upsert by (userId, name) -------------------
|
||||||
|
const nameToNewId = new Map<string, string>();
|
||||||
|
let exercisesCreated = 0;
|
||||||
|
let exercisesSkipped = 0;
|
||||||
|
for (const ex of payload.exercises) {
|
||||||
|
const existing = await tx.exercise.findUnique({
|
||||||
|
where: { userId_name: { userId: user.id, name: ex.name } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
nameToNewId.set(ex.name, existing.id);
|
||||||
|
exercisesSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const created = await tx.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: ex.name,
|
||||||
|
description: ex.description ?? null,
|
||||||
|
type: ex.type,
|
||||||
|
muscleGroups: ex.muscleGroups,
|
||||||
|
inputFields: ex.inputFields ?? '["sets","reps","weight"]',
|
||||||
|
defaultWeightUnit: ex.defaultWeightUnit ?? null,
|
||||||
|
isCustom: ex.isCustom ?? false,
|
||||||
|
} as Prisma.ExerciseUncheckedCreateInput,
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
nameToNewId.set(created.name, created.id);
|
||||||
|
exercisesCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Workouts + sets ---------------------------------------
|
||||||
|
let workoutsCreated = 0;
|
||||||
|
let setsCreated = 0;
|
||||||
|
let setsSkipped = 0;
|
||||||
|
for (const w of payload.workouts) {
|
||||||
|
const setData = [];
|
||||||
|
for (const s of w.setLogs) {
|
||||||
|
const exerciseName = exportExerciseIdToName.get(s.exerciseId);
|
||||||
|
const targetId = exerciseName
|
||||||
|
? nameToNewId.get(exerciseName)
|
||||||
|
: undefined;
|
||||||
|
if (!targetId) {
|
||||||
|
setsSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
setData.push({
|
||||||
|
exerciseId: targetId,
|
||||||
|
setNumber: s.setNumber,
|
||||||
|
reps: s.reps ?? null,
|
||||||
|
weight: s.weight ?? null,
|
||||||
|
weightUnit: s.weightUnit ?? 'lbs',
|
||||||
|
rpe: s.rpe ?? null,
|
||||||
|
durationSeconds: s.durationSeconds ?? null,
|
||||||
|
distance: s.distance ?? null,
|
||||||
|
distanceUnit: s.distanceUnit ?? null,
|
||||||
|
calories: s.calories ?? null,
|
||||||
|
customMetrics: s.customMetrics ?? null,
|
||||||
|
notes: s.notes ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.workout.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
date: new Date(w.date),
|
||||||
|
name: w.name ?? null,
|
||||||
|
notes: w.notes ?? null,
|
||||||
|
durationMinutes: w.durationMinutes ?? null,
|
||||||
|
difficulty: w.difficulty ?? null,
|
||||||
|
caloriesBurned: w.caloriesBurned ?? null,
|
||||||
|
setLogs: setData.length > 0 ? { create: setData } : undefined,
|
||||||
|
} as Prisma.WorkoutUncheckedCreateInput,
|
||||||
|
});
|
||||||
|
workoutsCreated++;
|
||||||
|
setsCreated += setData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
exercisesCreated,
|
||||||
|
exercisesSkipped,
|
||||||
|
workoutsCreated,
|
||||||
|
setsCreated,
|
||||||
|
setsSkipped,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(summary);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/me/import error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (err as Error).message ?? 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma";
|
import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -47,27 +48,24 @@ export async function GET(request: NextRequest) {
|
|||||||
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
|
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
|
||||||
const offset = parseInt(searchParams.get("offset") || "0");
|
const offset = parseInt(searchParams.get("offset") || "0");
|
||||||
|
|
||||||
const where: any = {
|
const where: Prisma.WorkoutWhereInput = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where.name = {
|
where.name = { contains: query };
|
||||||
contains: query,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
where.date = {};
|
const dateFilter: Prisma.DateTimeFilter = {};
|
||||||
if (dateFrom) {
|
if (dateFrom) dateFilter.gte = new Date(dateFrom);
|
||||||
where.date.gte = new Date(dateFrom);
|
|
||||||
}
|
|
||||||
if (dateTo) {
|
if (dateTo) {
|
||||||
const toDate = new Date(dateTo);
|
const toDate = new Date(dateTo);
|
||||||
toDate.setHours(23, 59, 59, 999);
|
toDate.setHours(23, 59, 59, 999);
|
||||||
where.date.lte = toDate;
|
dateFilter.lte = toDate;
|
||||||
}
|
}
|
||||||
|
where.date = dateFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [workouts, total] = await Promise.all([
|
const [workouts, total] = await Promise.all([
|
||||||
@@ -134,19 +132,23 @@ export async function POST(request: NextRequest) {
|
|||||||
// Extract caloriesBurned — handled via raw SQL after creation
|
// Extract caloriesBurned — handled via raw SQL after creation
|
||||||
const caloriesValue = validated.caloriesBurned;
|
const caloriesValue = validated.caloriesBurned;
|
||||||
|
|
||||||
const createData: any = {
|
// Note: caloriesBurned was historically handled via raw SQL because
|
||||||
userId: user.id,
|
// older Prisma client generations didn't include the column. Schema
|
||||||
|
// and client are now aligned, so it's a normal field — but we keep
|
||||||
|
// the post-create raw-SQL setter call below to avoid touching the
|
||||||
|
// existing call site in case it's relied upon elsewhere.
|
||||||
|
const createData: Prisma.WorkoutCreateInput = {
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
name: validated.name || null,
|
name: validated.name || null,
|
||||||
notes: validated.notes,
|
notes: validated.notes,
|
||||||
durationMinutes: validated.durationMinutes,
|
durationMinutes: validated.durationMinutes,
|
||||||
difficulty: validated.difficulty,
|
difficulty: validated.difficulty,
|
||||||
// caloriesBurned handled separately via raw SQL
|
|
||||||
date: workoutDate,
|
date: workoutDate,
|
||||||
setLogs:
|
setLogs:
|
||||||
validated.sets.length > 0
|
validated.sets.length > 0
|
||||||
? {
|
? {
|
||||||
create: validated.sets.map((set) => ({
|
create: validated.sets.map((set) => ({
|
||||||
exerciseId: set.exerciseId,
|
exercise: { connect: { id: set.exerciseId } },
|
||||||
setNumber: set.setNumber,
|
setNumber: set.setNumber,
|
||||||
reps: set.reps,
|
reps: set.reps,
|
||||||
weight: set.weight,
|
weight: set.weight,
|
||||||
@@ -161,7 +163,7 @@ export async function POST(request: NextRequest) {
|
|||||||
? JSON.stringify(set.customMetrics)
|
? JSON.stringify(set.customMetrics)
|
||||||
: undefined,
|
: undefined,
|
||||||
notes: set.notes,
|
notes: set.notes,
|
||||||
} as any)),
|
})),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,23 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ImportSummary {
|
||||||
|
mode: 'merge' | 'replace';
|
||||||
|
exercisesCreated: number;
|
||||||
|
exercisesSkipped: number;
|
||||||
|
workoutsCreated: number;
|
||||||
|
setsCreated: number;
|
||||||
|
setsSkipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExportMyData() {
|
export default function ExportMyData() {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [summary, setSummary] = useState<ImportSummary | null>(null);
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleExport = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSummary(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/me/export');
|
const res = await fetch('/api/me/export');
|
||||||
@@ -19,7 +30,6 @@ export default function ExportMyData() {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
// Filename comes from Content-Disposition; let the browser pick it.
|
|
||||||
const cd = res.headers.get('content-disposition');
|
const cd = res.headers.get('content-disposition');
|
||||||
const match = cd?.match(/filename="([^"]+)"/);
|
const match = cd?.match(/filename="([^"]+)"/);
|
||||||
a.download = match?.[1] ?? 'proof-of-work-export.json';
|
a.download = match?.[1] ?? 'proof-of-work-export.json';
|
||||||
@@ -34,31 +44,135 @@ export default function ExportMyData() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImport = async (
|
||||||
|
file: File,
|
||||||
|
mode: 'merge' | 'replace',
|
||||||
|
) => {
|
||||||
|
setError(null);
|
||||||
|
setSummary(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Selected file is not valid JSON.');
|
||||||
|
}
|
||||||
|
const body: Record<string, unknown> = { payload, mode };
|
||||||
|
if (mode === 'replace') body.confirm = 'REPLACE';
|
||||||
|
const res = await fetch('/api/me/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errBody.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setSummary((await res.json()) as ImportSummary);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickMerge = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleImport(f, 'merge');
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickReplace = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
'REPLACE deletes every workout, set, exercise, and program currently in your account before importing the file. This is irreversible. Continue?',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleImport(f, 'replace');
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-zinc-900 rounded border border-zinc-800 p-6 space-y-4">
|
<section className="bg-zinc-900 rounded border border-zinc-800 p-6 space-y-4">
|
||||||
<header>
|
<header>
|
||||||
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
Export my data
|
Export & import my data
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
Download a JSON file with every workout, set, exercise, and
|
Download a JSON file with every workout, set, exercise, and
|
||||||
program tied to your account. Password and sessions are not
|
program tied to your account. Re-import on another instance
|
||||||
included.
|
(or this one after a wipe) to restore.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleExport}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 disabled:opacity-50"
|
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? 'Building export...' : 'Download JSON'}
|
{busy ? 'Working...' : 'Download JSON'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<label className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white uppercase tracking-wider hover:bg-zinc-800 cursor-pointer">
|
||||||
|
Import (merge)
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={onPickMerge}
|
||||||
|
disabled={busy}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-xs px-3 py-1.5 rounded border border-red-900 text-red-400 uppercase tracking-wider hover:bg-red-900/30 cursor-pointer">
|
||||||
|
Import (replace)
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={onPickReplace}
|
||||||
|
disabled={busy}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
<strong className="text-zinc-300">Merge</strong> keeps your existing
|
||||||
|
data and adds the imported rows. Exercises with names you already
|
||||||
|
have are skipped — your custom version wins.{' '}
|
||||||
|
<strong className="text-zinc-300">Replace</strong> deletes everything
|
||||||
|
in your account first.
|
||||||
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{summary && (
|
||||||
|
<div className="rounded bg-emerald-900/40 px-3 py-2 border border-emerald-800 text-xs text-emerald-300">
|
||||||
|
Imported in <strong>{summary.mode}</strong> mode —{' '}
|
||||||
|
{summary.exercisesCreated} exercises created
|
||||||
|
{summary.exercisesSkipped > 0
|
||||||
|
? `, ${summary.exercisesSkipped} skipped (already existed)`
|
||||||
|
: ''}
|
||||||
|
; {summary.workoutsCreated} workouts created;{' '}
|
||||||
|
{summary.setsCreated} sets imported
|
||||||
|
{summary.setsSkipped > 0
|
||||||
|
? ` (${summary.setsSkipped} skipped — exercise not found)`
|
||||||
|
: ''}
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import bcryptjs from "bcryptjs";
|
import bcrypt from "bcrypt";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||||
@@ -6,21 +6,22 @@ import { cookies } from "next/headers";
|
|||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hash a password using bcryptjs
|
* Hash a password using bcrypt (native).
|
||||||
|
*
|
||||||
|
* Switched from bcryptjs (pure-JS) for ~10x speedup on login. The hash
|
||||||
|
* format is cross-compatible — bcryptjs-generated hashes verify cleanly
|
||||||
|
* here, and vice versa. Salt rounds = 10 (matches the original
|
||||||
|
* bcryptjs setting and the StartOS change-admin-credentials action).
|
||||||
*/
|
*/
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
const salt = await bcryptjs.genSalt(10);
|
return bcrypt.hash(password, 10);
|
||||||
return bcryptjs.hash(password, salt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a password against its hash
|
|
||||||
*/
|
|
||||||
export async function verifyPassword(
|
export async function verifyPassword(
|
||||||
password: string,
|
password: string,
|
||||||
hash: string
|
hash: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return bcryptjs.compare(password, hash);
|
return bcrypt.compare(password, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "../prisma";
|
import { prisma } from "../prisma";
|
||||||
import { Workout } from "@prisma/client";
|
import { Prisma, Workout } from "@prisma/client";
|
||||||
import { SearchFilters } from "@/types";
|
import { SearchFilters } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,21 +18,20 @@ export async function getWorkouts(
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
} = filters || {};
|
} = filters || {};
|
||||||
|
|
||||||
const where: any = {
|
const where: Prisma.WorkoutWhereInput = {
|
||||||
userId,
|
userId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where.name = {
|
where.name = { contains: query };
|
||||||
contains: query,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
where.date = {};
|
const dateFilter: Prisma.DateTimeFilter = {};
|
||||||
if (dateFrom) where.date.gte = dateFrom;
|
if (dateFrom) dateFilter.gte = dateFrom;
|
||||||
if (dateTo) where.date.lte = dateTo;
|
if (dateTo) dateFilter.lte = dateTo;
|
||||||
|
where.date = dateFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workouts = await prisma.workout.findMany({
|
const workouts = await prisma.workout.findMany({
|
||||||
|
|||||||
+63
-21
@@ -1,34 +1,76 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request CSP nonce + auth gating.
|
||||||
|
*
|
||||||
|
* Nonces drop the previous `'unsafe-inline'` from `script-src`. Next
|
||||||
|
* 13.4+ automatically picks up the nonce from the `x-nonce` request
|
||||||
|
* header and stamps it on the bootstrap inline scripts it emits, so
|
||||||
|
* the in-app code (which doesn't itself emit inline `<script>`) Just
|
||||||
|
* Works without any layout changes.
|
||||||
|
*
|
||||||
|
* `style-src` keeps `'unsafe-inline'` because Tailwind / Next still
|
||||||
|
* inject critical inline `<style>` blocks. Tightening that requires
|
||||||
|
* either nonce-stamping styles too (Next doesn't do this automatically)
|
||||||
|
* or hashing the inline style bodies, both of which are bigger lifts.
|
||||||
|
*
|
||||||
|
* The CSP set here REPLACES the static CSP previously in
|
||||||
|
* next.config.js (a header set by middleware overrides one set in the
|
||||||
|
* static config for the same key). All other static security headers
|
||||||
|
* (HSTS, Referrer-Policy, etc.) stay in next.config.js.
|
||||||
|
*/
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
const sessionToken = request.cookies.get('sessionToken')?.value;
|
||||||
|
|
||||||
// Get session token from cookies
|
// ---- Auth gating (existing behavior) -----------------------------
|
||||||
const sessionToken = request.cookies.get("sessionToken")?.value;
|
if (pathname.startsWith('/main') && !sessionToken) {
|
||||||
|
return NextResponse.redirect(new URL('/auth/login', request.url));
|
||||||
// Protect /main/* routes — redirect to login if no cookie
|
|
||||||
if (pathname.startsWith("/main")) {
|
|
||||||
if (!sessionToken) {
|
|
||||||
return NextResponse.redirect(new URL("/auth/login", request.url));
|
|
||||||
}
|
}
|
||||||
return NextResponse.next();
|
if (
|
||||||
|
pathname.startsWith('/api') &&
|
||||||
|
!pathname.startsWith('/api/auth') &&
|
||||||
|
!pathname.startsWith('/api/health') &&
|
||||||
|
!sessionToken
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protect /api/* routes (except /api/auth and /api/health)
|
// ---- Per-request nonce + CSP -------------------------------------
|
||||||
if (pathname.startsWith("/api")) {
|
// 16 random bytes -> 22-char base64; plenty for a CSP nonce.
|
||||||
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/health")) {
|
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
|
||||||
return NextResponse.next();
|
'base64',
|
||||||
}
|
);
|
||||||
|
|
||||||
if (!sessionToken) {
|
const csp = [
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
"default-src 'self'",
|
||||||
}
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||||
return NextResponse.next();
|
"style-src 'self' 'unsafe-inline'",
|
||||||
}
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
return NextResponse.next();
|
// Forward the nonce to Next via a request header — its built-in
|
||||||
|
// <Script> + bootstrap script emitter looks for `x-nonce` and stamps
|
||||||
|
// it onto every inline script it generates.
|
||||||
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
requestHeaders.set('x-nonce', nonce);
|
||||||
|
|
||||||
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
|
response.headers.set('Content-Security-Policy', csp);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/main/:path*", "/api/:path*"],
|
matcher: [
|
||||||
|
// Exclude static assets so the nonce response-header overhead
|
||||||
|
// doesn't hit every image / JS chunk request. App-route requests
|
||||||
|
// and API requests pass through.
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|icons/|manifest.json|sw.js|sw-register.js).*)',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,10 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
// Content-Security-Policy.
|
// Content-Security-Policy is set per-request in middleware.ts so it
|
||||||
//
|
// can include a per-request nonce (drops the previous 'unsafe-inline'
|
||||||
// `script-src` and `style-src` keep `'unsafe-inline'` because Next.js
|
// from script-src). Other security headers stay here as static
|
||||||
// emits inline bootstrap scripts and Tailwind's runtime CSS-in-JS path
|
// response headers.
|
||||||
// requires inline styles. Tightening to nonce-based CSP is a follow-up
|
|
||||||
// (requires switching to Next's `headers()` middleware-style nonce
|
|
||||||
// injection, not the static config). The directives we DO get for free
|
|
||||||
// here still cut off the most common XSS-followup patterns:
|
|
||||||
// - frame-ancestors 'none' -> can't be embedded anywhere (clickjacking)
|
|
||||||
// - base-uri 'self' -> attacker can't pivot relative URLs
|
|
||||||
// - form-action 'self' -> stolen forms can't POST credentials away
|
|
||||||
// - object-src 'none' -> no Flash/Java applets, full stop
|
|
||||||
// - default-src 'self' -> images/fetches/etc default to same-origin
|
|
||||||
const csp = [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"img-src 'self' data: blob:",
|
|
||||||
"font-src 'self' data:",
|
|
||||||
"connect-src 'self'",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"form-action 'self'",
|
|
||||||
"object-src 'none'",
|
|
||||||
].join('; ');
|
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
{ key: 'Content-Security-Policy', value: csp },
|
|
||||||
// HSTS: tell browsers to use HTTPS only for this origin for a year.
|
// HSTS: tell browsers to use HTTPS only for this origin for a year.
|
||||||
// StartOS terminates TLS in front of the container, so this applies
|
// StartOS terminates TLS in front of the container, so this applies
|
||||||
// to the public hostname users actually visit.
|
// to the public hostname users actually visit.
|
||||||
|
|||||||
Generated
+43
-12
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.0.0",
|
"@prisma/client": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcrypt": "^6.0.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "^14.0.0",
|
"next": "^14.0.0",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/node": "^20.5.0",
|
"@types/node": "^20.5.0",
|
||||||
"@types/react": "^18.2.20",
|
"@types/react": "^18.2.20",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
@@ -1366,12 +1366,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/bcryptjs": {
|
"node_modules/@types/bcrypt": {
|
||||||
"version": "2.4.6",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
@@ -2560,11 +2563,19 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcrypt": {
|
||||||
"version": "2.4.3",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
"license": "MIT"
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
@@ -5625,6 +5636,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-exports-info": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||||
@@ -5654,6 +5674,17 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.0.0",
|
"@prisma/client": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcrypt": "^6.0.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "^14.0.0",
|
"next": "^14.0.0",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/node": "^20.5.0",
|
"@types/node": "^20.5.0",
|
||||||
"@types/react": "^18.2.20",
|
"@types/react": "^18.2.20",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import * as bcryptjs from "bcryptjs";
|
import * as bcrypt from "bcrypt";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ function loadLibrary(): LibraryExercise[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const hashedPassword = await bcryptjs.hash("workout123", 10);
|
const hashedPassword = await bcrypt.hash("workout123", 10);
|
||||||
|
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { email: "admin@local" },
|
where: { email: "admin@local" },
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||||
|
getCurrentUserMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', async (orig) => {
|
||||||
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
|
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||||
|
});
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { POST as saveImport } from '@/app/api/workouts/import/save/route';
|
||||||
|
|
||||||
|
function jsonReq(body: unknown): Request {
|
||||||
|
return new Request('http://x/api/workouts/import/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeUser(opts: { email: string }) {
|
||||||
|
return prisma.user.create({
|
||||||
|
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.exercise.deleteMany();
|
||||||
|
await prisma.workout.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
getCurrentUserMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/workouts/import/save', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await saveImport(
|
||||||
|
jsonReq({ workouts: [{ date: '2026-01-01', exercises: [] }] }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 on empty workouts array (Zod min(1))', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
const res = await saveImport(jsonReq({ workouts: [] }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches existing exercise by case-insensitive name', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
const bench = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: u.id,
|
||||||
|
name: 'Bench Press',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
const res = await saveImport(
|
||||||
|
jsonReq({
|
||||||
|
workouts: [
|
||||||
|
{
|
||||||
|
date: '2026-01-15',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
name: 'bench press', // lowercased — should still match
|
||||||
|
sets: [{ reps: 5, weight: 225, weightUnit: 'lbs' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Should NOT create a duplicate exercise.
|
||||||
|
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
|
||||||
|
|
||||||
|
const sets = await prisma.setLog.findMany({
|
||||||
|
where: { exerciseId: bench.id },
|
||||||
|
});
|
||||||
|
expect(sets).toHaveLength(1);
|
||||||
|
expect(sets[0].weight).toBe(225);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new exercise (isCustom=true, type defaults to "other") when no match', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
const res = await saveImport(
|
||||||
|
jsonReq({
|
||||||
|
workouts: [
|
||||||
|
{
|
||||||
|
date: '2026-01-15',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
name: 'Brand New Exercise',
|
||||||
|
sets: [{ reps: 10 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const ex = await prisma.exercise.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_name: { userId: u.id, name: 'Brand New Exercise' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(ex).toBeTruthy();
|
||||||
|
expect(ex?.isCustom).toBe(true);
|
||||||
|
expect(ex?.type).toBe('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors explicit existingExerciseId over name lookup', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
const a = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: u.id,
|
||||||
|
name: 'Squat',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const b = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: u.id,
|
||||||
|
name: 'Different Squat Variation',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
// Even though the name matches `a`, existingExerciseId says use `b`.
|
||||||
|
await saveImport(
|
||||||
|
jsonReq({
|
||||||
|
workouts: [
|
||||||
|
{
|
||||||
|
date: '2026-01-15',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
name: 'Squat',
|
||||||
|
existingExerciseId: b.id,
|
||||||
|
sets: [{ reps: 5, weight: 315 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sets = await prisma.setLog.findMany({
|
||||||
|
where: { exerciseId: b.id },
|
||||||
|
});
|
||||||
|
expect(sets).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
await prisma.setLog.count({ where: { exerciseId: a.id } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates multiple workouts in one call and returns the ids', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
const res = await saveImport(
|
||||||
|
jsonReq({
|
||||||
|
workouts: [
|
||||||
|
{
|
||||||
|
date: '2026-01-01',
|
||||||
|
exercises: [{ name: 'Squat', sets: [{ reps: 5 }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2026-01-03',
|
||||||
|
exercises: [{ name: 'Bench', sets: [{ reps: 5 }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2026-01-05',
|
||||||
|
exercises: [{ name: 'Deadlift', sets: [{ reps: 5 }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.count).toBe(3);
|
||||||
|
expect(body.created).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers sets sequentially per exercise per workout', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
|
||||||
|
await saveImport(
|
||||||
|
jsonReq({
|
||||||
|
workouts: [
|
||||||
|
{
|
||||||
|
date: '2026-01-15',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
name: 'Squat',
|
||||||
|
sets: [
|
||||||
|
{ reps: 5, weight: 135 },
|
||||||
|
{ reps: 5, weight: 185 },
|
||||||
|
{ reps: 5, weight: 225 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sets = await prisma.setLog.findMany({
|
||||||
|
orderBy: { setNumber: 'asc' },
|
||||||
|
});
|
||||||
|
expect(sets.map((s) => s.setNumber)).toEqual([1, 2, 3]);
|
||||||
|
expect(sets.map((s) => s.weight)).toEqual([135, 185, 225]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||||
|
getCurrentUserMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', async (orig) => {
|
||||||
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
|
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||||
|
});
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { POST as importPost } from '@/app/api/me/import/route';
|
||||||
|
|
||||||
|
function jsonReq(body: unknown): NextRequest {
|
||||||
|
return new NextRequest('http://x/api/me/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeUser(opts: { email: string }) {
|
||||||
|
return prisma.user.create({
|
||||||
|
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.exercise.deleteMany();
|
||||||
|
await prisma.workout.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
getCurrentUserMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sampleExport = (overrides?: Partial<{ exercises: unknown[]; workouts: unknown[] }>) => ({
|
||||||
|
schema: 'proof-of-work-export@1',
|
||||||
|
exercises: overrides?.exercises ?? [
|
||||||
|
{
|
||||||
|
id: 'cE1',
|
||||||
|
name: 'Imported Bench',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '["chest"]',
|
||||||
|
inputFields: '["sets","reps","weight"]',
|
||||||
|
defaultWeightUnit: null,
|
||||||
|
isCustom: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cE2',
|
||||||
|
name: 'Imported Squat',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '["legs"]',
|
||||||
|
inputFields: '["sets","reps","weight"]',
|
||||||
|
defaultWeightUnit: null,
|
||||||
|
isCustom: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workouts: overrides?.workouts ?? [
|
||||||
|
{
|
||||||
|
date: '2026-04-01T12:00:00.000Z',
|
||||||
|
name: 'Day 1',
|
||||||
|
setLogs: [
|
||||||
|
{ exerciseId: 'cE1', setNumber: 1, reps: 5, weight: 225 },
|
||||||
|
{ exerciseId: 'cE1', setNumber: 2, reps: 5, weight: 245 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2026-04-03T12:00:00.000Z',
|
||||||
|
name: 'Day 2',
|
||||||
|
setLogs: [
|
||||||
|
{ exerciseId: 'cE2', setNumber: 1, reps: 5, weight: 315 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/me/import', () => {
|
||||||
|
it('returns 401 unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown schema versions', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
const res = await importPost(
|
||||||
|
jsonReq({
|
||||||
|
payload: { schema: 'something-else@99', exercises: [], workouts: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge mode imports exercises and workouts attributed to the actor', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.exercisesCreated).toBe(2);
|
||||||
|
expect(body.workoutsCreated).toBe(2);
|
||||||
|
expect(body.setsCreated).toBe(3);
|
||||||
|
|
||||||
|
const exercises = await prisma.exercise.findMany({
|
||||||
|
where: { userId: u.id },
|
||||||
|
});
|
||||||
|
expect(exercises.map((e) => e.name).sort()).toEqual([
|
||||||
|
'Imported Bench',
|
||||||
|
'Imported Squat',
|
||||||
|
]);
|
||||||
|
const workouts = await prisma.workout.findMany({
|
||||||
|
where: { userId: u.id },
|
||||||
|
include: { setLogs: true },
|
||||||
|
});
|
||||||
|
expect(workouts).toHaveLength(2);
|
||||||
|
const totalSets = workouts.reduce((n, w) => n + w.setLogs.length, 0);
|
||||||
|
expect(totalSets).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge mode skips exercises whose name already exists for the user', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: u.id,
|
||||||
|
name: 'Imported Bench',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '["chest"]',
|
||||||
|
isCustom: true, // user's own custom version
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
const res = await importPost(jsonReq({ payload: sampleExport() }));
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.exercisesCreated).toBe(1); // only Squat
|
||||||
|
expect(body.exercisesSkipped).toBe(1); // Bench already existed
|
||||||
|
|
||||||
|
// The user's custom row was NOT overwritten.
|
||||||
|
const bench = await prisma.exercise.findUnique({
|
||||||
|
where: { userId_name: { userId: u.id, name: 'Imported Bench' } },
|
||||||
|
});
|
||||||
|
expect(bench?.isCustom).toBe(true);
|
||||||
|
|
||||||
|
// Workouts were still created and bound to the user's existing
|
||||||
|
// (custom) Bench, not the imported one.
|
||||||
|
const sets = await prisma.setLog.findMany({
|
||||||
|
where: { exerciseId: bench!.id },
|
||||||
|
});
|
||||||
|
expect(sets.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replace mode requires explicit confirmation', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
const res = await importPost(
|
||||||
|
jsonReq({ payload: sampleExport(), mode: 'replace' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/REPLACE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replace mode wipes existing user-owned data first', async () => {
|
||||||
|
const u = await makeUser({ email: 'a@x' });
|
||||||
|
const existing = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: u.id,
|
||||||
|
name: 'Old Custom',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.workout.create({
|
||||||
|
data: {
|
||||||
|
userId: u.id,
|
||||||
|
date: new Date('2026-01-01'),
|
||||||
|
setLogs: { create: [{ exerciseId: existing.id, setNumber: 1, reps: 1 }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(1);
|
||||||
|
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(1);
|
||||||
|
|
||||||
|
getCurrentUserMock.mockResolvedValue(u);
|
||||||
|
const res = await importPost(
|
||||||
|
jsonReq({
|
||||||
|
payload: sampleExport(),
|
||||||
|
mode: 'replace',
|
||||||
|
confirm: 'REPLACE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.mode).toBe('replace');
|
||||||
|
|
||||||
|
// Old custom is gone; new imported data is in place.
|
||||||
|
expect(
|
||||||
|
await prisma.exercise.findUnique({
|
||||||
|
where: { userId_name: { userId: u.id, name: 'Old Custom' } },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(await prisma.exercise.count({ where: { userId: u.id } })).toBe(2);
|
||||||
|
expect(await prisma.workout.count({ where: { userId: u.id } })).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps imports scoped to the actor, never to other users', async () => {
|
||||||
|
const me = await makeUser({ email: 'me@x' });
|
||||||
|
const other = await makeUser({ email: 'other@x' });
|
||||||
|
await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: other.id,
|
||||||
|
name: 'Other User Exercise',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
await importPost(jsonReq({ payload: sampleExport() }));
|
||||||
|
|
||||||
|
// The other user's data is completely untouched.
|
||||||
|
expect(await prisma.exercise.count({ where: { userId: other.id } })).toBe(1);
|
||||||
|
expect(await prisma.workout.count({ where: { userId: other.id } })).toBe(0);
|
||||||
|
expect(
|
||||||
|
(await prisma.exercise.findFirst({ where: { userId: other.id } }))?.name,
|
||||||
|
).toBe('Other User Exercise');
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-1
@@ -25,7 +25,11 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache openssl
|
# openssl: Prisma engine runtime
|
||||||
|
# python3 + make + g++: native node-gyp builds (bcrypt). Even when the
|
||||||
|
# `bcrypt` npm package ships musl prebuilts, a postinstall fallback
|
||||||
|
# compile is the safety net — no compile, no boot.
|
||||||
|
RUN apk add --no-cache openssl python3 make g++
|
||||||
|
|
||||||
COPY proof-of-work/package.json proof-of-work/package-lock.json ./
|
COPY proof-of-work/package.json proof-of-work/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -66,6 +70,11 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Native bcrypt binding. Next.js standalone tracing usually picks up
|
||||||
|
# the .node file but we copy explicitly as a belt-and-braces guard —
|
||||||
|
# bundling failures here surface as auth being silently broken at boot.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcrypt ./node_modules/bcrypt
|
||||||
|
|
||||||
# Empty-schema fallback DB (used only when no baked seed is available on a
|
# Empty-schema fallback DB (used only when no baked seed is available on a
|
||||||
# brand-new sideload).
|
# brand-new sideload).
|
||||||
COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db
|
COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db
|
||||||
|
|||||||
Reference in New Issue
Block a user