u
This commit is contained in:
@@ -207,7 +207,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
||||
| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` |
|
||||
| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` |
|
||||
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
|
||||
| [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` |
|
||||
| [`routes/vfs/mkdir.md`](routes/vfs/mkdir.md) | `POST /vfs/mkdir` |
|
||||
| [`routes/vfs/rename.md`](routes/vfs/rename.md) | `POST /vfs/rename` |
|
||||
| [`routes/vfs/upload.md`](routes/vfs/upload.md) | `POST /vfs/upload` |
|
||||
| [`routes/vfs/delete.md`](routes/vfs/delete.md) | `DELETE /vfs/{*path}` |
|
||||
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` |
|
||||
| [`routes/vfs/write.md`](routes/vfs/write.md) | `PUT /vfs/{*path}` |
|
||||
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
|
||||
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
|
||||
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |
|
||||
@@ -227,6 +233,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
|
||||
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight` |
|
||||
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}` |
|
||||
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
|
||||
| `vfs` | `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `PUT /vfs/{*path}` |
|
||||
|
||||
## DESIGN
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 183 API endpoints across all modules.
|
||||
Overview of all 189 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -575,9 +575,27 @@ _none_
|
||||
#### JTL-POS product catalog synchronization — `GET /v1/product`
|
||||
- Params: `query: lastChangedProduct?: str, limit?: int`
|
||||
- Returns: `200 obj[]`
|
||||
#### List virtual filesystem files — `GET /vfs`
|
||||
_none_
|
||||
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
|
||||
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
|
||||
- Params: `body: { path: str }`
|
||||
- Returns: `200 { success: bool, dir: str }`
|
||||
#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename`
|
||||
- Params: `body: { oldPath: str, newPath: str }`
|
||||
- Returns: `200 { success: bool, oldPath: str, newPath: str }`
|
||||
#### Upload files to virtual filesystem — `POST /vfs/upload`
|
||||
- Params: `form: { files: str[], targetDir?: str }`
|
||||
- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }`
|
||||
#### Delete a virtual filesystem file — `DELETE /vfs/{*path}`
|
||||
- Params: `path: str`
|
||||
- Returns: `200 { success: bool, deleted: str }`
|
||||
#### Get a virtual filesystem file — `GET /vfs/{*path}`
|
||||
- Params: `path: str, query: download?: bool, raw?: bool`
|
||||
- Returns: `200 str`
|
||||
#### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}`
|
||||
- Params: `path: str, body: { content: str, mime?: str }`
|
||||
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`
|
||||
#### List warehouses — `GET /warehouse`
|
||||
_none_
|
||||
- Returns: `200 { warehouses: T37[] }`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dash API Routes Overview
|
||||
|
||||
Overview of all 146 API endpoints across all modules.
|
||||
Overview of all 145 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -460,9 +460,6 @@ _none_
|
||||
#### Upsert unit — `PUT /unit`
|
||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||
- Returns: `200 { unit: { si: str, basesi: str, basesifactor: num, symbol: str, name: str } }`
|
||||
#### Get a virtual filesystem file — `GET /vfs/{*path}`
|
||||
- Params: `path: str, query: download?: bool, raw?: bool`
|
||||
- Returns: `200 str`
|
||||
#### List warehouses — `GET /warehouse`
|
||||
_none_
|
||||
- Returns: `200 { warehouses: T34[] }`
|
||||
|
||||
@@ -4,42 +4,70 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Get a virtual filesystem file
|
||||
|
||||
Returns a file the model produced in the `javascript_execute` sandbox
|
||||
(virtual filesystem), for the authenticated user's selected company.
|
||||
Returns a file from the virtual filesystem for the authenticated user's selected company.
|
||||
Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users.
|
||||
|
||||
Embed the returned URL in chat markdown to display a generated image:
|
||||
``.
|
||||
### URL Format
|
||||
Accepts:
|
||||
- Relative VFS paths: `example.png`, `reports/summary.pdf`
|
||||
- Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped)
|
||||
- Absolute URLs: `https://.../api/vfs/example.png`
|
||||
- Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400.
|
||||
|
||||
Images are returned as a 250x250 aspect-preserving preview unless requested with
|
||||
`download=1` or `raw=1`.
|
||||
Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
|
||||
inline so they open directly in the browser; other files are sent as downloads.
|
||||
### Image Previews & Thumbnails
|
||||
Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
|
||||
are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
|
||||
- Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13).
|
||||
- Images whose larger edge is below 100px are upscaled to 100px for icon legibility.
|
||||
- A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`)
|
||||
is rendered along the bottom edge, displaying original dimensions and file size.
|
||||
- Served with `Content-Security-Policy: default-src 'none'; sandbox`.
|
||||
- Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline.
|
||||
|
||||
### Inline vs Attachment
|
||||
- Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM)
|
||||
are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers.
|
||||
- Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`.
|
||||
- Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`.
|
||||
|
||||
### Caching & Conditional Requests
|
||||
- Generates weak ETags (`W/"thumb-..."` for previews, `W/"<timestamp>-<mode>"` for originals).
|
||||
- Sets `Cache-Control: private, max-age=0, must-revalidate`.
|
||||
- Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `path` | yes | string | VFS file path (e.g. `example.png`, `labels/a.png`) |
|
||||
| `path` | yes | string | VFS file path (e.g. `example.png`, `charts/graph.svg`) |
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `download` | no | boolean | Force file download with Content-Disposition: attachment.
|
||||
|
|
||||
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail.
|
||||
|
|
||||
| `download` | no | boolean | Force file download with Content-Disposition: attachment (1 or true). |
|
||||
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail (1 or true). |
|
||||
|
||||
## Response `200`
|
||||
|
||||
File bytes
|
||||
File bytes. Served inline for browser-renderable formats or as a download.
|
||||
Includes headers:
|
||||
- `Content-Type`: MIME type of file, or `image/webp` for previews
|
||||
- `Content-Length`: Size of served payload in bytes
|
||||
- `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."`
|
||||
- `Cache-Control`: `private, max-age=0, must-revalidate`
|
||||
- `ETag`: Weak ETag identifier
|
||||
- `X-Content-Type-Options`: `nosniff`
|
||||
- `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails)
|
||||
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid path, or no company selected — `{"error":"…"}` |
|
||||
| 304 | Not modified (If-None-Match matched ETag) |
|
||||
| 400 | Invalid path, path escapes VFS boundary, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | File not found — `{"error":"…"}` |
|
||||
|
||||
42
docs/api/routes/vfs/delete.md
Normal file
42
docs/api/routes/vfs/delete.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# `DELETE /vfs/{*path}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Delete a virtual filesystem file
|
||||
|
||||
Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company.
|
||||
Deleting read-only database files (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `path` | yes | string | VFS file path to delete (e.g. `temp.png`, `notes.txt`) |
|
||||
|
||||
## Signals
|
||||
|
||||
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
|
||||
|
||||
## Response `200`
|
||||
|
||||
File successfully deleted
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"deleted": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Invalid path, path traversal, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized, or cannot delete read-only database files — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
43
docs/api/routes/vfs/list.md
Normal file
43
docs/api/routes/vfs/list.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# `GET /vfs`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
List virtual filesystem files
|
||||
|
||||
Lists all virtual filesystem files for the authenticated user's selected company.
|
||||
Scans directories recursively (ignoring hidden dotfiles) and returns file
|
||||
metadata including path, byte size, detected MIME type, timestamps, and classification flags.
|
||||
|
||||
|
||||
## Response `200`
|
||||
|
||||
List of virtual filesystem files
|
||||
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"name": "…",
|
||||
"size": 1,
|
||||
"mime": "…",
|
||||
"updated_at": "2026-08-10T00:00:00.000Z",
|
||||
"created_at": "2026-08-10T00:00:00.000Z",
|
||||
"isText": true,
|
||||
"isImage": true,
|
||||
"isPdf": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | No company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
48
docs/api/routes/vfs/mkdir.md
Normal file
48
docs/api/routes/vfs/mkdir.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# `POST /vfs/mkdir`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Create directory in virtual filesystem
|
||||
|
||||
Creates a directory (and any necessary parent directories) in the virtual filesystem
|
||||
for the authenticated user's selected company.
|
||||
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `path` | yes | string | Directory path to create (e.g. `reports/2026/q1`) |
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Signals
|
||||
|
||||
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
|
||||
|
||||
## Response `200`
|
||||
|
||||
Directory created successfully
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"dir": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized, or cannot create directory inside read-only database paths — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
52
docs/api/routes/vfs/rename.md
Normal file
52
docs/api/routes/vfs/rename.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# `POST /vfs/rename`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Rename or move a virtual filesystem file or directory
|
||||
|
||||
Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company.
|
||||
Parent directories for the destination path are created automatically if needed.
|
||||
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `oldPath` | yes | string | Existing VFS file or directory path (e.g. `draft.txt`) |
|
||||
| `newPath` | yes | string | New destination VFS path (e.g. `archive/final.txt`) |
|
||||
|
||||
```json
|
||||
{
|
||||
"oldPath": "…",
|
||||
"newPath": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Signals
|
||||
|
||||
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
|
||||
|
||||
## Response `200`
|
||||
|
||||
File or directory successfully renamed
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"oldPath": "…",
|
||||
"newPath": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Path is required, invalid characters, traversal, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized, or cannot rename read-only database paths — `{"error":"…"}` |
|
||||
| 404 | Source file or directory not found — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
53
docs/api/routes/vfs/upload.md
Normal file
53
docs/api/routes/vfs/upload.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# `POST /vfs/upload`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Upload files to virtual filesystem
|
||||
|
||||
Uploads one or more files to the virtual filesystem for the authenticated user's selected company.
|
||||
Files are written to the company's VFS root directory and saved to the database.
|
||||
An optional `targetDir` form field specifies a subfolder within the VFS.
|
||||
|
||||
Writing to read-only database paths (`image/*` or filenames containing 64-character hex hashes) is
|
||||
forbidden and automatically skipped during upload.
|
||||
|
||||
|
||||
## Body (`multipart/form-data`)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `files` | yes | file | One or more files to upload (form field name `files`) |
|
||||
| `targetDir` | no | string | Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`) |
|
||||
|
||||
## Signals
|
||||
|
||||
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
|
||||
|
||||
## Response `200`
|
||||
|
||||
Successfully uploaded files
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"uploaded": [
|
||||
{
|
||||
"name": "…",
|
||||
"size": 1,
|
||||
"mime": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | No files uploaded, invalid path, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
66
docs/api/routes/vfs/write.md
Normal file
66
docs/api/routes/vfs/write.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# `PUT /vfs/{*path}`
|
||||
|
||||
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||
|
||||
Create or overwrite a virtual filesystem file
|
||||
|
||||
Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company.
|
||||
Supports multiple request payload formats:
|
||||
- JSON body: `{"content": "text content", "mime": "optional/mime"}`
|
||||
- Binary body: raw binary buffer with optional `Content-Type` header
|
||||
- Plain text: raw UTF-8 string
|
||||
|
||||
Modifying read-only database paths (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
|
||||
|
||||
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `path` | yes | string | Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`) |
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `content` | yes | string | File text content |
|
||||
| `mime` | no | string | Optional MIME type override |
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "…",
|
||||
"mime": "…"
|
||||
}
|
||||
```
|
||||
|
||||
## Signals
|
||||
|
||||
On success, emits SSE signal: `vfs` (see [`GET /events`](../events.md)).
|
||||
|
||||
## Response `200`
|
||||
|
||||
File successfully written
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file": {
|
||||
"name": "…",
|
||||
"size": 1,
|
||||
"mime": "…",
|
||||
"updated_at": "2026-08-10T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Path is required, invalid characters/traversal, no content provided, or no company selected — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized, or cannot modify read-only database files — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
@@ -50,6 +50,9 @@ function isRouteOmitted(method, apiPath) {
|
||||
if (apiPath === '/public' || apiPath.startsWith('/public/')) {
|
||||
return true;
|
||||
}
|
||||
if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) {
|
||||
return true;
|
||||
}
|
||||
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1271,6 +1271,36 @@
|
||||
* type: string
|
||||
* sort:
|
||||
* type: integer
|
||||
* VfsFile:
|
||||
* type: object
|
||||
* required: [name, size, mime, updated_at, created_at, isText, isImage, isPdf]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Relative file path within the virtual filesystem
|
||||
* size:
|
||||
* type: integer
|
||||
* description: File size in bytes
|
||||
* mime:
|
||||
* type: string
|
||||
* description: Detected or declared MIME type
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: ISO timestamp of last modification
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: ISO timestamp of creation
|
||||
* isText:
|
||||
* type: boolean
|
||||
* description: True if file is text, code, JSON, Markdown, or tabular data
|
||||
* isImage:
|
||||
* type: boolean
|
||||
* description: True if file is a supported raster image eligible for preview thumbnailing
|
||||
* isPdf:
|
||||
* type: boolean
|
||||
* description: True if file is a PDF document
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
@@ -46,6 +46,7 @@ export const openApiOptions = {
|
||||
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
|
||||
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
|
||||
{ name: 'PublicCatalog', description: 'Public product catalog API' },
|
||||
{ name: 'Vfs', description: 'Virtual filesystem file management and preview' },
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
|
||||
@@ -2,10 +2,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import multer from 'multer';
|
||||
import { emit } from '../sse.js';
|
||||
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js';
|
||||
import * as db from '../../db/modelvfs.js';
|
||||
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
|
||||
import sharp from 'sharp';
|
||||
import { getMimeType } from '../../tools/vfs.js';
|
||||
|
||||
// Thumbnail box. The image is fitted inside this box preserving aspect ratio; it is never
|
||||
// stretched, so a 2000x100 image becomes 250x13 and not 250x250.
|
||||
@@ -379,61 +377,32 @@ export function register(app, basePath = '') {
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/{*path}:
|
||||
* /vfs:
|
||||
* get:
|
||||
* tags: [Vfs]
|
||||
* summary: Get a virtual filesystem file
|
||||
* summary: List virtual filesystem files
|
||||
* description: |
|
||||
* Returns a file the model produced in the `javascript_execute` sandbox
|
||||
* (virtual filesystem), for the authenticated user's selected company.
|
||||
*
|
||||
* Embed the returned URL in chat markdown to display a generated image:
|
||||
* ``.
|
||||
*
|
||||
* Images are returned as a 250x250 aspect-preserving preview unless requested with
|
||||
* `download=1` or `raw=1`.
|
||||
* Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
|
||||
* inline so they open directly in the browser; other files are sent as downloads.
|
||||
* Lists all virtual filesystem files for the authenticated user's selected company.
|
||||
* Scans directories recursively (ignoring hidden dotfiles) and returns file
|
||||
* metadata including path, byte size, detected MIME type, timestamps, and classification flags.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: VFS file path (e.g. `example.png`, `labels/a.png`)
|
||||
* - in: query
|
||||
* name: download
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: |
|
||||
* Force file download with Content-Disposition: attachment.
|
||||
* - in: query
|
||||
* name: raw
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: |
|
||||
* Return original unmodified image inline without generating a preview thumbnail.
|
||||
* x-markdown: vfs/list.md
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File bytes
|
||||
* headers:
|
||||
* Content-Disposition:
|
||||
* schema:
|
||||
* type: string
|
||||
* Cache-Control:
|
||||
* schema:
|
||||
* type: string
|
||||
* description: List of virtual filesystem files
|
||||
* content:
|
||||
* application/octet-stream:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* type: object
|
||||
* required: [files]
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/VfsFile'
|
||||
* 400:
|
||||
* description: Invalid path, or no company selected
|
||||
* description: No company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -450,14 +419,7 @@ export function register(app, basePath = '') {
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: File not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
app.get(`${basePath}/vfs`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
@@ -506,6 +468,85 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/upload:
|
||||
* post:
|
||||
* tags: [Vfs]
|
||||
* summary: Upload files to virtual filesystem
|
||||
* description: |
|
||||
* Uploads one or more files to the virtual filesystem for the authenticated user's selected company.
|
||||
* Files are written to the company's VFS root directory and saved to the database.
|
||||
* An optional `targetDir` form field specifies a subfolder within the VFS.
|
||||
*
|
||||
* Writing to read-only database paths (`image/*` or filenames containing 64-character hex hashes) is
|
||||
* forbidden and automatically skipped during upload.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [vfs]
|
||||
* x-markdown: vfs/upload.md
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [files]
|
||||
* properties:
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: One or more files to upload (form field name `files`)
|
||||
* targetDir:
|
||||
* type: string
|
||||
* description: Optional destination subdirectory within VFS (e.g. `reports` or `charts/q1`)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Successfully uploaded files
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [success, uploaded]
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* uploaded:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required: [name, size, mime]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Stored relative path in VFS
|
||||
* size:
|
||||
* type: integer
|
||||
* description: Size in bytes
|
||||
* mime:
|
||||
* type: string
|
||||
* description: Effective MIME type
|
||||
* 400:
|
||||
* description: No files uploaded, invalid path, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
@@ -531,6 +572,7 @@ export function register(app, basePath = '') {
|
||||
if (isReadOnlyDatabasePath(parsed.name)) continue;
|
||||
|
||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||
if (!absPath.startsWith(vfsRoot)) continue;
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, f.buffer);
|
||||
|
||||
@@ -538,12 +580,6 @@ export function register(app, basePath = '') {
|
||||
? f.mimetype
|
||||
: getMimeType(parsed.name);
|
||||
|
||||
await db.write(req.user.id, {
|
||||
name: parsed.name,
|
||||
mime: effectiveMime,
|
||||
content: f.buffer,
|
||||
});
|
||||
|
||||
uploaded.push({
|
||||
name: parsed.name,
|
||||
size: f.buffer.length,
|
||||
@@ -560,6 +596,305 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/mkdir:
|
||||
* post:
|
||||
* tags: [Vfs]
|
||||
* summary: Create directory in virtual filesystem
|
||||
* description: |
|
||||
* Creates a directory (and any necessary parent directories) in the virtual filesystem
|
||||
* for the authenticated user's selected company.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [vfs]
|
||||
* x-markdown: vfs/mkdir.md
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [path]
|
||||
* properties:
|
||||
* path:
|
||||
* type: string
|
||||
* description: Directory path to create (e.g. `reports/2026/q1`)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Directory created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [success, dir]
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* dir:
|
||||
* type: string
|
||||
* description: Created directory path
|
||||
* 400:
|
||||
* description: Path is required, invalid characters, traversal, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized, or cannot create directory inside read-only database paths
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.post(`${basePath}/vfs/mkdir`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
const raw = req.body?.path || req.body?.dir || req.body?.name;
|
||||
const parsed = parseVfsRef(raw);
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
if (isReadOnlyDatabasePath(parsed.name)) {
|
||||
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
|
||||
}
|
||||
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||
if (!absPath.startsWith(vfsRoot)) {
|
||||
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
|
||||
}
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
|
||||
emit('vfs', { companyId: selected.companyId });
|
||||
return res.json({ success: true, dir: parsed.name });
|
||||
} catch (error) {
|
||||
const status = vfsFileErrorStatus(error);
|
||||
if (status === 500) console.error('Create vfs directory failed:', error);
|
||||
return res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/rename:
|
||||
* post:
|
||||
* tags: [Vfs]
|
||||
* summary: Rename or move a virtual filesystem file or directory
|
||||
* description: |
|
||||
* Renames or moves a file or directory within the virtual filesystem for the authenticated user's selected company.
|
||||
* Parent directories for the destination path are created automatically if needed.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [vfs]
|
||||
* x-markdown: vfs/rename.md
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [oldPath, newPath]
|
||||
* properties:
|
||||
* oldPath:
|
||||
* type: string
|
||||
* description: Existing VFS file or directory path (e.g. `draft.txt`)
|
||||
* newPath:
|
||||
* type: string
|
||||
* description: New destination VFS path (e.g. `archive/final.txt`)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File or directory successfully renamed
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [success, oldPath, newPath]
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newPath:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Path is required, invalid characters, traversal, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized, or cannot rename read-only database paths
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Source file or directory not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.post(`${basePath}/vfs/rename`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
const rawOld = req.body?.oldPath || req.body?.from || req.body?.source;
|
||||
const rawNew = req.body?.newPath || req.body?.to || req.body?.target;
|
||||
const parsedOld = parseVfsRef(rawOld);
|
||||
if (parsedOld.error) {
|
||||
return res.status(400).json({ error: `oldPath: ${parsedOld.error}` });
|
||||
}
|
||||
const parsedNew = parseVfsRef(rawNew);
|
||||
if (parsedNew.error) {
|
||||
return res.status(400).json({ error: `newPath: ${parsedNew.error}` });
|
||||
}
|
||||
|
||||
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
|
||||
return res.status(403).json({ error: 'Cannot rename read-only database paths' });
|
||||
}
|
||||
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
const absOld = path.resolve(vfsRoot, parsedOld.name);
|
||||
const absNew = path.resolve(vfsRoot, parsedNew.name);
|
||||
|
||||
if (!absOld.startsWith(vfsRoot) || !absNew.startsWith(vfsRoot)) {
|
||||
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absOld)) {
|
||||
return res.status(404).json({ error: 'Source file or directory not found' });
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(absNew), { recursive: true });
|
||||
fs.renameSync(absOld, absNew);
|
||||
|
||||
emit('vfs', { companyId: selected.companyId });
|
||||
return res.json({
|
||||
success: true,
|
||||
oldPath: parsedOld.name,
|
||||
newPath: parsedNew.name,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = vfsFileErrorStatus(error);
|
||||
if (status === 500) console.error('Rename vfs file failed:', error);
|
||||
return res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/{*path}:
|
||||
* put:
|
||||
* tags: [Vfs]
|
||||
* summary: Create or overwrite a virtual filesystem file
|
||||
* description: |
|
||||
* Creates or overwrites a file at the specified virtual filesystem path for the authenticated user's selected company.
|
||||
* Supports multiple request payload formats:
|
||||
* - JSON body: `{"content": "text content", "mime": "optional/mime"}`
|
||||
* - Binary body: raw binary buffer with optional `Content-Type` header
|
||||
* - Plain text: raw UTF-8 string
|
||||
*
|
||||
* Modifying read-only database paths (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [vfs]
|
||||
* x-markdown: vfs/write.md
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Destination VFS file path (e.g. `notes.txt`, `data/metrics.json`)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [content]
|
||||
* properties:
|
||||
* content:
|
||||
* type: string
|
||||
* description: File text content
|
||||
* mime:
|
||||
* type: string
|
||||
* description: Optional MIME type override
|
||||
* application/octet-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Raw binary payload
|
||||
* text/plain:
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Raw text payload
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File successfully written
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [success, file]
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* file:
|
||||
* type: object
|
||||
* required: [name, size, mime, updated_at]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: VFS file path
|
||||
* size:
|
||||
* type: integer
|
||||
* description: Written byte length
|
||||
* mime:
|
||||
* type: string
|
||||
* description: Stored MIME type
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: ISO timestamp of update
|
||||
* 400:
|
||||
* description: Path is required, invalid characters/traversal, no content provided, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized, or cannot modify read-only database files
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.put(`${basePath}/vfs/*path`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
@@ -593,15 +928,12 @@ export function register(app, basePath = '') {
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||
if (!absPath.startsWith(vfsRoot)) {
|
||||
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
|
||||
}
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, buffer);
|
||||
|
||||
await db.write(req.user.id, {
|
||||
name: parsed.name,
|
||||
mime,
|
||||
content: buffer,
|
||||
});
|
||||
|
||||
emit('vfs', { companyId: selected.companyId });
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -619,6 +951,59 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/{*path}:
|
||||
* delete:
|
||||
* tags: [Vfs]
|
||||
* summary: Delete a virtual filesystem file
|
||||
* description: |
|
||||
* Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company.
|
||||
* Deleting read-only database files (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [vfs]
|
||||
* x-markdown: vfs/delete.md
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: VFS file path to delete (e.g. `temp.png`, `notes.txt`)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File successfully deleted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [success, deleted]
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* deleted:
|
||||
* type: string
|
||||
* description: Path of deleted file
|
||||
* 400:
|
||||
* description: Invalid path, path traversal, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized, or cannot delete read-only database files
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.delete(`${basePath}/vfs/*path`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
@@ -636,10 +1021,18 @@ export function register(app, basePath = '') {
|
||||
try {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||
if (fs.existsSync(absPath)) {
|
||||
try { fs.unlinkSync(absPath); } catch {}
|
||||
if (!absPath.startsWith(vfsRoot)) {
|
||||
return res.status(400).json({ error: 'path escapes virtual filesystem boundary' });
|
||||
}
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
const stat = fs.statSync(absPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(absPath, { recursive: true, force: true });
|
||||
} else {
|
||||
fs.unlinkSync(absPath);
|
||||
}
|
||||
await db.remove(req.user.id, parsed.name);
|
||||
|
||||
emit('vfs', { companyId: selected.companyId });
|
||||
return res.json({ success: true, deleted: parsed.name });
|
||||
@@ -650,6 +1043,131 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vfs/{*path}:
|
||||
* get:
|
||||
* tags: [Vfs]
|
||||
* summary: Get a virtual filesystem file
|
||||
* description: |
|
||||
* Returns a file from the virtual filesystem for the authenticated user's selected company.
|
||||
* Commonly used to retrieve artifacts generated by the model in the `javascript_execute` sandbox or uploaded by users.
|
||||
*
|
||||
* ### URL Format
|
||||
* Accepts:
|
||||
* - Relative VFS paths: `example.png`, `reports/summary.pdf`
|
||||
* - Full endpoint paths: `/vfs/example.png`, `/api/vfs/example.png` (leading `api/` and `vfs/` prefixes are automatically stripped)
|
||||
* - Absolute URLs: `https://.../api/vfs/example.png`
|
||||
* - Traversal (`..`), null bytes (`\0`), and backslashes (`\`) are strictly forbidden and rejected with HTTP 400.
|
||||
*
|
||||
* ### Image Previews & Thumbnails
|
||||
* Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
|
||||
* are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
|
||||
* - Scaled to fit within a 250×250 box without distortion (e.g. 2000×100 becomes 250×13).
|
||||
* - Images whose larger edge is below 100px are upscaled to 100px for icon legibility.
|
||||
* - A translucent dark metadata strip (`#101317`, max 50% height) with monospace text (`DejaVu Sans Mono`)
|
||||
* is rendered along the bottom edge, displaying original dimensions and file size.
|
||||
* - Served with `Content-Security-Policy: default-src 'none'; sandbox`.
|
||||
* - Use `?raw=1` (or `?raw=true`) to bypass preview generation and return the original unmodified image inline.
|
||||
*
|
||||
* ### Inline vs Attachment
|
||||
* - Browser-compatible types (HTML, CSS, JS, JSON, PDF, text, images, audio, video, fonts, WASM)
|
||||
* are served with `Content-Disposition: inline; filename="..."` so they open directly in modern browsers.
|
||||
* - Non-browser/binary types default to `Content-Disposition: attachment; filename="..."`.
|
||||
* - Use `?download=1` (or `?download=true`) to force file download with `Content-Disposition: attachment`.
|
||||
*
|
||||
* ### Caching & Conditional Requests
|
||||
* - Generates weak ETags (`W/"thumb-..."` for previews, `W/"<timestamp>-<mode>"` for originals).
|
||||
* - Sets `Cache-Control: private, max-age=0, must-revalidate`.
|
||||
* - Supports `If-None-Match` conditional requests returning HTTP `304 Not Modified`.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-markdown: vfs/_*path.md
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: VFS file path (e.g. `example.png`, `charts/graph.svg`)
|
||||
* - in: query
|
||||
* name: download
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: 'Force file download with Content-Disposition: attachment (1 or true).'
|
||||
* - in: query
|
||||
* name: raw
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: 'Return original unmodified image inline without generating a preview thumbnail (1 or true).'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: |
|
||||
* File bytes. Served inline for browser-renderable formats or as a download.
|
||||
* Includes headers:
|
||||
* - `Content-Type`: MIME type of file, or `image/webp` for previews
|
||||
* - `Content-Length`: Size of served payload in bytes
|
||||
* - `Content-Disposition`: `inline; filename="..."` or `attachment; filename="..."`
|
||||
* - `Cache-Control`: `private, max-age=0, must-revalidate`
|
||||
* - `ETag`: Weak ETag identifier
|
||||
* - `X-Content-Type-Options`: `nosniff`
|
||||
* - `Content-Security-Policy`: `default-src 'none'; sandbox` (for preview thumbnails)
|
||||
* headers:
|
||||
* Content-Type:
|
||||
* schema:
|
||||
* type: string
|
||||
* Content-Length:
|
||||
* schema:
|
||||
* type: string
|
||||
* Content-Disposition:
|
||||
* schema:
|
||||
* type: string
|
||||
* Cache-Control:
|
||||
* schema:
|
||||
* type: string
|
||||
* ETag:
|
||||
* schema:
|
||||
* type: string
|
||||
* X-Content-Type-Options:
|
||||
* schema:
|
||||
* type: string
|
||||
* Content-Security-Policy:
|
||||
* schema:
|
||||
* type: string
|
||||
* content:
|
||||
* application/octet-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* 304:
|
||||
* description: Not modified (If-None-Match matched ETag)
|
||||
* 400:
|
||||
* description: Invalid path, path escapes VFS boundary, or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Not authorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: File not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
app.get(`${basePath}/vfs/*path`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
@@ -663,15 +1181,20 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await db.read(req.user.id, parsed.name);
|
||||
if (!file?.content?.length) {
|
||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||
if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath)) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
const stat = fs.statSync(absPath);
|
||||
if (stat.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Cannot download a directory directly' });
|
||||
}
|
||||
const content = fs.readFileSync(absPath);
|
||||
const updatedAt = stat.mtime.toISOString();
|
||||
|
||||
const fallbackName = parsed.name.split('/').pop() || 'download';
|
||||
const effectiveMime = (file.mime && file.mime !== 'application/octet-stream')
|
||||
? file.mime
|
||||
: getMimeType(parsed.name);
|
||||
const effectiveMime = getMimeType(parsed.name);
|
||||
|
||||
const wantsDownload = req.query.download === '1' || req.query.download === 'true';
|
||||
const wantsRaw = req.query.raw === '1' || req.query.raw === 'true';
|
||||
@@ -681,7 +1204,7 @@ export function register(app, basePath = '') {
|
||||
// unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1).
|
||||
const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
|
||||
if (shouldThumbnail) {
|
||||
const rendered = await renderThumbnail(file.content, {
|
||||
const rendered = await renderThumbnail(content, {
|
||||
mime: effectiveMime,
|
||||
filename: fallbackName,
|
||||
}).catch((err) => {
|
||||
@@ -696,7 +1219,7 @@ export function register(app, basePath = '') {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
|
||||
// Previews are derived data — never cache them immutably, the source file can change.
|
||||
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
res.setHeader('ETag', `W/"thumb-${file.updated_at || file.name}"`);
|
||||
res.setHeader('ETag', `W/"thumb-${updatedAt || parsed.name}"`);
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
|
||||
return res.status(304).end();
|
||||
@@ -707,11 +1230,11 @@ export function register(app, basePath = '') {
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', effectiveMime);
|
||||
res.setHeader('Content-Length', String(file.content.length));
|
||||
res.setHeader('Content-Length', String(content.length));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
// Mutable VFS artifacts are cached with revalidation
|
||||
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
res.setHeader('ETag', `W/"${file.updated_at || file.name}-${isInline ? 'i' : 'a'}"`);
|
||||
res.setHeader('ETag', `W/"${updatedAt || parsed.name}-${isInline ? 'i' : 'a'}"`);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
|
||||
@@ -722,7 +1245,7 @@ export function register(app, basePath = '') {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
return res.status(200).send(file.content);
|
||||
return res.status(200).send(content);
|
||||
} catch (error) {
|
||||
const status = vfsFileErrorStatus(error);
|
||||
if (status === 500) console.error('Get vfs file failed:', error);
|
||||
|
||||
@@ -535,16 +535,6 @@ export class ConversationManager {
|
||||
} catch (err) {
|
||||
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, err);
|
||||
}
|
||||
|
||||
try {
|
||||
await store.modelvfs.write(googleAuthId, {
|
||||
name: vfsRelPath,
|
||||
mime,
|
||||
content: fileBuffer,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment to modelvfs:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool } from 'libllmorbiter';
|
||||
import { getStore } from '../db/store.js';
|
||||
import { isSkillPath } from './skill.js';
|
||||
import { resolveVfsRoot } from './vfs.js';
|
||||
|
||||
@@ -40,25 +39,8 @@ export function createResetVfsTool({ googleAuthId }) {
|
||||
},
|
||||
},
|
||||
invoke: async ({ include_skills = false } = {}) => {
|
||||
const store = await getStore();
|
||||
const keepSkills = include_skills !== true;
|
||||
|
||||
const keptSkills = [];
|
||||
const existing = await store.modelvfs.list(googleAuthId);
|
||||
|
||||
// Phase 1 — clear the database, but only for the names we may delete. Done before
|
||||
// touching disk so a store failure cannot leave the disk wiped while the database
|
||||
// still claims the files exist.
|
||||
for (const file of existing) {
|
||||
if (keepSkills && isSkillPath(file.name)) {
|
||||
keptSkills.push(file.name);
|
||||
continue;
|
||||
}
|
||||
await store.modelvfs.delete(googleAuthId, file.name);
|
||||
}
|
||||
|
||||
// Phase 2 — mirror the same decision onto the on-disk cache. The root comes from
|
||||
// the same lookup the store used above, so the two phases cannot drift apart.
|
||||
const vfsRoot = await resolveVfsRoot(googleAuthId);
|
||||
let deletedCount = 0;
|
||||
if (fs.existsSync(vfsRoot)) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import { getStore } from '../db/store.js';
|
||||
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
|
||||
import { isSkillPath } from './skill.js';
|
||||
|
||||
@@ -97,18 +96,12 @@ export function createRmTool({ googleAuthId }) {
|
||||
const vfsRoot = await prepareVfs({ googleAuthId });
|
||||
const { abs, size } = deleteVfsFile(vfsRoot, clean);
|
||||
|
||||
// Database first. If this throws, the file is still on disk — recoverable, and the row
|
||||
// still exists to describe it. Doing it the other way round would leave a blob on
|
||||
// disk that the next prepareVfs hydration would restore, silently undoing the delete.
|
||||
const store = await getStore();
|
||||
const res = await store.modelvfs.delete(googleAuthId, clean);
|
||||
|
||||
fs.unlinkSync(abs);
|
||||
|
||||
const wasSkill = isSkillPath(clean);
|
||||
const result = {
|
||||
success: true,
|
||||
deleted: res?.deleted !== false,
|
||||
deleted: true,
|
||||
filename: clean,
|
||||
size,
|
||||
message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`,
|
||||
|
||||
@@ -126,7 +126,7 @@ async function vfsRootFor(store, googleAuthId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads default SKILL files into the selected company's VFS (both modelvfs table and disk cache).
|
||||
* Loads default SKILL files into the selected company's on-disk VFS.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<Array<string>>} List of loaded skill file names
|
||||
@@ -140,19 +140,12 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR
|
||||
const loaded = [];
|
||||
|
||||
for (const skill of defaultSkills) {
|
||||
try {
|
||||
await store.modelvfs.write(googleAuthId, {
|
||||
name: skill.name,
|
||||
mime: 'text/markdown',
|
||||
content: Buffer.from(skill.content, 'utf8'),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const diskPath = path.resolve(vfsRoot, skill.name);
|
||||
fs.writeFileSync(diskPath, skill.content, 'utf8');
|
||||
if (!fs.existsSync(diskPath)) {
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
fs.writeFileSync(diskPath, skill.content, 'utf8');
|
||||
}
|
||||
loaded.push(skill.name);
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message);
|
||||
@@ -178,7 +171,7 @@ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the local directory for this company's VFS exists and is hydrated from modelvfs.
|
||||
* Ensure the local directory for this company's VFS exists and default skills are loaded.
|
||||
*
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
* @returns {Promise<string>} vfsRoot directory path
|
||||
@@ -189,86 +182,21 @@ export async function prepareVfs({ googleAuthId }) {
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
const files = await store.modelvfs.list(googleAuthId);
|
||||
const existingNames = new Set(files.map((f) => f.name));
|
||||
const defaultSkills = getDefaultSkills();
|
||||
for (const skill of defaultSkills) {
|
||||
if (!existingNames.has(skill.name)) {
|
||||
try {
|
||||
await store.modelvfs.write(googleAuthId, {
|
||||
name: skill.name,
|
||||
mime: 'text/markdown',
|
||||
content: Buffer.from(skill.content, 'utf8'),
|
||||
});
|
||||
files.push({ name: skill.name });
|
||||
existingNames.add(skill.name);
|
||||
} catch (err) {
|
||||
console.warn(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
const diskPath = path.resolve(vfsRoot, file.name);
|
||||
if (!diskPath.startsWith(vfsRoot)) continue;
|
||||
if (!fs.existsSync(diskPath)) {
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
const fullRecord = await store.modelvfs.read(googleAuthId, file.name);
|
||||
if (fullRecord?.content) {
|
||||
fs.writeFileSync(diskPath, fullRecord.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadDefaultSkills({ googleAuthId, store, vfsRoot });
|
||||
} catch (err) {
|
||||
console.warn('[vfs] hydration warning:', err.message);
|
||||
console.warn('[vfs] prepare warning:', err.message);
|
||||
}
|
||||
|
||||
return vfsRoot;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table.
|
||||
* Purely disk-based VFS; no database syncing needed. Kept as no-op for backward compatibility.
|
||||
*
|
||||
* The store re-scopes by the currently selected company on every call, so this only stays
|
||||
* correct while `vfsRoot` was produced by `prepareVfs`/`resolveVfsRoot` in the same turn.
|
||||
*
|
||||
* @param {{ googleAuthId: number, vfsRoot: string }} ctx
|
||||
* @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
|
||||
*/
|
||||
export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
|
||||
if (!fs.existsSync(vfsRoot)) return;
|
||||
const store = await getStore();
|
||||
|
||||
function walk(dir, rel = '') {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const list = [];
|
||||
for (const e of entries) {
|
||||
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
||||
const fullPath = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
list.push(...walk(fullPath, relPath));
|
||||
} else if (e.isFile()) {
|
||||
list.push({ relPath, fullPath });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
const currentFiles = walk(vfsRoot);
|
||||
for (const f of currentFiles) {
|
||||
if (isReadOnlyDatabasePath(f.relPath)) continue;
|
||||
try {
|
||||
const content = fs.readFileSync(f.fullPath);
|
||||
const mime = getMimeType(f.relPath);
|
||||
await store.modelvfs.write(googleAuthId, {
|
||||
name: f.relPath,
|
||||
mime,
|
||||
content,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to sync file ${f.relPath} to db:`, err.message);
|
||||
}
|
||||
}
|
||||
export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) {
|
||||
// Purely disk-based VFS; no database syncing.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user