summaryrefslogtreecommitdiff
path: root/src
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 /src
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
Diffstat (limited to 'src')
-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
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
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]);