- Updated local image storage documentation to reflect changes in protocol handling for attachments. - Added attachment metadata handling in message sending and inbox writing processes, improving attachment management. - Implemented size validation for attachments in the TeamAttachmentStore, ensuring compliance with storage limits. - Enhanced UI components to support attachment previews and improved user experience in managing attachments. - Introduced a new RoleSelect component for better role management within teams.
164 lines
7.2 KiB
Markdown
164 lines
7.2 KiB
Markdown
# Local Image Storage in Electron — Research & Recommendations
|
|
|
|
## Context
|
|
|
|
This document evaluates approaches for storing images/attachments locally in our Electron app (draft attachments in task descriptions, team-related images). The app uses Electron 28.x, React 18, and the main process already manages file I/O via IPC.
|
|
|
|
---
|
|
|
|
## Approach 1: Filesystem + SQLite Metadata (Recommended)
|
|
|
|
**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app-img://...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table.
|
|
|
|
### Pros
|
|
- Best I/O performance — direct filesystem reads, no serialization overhead.
|
|
- `protocol.handle` (Electron 28+) is the modern, secure way to serve local files without disabling `webSecurity`.
|
|
- `better-sqlite3` is synchronous, zero-dependency after rebuild, and already proven in the Electron ecosystem.
|
|
- Thumbnails generated once by `sharp` and stored alongside originals — no re-computation.
|
|
- Simple garbage collection: query metadata for orphaned entries, `fs.unlink` the files.
|
|
- No practical per-file size limit (limited only by disk space).
|
|
|
|
### Cons
|
|
- Requires `electron-rebuild` for `better-sqlite3` native bindings.
|
|
- Two storage systems to maintain (fs + sqlite).
|
|
- Path traversal must be prevented in the custom protocol handler (standard pattern, well-documented).
|
|
|
|
### Key implementation details
|
|
|
|
**Custom protocol (secure file serving):**
|
|
```ts
|
|
protocol.registerSchemesAsPrivileged([{
|
|
scheme: 'app-img',
|
|
privileges: { standard: true, secure: true, supportFetchAPI: true }
|
|
}]);
|
|
|
|
app.whenReady().then(() => {
|
|
protocol.handle('app-img', (req) => {
|
|
const { pathname } = new URL(req.url);
|
|
const base = path.join(app.getPath('userData'), 'attachments');
|
|
const resolved = path.resolve(base, pathname.slice(1));
|
|
const rel = path.relative(base, resolved);
|
|
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
return new Response('Forbidden', { status: 403 });
|
|
}
|
|
return net.fetch(pathToFileURL(resolved).toString());
|
|
});
|
|
});
|
|
```
|
|
|
|
**Thumbnail generation (sharp):**
|
|
```ts
|
|
import sharp from 'sharp';
|
|
|
|
async function createThumbnail(inputPath: string, outputPath: string) {
|
|
await sharp(inputPath)
|
|
.resize(300, 300, { fit: 'inside', withoutEnlargement: true })
|
|
.webp({ quality: 80 })
|
|
.toFile(outputPath);
|
|
}
|
|
```
|
|
|
|
**Metadata schema (better-sqlite3):**
|
|
```sql
|
|
CREATE TABLE attachments (
|
|
id TEXT PRIMARY KEY, -- uuid
|
|
original_name TEXT NOT NULL,
|
|
mime_type TEXT NOT NULL,
|
|
size_bytes INTEGER NOT NULL,
|
|
hash TEXT NOT NULL, -- sha256 for dedup
|
|
thumb_path TEXT,
|
|
entity_type TEXT, -- 'task' | 'team' | 'draft'
|
|
entity_id TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
accessed_at INTEGER
|
|
);
|
|
```
|
|
|
|
**Garbage collection:** Periodic sweep (on app start or every N hours) — find rows where `entity_id` no longer exists in the app state, delete file + row. Also enforce a configurable max total size (e.g. 500 MB) with LRU eviction based on `accessed_at`.
|
|
|
|
---
|
|
|
|
## Approach 2: Filesystem Only (Simplest)
|
|
|
|
**How it works:** Store images and a sidecar `.json` metadata file per attachment under `userData/attachments/{uuid}/`. No database.
|
|
|
|
### Pros
|
|
- Zero native dependencies — no `better-sqlite3` rebuild.
|
|
- Trivially portable (copy the folder).
|
|
- Simple to implement and debug.
|
|
|
|
### Cons
|
|
- Querying metadata (e.g. "all images for task X") requires scanning directory + reading JSON files — O(n).
|
|
- No transactional integrity between metadata and files.
|
|
- Deduplication harder without indexed hashes.
|
|
- Garbage collection requires full directory walk.
|
|
|
|
### When to choose this
|
|
If the total number of attachments is expected to stay under ~200 and complex queries are not needed. Good enough for an MVP.
|
|
|
|
---
|
|
|
|
## Approach 3: IndexedDB (Renderer-Side)
|
|
|
|
**How it works:** Store images as Blobs in IndexedDB (via Dexie.js wrapper) directly in the renderer process.
|
|
|
|
### Pros
|
|
- No native dependencies at all.
|
|
- Built-in browser API, well-documented.
|
|
- Transactional, supports indexes and queries.
|
|
|
|
### Cons
|
|
- **Performance:** Every read/write goes through Chromium's abstraction layers — significantly slower than direct fs I/O for large files.
|
|
- **Renderer-only:** Cannot be accessed from the main process without IPC round-trips.
|
|
- **Quota:** Chromium imposes storage quotas (varies, but often ~60% of disk on desktop).
|
|
- **Backup risk:** Data lives inside Chromium's internal LevelDB files — not human-readable, not easily portable.
|
|
- **Multi-window:** Concurrent access from multiple BrowserWindows can cause issues.
|
|
|
|
### When to choose this
|
|
Only if the app is being designed as a pure web app with Electron as a thin shell, and images are small (< 5 MB each).
|
|
|
|
---
|
|
|
|
## Comparison Matrix
|
|
|
|
| Criterion | FS + SQLite | FS Only | IndexedDB |
|
|
|---------------------------|-----------------|----------------|----------------|
|
|
| Read/write performance | Excellent | Excellent | Moderate |
|
|
| Query capability | Full SQL | Manual scan | IndexedDB API |
|
|
| Native deps required | better-sqlite3 | None | None |
|
|
| Max file size | Disk limit | Disk limit | ~2 GB (blob) |
|
|
| Garbage collection | SQL query + unlink | Dir walk | Cursor iterate |
|
|
| Security (serving to renderer) | protocol.handle | protocol.handle | N/A (in-process) |
|
|
| Deduplication | Hash index | Manual | Hash index |
|
|
| Complexity | Medium | Low | Medium |
|
|
| Multi-window safe | Yes (main proc) | Yes (main proc)| Risky |
|
|
|
|
---
|
|
|
|
## Recommendation
|
|
|
|
**Use Approach 1 (Filesystem + SQLite Metadata)** for this project.
|
|
|
|
Rationale:
|
|
1. The app already runs significant logic in the main process (file watchers, JSONL parsing, IPC handlers) — adding `better-sqlite3` fits the existing architecture.
|
|
2. `protocol.handle` is the Electron 28+ standard for secure local file serving; we should adopt it.
|
|
3. `sharp` handles thumbnailing efficiently (sub-millisecond per image) and outputs WebP for smaller sizes.
|
|
4. SQL metadata enables fast lookups by entity, dedup by hash, and clean GC queries.
|
|
5. The FS-only approach (Approach 2) is a valid MVP fallback if we want to avoid native deps initially, with a clear migration path to Approach 1 later.
|
|
|
|
### Size Limits & Quotas (Suggested Defaults)
|
|
- Max single file: 20 MB (covers high-res screenshots).
|
|
- Max total storage: 500 MB (configurable in settings).
|
|
- Thumbnail size: 300px max dimension, WebP quality 80.
|
|
- GC trigger: on app start + every 6 hours while running.
|
|
- Orphan grace period: 24 hours (allows undo of deletions).
|
|
|
|
### Libraries to Use
|
|
| Library | Version (as of 2026) | Purpose |
|
|
|---------|---------------------|---------|
|
|
| `better-sqlite3` | 11.x | Metadata storage |
|
|
| `sharp` | 0.33.x | Thumbnail generation, format conversion |
|
|
| `uuid` | 10.x (or `crypto.randomUUID()`) | Attachment IDs |
|
|
|
|
### Migration Path
|
|
Start with Approach 2 (FS-only) if speed matters. Add `better-sqlite3` when we need querying or the attachment count grows. The file layout (`userData/attachments/{uuid}.{ext}`) stays the same — only the metadata layer changes.
|