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 /src | |
| 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
Diffstat (limited to 'src')
| -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 |
6 files changed, 194 insertions, 197 deletions
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 | ]); | ||
