diff --git a/proof-of-work/app/api/me/import/route.ts b/proof-of-work/app/api/me/import/route.ts new file mode 100644 index 0000000..bd5ff73 --- /dev/null +++ b/proof-of-work/app/api/me/import/route.ts @@ -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(); + const rawExercises = (body?.payload?.exercises ?? []) as Array< + Record + >; + 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(); + 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 }, + ); + } +} diff --git a/proof-of-work/app/api/workouts/route.ts b/proof-of-work/app/api/workouts/route.ts index f68bb3f..595fad2 100644 --- a/proof-of-work/app/api/workouts/route.ts +++ b/proof-of-work/app/api/workouts/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { Prisma } from "@prisma/client"; import { getCurrentUser } from "@/lib/auth"; 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 offset = parseInt(searchParams.get("offset") || "0"); - const where: any = { + const where: Prisma.WorkoutWhereInput = { userId: user.id, deletedAt: null, }; if (query) { - where.name = { - contains: query, - }; + where.name = { contains: query }; } if (dateFrom || dateTo) { - where.date = {}; - if (dateFrom) { - where.date.gte = new Date(dateFrom); - } + const dateFilter: Prisma.DateTimeFilter = {}; + if (dateFrom) dateFilter.gte = new Date(dateFrom); if (dateTo) { const toDate = new Date(dateTo); toDate.setHours(23, 59, 59, 999); - where.date.lte = toDate; + dateFilter.lte = toDate; } + where.date = dateFilter; } const [workouts, total] = await Promise.all([ @@ -134,19 +132,23 @@ export async function POST(request: NextRequest) { // Extract caloriesBurned — handled via raw SQL after creation const caloriesValue = validated.caloriesBurned; - const createData: any = { - userId: user.id, + // Note: caloriesBurned was historically handled via raw SQL because + // 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, notes: validated.notes, durationMinutes: validated.durationMinutes, difficulty: validated.difficulty, - // caloriesBurned handled separately via raw SQL date: workoutDate, setLogs: validated.sets.length > 0 ? { create: validated.sets.map((set) => ({ - exerciseId: set.exerciseId, + exercise: { connect: { id: set.exerciseId } }, setNumber: set.setNumber, reps: set.reps, weight: set.weight, @@ -161,7 +163,7 @@ export async function POST(request: NextRequest) { ? JSON.stringify(set.customMetrics) : undefined, notes: set.notes, - } as any)), + })), } : undefined, }; diff --git a/proof-of-work/components/settings/ExportMyData.tsx b/proof-of-work/components/settings/ExportMyData.tsx index 654b48d..223fe9d 100644 --- a/proof-of-work/components/settings/ExportMyData.tsx +++ b/proof-of-work/components/settings/ExportMyData.tsx @@ -2,12 +2,23 @@ import { useState } from 'react'; +interface ImportSummary { + mode: 'merge' | 'replace'; + exercisesCreated: number; + exercisesSkipped: number; + workoutsCreated: number; + setsCreated: number; + setsSkipped: number; +} + export default function ExportMyData() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [summary, setSummary] = useState(null); - const handleClick = async () => { + const handleExport = async () => { setError(null); + setSummary(null); setBusy(true); try { const res = await fetch('/api/me/export'); @@ -19,7 +30,6 @@ export default function ExportMyData() { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - // Filename comes from Content-Disposition; let the browser pick it. const cd = res.headers.get('content-disposition'); const match = cd?.match(/filename="([^"]+)"/); 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 = { 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) => { + const f = e.target.files?.[0]; + if (f) void handleImport(f, 'merge'); + e.target.value = ''; + }; + + const onPickReplace = (e: React.ChangeEvent) => { + 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 (

- Export my data + Export & import my data

Download a JSON file with every workout, set, exercise, and - program tied to your account. Password and sessions are not - included. + program tied to your account. Re-import on another instance + (or this one after a wipe) to restore.

- + +
+ + + + + +
+ +

+ Merge keeps your existing + data and adds the imported rows. Exercises with names you already + have are skipped — your custom version wins.{' '} + Replace deletes everything + in your account first. +

+ {error && (
{error}
)} + {summary && ( +
+ Imported in {summary.mode} 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)` + : ''} + . +
+ )}
); } diff --git a/proof-of-work/lib/auth.ts b/proof-of-work/lib/auth.ts index f65ea1b..c1424df 100644 --- a/proof-of-work/lib/auth.ts +++ b/proof-of-work/lib/auth.ts @@ -1,4 +1,4 @@ -import bcryptjs from "bcryptjs"; +import bcrypt from "bcrypt"; import { randomBytes } from "node:crypto"; import { prisma } from "./prisma"; 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"; /** - * 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 { - const salt = await bcryptjs.genSalt(10); - return bcryptjs.hash(password, salt); + return bcrypt.hash(password, 10); } -/** - * Verify a password against its hash - */ export async function verifyPassword( password: string, - hash: string + hash: string, ): Promise { - return bcryptjs.compare(password, hash); + return bcrypt.compare(password, hash); } /** diff --git a/proof-of-work/lib/db/workouts.ts b/proof-of-work/lib/db/workouts.ts index cf21be9..b2343f9 100644 --- a/proof-of-work/lib/db/workouts.ts +++ b/proof-of-work/lib/db/workouts.ts @@ -1,5 +1,5 @@ import { prisma } from "../prisma"; -import { Workout } from "@prisma/client"; +import { Prisma, Workout } from "@prisma/client"; import { SearchFilters } from "@/types"; /** @@ -18,21 +18,20 @@ export async function getWorkouts( offset = 0, } = filters || {}; - const where: any = { + const where: Prisma.WorkoutWhereInput = { userId, deletedAt: null, }; if (query) { - where.name = { - contains: query, - }; + where.name = { contains: query }; } if (dateFrom || dateTo) { - where.date = {}; - if (dateFrom) where.date.gte = dateFrom; - if (dateTo) where.date.lte = dateTo; + const dateFilter: Prisma.DateTimeFilter = {}; + if (dateFrom) dateFilter.gte = dateFrom; + if (dateTo) dateFilter.lte = dateTo; + where.date = dateFilter; } const workouts = await prisma.workout.findMany({ diff --git a/proof-of-work/middleware.ts b/proof-of-work/middleware.ts index d38135f..87fa59c 100644 --- a/proof-of-work/middleware.ts +++ b/proof-of-work/middleware.ts @@ -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 `