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.
@@ -0,0 +1,5 @@
1
+ """A password-protected web interface for an A4 printer."""
2
+
3
+ from .app import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -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