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 단건. categoryjump가 아니면 찾을 수 없음으로 처리 → 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"

권장 워크플로 (스튜디오)

  1. POST /api/v1/uploads로 썸네일·미디어·ZIP/WASM 업로드 → upload.url 확보
  2. POST /api/v1/contents에 URL·카테고리 메타 포함해 게시 (contents.md)
  3. Jump면 play로 샌드박스 경계 확인 후, 필요 시 stream 또는 swf로 재생 파이프라인 연결
  4. 레거시 변환 추적은 GET …/conversion

관련 페이지