u
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user