summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKyle Javier [kj_sh604]2026-03-16 14:24:34 -0400
committerGitHub2026-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 @@
1src/uploads/* 1src/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
diff --git a/Dockerfile b/Dockerfile
index 5411081..cb2b9df 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,13 +1,15 @@
1FROM php:8.3-apache 1FROM 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
4RUN sed -i 's/^Components: main$/Components: main contrib non-free/' /etc/apt/sources.list.d/debian.sources 4RUN 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
7RUN apt-get update && apt-get install -y --no-install-recommends \ 7RUN 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)
13RUN apt-get update && apt-get install -y --no-install-recommends curl \ 15RUN 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 22WORKDIR /app
21ENV APACHE_DOCUMENT_ROOT=/var/www/html/src
22RUN 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
28RUN 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
33RUN a2enmod rewrite
34 23
35# php upload limits 24# install python dependencies
36RUN echo "upload_max_filesize = 50M" > /usr/local/etc/php/conf.d/sent-web.ini \ 25COPY src/requirements.txt .
37 && echo "post_max_size = 50M" >> /usr/local/etc/php/conf.d/sent-web.ini 26RUN pip install --no-cache-dir -r requirements.txt
38 27
39# copy entrypoint script 28# copy entrypoint script
40COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh 29COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
41RUN chmod +x /usr/local/bin/docker-entrypoint.sh 30RUN chmod +x /usr/local/bin/docker-entrypoint.sh
42 31
43# copy application 32# copy application
44COPY src/ /var/www/html/src/ 33COPY 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
47RUN mkdir -p /opt/uploads-seed \ 36RUN 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
51EXPOSE 3000 40EXPOSE 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
55ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] 44ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
56CMD ["apache2-foreground"] \ No newline at end of file 45CMD ["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 @@
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
5A web-based reimplementation of [suckless sent](https://tools.suckless.org/sent/) 5A web-based reimplementation of [suckless sent](https://tools.suckless.org/sent/)
6using pure PHP and vanilla JavaScript. 6using 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 .
37docker run -d -p 3000:3000 --init --name sent-web sent-web 36docker run -d -p 3000:3000 --init --name sent-web sent-web
38``` 37```
39 38
39### local python run (without docker)
40
41Requirements:
42
43- Python `3.12+`
44- `fontconfig` (`fc-list` must be available)
45- `libmagic` runtime (`libmagic1` on Ubuntu)
46
47Setup:
48
49```sh
50cd src
51python3.12 -m venv .venv
52. .venv/bin/activate
53pip install -r requirements.txt
54gunicorn --bind 0.0.0.0:3000 --workers 2 app:app
55```
56
57Then 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
2set -e 2set -e
3 3
4if [ -z "$(ls -A /var/www/html/src/uploads 2>/dev/null)" ] && \ 4if [ -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/
7fi 7fi
8 8
9chown -R www-data:www-data /var/www/html/src/uploads 9chown -R www-data:www-data /app/uploads
10 10
11exec "$@" \ No newline at end of file 11exec "$@" \ 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
3import base64
4import os
5import re
6import secrets
7import subprocess
8from pathlib import Path
9
10import magic
11from flask import Flask, Response, jsonify, request, send_file, send_from_directory
12
13app = Flask(__name__, static_folder=None)
14app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB upload cap
15
16UPLOAD_DIR = Path(__file__).parent / "uploads"
17UPLOAD_DIR.mkdir(mode=0o755, exist_ok=True)
18
19ALLOWED_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]
33for _home in Path("/home").glob("*"):
34 _FONT_DIRS.append(_home / ".local" / "share" / "fonts")
35 _FONT_DIRS.append(_home / ".fonts")
36
37
38def _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("/")
50def index():
51 return send_from_directory(app.root_path, "index.html")
52
53
54@app.route("/uploads/<filename>")
55def uploads(filename: str):
56 return send_from_directory(UPLOAD_DIR, filename)
57
58
59@app.route("/upload", methods=["POST"])
60def 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")
86def 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")
148def 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
187if __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'] ?? '';
5if (empty($encoded)) {
6 http_response_code(400);
7 exit('Missing parameter');
8}
9
10$file = base64_decode($encoded, true);
11if ($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
19foreach (glob('/home/*', GLOB_ONLYDIR) as $home) {
20 $allowed[] = $home . '/.local/share/fonts';
21 $allowed[] = $home . '/.fonts';
22}
23
24$ok = false;
25
26foreach ($allowed as $dir) {
27 if (str_starts_with($real, realpath($dir) ?: $dir)) {
28 $ok = true;
29 break;
30 }
31}
32
33if (!$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
47header("Content-Type: $mime");
48header('Cache-Control: public, max-age=31536000, immutable');
49header('Content-Length: ' . filesize($file));
50readfile($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
4header('Content-Type: application/json');
5header('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 = '';
13if (is_resource($proc)) {
14 $output = stream_get_contents($pipes[1]);
15 fclose($pipes[1]);
16 fclose($pipes[2]);
17 proc_close($proc);
18}
19if (!$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
36foreach ($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 = [];
58foreach ($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
76usort($fonts, fn($a, $b) => strcasecmp($a['family'], $b['family']));
77echo 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 @@
1flask>=3.0,<3.2
2gunicorn>=22,<24
3python-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
4header('Content-Type: application/json');
5
6if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
7 http_response_code(405);
8 echo json_encode(['error' => 'Method not allowed']);
9 exit;
10}
11
12if (!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']);
27finfo_close($finfo);
28
29if (!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';
52if (!is_dir($uploadDir)) {
53 mkdir($uploadDir, 0755, true);
54}
55
56$dest = $uploadDir . '/' . $filename;
57if (!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
63echo json_encode([
64 'filename' => $filename,
65 'url' => 'uploads/' . $filename,
66]);