diff options
| author | Kyle Javier [kj_sh604] <43.splash@gmail.com> | 2026-03-16 14:24:34 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-16 14:24:34 -0400 |
| commit | 64d8fc9ce9922f38780cc677478cbfb47b21a87e (patch) | |
| tree | 5b244c18193da100a88904536bf1496392339f65 | |
| parent | 17a970b067fcdf6758668c23184fb2112370bb94 (diff) | |
merge: python rewrite (#1)
* refactor: Dockerfile
* refactor: docker-compose.yml
* refactor: docker-entrypoint.sh
* refactor: src/font.php
* refactor: src/fonts.php
* refactor: src/index.php
* refactor: src/upload.php
* refactor: src/app.py
* refactor: src/index.html
* refactor: src/requirements.txt
* refactor: 24.04 vps compatibility and README re-write
* refactor: python .gitignore update
* refactor: README.md
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | Dockerfile | 39 | ||||
| -rw-r--r-- | README.md | 46 | ||||
| -rw-r--r-- | docker-compose.yml | 2 | ||||
| -rw-r--r-- | docker-entrypoint.sh | 6 | ||||
| -rw-r--r-- | src/app.py | 188 | ||||
| -rw-r--r-- | src/font.php | 50 | ||||
| -rw-r--r-- | src/fonts.php | 77 | ||||
| -rw-r--r-- | src/index.html (renamed from src/index.php) | 7 | ||||
| -rw-r--r-- | src/requirements.txt | 3 | ||||
| -rw-r--r-- | src/upload.php | 66 |
11 files changed, 248 insertions, 240 deletions
@@ -1,3 +1,5 @@ src/uploads/* !src/uploads/.htaccess -!src/uploads/nyan_819cac51.png
\ No newline at end of file +!src/uploads/nyan_819cac51.png +__pycache__/ +*.pyc
\ No newline at end of file @@ -1,13 +1,15 @@ -FROM php:8.3-apache +FROM python:3.12-slim # enable contrib (fonts-ibm-plex) and non-free (fonts-ubuntu) components RUN sed -i 's/^Components: main$/Components: main contrib non-free/' /etc/apt/sources.list.d/debian.sources -# install fonts, fontconfig (for fc-list), and tini (proper PID 1 / signal relay) +# install fonts, fontconfig (for fc-list), libmagic1 (for python-magic), and tini RUN apt-get update && apt-get install -y --no-install-recommends \ tini \ fontconfig \ - fonts-dejavu fonts-dejavu-core fonts-dejavu-extra fonts-dejavu-mono fonts-liberation fonts-liberation2 fonts-opensymbol fonts-urw-base35 fonts-noto-color-emoji fonts-noto-core fonts-noto-ui-core fonts-noto-extra fonts-noto-mono fonts-noto-cjk fonts-noto-cjk-extra fonts-roboto fonts-roboto-slab fonts-lato fonts-open-sans fonts-quicksand fonts-comfortaa fonts-cantarell fonts-beteckna fonts-ubuntu fonts-linuxlibertine fonts-ebgaramond fonts-ebgaramond-extra fonts-junicode fonts-stix fonts-texgyre fonts-sil-gentium fonts-sil-gentium-basic fonts-hack fonts-firacode fonts-cascadia-code fonts-inconsolata fonts-fantasque-sans fonts-terminus fonts-droid-fallback fonts-symbola fonts-ancient-scripts fonts-mathjax fonts-croscore fonts-nanum fonts-nanum-extra fonts-wqy-microhei fonts-wqy-zenhei fonts-arphic-ukai fonts-arphic-uming fonts-ipafont-gothic fonts-ipafont-mincho fonts-indic fonts-lohit-deva fonts-lohit-beng-assamese fonts-lohit-beng-bengali fonts-lohit-gujr fonts-lohit-guru fonts-lohit-knda fonts-lohit-mlym fonts-lohit-orya fonts-lohit-taml fonts-lohit-taml-classical fonts-lohit-telu fonts-smc fonts-arabeyes fonts-hosny-amiri fonts-sil-abyssinica fonts-beng fonts-thai-tlwg fonts-gfs-artemisia fonts-gfs-baskerville fonts-gfs-bodoni-classic fonts-gfs-didot fonts-gfs-gazis fonts-gfs-neohellenic fonts-gfs-olga fonts-gfs-porson fonts-gfs-solomos fonts-gfs-theokritos fonts-crosextra-carlito fonts-crosextra-caladea fonts-cabin fonts-vollkorn fonts-yanone-kaffeesatz fonts-ibm-plex fonts-freefont-ttf fonts-mplus fonts-monofur fonts-courier-prime fonts-anonymous-pro fonts-hermit + libmagic1 \ + fonts-dejavu fonts-dejavu-core fonts-dejavu-extra fonts-dejavu-mono fonts-liberation fonts-liberation2 fonts-opensymbol fonts-urw-base35 fonts-noto-color-emoji fonts-noto-core fonts-noto-ui-core fonts-noto-extra fonts-noto-mono fonts-noto-cjk fonts-noto-cjk-extra fonts-roboto fonts-roboto-slab fonts-lato fonts-open-sans fonts-quicksand fonts-comfortaa fonts-cantarell fonts-beteckna fonts-ubuntu fonts-linuxlibertine fonts-ebgaramond fonts-ebgaramond-extra fonts-junicode fonts-stix fonts-texgyre fonts-sil-gentium fonts-sil-gentium-basic fonts-hack fonts-firacode fonts-cascadia-code fonts-inconsolata fonts-fantasque-sans fonts-terminus fonts-droid-fallback fonts-symbola fonts-ancient-scripts fonts-mathjax fonts-croscore fonts-nanum fonts-nanum-extra fonts-wqy-microhei fonts-wqy-zenhei fonts-arphic-ukai fonts-arphic-uming fonts-ipafont-gothic fonts-ipafont-mincho fonts-indic fonts-lohit-deva fonts-lohit-beng-assamese fonts-lohit-beng-bengali fonts-lohit-gujr fonts-lohit-guru fonts-lohit-knda fonts-lohit-mlym fonts-lohit-orya fonts-lohit-taml fonts-lohit-taml-classical fonts-lohit-telu fonts-smc fonts-arabeyes fonts-hosny-amiri fonts-sil-abyssinica fonts-beng fonts-thai-tlwg fonts-gfs-artemisia fonts-gfs-baskerville fonts-gfs-bodoni-classic fonts-gfs-didot fonts-gfs-gazis fonts-gfs-neohellenic fonts-gfs-olga fonts-gfs-porson fonts-gfs-solomos fonts-gfs-theokritos fonts-crosextra-carlito fonts-crosextra-caladea fonts-cabin fonts-vollkorn fonts-yanone-kaffeesatz fonts-ibm-plex fonts-freefont-ttf fonts-mplus fonts-monofur fonts-courier-prime fonts-anonymous-pro fonts-hermit \ + && rm -rf /var/lib/apt/lists/* # install Roboto Mono manually (not packaged in Debian, kj_sh604's fave font) RUN apt-get update && apt-get install -y --no-install-recommends curl \ @@ -17,40 +19,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl \ && fc-cache -fv \ && rm -rf /var/lib/apt/lists/* -# configure apache: set document root to /var/www/html/src -ENV APACHE_DOCUMENT_ROOT=/var/www/html/src -RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ - /etc/apache2/sites-available/*.conf \ - /etc/apache2/apache2.conf \ - /etc/apache2/conf-available/*.conf - -# switch apache from port 80 to port 3000 -RUN sed -i 's/Listen 80/Listen 3000/' /etc/apache2/ports.conf \ - && sed -i 's/<VirtualHost \*:80>/<VirtualHost *:3000>/' \ - /etc/apache2/sites-available/*.conf - -# enable mod_rewrite -RUN a2enmod rewrite +WORKDIR /app -# php upload limits -RUN echo "upload_max_filesize = 50M" > /usr/local/etc/php/conf.d/sent-web.ini \ - && echo "post_max_size = 50M" >> /usr/local/etc/php/conf.d/sent-web.ini +# install python dependencies +COPY src/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt # copy entrypoint script COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh # copy application -COPY src/ /var/www/html/src/ +COPY src/ /app/ # stash a seed copy of uploads so the entrypoint can populate a fresh volume RUN mkdir -p /opt/uploads-seed \ - && cp -r /var/www/html/src/uploads/. /opt/uploads-seed/ \ - && chown -R www-data:www-data /var/www/html/src/uploads /opt/uploads-seed + && cp -r /app/uploads/. /opt/uploads-seed/ \ + && chown -R www-data:www-data /app/uploads /opt/uploads-seed EXPOSE 3000 -# tini as PID 1 ensures SIGTERM is properly forwarded to apache, +# tini as PID 1 ensures SIGTERM is properly forwarded to gunicorn, # preventing the 'permission denied' error on docker stop ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] -CMD ["apache2-foreground"]
\ No newline at end of file +CMD ["gunicorn", "--bind", "0.0.0.0:3000", "--workers", "2", "--user", "www-data", "--group", "www-data", "app:app"]
\ No newline at end of file @@ -3,7 +3,7 @@ > suckless's sent tool ported to the very sucky web world A web-based reimplementation of [suckless sent](https://tools.suckless.org/sent/) -using pure PHP and vanilla JavaScript. +using Python and vanilla JavaScript. <img width="1280" height="800" alt="sent0" src="https://github.com/user-attachments/assets/0c503bd4-3609-4e36-ae23-77b7f0711736" /> <br><br> @@ -11,15 +11,14 @@ using pure PHP and vanilla JavaScript. ## features -- **sent-compatible format** — paragraphs = slides, `#` comments, `@image` +- **sent-compatible format** - paragraphs = slides, `#` comments, `@image` slides, `\` escapes -- **keyboard navigation** — arrow keys, hjkl, space, enter, backspace, pgup/pgdn +- **keyboard navigation** - arrow keys, hjkl, space, enter, backspace, pgup/pgdn (same as sent) -- **mouse navigation** — left-click right half = next, left half = prev, scroll +- **mouse navigation** - left-click right half = next, left half = prev, scroll wheel -- **image upload** — upload images and insert `@filename` references -- **export** — download as `.sent` file for local sent, or export `.pdf` for portability - +- **image upload** - upload images and insert `@filename` references (50 MB cap) +- **export** - download as `.sent` file for local sent, or export `.pdf` for portability ## usage ### docker compose (recommended) @@ -37,6 +36,26 @@ docker build -t sent-web . docker run -d -p 3000:3000 --init --name sent-web sent-web ``` +### local python run (without docker) + +Requirements: + +- Python `3.12+` +- `fontconfig` (`fc-list` must be available) +- `libmagic` runtime (`libmagic1` on Ubuntu) + +Setup: + +```sh +cd src +python3.12 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +gunicorn --bind 0.0.0.0:3000 --workers 2 app:app +``` + +Then open [http://localhost:3000](http://localhost:3000). + ### presentation shortcuts | key | action | @@ -65,12 +84,13 @@ with multiple lines ## technology -- **PHP 8.3** — no framework, just `.php` files -- **vanilla JavaScript** — no npm, no webpack, no react -- **[noir.css](https://github.com/kj-sh604/noir.css)** — classless CSS -- **Apache** — serves it all -- **fontconfig** — `fc-list` for font enumeration -- **Docker** — containerized with fonts pre-installed +- **Python 3.12+** - Flask backend +- **vanilla JavaScript** - no npm, no webpack, no react +- **[noir.css](https://github.com/kj-sh604/noir.css)** - classless CSS +- **Gunicorn** - production WSGI server +- **fontconfig** - `fc-list` for font enumeration +- **python-magic + libmagic** - content-based upload type checks +- **Docker** - containerized with fonts pre-installed ## license diff --git a/docker-compose.yml b/docker-compose.yml index f4a5c54..d5bcae1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "3000:3000" volumes: - - uploads:/var/www/html/src/uploads + - uploads:/app/uploads restart: unless-stopped init: true stop_grace_period: 10s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8ffba10..6cbaccb 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,11 +1,11 @@ #!/bin/sh set -e -if [ -z "$(ls -A /var/www/html/src/uploads 2>/dev/null)" ] && \ +if [ -z "$(ls -A /app/uploads 2>/dev/null)" ] && \ [ -d /opt/uploads-seed ]; then - cp -r /opt/uploads-seed/. /var/www/html/src/uploads/ + cp -r /opt/uploads-seed/. /app/uploads/ fi -chown -R www-data:www-data /var/www/html/src/uploads +chown -R www-data:www-data /app/uploads exec "$@"
\ No newline at end of file diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..7d75f2b --- /dev/null +++ b/src/app.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 + +import base64 +import os +import re +import secrets +import subprocess +from pathlib import Path + +import magic +from flask import Flask, Response, jsonify, request, send_file, send_from_directory + +app = Flask(__name__, static_folder=None) +app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB upload cap + +UPLOAD_DIR = Path(__file__).parent / "uploads" +UPLOAD_DIR.mkdir(mode=0o755, exist_ok=True) + +ALLOWED_MIME = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + "image/bmp": "bmp", +} + +# build list of allowed font directories +_FONT_DIRS: list[Path] = [ + Path("/usr/share/fonts"), + Path("/usr/local/share/fonts"), +] +for _home in Path("/home").glob("*"): + _FONT_DIRS.append(_home / ".local" / "share" / "fonts") + _FONT_DIRS.append(_home / ".fonts") + + +def _allowed_font_dirs() -> list[str]: + """return resolved, existent font dirs with trailing separator.""" + out = [] + for d in _FONT_DIRS: + try: + out.append(str(d.resolve(strict=True)) + os.sep) + except (OSError, RuntimeError): + pass + return out + + +@app.route("/") +def index(): + return send_from_directory(app.root_path, "index.html") + + +@app.route("/uploads/<filename>") +def uploads(filename: str): + return send_from_directory(UPLOAD_DIR, filename) + + +@app.route("/upload", methods=["POST"]) +def upload(): + if "image" not in request.files: + return jsonify({"error": "no file provided"}), 400 + + f = request.files["image"] + if not f.filename: + return jsonify({"error": "empty filename"}), 400 + + data = f.read() + if not data: + return jsonify({"error": "empty file"}), 400 + + mime = magic.from_buffer(data, mime=True) + if mime not in ALLOWED_MIME: + return jsonify({"error": f"invalid file type: {mime}"}), 400 + + ext = ALLOWED_MIME[mime] + basename = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(f.filename).stem)[:64] + filename = f"{basename}_{secrets.token_hex(4)}.{ext}" + + (UPLOAD_DIR / filename).write_bytes(data) + + return jsonify({"filename": filename, "url": f"uploads/{filename}"}) + + +@app.route("/fonts") +def fonts(): + try: + result = subprocess.run( + ["fc-list", "--format=%{family}|%{style}|%{file}\n"], + capture_output=True, + text=True, + shell=False, + timeout=10, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return jsonify([]) + + if result.returncode != 0: + return jsonify([]) + + if not result.stdout.strip(): + return jsonify([]) + + def style_score(style: str) -> int: + s = style.strip().lower() + if s in ("regular", "roman", "book", "text"): + return 0 + if s == "bold": + return 1 + if "italic" in s or "oblique" in s: + return 2 + return 3 + + best: dict[str, dict] = {} + for line in result.stdout.splitlines(): + parts = line.split("|", 2) + if len(parts) < 3: + continue + family = parts[0].split(",")[0].strip() + if not family: + continue + style = parts[1].split(",")[0].strip() + file_path = parts[2].strip() + if not Path(file_path).exists(): + continue + score = style_score(style) + if family not in best or score < best[family]["score"]: + best[family] = {"file": file_path, "score": score} + + fmt_map = {"ttf": "truetype", "otf": "opentype", "woff": "woff", "woff2": "woff2"} + fonts_list = [ + { + "family": family, + "file": base64.b64encode(entry["file"].encode()).decode(), + "format": fmt_map.get(Path(entry["file"]).suffix.lstrip(".").lower(), "truetype"), + } + for family, entry in best.items() + ] + fonts_list.sort(key=lambda x: x["family"].casefold()) + + resp = jsonify(fonts_list) + resp.headers["Cache-Control"] = "public, max-age=3600" + return resp + + +@app.route("/font") +def font(): + encoded = request.args.get("f", "") + if not encoded: + return Response("missing parameter", status=400) + + try: + file_str = base64.b64decode(encoded).decode("utf-8") + except Exception: + return Response("invalid parameter", status=400) + + # null byte guard + if "\x00" in file_str: + return Response("invalid parameter", status=400) + + p = Path(file_str) + if not p.exists(): + return Response("font not found", status=404) + + try: + real = p.resolve(strict=True) + except (OSError, RuntimeError): + return Response("font not found", status=404) + + # path traversal guard: real path must be under an allowed font dir + real_str = str(real) + os.sep + if not any(real_str.startswith(d) for d in _allowed_font_dirs()): + return Response("access denied", status=403) + + mime_map = { + "ttf": "font/ttf", + "otf": "font/otf", + "woff": "font/woff", + "woff2": "font/woff2", + } + mime = mime_map.get(real.suffix.lstrip(".").lower(), "application/octet-stream") + + return send_file(real, mimetype=mime, max_age=31536000, conditional=True) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=3000) diff --git a/src/font.php b/src/font.php deleted file mode 100644 index d12ceb8..0000000 --- a/src/font.php +++ /dev/null @@ -1,50 +0,0 @@ -<?php -/* font.php — serve font files from the server's font directories */ - -$encoded = $_GET['f'] ?? ''; -if (empty($encoded)) { - http_response_code(400); - exit('Missing parameter'); -} - -$file = base64_decode($encoded, true); -if ($file === false || !file_exists($file)) { - http_response_code(404); - exit('Font not found'); -} - -$real = realpath($file); -$allowed = ['/usr/share/fonts', '/usr/local/share/fonts']; - -foreach (glob('/home/*', GLOB_ONLYDIR) as $home) { - $allowed[] = $home . '/.local/share/fonts'; - $allowed[] = $home . '/.fonts'; -} - -$ok = false; - -foreach ($allowed as $dir) { - if (str_starts_with($real, realpath($dir) ?: $dir)) { - $ok = true; - break; - } -} - -if (!$ok) { - http_response_code(403); - exit('Access denied'); -} - -$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); -$mime = match ($ext) { - 'ttf' => 'font/ttf', - 'otf' => 'font/otf', - 'woff' => 'font/woff', - 'woff2' => 'font/woff2', - default => 'application/octet-stream', -}; - -header("Content-Type: $mime"); -header('Cache-Control: public, max-age=31536000, immutable'); -header('Content-Length: ' . filesize($file)); -readfile($file); diff --git a/src/fonts.php b/src/fonts.php deleted file mode 100644 index 6d7912b..0000000 --- a/src/fonts.php +++ /dev/null @@ -1,77 +0,0 @@ -<?php -// fonts.php — LIST server-side fonts via fontconfig - -header('Content-Type: application/json'); -header('Cache-Control: public, max-age=3600'); - -/* get list of installed fonts using fc-list */ -$cmd = ['fc-list', '--format=%{family}|%{style}|%{file}\n']; -$desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; -$proc = proc_open($cmd, $desc, $pipes); - -$output = ''; -if (is_resource($proc)) { - $output = stream_get_contents($pipes[1]); - fclose($pipes[1]); - fclose($pipes[2]); - proc_close($proc); -} -if (!$output) { - echo json_encode([]); - exit; -} - -$lines = array_filter(explode("\n", trim($output))); -$best = []; // family => ['file' => ..., 'score' => ...] - -/* lower score = higher priority */ -$style_score = static function (string $style): int { - $s = strtolower(trim($style)); - if ($s === 'regular' || $s === 'roman' || $s === 'book' || $s === 'text') return 0; - if ($s === 'bold') return 1; - if (str_contains($s, 'italic') || str_contains($s, 'oblique')) return 2; - return 3; -}; - -foreach ($lines as $line) { - $parts = explode('|', $line, 3); - if (count($parts) < 3) continue; - - /* take first family name (some entries are comma-separated) */ - $families = explode(',', $parts[0]); - $family = trim($families[0]); - - if (empty($family)) continue; - - $style = trim(explode(',', $parts[1])[0]); - $file = trim($parts[2]); - if (!file_exists($file)) continue; - - $score = $style_score($style); - - if (!isset($best[$family]) || $score < $best[$family]['score']) { - $best[$family] = ['file' => $file, 'score' => $score]; - } -} - -$fonts = []; -foreach ($best as $family => $entry) { - $file = $entry['file']; - $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); - $format = match ($ext) { - 'ttf' => 'truetype', - 'otf' => 'opentype', - 'woff' => 'woff', - 'woff2' => 'woff2', - default => 'truetype', - }; - - $fonts[] = [ - 'family' => $family, - 'file' => base64_encode($file), - 'format' => $format, - ]; -} - -usort($fonts, fn($a, $b) => strcasecmp($a['family'], $b['family'])); -echo json_encode($fonts); diff --git a/src/index.php b/src/index.html index f855f39..ef2f584 100644 --- a/src/index.php +++ b/src/index.html @@ -1,4 +1,3 @@ -<?php /* sent-web — index.php */ ?> <!DOCTYPE html> <html lang="en"> @@ -182,7 +181,7 @@ questions?</textarea> async loadFonts() { try { - const res = await fetch('fonts.php'); + const res = await fetch('/fonts'); const data = await res.json(); const sel = document.getElementById('font-select'); sel.innerHTML = ''; @@ -221,7 +220,7 @@ questions?</textarea> if (this.loadedFonts.has(fontData.family)) return; try { - const url = `font.php?f=${encodeURIComponent(fontData.file)}`; + const url = `/font?f=${encodeURIComponent(fontData.file)}`; const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`; const face = new FontFace(fontData.family, src, { display: 'swap' @@ -531,7 +530,7 @@ questions?</textarea> fd.append('image', file); try { - const res = await fetch('upload.php', { + const res = await fetch('/upload', { method: 'POST', body: fd }); diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..dfce493 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0,<3.2 +gunicorn>=22,<24 +python-magic>=0.4.27,<0.5
\ No newline at end of file diff --git a/src/upload.php b/src/upload.php deleted file mode 100644 index 62db139..0000000 --- a/src/upload.php +++ /dev/null @@ -1,66 +0,0 @@ -<?php -/* upload.php — handle image uploads */ - -header('Content-Type: application/json'); - -if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(405); - echo json_encode(['error' => 'Method not allowed']); - exit; -} - -if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) { - $code = $_FILES['image']['error'] ?? 'unknown'; - http_response_code(400); - echo json_encode(['error' => "Upload failed (code: $code)"]); - exit; -} - -$file = $_FILES['image']; -$allowed = [ - 'image/png', 'image/jpeg', 'image/gif', - 'image/webp', 'image/svg+xml', 'image/bmp', -]; - -$finfo = finfo_open(FILEINFO_MIME_TYPE); -$mime = finfo_file($finfo, $file['tmp_name']); -finfo_close($finfo); - -if (!in_array($mime, $allowed, true)) { - http_response_code(400); - echo json_encode(['error' => "Invalid file type: $mime"]); - exit; -} - -$ext = match ($mime) { - 'image/png' => 'png', - 'image/jpeg' => 'jpg', - 'image/gif' => 'gif', - 'image/webp' => 'webp', - 'image/svg+xml' => 'svg', - 'image/bmp' => 'bmp', - default => 'bin', -}; - -/* generate safe filename */ -$basename = pathinfo($file['name'], PATHINFO_FILENAME); -$basename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $basename); -$basename = substr($basename, 0, 64); -$filename = $basename . '_' . bin2hex(random_bytes(4)) . '.' . $ext; - -$uploadDir = __DIR__ . '/uploads'; -if (!is_dir($uploadDir)) { - mkdir($uploadDir, 0755, true); -} - -$dest = $uploadDir . '/' . $filename; -if (!move_uploaded_file($file['tmp_name'], $dest)) { - http_response_code(500); - echo json_encode(['error' => 'Failed to save file']); - exit; -} - -echo json_encode([ - 'filename' => $filename, - 'url' => 'uploads/' . $filename, -]); |
