umami/src/app/api/websites/[websiteId]/route.ts
Mike Cao 72b5c658e2 Add rrweb-based session recording feature.
Implements full session recording with rrweb for DOM capture and rrweb-player
for playback. Includes: Prisma schema for SessionRecording model, chunked
gzip-compressed storage, recorder script built via Rollup, collection API
endpoint, recordings list/playback UI pages, website recording settings,
and cascade delete support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:49:59 -08:00

126 lines
3.1 KiB
TypeScript

import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
import {
createShare,
deleteSharesByEntityId,
deleteWebsite,
getShareByEntityId,
getWebsite,
updateWebsite,
} from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const website = await getWebsite(websiteId);
return json(website);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
name: z.string().optional(),
domain: z.string().optional(),
shareId: z.string().max(50).nullable().optional(),
recordingEnabled: z.boolean().optional(),
recordingConfig: z
.object({
sampleRate: z.number().min(0).max(1).optional(),
maskLevel: z.enum(['strict', 'moderate', 'relaxed']).optional(),
maxDuration: z.number().int().positive().optional(),
blockSelector: z.string().optional(),
retentionDays: z.number().int().positive().optional(),
})
.nullable()
.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { name, domain, shareId, recordingEnabled, recordingConfig } = body;
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
try {
const website = await updateWebsite(websiteId, {
name,
domain,
...(recordingEnabled !== undefined && { recordingEnabled }),
...(recordingConfig !== undefined && { recordingConfig }),
});
if (shareId === null) {
await deleteSharesByEntityId(website.id);
}
const share = shareId
? await createShare({
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
name: website.name,
slug: shareId,
parameters: { overview: true, events: true },
})
: await getShareByEntityId(websiteId);
return json({
...website,
shareId: share?.slug ?? null,
});
} catch (e: any) {
if (e.message.toLowerCase().includes('unique constraint')) {
return badRequest({ message: 'That share ID is already taken.' });
}
return serverError(e);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canDeleteWebsite(auth, websiteId))) {
return unauthorized();
}
await deleteWebsite(websiteId);
return ok();
}