ZUKU API — 미디어 · 업로드 · Jump
> 브랜드: ZUKU API
> 구현 기준: backend/rs/src/upload.rs, backend/rs/src/router.rs
> 업로드 API 접두사는 /api/v1. 정적 파일 서빙은 /uploads/…만 사용하며 /api/v1 접두사가 없다.
POST /api/v1/uploads
원드롭 스튜디오용 단일 파일 업로드.
| 항목 | 계약 |
|---|---|
| Content-Type | multipart/form-data (boundary 필수) |
| 파트 필드명 | file |
| 인증 | 필수 — Authorization: Bearer … |
| MIME 판정 | 클라이언트 Content-Type 무시. 매직바이트로만 판정 |
크기 한도
종류 (kind) |
한도 | 매직 예 |
|---|---|---|
image |
20MB | JPEG FF D8 FF, PNG, WEBP(RIFF+WEBP), GIF GIF8 |
video |
500MB | MP4 (….ftyp…), WebM/MKV (EBML 1A 45 DF A3) |
wasm |
50MB | \0asm |
archive |
500MB | ZIP PK\x03\x04 / PK\x05\x06 |
지원 형식 외 → 415 · UNSUPPORTED_MEDIA_TYPE.
한도 초과 → 413 · PAYLOAD_TOO_LARGE.
multipart 아님 / file 파트 없음 → 400 · BAD_REQUEST.
미로그인 → 401 · UNAUTHORIZED.
저장 경로 패턴: data/uploads/{yyyy-mm}/{uuid}.{ext} (원본 파일명은 DB에만 기록, 경로에 쓰지 않음).
성공 응답 (201 Created)
{
"success": true,
"data": {
"upload": {
"url": "/uploads/2026-08/{uuid}.mp4",
"kind": "video",
"mime": "video/mp4",
"size": 1234567,
"sha256": "…hex…",
"original_name": "clip.mp4"
}
},
"meta": { "request_id": "…", "timestamp": "…", "version": "v1" }
}
| 필드 | 설명 | |||
|---|---|---|---|---|
url |
이후 콘텐츠 생성에 넣을 공개 경로 (/uploads/…) |
|||
kind |
image \ |
video \ |
wasm \ |
archive |
mime |
서버가 매핑한 MIME | |||
size |
바이트 수 | |||
sha256 |
파일 SHA-256 hex | |||
original_name |
클라이언트가 보낸 원본 파일명 |
curl (multipart)
curl -sS -X POST "https://zuzunza.com/api/v1/uploads" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F "file=@./demo.mp4"
이미지 예:
curl -sS -X POST "https://zuzunza.com/api/v1/uploads" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F "file=@./thumb.png"
JS/TS (FormData)
async function uploadFile(token: string, file: File) {
const form = new FormData();
form.append('file', file); // 필드명 반드시 file
const res = await fetch('/api/v1/uploads', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
// Content-Type을 직접 넣지 말 것 — boundary는 브라우저가 설정
},
body: form,
});
const envelope = await res.json();
if (!envelope.success) {
throw Object.assign(new Error(envelope.error.message), {
code: envelope.error.code,
status: res.status,
});
}
return envelope.data.upload as {
url: string;
kind: string;
mime: string;
size: number;
sha256: string;
original_name: string;
};
}
// 사용: const up = await uploadFile(token, input.files[0]);
// createContent({ …, media_url: up.url, thumbnail_url: up.url })
GET /uploads/{yyyy-mm}/{file}
업로드된 파일을 원본 MIME으로 정적 서빙한다.
- 경로 접두사:
/uploads/…만 (/api/v1/uploads/…아님) - DB 커넥션 없이 처리 (풀 고갈·DB 장애와 분리)
canonicalize로 업로드 루트 밖 경로 순회 차단- 없거나 차단 →
404·NOT_FOUND(«업로드 파일을 찾을 수 없습니다»)
예: GET /uploads/2026-08/a1b2c3….jpg
브라우저/플레이어에서는 콘텐츠의 media_url / thumbnail_url을 그대로 사용하면 된다.
Jump 게임 API
Jump 카테고리 콘텐츠는 일반 /contents/{id}와 별도로 게임 목록·실행 경계를 둔다.
GET /api/v1/jump/games
Jump 콘텐츠 목록 (category=jump). 쿼리 page / per_page(공통 페이지네이션).
응답 data: { "games": Content[], "pagination": … }
각 항목에 변환 정보가 있으면 conversion이 첨부될 수 있다.
GET /api/v1/jump/games/{id}
Jump 단건. category가 jump가 아니면 찾을 수 없음으로 처리 → 404 · CONTENT_NOT_FOUND.
응답 data: { "content": Content }
POST /api/v1/jump/games/{id}/play — 인증 필수
샌드박스 실행 경계 확인. 본문:
{ "content_id": "{id}" }
경로 {id}와 본문 content_id는 바이트 단위로 동일해야 하며, ASCII 영숫자·_·-만, 길이 ≤ 128.
| 실패 코드 | 조건 |
|---|---|
UNAUTHORIZED |
Bearer 없음/무효 |
INVALID_CONTENT_ID |
안전하지 않은 ID |
CONTENT_ID_MISMATCH |
경로 ≠ 본문 |
CONTENT_NOT_FOUND |
Jump 콘텐츠 없음 |
BAD_REQUEST |
JSON 파싱 실패 |
성공 예 (data):
{
"ok": true,
"sandbox": {
"boundary": "OS-unprivileged; syscall_filter(proprietary)",
"memory_cap_mb": 512,
"pids_max": 512
}
}
> syscall 필터·메모리 카운팅 내부 로직은 비공개다. 문서·클라이언트는 위 경계·한도 계약만 사용한다.
POST /api/v1/jump/games/{id}/stream — 인증 필수
서버 측에서 SWF 등을 해석해 zuku IR(ZIR\0 계열) 바이너리로 스트리밍한다. 클라이언트에 SWF 파서가 없다는 전제.
- 응답은 JSON 봉투가 아니라 바이너리일 수 있음 (
application/octet-stream) - 원본 없음 →
SOURCE_UNAVAILABLE - 해석/인코딩 실패 →
SWF_PARSE_FAILED
POST /api/v1/jump/games/{id}/swf — 인증 필수
키 조작 등 AVM이 필요한 Jump용 원본 SWF 바이트 프록시. 변환·캐시 write-back 없음.
- 인증된 클라이언트만
- 원본 없음 →
SOURCE_UNAVAILABLE - 지원하지 않는 형식 →
SWF_PARSE_FAILED
curl — play
curl -sS -X POST "https://zuzunza.com/api/v1/jump/games/{id}/play" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"content_id\":\"{id}\"}"
JS/TS — stream (바이너리)
async function fetchJumpIr(token: string, gameId: string): Promise<ArrayBuffer> {
const res = await fetch(`/api/v1/jump/games/${gameId}/stream`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
// 오류 시 JSON 봉투일 수 있음
const text = await res.text();
try {
const env = JSON.parse(text);
throw Object.assign(new Error(env.error?.message ?? text), {
code: env.error?.code,
status: res.status,
});
} catch (e) {
if ((e as { code?: string }).code) throw e;
throw new Error(text || `HTTP ${res.status}`);
}
}
return res.arrayBuffer();
}
GET /api/v1/contents/{id}/conversion
콘텐츠의 변환 상태만 조회한다. 필드·의미는 contents.md와 동일.
Jump 목록/상세 응답에 conversion이 인라인으로 붙는 경우와 같은 ConversionInfo 스키마다.
curl -sS "https://zuzunza.com/api/v1/contents/{id}/conversion"
권장 워크플로 (스튜디오)
POST /api/v1/uploads로 썸네일·미디어·ZIP/WASM 업로드 →upload.url확보POST /api/v1/contents에 URL·카테고리 메타 포함해 게시 (contents.md)- Jump면
play로 샌드박스 경계 확인 후, 필요 시stream또는swf로 재생 파이프라인 연결 - 레거시 변환 추적은
GET …/conversion
관련 페이지
- contents.md — 콘텐츠 CRUD · 추천
- errors.md —
UNSUPPORTED_MEDIA_TYPE,PAYLOAD_TOO_LARGE,SOURCE_UNAVAILABLE등 - changelog.md