This commit is contained in:
rgrgogu
2026-05-12 00:09:09 +08:00
parent 4e6017c79b
commit d3ff140688
9 changed files with 1350 additions and 509 deletions
+384 -336
View File
@@ -1,380 +1,428 @@
# Assets Controller Documentation
# Assets API
**File:** `controllers/admin/assets.controller.js`
**Base URL:** `/api/admin/assets`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
Base path: `/api/admin/assets`
Controller: `controllers/admin/assets.controller.js`
Storage: Chibisafe (CDN) + PostgreSQL via Sequelize
---
## Table of Contents
- [Get All Assets](#get-all-assets)
- [Get Single Asset](#get-single-asset)
- [Upload Asset](#upload-asset)
- [Update Asset Metadata](#update-asset-metadata)
- [Update Thumbnail](#update-thumbnail)
- [Delete Asset](#delete-asset)
- [Bulk Delete Assets](#bulk-delete-assets)
- [Restore Asset](#restore-asset)
## 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 |
---
## Get All Assets
## Endpoints
**`GET /api/admin/assets`**
---
Returns a paginated list of non-deleted assets with optional filtering.
### GET `/`
### Query Parameters
| Parameter | Type | Required | Description |
|-------------|---------|----------|--------------------------------------------------|
| page | number | No | Page number. Default: `1` |
| limit | number | No | Records per page. Default: `20` |
| file_type | string | No | Filter by type: `image`, `video`, `document`, `other` |
| owner_type | string | No | Filter by owner type e.g. `User`, `Course` |
| owner_id | number | No | Filter by owner ID |
| uploadedBy | number | No | Filter by uploader user ID |
| is_public | boolean | No | Filter by visibility: `true` or `false` |
| resolution | string | No | Filter by resolution e.g. `1080p`, `720p` |
| search | string | No | Search by `display_name`, `original_name`, `description` |
| sort_by | string | No | Column to sort by. Default: `createdAt` |
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
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`**
### Response `200`
```json
{
"status": "success",
"message": "Assets retrieved.",
"data": {
"rows": [...],
"pagination": {
"total": 100,
"page": 1,
"limit": 20,
"totalPages": 5
}
}
"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
```
---
## Get Single Asset
### PATCH `/:assetId/thumbnail`
**`GET /api/admin/assets/:assetId`**
Replace the thumbnail image of an existing asset by uploading a new file.
Returns a single non-deleted asset by ID.
**Content-Type:** `multipart/form-data`
### Path Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|--------------|
| assetId | number | Yes | Asset ID |
**URL params**
### Response `200`
```json
{
"status": "success",
"message": "Asset found.",
"data": {
"asset_id": 1,
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"original_name": "intro.mp4",
"display_name": "Course Intro Video",
"file_url": "/uploads/intro.mp4",
"file_size": 104857600,
"mime_type": "video/mp4",
"extension": "mp4",
"checksum": "a3f5...",
"file_type": "video",
"width": 1920,
"height": 1080,
"duration": 120.5,
"resolution": "1080p",
"frame_rate": 29.97,
"bitrate": 8000000,
"video_codec": "H.264",
"audio_codec": "AAC",
"thumbnail_url": "/uploads/thumbnails/intro.jpg",
"description": "Introduction to the course.",
"storage_provider": "local",
"storage_bucket": null,
"storage_key": "intro.mp4",
"is_public": true,
"access_level": "public",
"owner_type": "Course",
"owner_id": 3,
"uploadedBy": 1,
"deletedBy": null,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z",
"deletedAt": null
}
}
```
| Param | Required | Description |
|---|---|---|
| `assetId` | **required** | Asset primary key |
### Response `404`
```json
{
"status": "error",
"message": "Asset not found."
}
```
**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 |
---
## Upload Asset
### PUT `/:assetId`
**`POST /api/admin/assets/upload`**
Update asset metadata. **File uploads are blocked on this endpoint.**
Uploads a new asset. Expects `multipart/form-data`.
Video metadata (`width`, `height`, `duration`, etc.) should be extracted via **ffprobe** server-side or passed from the client.
`resolution` is **auto-derived** from `width` and `height` — do not pass it manually.
**Content-Type:** `application/json`
### Request `multipart/form-data`
| Field | Type | Required | Description |
|-----------------|---------|----------|----------------------------------------------------------|
| file | File | Yes | The file to upload |
| uploadedBy | number | Yes | User ID of the uploader |
| display_name | string | No | Display name shown on platform. Defaults to filename |
| description | string | No | Description of the asset |
| owner_type | string | No | Owning entity type e.g. `Course`, `User` |
| owner_id | number | No | Owning entity ID |
| is_public | boolean | No | Whether asset is publicly accessible. Default: `false` |
| access_level | string | No | `public`, `private`, `restricted`. Default: `private` |
| storage_provider| string | No | `local`, `s3`, `gcs`, `cloudinary`, `chibisafe`, `other`. Default: `local` |
| storage_bucket | string | No | Bucket/container name for cloud storage |
| storage_key | string | No | Object key/path in bucket |
| file_url | string | No* | Required for non-local storage providers |
| width | number | No | Video/image width in px |
| height | number | No | Video/image height in px |
| duration | number | No | Video duration in seconds |
| frame_rate | number | No | Video frame rate in fps |
| bitrate | number | No | Video bitrate in bps |
| video_codec | string | No | Video codec e.g. `H.264`, `H.265` |
| audio_codec | string | No | Audio codec e.g. `AAC`, `MP3` |
| thumbnail_url | string | No | URL of the video/document preview thumbnail |
**URL params**
### Resolution Auto-Derivation
| Height (px) | Derived Resolution |
|-------------|-------------------|
| ≥ 2160 | `4K` |
| ≥ 1440 | `1440p` |
| ≥ 1080 | `1080p` |
| ≥ 720 | `720p` |
| ≥ 480 | `480p` |
| ≥ 360 | `360p` |
| ≥ 240 | `240p` |
| Other | `{width}x{height}`|
| 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`:
### Response `201`
```json
// success
{
"status": "success",
"message": "Asset uploaded.",
"data": { ...asset }
"data": { ... }
}
```
### Response `400`
```json
// error
{
"status": "error",
"message": "No file uploaded."
"message": "Asset not found.",
"status": 404
}
```
---
## Update Asset Metadata
## Related files
**`PUT /api/admin/assets/:assetId`**
Updates metadata of an existing asset. File replacement is not supported — upload a new asset instead.
`resolution` is **auto-re-derived** if `width` or `height` is updated.
### Path Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| assetId | number | Yes | Asset ID |
### Request Body `application/json`
| Field | Type | Required | Description |
|--------------|---------|----------|------------------------------------------|
| display_name | string | No | Updated display name |
| description | string | No | Updated description |
| owner_type | string | No | Updated owner type |
| owner_id | number | No | Updated owner ID |
| is_public | boolean | No | Updated visibility |
| access_level | string | No | Updated access level |
| thumbnail_url | string | No | Updated thumbnail URL |
| width | number | No | Updated width — re-derives resolution |
| height | number | No | Updated height — re-derives resolution |
| duration | number | No | Updated duration |
| frame_rate | number | No | Updated frame rate |
| bitrate | number | No | Updated bitrate |
| video_codec | string | No | Updated video codec |
| audio_codec | string | No | Updated audio codec |
### Response `200`
```json
{
"status": "success",
"message": "Asset updated.",
"data": { ...asset }
}
```
---
## Update Thumbnail
**`PATCH /api/admin/assets/:assetId/thumbnail`**
Updates only the thumbnail of an asset. Useful for video platforms where users frequently change the video cover independently.
### Path Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| assetId | number | Yes | Asset ID |
### Request Body `application/json`
| Field | Type | Required | Description |
|--------------|--------|----------|-------------------------|
| thumbnail_url | string | Yes | New thumbnail URL |
### Response `200`
```json
{
"status": "success",
"message": "Thumbnail updated.",
"data": { ...asset }
}
```
### Response `400`
```json
{
"status": "error",
"message": "thumbnail_url is required."
}
```
---
## Delete Asset
**`DELETE /api/admin/assets/:assetId`**
Soft deletes a single asset by setting `deletedAt` and `deletedBy`.
### Path Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| assetId | number | Yes | Asset ID |
### Request Body `application/json`
| Field | Type | Required | Description |
|----------|--------|----------|--------------------------------|
| deletedBy | number | No | User ID of who deleted the asset |
### Response `200`
```json
{
"status": "success",
"message": "Asset deleted."
}
```
---
## Bulk Delete Assets
**`DELETE /api/admin/assets/bulk`**
Soft deletes multiple assets at once.
### Request Body `application/json`
| Field | Type | Required | Description |
|----------|----------|----------|----------------------------------|
| ids | number[] | Yes | Array of asset IDs to delete |
| deletedBy | number | No | User ID of who deleted the assets |
### Response `200`
```json
{
"status": "success",
"message": "3 asset(s) deleted."
}
```
### Response `400`
```json
{
"status": "error",
"message": "ids must be a non-empty array."
}
```
---
## Restore Asset
**`PATCH /api/admin/assets/:assetId/restore`**
Restores a soft-deleted asset by clearing `deletedAt` and `deletedBy`.
### Path Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| assetId | number | Yes | Asset ID |
### Response `200`
```json
{
"status": "success",
"message": "Asset restored.",
"data": { ...asset }
}
```
### Response `404`
```json
{
"status": "error",
"message": "Asset not found or not deleted."
}
```
---
## Error Responses
All endpoints return the following on server error:
```json
{
"status": "error",
"message": "Internal server error."
}
```
---
## File Size Limits
| Type | Max Size |
|----------|----------|
| Images | 10 GB |
| Videos | 10 GB |
| Documents| 10 GB |
> Limit is applied at the multer middleware level. Adjust in `assets.routes.js` if needed.
---
## Notes
- **File replacement** is not supported. To replace a file, delete the old asset and upload a new one.
- **Checksum** (SHA-256) is computed on upload for duplicate detection.
- **Polymorphic ownership** via `owner_type` + `owner_id` allows any entity (`Course`, `User`, `Post`, etc.) to own assets without a direct foreign key.
- **Resolution** is always auto-derived from `width` and `height` — never set manually.
- **Soft delete** sets `deletedAt` timestamp. Assets are excluded from all queries unless explicitly queried with `paranoid: false`.
| 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 |