# Assets API Base path: `/api/admin/assets` Controller: `controllers/admin/assets.controller.js` Storage: Chibisafe (CDN) + PostgreSQL via Sequelize --- ## Prerequisites ### Multer setup The upload and update-thumbnail endpoints use `multer.fields()` — make sure your route file is configured with `memoryStorage`: ```js const multer = require("multer"); const upload = multer({ storage: multer.memoryStorage() }); // Upload router.post("/", upload.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]), assetsCtrl.uploadAsset); // Update thumbnail router.patch("/:assetId/thumbnail", upload.fields([{ name: "thumbnail", maxCount: 1 }]), assetsCtrl.updateThumbnail); ``` ### Environment variables ```env CHIBISAFE_BASE_URL=https://cdn.yourdomain.com CHIBISAFE_API_KEY=your-api-key CHIBISAFE_ALBUM_AVATARS=uuid CHIBISAFE_ALBUM_VIDEOS=uuid CHIBISAFE_ALBUM_DOCUMENTS=uuid CHIBISAFE_ALBUM_THUMBNAILS=uuid CHIBISAFE_ALBUM_ARCHIVED=uuid ``` ### Album routing `owner_type` is the single source of truth for which Chibisafe album a file lands in: | `owner_type` | Chibisafe album | Intended use | |---|---|---| | `avatar` | avatars | Profile pictures | | `video` | videos | Course / content videos | | `document` | documents | PDF, DOCX, PPT, TXT, etc. | | `thumbnail` | thumbnails | Set automatically — do not send manually | | `image` | *(none)* | General-purpose images | | anything else | *(none)* | Unclassified | --- ## Endpoints --- ### GET `/` List all assets (paginated). **Query params** | Param | Required | Description | |---|---|---| | `page` | optional | Page number. Default: `1` | | `limit` | optional | Items per page. Default: `10`, max: `1000` | | `filters` | optional | JSON array of filter objects passed to `buildQuery` | | `sort` | optional | JSON array of sort objects passed to `buildQuery` | **Response `200`** ```json { "message": "Assets retrieved.", "data": [...], "pagination": { "page": 1, "limit": 10, "totalRecords": 42, "totalPages": 5, "hasPrevPage": false, "hasNextPage": true }, "attributes": [...] } ``` Soft-deleted assets are excluded automatically. Hidden fields (per `adminExclude`): `checksum`, `storage_bucket`, `storage_key`, `deletedBy`. --- ### GET `/:assetId` Get a single asset by primary key. **URL params** | Param | Required | Description | |---|---|---| | `assetId` | required | Asset primary key (BIGINT) | **Responses** | Status | Description | |---|---| | `200` | `{ data: asset }` | | `400` | Invalid asset ID | | `404` | Asset not found | | `500` | Internal server error | --- ### POST `/` Upload a new asset. **Content-Type:** `multipart/form-data` #### File fields | Field | Required | Description | |---|---|---| | `file` | **required** | The main asset (image, video, document, etc.) | | `thumbnail` | **required if video** | Cover image for the video. Ignored for non-video files. | #### Text fields | Field | Required | Default | Description | |---|---|---|---| | `uploadedBy` | **required** | — | User ID (BIGINT) of the uploader | | `storage_provider` | **required** | — | `chibisafe` \| `local` \| `s3` \| `gcs` \| `cloudinary` | | `owner_type` | optional | `null` | Determines album routing: `avatar`, `video`, `document`, `image` | | `owner_id` | optional | `null` | ID of the owning entity (course ID, user ID, etc.) | | `display_name` | optional | original filename | Human-readable name shown in the UI | | `description` | optional | `null` | Free-text description | | `is_public` | optional | `false` | `true` \| `false` | | `access_level` | optional | `private` | `public` \| `private` \| `restricted` | | `storage_bucket` | optional | `null` | Bucket name (S3 / GCS only) | | `storage_key` | optional | `null` | Override storage key. Auto-set for Chibisafe (uses Chibisafe file UUID). | | `file_url` | conditional | — | Required when `storage_provider` is not `local` or `chibisafe` | | `width` | optional (non-video) | `null` | Image/document width in px. Ignored for videos. | | `height` | optional (non-video) | `null` | Image/document height in px. Ignored for videos. | #### Auto-extracted fields (videos only — do not send) These are extracted server-side via **ffprobe** and will override anything the client sends: | Field | Source | Example | |---|---|---| | `width` | ffprobe | `1920` | | `height` | ffprobe | `1080` | | `resolution` | derived | `1080p`, `720p`, `4K` | | `duration` | ffprobe | `281.49` (seconds) | | `frame_rate` | ffprobe | `23.976` (fps) | | `bitrate` | ffprobe | `447933` (bps) | | `video_codec` | ffprobe | `H.264`, `H.265`, `AV1`, `VP9` | | `audio_codec` | ffprobe | `AAC`, `MP3`, `Opus` | | `thumbnail_url` | Chibisafe upload | CDN URL of the uploaded thumbnail | #### Transaction strategy ``` Phase 1 (no DB connection held — slow I/O): ├─ Validate inputs ├─ Upload main file to Chibisafe → track UUID for rollback ├─ Run ffprobe on video buffer → extract metadata └─ Upload thumbnail to Chibisafe → track UUID for rollback Phase 2 (transaction open ~milliseconds): └─ Asset.create() → commit On Phase 2 failure: └─ rollback DB + deleteFile() all tracked Chibisafe UUIDs ``` **Responses** | Status | Description | |---|---| | `201` | `{ data: asset }` — fully populated asset record | | `400` | Missing `file`, `uploadedBy`, or `thumbnail` (for videos); buffer issues | | `500` | DB or Chibisafe error — Chibisafe uploads are cleaned up automatically | #### Example — video upload (Postman) ``` POST /api/admin/assets Content-Type: multipart/form-data file → (attach .mp4) thumbnail → (attach .jpg) uploadedBy → 1 storage_provider → chibisafe owner_type → video owner_id → 10 display_name → Intro to React is_public → true access_level → public ``` #### Example — avatar upload ``` POST /api/admin/assets Content-Type: multipart/form-data file → (attach .jpg) uploadedBy → 1 storage_provider → chibisafe owner_type → avatar owner_id → 5 ``` #### Example — document upload ``` POST /api/admin/assets Content-Type: multipart/form-data file → (attach .pdf) uploadedBy → 1 storage_provider → chibisafe owner_type → document owner_id → 7 display_name → Module 1 Handout ``` --- ### PATCH `/:assetId/thumbnail` Replace the thumbnail image of an existing asset by uploading a new file. **Content-Type:** `multipart/form-data` **URL params** | Param | Required | Description | |---|---|---| | `assetId` | **required** | Asset primary key | **File field** | Field | Required | Description | |---|---|---| | `thumbnail` | **required** | New thumbnail image file | **How it works** 1. Uploads the new thumbnail to Chibisafe (thumbnails album). 2. Updates `thumbnail_url` on the asset record. 3. Deletes the old thumbnail from Chibisafe (best-effort — non-fatal if it fails). > **Note:** Old thumbnail cleanup requires a `thumbnail_storage_key` column on the Asset model to track the previous Chibisafe file UUID. Without it, the old thumbnail remains on Chibisafe but the DB record is updated correctly. **Responses** | Status | Description | |---|---| | `200` | `{ data: asset }` — updated asset with new `thumbnail_url` | | `400` | No thumbnail file attached | | `404` | Asset not found | | `500` | Internal server error | --- ### PUT `/:assetId` Update asset metadata. **File uploads are blocked on this endpoint.** **Content-Type:** `application/json` **URL params** | Param | Required | Description | |---|---|---| | `assetId` | **required** | Asset primary key | **Body** — all fields optional, send only what changes | Field | Type | Description | |---|---|---| | `display_name` | string | New display name | | `description` | string | New description | | `owner_type` | string | New owner type | | `owner_id` | number | New owner entity ID | | `is_public` | boolean | `true` \| `false` | | `access_level` | string | `public` \| `private` \| `restricted` | | `thumbnail_url` | string | Manually replace thumbnail URL (use PATCH `/thumbnail` to upload a file instead) | | `width` | number | Width in px. Re-derives `resolution` automatically. | | `height` | number | Height in px. Re-derives `resolution` automatically. | | `duration` | number | Duration in seconds | | `frame_rate` | number | fps | | `bitrate` | number | bps | | `video_codec` | string | e.g. `H.264` | | `audio_codec` | string | e.g. `AAC` | **Responses** | Status | Description | |---|---| | `200` | `{ data: asset }` | | `400` | Invalid ID or file attached to request | | `404` | Asset not found | | `500` | Internal server error | --- ### DELETE `/:assetId` Soft-delete a single asset. Sets `deletedAt` on the DB record and moves the file to the **archived** album on Chibisafe (best-effort — non-fatal if Chibisafe is unavailable). **Content-Type:** `application/json` **URL params** | Param | Required | Description | |---|---|---| | `assetId` | **required** | Asset primary key | **Body** | Field | Required | Description | |---|---|---| | `deletedBy` | optional | User ID performing the delete | **Responses** | Status | Description | |---|---| | `200` | Asset deleted | | `400` | Invalid asset ID | | `404` | Asset not found | | `500` | Internal server error | --- ### DELETE `/bulk` Soft-delete multiple assets in one call. All matching Chibisafe files are moved to the **archived** album in a single API call. **Content-Type:** `application/json` **Body** | Field | Required | Description | |---|---|---| | `ids` | **required** | Non-empty array of asset IDs: `[1, 2, 3]` | | `deletedBy` | optional | User ID performing the delete | **Responses** | Status | Description | |---|---| | `200` | `N asset(s) deleted` | | `400` | `ids` missing or empty | | `500` | Internal server error | --- ### POST `/:assetId/restore` Restore a soft-deleted asset. Clears `deletedAt` and `deletedBy` on the DB record, then moves the file on Chibisafe from the **archived** album back to its home album based on `owner_type`: | `owner_type` | Moved back to | |---|---| | `video` | videos album | | `avatar` | avatars album | | `document` | documents album | | `image` / anything else | no move (no dedicated album) | The Chibisafe move is best-effort — a failed move will not block or roll back the DB restore. **URL params** | Param | Required | Description | |---|---|---| | `assetId` | **required** | Asset primary key (must be soft-deleted) | **Body:** none required. **Responses** | Status | Description | |---|---| | `200` | `{ data: asset }` — Asset restored | | `404` | Asset not found or not deleted | | `500` | Internal server error | --- ## Response shape All responses use `R.success` / `R.error` from `response.util`: ```json // success { "message": "Asset uploaded.", "data": { ... } } // error { "message": "Asset not found.", "status": 404 } ``` --- ## Related files | File | Purpose | |---|---| | `models/assets/assets.mdl.js` | Sequelize model | | `models/assets/assets.attributes.js` | Exclude sets, paginate config | | `services/chibisafe.service.js` | Chibisafe API wrapper (upload, delete, archive, album) | | `services/ffprobe.service.js` | ffprobe metadata extraction for videos | | `utils/paginate.util.js` | Paginated `findAndCountAll` used by `getAssets` | | `utils/response.util.js` | `R.success` / `R.error` response helpers |