Initial implementation of Bluetooth toy control app
Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getEnv } from "@/lib/env";
|
||||
import { timingSafeStringEqual } from "@/lib/auth/timing-safe-compare";
|
||||
import { createSessionToken, sessionCookieOptions } from "@/lib/auth/session";
|
||||
|
||||
const bodySchema = z.object({ secret: z.string().min(1) });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "secret is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!timingSafeStringEqual(parsed.data.secret, getEnv().ACCESS_PASSWORD)) {
|
||||
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await createSessionToken();
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set({ ...sessionCookieOptions, value: token });
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.delete(SESSION_COOKIE_NAME);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { renameDevice } from "@/lib/db/queries/devices";
|
||||
|
||||
const bodySchema = z.object({ displayName: z.string().min(1).max(120) });
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "displayName is required" }, { status: 400 });
|
||||
}
|
||||
const updated = await renameDevice(Number(id), parsed.data.displayName);
|
||||
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json({ device: updated });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listDevices } from "@/lib/db/queries/devices";
|
||||
|
||||
export async function GET() {
|
||||
const rows = await listDevices();
|
||||
return NextResponse.json({ devices: rows });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { sqlite } from "@/lib/db/client";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
sqlite.prepare("select 1").get();
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch {
|
||||
return NextResponse.json({ status: "error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { insertEvents } from "@/lib/db/queries/session-events";
|
||||
|
||||
const eventSchema = z.object({
|
||||
sessionDeviceId: z.number().int().positive(),
|
||||
tsMs: z.number().int().min(0),
|
||||
commandType: z.enum(["vibrate", "rotate", "linear", "stop"]),
|
||||
featureIndex: z.number().int().min(0),
|
||||
value: z.number().min(0).max(1),
|
||||
durationMs: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const bodySchema = z.object({ events: z.array(eventSchema).max(2000) });
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
|
||||
}
|
||||
await insertEvents(Number(id), parsed.data.events);
|
||||
return NextResponse.json({ inserted: parsed.data.events.length });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
|
||||
|
||||
const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) });
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const detail = await getPlaySessionDetail(Number(id));
|
||||
if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json(detail);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 });
|
||||
}
|
||||
const updated = await endPlaySession(Number(id), parsed.data);
|
||||
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json({ playSession: updated });
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
await deletePlaySession(Number(id));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) {
|
||||
return NextResponse.json(
|
||||
{ error: "cannot delete a session that a saved recording still references" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions";
|
||||
|
||||
const deviceInputSchema = z.object({
|
||||
slotLabel: z.string().min(1),
|
||||
bleName: z.string().min(1),
|
||||
deviceClass: z.string().nullish(),
|
||||
capabilities: z.object({ outputs: z.array(z.string()), featureCount: z.number() }).optional(),
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
kind: z.enum(["live", "replay"]),
|
||||
replayedRecordingId: z.number().int().positive().optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
devices: z.array(deviceInputSchema).min(1),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const rows = await listPlaySessions();
|
||||
return NextResponse.json({ playSessions: rows });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
|
||||
}
|
||||
const result = await startPlaySession(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings";
|
||||
|
||||
const patchSchema = z.object({
|
||||
name: z.string().min(1).max(160).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const result = await getRecording(Number(id));
|
||||
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "invalid body" }, { status: 400 });
|
||||
}
|
||||
const updated = await renameRecording(Number(id), parsed.data);
|
||||
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json({ recording: updated });
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
await deleteRecording(Number(id));
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createRecording, listRecordings } from "@/lib/db/queries/recordings";
|
||||
|
||||
const bodySchema = z.object({
|
||||
sourcePlaySessionId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(160),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const rows = await listRecordings();
|
||||
return NextResponse.json({ recordings: rows });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
|
||||
}
|
||||
const recording = await createRecording(parsed.data);
|
||||
return NextResponse.json({ recording }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDeviceCommandCounts, getDeviceUsageStats } from "@/lib/db/queries/stats";
|
||||
|
||||
export async function GET() {
|
||||
const [usage, commandCounts] = await Promise.all([getDeviceUsageStats(), getDeviceCommandCounts()]);
|
||||
const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount]));
|
||||
const devices = usage.map((d) => ({ ...d, commandCount: commandCountByDevice.get(d.deviceId) ?? 0 }));
|
||||
return NextResponse.json({ devices });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRecordingLibraryStats } from "@/lib/db/queries/stats";
|
||||
|
||||
export async function GET() {
|
||||
const stats = await getRecordingLibraryStats();
|
||||
return NextResponse.json(stats);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionTimeline } from "@/lib/db/queries/stats";
|
||||
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const bucketMs = Number(new URL(req.url).searchParams.get("bucketMs") ?? 1000);
|
||||
const timeline = await getSessionTimeline(Number(id), bucketMs);
|
||||
return NextResponse.json({ timeline });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionsSummary } from "@/lib/db/queries/stats";
|
||||
|
||||
export async function GET() {
|
||||
const summary = await getSessionsSummary();
|
||||
return NextResponse.json(summary);
|
||||
}
|
||||
Reference in New Issue
Block a user