diff --git a/README.md b/README.md index de7f11fc..15829ab8 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ ## Quick Start ```bash -docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest +docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ``` -Open http://localhost:1349 in your browser.
Have an NVIDIA GPU? Click here for GPU acceleration. @@ -36,7 +36,8 @@ Open http://localhost:1349 in your browser. Add `--gpus all` for GPU-accelerated background removal, upscaling, and OCR: ```bash -docker run -d --name SnapOtter -p 1349:1349 --gpus all -v SnapOtter-data:/data snapotter/snapotter:latest +docker run -d --name SnapOtter -p 1349:1349 --gpus all -v SnapOtter-data:/data snapotter/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ``` > Requires an NVIDIA GPU and [Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU if no GPU is found. See [Docker Tags](https://docs.snapotter.com/guide/docker-tags) for benchmarks and Docker Compose examples. diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 0df3e00a..921d32b9 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -67,7 +67,7 @@ export interface ValidationError { const RAW_EXTENSIONS = new Set(["dng", "cr2", "nef", "arw", "orf", "rw2"]); /** Formats that Sharp cannot decode natively — skip dimension check. */ -const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr"]); +const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]); /** * Check whether a file extension corresponds to a Camera RAW format. diff --git a/apps/api/src/openapi.yaml b/apps/api/src/openapi.yaml index 3318f94b..8ae6d101 100644 --- a/apps/api/src/openapi.yaml +++ b/apps/api/src/openapi.yaml @@ -64,16 +64,14 @@ components: schemas: Error: type: object + required: [error] properties: - statusCode: - type: integer - example: 400 error: - type: string - example: Bad Request - message: type: string example: Invalid image format + details: + type: string + description: Additional error details (e.g. Zod validation errors) ToolResponse: type: object @@ -91,6 +89,12 @@ components: processedSize: type: integer description: Processed file size in bytes + previewUrl: + type: string + description: URL to a WebP preview (present when output is not browser-previewable, e.g. TIFF, HEIF) + savedFileId: + type: string + description: ID of the auto-saved file in the library (present when fileId was provided in the request) UnauthorizedError: type: object @@ -228,7 +232,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -334,7 +338,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -382,7 +386,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -428,7 +432,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -473,7 +477,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -519,7 +523,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -567,7 +571,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -626,7 +630,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -681,7 +685,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -694,6 +698,11 @@ paths: - `brightness` (number -100 to 100, default 0) — Brightness adjustment - `contrast` (number -100 to 100, default 0) — Contrast adjustment - `saturation` (number -100 to 100, default 0) — Saturation adjustment + - `exposure` (number -100 to 100, default 0) — Exposure adjustment + - `temperature` (number -100 to 100, default 0) — Color temperature + - `tint` (number -100 to 100, default 0) — Tint adjustment + - `hue` (number -180 to 180, default 0) — Hue rotation + - `sharpness` (number 0 to 100, default 0) — Sharpness - `red` (number 0-200, default 100) — Red channel multiplier - `green` (number 0-200, default 100) — Green channel multiplier - `blue` (number 0-200, default 100) — Blue channel multiplier @@ -731,7 +740,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -744,6 +753,11 @@ paths: - `brightness` (number -100 to 100, default 0) — Brightness adjustment - `contrast` (number -100 to 100, default 0) — Contrast adjustment - `saturation` (number -100 to 100, default 0) — Saturation adjustment + - `exposure` (number -100 to 100, default 0) — Exposure adjustment + - `temperature` (number -100 to 100, default 0) — Color temperature + - `tint` (number -100 to 100, default 0) — Tint adjustment + - `hue` (number -180 to 180, default 0) — Hue rotation + - `sharpness` (number 0 to 100, default 0) — Sharpness - `red` (number 0-200, default 100) — Red channel multiplier - `green` (number 0-200, default 100) — Green channel multiplier - `blue` (number 0-200, default 100) — Blue channel multiplier @@ -781,7 +795,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -794,6 +808,11 @@ paths: - `brightness` (number -100 to 100, default 0) — Brightness adjustment - `contrast` (number -100 to 100, default 0) — Contrast adjustment - `saturation` (number -100 to 100, default 0) — Saturation adjustment + - `exposure` (number -100 to 100, default 0) — Exposure adjustment + - `temperature` (number -100 to 100, default 0) — Color temperature + - `tint` (number -100 to 100, default 0) — Tint adjustment + - `hue` (number -180 to 180, default 0) — Hue rotation + - `sharpness` (number 0 to 100, default 0) — Sharpness - `red` (number 0-200, default 100) — Red channel multiplier - `green` (number 0-200, default 100) — Green channel multiplier - `blue` (number 0-200, default 100) — Blue channel multiplier @@ -831,7 +850,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -844,6 +863,11 @@ paths: - `brightness` (number -100 to 100, default 0) — Brightness adjustment - `contrast` (number -100 to 100, default 0) — Contrast adjustment - `saturation` (number -100 to 100, default 0) — Saturation adjustment + - `exposure` (number -100 to 100, default 0) — Exposure adjustment + - `temperature` (number -100 to 100, default 0) — Color temperature + - `tint` (number -100 to 100, default 0) — Tint adjustment + - `hue` (number -180 to 180, default 0) — Hue rotation + - `sharpness` (number 0 to 100, default 0) — Sharpness - `red` (number 0-200, default 100) — Red channel multiplier - `green` (number 0-200, default 100) — Green channel multiplier - `blue` (number 0-200, default 100) — Blue channel multiplier @@ -881,7 +905,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -937,7 +961,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -1048,7 +1072,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -1898,7 +1922,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -1947,7 +1971,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -1997,7 +2021,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -2044,7 +2068,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -2157,7 +2181,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -2212,7 +2236,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -2263,7 +2287,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -2312,7 +2336,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: array @@ -2358,7 +2382,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: array @@ -2379,7 +2403,13 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ToolResponse" + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + pages: + type: integer + description: Number of images/pages in the PDF "400": description: Invalid input content: @@ -2392,6 +2422,12 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/tools/pdf-to-image: post: @@ -2566,7 +2602,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -2616,7 +2652,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: array @@ -2817,11 +2853,24 @@ paths: description: Second image to compare responses: "200": - description: Processed image + description: Comparison result content: application/json: schema: - $ref: "#/components/schemas/ToolResponse" + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + similarity: + type: number + description: Structural similarity score (0-1) + dimensions: + type: object + properties: + width: + type: integer + height: + type: integer "400": description: Invalid input content: @@ -2834,6 +2883,12 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/tools/erase-object: post: @@ -2874,7 +2929,7 @@ paths: description: Client-provided job ID for SSE progress tracking responses: "200": - description: Processed image + description: Processed image with erased regions content: application/json: schema: @@ -2891,6 +2946,18 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "501": + description: Feature not installed + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureNotInstalledError" /api/v1/tools/collage: post: @@ -2905,7 +2972,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: array @@ -3035,11 +3102,24 @@ paths: - `shadowOpacity` (number 0-100, optional) — Shadow opacity responses: "200": - description: Processed image + description: Processed image with background removed content: application/json: schema: - $ref: "#/components/schemas/ToolResponse" + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + maskUrl: + type: string + description: URL to the mask image + originalUrl: + type: string + description: URL to the original uploaded image + filename: + type: string + model: + type: string "400": description: Invalid input content: @@ -3052,6 +3132,18 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "501": + description: Feature not installed + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureNotInstalledError" /api/v1/tools/remove-background/effects: post: @@ -3129,7 +3221,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -3147,11 +3239,20 @@ paths: - `quality` (number, default 95) — Output quality (1-100) responses: "200": - description: Processed image + description: Upscaled image content: application/json: schema: - $ref: "#/components/schemas/ToolResponse" + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + width: + type: integer + height: + type: integer + method: + type: string "400": description: Invalid input content: @@ -3164,6 +3265,18 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "501": + description: Feature not installed + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureNotInstalledError" /api/v1/tools/blur-faces: post: @@ -3178,7 +3291,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -3190,13 +3303,26 @@ paths: JSON string with options: - `blurRadius` (number 1-100, default 30) — Blur strength radius - `sensitivity` (number 0-1, default 0.5) — Face detection sensitivity + clientJobId: + type: string responses: "200": - description: Processed image + description: Processed image with faces blurred content: application/json: schema: - $ref: "#/components/schemas/ToolResponse" + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + facesDetected: + type: integer + faces: + type: array + items: + type: object + warning: + type: string "400": description: Invalid input content: @@ -3209,6 +3335,18 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "501": + description: Feature not installed + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureNotInstalledError" /api/v1/tools/ocr: post: @@ -3223,7 +3361,7 @@ paths: multipart/form-data: schema: type: object - required: [file, settings] + required: [file] properties: file: type: string @@ -3237,6 +3375,8 @@ paths: - `language` (string, default "auto") — One of: auto, en, de, fr, es, zh, ja, ko - `enhance` (boolean, default true) — Pre-process image for better recognition - `engine` (string, optional) — One of: tesseract, paddleocr (backward compat) + clientJobId: + type: string responses: "200": description: Extracted text @@ -3245,13 +3385,15 @@ paths: schema: type: object properties: + jobId: + type: string + filename: + type: string text: type: string description: Extracted text content engine: type: string - confidence: - type: number "400": description: Invalid input content: @@ -3264,6 +3406,18 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "501": + description: Feature not installed + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureNotInstalledError" /api/v1/tools/info: post: @@ -3296,22 +3450,53 @@ paths: type: integer height: type: integer + filename: + type: string format: type: string - space: + colorSpace: type: string channels: type: integer - depth: + bitDepth: type: string density: type: integer + nullable: true hasAlpha: type: boolean fileSize: type: integer - exif: - type: object + hasExif: + type: boolean + isProgressive: + type: boolean + orientation: + type: integer + nullable: true + hasProfile: + type: boolean + hasIcc: + type: boolean + hasXmp: + type: boolean + pages: + type: integer + histogram: + type: array + items: + type: object + properties: + channel: + type: string + min: + type: number + max: + type: number + mean: + type: number + stdev: + type: number "400": description: Invalid input content: @@ -3324,6 +3509,12 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/tools/color-palette: post: @@ -3352,11 +3543,15 @@ paths: schema: type: object properties: + filename: + type: string colors: type: array items: type: string description: Hex color strings + count: + type: integer "400": description: Invalid input content: @@ -3402,10 +3597,46 @@ paths: schema: type: object properties: - data: + filename: type: string - location: - type: object + barcodes: + type: array + items: + type: object + properties: + type: + type: string + text: + type: string + position: + type: object + properties: + topLeft: + type: object + properties: + x: { type: number } + y: { type: number } + topRight: + type: object + properties: + x: { type: number } + y: { type: number } + bottomLeft: + type: object + properties: + x: { type: number } + y: { type: number } + bottomRight: + type: object + properties: + x: { type: number } + y: { type: number } + annotatedUrl: + type: string + nullable: true + previewUrl: + type: string + nullable: true "400": description: Invalid input content: @@ -3418,6 +3649,12 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/tools/find-duplicates: post: @@ -3453,17 +3690,40 @@ paths: schema: type: object properties: - groups: + totalImages: + type: integer + duplicateGroups: type: array items: type: object properties: + groupId: + type: integer files: type: array items: - type: string - similarity: - type: number + type: object + properties: + filename: + type: string + similarity: + type: number + width: + type: integer + height: + type: integer + fileSize: + type: integer + format: + type: string + isBest: + type: boolean + thumbnail: + type: string + uniqueImages: + type: integer + spaceSaveable: + type: integer "400": description: Invalid input content: @@ -3476,6 +3736,12 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/tools/qr-generate: post: @@ -3561,9 +3827,16 @@ paths: schema: type: object properties: + filename: + type: string + fileSize: + type: integer exif: type: object description: EXIF metadata + exifError: + type: string + description: Present when EXIF parsing fails gps: type: object description: GPS metadata @@ -3751,7 +4024,7 @@ paths: post: tags: [Pipelines] summary: Execute a pipeline - description: Run an image through a chain of tools. + description: Run an image through a chain of tools. The pipeline definition is sent as a JSON string in the `pipeline` multipart field. security: - bearerAuth: [] requestBody: @@ -3760,15 +4033,15 @@ paths: multipart/form-data: schema: type: object - required: [file, steps] + required: [file, pipeline] properties: file: type: string format: binary description: Image file to process - steps: + pipeline: type: string - description: JSON array string of {toolId, settings} objects + description: 'JSON string: { "steps": [{ "toolId": "resize", "settings": {...} }, ...] }' responses: "200": description: Pipeline result @@ -3781,8 +4054,16 @@ paths: type: string downloadUrl: type: string + originalSize: + type: integer + processedSize: + type: integer stepsCompleted: type: integer + steps: + type: array + items: + type: object "400": description: Invalid input content: @@ -3795,6 +4076,18 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "501": + description: Feature not installed + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureNotInstalledError" /api/v1/pipeline/save: post: @@ -3824,8 +4117,8 @@ paths: settings: type: object responses: - "200": - description: Saved pipeline + "201": + description: Pipeline saved content: application/json: schema: @@ -3835,6 +4128,15 @@ paths: type: string name: type: string + description: + type: string + steps: + type: array + items: + type: object + createdAt: + type: string + format: date-time "400": description: Invalid input content: @@ -3907,8 +4209,27 @@ paths: schema: type: string responses: - "204": + "200": description: Pipeline deleted + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + "403": + description: Not authorized to delete this pipeline + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" + "404": + description: Pipeline not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "400": description: Invalid input content: @@ -4031,8 +4352,17 @@ paths: properties: jobId: type: string - filename: - type: string + files: + type: array + items: + type: object + properties: + name: + type: string + size: + type: integer + format: + type: string "400": description: Invalid input content: @@ -4112,8 +4442,27 @@ paths: get: tags: [Files] summary: List saved files + description: Returns the latest version of each file (one per version chain). Supports pagination and search. security: - bearerAuth: [] + parameters: + - name: limit + in: query + description: Maximum number of files to return (default 50) + schema: + type: integer + default: 50 + - name: offset + in: query + description: Number of files to skip for pagination (default 0) + schema: + type: integer + default: 0 + - name: search + in: query + description: Filter files by original filename (substring match) + schema: + type: string responses: "200": description: List of saved files @@ -4138,6 +4487,15 @@ paths: createdAt: type: string format: date-time + total: + type: integer + description: Total number of matching files (for pagination) + limit: + type: integer + description: Applied limit value + offset: + type: integer + description: Applied offset value "400": description: Invalid input content: @@ -4211,17 +4569,18 @@ paths: type: string format: binary responses: - "200": - description: Saved file + "201": + description: Files uploaded content: application/json: schema: type: object properties: - id: - type: string - filename: - type: string + files: + type: array + items: + type: object + description: Full file object with all metadata "400": description: Invalid input content: @@ -4239,32 +4598,38 @@ paths: post: tags: [Files] summary: Save a processing result to library + description: Save a tool processing result as a new version of an existing file. Creates a version chain. security: - bearerAuth: [] requestBody: required: true content: - application/json: + multipart/form-data: schema: type: object - required: [jobId, filename] + required: [file, parentId] properties: - jobId: + file: type: string - filename: + format: binary + description: The processed image file + parentId: type: string + description: ID of the parent file in the version chain + toolId: + type: string + description: Tool that produced this result responses: - "200": - description: Saved file + "201": + description: File saved content: application/json: schema: type: object properties: - id: - type: string - filename: - type: string + file: + type: object + description: Full file object with all metadata "400": description: Invalid input content: @@ -4277,11 +4642,17 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "404": + description: Parent file not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/files/{id}: get: tags: [Files] - summary: Get file metadata + summary: Get file metadata and version history security: - bearerAuth: [] parameters: @@ -4293,32 +4664,56 @@ paths: type: string responses: "200": - description: File metadata + description: File metadata with version history content: application/json: schema: type: object properties: - id: - type: string - filename: - type: string - size: - type: integer - contentType: - type: string - "400": - description: Invalid input - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + file: + type: object + properties: + id: + type: string + originalName: + type: string + mimeType: + type: string + size: + type: integer + width: + type: integer + height: + type: integer + version: + type: integer + parentId: + type: string + nullable: true + toolChain: + type: array + items: + type: string + createdAt: + type: string + format: date-time + versions: + type: array + items: + type: object + description: Full version chain of this file "401": description: Authentication required content: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "404": + description: File not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/files/{id}/download: get: @@ -4341,25 +4736,24 @@ paths: schema: type: string format: binary - "400": - description: Invalid input - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "401": description: Authentication required content: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "404": + description: File not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/v1/files/{id}/thumbnail: get: tags: [Files] summary: Get file thumbnail - security: - - bearerAuth: [] + security: [] parameters: - name: id in: path @@ -4369,24 +4763,24 @@ paths: type: string responses: "200": - description: Image binary + description: 300px JPEG thumbnail content: - image/*: + image/jpeg: schema: type: string format: binary - "400": - description: Invalid input + "404": + description: File not found content: application/json: schema: $ref: "#/components/schemas/Error" - "401": - description: Authentication required + "422": + description: Thumbnail generation failed content: application/json: schema: - $ref: "#/components/schemas/UnauthorizedError" + $ref: "#/components/schemas/Error" # ─── Auth ───────────────────────────────────────────────────────────────── @@ -4427,6 +4821,26 @@ paths: type: string role: type: string + mustChangePassword: + type: boolean + permissions: + type: array + items: + type: string + teamName: + type: string + analyticsEnabled: + type: boolean + nullable: true + analyticsConsentShownAt: + type: integer + nullable: true + analyticsConsentRemindAt: + type: integer + nullable: true + expiresAt: + type: string + format: date-time "400": description: Invalid input content: @@ -4447,14 +4861,15 @@ paths: security: - bearerAuth: [] responses: - "204": + "200": description: Logged out - "400": - description: Invalid input content: application/json: schema: - $ref: "#/components/schemas/Error" + type: object + properties: + ok: + type: boolean "401": description: Authentication required content: @@ -4484,8 +4899,25 @@ paths: type: string role: type: string - authenticated: - type: boolean + mustChangePassword: + type: boolean + permissions: + type: array + items: + type: string + analyticsEnabled: + type: boolean + nullable: true + analyticsConsentShownAt: + type: integer + nullable: true + analyticsConsentRemindAt: + type: integer + nullable: true + expiresAt: + type: string + format: date-time + nullable: true /api/auth/change-password: post: @@ -4508,8 +4940,15 @@ paths: type: string format: password responses: - "204": + "200": description: Password changed + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean "400": description: Invalid input content: @@ -4526,7 +4965,8 @@ paths: /api/auth/users: get: tags: [Auth] - summary: List users (admin) + summary: List users + description: Requires users:manage permission. security: - bearerAuth: [] responses: @@ -4555,12 +4995,6 @@ paths: format: date-time maxUsers: type: integer - "400": - description: Invalid input - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "401": description: Authentication required content: @@ -4568,14 +5002,17 @@ paths: schema: $ref: "#/components/schemas/UnauthorizedError" "403": - description: Admin access required + description: Insufficient permissions content: application/json: schema: $ref: "#/components/schemas/ForbiddenError" + + /api/auth/register: post: tags: [Auth] - summary: Create user (admin) + summary: Create user + description: Requires users:manage permission. security: - bearerAuth: [] requestBody: @@ -4593,7 +5030,7 @@ paths: format: password role: type: string - enum: [admin, user] + description: Built-in role (admin, editor, user) or custom role name default: user team: type: string @@ -4612,6 +5049,8 @@ paths: type: string role: type: string + team: + type: string "400": description: Invalid input content: @@ -4625,7 +5064,7 @@ paths: schema: $ref: "#/components/schemas/UnauthorizedError" "403": - description: Admin access required + description: Insufficient permissions content: application/json: schema: @@ -4640,7 +5079,8 @@ paths: /api/auth/users/{id}: put: tags: [Auth] - summary: Update user (admin) + summary: Update user + description: Requires users:manage permission. security: - bearerAuth: [] parameters: @@ -4659,7 +5099,7 @@ paths: properties: role: type: string - enum: [admin, user] + description: Built-in role (admin, editor, user) or custom role name team: type: string responses: @@ -4670,12 +5110,8 @@ paths: schema: type: object properties: - id: - type: string - username: - type: string - role: - type: string + ok: + type: boolean "400": description: Invalid input content: @@ -4689,14 +5125,15 @@ paths: schema: $ref: "#/components/schemas/UnauthorizedError" "403": - description: Admin access required + description: Insufficient permissions content: application/json: schema: $ref: "#/components/schemas/ForbiddenError" delete: tags: [Auth] - summary: Delete user (admin) + summary: Delete user + description: Requires users:manage permission. security: - bearerAuth: [] parameters: @@ -4707,14 +5144,15 @@ paths: schema: type: string responses: - "204": + "200": description: User deleted - "400": - description: Invalid input content: application/json: schema: - $ref: "#/components/schemas/Error" + type: object + properties: + ok: + type: boolean "401": description: Authentication required content: @@ -4722,7 +5160,7 @@ paths: schema: $ref: "#/components/schemas/UnauthorizedError" "403": - description: Admin access required + description: Insufficient permissions content: application/json: schema: @@ -4731,7 +5169,8 @@ paths: /api/auth/users/{id}/reset-password: post: tags: [Auth] - summary: Reset user password (admin) + summary: Reset user password + description: Requires users:manage permission. security: - bearerAuth: [] parameters: @@ -4753,8 +5192,15 @@ paths: type: string format: password responses: - "204": + "200": description: Password reset + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean "400": description: Invalid input content: @@ -4768,7 +5214,7 @@ paths: schema: $ref: "#/components/schemas/UnauthorizedError" "403": - description: Admin access required + description: Insufficient permissions content: application/json: schema: @@ -4782,7 +5228,7 @@ paths: summary: Create an API key description: > Generate a new API key. The full key (prefixed with si_) is returned - only once. + only once. Keys are stored using scrypt hashing. security: - bearerAuth: [] requestBody: @@ -4791,11 +5237,20 @@ paths: application/json: schema: type: object - required: [name] properties: name: type: string maxLength: 100 + default: Default API Key + permissions: + type: array + items: + type: string + description: Scoped permissions for this key + expiresAt: + type: string + format: date-time + description: Optional expiration date responses: "201": description: API key created @@ -4806,12 +5261,22 @@ paths: properties: id: type: string - name: - type: string key: type: string - prefix: + description: Full key (only shown once) + name: type: string + permissions: + type: array + items: + type: string + expiresAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time "400": description: Invalid input content: @@ -4838,7 +5303,7 @@ paths: schema: type: object properties: - keys: + apiKeys: type: array items: type: object @@ -4847,17 +5312,21 @@ paths: type: string name: type: string - prefix: - type: string + permissions: + type: array + items: + type: string createdAt: type: string format: date-time - "400": - description: Invalid input - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + lastUsedAt: + type: string + format: date-time + nullable: true + expiresAt: + type: string + format: date-time + nullable: true "401": description: Authentication required content: @@ -4879,14 +5348,15 @@ paths: schema: type: string responses: - "204": + "200": description: API key deleted - "400": - description: Invalid input content: application/json: schema: - $ref: "#/components/schemas/Error" + type: object + properties: + ok: + type: boolean "401": description: Authentication required content: @@ -4904,19 +5374,16 @@ paths: - bearerAuth: [] responses: "200": - description: All settings as key-value pairs + description: All settings wrapped in a settings object content: application/json: schema: type: object - additionalProperties: - type: string - "400": - description: Invalid input - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + properties: + settings: + type: object + additionalProperties: + type: string "401": description: Authentication required content: @@ -4925,7 +5392,8 @@ paths: $ref: "#/components/schemas/UnauthorizedError" put: tags: [Settings] - summary: Update settings (admin) + summary: Update settings + description: Accepts a flat JSON object of key-value pairs to set. Requires admin role. security: - bearerAuth: [] requestBody: @@ -4939,6 +5407,15 @@ paths: responses: "200": description: Settings updated + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + updatedCount: + type: integer "400": description: Invalid input content: @@ -4983,14 +5460,17 @@ paths: type: string value: type: string - "400": - description: Invalid input + updatedAt: + type: string + format: date-time + "401": + description: Authentication required content: application/json: schema: - $ref: "#/components/schemas/Error" - "401": - description: Authentication required + $ref: "#/components/schemas/UnauthorizedError" + "404": + description: Setting not found content: application/json: schema: @@ -5002,6 +5482,7 @@ paths: get: tags: [Teams] summary: List teams + description: Requires teams:manage permission. security: - bearerAuth: [] responses: @@ -5018,24 +5499,30 @@ paths: type: object properties: id: - type: integer + type: string name: type: string - "400": - description: Invalid input - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + memberCount: + type: integer + createdAt: + type: string + format: date-time "401": description: Authentication required content: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" post: tags: [Teams] summary: Create a team + description: Requires teams:manage permission. security: - bearerAuth: [] requestBody: @@ -5057,7 +5544,7 @@ paths: type: object properties: id: - type: integer + type: string name: type: string "400": @@ -5072,6 +5559,12 @@ paths: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" "409": description: Team name already exists content: @@ -5083,15 +5576,16 @@ paths: put: tags: [Teams] summary: Rename a team + description: Requires teams:manage permission. security: - bearerAuth: [] parameters: - name: id in: path required: true - description: Team integer ID + description: Team UUID schema: - type: integer + type: string requestBody: required: true content: @@ -5110,49 +5604,73 @@ paths: schema: type: object properties: - id: - type: integer - name: - type: string - "400": - description: Invalid input - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + ok: + type: boolean "401": description: Authentication required content: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Team name already exists + content: + application/json: + schema: + $ref: "#/components/schemas/ConflictError" delete: tags: [Teams] summary: Delete a team + description: Requires teams:manage permission. security: - bearerAuth: [] parameters: - name: id in: path required: true - description: Team integer ID + description: Team UUID schema: - type: integer + type: string responses: - "204": + "200": description: Team deleted - "400": - description: Invalid input content: application/json: schema: - $ref: "#/components/schemas/Error" + type: object + properties: + ok: + type: boolean "401": description: Authentication required content: application/json: schema: $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" + "404": + description: Team not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" # ─── Branding ───────────────────────────────────────────────────────────── @@ -5211,12 +5729,20 @@ paths: $ref: "#/components/schemas/ForbiddenError" delete: tags: [Branding] - summary: Remove custom logo (admin) + summary: Remove custom logo + description: Requires branding:manage permission. security: - bearerAuth: [] responses: - "204": + "200": description: Logo removed + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean "400": description: Invalid input content: @@ -5271,11 +5797,18 @@ paths: type: string responses: "200": - description: SSE stream of job progress events + description: > + SSE stream of job progress events. Two event types: `single` (single-file tools) + and `batch` (batch processing). Single events have `status` and `progress`. + Batch events have `completedFiles`, `totalFiles`, `failedFiles` (integers), + and an `errors` array. content: text/event-stream: schema: type: object + description: > + Event shape varies by type. Single: { status, progress }. + Batch: { completedFiles, totalFiles, failedFiles, errors }. properties: status: type: string @@ -5285,13 +5818,24 @@ paths: minimum: 0 maximum: 100 completedFiles: - type: array - items: - type: string + type: integer + description: Number of completed files (batch mode) + totalFiles: + type: integer + description: Total files to process (batch mode) failedFiles: + type: integer + description: Number of failed files (batch mode) + errors: type: array items: - type: string + type: object + properties: + filename: + type: string + error: + type: string + description: Error details for failed files (batch mode) # ─── Admin ─────────────────────────────────────────────────────────────── diff --git a/apps/docs/api/rest.md b/apps/docs/api/rest.md index 876d042b..7995ed1d 100644 --- a/apps/docs/api/rest.md +++ b/apps/docs/api/rest.md @@ -42,7 +42,7 @@ curl http://localhost:1349/api/v1/tools/resize \ -H "Authorization: Bearer si_" ``` -Keys are prefixed `si_` and stored as SHA-256 hashes - the raw key is shown once and never retrievable again. +Keys are prefixed `si_` and stored as scrypt hashes - the raw key is shown once and never retrievable again. ### Auth Endpoints @@ -57,6 +57,7 @@ Keys are prefixed `si_` and stored as SHA-256 hashes - the raw key is shown once | `PUT` | `/api/auth/users/:id` | Admin | Update user role or team | | `POST` | `/api/auth/users/:id/reset-password` | Admin | Reset user's password | | `DELETE` | `/api/auth/users/:id` | Admin | Delete a user | +| `GET` | `/api/v1/config/auth` | Public | Check if authentication is enabled (`{ authEnabled: bool }`) | ### Permissions @@ -107,16 +108,16 @@ curl -X POST http://localhost:1349/api/v1/tools//batch \ | Tool ID | Name | Key settings | |---------|------|-------------| | `resize` | Resize | `width`, `height`, `fit` (cover/contain/fill/inside/outside), `percentage`, `withoutEnlargement`, plus 23 social media presets | -| `crop` | Crop | `left`, `top`, `width`, `height`, `aspectRatio`, `shape` (rectangle/circle/rounded) | -| `rotate` | Rotate & Flip | `angle`, `flip` (horizontal/vertical/both), `background` | -| `convert` | Convert | `format` (jpeg/png/webp/avif/tiff/gif/heif), `quality` | -| `compress` | Compress | `quality` (1–100), `format`, `targetSizeKB` | +| `crop` | Crop | `left`, `top`, `width`, `height`, `unit` (px/percent) | +| `rotate` | Rotate & Flip | `angle`, `horizontal` (bool), `vertical` (bool) | +| `convert` | Convert | `format` (jpg/png/webp/avif/tiff/gif/heic/heif), `quality` | +| `compress` | Compress | `mode` (quality/targetSize), `quality` (1–100), `targetSizeKb` | ### Optimization | Tool ID | Name | Key settings | |---------|------|-------------| -| `optimize-for-web` | Optimize for Web | `format` (auto/jpeg/webp/avif), `quality`, `maxWidthPx`, `stripMetadata` | +| `optimize-for-web` | Optimize for Web | `format` (webp/jpeg/avif/png), `quality`, `maxWidth`, `maxHeight`, `progressive`, `stripMetadata` | | `strip-metadata` | Strip Metadata | - | | `edit-metadata` | Edit Metadata | `title`, `description`, `author`, `copyright`, `keywords`, `gps` (lat/lon), `dateTime` | | `bulk-rename` | Bulk Rename | `pattern` (supports `{n}`, `{date}`, `{original}`), `startIndex`, `padding` | @@ -127,9 +128,9 @@ curl -X POST http://localhost:1349/api/v1/tools//batch \ | Tool ID | Name | Key settings | |---------|------|-------------| -| `adjust-colors` | Adjust Colors | `brightness`, `contrast`, `exposure`, `saturation`, `temperature`, `sharpness`, `vibrance`, effects (grayscale/sepia/invert/vignette) | -| `sharpening` | Sharpening | `mode` (adaptive/unsharp/highpass), `amount`, `radius`, `threshold` | -| `replace-color` | Replace Color | `targetColor`, `replacementColor`, `tolerance`, `invert` | +| `adjust-colors` | Adjust Colors | `brightness`, `contrast`, `exposure`, `saturation`, `temperature`, `tint`, `hue`, `sharpness`, `red`, `green`, `blue`, `effect` (none/grayscale/sepia/invert) | +| `sharpening` | Sharpening | `method` (adaptive/unsharp-mask/high-pass), `sigma`, `m1`, `m2`, `x1`, `y2`, `y3`, `amount`, `radius`, `threshold`, `strength`, `kernelSize` (3/5), `denoise` (off/light/medium/strong) | +| `replace-color` | Replace Color | `sourceColor`, `targetColor` (replacement), `makeTransparent`, `tolerance` | ### AI Tools @@ -137,16 +138,16 @@ All AI tools run on your hardware (CPU or NVIDIA GPU). No internet required. | Tool ID | Name | AI Model | Key settings | |---------|------|---------|-------------| -| `remove-background` | Remove Background | rembg (BiRefNet / U2-Net) | `model`, `alphaMattingForeground`, `alphaMattingBackground`, `returnMask`, background color/image | +| `remove-background` | Remove Background | rembg (BiRefNet / U2-Net) | `model`, `backgroundType` (transparent/color/gradient/blur/image), `backgroundColor`, `gradientColor1`, `gradientColor2`, `gradientAngle`, `blurEnabled`, `blurIntensity`, `shadowEnabled`, `shadowOpacity` | | `upscale` | Image Upscaling | RealESRGAN | `scale` (2/4), `model`, `faceEnhance`, `denoise`, `format`, `quality` | -| `erase-object` | Object Eraser | LaMa (ONNX) | `maskData` (base64 PNG), `maskThreshold` | +| `erase-object` | Object Eraser | LaMa (ONNX) | Mask sent as second file part (fieldname `mask`), `format`, `quality` | | `ocr` | OCR / Text Extraction | PaddleOCR / Tesseract | `quality` (fast/balanced/best), `language`, `enhance` | | `blur-faces` | Face / PII Blur | MediaPipe | `blurRadius`, `sensitivity` | -| `smart-crop` | Smart Crop | MediaPipe + Sharp | `mode` (subject/face/trim), `width`, `height`, `facePreset` (close-up/head-and-shoulders/upper-body/half-body) | +| `smart-crop` | Smart Crop | MediaPipe + Sharp | `mode` (subject/face/trim), `strategy` (attention/entropy), `width`, `height`, `padding`, `facePreset` (closeup/head-shoulders/upper-body/half-body), `sensitivity`, `threshold`, `padToSquare`, `padColor`, `targetSize`, `quality` | | `image-enhancement` | Image Enhancement | Analysis-based | `mode` (auto/exposure/contrast/color/sharpness), `strength` | | `enhance-faces` | Face Enhancement | GFPGAN / CodeFormer | `model` (gfpgan/codeformer), `strength`, `sensitivity`, `centerFace` | | `colorize` | AI Colorization | DDColor | `intensity`, `model` | -| `noise-removal` | Noise Removal | Tiered denoising | `quality` (fast/balanced/best), `strength`, `preserveDetail`, `colorNoise` | +| `noise-removal` | Noise Removal | Tiered denoising | `tier` (quick/balanced/quality/maximum), `strength`, `detailPreservation`, `colorNoise`, `format`, `quality` | | `red-eye-removal` | Red Eye Removal | Face landmark + color analysis | `sensitivity`, `strength` | | `restore-photo` | Photo Restoration | Multi-step pipeline | `mode` (auto/light/heavy), `scratchRemoval`, `faceEnhancement`, `fidelity`, `denoise`, `denoiseStrength`, `colorize` | | `passport-photo` | Passport Photo | MediaPipe landmarks | `country` (37 countries), `printLayout` (4x6/A4/none), `backgroundColor` | @@ -191,6 +192,24 @@ All AI tools run on your hardware (CPU or NVIDIA GPU). No internet required. | `gif-tools` | GIF Tools | `action` (resize/optimize/reverse/speed/extract-frames/rotate/add-text), action-specific params | | `pdf-to-image` | PDF to Image | `pages` (all/range), `format`, `dpi`, `quality` | +### Tool Sub-Routes + +Some tools expose additional endpoints beyond the standard `POST /api/v1/tools/`: + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/v1/tools/remove-background/effects` | Apply background effects (color/gradient/blur/shadow) without re-running AI. Uses cached mask from initial removal. | +| `POST` | `/api/v1/tools/edit-metadata/inspect` | Read existing EXIF/IPTC/XMP metadata from an image | +| `POST` | `/api/v1/tools/strip-metadata/inspect` | Inspect metadata fields before stripping | +| `POST` | `/api/v1/tools/passport-photo/analyze` | Phase 1: AI face detection + background removal. Returns face landmarks and cached data. | +| `POST` | `/api/v1/tools/passport-photo/generate` | Phase 2: Crop, resize, and tile using cached analysis. No AI re-run. | +| `POST` | `/api/v1/tools/gif-tools/info` | Get GIF metadata (frame count, dimensions, duration) | +| `POST` | `/api/v1/tools/pdf-to-image/info` | Get PDF metadata (page count, dimensions) | +| `POST` | `/api/v1/tools/pdf-to-image/preview` | Generate a preview of a specific PDF page | +| `POST` | `/api/v1/tools/svg-to-raster/batch` | Batch convert multiple SVGs to raster | +| `POST` | `/api/v1/tools/image-enhancement/analyze` | Analyze image quality and return enhancement recommendations | +| `POST` | `/api/v1/tools/optimize-for-web/preview` | Lightweight preview for live parameter tuning. Returns optimized image with size headers. | + ## Batch Processing Apply any tool to multiple files at once. Returns a ZIP archive. @@ -215,18 +234,18 @@ Concurrency is controlled by `CONCURRENT_JOBS` (default: auto-detected from CPU curl -X POST http://localhost:1349/api/v1/pipeline/execute \ -H "Authorization: Bearer " \ -F "file=@input.jpg" \ - -F 'pipeline=[ + -F 'pipeline={"steps":[ {"toolId":"resize","settings":{"width":1200}}, {"toolId":"compress","settings":{"quality":80}}, {"toolId":"watermark-text","settings":{"text":"© 2025"}} - ]' + ]}' # Batch (multiple files → ZIP) curl -X POST http://localhost:1349/api/v1/pipeline/batch \ -H "Authorization: Bearer " \ -F "files=@a.jpg" \ -F "files=@b.jpg" \ - -F 'pipeline=[{"toolId":"resize","settings":{"width":800}}]' + -F 'pipeline={"steps":[{"toolId":"resize","settings":{"width":800}}]}' ``` Each step's output is the next step's input. Unlimited steps per pipeline by default (configurable via `MAX_PIPELINE_STEPS`). @@ -235,7 +254,7 @@ Each step's output is the next step's input. Unlimited steps per pipeline by def | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api/v1/pipeline/save` | Save a named pipeline (`name`, `description`, `steps`) | +| `POST` | `/api/v1/pipeline/save` | Save a named pipeline (`name`, `description`, `steps[]`) | | `GET` | `/api/v1/pipeline/list` | List saved pipelines (admins see all; users see own) | | `DELETE` | `/api/v1/pipeline/:id` | Delete (owner or admin) | | `GET` | `/api/v1/pipeline/tools` | List tool IDs valid for pipeline steps | @@ -262,12 +281,16 @@ Persistent file storage with version history. | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api/v1/upload` | Upload files to workspace | +| `POST` | `/api/v1/upload` | Upload files to workspace (temp processing) | +| `POST` | `/api/v1/files/upload` | Upload files to the persistent file library | +| `POST` | `/api/v1/files/save-result` | Save a tool processing result as a new file version | | `GET` | `/api/v1/files` | List saved files (paginated, with search) | | `GET` | `/api/v1/files/:id` | Get file metadata + version chain | | `GET` | `/api/v1/files/:id/download` | Download file | | `GET` | `/api/v1/files/:id/thumbnail` | Get 300px JPEG thumbnail | -| `DELETE` | `/api/v1/files/:id` | Delete file (and its version chain) | +| `DELETE` | `/api/v1/files` | Bulk delete files and their version chains (body: `{ ids: [...] }`) | +| `POST` | `/api/v1/preview` | Generate a browser-compatible WebP preview (for HEIC/HEIF/RAW formats) | +| `GET` | `/api/v1/download/:jobId/:filename` | Download a processed file from a workspace | To auto-save a tool result to the library, include `fileId` in the settings payload referencing an existing library file. The processed result will be saved as a new version. @@ -283,18 +306,18 @@ To auto-save a tool result to the library, include `fileId` in the settings payl | Method | Path | Access | Description | |--------|------|--------|-------------| -| `GET` | `/api/v1/teams` | Auth | List teams | -| `POST` | `/api/v1/teams` | Admin | Create team | -| `PUT` | `/api/v1/teams/:id` | Admin | Rename team | -| `DELETE` | `/api/v1/teams/:id` | Admin | Delete team (cannot delete default team or teams with members) | +| `GET` | `/api/v1/teams` | Admin (`teams:manage`) | List teams | +| `POST` | `/api/v1/teams` | Admin (`teams:manage`) | Create team | +| `PUT` | `/api/v1/teams/:id` | Admin (`teams:manage`) | Rename team | +| `DELETE` | `/api/v1/teams/:id` | Admin (`teams:manage`) | Delete team (cannot delete default team or teams with members) | ## Branding | Method | Path | Access | Description | |--------|------|--------|-------------| -| `POST` | `/api/v1/branding/logo` | Admin | Upload custom logo (max 500 KB, converted to 128×128 PNG) | -| `GET` | `/api/v1/branding/logo` | Public | Serve current logo | -| `DELETE` | `/api/v1/branding/logo` | Admin | Remove custom logo | +| `POST` | `/api/v1/settings/logo` | Admin | Upload custom logo (max 500 KB, converted to 128×128 PNG) | +| `GET` | `/api/v1/settings/logo` | Public | Serve current logo | +| `DELETE` | `/api/v1/settings/logo` | Admin | Remove custom logo | ## Settings @@ -303,7 +326,8 @@ Runtime key-value configuration (read by any authenticated user, write by admin | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/v1/settings` | Get all settings | -| `PUT` | `/api/v1/settings/:key` | Set a value | +| `PUT` | `/api/v1/settings` | Bulk update settings (JSON body with key-value pairs) | +| `GET` | `/api/v1/settings/:key` | Get a specific setting by key | Known keys: `disabledTools` (JSON array of tool IDs), `enableExperimentalTools` (bool string), `loginAttemptLimit` (number), `customLogo` (managed via branding endpoint). diff --git a/apps/docs/guide/architecture.md b/apps/docs/guide/architecture.md index 33525772..f0ed24be 100644 --- a/apps/docs/guide/architecture.md +++ b/apps/docs/guide/architecture.md @@ -70,7 +70,7 @@ The built frontend gets served by the Fastify backend in production, so there is ### Docs (`apps/docs`) -This VitePress site. Deployed to GitHub Pages automatically on push to `main`. +This VitePress site. Deployed to Cloudflare Pages automatically on push to `main`. ## How a request flows diff --git a/apps/docs/guide/deployment.md b/apps/docs/guide/deployment.md index e7e4bee1..b00a4272 100644 --- a/apps/docs/guide/deployment.md +++ b/apps/docs/guide/deployment.md @@ -393,7 +393,7 @@ The GitHub repository has three workflows: - **ci.yml** -- Runs automatically on every push and PR. Lints, typechecks, tests, builds, and validates the Docker image (without pushing). - **release.yml** -- Triggered manually via `workflow_dispatch`. Runs semantic-release to create a version tag and GitHub release, then builds a multi-arch Docker image (amd64 + arm64) and pushes to Docker Hub (`snapotter/snapotter`) and GitHub Container Registry (`ghcr.io/snapotter-hq/snapotter`). -- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages on push to `main`. +- **deploy-docs.yml** -- Builds this documentation site and deploys it to Cloudflare Pages on push to `main`. To create a release, go to **Actions > Release > Run workflow** in the GitHub UI, or run: diff --git a/apps/docs/guide/docker-tags.md b/apps/docs/guide/docker-tags.md index 306cd180..b278716a 100644 --- a/apps/docs/guide/docker-tags.md +++ b/apps/docs/guide/docker-tags.md @@ -5,17 +5,17 @@ SnapOtter ships as a single Docker image that works on all platforms. ## Quick start ```bash -docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest +docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ``` -The app is available at `http://localhost:1349`. - ## GPU acceleration The image includes CUDA support on amd64. If you have an NVIDIA GPU with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, add `--gpus all`: ```bash -docker run -d --name SnapOtter --gpus all -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest +docker run -d --name SnapOtter --gpus all -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ``` The image auto-detects your GPU at runtime. Without `--gpus all`, it runs on CPU. Same image either way. diff --git a/apps/docs/guide/getting-started.md b/apps/docs/guide/getting-started.md index b988befc..dbf0a3e9 100644 --- a/apps/docs/guide/getting-started.md +++ b/apps/docs/guide/getting-started.md @@ -3,33 +3,27 @@ ## Quick Start ```bash -docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest +docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ``` -Open [http://localhost:1349](http://localhost:1349) in your browser. +You will be asked to change your password on first login. ::: tip Also on GHCR ```bash -docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data ghcr.io/snapotter-hq/snapotter:latest +docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data ghcr.io/snapotter-hq/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ``` Both registries publish the same image on every release. ::: -**Default credentials:** - -| Field | Value | -|----------|---------| -| Username | `admin` | -| Password | `admin` | - -You will be asked to change your password on first login. - ::: tip NVIDIA GPU acceleration Add `--gpus all` for GPU-accelerated background removal, upscaling, OCR, face enhancement, and restoration: ```bash -docker run -d --name SnapOtter -p 1349:1349 --gpus all -v SnapOtter-data:/data snapotter/snapotter:latest +docker run -d --name SnapOtter -p 1349:1349 --gpus all -v SnapOtter-data:/data snapotter/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ``` Requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU automatically. See [Docker Tags](/guide/docker-tags) for benchmarks. @@ -73,7 +67,7 @@ pnpm dev ## What You Can Do -### Image Processing (45+ Tools) +### Image Processing (47 Tools) | Category | Tools | |----------|-------| diff --git a/apps/docs/index.md b/apps/docs/index.md index 89ed7797..b38e1f0a 100644 --- a/apps/docs/index.md +++ b/apps/docs/index.md @@ -14,7 +14,7 @@ hero: link: /api/rest features: - - title: 45+ Image Tools + - title: 47 Image Tools details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more. - title: Local AI details: 14 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR). All on your hardware, no internet required. @@ -31,7 +31,8 @@ features:
```bash -docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest +docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest \ + && printf "\n 🦦 SnapOtter\n ────────────────────────────────────────\n\n ➜ Open http://localhost:1349\n ➜ Login admin / admin\n ➜ Docs https://docs.snapotter.com\n\n" ```
diff --git a/docker/Dockerfile b/docker/Dockerfile index b2f2d2bd..7ef7998e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -148,6 +148,7 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $( apt-get install -y --no-install-recommends \ tini \ imagemagick \ + libmagickcore-6.q16-7-extra \ libraw-dev \ libopenexr-dev \ potrace \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8e1b0a3f..3d90eb5e 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -25,6 +25,18 @@ if [ ! -d "$AI_VENV" ] && [ -d "/opt/venv" ]; then echo "AI venv ready at $AI_VENV" fi +print_banner() { + RST='\033[0m' + printf '\n' + printf ' \033[1;36m🦦 SnapOtter%b\n' "$RST" + printf ' \033[2m────────────────────────────────────────%b\n' "$RST" + printf '\n' + printf ' \033[32m➜%b Open \033[1;4mhttp://localhost:%s%b\n' "$RST" "${PORT:-1349}" "$RST" + printf ' \033[33m➜%b Login \033[1m%s%b / \033[1m%s%b\n' "$RST" "${DEFAULT_USERNAME}" "$RST" "${DEFAULT_PASSWORD}" "$RST" + printf ' \033[36m➜%b Docs \033[2mhttps://docs.snapotter.com%b\n' "$RST" "$RST" + printf '\n' +} + # Fix ownership of mounted volumes so the non-root snapotter user can write. # This runs as root, fixes permissions, then drops to snapotter via gosu. if [ "$(id -u)" = "0" ]; then @@ -68,8 +80,10 @@ if [ "$(id -u)" = "0" ]; then chown -R snapotter:snapotter /data /tmp/workspace 2>&1 || \ echo "WARNING: Could not fix volume permissions. Use named volumes (not Windows bind mounts) to avoid this. See docs for details." >&2 + print_banner exec gosu snapotter "$@" fi # Already running as snapotter (e.g. Kubernetes runAsUser) +print_banner exec "$@" diff --git a/packages/image-engine/src/operations/resize.ts b/packages/image-engine/src/operations/resize.ts index e670a75e..7b0d221d 100644 --- a/packages/image-engine/src/operations/resize.ts +++ b/packages/image-engine/src/operations/resize.ts @@ -24,6 +24,14 @@ export async function resize(image: Sharp, options: ResizeOptions): Promise curW) width = curW; + if (height !== undefined && height > curH) height = curH; + } + return image.resize({ width, height, diff --git a/tests/unit/api/utilities.test.ts b/tests/unit/api/utilities.test.ts index 27df2d75..6e081bee 100644 --- a/tests/unit/api/utilities.test.ts +++ b/tests/unit/api/utilities.test.ts @@ -304,12 +304,12 @@ describe("validateImageBuffer", () => { expect(result.valid).toBe(false); }); - it("rejects BMP magic with no actual BMP structure", async () => { + it("accepts BMP magic (decoded via CLI, not Sharp)", async () => { const fakeBmp = Buffer.from([0x42, 0x4d, 0x00, 0x00]); const result = await validateImageBuffer(fakeBmp); - expect(result.valid).toBe(false); - if (!result.valid) { - expect(result.reason).toBe("Failed to read image metadata"); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.format).toBe("bmp"); } });