Uppladdningar
Bild- och videodialogerna tar emot en fil från datorn lika väl som en länk. Redigeraren har ingen egen lagring, så vart en vald fil tar vägen beror på propen upload:
utan upload | med upload | |
|---|---|---|
| Bild (och en videoaffisch) | bäddas in i dokumentet som en base64-data-URL | skickas till din endpoint; dokumentet behåller sin URL |
| Videofil | ingen uppladdning: en video infogas via sin länk (YouTube, Vimeo, en fil-URL) | skickas till din endpoint; dokumentet behåller sin URL |
| Bild i länkkort | kortet länkar sidans egen bild | lagras också med din endpoint (en sida kan ändra eller ta bort sin bild) |
När upload är angivet tas base64-bilder bort när ett dokument läses in, så att innehållet bara har länkar.
<Tiptapify
:upload="{ endpoint: '/api/uploads', headers: { Authorization: `Bearer ${token}` } }"
/>Alternativet Ladda upp i videodialogen visas så snart upload har en endpoint; bilddialogen har det alltid.
Endpointens kontrakt
POST {endpoint} med en multipart/form-data-kropp med två fält, skickad med Accept: application/json och dina headers:
| fält | värde |
|---|---|
file | den valda filen |
kind | image eller video |
Dialogens förloppsindikator följer uppladdningen av förfrågan, och Avbryt avbryter den.
En lagrad fil besvaras med en 2xx-status och dess URL, som hamnar i dokumentet som den är (en absolut URL eller en sökväg på din webbplats):
HTTP/1.1 201 Created
Content-Type: application/json
{ "url": "https://example.com/uploads/3ebff0ac73c2db524d009ad01d2c6e00.png" }En nekad fil besvaras med en 4xx/5xx-status och { "error": "…" } (413 för en för stor fil, 422 för en typ som inte är tillåten). Dialogen visar det meddelandet och förblir öppen; utan något meddelande (eller när endpointen inte svarar) säger den på sitt eget språk att filen inte kunde laddas upp.
Exempel på endpoints
Exemplen nedan beter sig likadant, så välj det som passar din stack:
- En fil accepteras utifrån vad den innehåller, inte utifrån dess namn eller den typ webbläsaren skickar: en HTML-sida med namnet
photo.pngnekas. Bilder: JPEG, PNG, GIF, WebP, AVIF (inte SVG, eftersom den kan innehålla skript). Videor: MP4, WebM, Ogg, QuickTime. - Bilder upp till 10 MB, videor upp till 200 MB; en större fil besvaras med
413. - Filen lagras under ett slumpmässigt namn med filändelsen för sin typ, aldrig under det namn den kom med.
- Svaret är
201 { "url": … }; ett saknat fält eller en nekad typ besvaras med422. - Exemplen för Node.js och Python serverar också de lagrade filerna (
GET /uploads/<name>, medX-Content-Type-Options: nosniff); PHP och Laravel överlåter det åt webbservern.
<?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 kräver 8.2+ med tillägget fileinfo, Node.js 20+, Python 3.9+; inget av dem behöver något paket utöver ramverket.
I produktion
- Skydda endpointen. Den lagrar varje tillåten fil som skickas till den: placera den bakom din autentisering (redigeraren skickar
headersfrånuploadmed varje förfrågan) och en hastighetsbegränsning, som Laravel-routen ovan. - Serverns gränser. Webbservern och PHP måste också ta emot den största videon:
client_max_body_size(nginx),upload_max_filesizeochpost_max_size(PHP). Python-exemplet läser hela förfrågan till minnet; strömma den till disk för stora videor. - Lagring. Lägg filerna i objektlagring eller bakom ett CDN snarare än i en mapp i appen, och servera dem med
X-Content-Type-Options: nosniff. - Oanvända filer. En fil lagras så fort den väljs, även om dialogen avbryts eller bilden tas bort senare: rensa bort de filer som inget dokument refererar till.
- Samma origin eller CORS. En endpoint på redigerarens egen origin behöver inget mer; på en annan origin, tillåt redigerarens origin och de headers du skickar.