zebra-day 0.0.37__py3-none-any.whl → 2.0.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.
- zebra_day/__init__.py +35 -0
- zebra_day/bin/__init__.py +0 -0
- zebra_day/cli/__init__.py +240 -0
- zebra_day/cli/cognito.py +121 -0
- zebra_day/cli/gui.py +338 -0
- zebra_day/cli/printer.py +168 -0
- zebra_day/cli/template.py +176 -0
- zebra_day/cmd_mgr.py +35 -0
- zebra_day/etc/Monoid-Regular-HalfTight-Dollar-0-1-l.ttf +0 -0
- zebra_day/etc/label_styles/blank.zpl +0 -0
- zebra_day/etc/label_styles/cornersStripOf4Squares_1inX1in.zpl +55 -0
- zebra_day/etc/label_styles/corners_1inX2in.zpl +28 -0
- zebra_day/etc/label_styles/corners_20cmX30cm.zpl +6 -0
- zebra_day/etc/label_styles/corners_smallTube.zpl +7 -0
- zebra_day/etc/label_styles/corners_unspecifiedDimensions.zpl +15 -0
- zebra_day/etc/label_styles/generic_2inX1in.zpl +21 -0
- zebra_day/etc/label_styles/plate_1inX0.25in.zpl +9 -0
- zebra_day/etc/label_styles/plate_1inX0.25inHD.zpl +9 -0
- zebra_day/etc/label_styles/smallTubeWdotHD_prod.zpl +8 -0
- zebra_day/etc/label_styles/smallTubeWdot_corners.zpl +7 -0
- zebra_day/etc/label_styles/smallTubeWdot_prod.zpl +8 -0
- zebra_day/etc/label_styles/smallTubeWdot_prodAlt1.zpl +6 -0
- zebra_day/etc/label_styles/smallTubeWdot_prodAlt1b.zpl +3 -0
- zebra_day/etc/label_styles/smallTubeWdot_prodV2.zpl +8 -0
- zebra_day/etc/label_styles/smallTubeWdot_reagent.zpl +29 -0
- zebra_day/etc/label_styles/stripOf4Squares_1inX1in.zpl +32 -0
- zebra_day/etc/label_styles/test_800dX800dCoordinateArray.zpl +1 -0
- zebra_day/etc/label_styles/tmps/.hold +0 -0
- zebra_day/etc/label_styles/tmps/tmp_zpl_templates.here +0 -0
- zebra_day/etc/label_styles/tube_20mmX30mmA.zpl +7 -0
- zebra_day/etc/label_styles/tube_2inX0.3in.zpl +15 -0
- zebra_day/etc/label_styles/tube_2inX0.5in.zpl +15 -0
- zebra_day/etc/label_styles/tube_2inX0.5inHD.zpl +15 -0
- zebra_day/etc/label_styles/tube_2inX1in.zpl +25 -0
- zebra_day/etc/label_styles/tube_2inX1inHD.zpl +22 -0
- zebra_day/etc/label_styles/tube_2inX1inHDv3.zpl +21 -0
- zebra_day/etc/old_printer_config/.hold +0 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:50:25.022846_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:50:25.033657_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:50:25.039597_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:50:25.047295_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:50:25.055804_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:50:25.061337_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.073326_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.081950_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.088251_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.096501_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.104767_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.110364_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.118239_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.125950_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.349866_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.361085_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.558323_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:24.565756_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.739070_printer_config.json +16 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.753796_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.760201_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.768747_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.775312_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.782533_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.789287_printer_config.json +1 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.794230_printer_config.json +3 -0
- zebra_day/etc/old_printer_config/2026-02-01_01:51:29.800021_printer_config.json +5 -0
- zebra_day/etc/printer_config.json +4 -0
- zebra_day/etc/printer_config.template.json +24 -0
- zebra_day/etc/tmp_printers0.json +5 -0
- zebra_day/etc/tmp_printers120.json +10 -0
- zebra_day/etc/tmp_printers145.json +10 -0
- zebra_day/etc/tmp_printers207.json +10 -0
- zebra_day/etc/tmp_printers374.json +5 -0
- zebra_day/etc/tmp_printers383.json +5 -0
- zebra_day/etc/tmp_printers450.json +5 -0
- zebra_day/etc/tmp_printers469.json +10 -0
- zebra_day/etc/tmp_printers485.json +10 -0
- zebra_day/etc/tmp_printers504.json +5 -0
- zebra_day/etc/tmp_printers531.json +10 -0
- zebra_day/etc/tmp_printers540.json +10 -0
- zebra_day/etc/tmp_printers542.json +10 -0
- zebra_day/etc/tmp_printers552.json +10 -0
- zebra_day/etc/tmp_printers608.json +5 -0
- zebra_day/etc/tmp_printers657.json +5 -0
- zebra_day/etc/tmp_printers715.json +10 -0
- zebra_day/etc/tmp_printers838.json +5 -0
- zebra_day/etc/tmp_printers839.json +5 -0
- zebra_day/etc/tmp_printers933.json +5 -0
- zebra_day/etc/tmp_printers957.json +5 -0
- zebra_day/etc/tmp_printers972.json +10 -0
- zebra_day/exceptions.py +88 -0
- zebra_day/files/.hold +0 -0
- zebra_day/files/blank_preview.png +0 -0
- zebra_day/files/corners_20cmX30cm_preview.png +0 -0
- zebra_day/files/generic_2inX1in_preview.png +0 -0
- zebra_day/files/hold +0 -0
- zebra_day/files/test_png_12020.png +0 -0
- zebra_day/files/test_png_12352.png +0 -0
- zebra_day/files/test_png_15472.png +0 -0
- zebra_day/files/test_png_17696.png +0 -0
- zebra_day/files/test_png_23477.png +0 -0
- zebra_day/files/test_png_24493.png +0 -0
- zebra_day/files/test_png_28157.png +0 -0
- zebra_day/files/test_png_30069.png +0 -0
- zebra_day/files/test_png_35832.png +0 -0
- zebra_day/files/test_png_36400.png +0 -0
- zebra_day/files/test_png_40816.png +0 -0
- zebra_day/files/test_png_47791.png +0 -0
- zebra_day/files/test_png_47799.png +0 -0
- zebra_day/files/test_png_49564.png +0 -0
- zebra_day/files/test_png_53848.png +0 -0
- zebra_day/files/test_png_55588.png +0 -0
- zebra_day/files/test_png_58809.png +0 -0
- zebra_day/files/test_png_62542.png +0 -0
- zebra_day/files/test_png_67242.png +0 -0
- zebra_day/files/test_png_89893.png +0 -0
- zebra_day/files/test_png_91597.png +0 -0
- zebra_day/files/test_png_93633.png +0 -0
- zebra_day/files/tmpbjo3k7q1.png +0 -0
- zebra_day/files/tmpigtr4pwy.png +0 -0
- zebra_day/files/tube_20mmX30mmA_preview.png +0 -0
- zebra_day/files/zpl_label_tube_2inX1in_2026-02-01_01:51:24.370964.png +0 -0
- zebra_day/logging_config.py +74 -0
- zebra_day/logs/.hold +0 -0
- zebra_day/logs/print_requests.log +2 -0
- zebra_day/paths.py +143 -0
- zebra_day/print_mgr.py +557 -117
- zebra_day/static/datschund.css +140 -0
- zebra_day/static/datschund.png +0 -0
- zebra_day/static/daylily.png +0 -0
- zebra_day/static/favicon.svg +20 -0
- zebra_day/static/general.css +99 -0
- zebra_day/static/js/zebra_modern.js +172 -0
- zebra_day/static/lsmc.css +354 -0
- zebra_day/static/moon.jpeg +0 -0
- zebra_day/static/oakland.css +197 -0
- zebra_day/static/petrichor.css +150 -0
- zebra_day/static/popday_daylily.css +140 -0
- zebra_day/static/style.css +183 -0
- zebra_day/static/triangles.css +122 -0
- zebra_day/static/tron.css +277 -0
- zebra_day/static/zebra_modern.css +771 -0
- zebra_day/static/zebras.css +176 -0
- zebra_day/templates/modern/base.html +98 -0
- zebra_day/templates/modern/config.html +141 -0
- zebra_day/templates/modern/config_backups.html +59 -0
- zebra_day/templates/modern/config_editor.html +95 -0
- zebra_day/templates/modern/config_new.html +93 -0
- zebra_day/templates/modern/dashboard.html +160 -0
- zebra_day/templates/modern/print_request.html +145 -0
- zebra_day/templates/modern/print_result.html +88 -0
- zebra_day/templates/modern/printer_detail.html +244 -0
- zebra_day/templates/modern/printers.html +144 -0
- zebra_day/templates/modern/save_result.html +46 -0
- zebra_day/templates/modern/template_editor.html +175 -0
- zebra_day/templates/modern/templates.html +122 -0
- zebra_day/web/__init__.py +9 -0
- zebra_day/web/app.py +248 -0
- zebra_day/web/auth.py +172 -0
- zebra_day/web/middleware.py +159 -0
- zebra_day/web/routers/__init__.py +2 -0
- zebra_day/web/routers/api.py +313 -0
- zebra_day/web/routers/ui.py +636 -0
- zebra_day/zpl_renderer.py +273 -0
- zebra_day-2.0.0.dist-info/METADATA +847 -0
- zebra_day-2.0.0.dist-info/RECORD +168 -0
- {zebra_day-0.0.37.dist-info → zebra_day-2.0.0.dist-info}/WHEEL +1 -1
- zebra_day-2.0.0.dist-info/entry_points.txt +4 -0
- zebra_day/bin/scan_for_networed_zebra_printers.py +0 -23
- zebra_day/bin/te.py +0 -905
- zebra_day/bin/zserve.py +0 -620
- zebra_day-0.0.37.dist-info/METADATA +0 -1177
- zebra_day-0.0.37.dist-info/RECORD +0 -10
- {zebra_day-0.0.37.dist-info → zebra_day-2.0.0.dist-info/licenses}/LICENSE +0 -0
- {zebra_day-0.0.37.dist-info → zebra_day-2.0.0.dist-info}/top_level.txt +0 -0
zebra_day/web/app.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI application factory for zebra_day.
|
|
3
|
+
|
|
4
|
+
This module provides the main FastAPI application for the zebra_day web interface.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal, Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
from fastapi.templating import Jinja2Templates
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
|
|
18
|
+
from zebra_day.logging_config import get_logger
|
|
19
|
+
from zebra_day import paths as xdg
|
|
20
|
+
from zebra_day.web.middleware import RequestLoggingMiddleware, print_rate_limiter
|
|
21
|
+
|
|
22
|
+
_log = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
# Package paths
|
|
25
|
+
_PKG_PATH = Path(str(files("zebra_day")))
|
|
26
|
+
_STATIC_PATH = _PKG_PATH / "static"
|
|
27
|
+
_TEMPLATES_PATH = _PKG_PATH / "templates"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_local_ip() -> str:
|
|
31
|
+
"""Get the local IP address of this machine."""
|
|
32
|
+
ipcmd = r"""(ip addr show | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' || ifconfig | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1') 2>/dev/null"""
|
|
33
|
+
result = subprocess.run(ipcmd, shell=True, capture_output=True, text=True)
|
|
34
|
+
lines = result.stdout.strip().split("\n")
|
|
35
|
+
return lines[0] if lines and lines[0] else "127.0.0.1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_app(
|
|
39
|
+
*,
|
|
40
|
+
debug: bool = False,
|
|
41
|
+
css_theme: str = "lsmc.css",
|
|
42
|
+
auth: Optional[Literal["none", "cognito"]] = None,
|
|
43
|
+
) -> FastAPI:
|
|
44
|
+
"""
|
|
45
|
+
Create and configure the FastAPI application.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
debug: Enable debug mode
|
|
49
|
+
css_theme: Default CSS theme file name
|
|
50
|
+
auth: Authentication mode - "none" (public) or "cognito" (AWS Cognito).
|
|
51
|
+
If None, reads from ZEBRA_DAY_AUTH_MODE env var (defaults to "none").
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Configured FastAPI application
|
|
55
|
+
"""
|
|
56
|
+
# Get auth mode from parameter or environment variable
|
|
57
|
+
if auth is None:
|
|
58
|
+
auth = os.environ.get("ZEBRA_DAY_AUTH_MODE", "none") # type: ignore[assignment]
|
|
59
|
+
|
|
60
|
+
# Validate auth parameter
|
|
61
|
+
if auth not in ("none", "cognito"):
|
|
62
|
+
raise ValueError(f"Invalid auth mode: {auth!r}. Must be 'none' or 'cognito'.")
|
|
63
|
+
|
|
64
|
+
app = FastAPI(
|
|
65
|
+
title="Zebra Day",
|
|
66
|
+
description="Zebra printer fleet management and label printing",
|
|
67
|
+
version="0.5.0",
|
|
68
|
+
debug=debug,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Add request logging middleware
|
|
72
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
73
|
+
|
|
74
|
+
# Configure authentication if enabled
|
|
75
|
+
if auth == "cognito":
|
|
76
|
+
from zebra_day.web.auth import CognitoAuthMiddleware, setup_cognito_auth
|
|
77
|
+
|
|
78
|
+
cognito_auth = setup_cognito_auth(app)
|
|
79
|
+
app.add_middleware(CognitoAuthMiddleware, cognito_auth=cognito_auth)
|
|
80
|
+
app.state.cognito_auth = cognito_auth
|
|
81
|
+
app.state.auth_mode = "cognito"
|
|
82
|
+
_log.info("Cognito authentication middleware enabled")
|
|
83
|
+
else:
|
|
84
|
+
app.state.auth_mode = "none"
|
|
85
|
+
_log.info("Authentication disabled (auth=none)")
|
|
86
|
+
|
|
87
|
+
# Store rate limiter in app state for use in endpoints
|
|
88
|
+
app.state.print_rate_limiter = print_rate_limiter
|
|
89
|
+
|
|
90
|
+
# Store app state
|
|
91
|
+
app.state.css_theme = css_theme
|
|
92
|
+
app.state.local_ip = get_local_ip()
|
|
93
|
+
app.state.pkg_path = _PKG_PATH
|
|
94
|
+
|
|
95
|
+
# Mount static files
|
|
96
|
+
app.mount("/static", StaticFiles(directory=str(_STATIC_PATH)), name="static")
|
|
97
|
+
|
|
98
|
+
# Also mount package directories that need to be served
|
|
99
|
+
files_dir = _PKG_PATH / "files"
|
|
100
|
+
if files_dir.exists():
|
|
101
|
+
app.mount("/files", StaticFiles(directory=str(files_dir)), name="files")
|
|
102
|
+
|
|
103
|
+
etc_dir = _PKG_PATH / "etc"
|
|
104
|
+
if etc_dir.exists():
|
|
105
|
+
app.mount("/etc", StaticFiles(directory=str(etc_dir)), name="etc")
|
|
106
|
+
|
|
107
|
+
# Setup Jinja2 templates
|
|
108
|
+
templates = Jinja2Templates(directory=str(_TEMPLATES_PATH))
|
|
109
|
+
app.state.templates = templates
|
|
110
|
+
|
|
111
|
+
# Register routers
|
|
112
|
+
from zebra_day.web.routers import ui, api
|
|
113
|
+
|
|
114
|
+
app.include_router(ui.router)
|
|
115
|
+
app.include_router(api.router, prefix="/api/v1", tags=["api"])
|
|
116
|
+
|
|
117
|
+
@app.on_event("startup")
|
|
118
|
+
async def startup_event():
|
|
119
|
+
"""Initialize application state on startup."""
|
|
120
|
+
import zebra_day.print_mgr as zdpm
|
|
121
|
+
|
|
122
|
+
app.state.zp = zdpm.zpl()
|
|
123
|
+
_log.info(
|
|
124
|
+
"zebra_day web server starting at %s:8118",
|
|
125
|
+
app.state.local_ip,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@app.get("/healthz")
|
|
129
|
+
async def healthz():
|
|
130
|
+
"""Health check endpoint."""
|
|
131
|
+
return {"status": "healthy"}
|
|
132
|
+
|
|
133
|
+
@app.get("/readyz")
|
|
134
|
+
async def readyz():
|
|
135
|
+
"""Readiness check endpoint."""
|
|
136
|
+
# Check if printer manager is initialized
|
|
137
|
+
if hasattr(app.state, "zp") and app.state.zp is not None:
|
|
138
|
+
return {"status": "ready"}
|
|
139
|
+
return {"status": "not_ready"}, 503
|
|
140
|
+
|
|
141
|
+
return app
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_default_cert_paths() -> tuple[Optional[Path], Optional[Path]]:
|
|
145
|
+
"""
|
|
146
|
+
Get default certificate paths from XDG config directory.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Tuple of (cert_path, key_path) or (None, None) if not found.
|
|
150
|
+
"""
|
|
151
|
+
config_dir = xdg.get_config_dir()
|
|
152
|
+
cert_dir = config_dir / "certs"
|
|
153
|
+
cert_file = cert_dir / "server.crt"
|
|
154
|
+
key_file = cert_dir / "server.key"
|
|
155
|
+
|
|
156
|
+
if cert_file.exists() and key_file.exists():
|
|
157
|
+
return cert_file, key_file
|
|
158
|
+
return None, None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_server(
|
|
162
|
+
host: str = "0.0.0.0",
|
|
163
|
+
port: int = 8118,
|
|
164
|
+
reload: bool = False,
|
|
165
|
+
auth: Literal["none", "cognito"] = "none",
|
|
166
|
+
ssl_certfile: Optional[str] = None,
|
|
167
|
+
ssl_keyfile: Optional[str] = None,
|
|
168
|
+
):
|
|
169
|
+
"""
|
|
170
|
+
Run the FastAPI server using uvicorn.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
host: Host to bind to
|
|
174
|
+
port: Port to listen on
|
|
175
|
+
reload: Enable auto-reload for development
|
|
176
|
+
auth: Authentication mode - "none" (public) or "cognito" (AWS Cognito)
|
|
177
|
+
ssl_certfile: Path to SSL certificate file (PEM format)
|
|
178
|
+
ssl_keyfile: Path to SSL private key file (PEM format)
|
|
179
|
+
|
|
180
|
+
If ssl_certfile and ssl_keyfile are not provided, the server will:
|
|
181
|
+
1. Check SSL_CERT_PATH and SSL_KEY_PATH environment variables
|
|
182
|
+
2. Check for certificates in ~/.config/zebra_day/certs/
|
|
183
|
+
3. Fall back to HTTP with a warning if no certificates are found
|
|
184
|
+
"""
|
|
185
|
+
import uvicorn
|
|
186
|
+
|
|
187
|
+
# Store auth mode in environment for factory function
|
|
188
|
+
os.environ["ZEBRA_DAY_AUTH_MODE"] = auth
|
|
189
|
+
|
|
190
|
+
# Resolve SSL certificate paths
|
|
191
|
+
cert_path = ssl_certfile
|
|
192
|
+
key_path = ssl_keyfile
|
|
193
|
+
|
|
194
|
+
# Check environment variables if not provided
|
|
195
|
+
if not cert_path:
|
|
196
|
+
cert_path = os.environ.get("SSL_CERT_PATH")
|
|
197
|
+
if not key_path:
|
|
198
|
+
key_path = os.environ.get("SSL_KEY_PATH")
|
|
199
|
+
|
|
200
|
+
# Check default XDG paths if still not found
|
|
201
|
+
if not cert_path or not key_path:
|
|
202
|
+
default_cert, default_key = get_default_cert_paths()
|
|
203
|
+
if default_cert and default_key:
|
|
204
|
+
cert_path = str(default_cert)
|
|
205
|
+
key_path = str(default_key)
|
|
206
|
+
|
|
207
|
+
# Validate certificate files exist
|
|
208
|
+
use_ssl = False
|
|
209
|
+
if cert_path and key_path:
|
|
210
|
+
cert_exists = Path(cert_path).exists()
|
|
211
|
+
key_exists = Path(key_path).exists()
|
|
212
|
+
if cert_exists and key_exists:
|
|
213
|
+
use_ssl = True
|
|
214
|
+
_log.info("HTTPS enabled with certificates:")
|
|
215
|
+
_log.info(" Certificate: %s", cert_path)
|
|
216
|
+
_log.info(" Private key: %s", key_path)
|
|
217
|
+
else:
|
|
218
|
+
if not cert_exists:
|
|
219
|
+
_log.warning("SSL certificate not found: %s", cert_path)
|
|
220
|
+
if not key_exists:
|
|
221
|
+
_log.warning("SSL private key not found: %s", key_path)
|
|
222
|
+
_log.warning("Falling back to HTTP (insecure)")
|
|
223
|
+
else:
|
|
224
|
+
_log.warning(
|
|
225
|
+
"No SSL certificates configured. Running in HTTP mode (insecure). "
|
|
226
|
+
"For HTTPS, run: mkcert -install && mkcert -cert-file ~/.config/zebra_day/certs/server.crt "
|
|
227
|
+
"-key-file ~/.config/zebra_day/certs/server.key localhost 127.0.0.1 ::1"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Build uvicorn config
|
|
231
|
+
uvicorn_kwargs = {
|
|
232
|
+
"host": host,
|
|
233
|
+
"port": port,
|
|
234
|
+
"reload": reload,
|
|
235
|
+
"factory": True,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if use_ssl:
|
|
239
|
+
uvicorn_kwargs["ssl_certfile"] = cert_path
|
|
240
|
+
uvicorn_kwargs["ssl_keyfile"] = key_path
|
|
241
|
+
protocol = "https"
|
|
242
|
+
else:
|
|
243
|
+
protocol = "http"
|
|
244
|
+
|
|
245
|
+
_log.info("Starting server at %s://%s:%d", protocol, host, port)
|
|
246
|
+
|
|
247
|
+
uvicorn.run("zebra_day.web.app:create_app", **uvicorn_kwargs)
|
|
248
|
+
|
zebra_day/web/auth.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Authentication integration for zebra_day web server.
|
|
2
|
+
|
|
3
|
+
Provides optional Cognito authentication support via the daylily-cognito library.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
12
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
|
|
15
|
+
from zebra_day.logging_config import get_logger
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
|
|
20
|
+
_log = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
# Endpoints that should never require authentication
|
|
23
|
+
PUBLIC_PATHS: List[str] = [
|
|
24
|
+
"/healthz",
|
|
25
|
+
"/readyz",
|
|
26
|
+
"/docs",
|
|
27
|
+
"/openapi.json",
|
|
28
|
+
"/redoc",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Try to import daylily-cognito components
|
|
32
|
+
_COGNITO_AVAILABLE = False
|
|
33
|
+
_COGNITO_IMPORT_ERROR: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
from daylily_cognito import CognitoAuth, CognitoConfig, create_auth_dependency
|
|
37
|
+
|
|
38
|
+
_COGNITO_AVAILABLE = True
|
|
39
|
+
except ImportError as e:
|
|
40
|
+
_COGNITO_IMPORT_ERROR = str(e)
|
|
41
|
+
CognitoAuth = None # type: ignore[misc, assignment]
|
|
42
|
+
CognitoConfig = None # type: ignore[misc, assignment]
|
|
43
|
+
create_auth_dependency = None # type: ignore[misc, assignment]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_cognito_available() -> bool:
|
|
47
|
+
"""Check if daylily-cognito library is installed."""
|
|
48
|
+
return _COGNITO_AVAILABLE
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_cognito_import_error() -> Optional[str]:
|
|
52
|
+
"""Get the import error message if daylily-cognito is not available."""
|
|
53
|
+
return _COGNITO_IMPORT_ERROR
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CognitoAuthMiddleware(BaseHTTPMiddleware):
|
|
57
|
+
"""Middleware that enforces Cognito authentication on protected endpoints.
|
|
58
|
+
|
|
59
|
+
Exempts health check endpoints and other public paths.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, app: "FastAPI", cognito_auth: Any) -> None:
|
|
63
|
+
super().__init__(app)
|
|
64
|
+
self.cognito_auth = cognito_auth
|
|
65
|
+
self.get_current_user = create_auth_dependency(cognito_auth, optional=False)
|
|
66
|
+
|
|
67
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
68
|
+
"""Check authentication for protected endpoints."""
|
|
69
|
+
path = request.url.path
|
|
70
|
+
|
|
71
|
+
# Allow public endpoints without authentication
|
|
72
|
+
if any(path.startswith(public) for public in PUBLIC_PATHS):
|
|
73
|
+
return await call_next(request)
|
|
74
|
+
|
|
75
|
+
# Allow static files without authentication
|
|
76
|
+
if path.startswith("/static") or path.startswith("/files") or path.startswith("/etc"):
|
|
77
|
+
return await call_next(request)
|
|
78
|
+
|
|
79
|
+
# Check for Authorization header
|
|
80
|
+
auth_header = request.headers.get("Authorization")
|
|
81
|
+
if not auth_header:
|
|
82
|
+
return Response(
|
|
83
|
+
content='{"detail":"Authentication required"}',
|
|
84
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
85
|
+
media_type="application/json",
|
|
86
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Validate Bearer token
|
|
90
|
+
if not auth_header.startswith("Bearer "):
|
|
91
|
+
return Response(
|
|
92
|
+
content='{"detail":"Invalid authorization header format"}',
|
|
93
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
94
|
+
media_type="application/json",
|
|
95
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
token = auth_header[7:] # Remove "Bearer " prefix
|
|
99
|
+
try:
|
|
100
|
+
# Verify token and attach user to request state
|
|
101
|
+
user_claims = self.cognito_auth.verify_token(token)
|
|
102
|
+
request.state.user = user_claims
|
|
103
|
+
except HTTPException:
|
|
104
|
+
return Response(
|
|
105
|
+
content='{"detail":"Invalid or expired token"}',
|
|
106
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
107
|
+
media_type="application/json",
|
|
108
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
109
|
+
)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
_log.error("Authentication error: %s", str(e))
|
|
112
|
+
return Response(
|
|
113
|
+
content='{"detail":"Authentication failed"}',
|
|
114
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
115
|
+
media_type="application/json",
|
|
116
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return await call_next(request)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def setup_cognito_auth(app: "FastAPI") -> Any:
|
|
123
|
+
"""Configure Cognito authentication for the FastAPI app.
|
|
124
|
+
|
|
125
|
+
Reads configuration from environment variables:
|
|
126
|
+
- COGNITO_USER_POOL_ID: Cognito User Pool ID (required)
|
|
127
|
+
- COGNITO_APP_CLIENT_ID or COGNITO_CLIENT_ID: App Client ID (required)
|
|
128
|
+
- COGNITO_REGION or AWS_REGION: AWS region (defaults to us-west-2)
|
|
129
|
+
- AWS_PROFILE: Optional AWS profile name
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
CognitoAuth instance
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If required environment variables are missing
|
|
136
|
+
ImportError: If daylily-cognito is not installed
|
|
137
|
+
"""
|
|
138
|
+
if not _COGNITO_AVAILABLE:
|
|
139
|
+
raise ImportError(
|
|
140
|
+
f"daylily-cognito library is required for Cognito authentication. "
|
|
141
|
+
f"Install with: pip install -e '.[auth]'\n"
|
|
142
|
+
f"Import error: {_COGNITO_IMPORT_ERROR}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Load config from environment
|
|
146
|
+
try:
|
|
147
|
+
config = CognitoConfig.from_legacy_env()
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Missing required Cognito configuration. {e}\n"
|
|
151
|
+
"Set the following environment variables:\n"
|
|
152
|
+
" COGNITO_USER_POOL_ID=your-pool-id\n"
|
|
153
|
+
" COGNITO_APP_CLIENT_ID=your-client-id\n"
|
|
154
|
+
" COGNITO_REGION=us-west-2 (optional, defaults to us-west-2)"
|
|
155
|
+
) from e
|
|
156
|
+
|
|
157
|
+
# Create CognitoAuth instance
|
|
158
|
+
cognito_auth = CognitoAuth(
|
|
159
|
+
region=config.region,
|
|
160
|
+
user_pool_id=config.user_pool_id,
|
|
161
|
+
app_client_id=config.app_client_id,
|
|
162
|
+
profile=config.aws_profile,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
_log.info(
|
|
166
|
+
"Cognito authentication enabled (region=%s, pool=%s)",
|
|
167
|
+
config.region,
|
|
168
|
+
config.user_pool_id,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return cognito_auth
|
|
172
|
+
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Middleware for the zebra_day FastAPI application.
|
|
3
|
+
|
|
4
|
+
Provides request logging and rate limiting functionality.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from fastapi import Request, Response
|
|
14
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
15
|
+
|
|
16
|
+
from zebra_day.logging_config import get_logger
|
|
17
|
+
|
|
18
|
+
_log = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
22
|
+
"""
|
|
23
|
+
Middleware for structured request logging.
|
|
24
|
+
|
|
25
|
+
Logs client IP, request path, method, timing, and response status.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
29
|
+
"""Process request and log structured data."""
|
|
30
|
+
start_time = time.perf_counter()
|
|
31
|
+
|
|
32
|
+
# Extract client info
|
|
33
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
34
|
+
method = request.method
|
|
35
|
+
path = request.url.path
|
|
36
|
+
query = str(request.query_params) if request.query_params else ""
|
|
37
|
+
|
|
38
|
+
# Extract relevant parameters for print operations
|
|
39
|
+
lab = request.query_params.get("lab", "")
|
|
40
|
+
printer = request.query_params.get("printer", "")
|
|
41
|
+
template = request.query_params.get("label_zpl_style", "")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
response = await call_next(request)
|
|
45
|
+
status_code = response.status_code
|
|
46
|
+
outcome = "success" if status_code < 400 else "error"
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
status_code = 500
|
|
49
|
+
outcome = "exception"
|
|
50
|
+
_log.exception(
|
|
51
|
+
"Request failed",
|
|
52
|
+
extra={
|
|
53
|
+
"client_ip": client_ip,
|
|
54
|
+
"method": method,
|
|
55
|
+
"path": path,
|
|
56
|
+
"error": str(exc),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
62
|
+
|
|
63
|
+
# Build log context
|
|
64
|
+
log_context = {
|
|
65
|
+
"client_ip": client_ip,
|
|
66
|
+
"method": method,
|
|
67
|
+
"path": path,
|
|
68
|
+
"status_code": status_code,
|
|
69
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
70
|
+
"outcome": outcome,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Add print-specific context if relevant
|
|
74
|
+
if lab:
|
|
75
|
+
log_context["lab"] = lab
|
|
76
|
+
if printer:
|
|
77
|
+
log_context["printer"] = printer
|
|
78
|
+
if template:
|
|
79
|
+
log_context["template"] = template
|
|
80
|
+
|
|
81
|
+
# Log at appropriate level
|
|
82
|
+
if status_code >= 500:
|
|
83
|
+
_log.error("Request completed", extra=log_context)
|
|
84
|
+
elif status_code >= 400:
|
|
85
|
+
_log.warning("Request completed", extra=log_context)
|
|
86
|
+
else:
|
|
87
|
+
_log.info("Request completed", extra=log_context)
|
|
88
|
+
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PrintRateLimiter:
|
|
93
|
+
"""
|
|
94
|
+
Simple rate limiter for print endpoints.
|
|
95
|
+
|
|
96
|
+
Uses a sliding window approach with configurable limits.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
max_requests: int = 10,
|
|
102
|
+
window_seconds: float = 60.0,
|
|
103
|
+
max_concurrent: int = 3,
|
|
104
|
+
):
|
|
105
|
+
"""
|
|
106
|
+
Initialize rate limiter.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
max_requests: Maximum requests per window per client IP
|
|
110
|
+
window_seconds: Time window in seconds
|
|
111
|
+
max_concurrent: Maximum concurrent print operations
|
|
112
|
+
"""
|
|
113
|
+
self.max_requests = max_requests
|
|
114
|
+
self.window_seconds = window_seconds
|
|
115
|
+
self.max_concurrent = max_concurrent
|
|
116
|
+
|
|
117
|
+
self._request_times: dict[str, list[float]] = defaultdict(list)
|
|
118
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
119
|
+
self._lock = asyncio.Lock()
|
|
120
|
+
|
|
121
|
+
async def acquire(self, client_ip: str) -> tuple[bool, str]:
|
|
122
|
+
"""
|
|
123
|
+
Try to acquire a print slot.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Tuple of (allowed, reason)
|
|
127
|
+
"""
|
|
128
|
+
now = time.time()
|
|
129
|
+
|
|
130
|
+
async with self._lock:
|
|
131
|
+
# Clean old entries
|
|
132
|
+
cutoff = now - self.window_seconds
|
|
133
|
+
self._request_times[client_ip] = [
|
|
134
|
+
t for t in self._request_times[client_ip] if t > cutoff
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
# Check rate limit
|
|
138
|
+
if len(self._request_times[client_ip]) >= self.max_requests:
|
|
139
|
+
return False, f"Rate limit exceeded: {self.max_requests} requests per {self.window_seconds}s"
|
|
140
|
+
|
|
141
|
+
# Try to acquire semaphore (non-blocking check)
|
|
142
|
+
if self._semaphore.locked() and self._semaphore._value == 0:
|
|
143
|
+
return False, f"Too many concurrent print operations (max {self.max_concurrent})"
|
|
144
|
+
|
|
145
|
+
# Record this request
|
|
146
|
+
self._request_times[client_ip].append(now)
|
|
147
|
+
|
|
148
|
+
# Acquire semaphore for actual operation
|
|
149
|
+
await self._semaphore.acquire()
|
|
150
|
+
return True, ""
|
|
151
|
+
|
|
152
|
+
def release(self) -> None:
|
|
153
|
+
"""Release a print slot after operation completes."""
|
|
154
|
+
self._semaphore.release()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Global rate limiter instance
|
|
158
|
+
print_rate_limiter = PrintRateLimiter()
|
|
159
|
+
|