diff --git a/README.md b/README.md index 720e551b..797aa2d2 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,21 @@ For Docker Compose, persistent storage, and other setup options, see the [Gettin - [Getting Started](https://ashim-hq.github.io/ashim/guide/getting-started) - [Configuration](https://ashim-hq.github.io/ashim/guide/configuration) +- [Deployment](https://ashim-hq.github.io/ashim/guide/deployment) +- [Docker Tags](https://ashim-hq.github.io/ashim/guide/docker-tags) - [REST API](https://ashim-hq.github.io/ashim/api/rest) +- [AI Engine](https://ashim-hq.github.io/ashim/api/ai) +- [Image Engine](https://ashim-hq.github.io/ashim/api/image-engine) - [Architecture](https://ashim-hq.github.io/ashim/guide/architecture) +- [Database](https://ashim-hq.github.io/ashim/guide/database) - [Developer Guide](https://ashim-hq.github.io/ashim/guide/developer) +- [Contributing](https://ashim-hq.github.io/ashim/guide/contributing) - [Translation Guide](https://ashim-hq.github.io/ashim/guide/translations) ## Feedback Found a bug or have a feature idea? Open a [GitHub Issue](https://github.com/ashim-hq/ashim/issues). We don't accept pull requests, but your feedback directly shapes the project. See [CONTRIBUTING.md](CONTRIBUTING.md) for details. - - ## License This project is dual-licensed under the [AGPLv3](LICENSE) and a commercial license. diff --git a/apps/api/src/openapi.yaml b/apps/api/src/openapi.yaml index 0c25e844..9b2a1f45 100644 --- a/apps/api/src/openapi.yaml +++ b/apps/api/src/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: ashim API - version: 0.9.0 + version: 1.15.9 description: | REST API for ashim, a self-hosted image processing platform with 30+ tools. @@ -41,6 +41,16 @@ tags: description: Organize users into teams. - name: Branding description: Custom logo management. + - name: Roles + description: Custom role management with fine-grained permissions. + - name: Audit + description: Audit log for tracking administrative actions. + - name: Analytics + description: Analytics configuration and user consent. + - name: Features + description: AI feature bundle installation and management. + - name: Admin + description: Administrative health diagnostics. - name: System description: Health checks, configuration, and job progress. @@ -82,6 +92,64 @@ components: type: integer description: Processed file size in bytes + UnauthorizedError: + type: object + properties: + statusCode: + type: integer + example: 401 + error: + type: string + example: Unauthorized + message: + type: string + example: Authentication required + + ForbiddenError: + type: object + properties: + statusCode: + type: integer + example: 403 + error: + type: string + example: Forbidden + message: + type: string + example: Insufficient permissions + + ConflictError: + type: object + properties: + statusCode: + type: integer + example: 409 + error: + type: string + example: Conflict + message: + type: string + example: Resource already exists + + FeatureNotInstalledError: + type: object + properties: + error: + type: string + example: Feature not installed + code: + type: string + example: FEATURE_NOT_INSTALLED + feature: + type: string + description: Feature bundle identifier + featureName: + type: string + description: Human-readable feature name + estimatedSize: + type: string + description: Estimated download size + HealthResponse: type: object properties: @@ -96,6 +164,39 @@ components: type: string enum: [ok, error] + AdminHealthResponse: + type: object + properties: + status: + type: string + enum: [healthy, degraded] + version: + type: string + uptime: + type: string + storage: + type: object + properties: + mode: + type: string + available: + type: string + database: + type: string + enum: [ok, error] + queue: + type: object + properties: + active: + type: integer + pending: + type: integer + ai: + type: object + properties: + gpu: + type: boolean + security: - bearerAuth: [] @@ -157,6 +258,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/content-aware-resize: post: @@ -211,6 +316,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/crop: post: @@ -254,6 +363,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/rotate: post: @@ -296,6 +409,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/convert: post: @@ -337,6 +454,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/compress: post: @@ -379,6 +500,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/strip-metadata: post: @@ -423,6 +548,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/border: post: @@ -468,12 +597,76 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + + /api/v1/tools/adjust-colors: + post: + tags: [Tools] + summary: Color adjustments + description: | + Consolidated color adjustment tool. Adjusts brightness, contrast, exposure, + saturation, temperature, tint, hue, sharpness, color channels, and effects + in a single pass. This is the primary color adjustment endpoint; the + brightness-contrast, saturation, color-channels, and color-effects endpoints + are aliases that accept the same settings schema. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, settings] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `brightness` (number -100 to 100, default 0) - Brightness adjustment + - `contrast` (number -100 to 100, default 0) - Contrast adjustment + - `exposure` (number -100 to 100, default 0) - Exposure adjustment (gamma) + - `saturation` (number -100 to 100, default 0) - Color saturation + - `temperature` (number -100 to 100, default 0) - Color temperature (cool to warm) + - `tint` (number -100 to 100, default 0) - Tint shift (green to magenta) + - `hue` (number -180 to 180, default 0) - Hue rotation in degrees + - `sharpness` (number 0 to 100, default 0) - Sharpness enhancement + - `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 + - `effect` (string, default "none") - One of: none, grayscale, sepia, invert + responses: + "200": + description: Processed image + content: + application/json: + schema: + $ref: "#/components/schemas/ToolResponse" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/brightness-contrast: post: tags: [Tools] summary: Brightness and contrast - description: Adjust brightness, contrast, saturation, color channels, and effects. + description: Adjust brightness, contrast, saturation, color channels, and effects. Alias for adjust-colors. security: - bearerAuth: [] requestBody: @@ -514,6 +707,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/saturation: post: @@ -560,6 +757,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/color-channels: post: @@ -606,6 +807,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/color-effects: post: @@ -652,6 +857,1027 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + + /api/v1/tools/sharpening: + post: + tags: [Tools] + summary: Sharpening + description: Sharpen an image using adaptive, unsharp mask, or high-pass methods with optional noise reduction. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, settings] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `method` (string, default "adaptive") - One of: adaptive, unsharp-mask, high-pass + - `sigma` (number 0.5-10, default 1.0) - Gaussian sigma (adaptive) + - `m1` (number 0-10, default 1.0) - Flat area sharpening (adaptive) + - `m2` (number 0-20, default 3.0) - Jagged area sharpening (adaptive) + - `x1` (number 0-10, default 2.0) - Flat/jagged threshold (adaptive) + - `y2` (number 0-50, default 12) - Maximum brightening (adaptive) + - `y3` (number 0-50, default 20) - Maximum darkening (adaptive) + - `amount` (number 0-1000, default 100) - Sharpen amount (unsharp-mask) + - `radius` (number 0.1-5, default 1.0) - Blur radius (unsharp-mask) + - `threshold` (number 0-255, default 0) - Luminance threshold (unsharp-mask) + - `strength` (number 0-100, default 50) - Blend strength (high-pass) + - `kernelSize` (integer, default 3) - One of: 3, 5 (high-pass) + - `denoise` (string, default "off") - One of: off, light, medium, strong + responses: + "200": + description: Processed image + content: + application/json: + schema: + $ref: "#/components/schemas/ToolResponse" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + + /api/v1/tools/optimize-for-web: + post: + tags: [Tools] + summary: Optimize for web + description: Optimize an image for web delivery by converting to modern formats, adjusting quality, and optionally resizing. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, settings] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `format` (string, default "webp") - One of: webp, jpeg, avif, png + - `quality` (number 1-100, default 80) - Output quality + - `maxWidth` (number, optional) - Maximum width in pixels + - `maxHeight` (number, optional) - Maximum height in pixels + - `progressive` (boolean, default true) - Enable progressive encoding + - `stripMetadata` (boolean, default true) - Remove metadata + responses: + "200": + description: Processed image + content: + application/json: + schema: + $ref: "#/components/schemas/ToolResponse" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + + /api/v1/tools/optimize-for-web/preview: + post: + tags: [Tools] + summary: Optimize for web (preview) + description: | + Lightweight preview endpoint for live parameter tuning. Returns the + optimized image binary directly (not JSON). Size information is in + response headers. Does not create a workspace or persist results. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file to preview + settings: + type: string + description: Same settings as the main optimize-for-web endpoint + responses: + "200": + description: Optimized image binary + headers: + X-Original-Size: + schema: + type: string + description: Original file size in bytes + X-Processed-Size: + schema: + type: string + description: Processed file size in bytes + X-Output-Filename: + schema: + type: string + description: Suggested output filename + content: + image/*: + schema: + type: string + format: binary + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "422": + description: Processing failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /api/v1/tools/image-enhancement: + post: + tags: [Tools] + summary: Image enhancement + description: | + AI-powered automatic image enhancement. Analyzes the image and applies + corrections for exposure, contrast, white balance, saturation, sharpness, + and noise based on the selected scene mode. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, settings] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `mode` (string, default "auto") - One of: auto, portrait, landscape, low-light, food, document + - `intensity` (number 0-100, default 50) - Overall enhancement intensity + - `corrections` (object, optional) - Toggle individual corrections: + - `exposure` (boolean, default true) + - `contrast` (boolean, default true) + - `whiteBalance` (boolean, default true) + - `saturation` (boolean, default true) + - `sharpness` (boolean, default true) + - `denoise` (boolean, default true) + responses: + "200": + description: Processed image + content: + application/json: + schema: + $ref: "#/components/schemas/ToolResponse" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + + /api/v1/tools/image-enhancement/analyze: + post: + tags: [Tools] + summary: Analyze image for enhancement + description: | + Analyze an image and return suggested corrections without applying them. + Useful for previewing what enhancements would be made. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file to analyze + responses: + "200": + description: Image analysis with suggested corrections + content: + application/json: + schema: + type: object + properties: + corrections: + type: object + description: Suggested correction values + metrics: + type: object + description: Image quality metrics + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "422": + description: Analysis failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /api/v1/tools/noise-removal: + post: + tags: [Tools] + summary: Noise removal + description: | + AI-powered noise removal using the Python sidecar. Supports multiple + quality tiers from quick preview to maximum quality. Requires the + noise-removal feature bundle to be installed. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `tier` (string, default "balanced") - One of: quick, balanced, quality, maximum + - `strength` (number, default 50) - Denoising strength + - `detailPreservation` (number, default 50) - Detail preservation level + - `colorNoise` (number, default 30) - Color noise reduction + - `format` (string, default "original") - One of: original, png, jpeg, webp, avif + - `quality` (number, default 90) - Output quality (1-100) + clientJobId: + type: string + description: Client-provided job ID for SSE progress tracking + responses: + "200": + description: Processed image + content: + application/json: + schema: + $ref: "#/components/schemas/ToolResponse" + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + 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/red-eye-removal: + post: + tags: [Tools] + summary: Red-eye removal + description: | + AI-powered red-eye detection and removal. Detects faces and corrects + red-eye artifacts in photographs. Requires the red-eye-removal feature + bundle to be installed. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `sensitivity` (number 0-100, default 50) - Detection sensitivity + - `strength` (number 0-100, default 70) - Correction strength + - `format` (string, optional) - Output format + - `quality` (number 1-100, default 90) - Output quality + clientJobId: + type: string + description: Client-provided job ID for SSE progress tracking + responses: + "200": + description: Processed image with correction details + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + facesDetected: + type: integer + description: Number of faces detected + eyesCorrected: + type: integer + description: Number of eyes corrected + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + 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/restore-photo: + post: + tags: [Tools] + summary: Photo restoration + description: | + AI-powered photo restoration pipeline. Repairs scratches, enhances faces, + denoises, and optionally colorizes old or damaged photographs. Requires + the photo-restoration feature bundle to be installed. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file to process + settings: + type: string + description: | + JSON string with options: + - `mode` (string, default "auto") - One of: auto, light, heavy + - `scratchRemoval` (boolean, default true) - Enable scratch repair + - `faceEnhancement` (boolean, default true) - Enhance detected faces + - `fidelity` (number 0-1, default 0.7) - Face enhancement fidelity + - `denoise` (boolean, default true) - Enable denoising + - `denoiseStrength` (number 0-100, default 40) - Denoising strength + - `colorize` (boolean, default false) - Colorize grayscale photos + clientJobId: + type: string + description: Client-provided job ID for SSE progress tracking + responses: + "200": + description: Restored image with processing details + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + previewUrl: + type: string + description: WebP preview URL for non-previewable formats + width: + type: integer + height: + type: integer + steps: + type: array + items: + type: string + description: Processing steps performed + scratchCoverage: + type: number + description: Percentage of image with scratches detected + facesEnhanced: + type: integer + description: Number of faces enhanced + isGrayscale: + type: boolean + description: Whether the input was grayscale + colorized: + type: boolean + description: Whether colorization was applied + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + 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/passport-photo/analyze: + post: + tags: [Tools] + summary: Passport photo - analyze + description: | + Phase 1 of passport photo generation. Detects face landmarks and removes + the background. Returns landmark data and a base64-encoded preview for + the client to use in the generate phase. Requires the passport-photo + feature bundle to be installed. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Photo with a clear, front-facing face + clientJobId: + type: string + description: Client-provided job ID for SSE progress tracking + responses: + "200": + description: Face analysis and background removal result + content: + application/json: + schema: + type: object + properties: + jobId: + type: string + description: Job ID to pass to the generate endpoint + filename: + type: string + preview: + type: string + description: Base64-encoded PNG preview of the background-removed image + previewWidth: + type: integer + previewHeight: + type: integer + landmarks: + type: object + description: Normalized face landmark coordinates (0-1) + properties: + leftEye: + type: object + properties: + x: + type: number + y: + type: number + rightEye: + type: object + properties: + x: + type: number + y: + type: number + eyeCenter: + type: object + properties: + x: + type: number + y: + type: number + chin: + type: object + properties: + x: + type: number + y: + type: number + forehead: + type: object + properties: + x: + type: number + y: + type: number + crown: + type: object + properties: + x: + type: number + y: + type: number + nose: + type: object + properties: + x: + type: number + y: + type: number + faceCenterX: + type: number + imageWidth: + type: integer + imageHeight: + type: integer + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + "422": + description: No face detected or analysis 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/passport-photo/generate: + post: + tags: [Tools] + summary: Passport photo - generate + description: | + Phase 2 of passport photo generation. Uses the jobId and landmarks from + the analyze phase to crop, resize, and tile the photo to the specified + country/document specification. Fast response (no AI re-run). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [jobId, filename, countryCode, landmarks, imageWidth, imageHeight] + properties: + jobId: + type: string + description: Job ID from the analyze phase + filename: + type: string + description: Original filename from analyze phase + countryCode: + type: string + description: ISO country code for passport specification + documentType: + type: string + default: passport + description: Document type (passport, visa, etc.) + bgColor: + type: string + default: "#FFFFFF" + description: Background color (hex) + printLayout: + type: string + default: none + description: Print sheet layout (none, 4x6, A4, letter) + maxFileSizeKb: + type: number + default: 0 + description: Max file size in KB (0 = no limit) + dpi: + type: number + minimum: 72 + maximum: 1200 + default: 300 + description: Output DPI + customWidthMm: + type: number + description: Custom photo width in mm (overrides country spec) + customHeightMm: + type: number + description: Custom photo height in mm (overrides country spec) + zoom: + type: number + minimum: 0.5 + maximum: 3 + default: 1 + description: Zoom level (>1 = tighter crop) + adjustX: + type: number + default: 0 + description: Horizontal position adjustment + adjustY: + type: number + default: 0 + description: Vertical position adjustment + landmarks: + type: object + description: Face landmarks from analyze phase + imageWidth: + type: integer + description: Original image width from analyze phase + imageHeight: + type: integer + description: Original image height from analyze phase + responses: + "200": + description: Generated passport photo + content: + application/json: + schema: + type: object + properties: + jobId: + type: string + downloadUrl: + type: string + dimensions: + type: object + properties: + widthMm: + type: number + heightMm: + type: number + widthPx: + type: integer + heightPx: + type: integer + dpi: + type: integer + spec: + type: object + properties: + country: + type: string + countryCode: + type: string + documentType: + type: string + documentLabel: + type: string + printDownloadUrl: + type: string + description: URL for print sheet download (if printLayout was specified) + "400": + description: Invalid settings or unknown country code + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + "422": + description: Generation failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /api/v1/tools/colorize: + post: + tags: [Tools] + summary: Colorize + description: | + AI-powered photo colorization. Converts black-and-white or grayscale + photographs to full color using DDColor with OpenCV DNN fallback. Requires + the colorize feature bundle to be installed. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Grayscale or B&W image file + settings: + type: string + description: | + JSON string with options: + - `intensity` (number 0-1, default 1.0) - Colorization intensity + - `model` (string, default "auto") - One of: auto, ddcolor, opencv + clientJobId: + type: string + description: Client-provided job ID for SSE progress tracking + responses: + "200": + description: Colorized image + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + previewUrl: + type: string + description: WebP preview URL for non-previewable formats + width: + type: integer + height: + type: integer + method: + type: string + description: Colorization model used (ddcolor or opencv) + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + 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/enhance-faces: + post: + tags: [Tools] + summary: Face enhancement + description: | + AI-powered face enhancement using GFPGAN or CodeFormer. Detects faces + in the image and enhances facial details, skin texture, and clarity. + Requires the enhance-faces feature bundle to be installed. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: Image file containing faces + settings: + type: string + description: | + JSON string with options: + - `model` (string, default "auto") - One of: auto, gfpgan, codeformer + - `strength` (number 0-1, default 0.8) - Enhancement strength + - `onlyCenterFace` (boolean, default false) - Only enhance the center face + - `sensitivity` (number 0-1, default 0.5) - Face detection sensitivity + clientJobId: + type: string + description: Client-provided job ID for SSE progress tracking + responses: + "200": + description: Enhanced image with face details + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ToolResponse" + - type: object + properties: + previewUrl: + type: string + description: WebP preview URL + facesDetected: + type: integer + description: Number of faces detected + faces: + type: array + items: + type: object + description: Per-face bounding boxes and confidence scores + model: + type: string + description: Model actually used (gfpgan or codeformer) + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + 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/image-to-base64: + post: + tags: [Tools] + summary: Image to Base64 + description: | + Convert one or more images to Base64-encoded strings with data URIs. + Supports optional format conversion and resizing. Returns both the raw + Base64 string and a ready-to-use data URI for each file. + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: array + items: + type: string + format: binary + description: One or more image files to convert + settings: + type: string + description: | + JSON string with options: + - `outputFormat` (string, default "original") - One of: original, jpeg, png, webp, avif + - `quality` (integer 1-100, default 80) - Output quality for lossy formats + - `maxWidth` (integer, default 0) - Max width in pixels (0 = no limit) + - `maxHeight` (integer, default 0) - Max height in pixels (0 = no limit) + responses: + "200": + description: Base64-encoded results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + filename: + type: string + mimeType: + type: string + width: + type: integer + height: + type: integer + originalSize: + type: integer + description: Original file size in bytes + encodedSize: + type: integer + description: Base64 string size in bytes + overheadPercent: + type: number + description: Size overhead of Base64 encoding + base64: + type: string + description: Raw Base64-encoded image data + dataUri: + type: string + description: Complete data URI (data:mime;base64,...) + errors: + type: array + items: + type: object + properties: + filename: + type: string + error: + type: string + "400": + description: Invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/watermark-text: post: @@ -697,6 +1923,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/text-overlay: post: @@ -743,6 +1973,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/replace-color: post: @@ -786,6 +2020,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/gif-tools: post: @@ -829,6 +2067,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/gif-tools/info: post: @@ -933,6 +2175,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/vectorize: post: @@ -980,6 +2226,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/svg-to-raster: post: @@ -1025,6 +2275,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/svg-to-raster/batch: post: @@ -1065,6 +2319,10 @@ paths: description: Invalid input "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "422": description: All files failed processing @@ -1111,6 +2369,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/pdf-to-image: post: @@ -1183,6 +2445,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/pdf-to-image/info: post: @@ -1309,6 +2575,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/bulk-rename: post: @@ -1353,6 +2623,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/favicon: post: @@ -1389,6 +2663,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/watermark-image: post: @@ -1435,6 +2713,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/compose: post: @@ -1482,6 +2764,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/compare: post: @@ -1521,6 +2807,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/erase-object: post: @@ -1560,6 +2850,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/collage: post: @@ -1604,6 +2898,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/stitch: post: @@ -1655,6 +2953,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/remove-background: post: @@ -1696,6 +2998,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/remove-background/effects: post: @@ -1755,6 +3061,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/upscale: post: @@ -1795,6 +3105,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/blur-faces: post: @@ -1836,6 +3150,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/ocr: post: @@ -1885,6 +3203,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/info: post: @@ -1941,6 +3263,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/color-palette: post: @@ -1982,6 +3308,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/barcode-read: post: @@ -2022,6 +3352,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/find-duplicates: post: @@ -2071,6 +3405,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/qr-generate: post: @@ -2124,6 +3462,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/strip-metadata/inspect: post: @@ -2172,6 +3514,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/edit-metadata: post: @@ -2231,6 +3577,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/tools/edit-metadata/inspect: post: @@ -2321,6 +3671,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" # ─── Pipelines ──────────────────────────────────────────────────────────── @@ -2368,6 +3722,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/pipeline/save: post: @@ -2416,6 +3774,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/pipeline/list: get: @@ -2457,6 +3819,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/pipeline/{id}: delete: @@ -2468,6 +3834,7 @@ paths: - name: id in: path required: true + description: Pipeline UUID schema: type: string responses: @@ -2481,6 +3848,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/pipeline/tools: get: @@ -2557,6 +3928,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" # ─── Files ──────────────────────────────────────────────────────────────── @@ -2597,6 +3972,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/download/{jobId}/{filename}: get: @@ -2607,11 +3986,13 @@ paths: - name: jobId in: path required: true + description: Job identifier from a tool processing response schema: type: string - name: filename in: path required: true + description: Output filename from the processing response schema: type: string responses: @@ -2696,6 +4077,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" delete: tags: [Files] summary: Bulk delete saved files @@ -2734,6 +4119,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/files/upload: post: @@ -2772,6 +4161,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/files/save-result: post: @@ -2811,6 +4204,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/files/{id}: get: @@ -2822,6 +4219,7 @@ paths: - name: id in: path required: true + description: Saved file UUID schema: type: string responses: @@ -2848,6 +4246,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/files/{id}/download: get: @@ -2859,6 +4261,7 @@ paths: - name: id in: path required: true + description: Saved file UUID schema: type: string responses: @@ -2877,6 +4280,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/files/{id}/thumbnail: get: @@ -2888,6 +4295,7 @@ paths: - name: id in: path required: true + description: Saved file UUID schema: type: string responses: @@ -2906,6 +4314,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" # ─── Auth ───────────────────────────────────────────────────────────────── @@ -2954,6 +4366,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Invalid credentials + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/auth/logout: post: @@ -2972,6 +4388,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/auth/session: get: @@ -3029,6 +4449,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/auth/users: get: @@ -3070,8 +4494,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" post: tags: [Auth] summary: Create user (admin) @@ -3119,10 +4551,22 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" "409": description: Username already exists + content: + application/json: + schema: + $ref: "#/components/schemas/ConflictError" /api/auth/users/{id}: put: @@ -3134,6 +4578,7 @@ paths: - name: id in: path required: true + description: User UUID schema: type: string requestBody: @@ -3170,8 +4615,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" delete: tags: [Auth] summary: Delete user (admin) @@ -3181,6 +4634,7 @@ paths: - name: id in: path required: true + description: User UUID schema: type: string responses: @@ -3194,8 +4648,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" /api/auth/users/{id}/reset-password: post: @@ -3207,6 +4669,7 @@ paths: - name: id in: path required: true + description: User UUID schema: type: string requestBody: @@ -3231,8 +4694,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" # ─── API Keys ───────────────────────────────────────────────────────────── @@ -3280,6 +4751,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" get: tags: [API Keys] summary: List API keys @@ -3316,6 +4791,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" /api/v1/api-keys/{id}: delete: @@ -3327,6 +4806,7 @@ paths: - name: id in: path required: true + description: API key UUID schema: type: string responses: @@ -3340,6 +4820,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" # ─── Settings ───────────────────────────────────────────────────────────── @@ -3366,6 +4850,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" put: tags: [Settings] summary: Update settings (admin) @@ -3390,8 +4878,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" /api/v1/settings/{key}: get: @@ -3403,6 +4899,7 @@ paths: - name: key in: path required: true + description: Setting key name schema: type: string responses: @@ -3425,6 +4922,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" # ─── Teams ──────────────────────────────────────────────────────────────── @@ -3459,6 +4960,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" post: tags: [Teams] summary: Create a team @@ -3494,8 +4999,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "409": description: Team name already exists + content: + application/json: + schema: + $ref: "#/components/schemas/ConflictError" /api/v1/teams/{id}: put: @@ -3507,6 +5020,7 @@ paths: - name: id in: path required: true + description: Team integer ID schema: type: integer requestBody: @@ -3539,6 +5053,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" delete: tags: [Teams] summary: Delete a team @@ -3548,6 +5066,7 @@ paths: - name: id in: path required: true + description: Team integer ID schema: type: integer responses: @@ -3561,6 +5080,10 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" # ─── Branding ───────────────────────────────────────────────────────────── @@ -3607,8 +5130,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" delete: tags: [Branding] summary: Remove custom logo (admin) @@ -3625,8 +5156,16 @@ paths: $ref: "#/components/schemas/Error" "401": description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" "403": description: Admin access required + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" # ─── System (additional) ────────────────────────────────────────────────── @@ -3658,6 +5197,7 @@ paths: - name: jobId in: path required: true + description: Job UUID from a tool processing or installation response schema: type: string responses: @@ -3683,3 +5223,661 @@ paths: type: array items: type: string + + # ─── Admin ─────────────────────────────────────────────────────────────── + + /api/v1/admin/health: + get: + tags: [Admin] + summary: Admin health check + description: | + Full system diagnostics including database status, storage mode, queue + state, and GPU availability. Requires the system:health permission. + security: + - bearerAuth: [] + responses: + "200": + description: Detailed system health + content: + application/json: + schema: + $ref: "#/components/schemas/AdminHealthResponse" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" + + # ─── Analytics ─────────────────────────────────────────────────────────── + + /api/v1/config/analytics: + get: + tags: [Analytics] + summary: Get analytics configuration + description: | + Returns analytics provider configuration. When ANALYTICS_ENABLED is + false on the server, all values are empty. Public endpoint. + security: [] + responses: + "200": + description: Analytics configuration + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + posthogApiKey: + type: string + posthogHost: + type: string + sentryDsn: + type: string + sampleRate: + type: number + instanceId: + type: string + + /api/v1/user/analytics: + put: + tags: [Analytics] + summary: Update user analytics consent + description: | + Set the authenticated user's analytics consent preference, or snooze + the consent prompt for 7 days with remindLater. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + description: Whether the user consents to analytics + remindLater: + type: boolean + description: Snooze the consent prompt for 7 days + responses: + "200": + description: Consent updated + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + analyticsEnabled: + type: boolean + nullable: true + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + + # ─── Features ──────────────────────────────────────────────────────────── + + /api/v1/features: + get: + tags: [Features] + summary: List feature bundles + description: | + Returns all available AI feature bundles and their installation status. + In non-Docker environments, all bundles show as installed. + security: + - bearerAuth: [] + responses: + "200": + description: Feature bundle list + content: + application/json: + schema: + type: object + properties: + bundles: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + status: + type: string + enum: [installed, not_installed, installing, error] + installedVersion: + type: string + nullable: true + estimatedSize: + type: string + enablesTools: + type: array + items: + type: string + progress: + type: object + nullable: true + properties: + percent: + type: number + stage: + type: string + error: + type: string + nullable: true + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + + /api/v1/admin/features/{bundleId}/install: + post: + tags: [Features] + summary: Install a feature bundle + description: | + Start async installation of an AI feature bundle. Downloads models and + configures the Python sidecar. Returns a jobId for progress tracking + via the SSE endpoint. Requires features:manage permission. + security: + - bearerAuth: [] + parameters: + - name: bundleId + in: path + required: true + description: Feature bundle identifier + schema: + type: string + responses: + "202": + description: Installation started + content: + application/json: + schema: + type: object + properties: + jobId: + type: string + description: Job ID for tracking installation progress via SSE + "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: Unknown bundle ID + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Bundle already installed or another install in progress + content: + application/json: + schema: + $ref: "#/components/schemas/ConflictError" + + /api/v1/admin/features/{bundleId}/uninstall: + post: + tags: [Features] + summary: Uninstall a feature bundle + description: | + Uninstall an AI feature bundle and delete its model files (unless shared + with another installed bundle). Requires features:manage permission. + security: + - bearerAuth: [] + parameters: + - name: bundleId + in: path + required: true + description: Feature bundle identifier + schema: + type: string + responses: + "200": + description: Bundle uninstalled + content: + application/json: + schema: + 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: Unknown bundle ID + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Bundle is not installed + content: + application/json: + schema: + $ref: "#/components/schemas/ConflictError" + + /api/v1/admin/features/disk-usage: + get: + tags: [Features] + summary: AI model disk usage + description: | + Returns the total disk space used by AI model files. + Requires features:manage permission. + security: + - bearerAuth: [] + responses: + "200": + description: Disk usage + content: + application/json: + schema: + type: object + properties: + totalBytes: + type: integer + description: Total bytes used by AI model files + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" + + # ─── Audit Log ─────────────────────────────────────────────────────────── + + /api/v1/audit-log: + get: + tags: [Audit] + summary: Query audit log + description: | + Paginated audit log of administrative actions. Supports filtering by + action type and date range. Requires audit:read permission. + security: + - bearerAuth: [] + parameters: + - name: page + in: query + description: Page number (default 1) + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + description: Results per page (1-100, default 50) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: action + in: query + description: Filter by action type + schema: + type: string + - name: from + in: query + description: Filter entries after this ISO 8601 date + schema: + type: string + format: date-time + - name: to + in: query + description: Filter entries before this ISO 8601 date + schema: + type: string + format: date-time + responses: + "200": + description: Paginated audit log entries + content: + application/json: + schema: + type: object + properties: + entries: + type: array + items: + type: object + properties: + id: + type: string + actorId: + type: string + actorUsername: + type: string + action: + type: string + targetType: + type: string + targetId: + type: string + details: + type: object + nullable: true + ipAddress: + type: string + createdAt: + type: string + format: date-time + total: + type: integer + description: Total matching entries + page: + type: integer + limit: + type: integer + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" + + # ─── Roles ─────────────────────────────────────────────────────────────── + + /api/v1/roles: + get: + tags: [Roles] + summary: List roles + description: | + List all roles (built-in and custom) with their permissions and user + counts. Requires audit:read permission. + security: + - bearerAuth: [] + responses: + "200": + description: Role list + content: + application/json: + schema: + type: object + properties: + roles: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + permissions: + type: array + items: + type: string + isBuiltin: + type: boolean + userCount: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + 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: [Roles] + summary: Create a custom role + description: | + Create a new custom role with specified permissions. Role names must be + 2-30 characters, lowercase alphanumeric with hyphens and underscores. + Requires users:manage permission. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, permissions] + properties: + name: + type: string + minLength: 2 + maxLength: 30 + pattern: "^[a-z0-9_-]+$" + description: Lowercase role name + description: + type: string + description: Human-readable description + permissions: + type: array + items: + type: string + description: | + Permission strings. Valid values: tools:use, files:own, files:all, + apikeys:own, apikeys:all, pipelines:own, pipelines:all, + settings:read, settings:write, users:manage, teams:manage, + branding:manage, features:manage, system:health, audit:read + responses: + "201": + description: Role created + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + permissions: + type: array + items: + type: string + isBuiltin: + type: boolean + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/UnauthorizedError" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/ForbiddenError" + "409": + description: Role name already exists + content: + application/json: + schema: + $ref: "#/components/schemas/ConflictError" + + /api/v1/roles/{id}: + put: + tags: [Roles] + summary: Update a custom role + description: | + Update name, description, or permissions of a custom role. Built-in + roles cannot be modified. Requires users:manage permission. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: Role UUID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 2 + maxLength: 30 + description: + type: string + permissions: + type: array + items: + type: string + responses: + "200": + description: Role updated + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + "400": + description: Validation error or cannot modify built-in role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "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: Role not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Role name already exists + content: + application/json: + schema: + $ref: "#/components/schemas/ConflictError" + delete: + tags: [Roles] + summary: Delete a custom role + description: | + Delete a custom role. Users assigned to this role are reassigned to the + default "user" role. Built-in roles cannot be deleted. Requires + users:manage permission. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: Role UUID + schema: + type: string + responses: + "200": + description: Role deleted + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + "400": + description: Cannot delete built-in role + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "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: Role not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index dbaaa751..82e1f38b 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "no import { promisify } from "node:util"; import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { auditLog } from "../lib/audit.js"; @@ -69,6 +70,34 @@ function validateUsername(username: string): string | null { return null; } +// ── Zod schemas for auth request bodies ────────────────────────── + +const loginSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), +}); + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(1, "New password is required"), +}); + +const registerSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), + role: z.string().optional(), + team: z.string().optional(), +}); + +const updateUserSchema = z.object({ + role: z.string().optional(), + team: z.string().optional(), +}); + +const resetPasswordSchema = z.object({ + newPassword: z.string().min(1, "New password is required"), +}); + // ── Request helpers ─────────────────────────────────────────────── /** Extract the authenticated user attached by authMiddleware. */ @@ -164,11 +193,11 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(403).send({ error: "Authentication is disabled" }); } - const body = request.body as { username?: string; password?: string } | null; - - if (!body?.username || !body?.password) { + const parsed = loginSchema.safeParse(request.body); + if (!parsed.success) { return reply.status(400).send({ error: "Username and password are required" }); } + const body = parsed.data; const user = db .select() @@ -290,17 +319,14 @@ export async function authRoutes(app: FastifyInstance): Promise { const authUser = requireAuth(request, reply); if (!authUser) return; - const body = request.body as { - currentPassword?: string; - newPassword?: string; - } | null; - - if (!body?.currentPassword || !body?.newPassword) { + const parsed = changePasswordSchema.safeParse(request.body); + if (!parsed.success) { return reply.status(400).send({ error: "Current password and new password are required", code: "VALIDATION_ERROR", }); } + const body = parsed.data; const pwError = validatePasswordStrength(body.newPassword); if (pwError) { @@ -386,18 +412,14 @@ export async function authRoutes(app: FastifyInstance): Promise { const admin = requirePermission("users:manage")(request, reply); if (!admin) return; - const body = request.body as { - username?: string; - password?: string; - role?: string; - } | null; - - if (!body?.username || !body?.password) { + const parsed = registerSchema.safeParse(request.body); + if (!parsed.success) { return reply.status(400).send({ error: "Username and password are required", code: "VALIDATION_ERROR", }); } + const body = parsed.data; const usernameError = validateUsername(body.username); if (usernameError) { @@ -443,8 +465,8 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - // Resolve team — frontend sends team name (e.g. "Default"), not ID - const requestedTeam = (body as { team?: string }).team; + // Resolve team -- frontend sends team name (e.g. "Default"), not ID + const requestedTeam = body.team; let teamId: string; let teamName: string; @@ -487,13 +509,15 @@ export async function authRoutes(app: FastifyInstance): Promise { }); } - // Check user limit - const userCount = db.select().from(schema.users).all().length; - if (userCount >= MAX_USERS) { - return reply.status(403).send({ - error: `User limit reached (${MAX_USERS} max)`, - code: "USER_LIMIT_REACHED", - }); + // Check user limit (0 = unlimited) + if (MAX_USERS > 0) { + const userCount = db.select().from(schema.users).all().length; + if (userCount >= MAX_USERS) { + return reply.status(403).send({ + error: `User limit reached (${MAX_USERS} max)`, + code: "USER_LIMIT_REACHED", + }); + } } const id = randomUUID(); @@ -533,7 +557,14 @@ export async function authRoutes(app: FastifyInstance): Promise { if (!admin) return; const { id } = request.params; - const body = request.body as { role?: string; team?: string } | null; + const parsed = updateUserSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.issues.map((i) => i.message).join("; "), + code: "VALIDATION_ERROR", + }); + } + const body = parsed.data; const user = db.select().from(schema.users).where(eq(schema.users.id, id)).get(); @@ -546,7 +577,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }; // Escalation prevention - if (body?.role) { + if (body.role) { const roleHierarchy: Record = { admin: 3, editor: 2, user: 1 }; const actorLevel = roleHierarchy[admin.role] ?? 0; const targetLevel = roleHierarchy[body.role] ?? 0; @@ -558,7 +589,7 @@ export async function authRoutes(app: FastifyInstance): Promise { } } - if (body?.role) { + if (body.role) { const validBuiltinRoles = ["admin", "editor", "user"]; const isValid = validBuiltinRoles.includes(body.role) || @@ -591,7 +622,7 @@ export async function authRoutes(app: FastifyInstance): Promise { } } - if (typeof body?.team === "string" && body.team.trim()) { + if (body.team?.trim()) { // Look up by name first, then fall back to ID const teamByName = db .select() @@ -628,14 +659,14 @@ export async function authRoutes(app: FastifyInstance): Promise { if (!admin) return; const { id } = request.params; - const body = request.body as { newPassword?: string } | null; - - if (!body?.newPassword) { + const parsed = resetPasswordSchema.safeParse(request.body); + if (!parsed.success) { return reply.status(400).send({ error: "New password is required", code: "VALIDATION_ERROR", }); } + const body = parsed.data; const pwError = validatePasswordStrength(body.newPassword); if (pwError) { diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index dbff6dfa..080416bd 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -1,9 +1,15 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; +import { z } from "zod"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { requireAuth } from "../plugins/auth.js"; +const analyticsConsentSchema = z.object({ + enabled: z.boolean().optional(), + remindLater: z.boolean().optional(), +}); + export async function analyticsRoutes(app: FastifyInstance): Promise { app.get("/api/v1/config/analytics", async () => { if (!env.ANALYTICS_ENABLED) { @@ -37,14 +43,18 @@ export async function analyticsRoutes(app: FastifyInstance): Promise { const user = requireAuth(request, reply); if (!user) return; - const body = request.body as { - enabled?: boolean; - remindLater?: boolean; - } | null; + const parsed = analyticsConsentSchema.safeParse(request.body ?? {}); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.issues.map((i) => i.message).join("; "), + code: "VALIDATION_ERROR", + }); + } + const body = parsed.data; const now = new Date(); - if (body?.remindLater) { + if (body.remindLater) { const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); db.update(schema.users) .set({ @@ -58,7 +68,7 @@ export async function analyticsRoutes(app: FastifyInstance): Promise { return reply.send({ ok: true, analyticsEnabled: null }); } - const enabled = body?.enabled === true; + const enabled = body.enabled === true; db.update(schema.users) .set({ analyticsEnabled: enabled, diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index dc2808be..9cea1b25 100644 --- a/apps/api/src/routes/api-keys.ts +++ b/apps/api/src/routes/api-keys.ts @@ -8,36 +8,39 @@ import { randomBytes, randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; import { db, schema } from "../db/index.js"; import { auditLog } from "../lib/audit.js"; import { getPermissions, hasEffectivePermission } from "../permissions.js"; import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js"; +const createApiKeySchema = z.object({ + name: z.string().max(100, "Key name must be 100 characters or fewer").optional(), + permissions: z.array(z.string()).optional(), + expiresAt: z.string().optional(), +}); + export async function apiKeyRoutes(app: FastifyInstance): Promise { // POST /api/v1/api-keys — Generate a new API key app.post("/api/v1/api-keys", async (request: FastifyRequest, reply: FastifyReply) => { const user = requireAuth(request, reply); if (!user) return; - const body = request.body as { - name?: string; - permissions?: string[]; - expiresAt?: string; - } | null; - const name = body?.name?.trim() || "Default API Key"; - - if (name.length > 100) { + const parsed = createApiKeySchema.safeParse(request.body ?? {}); + if (!parsed.success) { return reply.status(400).send({ - error: "Key name must be 100 characters or fewer", + error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } + const body = parsed.data; + const name = body.name?.trim() || "Default API Key"; let scopedPermissions: string[] | null = null; - if (Array.isArray(body?.permissions) && body.permissions.length > 0) { + if (body.permissions && body.permissions.length > 0) { const userPerms = getPermissions(user.role); const permSet = new Set(userPerms); - const invalid = body.permissions.filter((p: string) => !permSet.has(p)); + const invalid = body.permissions.filter((p) => !permSet.has(p)); if (invalid.length > 0) { return reply.status(400).send({ error: `Cannot scope key with permissions you don't have: ${invalid.join(", ")}`, @@ -48,19 +51,19 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise { } let expiresAt: Date | null = null; - if (body?.expiresAt) { - const parsed = new Date(body.expiresAt); - if (Number.isNaN(parsed.getTime())) { + if (body.expiresAt) { + const parsedDate = new Date(body.expiresAt); + if (Number.isNaN(parsedDate.getTime())) { return reply .status(400) .send({ error: "Invalid expiresAt date", code: "VALIDATION_ERROR" }); } - if (parsed <= new Date()) { + if (parsedDate <= new Date()) { return reply .status(400) .send({ error: "expiresAt must be in the future", code: "VALIDATION_ERROR" }); } - expiresAt = parsed; + expiresAt = parsedDate; } // Generate a raw API key: "si_" prefix + 48 random bytes as hex diff --git a/apps/api/src/routes/roles.ts b/apps/api/src/routes/roles.ts index fdf7d2bc..eb7f62a5 100644 --- a/apps/api/src/routes/roles.ts +++ b/apps/api/src/routes/roles.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { Permission } from "@ashim/shared"; import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; import { db, schema } from "../db/index.js"; import { auditLog } from "../lib/audit.js"; import { requirePermission } from "../permissions.js"; @@ -24,6 +25,32 @@ const ALL_PERMISSIONS: Permission[] = [ "audit:read", ]; +const roleNameField = z + .string() + .transform((v) => v.trim().toLowerCase()) + .pipe( + z + .string() + .min(2, "Role name must be 2-30 characters") + .max(30, "Role name must be 2-30 characters") + .regex( + /^[a-z0-9_-]+$/, + "Role name can only contain lowercase letters, numbers, hyphens, and underscores", + ), + ); + +const createRoleSchema = z.object({ + name: roleNameField, + description: z.string().max(500).optional(), + permissions: z.array(z.string()).min(1, "At least one permission is required"), +}); + +const updateRoleSchema = z.object({ + name: roleNameField.optional(), + description: z.string().max(500).optional(), + permissions: z.array(z.string()).optional(), +}); + export async function rolesRoutes(app: FastifyInstance): Promise { // GET /api/v1/roles — List all roles (requires audit:read to view) app.get("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => { @@ -60,31 +87,16 @@ export async function rolesRoutes(app: FastifyInstance): Promise { const user = requirePermission("users:manage")(request, reply); if (!user) return; - const body = request.body as { - name?: string; - description?: string; - permissions?: string[]; - } | null; - if (!body?.name || !Array.isArray(body?.permissions)) { - return reply - .status(400) - .send({ error: "Name and permissions are required", code: "VALIDATION_ERROR" }); - } - - const name = body.name.trim().toLowerCase(); - if (name.length < 2 || name.length > 30) { - return reply - .status(400) - .send({ error: "Role name must be 2-30 characters", code: "VALIDATION_ERROR" }); - } - if (!/^[a-z0-9_-]+$/.test(name)) { + const parsed = createRoleSchema.safeParse(request.body); + if (!parsed.success) { return reply.status(400).send({ - error: "Role name can only contain lowercase letters, numbers, hyphens, and underscores", + error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } + const { name, description, permissions } = parsed.data; - const invalid = body.permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); + const invalid = permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); if (invalid.length > 0) { return reply .status(400) @@ -101,8 +113,8 @@ export async function rolesRoutes(app: FastifyInstance): Promise { .values({ id, name, - description: body.description?.trim() ?? "", - permissions: JSON.stringify(body.permissions), + description: description?.trim() ?? "", + permissions: JSON.stringify(permissions), isBuiltin: false, createdBy: user.id, }) @@ -113,8 +125,8 @@ export async function rolesRoutes(app: FastifyInstance): Promise { return reply.status(201).send({ id, name, - description: body.description?.trim() ?? "", - permissions: body.permissions, + description: description?.trim() ?? "", + permissions, isBuiltin: false, }); }); @@ -137,32 +149,32 @@ export async function rolesRoutes(app: FastifyInstance): Promise { .send({ error: "Cannot modify built-in roles", code: "VALIDATION_ERROR" }); } - const body = request.body as { - name?: string; - description?: string; - permissions?: string[]; - } | null; + const parsed = updateRoleSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.issues.map((i) => i.message).join("; "), + code: "VALIDATION_ERROR", + }); + } + const body = parsed.data; const updates: Record = { updatedAt: new Date() }; - if (body?.name) { - const name = body.name.trim().toLowerCase(); - if (name.length < 2 || name.length > 30) { - return reply - .status(400) - .send({ error: "Role name must be 2-30 characters", code: "VALIDATION_ERROR" }); - } - const dup = db.select().from(schema.roles).where(eq(schema.roles.name, name)).get(); + if (body.name) { + const dup = db.select().from(schema.roles).where(eq(schema.roles.name, body.name)).get(); if (dup && dup.id !== id) { return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" }); } // Update users on old role name to new name - db.update(schema.users).set({ role: name }).where(eq(schema.users.role, role.name)).run(); - updates.name = name; + db.update(schema.users) + .set({ role: body.name }) + .where(eq(schema.users.role, role.name)) + .run(); + updates.name = body.name; } - if (body?.description !== undefined) { + if (body.description !== undefined) { updates.description = body.description.trim(); } - if (Array.isArray(body?.permissions)) { + if (body.permissions) { const invalid = body.permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); if (invalid.length > 0) { return reply.status(400).send({ diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 52f12512..60774b9c 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -8,10 +8,13 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; import { db, schema } from "../db/index.js"; import { requirePermission } from "../permissions.js"; import { requireAuth } from "../plugins/auth.js"; +const settingsBodySchema = z.record(z.string().min(1), z.unknown()); + const HTML_TAG_PATTERN = /<[a-z/!][^>]*>/i; export async function settingsRoutes(app: FastifyInstance): Promise { @@ -35,14 +38,14 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const admin = requirePermission("settings:write")(request, reply); if (!admin) return; - const body = request.body as Record | null; - - if (!body || typeof body !== "object" || Array.isArray(body)) { + const parsed = settingsBodySchema.safeParse(request.body); + if (!parsed.success) { return reply.status(400).send({ error: "Request body must be a JSON object with key-value pairs", code: "VALIDATION_ERROR", }); } + const body = parsed.data; // Pass 1: validate all entries before writing any const entries: Array<{ key: string; strValue: string }> = []; diff --git a/apps/api/src/routes/teams.ts b/apps/api/src/routes/teams.ts index d6413ec9..5ebebc2b 100644 --- a/apps/api/src/routes/teams.ts +++ b/apps/api/src/routes/teams.ts @@ -10,16 +10,21 @@ import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; import { db, schema } from "../db/index.js"; import { requirePermission } from "../permissions.js"; -function validateTeamName(name: unknown): string | null { - if (typeof name !== "string") return "Team name is required"; - const trimmed = name.trim(); - if (trimmed.length === 0) return "Team name is required"; - if (trimmed.length > 50) return "Team name must be 50 characters or fewer"; - return null; -} +const teamNameSchema = z.object({ + name: z + .string({ required_error: "Team name is required" }) + .transform((v) => v.trim()) + .pipe( + z + .string() + .min(1, "Team name is required") + .max(50, "Team name must be 50 characters or fewer"), + ), +}); export async function teamsRoutes(app: FastifyInstance): Promise { // GET /api/v1/teams — List all teams with member count (admin only) @@ -50,14 +55,14 @@ export async function teamsRoutes(app: FastifyInstance): Promise { const admin = requirePermission("teams:manage")(request, reply); if (!admin) return; - const body = request.body as { name?: string } | null; - - const nameError = validateTeamName(body?.name); - if (nameError) { - return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" }); + const parsed = teamNameSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.issues.map((i) => i.message).join("; "), + code: "VALIDATION_ERROR", + }); } - - const trimmedName = (body?.name ?? "").trim(); + const trimmedName = parsed.data.name; // Check for duplicate name (case-insensitive) const existing = db @@ -85,19 +90,20 @@ export async function teamsRoutes(app: FastifyInstance): Promise { if (!admin) return; const { id } = request.params; - const body = request.body as { name?: string } | null; const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get(); if (!team) { return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" }); } - const nameError = validateTeamName(body?.name); - if (nameError) { - return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" }); + const parsed = teamNameSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.issues.map((i) => i.message).join("; "), + code: "VALIDATION_ERROR", + }); } - - const trimmedName = (body?.name ?? "").trim(); + const trimmedName = parsed.data.name; // Check for duplicate name (case-insensitive), excluding current team const duplicate = db diff --git a/apps/api/src/routes/tools/barcode-read.ts b/apps/api/src/routes/tools/barcode-read.ts index a41de07b..62f84b0b 100644 --- a/apps/api/src/routes/tools/barcode-read.ts +++ b/apps/api/src/routes/tools/barcode-read.ts @@ -3,12 +3,18 @@ import { writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; +import { z } from "zod"; import { readBarcodes } from "zxing-wasm/reader"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; +const settingsSchema = z.object({ + tryHarder: z.boolean().default(true), +}); + /** * Color palette for bounding-box overlays. * Semi-transparent fills paired with solid strokes. @@ -111,9 +117,23 @@ export function registerBarcodeRead(app: FastifyInstance) { }); } + // Parse and validate settings + let settings: z.infer; try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; - const tryHarder = settings.tryHarder !== false; // default true + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + + try { + const tryHarder = settings.tryHarder; // Decode HEIC/HEIF if needed, then auto-orient fileBuffer = await ensureSharpCompat(fileBuffer); diff --git a/apps/api/src/routes/tools/blur-faces.ts b/apps/api/src/routes/tools/blur-faces.ts index 3844f71d..4757b187 100644 --- a/apps/api/src/routes/tools/blur-faces.ts +++ b/apps/api/src/routes/tools/blur-faces.ts @@ -6,6 +6,7 @@ import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -14,6 +15,11 @@ import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; +const settingsSchema = z.object({ + blurRadius: z.number().min(1).max(100).default(30), + sensitivity: z.number().min(0).max(1).default(0.5), +}); + /** Face detection and blurring route. */ export function registerBlurFaces(app: FastifyInstance) { app.post("/api/v1/tools/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => { @@ -67,7 +73,19 @@ export function registerBlurFaces(app: FastifyInstance) { } try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); @@ -78,12 +96,13 @@ export function registerBlurFaces(app: FastifyInstance) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } + const { blurRadius, sensitivity } = settings; request.log.info( { toolId: "blur-faces", imageSize: fileBuffer.length, - blurRadius: settings.blurRadius, - sensitivity: settings.sensitivity, + blurRadius, + sensitivity, }, "Starting face blur", ); @@ -114,8 +133,8 @@ export function registerBlurFaces(app: FastifyInstance) { fileBuffer, join(workspacePath, "output"), { - blurRadius: settings.blurRadius ?? 30, - sensitivity: settings.sensitivity ?? 0.5, + blurRadius, + sensitivity, }, onProgress, ); diff --git a/apps/api/src/routes/tools/colorize.ts b/apps/api/src/routes/tools/colorize.ts index 44dc12bb..be988c70 100644 --- a/apps/api/src/routes/tools/colorize.ts +++ b/apps/api/src/routes/tools/colorize.ts @@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -16,6 +17,11 @@ import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; +const settingsSchema = z.object({ + intensity: z.number().min(0).max(1).default(1.0), + model: z.enum(["auto", "ddcolor", "opencv"]).default("auto"), +}); + /** * AI photo colorization route. * Converts B&W / grayscale photos to full color using DDColor, @@ -73,9 +79,21 @@ export function registerColorize(app: FastifyInstance) { } try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; - const intensity = Math.min(1, Math.max(0, Number(settings.intensity) || 1.0)); - const model = settings.model || "auto"; + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + + const { intensity, model } = settings; request.log.info( { toolId: "colorize", imageSize: fileBuffer.length, intensity, model }, diff --git a/apps/api/src/routes/tools/enhance-faces.ts b/apps/api/src/routes/tools/enhance-faces.ts index 822b2ff7..7e4862c4 100644 --- a/apps/api/src/routes/tools/enhance-faces.ts +++ b/apps/api/src/routes/tools/enhance-faces.ts @@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -15,6 +16,13 @@ import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; +const settingsSchema = z.object({ + model: z.enum(["auto", "gfpgan", "codeformer"]).default("auto"), + strength: z.number().min(0).max(1).default(0.8), + onlyCenterFace: z.boolean().default(false), + sensitivity: z.number().min(0).max(1).default(0.5), +}); + /** Face enhancement route using GFPGAN/CodeFormer. */ export function registerEnhanceFaces(app: FastifyInstance) { app.post("/api/v1/tools/enhance-faces", async (request: FastifyRequest, reply: FastifyReply) => { @@ -68,11 +76,21 @@ export function registerEnhanceFaces(app: FastifyInstance) { } try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; - const model = settings.model || "auto"; - const strength = Number(settings.strength) || 0.8; - const onlyCenterFace = Boolean(settings.onlyCenterFace); - const sensitivity = Number(settings.sensitivity) || 0.5; + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + + const { model, strength, onlyCenterFace, sensitivity } = settings; request.log.info( { toolId: "enhance-faces", imageSize: fileBuffer.length, model, strength }, "Starting face enhancement", diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts index 51e9d4a7..992caf97 100644 --- a/apps/api/src/routes/tools/erase-object.ts +++ b/apps/api/src/routes/tools/erase-object.ts @@ -5,6 +5,7 @@ import { inpaint } from "@ashim/ai"; import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; +import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; @@ -27,6 +28,13 @@ const EXT_MAP: Record = { const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); +const settingsSchema = z.object({ + format: z + .enum(["png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"]) + .default("png"), + quality: z.number().int().min(1).max(100).default(95), +}); + /** * Object eraser / inpainting route. * Accepts an image and a mask image, erases masked areas using LaMa. @@ -101,6 +109,19 @@ export function registerEraseObject(app: FastifyInstance) { } try { + // Validate format and quality via Zod + const settingsResult = settingsSchema.safeParse({ format, quality }); + if (!settingsResult.success) { + return reply.status(400).send({ + error: "Invalid settings", + details: settingsResult.error.issues + .map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message)) + .join("; "), + }); + } + format = settingsResult.data.format; + quality = settingsResult.data.quality; + request.log.info( { toolId: "erase-object", diff --git a/apps/api/src/routes/tools/favicon.ts b/apps/api/src/routes/tools/favicon.ts index 89838ed4..670548f9 100644 --- a/apps/api/src/routes/tools/favicon.ts +++ b/apps/api/src/routes/tools/favicon.ts @@ -3,9 +3,14 @@ import { basename, extname } from "node:path"; import archiver from "archiver"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; +import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; +const settingsSchema = z.object({}).passthrough(); + const FAVICON_SIZES = [ { name: "favicon-16x16.png", size: 16, format: "png" as const }, { name: "favicon-32x32.png", size: 32, format: "png" as const }, @@ -23,6 +28,7 @@ interface UploadedFile { export function registerFavicon(app: FastifyInstance) { app.post("/api/v1/tools/favicon", async (request, reply) => { const uploadedFiles: UploadedFile[] = []; + let settingsRaw: string | null = null; try { const parts = request.parts(); @@ -35,6 +41,8 @@ export function registerFavicon(app: FastifyInstance) { const buffer = Buffer.concat(chunks); const filename = basename(part.filename ?? `image-${uploadedFiles.length + 1}`); uploadedFiles.push({ buffer, filename }); + } else if (part.fieldname === "settings") { + settingsRaw = part.value as string; } } } catch (err) { @@ -48,6 +56,30 @@ export function registerFavicon(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } + // Validate all uploaded files + for (const file of uploadedFiles) { + const validation = await validateImageBuffer(file.buffer, file.filename); + if (!validation.valid) { + return reply + .status(400) + .send({ error: `Invalid file "${file.filename}": ${validation.reason}` }); + } + } + + if (settingsRaw) { + try { + const parsed = JSON.parse(settingsRaw); + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + } + try { const jobId = randomUUID(); const isSingleFile = uploadedFiles.length === 1; diff --git a/apps/api/src/routes/tools/find-duplicates.ts b/apps/api/src/routes/tools/find-duplicates.ts index 74ca9192..07b704ca 100644 --- a/apps/api/src/routes/tools/find-duplicates.ts +++ b/apps/api/src/routes/tools/find-duplicates.ts @@ -1,10 +1,15 @@ import { basename } from "node:path"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; +import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; -const DEFAULT_THRESHOLD = 8; +const settingsSchema = z.object({ + threshold: z.number().min(0).max(20).default(8), +}); + const THUMBNAIL_WIDTH = 200; /** @@ -89,7 +94,7 @@ async function extractFileInfo(file: FileData): Promise { export function registerFindDuplicates(app: FastifyInstance) { app.post("/api/v1/tools/find-duplicates", async (request, reply) => { const files: FileData[] = []; - let threshold = DEFAULT_THRESHOLD; + let settingsRaw: string | null = null; try { const parts = request.parts(); @@ -107,11 +112,11 @@ export function registerFindDuplicates(app: FastifyInstance) { originalSize: buf.length, }); } + } else if (part.type === "field" && part.fieldname === "settings") { + settingsRaw = part.value as string; } else if (part.type === "field" && part.fieldname === "threshold") { - const val = Number(part.value); - if (!Number.isNaN(val) && val >= 0 && val <= 20) { - threshold = val; - } + // Legacy: accept bare threshold field as settings + settingsRaw = JSON.stringify({ threshold: Number(part.value) }); } } } catch (err) { @@ -121,6 +126,23 @@ export function registerFindDuplicates(app: FastifyInstance) { }); } + // Parse and validate settings + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + + const threshold = settings.threshold; + if (files.length < 2) { return reply .status(400) diff --git a/apps/api/src/routes/tools/image-to-base64.ts b/apps/api/src/routes/tools/image-to-base64.ts index bb8dc2ec..c0a83dc1 100644 --- a/apps/api/src/routes/tools/image-to-base64.ts +++ b/apps/api/src/routes/tools/image-to-base64.ts @@ -2,6 +2,7 @@ import { basename } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ @@ -89,7 +90,7 @@ export function registerImageToBase64(app: FastifyInstance) { if (!parsed.success) { return reply.status(400).send({ error: "Invalid settings", - details: parsed.error.flatten().fieldErrors, + details: formatZodErrors(parsed.error.issues), }); } const opts = parsed.data; diff --git a/apps/api/src/routes/tools/noise-removal.ts b/apps/api/src/routes/tools/noise-removal.ts index e2deaca0..acda7f12 100644 --- a/apps/api/src/routes/tools/noise-removal.ts +++ b/apps/api/src/routes/tools/noise-removal.ts @@ -6,6 +6,7 @@ import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -79,7 +80,20 @@ export function registerNoiseRemoval(app: FastifyInstance) { } try { - const parsed = settingsSchema.parse(settingsRaw ? JSON.parse(settingsRaw) : {}); + let parsed: z.infer; + try { + const raw = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(raw); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + parsed = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + request.log.info( { toolId: "noise-removal", imageSize: fileBuffer.length, tier: parsed.tier }, "Starting noise removal", diff --git a/apps/api/src/routes/tools/red-eye-removal.ts b/apps/api/src/routes/tools/red-eye-removal.ts index 71f5b9ea..bf425e36 100644 --- a/apps/api/src/routes/tools/red-eye-removal.ts +++ b/apps/api/src/routes/tools/red-eye-removal.ts @@ -6,6 +6,7 @@ import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -14,6 +15,13 @@ import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; +const settingsSchema = z.object({ + sensitivity: z.number().min(0).max(100).default(50), + strength: z.number().min(0).max(100).default(70), + format: z.string().optional(), + quality: z.number().min(1).max(100).default(90), +}); + /** Red eye detection and removal route. */ export function registerRedEyeRemoval(app: FastifyInstance) { app.post( @@ -69,7 +77,19 @@ export function registerRedEyeRemoval(app: FastifyInstance) { } try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } if (validation.format === "heif") { fileBuffer = await decodeHeic(fileBuffer); @@ -80,12 +100,13 @@ export function registerRedEyeRemoval(app: FastifyInstance) { fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format); } + const { sensitivity, strength, format: outputFormat, quality } = settings; request.log.info( { toolId: "red-eye-removal", imageSize: fileBuffer.length, - sensitivity: settings.sensitivity, - strength: settings.strength, + sensitivity, + strength, }, "Starting red eye removal", ); @@ -116,10 +137,10 @@ export function registerRedEyeRemoval(app: FastifyInstance) { fileBuffer, join(workspacePath, "output"), { - sensitivity: settings.sensitivity ?? 50, - strength: settings.strength ?? 70, - format: settings.format, - quality: settings.quality ?? 90, + sensitivity, + strength, + format: outputFormat, + quality, }, onProgress, ); diff --git a/apps/api/src/routes/tools/remove-background.ts b/apps/api/src/routes/tools/remove-background.ts index 28b55f5c..87fbe949 100644 --- a/apps/api/src/routes/tools/remove-background.ts +++ b/apps/api/src/routes/tools/remove-background.ts @@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; import { applyEffects } from "../../lib/bg-effects.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -92,7 +93,19 @@ export function registerRemoveBackground(app: FastifyInstance) { } try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } // Decode HEIC/HEIF before processing if (validation.format === "heif") { @@ -210,14 +223,38 @@ export function registerRemoveBackground(app: FastifyInstance) { return reply.status(400).send({ error: "No settings provided" }); } - try { - const settings = JSON.parse(settingsRaw); - const { jobId, filename } = settings; + const effectsSchema = z.object({ + jobId: z.string().min(1), + filename: z.string().min(1), + backgroundType: z.enum(["transparent", "color", "gradient", "blur", "image"]).optional(), + backgroundColor: z.string().optional(), + gradientColor1: z.string().optional(), + gradientColor2: z.string().optional(), + gradientAngle: z.number().optional(), + blurEnabled: z.boolean().optional(), + blurIntensity: z.number().min(0).max(100).optional(), + shadowEnabled: z.boolean().optional(), + shadowOpacity: z.number().min(0).max(100).optional(), + }); - if (!jobId || !filename) { - return reply.status(400).send({ error: "jobId and filename are required" }); + try { + let settings: z.infer; + try { + const parsed = JSON.parse(settingsRaw); + const result = effectsSchema.safeParse(parsed); + if (!result.success) { + return reply.status(400).send({ + error: "Invalid settings", + details: formatZodErrors(result.error.issues), + }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); } + const { jobId, filename } = settings; + const workspacePath = getWorkspacePath(jobId); const baseName = filename.replace(/\.[^.]+$/, ""); diff --git a/apps/api/src/routes/tools/restore-photo.ts b/apps/api/src/routes/tools/restore-photo.ts index 6deb68a9..d8461e56 100644 --- a/apps/api/src/routes/tools/restore-photo.ts +++ b/apps/api/src/routes/tools/restore-photo.ts @@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -82,7 +83,19 @@ export function registerRestorePhoto(app: FastifyInstance) { } try { - const settings = settingsSchema.parse(settingsRaw ? JSON.parse(settingsRaw) : {}); + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } request.log.info( { toolId: "restore-photo", imageSize: fileBuffer.length, mode: settings.mode }, diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts index e02e0c4c..3f460b01 100644 --- a/apps/api/src/routes/tools/upscale.ts +++ b/apps/api/src/routes/tools/upscale.ts @@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; +import { formatZodErrors } from "../../lib/errors.js"; import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; @@ -15,6 +16,15 @@ import { createWorkspace } from "../../lib/workspace.js"; import { updateSingleFileProgress } from "../progress.js"; import { registerToolProcessFn } from "../tool-factory.js"; +const settingsSchema = z.object({ + scale: z.union([z.number(), z.string()]).transform(Number).default(2), + model: z.string().default("auto"), + faceEnhance: z.boolean().default(false), + denoise: z.union([z.number(), z.string()]).transform(Number).default(0), + format: z.string().default("png"), + quality: z.union([z.number(), z.string()]).transform(Number).default(95), +}); + /** * AI image upscaling route. * Uses Real-ESRGAN when available, falls back to Lanczos. @@ -71,13 +81,26 @@ export function registerUpscale(app: FastifyInstance) { } try { - const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; - const scale = Number(settings.scale) || 2; - const model = settings.model || "auto"; - const faceEnhance = Boolean(settings.faceEnhance); - const denoise = Number(settings.denoise) || 0; - const format = settings.format || "png"; - const outputQuality = Number(settings.quality) || 95; + let settings: z.infer; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = settingsSchema.safeParse(parsed); + if (!result.success) { + return reply + .status(400) + .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) }); + } + settings = result.data; + } catch { + return reply.status(400).send({ error: "Settings must be valid JSON" }); + } + + const scale = settings.scale; + const model = settings.model; + const faceEnhance = settings.faceEnhance; + const denoise = settings.denoise; + const format = settings.format; + const outputQuality = settings.quality; request.log.info( { toolId: "upscale", imageSize: fileBuffer.length, scale, model, format }, "Starting upscale", diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 8514a495..3084336e 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -16,6 +16,7 @@ import { extname } from "node:path"; import { and, desc, eq, like, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; +import { z } from "zod"; import { db, schema, sqlite } from "../db/index.js"; import { auditLog } from "../lib/audit.js"; import { @@ -402,16 +403,16 @@ export async function userFileRoutes(app: FastifyInstance): Promise { const user = requireAuth(request, reply); if (!user) return; - const body = request.body as { ids?: unknown } | null; - - if (!Array.isArray(body?.ids) || body.ids.length === 0) { - return reply.status(400).send({ error: "ids must be a non-empty array" }); - } - - const ids = body.ids.filter((id): id is string => typeof id === "string"); - if (ids.length === 0) { - return reply.status(400).send({ error: "ids must contain string values" }); + const deleteSchema = z.object({ + ids: z.array(z.string()).min(1, "ids must be a non-empty array of strings"), + }); + const parsed = deleteSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.issues.map((i) => i.message).join("; "), + }); } + const { ids } = parsed.data; let deletedCount = 0; diff --git a/apps/docs/api/ai.md b/apps/docs/api/ai.md index 50850854..13600640 100644 --- a/apps/docs/api/ai.md +++ b/apps/docs/api/ai.md @@ -2,7 +2,7 @@ The `@ashim/ai` package bridges Node.js to a **persistent Python sidecar** for all ML operations. The dispatcher process stays alive between requests for fast warm-start performance. GPU is auto-detected at startup and used when available. -13 AI tool routes. All models run locally - no internet required after initial model download. +14 AI tool routes. All models run locally - no internet required after initial model download. ## Architecture @@ -216,6 +216,27 @@ GPU-accelerated when an NVIDIA GPU is available. | `upper-body` | 4.5× face | LinkedIn / formal | | `half-body` | 7.0× face | Full upper body | +## Image Enhancement + +**Function:** `analyzeImage` + `applyCorrections` +**Tool route:** `image-enhancement` +**Engine:** Analysis-based (Sharp histogram and statistics) + +Analyzes the image and applies automatic corrections for exposure, contrast, white balance, saturation, sharpness, and noise. Supports scene-specific modes. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `mode` | `auto` \| `portrait` \| `landscape` \| `low-light` \| `food` \| `document` | `auto` | Scene mode for tuning corrections | +| `intensity` | number (0-100) | 50 | Overall correction strength | +| `corrections.exposure` | boolean | true | Apply exposure correction | +| `corrections.contrast` | boolean | true | Apply contrast correction | +| `corrections.whiteBalance` | boolean | true | Apply white balance correction | +| `corrections.saturation` | boolean | true | Apply saturation correction | +| `corrections.sharpness` | boolean | true | Apply sharpness correction | +| `corrections.denoise` | boolean | true | Apply denoising | + +An additional analysis endpoint is available at `POST /api/v1/tools/image-enhancement/analyze` which returns the detected corrections without applying them. + ## Content-Aware Resize (Seam Carving) **Function:** `seamCarve` diff --git a/apps/docs/api/rest.md b/apps/docs/api/rest.md index 722a72fb..550c3658 100644 --- a/apps/docs/api/rest.md +++ b/apps/docs/api/rest.md @@ -25,7 +25,7 @@ curl http://localhost:1349/api/v1/tools/resize \ -H "Authorization: Bearer " ``` -Sessions expire after 24 hours. +Sessions expire after 7 days (configurable via `SESSION_DURATION_HOURS`). ### API Keys @@ -69,6 +69,13 @@ Keys are prefixed `si_` and stored as SHA-256 hashes - the raw key is shown once | Manage users & teams | ✓ | - | | Manage branding | ✓ | - | +## Health Check + +| Method | Path | Access | Description | +|--------|------|--------|-------------| +| `GET` | `/api/v1/health` | Public | Basic health check. Returns `{"status":"healthy","version":"..."}` with 200, or `{"status":"unhealthy"}` with 503 if the database is unreachable. | +| `GET` | `/api/v1/admin/health` | Admin (`system:health`) | Detailed diagnostics including uptime, storage mode, database status, queue state, and GPU availability. | + ## Using Tools Every tool follows the same pattern: @@ -120,7 +127,7 @@ curl -X POST http://localhost:1349/api/v1/tools//batch \ | Tool ID | Name | Key settings | |---------|------|-------------| -| `color-adjustments` | Adjust Colors | `brightness`, `contrast`, `exposure`, `saturation`, `temperature`, `sharpness`, `vibrance`, effects (grayscale/sepia/invert/vignette) | +| `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` | @@ -197,7 +204,7 @@ curl -X POST http://localhost:1349/api/v1/tools/compress/batch \ -F 'settings={"quality":80}' ``` -Limits: up to **200 files** per batch. Concurrency controlled by `CONCURRENT_JOBS` (default: 3). +Concurrency is controlled by `CONCURRENT_JOBS` (default: auto-detected from CPU cores). Set `MAX_BATCH_SIZE` to limit the number of files per batch (default: unlimited). ## Pipelines @@ -300,6 +307,55 @@ Runtime key-value configuration (read by any authenticated user, write by admin Known keys: `disabledTools` (JSON array of tool IDs), `enableExperimentalTools` (bool string), `loginAttemptLimit` (number), `customLogo` (managed via branding endpoint). +## Roles + +Custom role management with granular permissions. + +| Method | Path | Access | Description | +|--------|------|--------|-------------| +| `GET` | `/api/v1/roles` | Admin (`audit:read`) | List all roles with user counts | +| `POST` | `/api/v1/roles` | Admin (`users:manage`) | Create a custom role (`name`, `description`, `permissions`) | +| `PUT` | `/api/v1/roles/:id` | Admin (`users:manage`) | Update a custom role (cannot modify built-in roles) | +| `DELETE` | `/api/v1/roles/:id` | Admin (`users:manage`) | Delete a custom role (cannot delete built-in roles; affected users revert to `user` role) | + +Available permissions: `tools:use`, `files:own`, `files:all`, `apikeys:own`, `apikeys:all`, `pipelines:own`, `pipelines:all`, `settings:read`, `settings:write`, `users:manage`, `teams:manage`, `branding:manage`, `features:manage`, `system:health`, `audit:read`. + +## Audit Log + +Admin-only endpoint for reviewing security-relevant actions. + +| Method | Path | Access | Description | +|--------|------|--------|-------------| +| `GET` | `/api/v1/audit-log` | Admin (`audit:read`) | Paginated audit log with optional filters | + +Query parameters: + +| Parameter | Description | +|-----------|-------------| +| `page` | Page number (default: 1) | +| `limit` | Entries per page (default: 50, max: 100) | +| `action` | Filter by action type (e.g. `ROLE_CREATED`, `ROLE_DELETED`) | +| `from` | Filter entries after this ISO 8601 date | +| `to` | Filter entries before this ISO 8601 date | + +## Analytics + +| Method | Path | Access | Description | +|--------|------|--------|-------------| +| `GET` | `/api/v1/config/analytics` | Public | Get analytics configuration (PostHog key, Sentry DSN, sample rate). Returns empty values if `ANALYTICS_ENABLED=false`. | +| `PUT` | `/api/v1/user/analytics` | Auth | Set the current user's analytics consent (`enabled: true/false`) or defer with `remindLater: true`. | + +## Features / AI Bundles + +Manage AI feature bundles (install/uninstall AI model packages in the Docker environment). + +| Method | Path | Access | Description | +|--------|------|--------|-------------| +| `GET` | `/api/v1/features` | Auth | List all feature bundles and their install status | +| `POST` | `/api/v1/admin/features/:bundleId/install` | Admin (`features:manage`) | Install a feature bundle (async, returns `jobId` for progress tracking) | +| `POST` | `/api/v1/admin/features/:bundleId/uninstall` | Admin (`features:manage`) | Uninstall a feature bundle and clean up model files | +| `GET` | `/api/v1/admin/features/disk-usage` | Admin (`features:manage`) | Get total disk usage of AI models | + ## Error Responses All errors return JSON: diff --git a/apps/docs/guide/architecture.md b/apps/docs/guide/architecture.md index 366270ec..b7663c5f 100644 --- a/apps/docs/guide/architecture.md +++ b/apps/docs/guide/architecture.md @@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an ### API (`apps/api`) -A Fastify v5 server exposing 47 tool routes (34 standard image operations + 13 AI-powered) that handles: +A Fastify v5 server exposing 47 tool routes (33 standard image operations + 14 AI-powered) that handles: - File uploads, temporary workspace management, and persistent file storage - User file library with version chains (`user_files` table) -- each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page - Tool execution (routes each tool request to the image engine or AI bridge) diff --git a/apps/docs/guide/configuration.md b/apps/docs/guide/configuration.md index e8711ea3..7de9d67a 100644 --- a/apps/docs/guide/configuration.md +++ b/apps/docs/guide/configuration.md @@ -9,7 +9,10 @@ All configuration is done through environment variables. Every variable has a se | Variable | Default | Description | |---|---|---| | `PORT` | `1349` | Port the server listens on. | -| `RATE_LIMIT_PER_MIN` | `100` | Maximum requests per minute per IP. | +| `RATE_LIMIT_PER_MIN` | `0` (disabled) | Maximum requests per minute per IP. Set to 0 to disable rate limiting. | +| `CORS_ORIGIN` | (empty) | Comma-separated allowed origins for CORS, or empty for same-origin only. | +| `LOG_LEVEL` | `info` | Log verbosity. One of: `fatal`, `error`, `warn`, `info`, `debug`, `trace`. | +| `TRUST_PROXY` | `true` | Trust `X-Forwarded-For` headers from a reverse proxy. Set to `false` if not behind a proxy. | ### Authentication @@ -18,7 +21,8 @@ All configuration is done through environment variables. Every variable has a se | `AUTH_ENABLED` | `false` | Set to `true` to require login. The Docker image defaults to `true`. | | `DEFAULT_USERNAME` | `admin` | Username for the initial admin account. Only used on first run. | | `DEFAULT_PASSWORD` | `admin` | Password for the initial admin account. Change this after first login. | -| `MAX_USERS` | `5` | Maximum number of registered user accounts | +| `MAX_USERS` | `0` (unlimited) | Maximum number of registered user accounts. Set to 0 for unlimited. | +| `SESSION_DURATION_HOURS` | `168` | Login session lifetime in hours (default is 7 days). | | `SKIP_MUST_CHANGE_PASSWORD` | - | Set to any non-empty value to bypass the forced password-change prompt on first login | ### Storage @@ -34,17 +38,25 @@ All configuration is done through environment variables. Every variable has a se | Variable | Default | Description | |---|---|---| -| `MAX_UPLOAD_SIZE_MB` | `100` | Maximum file size per upload in megabytes. | -| `MAX_BATCH_SIZE` | `200` | Maximum number of files in a single batch request. | -| `CONCURRENT_JOBS` | `3` | Number of batch jobs that run in parallel. Higher values use more memory. | -| `MAX_MEGAPIXELS` | `100` | Maximum image resolution allowed. Rejects images larger than this. | +| `MAX_UPLOAD_SIZE_MB` | `0` (unlimited) | Maximum file size per upload in megabytes. Set to 0 for unlimited. | +| `MAX_BATCH_SIZE` | `0` (unlimited) | Maximum number of files in a single batch request. Set to 0 for unlimited. | +| `CONCURRENT_JOBS` | `0` (auto) | Number of batch jobs that run in parallel. Set to 0 to auto-detect based on available CPU cores. | +| `MAX_MEGAPIXELS` | `0` (unlimited) | Maximum image resolution allowed in megapixels. Set to 0 for unlimited. | +| `MAX_WORKER_THREADS` | `0` (auto) | Maximum worker threads for image processing. Set to 0 to auto-detect based on available CPU cores. | +| `PROCESSING_TIMEOUT_S` | `0` (no limit) | Maximum processing time per request in seconds. Set to 0 for no timeout. | +| `MAX_PIPELINE_STEPS` | `0` (no limit) | Maximum number of steps in a pipeline. Set to 0 for no limit. | +| `MAX_CANVAS_PIXELS` | `0` (no limit) | Maximum canvas size in pixels for output images. Set to 0 for no limit. | +| `MAX_SVG_SIZE_MB` | `0` (unlimited) | Maximum SVG file size in megabytes. Set to 0 for unlimited. | +| `MAX_LOGO_SIZE_KB` | `500` | Maximum custom branding logo size in kilobytes. | +| `MAX_SPLIT_GRID` | `100` | Maximum grid dimension for the image split tool. | +| `MAX_PDF_PAGES` | `0` (unlimited) | Maximum number of PDF pages for PDF-to-image conversion. Set to 0 for unlimited. | ### Cleanup | Variable | Default | Description | |---|---|---| -| `FILE_MAX_AGE_HOURS` | `24` | How long temporary files are kept before automatic deletion. | -| `CLEANUP_INTERVAL_MINUTES` | `30` | How often the cleanup job runs. | +| `FILE_MAX_AGE_HOURS` | `72` | How long temporary files are kept before automatic deletion. | +| `CLEANUP_INTERVAL_MINUTES` | `60` | How often the cleanup job runs. | ### Appearance @@ -54,6 +66,13 @@ All configuration is done through environment variables. Every variable has a se | `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. | | `DEFAULT_LOCALE` | `en` | Default interface language. | +### Docker permissions + +| Variable | Default | Description | +|---|---|---| +| `PUID` | `999` | Run the container process as this UID. Set to match your host user for bind mounts (`id -u`). | +| `PGID` | `999` | Run the container process as this GID. Set to match your host group for bind mounts (`id -g`). | + ## Docker example ```yaml diff --git a/apps/docs/guide/developer.md b/apps/docs/guide/developer.md index f75235e7..96d16feb 100644 --- a/apps/docs/guide/developer.md +++ b/apps/docs/guide/developer.md @@ -214,5 +214,5 @@ See the [Configuration guide](/guide/configuration) for the full list. Key ones | `DEFAULT_USERNAME` | `admin` | Default admin username | | `DEFAULT_PASSWORD` | `admin` | Default admin password | | `SKIP_MUST_CHANGE_PASSWORD` | `false` | Skip forced password change (CI/dev only) | -| `RATE_LIMIT_PER_MIN` | `100` | API rate limit per minute | -| `MAX_UPLOAD_SIZE_MB` | `100` | Maximum upload size in MB | +| `RATE_LIMIT_PER_MIN` | `0` | API rate limit per minute (0 = disabled) | +| `MAX_UPLOAD_SIZE_MB` | `0` | Maximum upload size in MB (0 = unlimited) | diff --git a/apps/docs/index.md b/apps/docs/index.md index bad55d83..2cc281f1 100644 --- a/apps/docs/index.md +++ b/apps/docs/index.md @@ -17,7 +17,7 @@ features: - title: 45+ 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: 13 AI-powered tools - remove backgrounds, upscale, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR). All on your hardware, no internet required. + 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. - title: Pipelines details: Chain tools into reusable workflows with up to 20 steps. Batch process up to 200 images at once with a single request. - title: REST API