Server-persisted file manager with version tracking, modeled after Stirling-PDF. Covers DB schema, API routes, file storage, frontend components, and tool processing integration.
10 KiB
Files Page — Design Spec
Overview
A persistent file manager for Stirling Image, modeled after Stirling-PDF's Files tab. Users can upload images, browse recent files, view file details with image metadata, and re-open files for further processing. Files processed through any tool automatically save the result as a new version, building a version chain (V1 → V2 → V3...) with tool attribution.
Scope
- In scope: Recent files view, file upload, file details panel, version tracking, search, bulk select/delete/download, "Open File" navigation
- Out of scope: Google Drive integration (placeholder shown as "Coming Soon"), file sharing, folder organization
Key Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Persistence | Server-persisted with SQLite metadata + disk storage | Survives restarts, enables version history |
| Version tracking | Auto-save on tool processing | Matches Stirling-PDF; makes Files page useful |
| "Open File" behavior | Navigate to home page with file pre-loaded | Most flexible — user can pick any tool |
| Auth | Required when auth is enabled; works without auth in single-user mode | Files are per-user when auth is on, shared when off |
1. Database Schema
New table user_files in the existing SQLite database:
CREATE TABLE user_files (
id TEXT PRIMARY KEY, -- UUID
user_id TEXT, -- FK to users.id (nullable for no-auth mode)
original_name TEXT NOT NULL, -- Original filename as uploaded
stored_name TEXT NOT NULL, -- UUID-based name on disk
mime_type TEXT NOT NULL, -- e.g. "image/jpeg"
size INTEGER NOT NULL, -- File size in bytes
width INTEGER, -- Image width in px
height INTEGER, -- Image height in px
version INTEGER NOT NULL DEFAULT 1, -- Version number
parent_id TEXT, -- FK to user_files.id (previous version)
tool_chain TEXT, -- JSON array of tool IDs applied, e.g. ["resize", "compress"]
created_at INTEGER NOT NULL, -- Unix timestamp (ms)
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (parent_id) REFERENCES user_files(id)
);
CREATE INDEX idx_user_files_user_id ON user_files(user_id);
CREATE INDEX idx_user_files_created_at ON user_files(created_at);
CREATE INDEX idx_user_files_parent_id ON user_files(parent_id);
Drizzle ORM Definition
export const userFiles = sqliteTable("user_files", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id),
originalName: text("original_name").notNull(),
storedName: text("stored_name").notNull(),
mimeType: text("mime_type").notNull(),
size: integer("size").notNull(),
width: integer("width"),
height: integer("height"),
version: integer("version").notNull().default(1),
parentId: text("parent_id"),
toolChain: text("tool_chain"), // JSON string: ["resize", "compress"]
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
});
2. File Storage
- Storage directory:
{DATA_DIR}/files/(configurable viaFILES_STORAGE_PATHenv var, default:/data/files/) - File naming:
{uuid}.{ext}— avoids collisions, the original name is in the DB - No subdirectories per user — a flat directory with UUID names is simpler and avoids path traversal issues
- Cleanup: Files deleted from the DB also have their disk file removed. No cron needed — deletion is explicit.
3. API Routes
All routes prefixed with /api/v1/files. Auth required when auth is enabled.
3.1 List Files (Recent)
GET /api/v1/files?search=&limit=50&offset=0
Returns the latest version of each file group (grouped by root parent), sorted by created_at DESC.
Response:
{
"files": [
{
"id": "uuid",
"originalName": "beach_sunset.jpg",
"mimeType": "image/jpeg",
"size": 2400000,
"width": 1920,
"height": 1080,
"version": 3,
"toolChain": ["resize", "compress"],
"createdAt": "2026-03-24T21:15:00Z"
}
],
"total": 42
}
3.2 Upload Files
POST /api/v1/files/upload
Content-Type: multipart/form-data
Body: file (one or more image files)
Validates each file (magic bytes, supported format), extracts dimensions via Sharp, stores to disk, creates DB record with version=1.
Response:
{
"files": [
{ "id": "uuid", "originalName": "photo.jpg", "size": 2400000, "version": 1 }
]
}
3.3 Get File Details
GET /api/v1/files/:id
Returns full metadata for a single file, including all versions in the chain.
Response:
{
"id": "uuid",
"originalName": "beach_sunset.jpg",
"mimeType": "image/jpeg",
"size": 2400000,
"width": 1920,
"height": 1080,
"version": 3,
"toolChain": ["resize", "compress"],
"createdAt": "2026-03-24T21:15:00Z",
"versions": [
{ "id": "uuid-v1", "version": 1, "size": 5000000, "toolChain": [], "createdAt": "..." },
{ "id": "uuid-v2", "version": 2, "size": 3000000, "toolChain": ["resize"], "createdAt": "..." },
{ "id": "uuid-v3", "version": 3, "size": 2400000, "toolChain": ["resize", "compress"], "createdAt": "..." }
]
}
3.4 Download File
GET /api/v1/files/:id/download
Streams the file from disk with Content-Disposition: attachment.
3.5 Get File Thumbnail
GET /api/v1/files/:id/thumbnail
Returns a 300px-wide JPEG thumbnail (generated on-the-fly via Sharp, can be cached later).
3.6 Delete Files
DELETE /api/v1/files
Body: { "ids": ["uuid1", "uuid2"] }
Deletes specified files from DB and disk. When deleting a file that has child versions, deletes the entire chain.
3.7 Save Tool Result (internal — called by tool-factory)
POST /api/v1/files/save-result
Body: { parentId?: string, toolId: string, buffer: <binary>, filename: string }
This is an internal route called by the tool processing pipeline. It:
- Looks up the parent file (if parentId provided)
- Computes the new version number (parent.version + 1)
- Builds the tool chain (parent.toolChain + [toolId])
- Stores the file to disk
- Creates the DB record
- Returns the new file record
4. Tool Processing Integration
The tool-factory needs a small addition: after successfully processing a file, if the input file came from the Files store (identified by a fileId parameter in the request), save the result as a new version.
Flow:
- User clicks "Open File" on Files page → navigates to home with file loaded
- The file-store entry carries a
fileId(the user_files.id from the server) - User picks a tool, adjusts settings, clicks Process
- Tool processes the file as normal
- After success, the response includes the new
fileIdof the saved version - The file-store entry updates its
fileIdto the new version
Changes to tool-factory.ts:
- Accept optional
fileIdfield in multipart body - After processing, call the save-result logic internally (not an HTTP call — direct function call)
- Return
fileIdin the response alongside existingjobIdanddownloadUrl
5. Frontend
5.1 New Files Page (apps/web/src/pages/files-page.tsx)
Three-panel layout inside AppLayout:
- Left panel (180px): "My Files" heading, nav items (Recent, Upload Files, Google Drive disabled)
- Center panel (flex): Search bar, toolbar (select all, delete, download), scrollable file list
- Right panel (240px): Thumbnail preview, File Details card, "Open File" button. Hidden when no file selected.
5.2 Components
apps/web/src/components/files/
├── files-nav.tsx # Left nav (Recent, Upload, Drive placeholder)
├── file-list.tsx # Center: search + toolbar + file rows
├── file-list-item.tsx # Single file row (checkbox, name, size, date, version, tools)
├── file-details.tsx # Right panel (thumbnail, metadata, Open File)
├── file-upload-area.tsx # Dropzone for the Upload Files tab
5.3 Files Store (apps/web/src/stores/files-page-store.ts)
Separate Zustand store for the Files page (distinct from the existing file-store.ts which manages tool processing state):
interface FilesPageState {
// Data
files: UserFile[];
selectedFileId: string | null;
selectedFileIds: Set<string>; // for bulk operations
total: number;
// UI state
activeTab: "recent" | "upload";
searchQuery: string;
loading: boolean;
// Actions
fetchFiles: () => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
deleteFiles: (ids: string[]) => Promise<void>;
selectFile: (id: string) => void;
toggleFileSelection: (id: string) => void;
selectAll: () => void;
deselectAll: () => void;
setSearchQuery: (query: string) => void;
setActiveTab: (tab: "recent" | "upload") => void;
}
5.4 "Open File" Flow
When user clicks "Open File":
- Fetch the file blob from
GET /api/v1/files/:id/download - Create a
Fileobject from the blob - Add it to the existing
file-storewith thefileIdattached - Navigate to
/(home page) - Home page sees the file in the store and shows the tool selection + preview
5.5 Routing
Add to App.tsx:
<Route path="/files" element={<FilesPage />} />
Re-add Files to sidebar and mobile nav (reverting the earlier removal).
5.6 Mobile Layout
On mobile, the three-panel layout collapses:
- Left nav becomes tabs at the top (Recent | Upload)
- File list takes full width
- File details shows as a bottom sheet when a file is tapped
- "Open File" button is prominent in the bottom sheet
6. Environment Variables
| Variable | Default | Description |
|---|---|---|
FILES_STORAGE_PATH |
/data/files |
Directory for persistent file storage |
MAX_STORED_FILES |
500 |
Maximum files per user (0 = unlimited) |
7. Error Handling
- Upload validation: Same as existing file validation (magic bytes, format, size limit)
- Storage full: Return 507 if disk write fails
- File not found: Return 404 if file ID doesn't exist or belongs to another user
- Auth: Return 401 if auth is enabled and user is not authenticated