업로드
이미지 및 동영상 대화상자에서는 링크뿐 아니라 컴퓨터의 파일도 선택할 수 있습니다. 에디터에는 자체 저장소가 없으므로, 선택한 파일이 어디로 가는지는 upload prop에 따라 달라집니다.
upload 없음 | upload 있음 | |
|---|---|---|
| 이미지 (및 동영상 포스터) | base64 데이터 URL로 문서에 포함됨 | 엔드포인트로 전송됨. 문서에는 URL이 남음 |
| 동영상 파일 | 업로드 없음. 동영상은 링크(YouTube, Vimeo, 파일 URL)로 삽입됨 | 엔드포인트로 전송됨. 문서에는 URL이 남음 |
| 링크 카드 이미지 | 카드가 해당 페이지의 이미지를 그대로 링크함 | 이것도 엔드포인트에 저장됨(페이지가 이미지를 바꾸거나 삭제할 수 있으므로) |
upload를 설정하면 문서를 불러올 때 base64 이미지가 제거되어 콘텐츠에 링크만 남습니다.
<Tiptapify
:upload="{ endpoint: '/api/uploads', headers: { Authorization: `Bearer ${token}` } }"
/>동영상 대화상자의 업로드 옵션은 upload에 endpoint가 있으면 바로 표시되며, 이미지 대화상자에는 항상 표시됩니다.
엔드포인트 규약
POST {endpoint}로 두 개의 필드가 담긴 multipart/form-data 본문을 보내며, Accept: application/json 헤더와 사용자의 headers가 함께 전송됩니다.
| 필드 | 값 |
|---|---|
file | 선택한 파일 |
kind | image 또는 video |
대화상자의 진행률 표시줄은 요청의 업로드 진행 상황을 따르며, 취소를 누르면 업로드가 중단됩니다.
파일이 저장되면 2xx 상태와 함께 URL을 응답하며, 이 URL은 문서에 그대로 들어갑니다(절대 URL 또는 사이트 내 경로).
HTTP/1.1 201 Created
Content-Type: application/json
{ "url": "https://example.com/uploads/3ebff0ac73c2db524d009ad01d2c6e00.png" }거부된 파일에는 4xx/5xx 상태와 { "error": "…" }로 응답합니다(파일이 너무 크면 413, 허용되지 않는 형식이면 422). 대화상자는 해당 메시지를 표시하고 열린 상태로 유지됩니다. 메시지가 없거나(또는 엔드포인트가 응답하지 않으면) 대화상자가 자체 언어로 파일을 업로드할 수 없다고 안내합니다.
엔드포인트 예제
아래 예제는 모두 동일하게 동작하므로 사용 중인 스택에 맞는 것을 고르세요.
- 파일은 이름이나 브라우저가 보낸 형식이 아니라 실제 내용으로 허용 여부를 판단합니다.
photo.png라는 이름의 HTML 페이지는 거부됩니다. 이미지: JPEG, PNG, GIF, WebP, AVIF(스크립트를 포함할 수 있으므로 SVG는 제외). 동영상: MP4, WebM, Ogg, QuickTime. - 이미지는 최대 10 MB, 동영상은 최대 200 MB이며, 그보다 큰 파일에는
413으로 응답합니다. - 파일은 원래 이름이 아니라 해당 형식의 확장자가 붙은 무작위 이름으로 저장됩니다.
- 응답은
201 { "url": … }이며, 필드가 없거나 허용되지 않는 형식이면422로 응답합니다. - Node.js와 Python 예제는 저장된 파일도 함께 제공합니다(
GET /uploads/<name>,X-Content-Type-Options: nosniff포함). PHP와 Laravel은 이를 웹 서버에 맡깁니다.
<?php
// upload.php: POST /upload.php (multipart `file` and `kind`) stores the file in ./uploads and answers its URL.
// PHP 8.2+ with the fileinfo extension.
declare(strict_types=1);
// The types a file may have, by what it contains (not by its name), and the extension it is stored with
const TYPES = [
'image' => ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/avif' => 'avif'],
'video' => ['video/mp4' => 'mp4', 'video/webm' => 'webm', 'video/ogg' => 'ogv', 'video/quicktime' => 'mov'],
];
const MAX_BYTES = ['image' => 10_000_000, 'video' => 200_000_000];
const UPLOADS = __DIR__.'/uploads';
function reply(int $status, array $body): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
reply(405, ['error' => 'POST a file.']);
}
// A body over post_max_size reaches PHP empty
if ($_POST === [] && $_FILES === [] && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
reply(413, ['error' => 'The file is too large.']);
}
$kind = (string) ($_POST['kind'] ?? '');
$file = $_FILES['file'] ?? null;
if (!isset(TYPES[$kind]) || !is_array($file) || is_array($file['error'])) {
reply(422, ['error' => 'Send a file and its kind: image or video.']);
}
if (in_array($file['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || $file['size'] > MAX_BYTES[$kind]) {
reply(413, ['error' => 'The file is too large.']);
}
if ($file['error'] !== UPLOAD_ERR_OK) {
reply(422, ['error' => 'The file did not arrive.']);
}
$type = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extension = TYPES[$kind][$type] ?? null;
if ($extension === null) {
reply(422, ['error' => "Not an allowed {$kind} file."]);
}
// A random name: never the name the file came with
$name = bin2hex(random_bytes(16)).'.'.$extension;
if (!is_dir(UPLOADS) && !mkdir(UPLOADS, 0755, true) || !move_uploaded_file($file['tmp_name'], UPLOADS.'/'.$name)) {
reply(500, ['error' => 'The file could not be stored.']);
}
$scheme = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http';
$directory = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])), '/');
reply(201, ['url' => "{$scheme}://{$_SERVER['HTTP_HOST']}{$directory}/uploads/{$name}"]);<?php
// routes/api.php:
// Route::post('/uploads', UploadController::class)->middleware(['auth:sanctum', 'throttle:30,1']);
// The files go to the `public` disk: run `php artisan storage:link` once
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
final class UploadController
{
// The types a file may have, by what it contains (not by its name)
private const TYPES = [
'image' => 'mimetypes:image/jpeg,image/png,image/gif,image/webp,image/avif',
'video' => 'mimetypes:video/mp4,video/webm,video/ogg,video/quicktime',
];
private const MAX_KILOBYTES = ['image' => 10_000, 'video' => 200_000];
public function __invoke(Request $request): JsonResponse
{
$kind = (string) $request->input('kind');
if (! isset(self::TYPES[$kind])) {
return response()->json(['error' => 'Send a file and its kind: image or video.'], 422);
}
$validator = Validator::make($request->all(), [
'file' => ['required', 'file', self::TYPES[$kind], 'max:'.self::MAX_KILOBYTES[$kind]],
]);
if ($validator->fails()) {
$tooLarge = isset($validator->failed()['file']['Max']);
return response()->json(['error' => $validator->errors()->first('file')], $tooLarge ? 413 : 422);
}
// A random name with the extension of the file's type: never the name it came with
$path = $request->file('file')->store('uploads', 'public');
return response()->json(['url' => Storage::disk('public')->url($path)], 201);
}
}// upload.mjs: `node upload.mjs`, then POST http://localhost:3003/uploads (multipart `file` and `kind`).
// It stores the file in ./uploads and serves it at /uploads/<name>. Node 20+, no packages.
import { randomBytes } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import http from 'node:http'
import path from 'node:path'
import { Readable } from 'node:stream'
// The types a file may have and the extension it is stored with
const TYPES = {
image: { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif' },
video: { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/ogg': 'ogv', 'video/quicktime': 'mov' },
}
const MAX_BYTES = { image: 10_000_000, video: 200_000_000 }
const UPLOADS = path.resolve('uploads')
const CONTENT_TYPES = Object.fromEntries(Object.values(TYPES).flatMap(types => Object.entries(types).map(([type, extension]) => [extension, type])))
// The type of a file by its first bytes: the type a browser sends is only what the file name suggests
function sniff(bytes) {
const ascii = (start, end) => String.fromCharCode(...bytes.subarray(start, end))
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
if (ascii(0, 8) === '\x89PNG\r\n\x1A\n') return 'image/png'
if (ascii(0, 4) === 'GIF8') return 'image/gif'
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'image/webp'
if (ascii(0, 4) === '\x1A\x45\xDF\xA3') return 'video/webm'
if (ascii(0, 4) === 'OggS') return 'video/ogg'
if (ascii(4, 8) === 'ftyp') {
const brand = ascii(8, 12)
if (brand === 'avif' || brand === 'avis') return 'image/avif'
return brand === 'qt ' ? 'video/quicktime' : 'video/mp4'
}
return null
}
function send(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function upload(req, res) {
const length = Number(req.headers['content-length'] ?? 0)
if (length > MAX_BYTES.video + 1_000_000) {
return send(res, 413, { error: 'The file is too large.' })
}
let form
try {
const request = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': req.headers['content-type'] ?? '' },
body: Readable.toWeb(req),
duplex: 'half',
})
form = await request.formData()
} catch {
return send(res, 422, { error: 'Send a file and its kind: image or video.' })
}
const kind = form.get('kind')
const file = form.get('file')
if (!Object.hasOwn(TYPES, kind) || !(file instanceof File)) {
return send(res, 422, { error: 'Send a file and its kind: image or video.' })
}
if (file.size > MAX_BYTES[kind]) {
return send(res, 413, { error: 'The file is too large.' })
}
const bytes = new Uint8Array(await file.arrayBuffer())
const extension = TYPES[kind][sniff(bytes)]
if (!extension) {
return send(res, 422, { error: `Not an allowed ${kind} file.` })
}
// A random name: never the name the file came with
const name = `${randomBytes(16).toString('hex')}.${extension}`
await mkdir(UPLOADS, { recursive: true })
await writeFile(path.join(UPLOADS, name), bytes)
send(res, 201, { url: `http://${req.headers.host}/uploads/${name}` })
}
// The stored files, by the names this endpoint gives only
function serve(res, name) {
const extension = /^[a-f\d]{32}\.([a-z\d]+)$/.exec(name)?.[1]
if (!CONTENT_TYPES[extension]) {
return send(res, 404, { error: 'Not found.' })
}
const stream = createReadStream(path.join(UPLOADS, name))
stream.on('open', () => {
res.writeHead(200, { 'Content-Type': CONTENT_TYPES[extension], 'X-Content-Type-Options': 'nosniff' })
stream.pipe(res)
})
stream.on('error', () => send(res, 404, { error: 'Not found.' }))
}
http.createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost')
if (pathname === '/uploads' && req.method === 'POST') {
return upload(req, res).catch(() => send(res, 500, { error: 'The file could not be stored.' }))
}
if (pathname === '/uploads') {
return send(res, 405, { error: 'POST a file.' })
}
if (pathname.startsWith('/uploads/') && req.method === 'GET') {
return serve(res, pathname.slice('/uploads/'.length))
}
send(res, 404, { error: 'Not found.' })
}).listen(3003)# upload.py: `python3 upload.py`, then POST http://localhost:3004/uploads (multipart `file` and `kind`).
# It stores the file in ./uploads and serves it at /uploads/<name>. Python 3.9+, no packages.
import json
import re
import secrets
from email.parser import BytesParser
from email.policy import default
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
# The types a file may have and the extension it is stored with
TYPES = {
'image': {'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif'},
'video': {'video/mp4': 'mp4', 'video/webm': 'webm', 'video/ogg': 'ogv', 'video/quicktime': 'mov'},
}
MAX_BYTES = {'image': 10_000_000, 'video': 200_000_000}
UPLOADS = Path('uploads').resolve()
CONTENT_TYPES = {extension: type_ for types in TYPES.values() for type_, extension in types.items()}
def sniff(data):
"""The type of a file by its first bytes: the type a browser sends is only what the file name suggests."""
if data[:3] == b'\xff\xd8\xff':
return 'image/jpeg'
if data[:8] == b'\x89PNG\r\n\x1a\n':
return 'image/png'
if data[:4] == b'GIF8':
return 'image/gif'
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return 'image/webp'
if data[:4] == b'\x1a\x45\xdf\xa3':
return 'video/webm'
if data[:4] == b'OggS':
return 'video/ogg'
if data[4:8] == b'ftyp':
brand = data[8:12]
if brand in (b'avif', b'avis'):
return 'image/avif'
return 'video/quicktime' if brand == b'qt ' else 'video/mp4'
return None
def multipart_fields(content_type, body):
"""The fields of a multipart/form-data body: name -> (file name, bytes)."""
message = BytesParser(policy=default).parsebytes(b'Content-Type: ' + content_type.encode('latin-1') + b'\r\n\r\n' + body)
if not message.is_multipart():
return {}
return {
part.get_param('name', header='content-disposition'): (part.get_filename(), part.get_payload(decode=True) or b'')
for part in message.iter_parts()
}
class Upload(BaseHTTPRequestHandler):
def send(self, status, body):
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_POST(self):
if urlsplit(self.path).path != '/uploads':
return self.send(404, {'error': 'Not found.'})
length = int(self.headers.get('Content-Length') or 0)
if length > MAX_BYTES['video'] + 1_000_000:
return self.send(413, {'error': 'The file is too large.'})
fields = multipart_fields(self.headers.get('Content-Type', ''), self.rfile.read(length))
kind = fields.get('kind', (None, b''))[1].decode(errors='replace')
filename, data = fields.get('file', (None, b''))
if kind not in TYPES or filename is None:
return self.send(422, {'error': 'Send a file and its kind: image or video.'})
if len(data) > MAX_BYTES[kind]:
return self.send(413, {'error': 'The file is too large.'})
extension = TYPES[kind].get(sniff(data))
if extension is None:
return self.send(422, {'error': f'Not an allowed {kind} file.'})
# A random name: never the name the file came with
name = f'{secrets.token_hex(16)}.{extension}'
UPLOADS.mkdir(exist_ok=True)
(UPLOADS / name).write_bytes(data)
self.send(201, {'url': f'http://{self.headers["Host"]}/uploads/{name}'})
def do_GET(self):
path = urlsplit(self.path).path
if path == '/uploads':
return self.send(405, {'error': 'POST a file.'})
# The stored files, by the names this endpoint gives only
match = re.fullmatch(r'/uploads/([a-f\d]{32})\.([a-z\d]+)', path)
file = UPLOADS / f'{match[1]}.{match[2]}' if match and match[2] in CONTENT_TYPES else None
if file is None or not file.is_file():
return self.send(404, {'error': 'Not found.'})
data = file.read_bytes()
self.send_response(200)
self.send_header('Content-Type', CONTENT_TYPES[match[2]])
self.send_header('Content-Length', str(len(data)))
self.send_header('X-Content-Type-Options', 'nosniff')
self.end_headers()
self.wfile.write(data)
ThreadingHTTPServer(('', 3004), Upload).serve_forever()PHP는 fileinfo 확장이 설치된 8.2 이상, Node.js는 20 이상, Python은 3.9 이상이 필요하며, 프레임워크 외에 추가로 필요한 패키지는 없습니다.
프로덕션 환경에서
- 엔드포인트를 보호하세요. 허용된 파일이라면 무엇이든 저장하므로, 인증(에디터는 모든 요청에
upload의headers를 보냅니다)과 요청 속도 제한 뒤에 두세요. 위의 Laravel 라우트처럼 설정하면 됩니다. - 서버 제한. 웹 서버와 PHP도 가장 큰 동영상을 받을 수 있어야 합니다:
client_max_body_size(nginx),upload_max_filesize,post_max_size(PHP). Python 예제는 요청 전체를 메모리로 읽으므로, 큰 동영상은 디스크로 스트리밍하세요. - 저장소. 파일은 앱의 폴더가 아니라 오브젝트 스토리지나 CDN 뒤에 보관하고,
X-Content-Type-Options: nosniff와 함께 제공하세요. - 사용하지 않는 파일. 파일은 선택하는 즉시 저장되므로, 대화상자를 취소하거나 나중에 이미지를 삭제해도 남습니다. 어떤 문서도 참조하지 않는 파일은 정리하세요.
- 동일 출처 또는 CORS. 에디터와 같은 출처의 엔드포인트라면 추가 설정이 필요 없으며, 다른 출처라면 에디터의 출처와 전송하는 헤더를 허용하세요.