add build profiles

This commit is contained in:
KingKDot
2026-04-23 22:54:49 -05:00
parent 666bbfac4d
commit 5f1501c5ef
4 changed files with 546 additions and 90 deletions
+63
View File
@@ -193,6 +193,18 @@ try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {}
try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`);
db.run(`
CREATE TABLE IF NOT EXISTS build_profiles (
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
profile_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, name)
);
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_build_profiles_user_updated ON build_profiles(user_id, updated_at DESC);`);
db.run(`
CREATE TABLE IF NOT EXISTS notification_screenshots (
id TEXT PRIMARY KEY,
@@ -1460,6 +1472,57 @@ export function getClientBookmark(id: string): boolean {
return row?.bookmarked === 1;
}
export interface BuildProfileRecord {
userId: number;
name: string;
profileJson: string;
createdAt: number;
updatedAt: number;
}
export function listBuildProfilesForUser(userId: number): BuildProfileRecord[] {
return db
.query<any>(
`SELECT user_id, name, profile_json, created_at, updated_at
FROM build_profiles
WHERE user_id = ?
ORDER BY updated_at DESC, name ASC`,
)
.all(userId)
.map((row: any) => ({
userId: row.user_id,
name: row.name,
profileJson: row.profile_json,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export function saveBuildProfileForUser(userId: number, name: string, profileJson: string): void {
const now = Date.now();
db.run(
`INSERT INTO build_profiles (user_id, name, profile_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, name) DO UPDATE SET
profile_json = excluded.profile_json,
updated_at = excluded.updated_at`,
userId,
name,
profileJson,
now,
now,
);
}
export function deleteBuildProfileForUser(userId: number, name: string): boolean {
const result = db.run(
`DELETE FROM build_profiles WHERE user_id = ? AND name = ?`,
userId,
name,
);
return ((result as any)?.changes || 0) > 0;
}
export interface BuildRecord {
id: string;
status: string;
@@ -4,7 +4,16 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import * as buildManager from "../../build/buildManager";
import * as clientManager from "../../clientManager";
import { deleteBuild, getAllBuilds, getBuild, countBuildsForUser, getOldestBuildForUser } from "../../db";
import {
deleteBuild,
getAllBuilds,
getBuild,
countBuildsForUser,
getOldestBuildForUser,
listBuildProfilesForUser,
saveBuildProfileForUser,
deleteBuildProfileForUser,
} from "../../db";
import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
@@ -380,6 +389,90 @@ export async function handleBuildRoutes(
return Response.json({ builds });
}
if (req.method === "GET" && url.pathname === "/api/build/profiles") {
requirePermission(user, "clients:build");
const profiles = listBuildProfilesForUser(user.userId).map((p) => {
let parsedConfig: any = {};
try {
parsedConfig = JSON.parse(p.profileJson);
} catch {
parsedConfig = {};
}
return {
name: p.name,
config: parsedConfig,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
});
return Response.json({ profiles });
}
if (req.method === "POST" && url.pathname === "/api/build/profiles") {
requirePermission(user, "clients:build");
const body = await req.json();
const name = typeof body?.name === "string" ? body.name.trim() : "";
const config = body?.config;
if (!name || name.length > 64 || !/^[A-Za-z0-9 _.-]+$/.test(name)) {
return Response.json(
{ error: "Invalid profile name (1-64 chars, letters/numbers/space/._-)" },
{ status: 400 },
);
}
if (!config || typeof config !== "object" || Array.isArray(config)) {
return Response.json({ error: "Invalid profile config" }, { status: 400 });
}
const MAX_PROFILES_PER_USER = 50;
const existingProfiles = listBuildProfilesForUser(user.userId);
const hasExistingWithSameName = existingProfiles.some((p) => p.name === name);
if (!hasExistingWithSameName && existingProfiles.length >= MAX_PROFILES_PER_USER) {
return Response.json(
{ error: `Profile limit reached (${MAX_PROFILES_PER_USER} max)` },
{ status: 400 },
);
}
let profileJson = "{}";
try {
profileJson = JSON.stringify(config);
} catch {
return Response.json({ error: "Profile config must be JSON serializable" }, { status: 400 });
}
if (profileJson.length > 512 * 1024) {
return Response.json({ error: "Profile config too large (max 512KB)" }, { status: 400 });
}
saveBuildProfileForUser(user.userId, name, profileJson);
return Response.json({ success: true });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/profiles\/.+$/)) {
requirePermission(user, "clients:build");
const encodedName = url.pathname.split("/api/build/profiles/")[1] || "";
let name = "";
try {
name = decodeURIComponent(encodedName).trim();
} catch {
return Response.json({ error: "Invalid profile name" }, { status: 400 });
}
if (!name) {
return Response.json({ error: "Invalid profile name" }, { status: 400 });
}
const deleted = deleteBuildProfileForUser(user.userId, name);
if (!deleted) {
return Response.json({ error: "Profile not found" }, { status: 404 });
}
return Response.json({ success: true });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/(.+)\/delete$/)) {
requirePermission(user, "clients:build");