summaryrefslogtreecommitdiff
path: root/src/app.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.py')
-rw-r--r--src/app.py188
1 files changed, 188 insertions, 0 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)