mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
1765 lines
65 KiB
TypeScript
1765 lines
65 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import type { Express, Response } from 'express';
|
|
import {
|
|
defaultScenarioPluginIdForProjectMetadata,
|
|
type PluginManifest,
|
|
} from '@open-design/contracts';
|
|
import { createProjectArtifactFile } from './artifact-create.js';
|
|
import { ArtifactPublicationBlockedError } from './artifact-publication-guard.js';
|
|
import { ArtifactRegressionError } from './artifact-stub-guard.js';
|
|
import { listDesignSystems } from './design-systems.js';
|
|
import {
|
|
FIRST_PARTY_ATOMS,
|
|
buildConnectorProbe,
|
|
getInstalledPlugin,
|
|
listInstalledPlugins,
|
|
resolvePluginSnapshot,
|
|
} from './plugins/index.js';
|
|
import { connectorService } from './connectors/service.js';
|
|
import type { RouteDeps } from './server-context.js';
|
|
import { listSkills } from './skills.js';
|
|
import { isSafeId } from './projects.js';
|
|
import {
|
|
BUILT_IN_PROJECT_LOCATION_ID,
|
|
allProjectLocations,
|
|
createLocationProjectDir,
|
|
ensureProjectLocation,
|
|
scanProjectLocation,
|
|
writeProjectManifest,
|
|
} from './project-locations.js';
|
|
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
|
|
|
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {}
|
|
|
|
function projectDetailResolvedDir(
|
|
projectsRoot: string,
|
|
project: any,
|
|
resolveProjectDir: (
|
|
projectsRoot: string,
|
|
projectId: string,
|
|
metadata?: unknown,
|
|
opts?: { allowUnavailableSandboxImportedProject?: boolean },
|
|
) => string,
|
|
): string {
|
|
const baseDir = typeof project?.metadata?.baseDir === 'string'
|
|
? path.normalize(project.metadata.baseDir)
|
|
: null;
|
|
if (baseDir && path.isAbsolute(baseDir)) return baseDir;
|
|
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
|
|
allowUnavailableSandboxImportedProject: true,
|
|
});
|
|
}
|
|
|
|
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
|
|
(function(){
|
|
if (window.__odUrlScrollBridge) return;
|
|
window.__odUrlScrollBridge = true;
|
|
var pending = false;
|
|
function scrollElement(){
|
|
return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement;
|
|
}
|
|
function num(value){
|
|
var next = Number(value || 0);
|
|
return Number.isFinite(next) ? next : 0;
|
|
}
|
|
function post(){
|
|
var el = scrollElement();
|
|
if (!el) return;
|
|
var frame = document.scrollingElement || document.documentElement;
|
|
window.parent.postMessage({
|
|
type: 'od:preview-scroll',
|
|
canvasLeft: Math.round(el.scrollLeft || 0),
|
|
canvasTop: Math.round(el.scrollTop || 0),
|
|
frameLeft: Math.round(frame.scrollLeft || 0),
|
|
frameTop: Math.round(frame.scrollTop || 0)
|
|
}, '*');
|
|
}
|
|
function schedule(){
|
|
if (pending) return;
|
|
pending = true;
|
|
window.requestAnimationFrame(function(){
|
|
pending = false;
|
|
post();
|
|
});
|
|
}
|
|
function scrollTo(el, left, top){
|
|
if (!el) return;
|
|
if (typeof el.scrollTo === 'function') el.scrollTo(num(left), num(top));
|
|
else {
|
|
el.scrollLeft = num(left);
|
|
el.scrollTop = num(top);
|
|
}
|
|
}
|
|
function scrollBy(el, left, top){
|
|
if (!el) return;
|
|
var dx = num(left);
|
|
var dy = num(top);
|
|
if (!dx && !dy) return;
|
|
if (typeof el.scrollBy === 'function') el.scrollBy({ left: dx, top: dy, behavior: 'auto' });
|
|
else {
|
|
el.scrollLeft = (el.scrollLeft || 0) + dx;
|
|
el.scrollTop = (el.scrollTop || 0) + dy;
|
|
}
|
|
}
|
|
function requestRestore(){
|
|
window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*');
|
|
}
|
|
window.addEventListener('message', function(ev){
|
|
var data = ev && ev.data;
|
|
if (!data || !data.type) return;
|
|
if (data.type === 'od:preview-scroll-restore') {
|
|
scrollTo(document.scrollingElement || document.documentElement, data.frameLeft, data.frameTop);
|
|
scrollTo(scrollElement(), data.canvasLeft, data.canvasTop);
|
|
setTimeout(post, 0);
|
|
return;
|
|
}
|
|
if (data.type === 'od:preview-scroll-by') {
|
|
scrollBy(scrollElement(), data.left, data.top);
|
|
schedule();
|
|
}
|
|
});
|
|
window.addEventListener('scroll', schedule, true);
|
|
document.addEventListener('scroll', schedule, true);
|
|
window.addEventListener('resize', schedule);
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
requestRestore();
|
|
schedule();
|
|
});
|
|
} else {
|
|
setTimeout(function(){
|
|
requestRestore();
|
|
schedule();
|
|
}, 0);
|
|
}
|
|
})();
|
|
</script>`;
|
|
|
|
function wantsUrlPreviewScrollBridge(value: unknown): boolean {
|
|
if (Array.isArray(value)) return value.some(wantsUrlPreviewScrollBridge);
|
|
if (typeof value !== 'string') return false;
|
|
return value === 'scroll' || value === '1' || value === 'true';
|
|
}
|
|
|
|
function injectUrlPreviewScrollBridge(html: string): string {
|
|
if (html.includes('data-od-url-scroll-bridge')) return html;
|
|
const bodyCloseIndex = html.search(/<\/body\s*>/i);
|
|
if (bodyCloseIndex >= 0) {
|
|
return `${html.slice(0, bodyCloseIndex)}${URL_PREVIEW_SCROLL_BRIDGE}${html.slice(bodyCloseIndex)}`;
|
|
}
|
|
return `${html}${URL_PREVIEW_SCROLL_BRIDGE}`;
|
|
}
|
|
|
|
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
|
|
const { db, design } = ctx;
|
|
const { sendApiError, createSseResponse } = ctx.http;
|
|
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
|
|
const { readAppConfig, writeAppConfig } = ctx.appConfig;
|
|
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
|
|
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
|
|
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
|
|
const { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate } = ctx.templates;
|
|
const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status;
|
|
const { subscribeFileEvents, activeProjectEventSinks } = ctx.events;
|
|
const { randomId } = ctx.ids;
|
|
const { validateProjectDesignSystemId, validateProjectSkillId } = ctx.validation;
|
|
async function loadPluginRegistryView() {
|
|
const [skills, designSystems] = await Promise.all([
|
|
listSkills(SKILLS_DIR),
|
|
listDesignSystems(DESIGN_SYSTEMS_DIR),
|
|
]);
|
|
return {
|
|
skills: skills.map((s) => ({ id: s.id, title: s.name, description: s.description })),
|
|
designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })),
|
|
craft: [],
|
|
atoms: FIRST_PARTY_ATOMS.map((a) => ({ id: a.id, label: a.label })),
|
|
scenarios: collectBundledScenarios(),
|
|
};
|
|
}
|
|
|
|
function collectBundledScenarios() {
|
|
type ScenarioEntry = {
|
|
id: string;
|
|
taskKind: 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab';
|
|
pipeline: NonNullable<NonNullable<PluginManifest['od']>['pipeline']>;
|
|
};
|
|
const byTaskKind = new Map<ScenarioEntry['taskKind'], ScenarioEntry>();
|
|
try {
|
|
const all = listInstalledPlugins(db);
|
|
for (const row of all) {
|
|
if (row.sourceKind !== 'bundled') continue;
|
|
const od = row.manifest.od;
|
|
if (!od || od.kind !== 'scenario') continue;
|
|
if (!od.pipeline || !Array.isArray(od.pipeline.stages) || od.pipeline.stages.length === 0) continue;
|
|
const taskKind = (od.taskKind ?? 'new-generation') as ScenarioEntry['taskKind'];
|
|
if (
|
|
taskKind !== 'new-generation' &&
|
|
taskKind !== 'figma-migration' &&
|
|
taskKind !== 'code-migration' &&
|
|
taskKind !== 'tune-collab'
|
|
) {
|
|
continue;
|
|
}
|
|
const entry: ScenarioEntry = { id: row.id, taskKind, pipeline: od.pipeline };
|
|
const existing = byTaskKind.get(taskKind);
|
|
if (!existing || entry.id === `od-${taskKind}`) {
|
|
byTaskKind.set(taskKind, entry);
|
|
}
|
|
}
|
|
} catch {
|
|
return [];
|
|
}
|
|
return Array.from(byTaskKind.values());
|
|
}
|
|
|
|
async function configuredProjectLocations() {
|
|
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
|
|
const all = allProjectLocations(PROJECTS_DIR, config.projectLocations);
|
|
const valid = all[0] ? [all[0]] : [];
|
|
for (const location of all.slice(1)) {
|
|
const validated = validateLinkedDirs([location.path]);
|
|
if (validated.error) continue;
|
|
const canonical = validated.dirs[0];
|
|
if (!canonical) continue;
|
|
if (locationOverlapsDaemonData(canonical)) continue;
|
|
valid.push({ ...location, path: canonical });
|
|
}
|
|
return valid;
|
|
}
|
|
|
|
function locationOverlapsDaemonData(locationPath: string): boolean {
|
|
const runtimeDir = ctx.paths.RUNTIME_DATA_DIR_CANONICAL || ctx.paths.RUNTIME_DATA_DIR;
|
|
const projectsDir = path.join(runtimeDir, 'projects');
|
|
const relativeToRuntime = pathRelative(runtimeDir, locationPath);
|
|
const runtimeInsideLocation = pathRelative(locationPath, runtimeDir);
|
|
const relativeToProjects = pathRelative(projectsDir, locationPath);
|
|
const projectsInsideLocation = pathRelative(locationPath, projectsDir);
|
|
return isInsideOrSame(relativeToRuntime) || isInsideOrSame(runtimeInsideLocation)
|
|
|| isInsideOrSame(relativeToProjects) || isInsideOrSame(projectsInsideLocation);
|
|
}
|
|
|
|
function pathRelative(from: string, to: string): string {
|
|
return path.relative(from, to);
|
|
}
|
|
|
|
function isInsideOrSame(relative: string): boolean {
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function projectBelongsToLocation(project: any, location: { id: string; path: string }): boolean {
|
|
const metadata = project?.metadata;
|
|
if (typeof metadata?.baseDir !== 'string') return metadata?.projectLocationId === location.id;
|
|
const relative = path.relative(location.path, metadata.baseDir);
|
|
return isInsideOrSame(relative) && relative !== '';
|
|
}
|
|
|
|
function isProjectLocationProject(project: any): boolean {
|
|
const metadata = project?.metadata;
|
|
return metadata?.importedFrom === 'project-location'
|
|
|| typeof metadata?.projectLocationId === 'string';
|
|
}
|
|
|
|
function projectVisibleForLocations(
|
|
project: any,
|
|
locations: Array<{ id: string; path: string; builtIn?: boolean }>,
|
|
): boolean {
|
|
if (!isProjectLocationProject(project)) return true;
|
|
return locations.some((location) => !location.builtIn && projectBelongsToLocation(project, location));
|
|
}
|
|
|
|
async function resolveCreateProjectLocationId(explicitProjectLocationId: unknown): Promise<string> {
|
|
if (typeof explicitProjectLocationId === 'string' && explicitProjectLocationId.trim()) {
|
|
return explicitProjectLocationId.trim();
|
|
}
|
|
const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR);
|
|
const configuredDefault = typeof config.defaultProjectLocationId === 'string'
|
|
? config.defaultProjectLocationId.trim()
|
|
: '';
|
|
if (!configuredDefault || configuredDefault === BUILT_IN_PROJECT_LOCATION_ID) {
|
|
return BUILT_IN_PROJECT_LOCATION_ID;
|
|
}
|
|
const locations = await configuredProjectLocations();
|
|
return locations.some((location) => !location.builtIn && location.id === configuredDefault)
|
|
? configuredDefault
|
|
: BUILT_IN_PROJECT_LOCATION_ID;
|
|
}
|
|
|
|
function unregisterProjectsForRemovedLocations(
|
|
previousLocations: Array<{ id: string; path: string; builtIn?: boolean }>,
|
|
nextLocations: Array<{ id?: string; path: string }>,
|
|
): string[] {
|
|
const nextIds = new Set(nextLocations.map((location) => location.id).filter(Boolean));
|
|
const nextPaths = new Set(nextLocations.map((location) => location.path));
|
|
const removed = previousLocations.filter(
|
|
(location) => !location.builtIn && !nextIds.has(location.id) && !nextPaths.has(location.path),
|
|
);
|
|
if (removed.length === 0) return [];
|
|
return listProjects(db)
|
|
.filter((project: any) => removed.some((location) => projectBelongsToLocation(project, location)))
|
|
.map((project: any) => project.id);
|
|
}
|
|
|
|
app.get('/api/project-locations', async (_req, res) => {
|
|
try {
|
|
const locations = await configuredProjectLocations();
|
|
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
|
|
const body = { locations };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
|
}
|
|
});
|
|
|
|
app.put('/api/project-locations', async (req, res) => {
|
|
try {
|
|
const requested = Array.isArray(req.body?.locations) ? req.body.locations : null;
|
|
if (!requested) return sendApiError(res, 400, 'BAD_REQUEST', 'locations must be an array');
|
|
const previousLocations = await configuredProjectLocations();
|
|
const prepared = [];
|
|
for (const loc of requested) {
|
|
if (!loc || typeof loc !== 'object' || typeof loc.path !== 'string') continue;
|
|
const canonicalPath = await ensureProjectLocation(loc.path);
|
|
const validated = validateLinkedDirs([canonicalPath]);
|
|
if (validated.error) return sendApiError(res, 400, 'BAD_REQUEST', validated.error);
|
|
if (locationOverlapsDaemonData(canonicalPath)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'project location cannot overlap daemon data');
|
|
}
|
|
prepared.push({
|
|
id: typeof loc.id === 'string' ? loc.id : undefined,
|
|
name: typeof loc.name === 'string' ? loc.name : undefined,
|
|
path: canonicalPath,
|
|
});
|
|
}
|
|
const config = await writeAppConfig(ctx.paths.RUNTIME_DATA_DIR, { projectLocations: prepared });
|
|
const locations = allProjectLocations(PROJECTS_DIR, config.projectLocations);
|
|
const removedProjectIds = unregisterProjectsForRemovedLocations(previousLocations, config.projectLocations ?? []);
|
|
/** @type {import('@open-design/contracts').ProjectLocationsResponse} */
|
|
const body = { locations, removedProjectIds };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
app.post('/api/project-locations/scan', async (_req, res) => {
|
|
try {
|
|
const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn);
|
|
const imported = [];
|
|
const existing: string[] = [];
|
|
const skipped: Array<{ path: string; reason: string }> = [];
|
|
let scanned = 0;
|
|
const now = Date.now();
|
|
for (const location of locations) {
|
|
let found;
|
|
try {
|
|
found = await scanProjectLocation(location);
|
|
} catch (err: any) {
|
|
skipped.push({ path: location.path, reason: String(err?.message ?? err) });
|
|
continue;
|
|
}
|
|
scanned += found.length;
|
|
for (const entry of found) {
|
|
const { manifest } = entry;
|
|
if (getProject(db, manifest.id)) {
|
|
existing.push(manifest.id);
|
|
continue;
|
|
}
|
|
try {
|
|
const project = insertProject(db, {
|
|
id: manifest.id,
|
|
name: manifest.name,
|
|
skillId: manifest.skillId ?? null,
|
|
designSystemId: manifest.designSystemId ?? null,
|
|
pendingPrompt: null,
|
|
metadata: {
|
|
kind: 'prototype',
|
|
baseDir: entry.dir,
|
|
importedFrom: 'project-location',
|
|
projectLocationId: location.id,
|
|
},
|
|
customInstructions: null,
|
|
createdAt: manifest.createdAt,
|
|
updatedAt: manifest.updatedAt,
|
|
});
|
|
insertConversation(db, {
|
|
id: randomId(),
|
|
projectId: manifest.id,
|
|
title: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
if (project) imported.push(project);
|
|
} catch (err: any) {
|
|
skipped.push({ path: entry.dir, reason: String(err?.message ?? err) });
|
|
}
|
|
}
|
|
}
|
|
/** @type {import('@open-design/contracts').ScanProjectLocationsResponse} */
|
|
const body = { scanned, imported, existing, skipped };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects', async (_req, res) => {
|
|
try {
|
|
const locations = await configuredProjectLocations();
|
|
const latestRunStatuses = listLatestProjectRunStatuses(db);
|
|
const awaitingInputProjects = listProjectsAwaitingInput(db);
|
|
const activeRunStatuses = new Map();
|
|
for (const run of design.runs.list()) {
|
|
if (!run.projectId) continue;
|
|
const runStatus = projectStatusFromRun(run);
|
|
if (design.runs.isTerminal(run.status)) {
|
|
const existing = latestRunStatuses.get(run.projectId);
|
|
if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
|
|
latestRunStatuses.set(run.projectId, runStatus);
|
|
}
|
|
} else {
|
|
const existing = activeRunStatuses.get(run.projectId);
|
|
if (!existing || run.updatedAt > (existing.updatedAt ?? 0)) {
|
|
activeRunStatuses.set(run.projectId, runStatus);
|
|
}
|
|
}
|
|
}
|
|
/** @type {import('@open-design/contracts').ProjectsResponse} */
|
|
const body = {
|
|
projects: listProjects(db)
|
|
.filter((project: any) => projectVisibleForLocations(project, locations))
|
|
.map((project: any) => ({
|
|
...project,
|
|
status: composeProjectDisplayStatus(
|
|
activeRunStatuses.get(project.id) ??
|
|
latestRunStatuses.get(project.id) ?? { value: 'not_started' },
|
|
awaitingInputProjects,
|
|
project.id,
|
|
),
|
|
})),
|
|
};
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
|
|
}
|
|
});
|
|
|
|
function projectStatusFromRun(run: any) {
|
|
return {
|
|
value: normalizeProjectDisplayStatus(run.status),
|
|
updatedAt: run.updatedAt,
|
|
runId: run.id,
|
|
};
|
|
}
|
|
|
|
app.post('/api/projects', async (req, res) => {
|
|
try {
|
|
const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } =
|
|
req.body || {};
|
|
if (typeof id !== 'string' || !isSafeId(id)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
|
}
|
|
if (typeof name !== 'string' || !name.trim()) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'name required');
|
|
}
|
|
// baseDir is privileged: it lets a project root directly inside the
|
|
// user's filesystem. The /api/import/folder endpoint is the only
|
|
// path that's allowed to set it, because that's where realpath() +
|
|
// RUNTIME_DATA_DIR reentry checks live. Block client-supplied
|
|
// metadata.baseDir on this generic create endpoint so an attacker
|
|
// can't smuggle e.g. /etc through here. Same rule for
|
|
// originalBaseDir / importedFrom='folder' — only the import path
|
|
// owns those state fields.
|
|
if (metadata && typeof metadata === 'object') {
|
|
if ('baseDir' in metadata) {
|
|
return sendApiError(
|
|
res, 400, 'BAD_REQUEST',
|
|
'baseDir can only be set via POST /api/import/folder',
|
|
);
|
|
}
|
|
if ('fromTrustedPicker' in metadata) {
|
|
return sendApiError(
|
|
res, 400, 'BAD_REQUEST',
|
|
'fromTrustedPicker can only be set via POST /api/import/folder',
|
|
);
|
|
}
|
|
}
|
|
if (customInstructions !== undefined
|
|
&& typeof customInstructions !== 'string'
|
|
&& customInstructions !== null) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions must be a string or null');
|
|
}
|
|
if (typeof customInstructions === 'string' && customInstructions.length > 5000) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
|
|
}
|
|
if (skipDiscoveryBrief !== undefined && typeof skipDiscoveryBrief !== 'boolean') {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'skipDiscoveryBrief must be a boolean');
|
|
}
|
|
const designSystemValidation = await validateProjectDesignSystemId(designSystemId);
|
|
if (!designSystemValidation.ok) {
|
|
return sendApiError(
|
|
res,
|
|
400,
|
|
designSystemValidation.code,
|
|
designSystemValidation.message,
|
|
);
|
|
}
|
|
const normalizedDesignSystemId = designSystemValidation.id;
|
|
const skillValidation = await validateProjectSkillId(skillId);
|
|
if (!skillValidation.ok) {
|
|
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
|
|
}
|
|
const normalizedSkillId = skillValidation.id;
|
|
const selectedLocationId = await resolveCreateProjectLocationId(projectLocationId);
|
|
let externalProjectDir: string | null = null;
|
|
if (selectedLocationId !== BUILT_IN_PROJECT_LOCATION_ID) {
|
|
const location = (await configuredProjectLocations()).find((loc: any) => loc.id === selectedLocationId);
|
|
if (!location || location.builtIn) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'unknown project location');
|
|
}
|
|
if (getProject(db, id)) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'project id already exists');
|
|
}
|
|
externalProjectDir = await createLocationProjectDir(location, id);
|
|
}
|
|
const projectMetadata =
|
|
metadata && typeof metadata === 'object'
|
|
? {
|
|
...metadata,
|
|
...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}),
|
|
...(externalProjectDir
|
|
? {
|
|
baseDir: externalProjectDir,
|
|
importedFrom: 'project-location',
|
|
projectLocationId: selectedLocationId,
|
|
}
|
|
: {}),
|
|
...(Array.isArray(metadata.linkedDirs)
|
|
? (() => {
|
|
const v = validateLinkedDirs(metadata.linkedDirs);
|
|
return v.error ? {} : { linkedDirs: v.dirs };
|
|
})()
|
|
: {}),
|
|
}
|
|
: skipDiscoveryBrief === true
|
|
? {
|
|
skipDiscoveryBrief: true,
|
|
...(externalProjectDir
|
|
? {
|
|
baseDir: externalProjectDir,
|
|
importedFrom: 'project-location',
|
|
projectLocationId: selectedLocationId,
|
|
}
|
|
: {}),
|
|
}
|
|
: externalProjectDir
|
|
? {
|
|
kind: 'prototype',
|
|
baseDir: externalProjectDir,
|
|
importedFrom: 'project-location',
|
|
projectLocationId: selectedLocationId,
|
|
}
|
|
: null;
|
|
const now = Date.now();
|
|
let project;
|
|
try {
|
|
if (externalProjectDir) {
|
|
await writeProjectManifest(externalProjectDir, {
|
|
schemaVersion: 1,
|
|
id,
|
|
name: name.trim(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
skillId: normalizedSkillId,
|
|
designSystemId: normalizedDesignSystemId,
|
|
});
|
|
}
|
|
project = insertProject(db, {
|
|
id,
|
|
name: name.trim(),
|
|
skillId: normalizedSkillId,
|
|
designSystemId: normalizedDesignSystemId,
|
|
pendingPrompt: pendingPrompt || null,
|
|
metadata: projectMetadata,
|
|
customInstructions:
|
|
typeof customInstructions === 'string'
|
|
? customInstructions
|
|
: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
} catch (err) {
|
|
if (externalProjectDir) {
|
|
await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
throw err;
|
|
}
|
|
// Seed a default conversation so the UI always has somewhere to write.
|
|
const cid = randomId();
|
|
insertConversation(db, {
|
|
id: cid,
|
|
projectId: id,
|
|
title: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
const explicitPlugin =
|
|
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
|
|
? true
|
|
: typeof req.body?.appliedPluginSnapshotId === 'string'
|
|
&& req.body.appliedPluginSnapshotId.trim().length > 0;
|
|
let resolveBody =
|
|
explicitPlugin ? (req.body as Record<string, unknown>) : null;
|
|
if (!resolveBody) {
|
|
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectMetadata);
|
|
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
|
|
resolveBody = { ...(req.body || {}), pluginId: fallbackPluginId };
|
|
}
|
|
}
|
|
let resolvedSnapshot = null;
|
|
if (resolveBody) {
|
|
const registry = await loadPluginRegistryView();
|
|
const resolved = resolvePluginSnapshot({
|
|
db,
|
|
body: resolveBody,
|
|
projectId: id,
|
|
conversationId: cid,
|
|
registry,
|
|
activeProjectDesignSystem:
|
|
typeof normalizedDesignSystemId === 'string' && normalizedDesignSystemId.length > 0
|
|
? { id: normalizedDesignSystemId }
|
|
: undefined,
|
|
connectorProbe: buildConnectorProbe(connectorService),
|
|
});
|
|
if (resolved && !resolved.ok) {
|
|
if (!explicitPlugin) {
|
|
console.warn(
|
|
`[plugins] default-scenario fallback skipped for project ${id}: ${resolved.body?.error?.code ?? 'unknown'}`,
|
|
);
|
|
} else {
|
|
return res.status(resolved.status).json(resolved.body);
|
|
}
|
|
} else {
|
|
resolvedSnapshot = resolved;
|
|
}
|
|
}
|
|
// For "from template" projects, seed the chosen template's snapshot
|
|
// HTML into the new project folder so the agent can Read/edit files
|
|
// on disk (the system prompt also embeds them, but a real on-disk
|
|
// copy lets the agent treat them as the project's working state).
|
|
if (
|
|
metadata &&
|
|
typeof metadata === 'object' &&
|
|
metadata.kind === 'template' &&
|
|
typeof metadata.templateId === 'string'
|
|
) {
|
|
const tpl = getTemplate(db, metadata.templateId);
|
|
if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) {
|
|
await ensureProject(PROJECTS_DIR, id, projectMetadata);
|
|
for (const f of tpl.files) {
|
|
if (
|
|
!f ||
|
|
typeof f.name !== 'string' ||
|
|
typeof f.content !== 'string'
|
|
) {
|
|
continue;
|
|
}
|
|
try {
|
|
await writeProjectFile(
|
|
PROJECTS_DIR,
|
|
id,
|
|
f.name,
|
|
Buffer.from(f.content, 'utf8'),
|
|
{},
|
|
projectMetadata,
|
|
);
|
|
} catch {
|
|
// Skip individual file failures — the template snapshot is
|
|
// best-effort; the agent still has the embedded copy.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/** @type {import('@open-design/contracts').CreateProjectResponse} */
|
|
const body = {
|
|
project: resolvedSnapshot?.ok ? getProject(db, id) ?? project : project,
|
|
conversationId: cid,
|
|
...(resolvedSnapshot?.ok
|
|
? { appliedPluginSnapshotId: resolvedSnapshot.snapshotId }
|
|
: {}),
|
|
};
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects/:id', async (req, res) => {
|
|
const project = getProject(db, req.params.id);
|
|
const locations = await configuredProjectLocations();
|
|
if (!project || !projectVisibleForLocations(project, locations))
|
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
|
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
|
|
/** @type {import('@open-design/contracts').ProjectResponse} */
|
|
const body = { project, resolvedDir };
|
|
res.json(body);
|
|
});
|
|
|
|
app.patch('/api/projects/:id', async (req, res) => {
|
|
try {
|
|
const patch = req.body || {};
|
|
// baseDir / folder-import state is privileged: it's set only by the
|
|
// import endpoint and otherwise immutable. Two failure modes to
|
|
// guard against here:
|
|
// 1. Explicit attempt to change baseDir → reject with 400.
|
|
// 2. A regular metadata patch that *omits* baseDir (e.g. a UI
|
|
// that only edits linkedDirs sends `{ metadata: { kind, linkedDirs } }`).
|
|
// updateProject() replaces metadata wholesale, so without
|
|
// preservation the existing baseDir gets wiped and the project
|
|
// detaches from the user's folder — subsequent reads/writes
|
|
// silently fall back to .od/projects/<id>.
|
|
// For case 2 we re-stamp the immutable fields from the existing
|
|
// project record onto the incoming patch so the user can keep
|
|
// patching other metadata without ever losing their import root.
|
|
if (patch.metadata && typeof patch.metadata === 'object') {
|
|
const existing = getProject(db, req.params.id);
|
|
const existingMeta = existing?.metadata;
|
|
if ('fromTrustedPicker' in patch.metadata
|
|
&& patch.metadata.fromTrustedPicker !== existingMeta?.fromTrustedPicker) {
|
|
return sendApiError(
|
|
res, 400, 'BAD_REQUEST',
|
|
'fromTrustedPicker can only be set via POST /api/import/folder',
|
|
);
|
|
}
|
|
if (existingMeta?.baseDir) {
|
|
if ('baseDir' in patch.metadata && patch.metadata.baseDir !== existingMeta.baseDir) {
|
|
return sendApiError(
|
|
res, 400, 'BAD_REQUEST',
|
|
'baseDir is immutable after import; use a new import to change it',
|
|
);
|
|
}
|
|
patch.metadata = {
|
|
...patch.metadata,
|
|
baseDir: existingMeta.baseDir,
|
|
...(existingMeta.importedFrom === 'folder'
|
|
? { importedFrom: 'folder' }
|
|
: {}),
|
|
...(existingMeta.importedFrom === 'project-location'
|
|
? { importedFrom: 'project-location' }
|
|
: {}),
|
|
...(typeof existingMeta.projectLocationId === 'string'
|
|
? { projectLocationId: existingMeta.projectLocationId }
|
|
: {}),
|
|
...(existingMeta.fromTrustedPicker === true
|
|
? { fromTrustedPicker: true as const }
|
|
: {}),
|
|
};
|
|
} else if ('baseDir' in patch.metadata) {
|
|
// Non-imported project trying to acquire a baseDir → reject (only
|
|
// /api/import/folder can set it).
|
|
return sendApiError(
|
|
res, 400, 'BAD_REQUEST',
|
|
'baseDir can only be set via POST /api/import/folder',
|
|
);
|
|
}
|
|
}
|
|
if (patch.metadata?.linkedDirs) {
|
|
const existing = getProject(db, req.params.id);
|
|
const validated = validateLinkedDirs(patch.metadata.linkedDirs);
|
|
if (validated.error) {
|
|
return sendApiError(res, 400, 'INVALID_LINKED_DIR', validated.error);
|
|
}
|
|
patch.metadata.linkedDirs =
|
|
existing?.metadata?.fromTrustedPicker === true
|
|
? patch.metadata.linkedDirs
|
|
: validated.dirs;
|
|
}
|
|
if (patch.customInstructions !== undefined
|
|
&& typeof patch.customInstructions !== 'string'
|
|
&& patch.customInstructions !== null) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions must be a string or null');
|
|
}
|
|
if (typeof patch.customInstructions === 'string' && patch.customInstructions.length > 5000) {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit');
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(patch, 'designSystemId')) {
|
|
const designSystemValidation = await validateProjectDesignSystemId(patch.designSystemId);
|
|
if (!designSystemValidation.ok) {
|
|
return sendApiError(
|
|
res,
|
|
400,
|
|
designSystemValidation.code,
|
|
designSystemValidation.message,
|
|
);
|
|
}
|
|
patch.designSystemId = designSystemValidation.id;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(patch, 'skillId')) {
|
|
const skillValidation = await validateProjectSkillId(patch.skillId);
|
|
if (!skillValidation.ok) {
|
|
return sendApiError(res, 400, skillValidation.code, skillValidation.message);
|
|
}
|
|
patch.skillId = skillValidation.id;
|
|
}
|
|
const project = updateProject(db, req.params.id, patch);
|
|
if (!project)
|
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
|
/** @type {import('@open-design/contracts').ProjectResponse} */
|
|
const body = { project };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
app.delete('/api/projects/:id', async (req, res) => {
|
|
try {
|
|
dbDeleteProject(db, req.params.id);
|
|
await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => {});
|
|
/** @type {import('@open-design/contracts').OkResponse} */
|
|
const body = { ok: true };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
// SSE stream of file-changed events for a project. Drives preview live-reload.
|
|
// Receipt of a `file-changed` event triggers a file-list refresh, which
|
|
// propagates new mtimes through to FileViewer iframes (the URL-load
|
|
// `?v=${mtime}` cache-bust from PR #384 then reloads the iframe automatically).
|
|
// Subscribers come and go as users open/close project tabs; the underlying
|
|
// chokidar watcher is refcounted in project-watchers.ts so we never hold
|
|
// descriptors for projects no UI is looking at.
|
|
app.get('/api/projects/:id/events', (req, res) => {
|
|
if (!getProject(db, req.params.id)) {
|
|
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
|
}
|
|
let sub: any;
|
|
try {
|
|
const sse = createSseResponse(res);
|
|
const projectEventSink = (payload: any) => {
|
|
sse.send(payload.type, payload);
|
|
};
|
|
let sinks = activeProjectEventSinks.get(req.params.id);
|
|
if (!sinks) {
|
|
sinks = new Set();
|
|
activeProjectEventSinks.set(req.params.id, sinks);
|
|
}
|
|
sinks.add(projectEventSink);
|
|
const watchProject = getProject(db, req.params.id);
|
|
sub = subscribeFileEvents(PROJECTS_DIR, req.params.id, (evt: any) => {
|
|
sse.send('file-changed', evt);
|
|
}, { metadata: watchProject?.metadata });
|
|
sub.ready.then(() => sse.send('ready', { projectId: req.params.id })).catch(() => {});
|
|
const cleanup = () => {
|
|
if (sub) {
|
|
const { unsubscribe } = sub;
|
|
sub = null;
|
|
Promise.resolve(unsubscribe()).catch(() => {});
|
|
}
|
|
const currentSinks = activeProjectEventSinks.get(req.params.id);
|
|
currentSinks?.delete(projectEventSink);
|
|
if (currentSinks?.size === 0) activeProjectEventSinks.delete(req.params.id);
|
|
};
|
|
res.on('close', cleanup);
|
|
res.on('finish', cleanup);
|
|
} catch (err: any) {
|
|
if (sub) Promise.resolve(sub.unsubscribe()).catch(() => {});
|
|
if (!res.headersSent) sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err));
|
|
}
|
|
});
|
|
|
|
// ---- Conversations --------------------------------------------------------
|
|
|
|
app.get('/api/projects/:id/conversations', (req, res) => {
|
|
if (!getProject(db, req.params.id)) {
|
|
return res.status(404).json({ error: 'project not found' });
|
|
}
|
|
res.json({ conversations: listConversations(db, req.params.id) });
|
|
});
|
|
|
|
app.post('/api/projects/:id/conversations', (req, res) => {
|
|
if (!getProject(db, req.params.id)) {
|
|
return res.status(404).json({ error: 'project not found' });
|
|
}
|
|
const { title } = req.body || {};
|
|
const now = Date.now();
|
|
const conv = insertConversation(db, {
|
|
id: randomId(),
|
|
projectId: req.params.id,
|
|
title: typeof title === 'string' ? title.trim() || null : null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
res.json({ conversation: conv });
|
|
});
|
|
|
|
app.patch('/api/projects/:id/conversations/:cid', (req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'not found' });
|
|
}
|
|
const updated = updateConversation(db, req.params.cid, req.body || {});
|
|
res.json({ conversation: updated });
|
|
});
|
|
|
|
app.delete('/api/projects/:id/conversations/:cid', (req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'not found' });
|
|
}
|
|
deleteConversation(db, req.params.cid);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ---- Messages -------------------------------------------------------------
|
|
|
|
app.get('/api/projects/:id/conversations/:cid/messages', (req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'conversation not found' });
|
|
}
|
|
res.json({ messages: listMessages(db, req.params.cid) });
|
|
});
|
|
|
|
app.put('/api/projects/:id/conversations/:cid/messages/:mid', (req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'conversation not found' });
|
|
}
|
|
const m = req.body || {};
|
|
if (m.id && m.id !== req.params.mid) {
|
|
return res.status(400).json({ error: 'id mismatch' });
|
|
}
|
|
const saved = upsertMessage(db, req.params.cid, {
|
|
...m,
|
|
id: req.params.mid,
|
|
});
|
|
// Bump the parent project's updatedAt so the project list re-orders.
|
|
updateProject(db, req.params.id, {});
|
|
ctx.telemetry?.reportFinalizedMessage(saved, m);
|
|
res.json({ message: saved });
|
|
});
|
|
|
|
// ---- Preview comments ----------------------------------------------------
|
|
|
|
app.get('/api/projects/:id/conversations/:cid/comments', (req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'conversation not found' });
|
|
}
|
|
res.json({
|
|
comments: listPreviewComments(db, req.params.id, req.params.cid),
|
|
});
|
|
});
|
|
|
|
app.post('/api/projects/:id/conversations/:cid/comments', (req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'conversation not found' });
|
|
}
|
|
try {
|
|
const comment = upsertPreviewComment(
|
|
db,
|
|
req.params.id,
|
|
req.params.cid,
|
|
req.body || {},
|
|
);
|
|
updateProject(db, req.params.id, {});
|
|
res.json({ comment });
|
|
} catch (err: any) {
|
|
res.status(400).json({ error: String(err?.message || err) });
|
|
}
|
|
});
|
|
|
|
app.patch(
|
|
'/api/projects/:id/conversations/:cid/comments/:commentId',
|
|
(req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'conversation not found' });
|
|
}
|
|
try {
|
|
const comment = updatePreviewCommentStatus(
|
|
db,
|
|
req.params.id,
|
|
req.params.cid,
|
|
req.params.commentId,
|
|
req.body?.status,
|
|
);
|
|
if (!comment)
|
|
return res.status(404).json({ error: 'comment not found' });
|
|
updateProject(db, req.params.id, {});
|
|
res.json({ comment });
|
|
} catch (err: any) {
|
|
res.status(400).json({ error: String(err?.message || err) });
|
|
}
|
|
},
|
|
);
|
|
|
|
app.delete(
|
|
'/api/projects/:id/conversations/:cid/comments/:commentId',
|
|
(req, res) => {
|
|
const conv = getConversation(db, req.params.cid);
|
|
if (!conv || conv.projectId !== req.params.id) {
|
|
return res.status(404).json({ error: 'conversation not found' });
|
|
}
|
|
const ok = deletePreviewComment(
|
|
db,
|
|
req.params.id,
|
|
req.params.cid,
|
|
req.params.commentId,
|
|
);
|
|
if (!ok) return res.status(404).json({ error: 'comment not found' });
|
|
updateProject(db, req.params.id, {});
|
|
res.json({ ok: true });
|
|
},
|
|
);
|
|
|
|
// ---- Tabs -----------------------------------------------------------------
|
|
|
|
app.get('/api/projects/:id/tabs', (req, res) => {
|
|
if (!getProject(db, req.params.id)) {
|
|
return res.status(404).json({ error: 'project not found' });
|
|
}
|
|
res.json(listTabs(db, req.params.id));
|
|
});
|
|
|
|
app.put('/api/projects/:id/tabs', (req, res) => {
|
|
if (!getProject(db, req.params.id)) {
|
|
return res.status(404).json({ error: 'project not found' });
|
|
}
|
|
const { tabs = [], active = null } = req.body || {};
|
|
if (!Array.isArray(tabs) || !tabs.every((t) => typeof t === 'string')) {
|
|
return res.status(400).json({ error: 'tabs must be string[]' });
|
|
}
|
|
const result = setTabs(
|
|
db,
|
|
req.params.id,
|
|
tabs,
|
|
typeof active === 'string' ? active : null,
|
|
);
|
|
res.json(result);
|
|
});
|
|
|
|
// ---- Templates ----------------------------------------------------------
|
|
// User-saved snapshots of a project's HTML files. Surfaced in the
|
|
// "From template" tab of the new-project panel so a user can spin up
|
|
// a fresh project pre-seeded with another project's design as a
|
|
// starting point. Created via the project's Share menu (snapshots
|
|
// every .html file in the project folder at the moment of save).
|
|
|
|
app.get('/api/templates', (_req, res) => {
|
|
res.json({ templates: listTemplates(db) });
|
|
});
|
|
|
|
app.get('/api/templates/:id', (req, res) => {
|
|
const t = getTemplate(db, req.params.id);
|
|
if (!t) return res.status(404).json({ error: 'not found' });
|
|
res.json({ template: t });
|
|
});
|
|
|
|
app.post('/api/templates', async (req, res) => {
|
|
try {
|
|
const { name, description, sourceProjectId } = req.body || {};
|
|
if (typeof name !== 'string' || !name.trim()) {
|
|
return res.status(400).json({ error: 'name required' });
|
|
}
|
|
if (name.length > 100) {
|
|
return res.status(400).json({ error: 'name must be 100 characters or fewer' });
|
|
}
|
|
if (typeof sourceProjectId !== 'string') {
|
|
return res.status(400).json({ error: 'sourceProjectId required' });
|
|
}
|
|
const sourceProject = getProject(db, sourceProjectId);
|
|
if (!sourceProject) {
|
|
return res.status(404).json({ error: 'source project not found' });
|
|
}
|
|
// Snapshot every HTML / sketch / text file in the source project.
|
|
// We deliberately skip binary uploads — templates are about the
|
|
// generated design, not the user's reference imagery.
|
|
const files = await listFiles(PROJECTS_DIR, sourceProjectId, {
|
|
metadata: sourceProject.metadata,
|
|
});
|
|
const snapshot = [];
|
|
for (const f of files) {
|
|
if (f.kind !== 'html' && f.kind !== 'text' && f.kind !== 'code')
|
|
continue;
|
|
const entry = await readProjectFile(
|
|
PROJECTS_DIR,
|
|
sourceProjectId,
|
|
f.name,
|
|
sourceProject.metadata,
|
|
);
|
|
if (entry && Buffer.isBuffer(entry.buffer)) {
|
|
snapshot.push({
|
|
name: f.name,
|
|
content: entry.buffer.toString('utf8'),
|
|
});
|
|
}
|
|
}
|
|
const trimmedName = name.trim();
|
|
const descValue = typeof description === 'string' ? description : null;
|
|
const existing = findTemplateByNameAndProject(db, trimmedName, sourceProjectId);
|
|
let t;
|
|
if (existing) {
|
|
t = updateTemplate(db, existing.id, {
|
|
description: descValue,
|
|
files: snapshot,
|
|
});
|
|
} else {
|
|
t = insertTemplate(db, {
|
|
id: randomId(),
|
|
name: trimmedName,
|
|
description: descValue,
|
|
sourceProjectId,
|
|
files: snapshot,
|
|
createdAt: Date.now(),
|
|
});
|
|
}
|
|
res.json({ template: t });
|
|
} catch (err: any) {
|
|
res.status(400).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/templates/:id', (req, res) => {
|
|
deleteTemplate(db, req.params.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
}
|
|
|
|
export interface RegisterProjectArtifactRoutesDeps extends RouteDeps<'http' | 'uploads' | 'paths' | 'node' | 'artifacts'> {}
|
|
|
|
export function registerProjectArtifactRoutes(app: Express, ctx: RegisterProjectArtifactRoutesDeps) {
|
|
const { upload } = ctx.uploads;
|
|
const { ARTIFACTS_DIR } = ctx.paths;
|
|
const { path, fs } = ctx.node;
|
|
const { sanitizeSlug, lintArtifact, renderFindingsForAgent } = ctx.artifacts;
|
|
app.post('/api/upload', upload.array('images', 8), (req, res) => {
|
|
const files = ((req.files || []) as any[]).map((f: any) => ({
|
|
name: f.originalname,
|
|
path: f.path,
|
|
size: f.size,
|
|
}));
|
|
res.json({ files });
|
|
});
|
|
|
|
// Persist a generated artifact (HTML) to disk so the user can re-open it
|
|
// in their browser or hand it off. Returns the on-disk path + a served URL.
|
|
// The body is also passed through the anti-slop linter; findings are
|
|
// returned alongside the path so the UI can render a P0/P1 badge and the
|
|
// chat layer can splice them into a system reminder for the agent.
|
|
app.post('/api/artifacts/save', (req, res) => {
|
|
try {
|
|
const { identifier, title, html } = req.body || {};
|
|
if (typeof html !== 'string' || html.length === 0) {
|
|
return res.status(400).json({ error: 'html required' });
|
|
}
|
|
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
|
const slug = sanitizeSlug(identifier || title || 'artifact');
|
|
const dir = path.join(ARTIFACTS_DIR, `${stamp}-${slug}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const file = path.join(dir, 'index.html');
|
|
fs.writeFileSync(file, html, 'utf8');
|
|
const findings = lintArtifact(html);
|
|
res.json({
|
|
path: file,
|
|
url: `/artifacts/${path.basename(dir)}/index.html`,
|
|
lint: findings,
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
// Standalone lint endpoint — POST raw HTML, get findings back.
|
|
// The chat layer uses this to lint streamed-in artifacts without writing
|
|
// them to disk first, so a P0 issue can be surfaced before save.
|
|
app.post('/api/artifacts/lint', (req, res) => {
|
|
try {
|
|
const { html } = req.body || {};
|
|
if (typeof html !== 'string' || html.length === 0) {
|
|
return res.status(400).json({ error: 'html required' });
|
|
}
|
|
const findings = lintArtifact(html);
|
|
res.json({
|
|
findings,
|
|
agentMessage: renderFindingsForAgent(findings),
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: String(err) });
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
export interface RegisterProjectFileRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'uploads' | 'node' | 'projectStore' | 'projectFiles' | 'documents' | 'artifacts'> {}
|
|
|
|
export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFileRoutesDeps) {
|
|
const { db } = ctx;
|
|
const { sendApiError, sendMulterError } = ctx.http;
|
|
const { PROJECTS_DIR } = ctx.paths;
|
|
const { upload } = ctx.uploads;
|
|
const { fs } = ctx.node;
|
|
const { getProject } = ctx.projectStore;
|
|
const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles;
|
|
const { buildDocumentPreview } = ctx.documents;
|
|
const { validateArtifactManifestInput } = ctx.artifacts;
|
|
const projectPreviewIframeSandbox = 'allow-scripts allow-forms';
|
|
const projectPreviewCsp = [
|
|
`sandbox ${projectPreviewIframeSandbox}`,
|
|
"default-src 'self' data: blob:",
|
|
"img-src 'self' data: blob:",
|
|
"media-src 'self' data: blob:",
|
|
"font-src 'self' data:",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
"connect-src 'none'",
|
|
"form-action 'none'",
|
|
"base-uri 'none'",
|
|
"object-src 'none'",
|
|
].join('; ');
|
|
const previewScopeRe = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
|
|
function setProjectPreviewHeaders(res: Response) {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('Content-Security-Policy', projectPreviewCsp);
|
|
}
|
|
|
|
async function sendProjectFile(
|
|
req: any,
|
|
res: Response,
|
|
projectId: string,
|
|
relPath: string,
|
|
metadata?: unknown,
|
|
beforeSend?: (mime: string) => void,
|
|
transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string,
|
|
) {
|
|
const meta = await resolveProjectFilePath(
|
|
PROJECTS_DIR,
|
|
projectId,
|
|
relPath,
|
|
metadata,
|
|
);
|
|
beforeSend?.(meta.mime);
|
|
|
|
if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) {
|
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
res.setHeader('Content-Type', meta.mime);
|
|
|
|
if (meta.size === 0) {
|
|
res.setHeader('Content-Length', '0');
|
|
return res.status(200).end();
|
|
}
|
|
|
|
const range = parseByteRange(req.headers.range, meta.size);
|
|
|
|
if (range === 'unsatisfiable') {
|
|
res.setHeader('Content-Range', `bytes */${meta.size}`);
|
|
return res.status(416).end();
|
|
}
|
|
|
|
let start;
|
|
let end;
|
|
let statusCode;
|
|
if (range) {
|
|
({ start, end } = range);
|
|
statusCode = 206;
|
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`);
|
|
res.setHeader('Content-Length', String(end - start + 1));
|
|
} else {
|
|
start = 0;
|
|
end = meta.size - 1;
|
|
statusCode = 200;
|
|
res.setHeader('Content-Length', String(meta.size));
|
|
}
|
|
|
|
res.status(statusCode);
|
|
const stream = fs.createReadStream(meta.filePath, { start, end });
|
|
stream.on('error', (streamErr: any) => {
|
|
if (!res.headersSent) {
|
|
sendApiError(res, 500, 'STREAM_ERROR', String(streamErr));
|
|
} else {
|
|
res.destroy(streamErr);
|
|
}
|
|
});
|
|
stream.pipe(res);
|
|
return;
|
|
}
|
|
|
|
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, metadata);
|
|
res.type(file.mime).send(transformFile ? transformFile(file) : file.buffer);
|
|
}
|
|
|
|
function previewFilePathForProject(project: any, queryFile: unknown): string {
|
|
if (typeof queryFile === 'string' && queryFile.trim().length > 0) {
|
|
return queryFile;
|
|
}
|
|
const entryFile = project?.metadata?.entryFile;
|
|
return typeof entryFile === 'string' && entryFile.length > 0 ? entryFile : 'index.html';
|
|
}
|
|
|
|
function encodeProjectPathForUrl(filePath: string): string {
|
|
return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/');
|
|
}
|
|
|
|
// Project files. Each project owns a flat folder under .od/projects/<id>/
|
|
// containing every file the user has uploaded, pasted, sketched, or that
|
|
// the agent has generated. Names are sanitized; paths are confined to the
|
|
// project's own folder (see apps/daemon/src/projects.ts).
|
|
app.get('/api/projects/:id/files', async (req, res) => {
|
|
try {
|
|
const since = Number(req.query?.since);
|
|
const project = getProject(db, req.params.id);
|
|
const files = await listFiles(PROJECTS_DIR, req.params.id, {
|
|
since: Number.isFinite(since) ? since : undefined,
|
|
metadata: project?.metadata,
|
|
});
|
|
/** @type {import('@open-design/contracts').ProjectFilesResponse} */
|
|
const body = { files };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects/:id/search', async (req, res) => {
|
|
try {
|
|
const query = String(req.query.q ?? '');
|
|
if (!query) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', 'q query parameter is required');
|
|
return;
|
|
}
|
|
const pattern = req.query.pattern ? String(req.query.pattern) : null;
|
|
const max = Math.min(Number(req.query.max) || 200, 1000);
|
|
const searchProject = getProject(db, req.params.id);
|
|
const matches = await searchProjectFiles(PROJECTS_DIR, req.params.id, query, {
|
|
pattern,
|
|
max,
|
|
metadata: searchProject?.metadata,
|
|
});
|
|
res.json({ query, matches });
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects/:id/design-system-package-audit', async (req, res) => {
|
|
try {
|
|
const project = getProject(db, req.params.id);
|
|
if (!project) {
|
|
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
return;
|
|
}
|
|
const projectRoot = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
|
const audit = await auditDesignSystemPackage(projectRoot);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.json({ audit });
|
|
} catch (err: any) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects/:id/preview-url', async (req, res) => {
|
|
try {
|
|
const project = getProject(db, req.params.id);
|
|
if (!project) {
|
|
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
return;
|
|
}
|
|
const requestedPath = previewFilePathForProject(project, req.query.file);
|
|
const meta = await resolveProjectFilePath(
|
|
PROJECTS_DIR,
|
|
project.id,
|
|
requestedPath,
|
|
project.metadata,
|
|
);
|
|
const scope = randomUUID();
|
|
/** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */
|
|
const body = {
|
|
url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`,
|
|
file: meta.name,
|
|
csp: projectPreviewCsp,
|
|
iframeSandbox: projectPreviewIframeSandbox,
|
|
opaqueOrigin: true,
|
|
};
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
});
|
|
|
|
app.get(/^\/api\/projects\/([^/]+)\/preview\/([^/]+)\/(.+)$/u, async (req, res) => {
|
|
try {
|
|
const params = req.params as unknown as { 0?: string; 1?: string; 2?: string };
|
|
const projectId = String(params[0] ?? '');
|
|
const scope = String(params[1] ?? '');
|
|
const relPath = String(params[2] ?? '');
|
|
if (!previewScopeRe.test(scope)) {
|
|
sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope');
|
|
return;
|
|
}
|
|
const project = getProject(db, projectId);
|
|
if (!project) {
|
|
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
|
|
return;
|
|
}
|
|
if (req.headers.origin === 'null') {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
}
|
|
await sendProjectFile(
|
|
req,
|
|
res,
|
|
project.id,
|
|
relPath,
|
|
project.metadata,
|
|
() => setProjectPreviewHeaders(res),
|
|
);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
});
|
|
|
|
|
|
// Preflight for the raw file route. Current artifact fetches are simple GETs
|
|
// (no preflight needed), but an explicit handler future-proofs the route if
|
|
// artifacts ever add custom request headers.
|
|
app.options(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, (req, res) => {
|
|
if (req.headers.origin === 'null') {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
res.header('Access-Control-Allow-Methods', 'GET');
|
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
}
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
app.get(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => {
|
|
try {
|
|
const params = req.params as unknown as { 0?: string; 1?: string };
|
|
const projectId = String(params[0] ?? '');
|
|
const relPath = String(params[1] ?? '');
|
|
const project = getProject(db, projectId);
|
|
// PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null".
|
|
// data: URIs, file://, and some sandboxed iframes also send null — all are
|
|
// local-only callers, so this is safe. Real cross-origin sites send a real
|
|
// origin and remain blocked by the browser's same-origin policy.
|
|
if (req.headers.origin === 'null') {
|
|
res.header('Access-Control-Allow-Origin', '*');
|
|
}
|
|
|
|
await sendProjectFile(
|
|
req,
|
|
res,
|
|
projectId,
|
|
relPath,
|
|
project?.metadata,
|
|
undefined,
|
|
(file) => {
|
|
if (
|
|
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
|
|
/^text\/html(?:;|$)/i.test(file.mime)
|
|
) {
|
|
return injectUrlPreviewScrollBridge(file.buffer.toString('utf8'));
|
|
}
|
|
return file.buffer;
|
|
},
|
|
);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
});
|
|
|
|
app.delete(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => {
|
|
try {
|
|
const params = req.params as unknown as { 0?: string; 1?: string };
|
|
const projectId = String(params[0] ?? '');
|
|
const rawSplat = String(params[1] ?? '');
|
|
const project = getProject(db, projectId);
|
|
await deleteProjectFile(PROJECTS_DIR, projectId, rawSplat, project?.metadata);
|
|
/** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
|
|
const body = { ok: true };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
});
|
|
|
|
app.get('/api/projects/:id/files/:name/preview', async (req, res) => {
|
|
try {
|
|
const project = getProject(db, req.params.id);
|
|
const file = await readProjectFile(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
req.params.name,
|
|
project?.metadata,
|
|
);
|
|
const preview = await buildDocumentPreview(file);
|
|
res.json(preview);
|
|
} catch (err: any) {
|
|
const status =
|
|
err && err.statusCode
|
|
? err.statusCode
|
|
: err && err.code === 'ENOENT'
|
|
? 404
|
|
: 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
err?.message || 'preview unavailable',
|
|
);
|
|
}
|
|
});
|
|
|
|
app.get(/^\/api\/projects\/([^/]+)\/files\/(.+)$/u, async (req, res) => {
|
|
try {
|
|
const params = req.params as unknown as { 0?: string; 1?: string };
|
|
const projectId = String(params[0] ?? '');
|
|
const fileSplat = String(params[1] ?? '');
|
|
const project = getProject(db, projectId);
|
|
const file = await readProjectFile(
|
|
PROJECTS_DIR,
|
|
projectId,
|
|
fileSplat,
|
|
project?.metadata,
|
|
);
|
|
res.type(file.mime).send(file.buffer);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
});
|
|
|
|
// Two ways to upload: multipart for binary files (images), and JSON
|
|
// {name, content, encoding} for sketches and pasted text. The frontend
|
|
// uses both depending on the file source.
|
|
app.post(
|
|
'/api/projects/:id/files',
|
|
(req, res, next) => {
|
|
upload.single('file')(req, res, (err: any) => {
|
|
if (err) return sendMulterError(res, err);
|
|
next();
|
|
});
|
|
},
|
|
async (req, res) => {
|
|
try {
|
|
const uploadProject = getProject(db, req.params.id);
|
|
await ensureProject(PROJECTS_DIR, req.params.id, uploadProject?.metadata);
|
|
if (req.file) {
|
|
const buf = await fs.promises.readFile(req.file.path);
|
|
const desiredName = sanitizeName(
|
|
req.body?.name || req.file.originalname,
|
|
);
|
|
const meta = await writeProjectFile(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
desiredName,
|
|
buf,
|
|
{},
|
|
uploadProject?.metadata,
|
|
);
|
|
fs.promises.unlink(req.file.path).catch(() => {});
|
|
/** @type {import('@open-design/contracts').ProjectFileResponse} */
|
|
const body = { file: meta };
|
|
return res.json(body);
|
|
}
|
|
const { name, content, encoding, artifactManifest, artifact, overwrite } = req.body || {};
|
|
if (typeof name !== 'string' || typeof content !== 'string') {
|
|
return sendApiError(
|
|
res,
|
|
400,
|
|
'BAD_REQUEST',
|
|
'name and content required',
|
|
);
|
|
}
|
|
if (artifactManifest !== undefined && artifactManifest !== null) {
|
|
const validated = validateArtifactManifestInput(
|
|
artifactManifest,
|
|
name,
|
|
);
|
|
if (!validated.ok) {
|
|
return sendApiError(
|
|
res,
|
|
400,
|
|
'BAD_REQUEST',
|
|
`invalid artifactManifest: ${validated.error}`,
|
|
);
|
|
}
|
|
}
|
|
const buf =
|
|
encoding === 'base64'
|
|
? Buffer.from(content, 'base64')
|
|
: Buffer.from(content, 'utf8');
|
|
const meta = artifact === true
|
|
? await createProjectArtifactFile({
|
|
projectsRoot: PROJECTS_DIR,
|
|
projectId: req.params.id,
|
|
input: { name, content, encoding, artifactManifest },
|
|
metadata: uploadProject?.metadata,
|
|
writeProjectFile,
|
|
})
|
|
: await writeProjectFile(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
name,
|
|
buf,
|
|
{
|
|
artifactManifest,
|
|
...(overwrite === false ? { overwrite: false } : {}),
|
|
},
|
|
uploadProject?.metadata,
|
|
);
|
|
/** @type {import('@open-design/contracts').ProjectFileResponse} */
|
|
const body = { file: meta };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
if (err instanceof ArtifactRegressionError) {
|
|
return sendApiError(res, 422, 'ARTIFACT_REGRESSION', err.message, {
|
|
details: {
|
|
identifier: err.identifier,
|
|
newSize: err.newSize,
|
|
priorSize: err.priorSize,
|
|
priorName: err.priorName,
|
|
},
|
|
});
|
|
}
|
|
if (err instanceof ArtifactPublicationBlockedError) {
|
|
return sendApiError(res, 422, 'ARTIFACT_PUBLICATION_BLOCKED', err.message, {
|
|
details: { placeholders: err.placeholders },
|
|
});
|
|
}
|
|
if (err?.code === 'EEXIST') {
|
|
return sendApiError(res, 409, 'FILE_EXISTS', 'file already exists');
|
|
}
|
|
if (err?.code === 'ARTIFACT_MANIFEST_REQUIRED') {
|
|
return sendApiError(res, 400, 'ARTIFACT_MANIFEST_REQUIRED', err.message);
|
|
}
|
|
if (err?.code === 'ARTIFACT_MANIFEST_INVALID') {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
|
|
}
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post('/api/projects/:id/files/rename', async (req, res) => {
|
|
try {
|
|
const { from, to } = req.body || {};
|
|
if (typeof from !== 'string' || typeof to !== 'string') {
|
|
return sendApiError(res, 400, 'BAD_REQUEST', 'from and to required');
|
|
}
|
|
const project = getProject(db, req.params.id);
|
|
const result = await renameProjectFile(
|
|
PROJECTS_DIR,
|
|
req.params.id,
|
|
from,
|
|
to,
|
|
project?.metadata,
|
|
);
|
|
/** @type {import('@open-design/contracts').RenameProjectFileResponse} */
|
|
const body = result;
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
if (err?.code === 'EEXIST') {
|
|
return sendApiError(res, 409, 'CONFLICT', String(err?.message || err));
|
|
}
|
|
const message = String(err?.message || err);
|
|
if (err?.code === 'ENOENT' || message.includes('ENOENT') || message.includes('no such file or directory')) {
|
|
return sendApiError(res, 404, 'FILE_NOT_FOUND', message);
|
|
}
|
|
sendApiError(res, 400, 'BAD_REQUEST', message);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/projects/:id/files/:name', async (req, res) => {
|
|
try {
|
|
const delProject = getProject(db, req.params.id);
|
|
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name, delProject?.metadata);
|
|
/** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
|
|
const body = { ok: true };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
|
sendApiError(
|
|
res,
|
|
status,
|
|
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
|
String(err),
|
|
);
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
export interface RegisterProjectUploadRoutesDeps extends RouteDeps<'http' | 'uploads' | 'node'> {}
|
|
|
|
export function registerProjectUploadRoutes(app: Express, ctx: RegisterProjectUploadRoutesDeps) {
|
|
const { sendApiError } = ctx.http;
|
|
const { handleProjectUpload } = ctx.uploads;
|
|
const { fs } = ctx.node;
|
|
|
|
app.post(
|
|
'/api/projects/:id/upload',
|
|
handleProjectUpload,
|
|
async (req, res) => {
|
|
try {
|
|
const incoming = Array.isArray(req.files) ? req.files : [];
|
|
const out = [];
|
|
for (const f of incoming) {
|
|
try {
|
|
const stat = await fs.promises.stat(f.path);
|
|
out.push({
|
|
name: f.filename,
|
|
path: f.filename,
|
|
size: stat.size,
|
|
mtime: stat.mtimeMs,
|
|
originalName: f.originalname,
|
|
});
|
|
} catch {
|
|
// skip files that vanished mid-flight
|
|
}
|
|
}
|
|
/** @type {import('@open-design/contracts').UploadProjectFilesResponse} */
|
|
const body = { files: out };
|
|
res.json(body);
|
|
} catch (err: any) {
|
|
sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
|
|
}
|
|
},
|
|
);
|
|
}
|