# Openinary > Self-hosted, open-source media management platform. A Cloudinary-compatible alternative with image/video transformation URLs, REST API, JavaScript SDK, and webhooks. Single Docker container — no separate media server needed. - Docs: https://openinary.adityaraj.codes/docs - Dashboard: https://openinary.adityaraj.codes/dashboard --- ## Overview Openinary is a single Next.js application that handles everything: file storage, on-the-fly image/video transformations, REST API, and a management dashboard. All API routes are under `/api/`. Media files are served from `/uploads/` and transformed on demand via `/img/`. **Key capabilities:** - Upload **any file type** — images, videos, and raw files (PDF, XLSX, CSV, DOCX, ZIP, etc.) via multipart, URL import, or base64 - Upload limit: **500 MB** per file - On-the-fly transforms via URL parameters (resize, crop, format, quality, filters, video trim) — images and videos only - Named transformation presets - Async video transcoding (MP4, WebM, GIF), sprite sheets, frame extraction - Folder and tag organization - Upload presets with format/size restrictions - Webhook delivery for upload/delete/transcode events - API key authentication (`Authorization: ApiKey ok_...`) - Analytics (storage, bandwidth, requests, transforms) - Moderation queue (approved / flagged / pending) - Raw files served at `/uploads/:path` with `Content-Disposition: attachment` for browser downloads --- ## Deployment Single docker-compose with two services: `postgres` and `app`. ```yaml services: postgres: image: postgres:16-alpine environment: POSTGRES_DB: openinary POSTGRES_USER: openinary POSTGRES_PASSWORD: secret volumes: - pgdata:/var/lib/postgresql/data app: image: ghcr.io/yourorg/openinary:latest ports: - "3080:3080" environment: DATABASE_URL: postgresql://openinary:secret@postgres:5432/openinary UPLOAD_DIR: /data/uploads CACHE_DIR: /data/cache PUBLIC_URL: https://your-domain.com AUTH_ENABLED: "true" DASHBOARD_USERNAME: admin DASHBOARD_PASSWORD: your-password IMG_SIGNING_SECRET: random-secret volumes: - uploads:/data/uploads - cache:/data/cache depends_on: postgres: condition: service_healthy ``` Database migrations, admin seeding, and cache init run automatically on startup. --- ## Environment Variables | Variable | Required | Default | Description | |---|---|---|---| | `DATABASE_URL` | yes | — | PostgreSQL connection string | | `PUBLIC_URL` | yes | http://localhost:3080 | Public base URL for asset URLs | | `AUTH_ENABLED` | no | false | Require login to access dashboard and API | | `DASHBOARD_USERNAME` | no | admin | Admin login username | | `DASHBOARD_PASSWORD` | no | changeme | Admin login password | | `IMG_SIGNING_SECRET` | no | changeme | HMAC secret for signed transform URLs | | `UPLOAD_DIR` | no | /data/uploads | Path for uploaded files | | `CACHE_DIR` | no | /data/cache | Path for transform cache | --- ## Authentication When `AUTH_ENABLED=true`, every API request must include an Authorization header: ``` Authorization: Bearer ← login token Authorization: ApiKey ok_xxx ← API key ``` ### Login ``` POST /api/auth/login Content-Type: application/json {"username": "admin", "password": "your-password"} ``` Response: ```json {"token": "eyJhbGci...", "user": {"id": "...", "role": "admin"}} ``` Token is valid for 7 days. When `AUTH_ENABLED=false`, all endpoints are public. ### Current user ``` GET /api/auth/me Authorization: Bearer ``` --- ## Upload API ### Upload file (multipart) Accepts any file type — images, videos, and raw files (PDF, XLSX, CSV, DOCX, ZIP, etc.). Max size: 500 MB. ``` POST /api/upload Authorization: Bearer or ApiKey ok_... Content-Type: multipart/form-data file=@report.pdf folder=documents (optional, default: "default") tags=q1,finance (optional, comma-separated) preset=my-preset (optional, upload preset name) ``` Response: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "filename": "report.pdf", "file_key": "uploads/documents/550e8400-....pdf", "folder": "documents", "url": "https://your-domain.com/uploads/documents/550e8400-....pdf", "size": 245120, "mime": "application/pdf", "width": null, "height": null, "duration": null, "created_at": "2024-01-15T10:30:00Z" } ``` For images, `width` and `height` are populated. For videos, `width`, `height`, and `duration` are populated. For raw files, all three are `null`. Raw files are served with `Content-Disposition: attachment` so they download directly. Images and PDFs are served inline. ### Upload from URL ``` POST /api/upload/url Authorization: Bearer Content-Type: application/json {"url": "https://example.com/photo.jpg", "folder": "imports"} ``` ### Upload from base64 ``` POST /api/upload/base64 Authorization: Bearer Content-Type: application/json {"data": "data:image/jpeg;base64,/9j/4AAQ...", "filename": "avatar.jpg", "folder": "users"} ``` ### Replace file (preserves ID and metadata) ``` POST /api/upload/replace/:id Content-Type: multipart/form-data file=@new-version.jpg ``` ### Unsigned upload (no auth, preset required) First obtain a signed upload token from the preset: ``` POST /api/upload-presets/:name/sign Authorization: Bearer ``` Then upload without auth using the returned token: ``` POST /api/upload?upload_token= Content-Type: multipart/form-data file=@image.jpg ``` --- ## Asset API ### List assets ``` GET /api/assets?folder=marketing&tag=hero&mime=image/&limit=50&offset=0&sort=created_at&order=desc Authorization: Bearer ``` Query params: `folder`, `tag`, `mime`, `limit` (max 200), `offset`, `sort` (created_at|filename|size), `order` (asc|desc), `trash` (true for trash bin) ### Get asset ``` GET /api/asset/:id ``` ### Update asset metadata ``` PATCH /api/asset/:id Content-Type: application/json {"filename": "new-name.jpg", "folder": "archived", "context": {"author": "Alice"}} ``` ### Soft delete (move to trash) ``` DELETE /api/asset/:id ``` ### Permanent delete ``` DELETE /api/asset/:id/permanent ``` ### Restore from trash ``` POST /api/asset/:id/restore ``` ### Tags ``` POST /api/asset/:id/tags {"tags": ["featured", "sale"]} DELETE /api/asset/:id/tags {"tags": ["sale"]} GET /api/tags ← list all tags with counts ``` ### Versions ``` GET /api/asset/:id/versions POST /api/asset/:id/versions/:versionId/restore ``` ### Search ``` GET /api/search?q=mountains&folder=travel&limit=20 ``` ### Bulk operations ``` POST /api/assets/bulk-delete {"ids": ["uuid-1", "uuid-2"]} POST /api/assets/bulk-tag {"ids": ["uuid-1"], "tags": ["sale"]} POST /api/assets/bulk-move {"ids": ["uuid-1"], "folder": "archived"} ``` ### Low-quality image placeholder (LQIP) ``` GET /api/asset/:id/placeholder ``` Returns a tiny base64-encoded blur placeholder for use as a loading state. ### Moderation ``` PATCH /api/asset/:id/moderation {"status": "approved"} ← approved | flagged | pending ``` --- ## Transformation URLs All image transformations use the URL pattern: ``` GET /img/// ``` `` is a 16-character HMAC-SHA256 hex string computed from `transforms + "/" + sourcePath`. Use `POST /api/sign-url` or the SDK's `client.assets.url()` to generate valid signed URLs — do not construct them manually. The transform string is a comma-separated list of `key_value` pairs. Multiple pipeline stages can be chained with `/`. ### Image parameters | Param | Description | |---|---| | `w_N` | Width in pixels | | `h_N` | Height in pixels | | `c_MODE` | Crop mode: `fill` \| `fit` \| `scale` \| `thumb` \| `pad` \| `crop` | | `g_GRAVITY` | Gravity: `center` \| `north` \| `south` \| `east` \| `west` \| `face` | | `f_FORMAT` | Output format: `webp` \| `avif` \| `jpeg` \| `jpg` \| `png` \| `gif` | | `q_N` | Quality 1–100 (JPEG/WebP/AVIF only) | | `r_N` | Border radius px, or `max` for circle | | `e_EFFECT` | Effect: `grayscale` \| `blur` \| `sharpen` \| `negate` \| `flip` \| `flop` | | `b_COLOR` | Background color (hex without #) for pad mode | | `t_NAME` | Use a named transformation preset | | `dpr_N` | Device pixel ratio multiplier (1–3) | | `o_URL` | Image overlay (URL-encoded) | ### Video parameters | Param | Description | |---|---| | `f_FORMAT` | Output: `mp4` \| `webm` \| `ogv` \| `gif` | | `w_N` | Width | | `h_N` | Height | | `so_N` | Start offset in seconds (trim) | | `eo_N` | End offset in seconds (trim) | | `q_N` | Quality/CRF (0–51, lower = better) | | `fps_N` | Frame rate | | `vc_CODEC` | Video codec: `h264` \| `vp9` \| `libx265` | | `ac_CODEC` | Audio codec: `aac` \| `libopus` \| `none` | | `br_RATE` | Bitrate: `1000k` \| `2M` | ### Examples URLs below show the structure — `` represents the computed HMAC signature returned by `POST /api/sign-url`. ``` # 400×400 fill crop, WebP, quality 80 /img//w_400,h_400,c_fill,f_webp,q_80/uploads/photos/portrait.jpg # Circular avatar, 200px /img//w_200,h_200,c_fill,r_max/uploads/users/avatar.png # Grayscale then blur (chained stages) /img//e_grayscale/e_blur:10/uploads/product.jpg # Named preset /img//t_thumbnail/uploads/marketing/hero.jpg # Retina (serves 400px for a 200px container) /img//w_200,dpr_2/uploads/logo.png # Trim video 5s–15s, output WebM /img//so_5,eo_15,f_webm/uploads/videos/clip.mp4 ``` Transformed results are cached on disk. Same params + same file = cached response, no reprocessing. ### Signed URL generation ``` POST /api/sign-url Content-Type: application/json {"transforms": "w_400,c_fill", "path": "uploads/photos/portrait.jpg"} ``` Returns `{"url": "https://...", "transforms": "...", "path": "..."}` — a signed URL that works without authentication. --- ## Named Transformations Save reusable transform presets. Reference with `t_name` in any image URL. ``` GET /api/transformations POST /api/transformations {"name": "thumbnail", "transform_string": "w_200,h_200,c_fill,f_webp,q_80"} PATCH /api/transformations/:name {"transform_string": "w_300,h_300,c_fill"} DELETE /api/transformations/:name ``` --- ## Upload Presets Reusable upload configurations with format restrictions, size limits, and auto-tags. ``` GET /api/upload-presets POST /api/upload-presets DELETE /api/upload-presets/:name GET /api/upload-presets/:name/sign ← generate signed upload token ``` Preset fields: | Field | Type | Description | |---|---|---| | `name` | string | Unique identifier | | `folder` | string | Default upload folder | | `allowed_formats` | string[] | Whitelisted extensions. Empty = any | | `max_size` | number | Max file bytes. Default: 52428800 (50 MB) | | `tags` | string[] | Auto-applied tags | | `transform_string` | string | Transform applied on ingest | | `unsigned` | boolean | Allow uploads without auth token | --- ## Video Transcoding Async transcoding jobs for format conversion, trimming, and processing. ``` POST /api/asset/:id/transcode Content-Type: application/json { "format": "webm", "width": 1280, "quality": 28, "bitrate": "2M", "fps": 30, "startOffset": 5, "endOffset": 60 } ``` Response: `202 Accepted` with `{"job_id": "..."}` ``` GET /api/asset/:id/transcode-status ← all jobs for an asset GET /api/transcode-jobs/:jobId ← poll specific job ``` Job status values: `queued` | `processing` | `done` | `failed` ### Sprite sheet (video preview thumbnails) ``` GET /api/asset/:id/sprite?cols=5&rows=4&thumb_w=160 ``` Returns an image with a grid of video thumbnails (for hover preview). ### Frame extraction ``` GET /api/asset/:id/frame?t=5.0 ``` Extracts a single frame at timestamp `t` (seconds). Returns JPEG. --- ## Folders Folders are implicit — created when the first asset is uploaded to them. ``` GET /api/folders ← list with asset counts and sizes DELETE /api/folders/:folderName ← delete empty folder ``` Nested folders use forward-slash paths: `photos/2024/january` --- ## Analytics ``` GET /api/analytics/storage?days=30 GET /api/analytics/bandwidth?days=7 GET /api/analytics/transforms?days=30 GET /api/analytics/requests?days=30&path=/img&method=GET&status=200&limit=100 ``` --- ## Webhooks HTTP POST callbacks triggered by events. ``` GET /api/webhooks POST /api/webhooks PATCH /api/webhooks/:id DELETE /api/webhooks/:id GET /api/webhooks/:id/deliveries ← last 50 deliveries POST /api/webhooks/:id/test ← send test ping ``` Create payload: ```json { "url": "https://your-server.com/webhook", "events": ["upload.complete", "transcode.complete"], "enabled": true } ``` Available events: `upload.complete`, `asset.deleted`, `asset.restored`, `asset.updated`, `transcode.complete`, `moderation.flagged` Webhook payload structure: ```json { "event": "upload.complete", "timestamp": "2024-01-15T10:30:00Z", "data": { "asset_id": "550e8400-...", "filename": "photo.jpg", "folder": "marketing", "url": "https://your-domain.com/uploads/marketing/photo.jpg" } } ``` ### Signature verification Every delivery includes `X-Openinary-Signature: sha256=`. ```typescript import { createHmac, timingSafeEqual } from "crypto"; function verifyWebhook(rawBody: Buffer, signature: string, secret: string): boolean { if (!signature.startsWith("sha256=")) return false; const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"); try { return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } catch { return false; } } ``` --- ## API Keys Programmatic authentication tokens. ``` GET /api/api-keys POST /api/api-keys {"name": "CI pipeline"} DELETE /api/api-keys/:id ``` The full key (`ok_xxx`) is only returned once on creation. Use in the `Authorization: ApiKey ok_xxx` header. --- ## Users User management (admin only). ``` GET /api/users PATCH /api/users/:id {"role": "editor"} ← admin | editor | viewer DELETE /api/users/:id ``` Roles: `admin` (full access), `editor` (upload + manage own assets), `viewer` (read-only) --- ## Moderation Queue ``` GET /api/moderation?status=flagged&limit=50 PATCH /api/asset/:id/moderation {"status": "approved"} ← approved | flagged | pending ``` --- ## JavaScript SDK > **Note:** The SDK package is not yet published to npm. Use the REST API directly for all integrations. ```bash npm install openinary ``` ```typescript import { OpeninaryClient } from "openinary"; const client = new OpeninaryClient({ baseUrl: "https://your-domain.com", apiKey: "ok_yourkey", // or token: "eyJhbGci..." }); // Upload const asset = await client.assets.upload(file, { folder: "products", tags: ["hero"] }); // Build transform URL (no round-trip) const url = client.assets.url(asset.file_key, { width: 400, height: 400, crop: "fill", format: "webp", quality: 80, }); // → https://your-domain.com/img/w_400,h_400,c_fill,f_webp,q_80/products/image.jpg // List, get, update, delete const assets = await client.assets.list({ folder: "products", limit: 50 }); const asset = await client.assets.get("uuid"); const updated = await client.assets.update("uuid", { folder: "archived" }); await client.assets.delete("uuid"); await client.assets.restore("uuid"); // Search const results = await client.assets.search("mountains", { folder: "travel" }); // Tags await client.assets.addTags("uuid", ["featured"]); await client.assets.removeTags("uuid", ["old-tag"]); // Bulk await client.assets.bulkDelete(["uuid-1", "uuid-2"]); await client.assets.bulkMove(["uuid-1"], "archived"); // Video transcoding const { job_id } = await client.assets.enqueueTranscode("uuid", { format: "webm", width: 1280, quality: 28 }); const job = await client.assets.getTranscodeJob(job_id); // Signed URL const { url, expires_at } = await client.assets.signedUrl("uuid", { transforms: "w_400,c_fill", expires: 3600 }); // Webhooks const hook = await client.webhooks.create({ url: "https://yourserver.com/hook", events: ["upload.complete"] }); console.log(hook.secret); // store this // Admin const users = await client.admin.users.list(); const keys = await client.admin.apiKeys.list(); const newKey = await client.admin.apiKeys.create("my-app"); console.log(newKey.key); // shown once await client.admin.namedTransformations.create("avatar", "w_200,h_200,c_fill,r_max"); ``` ### TransformOptions type ```typescript type TransformOptions = { width?: number; // w_ height?: number; // h_ crop?: string; // c_ — fill|fit|scale|thumb|pad|crop gravity?: string; // g_ — center|face|north|south|east|west quality?: number; // q_ format?: string; // f_ — webp|avif|jpeg|png|gif|mp4|webm radius?: string | number; // r_ — px or "max" for circle effect?: string; // e_ — grayscale|blur|sharpen|negate|flip|flop background?: string; // b_ — hex color without # named?: string; // t_ — named preset name dpr?: number; // dpr_ — 1-3 startOffset?: number; // so_ — video trim start seconds endOffset?: number; // eo_ — video trim end seconds fps?: number; // fps_ bitrate?: string; // br_ — e.g. "1500k" or "2M" }; ``` ### React Upload Widget ```tsx import { OpeninaryUploadWidget } from "openinary/upload-widget"; console.log(assets)} /> ``` ### React Media Library Widget ```tsx import { OpeninaryMediaLibrary } from "openinary/media-library"; handleSelect(assets[0].url)} /> ``` --- ## Static File Serving - `GET /uploads/:path` — serve raw uploaded files - `GET /img/:transforms/:file-key` — serve transformed file (cached) --- ## Health Check ``` GET /api/health → {"status": "ok"} ```