a4-printer-webserver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a4_printer_webserver/__init__.py +5 -0
- a4_printer_webserver/__main__.py +96 -0
- a4_printer_webserver/app.py +390 -0
- a4_printer_webserver/service.py +319 -0
- a4_printer_webserver/static/app.css +269 -0
- a4_printer_webserver/static/app.js +509 -0
- a4_printer_webserver/static/login.js +47 -0
- a4_printer_webserver/storage.py +222 -0
- a4_printer_webserver/templates/index.html +132 -0
- a4_printer_webserver/templates/login.html +40 -0
- a4_printer_webserver-0.1.0.dist-info/METADATA +106 -0
- a4_printer_webserver-0.1.0.dist-info/RECORD +15 -0
- a4_printer_webserver-0.1.0.dist-info/WHEEL +5 -0
- a4_printer_webserver-0.1.0.dist-info/licenses/LICENSE +21 -0
- a4_printer_webserver-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Run the web server with ``python -m a4_printer_webserver``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .app import create_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
description="Serve a password-protected web interface for one A4 printer."
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--uuid",
|
|
19
|
+
required=True,
|
|
20
|
+
type=_printer_uuid,
|
|
21
|
+
help="UUID reported by a4-printer-interface for the target printer",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--password",
|
|
25
|
+
required=True,
|
|
26
|
+
help="password required to access the website",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--title", default="Printer Website", help="website title")
|
|
29
|
+
parser.add_argument("--host", default="127.0.0.1", help="address to listen on")
|
|
30
|
+
parser.add_argument("--port", default=5000, type=int, help="TCP port to listen on")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--data-dir",
|
|
33
|
+
type=Path,
|
|
34
|
+
default=Path("printer_data"),
|
|
35
|
+
help="directory used for uploaded files and queue history",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--max-upload-mb",
|
|
39
|
+
default=100,
|
|
40
|
+
type=_positive_integer,
|
|
41
|
+
help="maximum size of one uploaded file in MiB",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--auto-delete",
|
|
45
|
+
metavar="DAYS",
|
|
46
|
+
type=_positive_integer,
|
|
47
|
+
help="delete submitted document files after this many days",
|
|
48
|
+
)
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> None:
|
|
53
|
+
args = build_parser().parse_args()
|
|
54
|
+
if not args.password:
|
|
55
|
+
raise SystemExit("--password cannot be empty")
|
|
56
|
+
if not 1 <= args.port <= 65535:
|
|
57
|
+
raise SystemExit("--port must be between 1 and 65535")
|
|
58
|
+
|
|
59
|
+
logging.basicConfig(
|
|
60
|
+
level=logging.INFO,
|
|
61
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
62
|
+
)
|
|
63
|
+
app = create_app(
|
|
64
|
+
printer_uuid=args.uuid,
|
|
65
|
+
password=args.password,
|
|
66
|
+
title=args.title,
|
|
67
|
+
data_dir=args.data_dir,
|
|
68
|
+
max_upload_mb=args.max_upload_mb,
|
|
69
|
+
auto_delete_days=args.auto_delete,
|
|
70
|
+
)
|
|
71
|
+
service = app.extensions["printer_service"]
|
|
72
|
+
try:
|
|
73
|
+
app.run(host=args.host, port=args.port, threaded=True, use_reloader=False)
|
|
74
|
+
finally:
|
|
75
|
+
service.stop()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _printer_uuid(value: str) -> str:
|
|
79
|
+
try:
|
|
80
|
+
return str(uuid.UUID(value))
|
|
81
|
+
except ValueError:
|
|
82
|
+
raise argparse.ArgumentTypeError("must be a valid UUID") from None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _positive_integer(value: str) -> int:
|
|
86
|
+
try:
|
|
87
|
+
result = int(value)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise argparse.ArgumentTypeError("must be an integer") from None
|
|
90
|
+
if result <= 0:
|
|
91
|
+
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Flask application factory and HTTP/WebSocket endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import hmac
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
import pypdfium2 as pdfium
|
|
19
|
+
from flask import (
|
|
20
|
+
Flask,
|
|
21
|
+
Response,
|
|
22
|
+
jsonify,
|
|
23
|
+
redirect,
|
|
24
|
+
render_template,
|
|
25
|
+
request,
|
|
26
|
+
send_file,
|
|
27
|
+
session,
|
|
28
|
+
url_for,
|
|
29
|
+
)
|
|
30
|
+
from flask_sock import Sock
|
|
31
|
+
from PIL import Image, UnidentifiedImageError
|
|
32
|
+
|
|
33
|
+
from .service import PrinterService
|
|
34
|
+
from .storage import JobStore
|
|
35
|
+
|
|
36
|
+
ALLOWED_IMAGE_EXTENSIONS = {
|
|
37
|
+
".bmp",
|
|
38
|
+
".gif",
|
|
39
|
+
".jpeg",
|
|
40
|
+
".jpg",
|
|
41
|
+
".png",
|
|
42
|
+
".tif",
|
|
43
|
+
".tiff",
|
|
44
|
+
".webp",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app(
|
|
49
|
+
*,
|
|
50
|
+
printer_uuid: str,
|
|
51
|
+
password: str,
|
|
52
|
+
title: str = "Printer Website",
|
|
53
|
+
data_dir: str | Path = "printer_data",
|
|
54
|
+
max_upload_mb: int = 100,
|
|
55
|
+
auto_delete_days: int | None = None,
|
|
56
|
+
start_service: bool = True,
|
|
57
|
+
printer_manager_factory: Callable[..., Any] | None = None,
|
|
58
|
+
) -> Flask:
|
|
59
|
+
app = Flask(__name__)
|
|
60
|
+
app.config.update(
|
|
61
|
+
SECRET_KEY=secrets.token_bytes(32),
|
|
62
|
+
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
|
|
63
|
+
MAX_CONTENT_LENGTH=max_upload_mb * 1024 * 1024,
|
|
64
|
+
SESSION_COOKIE_HTTPONLY=True,
|
|
65
|
+
SESSION_COOKIE_SAMESITE="Strict",
|
|
66
|
+
TITLE=title,
|
|
67
|
+
PRINTER_UUID=printer_uuid,
|
|
68
|
+
LOGIN_PASSWORD=password,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
root = Path(data_dir).expanduser().resolve()
|
|
72
|
+
upload_dir = root / "uploads"
|
|
73
|
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
store = JobStore(root / "jobs.sqlite3")
|
|
75
|
+
store.initialize()
|
|
76
|
+
|
|
77
|
+
service_options: dict[str, Any] = {
|
|
78
|
+
"printer_uuid": printer_uuid,
|
|
79
|
+
"store": store,
|
|
80
|
+
"upload_dir": upload_dir,
|
|
81
|
+
"auto_delete_days": auto_delete_days,
|
|
82
|
+
}
|
|
83
|
+
if printer_manager_factory is not None:
|
|
84
|
+
service_options["printer_manager_factory"] = printer_manager_factory
|
|
85
|
+
service = PrinterService(**service_options)
|
|
86
|
+
|
|
87
|
+
app.extensions["job_store"] = store
|
|
88
|
+
app.extensions["printer_service"] = service
|
|
89
|
+
app.extensions["upload_dir"] = upload_dir
|
|
90
|
+
|
|
91
|
+
sock = Sock(app)
|
|
92
|
+
active_sessions: dict[str, float] = {}
|
|
93
|
+
active_sessions_lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
def session_is_authenticated() -> bool:
|
|
96
|
+
auth_token = session.get("auth_token")
|
|
97
|
+
if not session.get("authenticated") or not auth_token:
|
|
98
|
+
return False
|
|
99
|
+
with active_sessions_lock:
|
|
100
|
+
expires_at = active_sessions.get(auth_token, 0)
|
|
101
|
+
if expires_at <= time.time():
|
|
102
|
+
active_sessions.pop(auth_token, None)
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
@app.before_request
|
|
107
|
+
def ensure_csrf_token() -> None:
|
|
108
|
+
if "csrf_token" not in session:
|
|
109
|
+
session["csrf_token"] = secrets.token_urlsafe(32)
|
|
110
|
+
|
|
111
|
+
@app.after_request
|
|
112
|
+
def secure_response(response: Response) -> Response:
|
|
113
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
114
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
115
|
+
response.headers["Referrer-Policy"] = "same-origin"
|
|
116
|
+
response.headers["Cache-Control"] = "no-store"
|
|
117
|
+
response.headers["Content-Security-Policy"] = (
|
|
118
|
+
"default-src 'self'; img-src 'self' data:; style-src 'self'; "
|
|
119
|
+
"script-src 'self'; connect-src 'self' ws: wss:; "
|
|
120
|
+
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
|
121
|
+
)
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
def authenticated(view: Callable[..., Any]) -> Callable[..., Any]:
|
|
125
|
+
@functools.wraps(view)
|
|
126
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
127
|
+
if not session_is_authenticated():
|
|
128
|
+
return jsonify(error="authentication_required"), 401
|
|
129
|
+
return view(*args, **kwargs)
|
|
130
|
+
|
|
131
|
+
return wrapped
|
|
132
|
+
|
|
133
|
+
def csrf_protected(view: Callable[..., Any]) -> Callable[..., Any]:
|
|
134
|
+
@functools.wraps(view)
|
|
135
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
136
|
+
supplied = request.headers.get("X-CSRF-Token", "")
|
|
137
|
+
expected = session.get("csrf_token", "")
|
|
138
|
+
if not supplied or not hmac.compare_digest(supplied, expected):
|
|
139
|
+
return jsonify(error="invalid_csrf_token"), 400
|
|
140
|
+
return view(*args, **kwargs)
|
|
141
|
+
|
|
142
|
+
return wrapped
|
|
143
|
+
|
|
144
|
+
@app.get("/")
|
|
145
|
+
def index() -> Response | str:
|
|
146
|
+
if not session_is_authenticated():
|
|
147
|
+
return redirect(url_for("login"))
|
|
148
|
+
return render_template(
|
|
149
|
+
"index.html",
|
|
150
|
+
title=app.config["TITLE"],
|
|
151
|
+
csrf_token=session["csrf_token"],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@app.route("/login", methods=["GET", "POST"])
|
|
155
|
+
def login() -> Response | str:
|
|
156
|
+
error = None
|
|
157
|
+
if request.method == "POST":
|
|
158
|
+
supplied_csrf = request.form.get("csrf_token", "")
|
|
159
|
+
supplied_password = request.form.get("password", "")
|
|
160
|
+
valid_csrf = hmac.compare_digest(
|
|
161
|
+
supplied_csrf, session.get("csrf_token", "")
|
|
162
|
+
)
|
|
163
|
+
valid_password = hmac.compare_digest(
|
|
164
|
+
supplied_password, app.config["LOGIN_PASSWORD"]
|
|
165
|
+
)
|
|
166
|
+
if valid_csrf and valid_password:
|
|
167
|
+
session.clear()
|
|
168
|
+
auth_token = secrets.token_urlsafe(32)
|
|
169
|
+
session["authenticated"] = True
|
|
170
|
+
session["auth_token"] = auth_token
|
|
171
|
+
session["csrf_token"] = secrets.token_urlsafe(32)
|
|
172
|
+
session.permanent = True
|
|
173
|
+
with active_sessions_lock:
|
|
174
|
+
active_sessions[auth_token] = time.time() + (
|
|
175
|
+
app.permanent_session_lifetime.total_seconds()
|
|
176
|
+
)
|
|
177
|
+
return redirect(url_for("index"))
|
|
178
|
+
error = "invalid_password" if valid_csrf else "invalid_request"
|
|
179
|
+
elif session_is_authenticated():
|
|
180
|
+
return redirect(url_for("index"))
|
|
181
|
+
return render_template(
|
|
182
|
+
"login.html",
|
|
183
|
+
title=app.config["TITLE"],
|
|
184
|
+
csrf_token=session["csrf_token"],
|
|
185
|
+
error=error,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@app.post("/logout")
|
|
189
|
+
@authenticated
|
|
190
|
+
@csrf_protected
|
|
191
|
+
def logout() -> Response:
|
|
192
|
+
auth_token = session.get("auth_token")
|
|
193
|
+
if auth_token:
|
|
194
|
+
with active_sessions_lock:
|
|
195
|
+
active_sessions.pop(auth_token, None)
|
|
196
|
+
session.clear()
|
|
197
|
+
return jsonify(ok=True)
|
|
198
|
+
|
|
199
|
+
@app.get("/api/state")
|
|
200
|
+
@authenticated
|
|
201
|
+
def get_state() -> Response:
|
|
202
|
+
return jsonify(service.snapshot())
|
|
203
|
+
|
|
204
|
+
@app.post("/api/pause")
|
|
205
|
+
@authenticated
|
|
206
|
+
@csrf_protected
|
|
207
|
+
def set_printing_paused() -> tuple[Response, int] | Response:
|
|
208
|
+
payload = request.get_json(silent=True)
|
|
209
|
+
if not isinstance(payload, dict) or type(payload.get("paused")) is not bool:
|
|
210
|
+
return jsonify(error="invalid_pause_state"), 400
|
|
211
|
+
return jsonify(paused=service.set_paused(payload["paused"]))
|
|
212
|
+
|
|
213
|
+
@app.post("/api/uploads")
|
|
214
|
+
@authenticated
|
|
215
|
+
@csrf_protected
|
|
216
|
+
def upload() -> tuple[Response, int] | Response:
|
|
217
|
+
uploaded = request.files.get("file")
|
|
218
|
+
if uploaded is None or not uploaded.filename:
|
|
219
|
+
return jsonify(error="missing_file"), 400
|
|
220
|
+
|
|
221
|
+
original_name = _display_filename(uploaded.filename)
|
|
222
|
+
extension = Path(original_name).suffix.lower()
|
|
223
|
+
if extension != ".pdf" and extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
224
|
+
return jsonify(error="unsupported_file_type"), 400
|
|
225
|
+
|
|
226
|
+
job_id = str(uuid.uuid4())
|
|
227
|
+
stored_name = f"{job_id}{extension}"
|
|
228
|
+
final_path = upload_dir / stored_name
|
|
229
|
+
temporary_path = upload_dir / f".{job_id}.part"
|
|
230
|
+
try:
|
|
231
|
+
uploaded.save(temporary_path)
|
|
232
|
+
if not temporary_path.is_file() or temporary_path.stat().st_size == 0:
|
|
233
|
+
raise ValueError("empty_file")
|
|
234
|
+
_validate_document(temporary_path, extension)
|
|
235
|
+
os.replace(temporary_path, final_path)
|
|
236
|
+
except ValueError as error:
|
|
237
|
+
temporary_path.unlink(missing_ok=True)
|
|
238
|
+
return jsonify(error=str(error)), 400
|
|
239
|
+
except Exception:
|
|
240
|
+
temporary_path.unlink(missing_ok=True)
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
job = store.create_job(
|
|
245
|
+
job_id=job_id,
|
|
246
|
+
original_name=original_name,
|
|
247
|
+
stored_name=stored_name,
|
|
248
|
+
media_type=uploaded.mimetype or "application/octet-stream",
|
|
249
|
+
size=final_path.stat().st_size,
|
|
250
|
+
)
|
|
251
|
+
except Exception:
|
|
252
|
+
final_path.unlink(missing_ok=True)
|
|
253
|
+
raise
|
|
254
|
+
service.publish_snapshot()
|
|
255
|
+
return jsonify(job=service.public_job(job)), 201
|
|
256
|
+
|
|
257
|
+
@app.post("/api/jobs/<job_id>/print")
|
|
258
|
+
@authenticated
|
|
259
|
+
@csrf_protected
|
|
260
|
+
def queue_for_printing(job_id: str) -> tuple[Response, int] | Response:
|
|
261
|
+
job = store.get_job(job_id)
|
|
262
|
+
if job is None:
|
|
263
|
+
return jsonify(error="job_not_found"), 404
|
|
264
|
+
if job["status"] != "uploaded":
|
|
265
|
+
return jsonify(error="job_cannot_be_queued"), 409
|
|
266
|
+
if not (upload_dir / job["stored_name"]).is_file():
|
|
267
|
+
return jsonify(error="file_missing"), 410
|
|
268
|
+
queued_job = store.queue_job(job_id)
|
|
269
|
+
if queued_job is None:
|
|
270
|
+
return jsonify(error="job_cannot_be_queued"), 409
|
|
271
|
+
service.publish_snapshot()
|
|
272
|
+
service.wake_worker()
|
|
273
|
+
return jsonify(job=service.public_job(queued_job))
|
|
274
|
+
|
|
275
|
+
@app.post("/api/test-page")
|
|
276
|
+
@authenticated
|
|
277
|
+
@csrf_protected
|
|
278
|
+
def queue_test_page() -> tuple[Response, int]:
|
|
279
|
+
job = store.create_test_page_job(job_id=str(uuid.uuid4()))
|
|
280
|
+
service.publish_snapshot()
|
|
281
|
+
service.wake_worker()
|
|
282
|
+
return jsonify(job=service.public_job(job)), 201
|
|
283
|
+
|
|
284
|
+
@app.delete("/api/jobs/<job_id>")
|
|
285
|
+
@authenticated
|
|
286
|
+
@csrf_protected
|
|
287
|
+
def cancel_job(job_id: str) -> tuple[Response, int] | Response:
|
|
288
|
+
job = store.get_job(job_id)
|
|
289
|
+
if job is None:
|
|
290
|
+
return jsonify(error="job_not_found"), 404
|
|
291
|
+
if job["status"] == "printing":
|
|
292
|
+
return jsonify(error="printing_job_cannot_be_canceled"), 409
|
|
293
|
+
canceled_job = store.cancel_job(job_id)
|
|
294
|
+
if canceled_job is None:
|
|
295
|
+
return jsonify(error="job_cannot_be_canceled"), 409
|
|
296
|
+
(upload_dir / job["stored_name"]).unlink(missing_ok=True)
|
|
297
|
+
service.publish_snapshot()
|
|
298
|
+
return jsonify(job=service.public_job(canceled_job))
|
|
299
|
+
|
|
300
|
+
@app.delete("/api/jobs/<job_id>/file")
|
|
301
|
+
@authenticated
|
|
302
|
+
@csrf_protected
|
|
303
|
+
def delete_job_file(job_id: str) -> tuple[Response, int] | Response:
|
|
304
|
+
job = store.get_job(job_id)
|
|
305
|
+
if job is None:
|
|
306
|
+
return jsonify(error="job_not_found"), 404
|
|
307
|
+
visible_job = next(
|
|
308
|
+
(
|
|
309
|
+
candidate
|
|
310
|
+
for candidate in service.snapshot()["jobs"]
|
|
311
|
+
if candidate["id"] == job_id
|
|
312
|
+
),
|
|
313
|
+
None,
|
|
314
|
+
)
|
|
315
|
+
if visible_job is None or not visible_job["can_delete_file"]:
|
|
316
|
+
return jsonify(error="job_file_cannot_be_deleted"), 409
|
|
317
|
+
(upload_dir / job["stored_name"]).unlink(missing_ok=True)
|
|
318
|
+
service.publish_snapshot()
|
|
319
|
+
return jsonify(job=service.public_job(job))
|
|
320
|
+
|
|
321
|
+
@app.get("/api/jobs/<job_id>/download")
|
|
322
|
+
@authenticated
|
|
323
|
+
def download_job(job_id: str) -> Response | tuple[Response, int]:
|
|
324
|
+
job = store.get_job(job_id)
|
|
325
|
+
if job is None:
|
|
326
|
+
return jsonify(error="job_not_found"), 404
|
|
327
|
+
if job["kind"] != "document" or job["status"] != "completed":
|
|
328
|
+
return jsonify(error="job_not_downloadable"), 409
|
|
329
|
+
document_path = upload_dir / job["stored_name"]
|
|
330
|
+
if not document_path.is_file():
|
|
331
|
+
return jsonify(error="file_missing"), 410
|
|
332
|
+
return send_file(
|
|
333
|
+
document_path,
|
|
334
|
+
as_attachment=True,
|
|
335
|
+
download_name=job["original_name"],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
@app.errorhandler(413)
|
|
339
|
+
def upload_too_large(_: Exception) -> tuple[Response, int]:
|
|
340
|
+
return jsonify(error="file_too_large"), 413
|
|
341
|
+
|
|
342
|
+
@sock.route("/ws")
|
|
343
|
+
def websocket(ws: Any) -> None:
|
|
344
|
+
if not session_is_authenticated():
|
|
345
|
+
ws.send('{"type":"authentication_required"}')
|
|
346
|
+
ws.close()
|
|
347
|
+
return
|
|
348
|
+
subscriber = service.broker.subscribe()
|
|
349
|
+
try:
|
|
350
|
+
ws.send(service.broker_message())
|
|
351
|
+
while True:
|
|
352
|
+
try:
|
|
353
|
+
message = subscriber.get(timeout=20)
|
|
354
|
+
except queue.Empty:
|
|
355
|
+
message = service.broker_message()
|
|
356
|
+
if not session_is_authenticated():
|
|
357
|
+
ws.send('{"type":"authentication_required"}')
|
|
358
|
+
ws.close()
|
|
359
|
+
return
|
|
360
|
+
ws.send(message)
|
|
361
|
+
except Exception:
|
|
362
|
+
return
|
|
363
|
+
finally:
|
|
364
|
+
service.broker.unsubscribe(subscriber)
|
|
365
|
+
|
|
366
|
+
if start_service:
|
|
367
|
+
service.start()
|
|
368
|
+
return app
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _display_filename(filename: str) -> str:
|
|
372
|
+
basename = filename.replace("\\", "/").rsplit("/", 1)[-1].strip()
|
|
373
|
+
basename = re.sub(r"[\x00-\x1f\x7f]", "", basename)
|
|
374
|
+
return basename[:255] or "document"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _validate_document(path: Path, extension: str) -> None:
|
|
378
|
+
try:
|
|
379
|
+
if extension == ".pdf":
|
|
380
|
+
document = pdfium.PdfDocument(str(path))
|
|
381
|
+
try:
|
|
382
|
+
if len(document) < 1:
|
|
383
|
+
raise ValueError("invalid_file")
|
|
384
|
+
finally:
|
|
385
|
+
document.close()
|
|
386
|
+
else:
|
|
387
|
+
with Image.open(path) as image:
|
|
388
|
+
image.verify()
|
|
389
|
+
except (ValueError, UnidentifiedImageError, OSError, RuntimeError):
|
|
390
|
+
raise ValueError("invalid_file") from None
|