mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
chore: remove all docs/superpowers from git tracking
These are local planning artifacts that should not be in the repository. The directory is already gitignored.
This commit is contained in:
@@ -1,494 +0,0 @@
|
||||
# HTTP/Non-Secure Context Compatibility - Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make all frontend features work over plain HTTP on non-localhost addresses (LAN/NAS deployments).
|
||||
|
||||
**Architecture:** Add `generateId()` and `copyToClipboard()` utilities to `apps/web/src/lib/utils.ts`, then replace all 10 call sites across 7 files. Test the utilities with Vitest.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest (jsdom), `crypto.getRandomValues()`, `document.execCommand("copy")`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-04-http-compatibility-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `generateId()` utility and test
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/lib/utils.ts`
|
||||
- Create: `tests/unit/web/utils.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the test file**
|
||||
|
||||
Create `tests/unit/web/utils.test.ts`:
|
||||
|
||||
```ts
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateId } from "../../../apps/web/src/lib/utils";
|
||||
|
||||
describe("generateId", () => {
|
||||
it("returns a valid UUID v4 string", () => {
|
||||
const id = generateId();
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unique values on successive calls", () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm test:unit -- tests/unit/web/utils.test.ts`
|
||||
Expected: FAIL - `generateId` is not exported from utils.
|
||||
|
||||
- [ ] **Step 3: Implement `generateId` in utils.ts**
|
||||
|
||||
Add to `apps/web/src/lib/utils.ts` after the existing `cn` function:
|
||||
|
||||
```ts
|
||||
export function generateId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pnpm test:unit -- tests/unit/web/utils.test.ts`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/lib/utils.ts tests/unit/web/utils.test.ts
|
||||
git commit -m "feat: add generateId() utility for non-secure context compatibility"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `copyToClipboard()` utility and test
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/lib/utils.ts`
|
||||
- Modify: `tests/unit/web/utils.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the tests**
|
||||
|
||||
Append to `tests/unit/web/utils.test.ts`:
|
||||
|
||||
```ts
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { copyToClipboard } from "../../../apps/web/src/lib/utils";
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns true when clipboard API succeeds", async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
expect(await copyToClipboard("hello")).toBe(true);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("falls back to execCommand when clipboard API fails", async () => {
|
||||
Object.assign(navigator, { clipboard: undefined });
|
||||
const execCommand = vi.spyOn(document, "execCommand").mockReturnValue(true);
|
||||
expect(await copyToClipboard("hello")).toBe(true);
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("returns false when both approaches fail", async () => {
|
||||
Object.assign(navigator, { clipboard: undefined });
|
||||
vi.spyOn(document, "execCommand").mockImplementation(() => {
|
||||
throw new Error("not supported");
|
||||
});
|
||||
expect(await copyToClipboard("hello")).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: update the `import` line at the top of the file to also import `afterEach` and `vi` alongside the existing `describe, expect, it`, and import `copyToClipboard` from the same utils path.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm test:unit -- tests/unit/web/utils.test.ts`
|
||||
Expected: FAIL - `copyToClipboard` is not exported from utils.
|
||||
|
||||
- [ ] **Step 3: Implement `copyToClipboard` in utils.ts**
|
||||
|
||||
Add to `apps/web/src/lib/utils.ts` after `generateId`:
|
||||
|
||||
```ts
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pnpm test:unit -- tests/unit/web/utils.test.ts`
|
||||
Expected: PASS (5 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/lib/utils.ts tests/unit/web/utils.test.ts
|
||||
git commit -m "feat: add copyToClipboard() utility with execCommand fallback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Replace `crypto.randomUUID()` in `use-tool-processor.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/hooks/use-tool-processor.ts`
|
||||
|
||||
- [ ] **Step 1: Add import**
|
||||
|
||||
Add `generateId` to imports at the top of `apps/web/src/hooks/use-tool-processor.ts`:
|
||||
|
||||
```ts
|
||||
import { generateId } from "@/lib/utils";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace line 91**
|
||||
|
||||
Change:
|
||||
```ts
|
||||
const clientJobId = crypto.randomUUID();
|
||||
```
|
||||
To:
|
||||
```ts
|
||||
const clientJobId = generateId();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace line 260**
|
||||
|
||||
Change:
|
||||
```ts
|
||||
const clientJobId = crypto.randomUUID();
|
||||
```
|
||||
To:
|
||||
```ts
|
||||
const clientJobId = generateId();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify no remaining `crypto.randomUUID` references**
|
||||
|
||||
Run: `grep -n "crypto.randomUUID" apps/web/src/hooks/use-tool-processor.ts`
|
||||
Expected: No output.
|
||||
|
||||
- [ ] **Step 5: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/hooks/use-tool-processor.ts
|
||||
git commit -m "fix: replace crypto.randomUUID with generateId in use-tool-processor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Replace `crypto.randomUUID()` in AI tool settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/components/tools/ocr-settings.tsx`
|
||||
- Modify: `apps/web/src/components/tools/erase-object-settings.tsx`
|
||||
|
||||
- [ ] **Step 1: Update `ocr-settings.tsx`**
|
||||
|
||||
Add import at the top:
|
||||
```ts
|
||||
import { generateId } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace line 52:
|
||||
```ts
|
||||
const clientJobId = crypto.randomUUID();
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
const clientJobId = generateId();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `erase-object-settings.tsx`**
|
||||
|
||||
Add import at the top:
|
||||
```ts
|
||||
import { generateId } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace line 52:
|
||||
```ts
|
||||
const clientJobId = crypto.randomUUID();
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
const clientJobId = generateId();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/tools/ocr-settings.tsx apps/web/src/components/tools/erase-object-settings.tsx
|
||||
git commit -m "fix: replace crypto.randomUUID with generateId in AI tool settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Replace `crypto.randomUUID()` in pipeline/automation
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/components/tools/pipeline-builder.tsx`
|
||||
- Modify: `apps/web/src/pages/automate-page.tsx`
|
||||
|
||||
- [ ] **Step 1: Update `pipeline-builder.tsx`**
|
||||
|
||||
Add import at the top:
|
||||
```ts
|
||||
import { generateId } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace line 104:
|
||||
```ts
|
||||
id: crypto.randomUUID(),
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
id: generateId(),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `automate-page.tsx`**
|
||||
|
||||
Add import at the top:
|
||||
```ts
|
||||
import { generateId } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace line 147:
|
||||
```ts
|
||||
id: crypto.randomUUID(),
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
id: generateId(),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/tools/pipeline-builder.tsx apps/web/src/pages/automate-page.tsx
|
||||
git commit -m "fix: replace crypto.randomUUID with generateId in pipeline/automation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Replace `navigator.clipboard` in all 4 call sites
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/components/settings/settings-dialog.tsx`
|
||||
- Modify: `apps/web/src/components/tools/color-palette-settings.tsx`
|
||||
- Modify: `apps/web/src/components/tools/ocr-settings.tsx`
|
||||
- Modify: `apps/web/src/components/tools/barcode-read-settings.tsx`
|
||||
|
||||
- [ ] **Step 1: Update `settings-dialog.tsx`**
|
||||
|
||||
Add `copyToClipboard` to imports (the file already imports from `@/lib/utils` if `cn` is used, otherwise add a new import):
|
||||
```ts
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace lines 1147-1152:
|
||||
```ts
|
||||
const copyKey = useCallback((key: string) => {
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, []);
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
const copyKey = useCallback(async (key: string) => {
|
||||
const ok = await copyToClipboard(key);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `color-palette-settings.tsx`**
|
||||
|
||||
Add import:
|
||||
```ts
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace lines 45-53:
|
||||
```ts
|
||||
const copyColor = async (color: string, idx: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(color);
|
||||
setCopiedIdx(idx);
|
||||
setTimeout(() => setCopiedIdx(null), 1500);
|
||||
} catch {
|
||||
// Fallback: silent fail
|
||||
}
|
||||
};
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
const copyColor = async (color: string, idx: number) => {
|
||||
const ok = await copyToClipboard(color);
|
||||
if (ok) {
|
||||
setCopiedIdx(idx);
|
||||
setTimeout(() => setCopiedIdx(null), 1500);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `ocr-settings.tsx`**
|
||||
|
||||
This file already has a `generateId` import from Task 4. Add `copyToClipboard` to the same import:
|
||||
```ts
|
||||
import { copyToClipboard, generateId } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace lines 118-124:
|
||||
```ts
|
||||
const handleCopy = async () => {
|
||||
if (text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
const handleCopy = async () => {
|
||||
if (text) {
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `barcode-read-settings.tsx`**
|
||||
|
||||
Add import:
|
||||
```ts
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace lines 45-54:
|
||||
```ts
|
||||
const copyText = async () => {
|
||||
if (!result?.text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Fallback: silent fail
|
||||
}
|
||||
};
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
const copyText = async () => {
|
||||
if (!result?.text) return;
|
||||
const ok = await copyToClipboard(result.text);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/settings/settings-dialog.tsx apps/web/src/components/tools/color-palette-settings.tsx apps/web/src/components/tools/ocr-settings.tsx apps/web/src/components/tools/barcode-read-settings.tsx
|
||||
git commit -m "fix: replace navigator.clipboard with copyToClipboard utility"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final verification
|
||||
|
||||
- [ ] **Step 1: Verify no remaining direct usages**
|
||||
|
||||
Run: `grep -rn "crypto\.randomUUID\|navigator\.clipboard" apps/web/src/`
|
||||
Expected: No output.
|
||||
|
||||
- [ ] **Step 2: Run full lint**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: All existing tests pass, plus the 5 new tests in `utils.test.ts`.
|
||||
|
||||
- [ ] **Step 4: Commit any lint fixes if needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: lint fixes for http compatibility changes"
|
||||
```
|
||||
|
||||
(Skip if lint passed cleanly.)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,100 +0,0 @@
|
||||
# Fix HTTP/Non-Secure Context Compatibility
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Issues:** [#4](https://github.com/stirling-image/stirling-image/issues/4), [#5](https://github.com/stirling-image/stirling-image/issues/5)
|
||||
|
||||
## Problem
|
||||
|
||||
`crypto.randomUUID()` and `navigator.clipboard.writeText()` are secure-context-only Web APIs. They throw or are undefined when the app is accessed over plain HTTP on non-localhost addresses (e.g., `http://192.168.1.x:1349`). This breaks all tool operations and copy-to-clipboard functionality.
|
||||
|
||||
Stirling-Image is a self-hosted tool where many users deploy on NAS/homelab devices over plain HTTP. This must be a first-class supported deployment mode.
|
||||
|
||||
## Solution
|
||||
|
||||
### Part 1: `generateId()` utility
|
||||
|
||||
Add to `apps/web/src/lib/utils.ts`:
|
||||
|
||||
```ts
|
||||
export function generateId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
```
|
||||
|
||||
Produces a standard UUID v4 string using `crypto.getRandomValues()`, which is available in all modern browsers regardless of secure context.
|
||||
|
||||
**6 call sites replaced** (`crypto.randomUUID()` -> `generateId()`):
|
||||
|
||||
| File | Line | Purpose |
|
||||
|------|------|---------|
|
||||
| `apps/web/src/hooks/use-tool-processor.ts` | 91 | Single-file job correlation ID |
|
||||
| `apps/web/src/hooks/use-tool-processor.ts` | 260 | Batch job correlation ID |
|
||||
| `apps/web/src/components/tools/ocr-settings.tsx` | 52 | OCR job ID |
|
||||
| `apps/web/src/components/tools/erase-object-settings.tsx` | 52 | Erase object job ID |
|
||||
| `apps/web/src/components/tools/pipeline-builder.tsx` | 104 | Pipeline step ID |
|
||||
| `apps/web/src/pages/automate-page.tsx` | 147 | Automation step ID |
|
||||
|
||||
### Part 2: `copyToClipboard()` utility
|
||||
|
||||
Add to `apps/web/src/lib/utils.ts`:
|
||||
|
||||
```ts
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback for non-secure contexts (HTTP on LAN)
|
||||
// document.execCommand is deprecated but works in all current browsers
|
||||
// and does not require a secure context
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tries the Clipboard API first (works on HTTPS/localhost). Falls back to `document.execCommand("copy")` which is deprecated but works in all current browsers including non-secure contexts. This is the standard clipboard compatibility pattern used by GitHub, Stack Overflow, etc. Returns `true`/`false` so callers can decide whether to show a "Copied!" confirmation. Only returns `false` if both approaches fail.
|
||||
|
||||
**4 call sites replaced** (`navigator.clipboard.writeText()` -> `copyToClipboard()`):
|
||||
|
||||
| File | Line | Current error handling |
|
||||
|------|------|-----------------------|
|
||||
| `apps/web/src/components/settings/settings-dialog.tsx` | 1148 | None - needs wrapping |
|
||||
| `apps/web/src/components/tools/color-palette-settings.tsx` | 47 | Has try-catch, simplify |
|
||||
| `apps/web/src/components/tools/ocr-settings.tsx` | 120 | None - needs wrapping |
|
||||
| `apps/web/src/components/tools/barcode-read-settings.tsx` | 48 | Has try-catch, simplify |
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- Server-side code: Node's `crypto.randomUUID()` works fine outside browsers
|
||||
- `vitest.config.ts`: Runs in Node, not a browser
|
||||
- No new dependencies added
|
||||
- No config file changes
|
||||
|
||||
## Testing
|
||||
|
||||
- `pnpm typecheck` - verify all imports resolve
|
||||
- `pnpm lint` - verify Biome formatting
|
||||
- `pnpm test` - catch regressions
|
||||
- Manual: access over HTTP on a non-localhost address, confirm tools work
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **`uuid` npm package** - rejected, adds a dependency for 5 lines of code that does the same thing internally
|
||||
2. **Try-catch fallback wrapper** (try native `randomUUID()`, fall back to `getRandomValues()`) - rejected, two code paths for zero meaningful performance benefit
|
||||
@@ -1,182 +0,0 @@
|
||||
# Lightweight Docker Image Without AI/ML Tools
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Issue:** stirling-image/stirling-image#1
|
||||
**Status:** Design approved
|
||||
|
||||
## Problem
|
||||
|
||||
The full Docker image is ~11 GB, mostly Python ML dependencies (rembg, RealESRGAN, PaddleOCR, MediaPipe, LaMa) and pre-downloaded model weights. Users on constrained hardware (Raspberry Pi, small VPS) or those who only need image processing tools are paying for size they don't use. First community feedback on r/selfhosted flagged this.
|
||||
|
||||
## Solution
|
||||
|
||||
Ship a `:lite` Docker tag that drops the Python sidecar and all ML dependencies. Keep every Sharp-based tool. Target size: 1-2 GB.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Build strategy | Single Dockerfile, `ARG VARIANT=full` | One file to maintain. Avoids drift between two Dockerfiles. |
|
||||
| Detection mechanism | Build-time `ENV STIRLING_VARIANT` | Explicit, instant, testable. No startup probing. |
|
||||
| API behavior (lite) | AI routes return 501 | Clear signal vs confusing 404. Tells consumers what to do. |
|
||||
| Frontend-API bridge | Extend `/v1/settings` response | Reuses existing fetch. Avoids extra endpoint complexity. |
|
||||
| Frontend UX | Grey out AI tools + "AI" badge + toast on click | Users see what they're missing. Toast links to docs for upgrade path. |
|
||||
| Tag naming | `:lite` / `:latest` (full) | "lite" = fewer features (accurate). "slim" = smaller OS base (misleading in Docker convention). |
|
||||
| Feature scope | All Sharp tools stay, 5 Python tools dropped | Sharp tools add zero meaningful size. All savings come from Python. |
|
||||
| Shared constants | `PYTHON_SIDECAR_TOOLS` in `packages/shared/` | Single source of truth for AI tool IDs across API and frontend. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dockerfile (`docker/Dockerfile`)
|
||||
|
||||
A build arg controls the variant, defaulting to `full`:
|
||||
|
||||
```dockerfile
|
||||
ARG VARIANT=full
|
||||
```
|
||||
|
||||
In the production stage, the Python installation block (system packages, venv, pip installs, model downloads) is wrapped in a shell conditional:
|
||||
|
||||
```dockerfile
|
||||
ARG VARIANT
|
||||
RUN if [ "$VARIANT" = "full" ]; then \
|
||||
apt-get install -y python3 python3-pip python3-venv python3-dev \
|
||||
tesseract-ocr tesseract-ocr-deu tesseract-ocr-fra \
|
||||
tesseract-ocr-spa tesseract-ocr-chi-sim \
|
||||
build-essential libgl1 libglib2.0-0 && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install ... && \
|
||||
python3 docker/download_models.py && \
|
||||
... model downloads ... \
|
||||
; fi
|
||||
```
|
||||
|
||||
A runtime env var is set from the build arg:
|
||||
|
||||
```dockerfile
|
||||
ENV STIRLING_VARIANT=${VARIANT}
|
||||
```
|
||||
|
||||
Packages kept in both variants (used by Sharp-based tools): imagemagick, libraw-dev, potrace, libheif-examples, gosu.
|
||||
|
||||
Packages dropped in lite: python3, python3-pip, python3-venv, python3-dev, tesseract-ocr (+ language packs), build-essential, libgl1, libglib2.0-0.
|
||||
|
||||
Base image stays `node:22-bookworm` for both variants. Switching lite to `bookworm-slim` is a future optimization, not in scope for the first pass.
|
||||
|
||||
Building the lite image: `docker build --build-arg VARIANT=lite -t stirling-image:lite .`
|
||||
|
||||
### Shared Constants (`packages/shared/`)
|
||||
|
||||
A new constant in `packages/shared/src/constants.ts`:
|
||||
|
||||
```typescript
|
||||
export const PYTHON_SIDECAR_TOOLS = [
|
||||
"remove-background",
|
||||
"upscale",
|
||||
"blur-faces",
|
||||
"erase-object",
|
||||
"ocr",
|
||||
] as const;
|
||||
```
|
||||
|
||||
This replaces the hardcoded `AI_PYTHON_TOOLS` set in `apps/web/src/hooks/use-tool-processor.ts` and is used by the API for route registration and settings response.
|
||||
|
||||
### API Changes (`apps/api/`)
|
||||
|
||||
**Route registration** (`apps/api/src/routes/tools/index.ts`):
|
||||
|
||||
When `STIRLING_VARIANT === "lite"`, the 5 AI tool routes are registered as lightweight stub handlers returning 501. The `@stirling-image/ai` package is not imported at all in lite mode (conditional import), avoiding any accidental Python spawn attempt.
|
||||
|
||||
```typescript
|
||||
if (process.env.STIRLING_VARIANT !== "lite") {
|
||||
// Register actual AI tool routes (import @stirling-image/ai)
|
||||
} else {
|
||||
// Register stub routes returning 501 for each PYTHON_SIDECAR_TOOLS entry
|
||||
}
|
||||
```
|
||||
|
||||
The 501 response:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 501,
|
||||
"error": "Not Available",
|
||||
"message": "This tool requires the full image. See docs at <link>"
|
||||
}
|
||||
```
|
||||
|
||||
**Settings endpoint** (`/v1/settings` response):
|
||||
|
||||
Two new fields, derived at startup from `process.env.STIRLING_VARIANT` and the shared constant. Not stored in the database.
|
||||
|
||||
```json
|
||||
{
|
||||
"...existing settings...",
|
||||
"variant": "lite",
|
||||
"variantUnavailableTools": ["remove-background", "upscale", "blur-faces", "erase-object", "ocr"]
|
||||
}
|
||||
```
|
||||
|
||||
In `full` mode: `variant: "full"`, `variantUnavailableTools: []`.
|
||||
|
||||
### Frontend Changes (`apps/web/`)
|
||||
|
||||
**Settings state**: Variant info is fetched once via a shared Zustand store (or shared hook) so both `ToolPanel` and `HomePage` access it without duplicate requests.
|
||||
|
||||
**Tool rendering**: Tools listed in `variantUnavailableTools` are rendered greyed out with an "AI" badge. Clicking shows a toast: "This tool requires the full image" with a link to the docs page.
|
||||
|
||||
This is distinct from user-disabled tools (`disabledTools`), which are hidden entirely with no toast.
|
||||
|
||||
**`use-tool-processor.ts`**: Replaces the hardcoded `AI_PYTHON_TOOLS` set with the shared `PYTHON_SIDECAR_TOOLS` constant import.
|
||||
|
||||
### CI/CD Changes (`.github/workflows/release.yml`)
|
||||
|
||||
The Docker build job uses a matrix strategy:
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [full, lite]
|
||||
```
|
||||
|
||||
Tags published per variant:
|
||||
|
||||
| Variant | Tags |
|
||||
|---------|------|
|
||||
| full | `latest`, `1.6.0`, `1.6`, `1` |
|
||||
| lite | `lite`, `1.6.0-lite`, `1.6-lite`, `1-lite` |
|
||||
|
||||
Both variants are multi-arch (`linux/amd64,linux/arm64`) and pushed to Docker Hub (`stirlingimage/stirling-image`) and GHCR (`ghcr.io/stirling-image/stirling-image`).
|
||||
|
||||
The CI workflow (`ci.yml`) also builds both variants as a smoke test (build only, no push).
|
||||
|
||||
### Documentation (`apps/docs/`)
|
||||
|
||||
A new docs page covering:
|
||||
|
||||
- What the lite image is and why it exists
|
||||
- Which tools are included vs excluded (the 5 AI tools)
|
||||
- Pull commands: `docker pull stirlingimage/stirling-image:lite`
|
||||
- Docker Compose examples for both variants
|
||||
- How to switch from lite to full when AI tools are needed
|
||||
|
||||
This is the page linked from the frontend toast and the 501 API response.
|
||||
|
||||
## Tools by Variant
|
||||
|
||||
### Included in lite (all Sharp-based, ~27 tools)
|
||||
|
||||
resize, crop, rotate, convert, compress, strip-metadata, color-adjustments, watermark-text, watermark-image, text-overlay, compose, info, compare, find-duplicates, color-palette, qr-generate, barcode-read, collage, split, border, svg-to-raster, vectorize, gif-tools, bulk-rename, favicon, image-to-pdf, replace-color, smart-crop
|
||||
|
||||
### Excluded from lite (Python sidecar required, 5 tools)
|
||||
|
||||
remove-background, upscale, blur-faces, erase-object, ocr
|
||||
|
||||
## Testing
|
||||
|
||||
- Build both variants in CI and verify they start successfully
|
||||
- Verify lite image does not contain Python, pip, or model weights
|
||||
- Verify AI routes return 501 in lite mode
|
||||
- Verify frontend shows greyed-out AI tools with correct toast in lite mode
|
||||
- Verify all Sharp-based tools work identically in both variants
|
||||
- Verify lite image size is in the 1-2 GB target range
|
||||
@@ -1,202 +0,0 @@
|
||||
# Edit Metadata Tool - Design Spec
|
||||
|
||||
**Date:** 2026-04-06
|
||||
**Issue:** [stirling-image/stirling-image#15](https://github.com/stirling-image/stirling-image/issues/15)
|
||||
**Approach:** Shared metadata infrastructure (Approach 2)
|
||||
|
||||
## Overview
|
||||
|
||||
A new tool for editing and selectively removing EXIF metadata from images. Covers common editable fields (description, artist, copyright, software, dates), GPS clearing, and granular per-field stripping. Builds on shared infrastructure extracted from the existing strip-metadata tool.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- Edit common EXIF fields: description, artist, copyright, software, date modified, date taken
|
||||
- GPS clear via checkbox
|
||||
- Granular strip: per-field removal of any displayed EXIF tag
|
||||
- Read-only display of current metadata (EXIF, GPS, XMP)
|
||||
- Pre-population of edit form from current values
|
||||
- Dirty tracking to distinguish untouched/edited/cleared fields
|
||||
- Shared metadata parsing and UI components extracted from strip-metadata
|
||||
|
||||
**Out of scope (potential future work):**
|
||||
- Arbitrary advanced EXIF field editing (camera make/model, lens, exposure, etc.)
|
||||
- XMP/ICC profile editing
|
||||
- Batch-specific metadata (different values per file)
|
||||
|
||||
## Architecture
|
||||
|
||||
### File changes
|
||||
|
||||
```
|
||||
packages/image-engine/
|
||||
src/utils/metadata.ts EXTEND add parseExif(), parseGps(), parseXmp(), sanitizeValue()
|
||||
src/operations/edit-metadata.ts NEW editMetadata() function
|
||||
src/types.ts EXTEND add EditMetadataOptions
|
||||
src/index.ts EXTEND export new operation
|
||||
|
||||
apps/api/
|
||||
src/routes/tools/edit-metadata.ts NEW /inspect + /edit endpoints
|
||||
src/routes/tools/strip-metadata.ts REFACTOR swap local parsing helpers for shared imports
|
||||
src/routes/tools/index.ts EXTEND register new tool
|
||||
|
||||
apps/web/
|
||||
src/components/common/collapsible-section.tsx NEW extract from strip-metadata
|
||||
src/components/common/metadata-grid.tsx NEW extract from strip-metadata
|
||||
src/lib/metadata-utils.ts NEW EXIF_LABELS, SKIP_KEYS, formatExifValue, exifStr
|
||||
src/components/tools/edit-metadata-settings.tsx NEW main component
|
||||
src/components/tools/strip-metadata-settings.tsx REFACTOR use shared imports
|
||||
src/lib/tool-registry.tsx EXTEND register new tool
|
||||
|
||||
packages/shared/
|
||||
src/constants.ts EXTEND add tool entry
|
||||
src/i18n/en.ts EXTEND add i18n strings
|
||||
```
|
||||
|
||||
### Image-engine layer
|
||||
|
||||
**Extended `utils/metadata.ts`** adds four parsing functions alongside the existing `getImageInfo()`:
|
||||
|
||||
- `sanitizeValue(v)` - makes EXIF values JSON-safe (Dates to ISO strings, Buffers to arrays or `<binary N bytes>`, recursion for nested objects)
|
||||
- `parseExif(exifBuffer)` - calls `exif-reader`, returns `{ image, photo, iop }` sections with sanitized values
|
||||
- `parseGps(gpsInfo)` - extracts DMS coordinates to decimal `{ latitude, longitude, altitude }`
|
||||
- `parseXmp(xmpBuffer)` - regex extraction of key/value pairs from XMP XML
|
||||
|
||||
**New `operations/edit-metadata.ts`** - `editMetadata(image, options)`:
|
||||
|
||||
- Maps common option fields (artist, copyright, imageDescription, software, dateTime, dateTimeOriginal) to their IFD0/IFD2 EXIF tag names
|
||||
- Accepts `fieldsToRemove: string[]` for granular strip
|
||||
- Logic:
|
||||
- If `clearGps` or `fieldsToRemove` has entries: read existing EXIF, rebuild the EXIF object minus the removed fields/GPS, merge in edits, then `withExif()` (full replace)
|
||||
- If only edits (no removals): `withExifMerge()` (non-destructive merge)
|
||||
- If nothing to do: `keepMetadata()` (passthrough)
|
||||
|
||||
**New type:**
|
||||
```ts
|
||||
interface EditMetadataOptions {
|
||||
artist?: string;
|
||||
copyright?: string;
|
||||
imageDescription?: string;
|
||||
software?: string;
|
||||
dateTime?: string;
|
||||
dateTimeOriginal?: string;
|
||||
clearGps?: boolean;
|
||||
fieldsToRemove?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### API route design
|
||||
|
||||
**`POST /api/v1/tools/edit-metadata/inspect`** - custom endpoint:
|
||||
- Accepts multipart file upload
|
||||
- Calls shared parsing functions from image-engine
|
||||
- Returns:
|
||||
```json
|
||||
{
|
||||
"filename": "photo.jpg",
|
||||
"fileSize": 2048000,
|
||||
"exif": { "Artist": "John", "Software": "Lightroom", ... },
|
||||
"gps": { "GPSLatitude": [...], "_latitude": 51.5074, "_longitude": -0.1278, ... },
|
||||
"xmp": { "dc:creator": "John", ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`POST /api/v1/tools/edit-metadata`** - via `createToolRoute` factory:
|
||||
- Settings schema:
|
||||
```ts
|
||||
z.object({
|
||||
artist: z.string().optional(),
|
||||
copyright: z.string().optional(),
|
||||
imageDescription: z.string().optional(),
|
||||
software: z.string().optional(),
|
||||
dateTime: z.string().optional(),
|
||||
dateTimeOriginal: z.string().optional(),
|
||||
clearGps: z.boolean().default(false),
|
||||
fieldsToRemove: z.array(z.string()).default([]),
|
||||
})
|
||||
```
|
||||
- Process function: reads format, calls `editMetadata(image, settings)`, re-encodes in original format, returns `{ buffer, filename, contentType }`
|
||||
|
||||
### UI component design
|
||||
|
||||
**Shared extractions (from strip-metadata):**
|
||||
- `CollapsibleSection` to `components/common/collapsible-section.tsx` - unchanged from strip-metadata
|
||||
- `MetadataGrid` to `components/common/metadata-grid.tsx` - extended with optional `onRemove?: (key: string) => void` and `removedKeys?: Set<string>` props. When `onRemove` is provided, each row shows a trash icon. When a key is in `removedKeys`, the row renders with strikethrough + muted styling. Strip-metadata passes neither prop (read-only behavior preserved).
|
||||
- `EXIF_LABELS`, `SKIP_KEYS`, `formatExifValue()`, `exifStr()` to `lib/metadata-utils.ts`
|
||||
|
||||
**`EditMetadataSettings` - three sections:**
|
||||
|
||||
**1. Current Metadata (read-only + granular strip)**
|
||||
- Auto-fetched via `/inspect` on file selection (per-file cache, AbortController cleanup)
|
||||
- EXIF: `CollapsibleSection` with `MetadataGrid`. String-typed and safely-serializable fields get a trash icon for granular removal. Binary blobs (MakerNote, PrintImageMatching) and complex array fields are displayed read-only without a remove option - this avoids data corruption from lossy EXIF round-trips through `withExif()`. Clicking a trash icon toggles the tag into `fieldsToRemove` set (strikethrough + muted styling).
|
||||
- GPS: `CollapsibleSection` with warning styling if GPS detected, coordinates displayed
|
||||
|
||||
**2. Edit Fields**
|
||||
- Common fields: Description, Artist, Copyright, Software, Date Modified, Date Taken as `LabeledInput` components, pre-populated from inspect data
|
||||
- Dirty tracking: store initial values from inspect. On submit, compare current to initial. Changed + has value = include in settings. Changed + empty = add to `fieldsToRemove`. Untouched = skip.
|
||||
- GPS: "Remove GPS location data" checkbox with coordinate display if present
|
||||
|
||||
**3. Submit / Download**
|
||||
- Submit via `useToolProcessor("edit-metadata")`
|
||||
- `ProgressCard` during processing, download link after
|
||||
|
||||
**Display mode:** `"no-comparison"` in tool registry.
|
||||
|
||||
**Edit + remove conflict resolution:** If a user marks a field for removal in the metadata view AND edits the same field in the edit form, the edit wins. Submit logic checks edit fields first, only adds to `fieldsToRemove` tags that aren't being written.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User drops image into dropzone
|
||||
2. Component auto-calls `/inspect`, parses response, pre-populates form, stores initial values
|
||||
3. User edits fields and/or marks tags for removal in metadata view
|
||||
4. On submit: dirty-diff builds settings object (e.g. `{ artist: "New Name", fieldsToRemove: ["Software", "MeteringMode"], clearGps: true }`)
|
||||
5. Tool factory receives file + settings, calls `editMetadata()`, re-encodes, returns download URL
|
||||
6. User downloads modified image
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Inspect fails** (corrupt file, unsupported format): inline warning "Could not read metadata", form fields start empty, user can still write new metadata
|
||||
- **No EXIF in image**: "No metadata found" in current metadata section, form fields start empty, editing still works (writes fresh EXIF)
|
||||
- **Format with limited EXIF support** (PNG): no special handling. Sharp writes what the format supports, silently drops what it doesn't. Matches strip-metadata behavior.
|
||||
- **Processing fails**: tool factory returns 422, component displays error from response
|
||||
- **No changes submitted**: `keepMetadata()` passthrough, image re-encoded with metadata preserved
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (image-engine)
|
||||
- `editMetadata` writes common fields, readable back via `exif-reader`
|
||||
- `editMetadata` with `clearGps: true` removes GPS, preserves other EXIF
|
||||
- `editMetadata` with `fieldsToRemove` drops specific tags, preserves others
|
||||
- `editMetadata` with no options preserves metadata
|
||||
- Edit + remove conflict: edit wins
|
||||
- Works through `processImage` pipeline
|
||||
|
||||
### Unit tests (web utilities)
|
||||
- Dirty tracking: detects changed fields, cleared fields, ignores untouched
|
||||
- Settings builder: correctly splits edits vs removals
|
||||
- `formatExifValue` and `exifStr` tests (moved from fork's tests to shared location)
|
||||
|
||||
### Integration tests (API)
|
||||
- `/inspect` returns parsed EXIF/GPS/XMP for test JPEG with known metadata
|
||||
- `/inspect` returns nulls for metadata-free PNG
|
||||
- `/inspect` rejects no-file and invalid-file requests
|
||||
- Edit endpoint writes metadata, returns downloadable file
|
||||
- Edit endpoint with `fieldsToRemove` strips specific tags
|
||||
- Edit endpoint with `clearGps` removes GPS
|
||||
- Edit endpoint with empty settings preserves original metadata
|
||||
|
||||
### Strip-metadata regression
|
||||
- Re-run all existing strip-metadata tests after the shared extraction refactor to confirm no behavioral changes
|
||||
|
||||
### E2e tests (Playwright)
|
||||
- Tool appears in tool list and is navigable
|
||||
- Upload image, verify metadata displays
|
||||
- Edit a field, submit, download, re-upload and verify
|
||||
- Mark a field for removal, submit, verify removal
|
||||
- Add to `tools-all.spec.ts`
|
||||
|
||||
### Docker + Playwright GUI verification
|
||||
- Docker rebuild with cache
|
||||
- Spin up container
|
||||
- Playwright headed/GUI mode against running container
|
||||
- Manual verification: navigate to tool, upload test image with known EXIF/GPS, confirm metadata displays, edit fields, mark tags for removal, submit, download, re-upload to confirm changes persisted
|
||||
Reference in New Issue
Block a user