diff options
| author | Kyle Javier [kj_sh604] | 2026-03-16 14:24:34 -0400 |
|---|---|---|
| committer | GitHub | 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 @@ | |||
| 1 | src/uploads/* | 1 | src/uploads/* |
| 2 | !src/uploads/.htaccess | 2 | !src/uploads/.htaccess |
| 3 | !src/uploads/nyan_819cac51.png \ No newline at end of file | 3 | !src/uploads/nyan_819cac51.png |
| 4 | __pycache__/ | ||
| 5 | *.pyc \ No newline at end of file | ||
| @@ -1,13 +1,15 @@ | |||
| 1 | FROM php:8.3-apache | 1 | FROM python:3.12-slim |
| 2 | 2 | ||
| 3 | # enable contrib (fonts-ibm-plex) and non-free (fonts-ubuntu) components | 3 | # enable contrib (fonts-ibm-plex) and non-free (fonts-ubuntu) components |
| 4 | RUN sed -i 's/^Components: main$/Components: main contrib non-free/' /etc/apt/sources.list.d/debian.sources | 4 | RUN sed -i 's/^Components: main$/Components: main contrib non-free/' /etc/apt/sources.list.d/debian.sources |
| 5 | 5 | ||
| 6 | # install fonts, fontconfig (for fc-list), and tini (proper PID 1 / signal relay) | 6 | # install fonts, fontconfig (for fc-list), libmagic1 (for python-magic), and tini |
| 7 | RUN apt-get update && apt-get install -y --no-install-recommends \ | 7 | RUN apt-get update && apt-get install -y --no-install-recommends \ |
| 8 | tini \ | 8 | tini \ |
| 9 | fontconfig \ | 9 | fontconfig \ |
| 10 | 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 | 10 | libmagic1 \ |
| 11 | 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 \ | ||
| 12 | && rm -rf /var/lib/apt/lists/* | ||
| 11 | 13 | ||
| 12 | # install Roboto Mono manually (not packaged in Debian, kj_sh604's fave font) | 14 | # install Roboto Mono manually (not packaged in Debian, kj_sh604's fave font) |
| 13 | RUN apt-get update && apt-get install -y --no-install-recommends curl \ | 15 | 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 \ | |||
| 17 | && fc-cache -fv \ | 19 | && fc-cache -fv \ |
| 18 | && rm -rf /var/lib/apt/lists/* | 20 | && rm -rf /var/lib/apt/lists/* |
| 19 | 21 | ||
| 20 | # configure apache: set document root to /var/www/html/src | 22 | WORKDIR /app |
| 21 | ENV APACHE_DOCUMENT_ROOT=/var/www/html/src | ||
| 22 | RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ | ||
| 23 | /etc/apache2/sites-available/*.conf \ | ||
| 24 | /etc/apache2/apache2.conf \ | ||
| 25 | /etc/apache2/conf-available/*.conf | ||
| 26 | |||
| 27 | # switch apache from port 80 to port 3000 | ||
| 28 | RUN sed -i 's/Listen 80/Listen 3000/' /etc/apache2/ports.conf \ | ||
| 29 | && sed -i 's/<VirtualHost \*:80>/<VirtualHost *:3000>/' \ | ||
| 30 | /etc/apache2/sites-available/*.conf | ||
| 31 | |||
| 32 | # enable mod_rewrite | ||
| 33 | RUN a2enmod rewrite | ||
| 34 | 23 | ||
| 35 | # php upload limits | 24 | # install python dependencies |
| 36 | RUN echo "upload_max_filesize = 50M" > /usr/local/etc/php/conf.d/sent-web.ini \ | 25 | COPY src/requirements.txt . |
| 37 | && echo "post_max_size = 50M" >> /usr/local/etc/php/conf.d/sent-web.ini | 26 | RUN pip install --no-cache-dir -r requirements.txt |
| 38 | 27 | ||
| 39 | # copy entrypoint script | 28 | # copy entrypoint script |
| 40 | COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh | 29 | COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh |
| 41 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh | 30 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh |
| 42 | 31 | ||
| 43 | # copy application | 32 | # copy application |
| 44 | COPY src/ /var/www/html/src/ | 33 | COPY src/ /app/ |
| 45 | 34 | ||
| 46 | # stash a seed copy of uploads so the entrypoint can populate a fresh volume | 35 | # stash a seed copy of uploads so the entrypoint can populate a fresh volume |
| 47 | RUN mkdir -p /opt/uploads-seed \ | 36 | RUN mkdir -p /opt/uploads-seed \ |
| 48 | && cp -r /var/www/html/src/uploads/. /opt/uploads-seed/ \ | 37 | && cp -r /app/uploads/. /opt/uploads-seed/ \ |
| 49 | && chown -R www-data:www-data /var/www/html/src/uploads /opt/uploads-seed | 38 | && chown -R www-data:www-data /app/uploads /opt/uploads-seed |
| 50 | 39 | ||
| 51 | EXPOSE 3000 | 40 | EXPOSE 3000 |
| 52 | 41 | ||
| 53 | # tini as PID 1 ensures SIGTERM is properly forwarded to apache, | 42 | # tini as PID 1 ensures SIGTERM is properly forwarded to gunicorn, |
| 54 | # preventing the 'permission denied' error on docker stop | 43 | # preventing the 'permission denied' error on docker stop |
| 55 | ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] | 44 | ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] |
| 56 | CMD ["apache2-foreground"] \ No newline at end of file | 45 | 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 @@ | |||
| 3 | > suckless's sent tool ported to the very sucky web world | 3 | > suckless's sent tool ported to the very sucky web world |
| 4 | 4 | ||
| 5 | A web-based reimplementation of [suckless sent](https://tools.suckless.org/sent/) | 5 | A web-based reimplementation of [suckless sent](https://tools.suckless.org/sent/) |
| 6 | using pure PHP and vanilla JavaScript. | 6 | using Python and vanilla JavaScript. |
| 7 | 7 | ||
| 8 | <img width="1280" height="800" alt="sent0" src="https://github.com/user-attachments/assets/0c503bd4-3609-4e36-ae23-77b7f0711736" /> | 8 | <img width="1280" height="800" alt="sent0" src="https://github.com/user-attachments/assets/0c503bd4-3609-4e36-ae23-77b7f0711736" /> |
| 9 | <br><br> | 9 | <br><br> |
| @@ -11,15 +11,14 @@ using pure PHP and vanilla JavaScript. | |||
| 11 | 11 | ||
| 12 | ## features | 12 | ## features |
| 13 | 13 | ||
| 14 | - **sent-compatible format** — paragraphs = slides, `#` comments, `@image` | 14 | - **sent-compatible format** - paragraphs = slides, `#` comments, `@image` |
| 15 | slides, `\` escapes | 15 | slides, `\` escapes |
| 16 | - **keyboard navigation** — arrow keys, hjkl, space, enter, backspace, pgup/pgdn | 16 | - **keyboard navigation** - arrow keys, hjkl, space, enter, backspace, pgup/pgdn |
| 17 | (same as sent) | 17 | (same as sent) |
| 18 | - **mouse navigation** — left-click right half = next, left half = prev, scroll | 18 | - **mouse navigation** - left-click right half = next, left half = prev, scroll |
| 19 | wheel | 19 | wheel |
| 20 | - **image upload** — upload images and insert `@filename` references | 20 | - **image upload** - upload images and insert `@filename` references (50 MB cap) |
| 21 | - **export** — download as `.sent` file for local sent, or export `.pdf` for portability | 21 | - **export** - download as `.sent` file for local sent, or export `.pdf` for portability |
| 22 | |||
| 23 | ## usage | 22 | ## usage |
| 24 | 23 | ||
| 25 | ### docker compose (recommended) | 24 | ### docker compose (recommended) |
| @@ -37,6 +36,26 @@ docker build -t sent-web . | |||
| 37 | docker run -d -p 3000:3000 --init --name sent-web sent-web | 36 | docker run -d -p 3000:3000 --init --name sent-web sent-web |
| 38 | ``` | 37 | ``` |
| 39 | 38 | ||
| 39 | ### local python run (without docker) | ||
| 40 | |||
| 41 | Requirements: | ||
| 42 | |||
| 43 | - Python `3.12+` | ||
| 44 | - `fontconfig` (`fc-list` must be available) | ||
| 45 | - `libmagic` runtime (`libmagic1` on Ubuntu) | ||
| 46 | |||
| 47 | Setup: | ||
| 48 | |||
| 49 | ```sh | ||
| 50 | cd src | ||
| 51 | python3.12 -m venv .venv | ||
| 52 | . .venv/bin/activate | ||
| 53 | pip install -r requirements.txt | ||
| 54 | gunicorn --bind 0.0.0.0:3000 --workers 2 app:app | ||
| 55 | ``` | ||
| 56 | |||
| 57 | Then open [http://localhost:3000](http://localhost:3000). | ||
| 58 | |||
| 40 | ### presentation shortcuts | 59 | ### presentation shortcuts |
| 41 | 60 | ||
| 42 | | key | action | | 61 | | key | action | |
| @@ -65,12 +84,13 @@ with multiple lines | |||
| 65 | 84 | ||
| 66 | ## technology | 85 | ## technology |
| 67 | 86 | ||
| 68 | - **PHP 8.3** — no framework, just `.php` files | 87 | - **Python 3.12+** - Flask backend |
| 69 | - **vanilla JavaScript** — no npm, no webpack, no react | 88 | - **vanilla JavaScript** - no npm, no webpack, no react |
| 70 | - **[noir.css](https://github.com/kj-sh604/noir.css)** — classless CSS | 89 | - **[noir.css](https://github.com/kj-sh604/noir.css)** - classless CSS |
| 71 | - **Apache** — serves it all | 90 | - **Gunicorn** - production WSGI server |
| 72 | - **fontconfig** — `fc-list` for font enumeration | 91 | - **fontconfig** - `fc-list` for font enumeration |
| 73 | - **Docker** — containerized with fonts pre-installed | 92 | - **python-magic + libmagic** - content-based upload type checks |
| 93 | - **Docker** - containerized with fonts pre-installed | ||
| 74 | 94 | ||
| 75 | ## license | 95 | ## license |
| 76 | 96 | ||
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: | |||
| 8 | ports: | 8 | ports: |
| 9 | - "3000:3000" | 9 | - "3000:3000" |
| 10 | volumes: | 10 | volumes: |
| 11 | - uploads:/var/www/html/src/uploads | 11 | - uploads:/app/uploads |
| 12 | restart: unless-stopped | 12 | restart: unless-stopped |
| 13 | init: true | 13 | init: true |
| 14 | stop_grace_period: 10s | 14 | 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 @@ | |||
| 1 | #!/bin/sh | 1 | #!/bin/sh |
| 2 | set -e | 2 | set -e |
| 3 | 3 | ||
| 4 | if [ -z "$(ls -A /var/www/html/src/uploads 2>/dev/null)" ] && \ | 4 | if [ -z "$(ls -A /app/uploads 2>/dev/null)" ] && \ |
| 5 | [ -d /opt/uploads-seed ]; then | 5 | [ -d /opt/uploads-seed ]; then |
| 6 | cp -r /opt/uploads-seed/. /var/www/html/src/uploads/ | 6 | cp -r /opt/uploads-seed/. /app/uploads/ |
| 7 | fi | 7 | fi |
| 8 | 8 | ||
| 9 | chown -R www-data:www-data /var/www/html/src/uploads | 9 | chown -R www-data:www-data /app/uploads |
| 10 | 10 | ||
| 11 | exec "$@" \ No newline at end of file | 11 | 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 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | |||
| 3 | import base64 | ||
| 4 | import os | ||
| 5 | import re | ||
| 6 | import secrets | ||
| 7 | import subprocess | ||
| 8 | from pathlib import Path | ||
| 9 | |||
| 10 | import magic | ||
| 11 | from flask import Flask, Response, jsonify, request, send_file, send_from_directory | ||
| 12 | |||
| 13 | app = Flask(__name__, static_folder=None) | ||
| 14 | app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB upload cap | ||
| 15 | |||
| 16 | UPLOAD_DIR = Path(__file__).parent / "uploads" | ||
| 17 | UPLOAD_DIR.mkdir(mode=0o755, exist_ok=True) | ||
| 18 | |||
| 19 | ALLOWED_MIME = { | ||
| 20 | "image/png": "png", | ||
| 21 | "image/jpeg": "jpg", | ||
| 22 | "image/gif": "gif", | ||
| 23 | "image/webp": "webp", | ||
| 24 | "image/svg+xml": "svg", | ||
| 25 | "image/bmp": "bmp", | ||
| 26 | } | ||
| 27 | |||
| 28 | # build list of allowed font directories | ||
| 29 | _FONT_DIRS: list[Path] = [ | ||
| 30 | Path("/usr/share/fonts"), | ||
| 31 | Path("/usr/local/share/fonts"), | ||
| 32 | ] | ||
| 33 | for _home in Path("/home").glob("*"): | ||
| 34 | _FONT_DIRS.append(_home / ".local" / "share" / "fonts") | ||
| 35 | _FONT_DIRS.append(_home / ".fonts") | ||
| 36 | |||
| 37 | |||
| 38 | def _allowed_font_dirs() -> list[str]: | ||
| 39 | """return resolved, existent font dirs with trailing separator.""" | ||
| 40 | out = [] | ||
| 41 | for d in _FONT_DIRS: | ||
| 42 | try: | ||
| 43 | out.append(str(d.resolve(strict=True)) + os.sep) | ||
| 44 | except (OSError, RuntimeError): | ||
| 45 | pass | ||
| 46 | return out | ||
| 47 | |||
| 48 | |||
| 49 | @app.route("/") | ||
| 50 | def index(): | ||
| 51 | return send_from_directory(app.root_path, "index.html") | ||
| 52 | |||
| 53 | |||
| 54 | @app.route("/uploads/<filename>") | ||
| 55 | def uploads(filename: str): | ||
| 56 | return send_from_directory(UPLOAD_DIR, filename) | ||
| 57 | |||
| 58 | |||
| 59 | @app.route("/upload", methods=["POST"]) | ||
| 60 | def upload(): | ||
| 61 | if "image" not in request.files: | ||
| 62 | return jsonify({"error": "no file provided"}), 400 | ||
| 63 | |||
| 64 | f = request.files["image"] | ||
| 65 | if not f.filename: | ||
| 66 | return jsonify({"error": "empty filename"}), 400 | ||
| 67 | |||
| 68 | data = f.read() | ||
| 69 | if not data: | ||
| 70 | return jsonify({"error": "empty file"}), 400 | ||
| 71 | |||
| 72 | mime = magic.from_buffer(data, mime=True) | ||
| 73 | if mime not in ALLOWED_MIME: | ||
| 74 | return jsonify({"error": f"invalid file type: {mime}"}), 400 | ||
| 75 | |||
| 76 | ext = ALLOWED_MIME[mime] | ||
| 77 | basename = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(f.filename).stem)[:64] | ||
| 78 | filename = f"{basename}_{secrets.token_hex(4)}.{ext}" | ||
| 79 | |||
| 80 | (UPLOAD_DIR / filename).write_bytes(data) | ||
| 81 | |||
| 82 | return jsonify({"filename": filename, "url": f"uploads/{filename}"}) | ||
| 83 | |||
| 84 | |||
| 85 | @app.route("/fonts") | ||
| 86 | def fonts(): | ||
| 87 | try: | ||
| 88 | result = subprocess.run( | ||
| 89 | ["fc-list", "--format=%{family}|%{style}|%{file}\n"], | ||
| 90 | capture_output=True, | ||
| 91 | text=True, | ||
| 92 | shell=False, | ||
| 93 | timeout=10, | ||
| 94 | check=False, | ||
| 95 | ) | ||
| 96 | except (FileNotFoundError, subprocess.TimeoutExpired): | ||
| 97 | return jsonify([]) | ||
| 98 | |||
| 99 | if result.returncode != 0: | ||
| 100 | return jsonify([]) | ||
| 101 | |||
| 102 | if not result.stdout.strip(): | ||
| 103 | return jsonify([]) | ||
| 104 | |||
| 105 | def style_score(style: str) -> int: | ||
| 106 | s = style.strip().lower() | ||
| 107 | if s in ("regular", "roman", "book", "text"): | ||
| 108 | return 0 | ||
| 109 | if s == "bold": | ||
| 110 | return 1 | ||
| 111 | if "italic" in s or "oblique" in s: | ||
| 112 | return 2 | ||
| 113 | return 3 | ||
| 114 | |||
| 115 | best: dict[str, dict] = {} | ||
| 116 | for line in result.stdout.splitlines(): | ||
| 117 | parts = line.split("|", 2) | ||
| 118 | if len(parts) < 3: | ||
| 119 | continue | ||
| 120 | family = parts[0].split(",")[0].strip() | ||
| 121 | if not family: | ||
| 122 | continue | ||
| 123 | style = parts[1].split(",")[0].strip() | ||
| 124 | file_path = parts[2].strip() | ||
| 125 | if not Path(file_path).exists(): | ||
| 126 | continue | ||
| 127 | score = style_score(style) | ||
| 128 | if family not in best or score < best[family]["score"]: | ||
| 129 | best[family] = {"file": file_path, "score": score} | ||
| 130 | |||
| 131 | fmt_map = {"ttf": "truetype", "otf": "opentype", "woff": "woff", "woff2": "woff2"} | ||
| 132 | fonts_list = [ | ||
| 133 | { | ||
| 134 | "family": family, | ||
| 135 | "file": base64.b64encode(entry["file"].encode()).decode(), | ||
| 136 | "format": fmt_map.get(Path(entry["file"]).suffix.lstrip(".").lower(), "truetype"), | ||
| 137 | } | ||
| 138 | for family, entry in best.items() | ||
| 139 | ] | ||
| 140 | fonts_list.sort(key=lambda x: x["family"].casefold()) | ||
| 141 | |||
| 142 | resp = jsonify(fonts_list) | ||
| 143 | resp.headers["Cache-Control"] = "public, max-age=3600" | ||
| 144 | return resp | ||
| 145 | |||
| 146 | |||
| 147 | @app.route("/font") | ||
| 148 | def font(): | ||
| 149 | encoded = request.args.get("f", "") | ||
| 150 | if not encoded: | ||
| 151 | return Response("missing parameter", status=400) | ||
| 152 | |||
| 153 | try: | ||
| 154 | file_str = base64.b64decode(encoded).decode("utf-8") | ||
| 155 | except Exception: | ||
| 156 | return Response("invalid parameter", status=400) | ||
| 157 | |||
| 158 | # null byte guard | ||
| 159 | if "\x00" in file_str: | ||
| 160 | return Response("invalid parameter", status=400) | ||
| 161 | |||
| 162 | p = Path(file_str) | ||
| 163 | if not p.exists(): | ||
| 164 | return Response("font not found", status=404) | ||
| 165 | |||
| 166 | try: | ||
| 167 | real = p.resolve(strict=True) | ||
| 168 | except (OSError, RuntimeError): | ||
| 169 | return Response("font not found", status=404) | ||
| 170 | |||
| 171 | # path traversal guard: real path must be under an allowed font dir | ||
| 172 | real_str = str(real) + os.sep | ||
| 173 | if not any(real_str.startswith(d) for d in _allowed_font_dirs()): | ||
| 174 | return Response("access denied", status=403) | ||
| 175 | |||
| 176 | mime_map = { | ||
| 177 | "ttf": "font/ttf", | ||
| 178 | "otf": "font/otf", | ||
| 179 | "woff": "font/woff", | ||
| 180 | "woff2": "font/woff2", | ||
| 181 | } | ||
| 182 | mime = mime_map.get(real.suffix.lstrip(".").lower(), "application/octet-stream") | ||
| 183 | |||
| 184 | return send_file(real, mimetype=mime, max_age=31536000, conditional=True) | ||
| 185 | |||
| 186 | |||
| 187 | if __name__ == "__main__": | ||
| 188 | 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 @@ | |||
| 1 | <?php | ||
| 2 | /* font.php — serve font files from the server's font directories */ | ||
| 3 | |||
| 4 | $encoded = $_GET['f'] ?? ''; | ||
| 5 | if (empty($encoded)) { | ||
| 6 | http_response_code(400); | ||
| 7 | exit('Missing parameter'); | ||
| 8 | } | ||
| 9 | |||
| 10 | $file = base64_decode($encoded, true); | ||
| 11 | if ($file === false || !file_exists($file)) { | ||
| 12 | http_response_code(404); | ||
| 13 | exit('Font not found'); | ||
| 14 | } | ||
| 15 | |||
| 16 | $real = realpath($file); | ||
| 17 | $allowed = ['/usr/share/fonts', '/usr/local/share/fonts']; | ||
| 18 | |||
| 19 | foreach (glob('/home/*', GLOB_ONLYDIR) as $home) { | ||
| 20 | $allowed[] = $home . '/.local/share/fonts'; | ||
| 21 | $allowed[] = $home . '/.fonts'; | ||
| 22 | } | ||
| 23 | |||
| 24 | $ok = false; | ||
| 25 | |||
| 26 | foreach ($allowed as $dir) { | ||
| 27 | if (str_starts_with($real, realpath($dir) ?: $dir)) { | ||
| 28 | $ok = true; | ||
| 29 | break; | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | if (!$ok) { | ||
| 34 | http_response_code(403); | ||
| 35 | exit('Access denied'); | ||
| 36 | } | ||
| 37 | |||
| 38 | $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); | ||
| 39 | $mime = match ($ext) { | ||
| 40 | 'ttf' => 'font/ttf', | ||
| 41 | 'otf' => 'font/otf', | ||
| 42 | 'woff' => 'font/woff', | ||
| 43 | 'woff2' => 'font/woff2', | ||
| 44 | default => 'application/octet-stream', | ||
| 45 | }; | ||
| 46 | |||
| 47 | header("Content-Type: $mime"); | ||
| 48 | header('Cache-Control: public, max-age=31536000, immutable'); | ||
| 49 | header('Content-Length: ' . filesize($file)); | ||
| 50 | 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 @@ | |||
| 1 | <?php | ||
| 2 | // fonts.php — LIST server-side fonts via fontconfig | ||
| 3 | |||
| 4 | header('Content-Type: application/json'); | ||
| 5 | header('Cache-Control: public, max-age=3600'); | ||
| 6 | |||
| 7 | /* get list of installed fonts using fc-list */ | ||
| 8 | $cmd = ['fc-list', '--format=%{family}|%{style}|%{file}\n']; | ||
| 9 | $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; | ||
| 10 | $proc = proc_open($cmd, $desc, $pipes); | ||
| 11 | |||
| 12 | $output = ''; | ||
| 13 | if (is_resource($proc)) { | ||
| 14 | $output = stream_get_contents($pipes[1]); | ||
| 15 | fclose($pipes[1]); | ||
| 16 | fclose($pipes[2]); | ||
| 17 | proc_close($proc); | ||
| 18 | } | ||
| 19 | if (!$output) { | ||
| 20 | echo json_encode([]); | ||
| 21 | exit; | ||
| 22 | } | ||
| 23 | |||
| 24 | $lines = array_filter(explode("\n", trim($output))); | ||
| 25 | $best = []; // family => ['file' => ..., 'score' => ...] | ||
| 26 | |||
| 27 | /* lower score = higher priority */ | ||
| 28 | $style_score = static function (string $style): int { | ||
| 29 | $s = strtolower(trim($style)); | ||
| 30 | if ($s === 'regular' || $s === 'roman' || $s === 'book' || $s === 'text') return 0; | ||
| 31 | if ($s === 'bold') return 1; | ||
| 32 | if (str_contains($s, 'italic') || str_contains($s, 'oblique')) return 2; | ||
| 33 | return 3; | ||
| 34 | }; | ||
| 35 | |||
| 36 | foreach ($lines as $line) { | ||
| 37 | $parts = explode('|', $line, 3); | ||
| 38 | if (count($parts) < 3) continue; | ||
| 39 | |||
| 40 | /* take first family name (some entries are comma-separated) */ | ||
| 41 | $families = explode(',', $parts[0]); | ||
| 42 | $family = trim($families[0]); | ||
| 43 | |||
| 44 | if (empty($family)) continue; | ||
| 45 | |||
| 46 | $style = trim(explode(',', $parts[1])[0]); | ||
| 47 | $file = trim($parts[2]); | ||
| 48 | if (!file_exists($file)) continue; | ||
| 49 | |||
| 50 | $score = $style_score($style); | ||
| 51 | |||
| 52 | if (!isset($best[$family]) || $score < $best[$family]['score']) { | ||
| 53 | $best[$family] = ['file' => $file, 'score' => $score]; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | $fonts = []; | ||
| 58 | foreach ($best as $family => $entry) { | ||
| 59 | $file = $entry['file']; | ||
| 60 | $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); | ||
| 61 | $format = match ($ext) { | ||
| 62 | 'ttf' => 'truetype', | ||
| 63 | 'otf' => 'opentype', | ||
| 64 | 'woff' => 'woff', | ||
| 65 | 'woff2' => 'woff2', | ||
| 66 | default => 'truetype', | ||
| 67 | }; | ||
| 68 | |||
| 69 | $fonts[] = [ | ||
| 70 | 'family' => $family, | ||
| 71 | 'file' => base64_encode($file), | ||
| 72 | 'format' => $format, | ||
| 73 | ]; | ||
| 74 | } | ||
| 75 | |||
| 76 | usort($fonts, fn($a, $b) => strcasecmp($a['family'], $b['family'])); | ||
| 77 | 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 @@ | |||
| 1 | <?php /* sent-web — index.php */ ?> | ||
| 2 | <!DOCTYPE html> | 1 | <!DOCTYPE html> |
| 3 | <html lang="en"> | 2 | <html lang="en"> |
| 4 | 3 | ||
| @@ -182,7 +181,7 @@ questions?</textarea> | |||
| 182 | 181 | ||
| 183 | async loadFonts() { | 182 | async loadFonts() { |
| 184 | try { | 183 | try { |
| 185 | const res = await fetch('fonts.php'); | 184 | const res = await fetch('/fonts'); |
| 186 | const data = await res.json(); | 185 | const data = await res.json(); |
| 187 | const sel = document.getElementById('font-select'); | 186 | const sel = document.getElementById('font-select'); |
| 188 | sel.innerHTML = ''; | 187 | sel.innerHTML = ''; |
| @@ -221,7 +220,7 @@ questions?</textarea> | |||
| 221 | if (this.loadedFonts.has(fontData.family)) return; | 220 | if (this.loadedFonts.has(fontData.family)) return; |
| 222 | 221 | ||
| 223 | try { | 222 | try { |
| 224 | const url = `font.php?f=${encodeURIComponent(fontData.file)}`; | 223 | const url = `/font?f=${encodeURIComponent(fontData.file)}`; |
| 225 | const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`; | 224 | const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`; |
| 226 | const face = new FontFace(fontData.family, src, { | 225 | const face = new FontFace(fontData.family, src, { |
| 227 | display: 'swap' | 226 | display: 'swap' |
| @@ -531,7 +530,7 @@ questions?</textarea> | |||
| 531 | fd.append('image', file); | 530 | fd.append('image', file); |
| 532 | 531 | ||
| 533 | try { | 532 | try { |
| 534 | const res = await fetch('upload.php', { | 533 | const res = await fetch('/upload', { |
| 535 | method: 'POST', | 534 | method: 'POST', |
| 536 | body: fd | 535 | body: fd |
| 537 | }); | 536 | }); |
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 @@ | |||
| 1 | flask>=3.0,<3.2 | ||
| 2 | gunicorn>=22,<24 | ||
| 3 | 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 @@ | |||
| 1 | <?php | ||
| 2 | /* upload.php — handle image uploads */ | ||
| 3 | |||
| 4 | header('Content-Type: application/json'); | ||
| 5 | |||
| 6 | if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | ||
| 7 | http_response_code(405); | ||
| 8 | echo json_encode(['error' => 'Method not allowed']); | ||
| 9 | exit; | ||
| 10 | } | ||
| 11 | |||
| 12 | if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) { | ||
| 13 | $code = $_FILES['image']['error'] ?? 'unknown'; | ||
| 14 | http_response_code(400); | ||
| 15 | echo json_encode(['error' => "Upload failed (code: $code)"]); | ||
| 16 | exit; | ||
| 17 | } | ||
| 18 | |||
| 19 | $file = $_FILES['image']; | ||
| 20 | $allowed = [ | ||
| 21 | 'image/png', 'image/jpeg', 'image/gif', | ||
| 22 | 'image/webp', 'image/svg+xml', 'image/bmp', | ||
| 23 | ]; | ||
| 24 | |||
| 25 | $finfo = finfo_open(FILEINFO_MIME_TYPE); | ||
| 26 | $mime = finfo_file($finfo, $file['tmp_name']); | ||
| 27 | finfo_close($finfo); | ||
| 28 | |||
| 29 | if (!in_array($mime, $allowed, true)) { | ||
| 30 | http_response_code(400); | ||
| 31 | echo json_encode(['error' => "Invalid file type: $mime"]); | ||
| 32 | exit; | ||
| 33 | } | ||
| 34 | |||
| 35 | $ext = match ($mime) { | ||
| 36 | 'image/png' => 'png', | ||
| 37 | 'image/jpeg' => 'jpg', | ||
| 38 | 'image/gif' => 'gif', | ||
| 39 | 'image/webp' => 'webp', | ||
| 40 | 'image/svg+xml' => 'svg', | ||
| 41 | 'image/bmp' => 'bmp', | ||
| 42 | default => 'bin', | ||
| 43 | }; | ||
| 44 | |||
| 45 | /* generate safe filename */ | ||
| 46 | $basename = pathinfo($file['name'], PATHINFO_FILENAME); | ||
| 47 | $basename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $basename); | ||
| 48 | $basename = substr($basename, 0, 64); | ||
| 49 | $filename = $basename . '_' . bin2hex(random_bytes(4)) . '.' . $ext; | ||
| 50 | |||
| 51 | $uploadDir = __DIR__ . '/uploads'; | ||
| 52 | if (!is_dir($uploadDir)) { | ||
| 53 | mkdir($uploadDir, 0755, true); | ||
| 54 | } | ||
| 55 | |||
| 56 | $dest = $uploadDir . '/' . $filename; | ||
| 57 | if (!move_uploaded_file($file['tmp_name'], $dest)) { | ||
| 58 | http_response_code(500); | ||
| 59 | echo json_encode(['error' => 'Failed to save file']); | ||
| 60 | exit; | ||
| 61 | } | ||
| 62 | |||
| 63 | echo json_encode([ | ||
| 64 | 'filename' => $filename, | ||
| 65 | 'url' => 'uploads/' . $filename, | ||
| 66 | ]); | ||
