summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKyle Javier [kj_sh604] <43.splash@gmail.com>2026-03-16 14:24:34 -0400
committerGitHub <noreply@github.com>2026-03-16 14:24:34 -0400
commit64d8fc9ce9922f38780cc677478cbfb47b21a87e (patch)
tree5b244c18193da100a88904536bf1496392339f65
parent17a970b067fcdf6758668c23184fb2112370bb94 (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--.gitignore4
-rw-r--r--Dockerfile39
-rw-r--r--README.md46
-rw-r--r--docker-compose.yml2
-rw-r--r--docker-entrypoint.sh6
-rw-r--r--src/app.py188
-rw-r--r--src/font.php50
-rw-r--r--src/fonts.php77
-rw-r--r--src/index.html (renamed from src/index.php)7
-rw-r--r--src/requirements.txt3
-rw-r--r--src/upload.php66
11 files changed, 248 insertions, 240 deletions
diff --git a/.gitignore b/.gitignore
index d3d5868..5d9b990 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Dockerfile b/Dockerfile
index 5411081..cb2b9df 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index 140f679..f8883d0 100644
--- a/README.md
+++ b/README.md
@@ -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,
-]);