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/init.md`](routes/pos/init.md) | `GET /v1/init` |
|
||||||
| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` |
|
| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` |
|
||||||
| [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
|
| [`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/_*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/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
|
||||||
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
|
| [`routes/warehouse/create.md`](routes/warehouse/create.md) | `POST /warehouse` |
|
||||||
| [`routes/warehouse/order.md`](routes/warehouse/order.md) | `PUT /warehouse/order` |
|
| [`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` |
|
| `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}` |
|
| `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` |
|
| `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
|
## DESIGN
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 183 API endpoints across all modules.
|
Overview of all 189 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -575,9 +575,27 @@ _none_
|
|||||||
#### JTL-POS product catalog synchronization — `GET /v1/product`
|
#### JTL-POS product catalog synchronization — `GET /v1/product`
|
||||||
- Params: `query: lastChangedProduct?: str, limit?: int`
|
- Params: `query: lastChangedProduct?: str, limit?: int`
|
||||||
- Returns: `200 obj[]`
|
- 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}`
|
#### Get a virtual filesystem file — `GET /vfs/{*path}`
|
||||||
- Params: `path: str, query: download?: bool, raw?: bool`
|
- Params: `path: str, query: download?: bool, raw?: bool`
|
||||||
- Returns: `200 str`
|
- 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`
|
#### List warehouses — `GET /warehouse`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { warehouses: T37[] }`
|
- Returns: `200 { warehouses: T37[] }`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dash API Routes Overview
|
# Dash API Routes Overview
|
||||||
|
|
||||||
Overview of all 146 API endpoints across all modules.
|
Overview of all 145 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -460,9 +460,6 @@ _none_
|
|||||||
#### Upsert unit — `PUT /unit`
|
#### Upsert unit — `PUT /unit`
|
||||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
- 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 } }`
|
- 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`
|
#### List warehouses — `GET /warehouse`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { warehouses: T34[] }`
|
- Returns: `200 { warehouses: T34[] }`
|
||||||
|
|||||||
@@ -4,42 +4,70 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
|
|||||||
|
|
||||||
Get a virtual filesystem file
|
Get a virtual filesystem file
|
||||||
|
|
||||||
Returns a file the model produced in the `javascript_execute` sandbox
|
Returns a file from the virtual filesystem for the authenticated user's selected company.
|
||||||
(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
|
### Image Previews & Thumbnails
|
||||||
`download=1` or `raw=1`.
|
Supported raster images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/bmp`)
|
||||||
Browser-compatible files (HTML, CSS, JS, JSON, PDF, text, images, media) are served
|
are automatically transformed into a **250×250 aspect-preserving preview thumbnail** (WebP format, quality 88):
|
||||||
inline so they open directly in the browser; other files are sent as downloads.
|
- 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
|
## Path parameters
|
||||||
|
|
||||||
| Param | Required | Type | Description |
|
| 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
|
## Query parameters
|
||||||
|
|
||||||
| Param | Required | Type | Description |
|
| Param | Required | Type | Description |
|
||||||
|-------|----------|------|-------------|
|
|-------|----------|------|-------------|
|
||||||
| `download` | no | boolean | Force file download with Content-Disposition: attachment.
|
| `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). |
|
||||||
| `raw` | no | boolean | Return original unmodified image inline without generating a preview thumbnail.
|
|
||||||
|
|
|
||||||
|
|
||||||
## Response `200`
|
## 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
|
## Errors
|
||||||
|
|
||||||
| Status | Description |
|
| 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":"…"}` |
|
| 401 | Unauthorized — `{"error":"…"}` |
|
||||||
| 403 | Not authorized — `{"error":"…"}` |
|
| 403 | Not authorized — `{"error":"…"}` |
|
||||||
| 404 | File not found — `{"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/')) {
|
if (apiPath === '/public' || apiPath.startsWith('/public/')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
|
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1271,6 +1271,36 @@
|
|||||||
* type: string
|
* type: string
|
||||||
* sort:
|
* sort:
|
||||||
* type: integer
|
* 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 {};
|
export {};
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const openApiOptions = {
|
|||||||
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
|
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
|
||||||
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
|
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
|
||||||
{ name: 'PublicCatalog', description: 'Public product catalog API' },
|
{ name: 'PublicCatalog', description: 'Public product catalog API' },
|
||||||
|
{ name: 'Vfs', description: 'Virtual filesystem file management and preview' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
apis: [
|
apis: [
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { emit } from '../sse.js';
|
import { emit } from '../sse.js';
|
||||||
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath } from '../../tools/vfs.js';
|
import { prepareVfs, resolveVfsRoot, isReadOnlyDatabasePath, getMimeType } from '../../tools/vfs.js';
|
||||||
import * as db from '../../db/modelvfs.js';
|
|
||||||
import sharp from 'sharp';
|
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
|
// 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.
|
// stretched, so a 2000x100 image becomes 250x13 and not 250x250.
|
||||||
@@ -379,61 +377,32 @@ export function register(app, basePath = '') {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /vfs/{*path}:
|
* /vfs:
|
||||||
* get:
|
* get:
|
||||||
* tags: [Vfs]
|
* tags: [Vfs]
|
||||||
* summary: Get a virtual filesystem file
|
* summary: List virtual filesystem files
|
||||||
* description: |
|
* description: |
|
||||||
* Returns a file the model produced in the `javascript_execute` sandbox
|
* Lists all virtual filesystem files for the authenticated user's selected company.
|
||||||
* (virtual filesystem), 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.
|
||||||
* 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.
|
|
||||||
* security:
|
* security:
|
||||||
* - cookieAuth: []
|
* - cookieAuth: []
|
||||||
* parameters:
|
* x-markdown: vfs/list.md
|
||||||
* - 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.
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: File bytes
|
* description: List of virtual filesystem files
|
||||||
* headers:
|
|
||||||
* Content-Disposition:
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* Cache-Control:
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* content:
|
* content:
|
||||||
* application/octet-stream:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: object
|
||||||
* format: binary
|
* required: [files]
|
||||||
|
* properties:
|
||||||
|
* files:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/VfsFile'
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid path, or no company selected
|
* description: No company selected
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
@@ -450,14 +419,7 @@ export function register(app, basePath = '') {
|
|||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* $ref: '#/components/schemas/Error'
|
* $ref: '#/components/schemas/Error'
|
||||||
* 404:
|
|
||||||
* description: File not found
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* $ref: '#/components/schemas/Error'
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
app.get(`${basePath}/vfs`, async (req, res) => {
|
app.get(`${basePath}/vfs`, async (req, res) => {
|
||||||
const selected = requireSelectedCompany(req);
|
const selected = requireSelectedCompany(req);
|
||||||
if (selected.error) {
|
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) => {
|
app.post(`${basePath}/vfs/upload`, vfsUpload.array('files'), async (req, res) => {
|
||||||
const selected = requireSelectedCompany(req);
|
const selected = requireSelectedCompany(req);
|
||||||
if (selected.error) {
|
if (selected.error) {
|
||||||
@@ -531,6 +572,7 @@ export function register(app, basePath = '') {
|
|||||||
if (isReadOnlyDatabasePath(parsed.name)) continue;
|
if (isReadOnlyDatabasePath(parsed.name)) continue;
|
||||||
|
|
||||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||||
|
if (!absPath.startsWith(vfsRoot)) continue;
|
||||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||||
fs.writeFileSync(absPath, f.buffer);
|
fs.writeFileSync(absPath, f.buffer);
|
||||||
|
|
||||||
@@ -538,12 +580,6 @@ export function register(app, basePath = '') {
|
|||||||
? f.mimetype
|
? f.mimetype
|
||||||
: getMimeType(parsed.name);
|
: getMimeType(parsed.name);
|
||||||
|
|
||||||
await db.write(req.user.id, {
|
|
||||||
name: parsed.name,
|
|
||||||
mime: effectiveMime,
|
|
||||||
content: f.buffer,
|
|
||||||
});
|
|
||||||
|
|
||||||
uploaded.push({
|
uploaded.push({
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
size: f.buffer.length,
|
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) => {
|
app.put(`${basePath}/vfs/*path`, async (req, res) => {
|
||||||
const selected = requireSelectedCompany(req);
|
const selected = requireSelectedCompany(req);
|
||||||
if (selected.error) {
|
if (selected.error) {
|
||||||
@@ -593,15 +928,12 @@ export function register(app, basePath = '') {
|
|||||||
try {
|
try {
|
||||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
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.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||||
fs.writeFileSync(absPath, buffer);
|
fs.writeFileSync(absPath, buffer);
|
||||||
|
|
||||||
await db.write(req.user.id, {
|
|
||||||
name: parsed.name,
|
|
||||||
mime,
|
|
||||||
content: buffer,
|
|
||||||
});
|
|
||||||
|
|
||||||
emit('vfs', { companyId: selected.companyId });
|
emit('vfs', { companyId: selected.companyId });
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
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) => {
|
app.delete(`${basePath}/vfs/*path`, async (req, res) => {
|
||||||
const selected = requireSelectedCompany(req);
|
const selected = requireSelectedCompany(req);
|
||||||
if (selected.error) {
|
if (selected.error) {
|
||||||
@@ -636,10 +1021,18 @@ export function register(app, basePath = '') {
|
|||||||
try {
|
try {
|
||||||
const vfsRoot = await resolveVfsRoot(req.user.id);
|
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||||
const absPath = path.resolve(vfsRoot, parsed.name);
|
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||||
if (fs.existsSync(absPath)) {
|
if (!absPath.startsWith(vfsRoot)) {
|
||||||
try { fs.unlinkSync(absPath); } catch {}
|
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 });
|
emit('vfs', { companyId: selected.companyId });
|
||||||
return res.json({ success: true, deleted: parsed.name });
|
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) => {
|
app.get(`${basePath}/vfs/*path`, async (req, res) => {
|
||||||
const selected = requireSelectedCompany(req);
|
const selected = requireSelectedCompany(req);
|
||||||
if (selected.error) {
|
if (selected.error) {
|
||||||
@@ -663,15 +1181,20 @@ export function register(app, basePath = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = await db.read(req.user.id, parsed.name);
|
const vfsRoot = await resolveVfsRoot(req.user.id);
|
||||||
if (!file?.content?.length) {
|
const absPath = path.resolve(vfsRoot, parsed.name);
|
||||||
|
if (!absPath.startsWith(vfsRoot) || !fs.existsSync(absPath)) {
|
||||||
return res.status(404).json({ error: 'File not found' });
|
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 fallbackName = parsed.name.split('/').pop() || 'download';
|
||||||
const effectiveMime = (file.mime && file.mime !== 'application/octet-stream')
|
const effectiveMime = getMimeType(parsed.name);
|
||||||
? file.mime
|
|
||||||
: getMimeType(parsed.name);
|
|
||||||
|
|
||||||
const wantsDownload = req.query.download === '1' || req.query.download === 'true';
|
const wantsDownload = req.query.download === '1' || req.query.download === 'true';
|
||||||
const wantsRaw = req.query.raw === '1' || req.query.raw === '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).
|
// unless the caller explicitly asks for the raw file (?raw=1) or a download (?download=1).
|
||||||
const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
|
const shouldThumbnail = parsed.canThumbnail && !wantsDownload && !wantsRaw;
|
||||||
if (shouldThumbnail) {
|
if (shouldThumbnail) {
|
||||||
const rendered = await renderThumbnail(file.content, {
|
const rendered = await renderThumbnail(content, {
|
||||||
mime: effectiveMime,
|
mime: effectiveMime,
|
||||||
filename: fallbackName,
|
filename: fallbackName,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -696,7 +1219,7 @@ export function register(app, basePath = '') {
|
|||||||
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${fallbackName.replace(/"/g, '')}"`);
|
||||||
// Previews are derived data — never cache them immutably, the source file can change.
|
// 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('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'];
|
const ifNoneMatch = req.headers['if-none-match'];
|
||||||
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
|
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
|
||||||
return res.status(304).end();
|
return res.status(304).end();
|
||||||
@@ -707,11 +1230,11 @@ export function register(app, basePath = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', effectiveMime);
|
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');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
// Mutable VFS artifacts are cached with revalidation
|
// Mutable VFS artifacts are cached with revalidation
|
||||||
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
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(
|
res.setHeader(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
|
`${isInline ? 'inline' : 'attachment'}; filename="${fallbackName.replace(/"/g, '')}"`
|
||||||
@@ -722,7 +1245,7 @@ export function register(app, basePath = '') {
|
|||||||
return res.status(304).end();
|
return res.status(304).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send(file.content);
|
return res.status(200).send(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = vfsFileErrorStatus(error);
|
const status = vfsFileErrorStatus(error);
|
||||||
if (status === 500) console.error('Get vfs file failed:', error);
|
if (status === 500) console.error('Get vfs file failed:', error);
|
||||||
|
|||||||
@@ -535,16 +535,6 @@ export class ConversationManager {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[ConversationManager ${this.conversationId}] failed to write attachment disk file:`, 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);
|
const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineTool } from 'libllmorbiter';
|
import { defineTool } from 'libllmorbiter';
|
||||||
import { getStore } from '../db/store.js';
|
|
||||||
import { isSkillPath } from './skill.js';
|
import { isSkillPath } from './skill.js';
|
||||||
import { resolveVfsRoot } from './vfs.js';
|
import { resolveVfsRoot } from './vfs.js';
|
||||||
|
|
||||||
@@ -40,25 +39,8 @@ export function createResetVfsTool({ googleAuthId }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
invoke: async ({ include_skills = false } = {}) => {
|
invoke: async ({ include_skills = false } = {}) => {
|
||||||
const store = await getStore();
|
|
||||||
const keepSkills = include_skills !== true;
|
const keepSkills = include_skills !== true;
|
||||||
|
|
||||||
const keptSkills = [];
|
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);
|
const vfsRoot = await resolveVfsRoot(googleAuthId);
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
if (fs.existsSync(vfsRoot)) {
|
if (fs.existsSync(vfsRoot)) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineTool, ToolError } from 'libllmorbiter';
|
import { defineTool, ToolError } from 'libllmorbiter';
|
||||||
import { getStore } from '../db/store.js';
|
|
||||||
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
|
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
|
||||||
import { isSkillPath } from './skill.js';
|
import { isSkillPath } from './skill.js';
|
||||||
|
|
||||||
@@ -97,18 +96,12 @@ export function createRmTool({ googleAuthId }) {
|
|||||||
const vfsRoot = await prepareVfs({ googleAuthId });
|
const vfsRoot = await prepareVfs({ googleAuthId });
|
||||||
const { abs, size } = deleteVfsFile(vfsRoot, clean);
|
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);
|
fs.unlinkSync(abs);
|
||||||
|
|
||||||
const wasSkill = isSkillPath(clean);
|
const wasSkill = isSkillPath(clean);
|
||||||
const result = {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
deleted: res?.deleted !== false,
|
deleted: true,
|
||||||
filename: clean,
|
filename: clean,
|
||||||
size,
|
size,
|
||||||
message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`,
|
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
|
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||||
* @returns {Promise<Array<string>>} List of loaded skill file names
|
* @returns {Promise<Array<string>>} List of loaded skill file names
|
||||||
@@ -140,19 +140,12 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR
|
|||||||
const loaded = [];
|
const loaded = [];
|
||||||
|
|
||||||
for (const skill of defaultSkills) {
|
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 {
|
try {
|
||||||
const diskPath = path.resolve(vfsRoot, skill.name);
|
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);
|
loaded.push(skill.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message);
|
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
|
* @param {{ googleAuthId: number }} ctx
|
||||||
* @returns {Promise<string>} vfsRoot directory path
|
* @returns {Promise<string>} vfsRoot directory path
|
||||||
@@ -189,86 +182,21 @@ export async function prepareVfs({ googleAuthId }) {
|
|||||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await store.modelvfs.list(googleAuthId);
|
await loadDefaultSkills({ googleAuthId, store, vfsRoot });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[vfs] hydration warning:', err.message);
|
console.warn('[vfs] prepare warning:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return vfsRoot;
|
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
|
* @param {{ googleAuthId?: number, vfsRoot?: string }} [ctx]
|
||||||
* correct while `vfsRoot` was produced by `prepareVfs`/`resolveVfsRoot` in the same turn.
|
|
||||||
*
|
|
||||||
* @param {{ googleAuthId: number, vfsRoot: string }} ctx
|
|
||||||
*/
|
*/
|
||||||
export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
|
export async function syncVfsToDb({ googleAuthId, vfsRoot } = {}) {
|
||||||
if (!fs.existsSync(vfsRoot)) return;
|
// Purely disk-based VFS; no database syncing.
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user