This commit is contained in:
seb
2026-10-14 00:21:31 +02:00
parent 74b24686bc
commit f9ed4fe75d
18 changed files with 1508 additions and 279 deletions

View File

@@ -19,14 +19,13 @@ export function register(app, basePath = '') {
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: object
* backups:
* type: object
* $ref: '#/components/schemas/BackupOverviewResponse'
* 500:
* description: Internal error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /backup
// MARK: handles GET /backup to list backups and service status
@@ -54,9 +53,13 @@ export function register(app, basePath = '') {
* content:
* application/json:
* schema:
* type: object
* $ref: '#/components/schemas/BackupRunResult'
* 500:
* description: Backup execution failure
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /backup/run
// MARK: handles POST /backup/run to trigger manual backup

View File

@@ -53,6 +53,8 @@ export function register(app, basePath = '') {
* | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
* | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
* | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
* | `draft` | `{ sender, action, name, content }` | Draft update on `drafts/quote`, `drafts/order`, `drafts/delivery` (`sender` echoes the caller's `x-client-id`) |
* | `draft:request` | `{ requestId, action, name, target, sender }` | Server request to frontend to read a draft or list drafts |
*
* The `brand` payload is `{ sender, brandId }`: `sender` echoes the
* caller's `x-client-id` header (empty string if absent) so the sender
@@ -95,6 +97,10 @@ export function register(app, basePath = '') {
* payload: "{}"
* - name: brand
* payload: "{ sender, brandId }"
* - name: draft
* payload: "{ sender, action, name, content }"
* - name: draft:request
* payload: "{ requestId, action, name, target, sender }"
* responses:
* 200:
* description: |
@@ -157,5 +163,6 @@ export function handleSse(req, res) {
// Send an initial ping to establish frame
res.write(': ping\n\n');
subscribe(res, { companyId, userId: req.user?.id }, req);
const clientId = req.query?.cid || req.headers?.['x-client-id'] || null;
subscribe(res, { companyId, userId: req.user?.id, clientId }, req);
}

View File

@@ -4,6 +4,13 @@ import multer from 'multer';
import { emit } from '../sse.js';
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
import sharp from 'sharp';
import {
parseDraftPath,
requestDraftRead,
requestDraftList,
handleDraftResponse,
notifyDraftUpdate,
} from '../../services/draftManager.js';
// #region Helpers
@@ -465,7 +472,18 @@ export function register(app, basePath = '') {
}
return list;
}
const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name));
const diskFiles = walk(vfsRoot);
let drafts = [];
try {
drafts = await requestDraftList({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
});
} catch (err) {
console.error('Failed to request drafts from frontend:', err);
}
const files = [...diskFiles, ...drafts].sort((a, b) => a.name.localeCompare(b.name));
return res.json({ files });
} catch (error) {
const status = vfsFileErrorStatus(error);
@@ -580,6 +598,7 @@ export function register(app, basePath = '') {
const parsed = parseVfsRef(relPath);
if (parsed.error) continue;
if (isReadOnlyDatabasePath(parsed.name)) continue;
if (parseDraftPath(parsed.name).isFolder || parseDraftPath(parsed.name).isDraft) continue;
const absPath = path.resolve(vfsRoot, parsed.name);
if (!absPath.startsWith(vfsRoot)) continue;
@@ -680,6 +699,9 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
}
if (parseDraftPath(parsed.name).isFolder || parseDraftPath(parsed.name).isDraft) {
return res.status(403).json({ error: 'Cannot create directories in virtual drafts folder' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -788,6 +810,14 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
return res.status(403).json({ error: 'Cannot rename read-only database paths' });
}
if (
parseDraftPath(parsedOld.name).isFolder ||
parseDraftPath(parsedOld.name).isDraft ||
parseDraftPath(parsedNew.name).isFolder ||
parseDraftPath(parsedNew.name).isDraft
) {
return res.status(403).json({ error: 'Cannot rename virtual draft files or folders' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -931,6 +961,50 @@ export function register(app, basePath = '') {
return res.status(403).json({ error: 'Cannot modify read-only database files' });
}
const draftInfo = parseDraftPath(parsed.name);
if (draftInfo.isFolder) {
return res.status(400).json({ error: 'Cannot write directly to directory' });
}
if (draftInfo.isDraft) {
let strContent;
if (typeof req.body?.content === 'string') {
strContent = req.body.content;
} else if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
strContent = req.body.content !== undefined
? (typeof req.body.content === 'string' ? req.body.content : JSON.stringify(req.body.content))
: JSON.stringify(req.body);
} else if (Buffer.isBuffer(req.body)) {
strContent = req.body.toString('utf8');
} else if (typeof req.body === 'string') {
strContent = req.body;
} else {
return res.status(400).json({ error: 'No content provided' });
}
let parsedContent = strContent;
try {
parsedContent = JSON.parse(strContent);
} catch {}
const updated = notifyDraftUpdate({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
name: draftInfo.name,
content: parsedContent,
});
return res.json({
success: true,
file: {
name: `drafts/${draftInfo.name}`,
size: updated.size,
mime: 'application/json; charset=utf-8',
updated_at: updated.updatedAt,
},
});
}
let buffer;
let mime = parsed.mime;
if (typeof req.body?.content === 'string') {
@@ -1043,6 +1117,10 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot delete read-only database files' });
}
const draftDeleteInfo = parseDraftPath(parsed.name);
if (draftDeleteInfo.isFolder || draftDeleteInfo.isDraft) {
return res.status(403).json({ error: 'Cannot delete virtual draft files' });
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -1210,6 +1288,35 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: parsed.error });
}
const draftGetInfo = parseDraftPath(parsed.name);
if (draftGetInfo.isFolder) {
return res.status(400).json({ error: 'Cannot download a directory directly' });
}
if (draftGetInfo.isDraft) {
try {
const content = await requestDraftRead({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
name: draftGetInfo.name,
});
const strContent = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
const buffer = Buffer.from(strContent, 'utf8');
const effectiveMime = 'application/json; charset=utf-8';
const fallbackName = `${draftGetInfo.name}.json`;
res.setHeader('Content-Type', effectiveMime);
res.setHeader('Content-Length', String(buffer.length));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('Content-Disposition', `inline; filename="${fallbackName}"`);
return res.status(200).send(buffer);
} catch (err) {
const status = err.message?.includes('timed out') ? 504 : 404;
return res.status(status).json({ error: err.message });
}
}
try {
const vfsRoot = await resolveVfsRoot(req.user.id);
const absPath = path.resolve(vfsRoot, parsed.name);
@@ -1284,4 +1391,77 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /vfs/drafts/response:
* post:
* tags: [Vfs]
* summary: Respond to a frontend draft request
* description: |
* Used by the frontend to answer a server-initiated `draft:request` SSE event
* when reading or listing virtual drafts (`drafts/quote`, `drafts/order`, `drafts/delivery`).
* security:
* - cookieAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [requestId]
* properties:
* requestId:
* type: string
* description: Unique request ID from the draft:request SSE event
* content:
* description: File content when action was read
* error:
* type: string
* description: Error message if draft retrieval failed on frontend
* files:
* type: array
* description: File listing when action was list
* responses:
* 200:
* description: Response received and resolved
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* 400:
* description: Missing requestId or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Request ID not found or already timed out
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /vfs/drafts/response
app.post(`${basePath}/vfs/drafts/response`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { requestId, content, error, files } = req.body || {};
if (!requestId) {
return res.status(400).json({ error: 'requestId is required' });
}
const result = handleDraftResponse({ requestId, content, error, files });
if (!result.handled) {
return res.status(404).json({ error: result.error || 'Request expired or not found' });
}
return res.json({ success: true });
});
// #endregion
}