Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
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:
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
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
{
"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
- Uploads the new thumbnail to Chibisafe (thumbnails album).
- Updates
thumbnail_urlon the asset record. - Deletes the old thumbnail from Chibisafe (best-effort — non-fatal if it fails).
Note: Old thumbnail cleanup requires a
thumbnail_storage_keycolumn 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:
// 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 |