pumaguard 20.post172__py3-none-any.whl → 20.post189__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.
- pumaguard/main.py +7 -1
- pumaguard/presets.py +14 -1
- pumaguard/pumaguard-ui/flutter_bootstrap.js +1 -1
- pumaguard/pumaguard-ui/flutter_service_worker.js +1 -1
- pumaguard/server.py +18 -0
- pumaguard/web_routes/__init__.py +0 -0
- pumaguard/web_routes/artifacts.py +114 -0
- pumaguard/web_routes/diagnostics.py +89 -0
- pumaguard/web_routes/directories.py +67 -0
- pumaguard/web_routes/folders.py +121 -0
- pumaguard/web_routes/photos.py +112 -0
- pumaguard/web_routes/settings.py +116 -0
- pumaguard/web_routes/sync.py +130 -0
- pumaguard/web_ui.py +48 -519
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/METADATA +1 -1
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/RECORD +20 -12
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/WHEEL +0 -0
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/entry_points.txt +0 -0
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/licenses/LICENSE +0 -0
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/top_level.txt +0 -0
pumaguard/main.py
CHANGED
|
@@ -18,6 +18,7 @@ from pumaguard.presets import (
|
|
|
18
18
|
Preset,
|
|
19
19
|
PresetError,
|
|
20
20
|
get_default_settings_file,
|
|
21
|
+
get_xdg_cache_home,
|
|
21
22
|
)
|
|
22
23
|
from pumaguard.utils import (
|
|
23
24
|
print_bash_completion,
|
|
@@ -182,7 +183,12 @@ def main():
|
|
|
182
183
|
logging.basicConfig(level=logging.INFO)
|
|
183
184
|
logger = logging.getLogger("PumaGuard")
|
|
184
185
|
|
|
185
|
-
|
|
186
|
+
# Store logs in XDG cache directory
|
|
187
|
+
log_dir = get_xdg_cache_home() / "pumaguard"
|
|
188
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
log_file = log_dir / "pumaguard.log"
|
|
190
|
+
|
|
191
|
+
file_handler = logging.FileHandler(str(log_file))
|
|
186
192
|
file_handler.setLevel(logging.DEBUG)
|
|
187
193
|
formatter = logging.Formatter(
|
|
188
194
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
pumaguard/presets.py
CHANGED
|
@@ -36,7 +36,7 @@ def get_xdg_config_home() -> Path:
|
|
|
36
36
|
|
|
37
37
|
def get_xdg_data_home() -> Path:
|
|
38
38
|
"""
|
|
39
|
-
Get the XDG data home directory
|
|
39
|
+
Get the XDG data home directory.
|
|
40
40
|
|
|
41
41
|
Returns:
|
|
42
42
|
Path to XDG_DATA_HOME (defaults to ~/.local/share if not set)
|
|
@@ -47,6 +47,19 @@ def get_xdg_data_home() -> Path:
|
|
|
47
47
|
return Path.home() / ".local" / "share"
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def get_xdg_cache_home() -> Path:
|
|
51
|
+
"""
|
|
52
|
+
Get the XDG cache home directory.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Path to XDG_CACHE_HOME (defaults to ~/.cache if not set)
|
|
56
|
+
"""
|
|
57
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME")
|
|
58
|
+
if xdg_cache:
|
|
59
|
+
return Path(xdg_cache)
|
|
60
|
+
return Path.home() / ".cache"
|
|
61
|
+
|
|
62
|
+
|
|
50
63
|
def get_default_settings_file() -> str:
|
|
51
64
|
"""
|
|
52
65
|
Get the default settings file path using XDG standards.
|
|
@@ -34,7 +34,7 @@ const RESOURCES = {"flutter.js": "24bc71911b75b5f8135c949e27a2984e",
|
|
|
34
34
|
"canvaskit/skwasm.js.symbols": "3a4aadf4e8141f284bd524976b1d6bdc",
|
|
35
35
|
"favicon.png": "5dcef449791fa27946b3d35ad8803796",
|
|
36
36
|
"main.dart.wasm": "b7e85784e7cc42b937c6ddffca885d19",
|
|
37
|
-
"flutter_bootstrap.js": "
|
|
37
|
+
"flutter_bootstrap.js": "4a763a346d06c77e69652e5a31b9a5b8",
|
|
38
38
|
"version.json": "d3ae24eea88b92d3d617cfcd1d6d057d",
|
|
39
39
|
"main.dart.js": "8d76816e4e65d2d9504d5fd4013ba7cf"};
|
|
40
40
|
// The application shell files that are downloaded before a service worker can
|
pumaguard/server.py
CHANGED
|
@@ -394,6 +394,24 @@ def main(options: argparse.Namespace, presets: Preset):
|
|
|
394
394
|
webui.add_image_directory(folder)
|
|
395
395
|
logger.info("Watching folder: %s", folder)
|
|
396
396
|
|
|
397
|
+
# Also expose classified result folders in the UI (browse-only)
|
|
398
|
+
try:
|
|
399
|
+
Path(presets.classified_puma_dir).mkdir(parents=True, exist_ok=True)
|
|
400
|
+
Path(presets.classified_other_dir).mkdir(parents=True, exist_ok=True)
|
|
401
|
+
Path(presets.intermediate_dir).mkdir(parents=True, exist_ok=True)
|
|
402
|
+
except OSError as exc: # pragma: no cover
|
|
403
|
+
logger.error("Could not ensure classified folders exist: %s", exc)
|
|
404
|
+
|
|
405
|
+
webui.add_image_directory(presets.classified_puma_dir)
|
|
406
|
+
webui.add_image_directory(presets.classified_other_dir)
|
|
407
|
+
webui.add_image_directory(presets.intermediate_dir)
|
|
408
|
+
logger.info(
|
|
409
|
+
"Classified browsing enabled for: %s, %s; intermediate: %s",
|
|
410
|
+
presets.classified_puma_dir,
|
|
411
|
+
presets.classified_other_dir,
|
|
412
|
+
presets.intermediate_dir,
|
|
413
|
+
)
|
|
414
|
+
|
|
397
415
|
manager.start_all()
|
|
398
416
|
|
|
399
417
|
lock = acquire_lock()
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Artifacts routes for intermediate visualizations and CSVs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import (
|
|
4
|
+
annotations,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from flask import (
|
|
13
|
+
jsonify,
|
|
14
|
+
request,
|
|
15
|
+
send_from_directory,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from flask import (
|
|
20
|
+
Flask,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from pumaguard.web_ui import (
|
|
24
|
+
WebUI,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_artifacts_routes(app: "Flask", webui: "WebUI") -> None:
|
|
31
|
+
"""Register artifacts endpoints for list and download."""
|
|
32
|
+
|
|
33
|
+
@app.route("/api/artifacts", methods=["GET"])
|
|
34
|
+
def list_artifacts():
|
|
35
|
+
base_dir = os.path.realpath(
|
|
36
|
+
os.path.normpath(webui.presets.intermediate_dir)
|
|
37
|
+
)
|
|
38
|
+
if not os.path.exists(base_dir):
|
|
39
|
+
return jsonify(
|
|
40
|
+
{"artifacts": [], "total": 0, "directory": base_dir}
|
|
41
|
+
)
|
|
42
|
+
ext_param = request.args.get("ext", default=None, type=str)
|
|
43
|
+
ext_filter: set[str] | None = None
|
|
44
|
+
if ext_param:
|
|
45
|
+
parts = [
|
|
46
|
+
p.strip().lower() for p in ext_param.split(",") if p.strip()
|
|
47
|
+
]
|
|
48
|
+
ext_filter = {p if p.startswith(".") else f".{p}" for p in parts}
|
|
49
|
+
limit = request.args.get("limit", default=None, type=int)
|
|
50
|
+
entries: list[dict[str, object]] = []
|
|
51
|
+
try:
|
|
52
|
+
for filename in os.listdir(base_dir):
|
|
53
|
+
filepath = os.path.join(base_dir, filename)
|
|
54
|
+
if not os.path.isfile(filepath):
|
|
55
|
+
continue
|
|
56
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
57
|
+
if ext_filter is not None and ext not in ext_filter:
|
|
58
|
+
continue
|
|
59
|
+
stat = os.stat(filepath)
|
|
60
|
+
kind = (
|
|
61
|
+
"image"
|
|
62
|
+
if ext in IMAGE_EXTS
|
|
63
|
+
else ("csv" if ext == ".csv" else "file")
|
|
64
|
+
)
|
|
65
|
+
entries.append(
|
|
66
|
+
{
|
|
67
|
+
"filename": filename,
|
|
68
|
+
"path": filepath,
|
|
69
|
+
"ext": ext,
|
|
70
|
+
"kind": kind,
|
|
71
|
+
"size": stat.st_size,
|
|
72
|
+
"modified": stat.st_mtime,
|
|
73
|
+
"created": stat.st_ctime,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
except OSError:
|
|
77
|
+
return jsonify({"error": "Failed to read artifacts"}), 500
|
|
78
|
+
entries.sort(key=lambda x: x["modified"], reverse=True) # type: ignore
|
|
79
|
+
if limit is not None and limit > 0:
|
|
80
|
+
entries = entries[:limit]
|
|
81
|
+
return jsonify(
|
|
82
|
+
{
|
|
83
|
+
"artifacts": entries,
|
|
84
|
+
"total": len(entries),
|
|
85
|
+
"directory": base_dir,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@app.route("/api/artifacts/<path:filepath>", methods=["GET"])
|
|
90
|
+
def get_artifact(filepath: str):
|
|
91
|
+
# Resolve to absolute path and validate within intermediate
|
|
92
|
+
base_dir = os.path.realpath(
|
|
93
|
+
os.path.normpath(webui.presets.intermediate_dir)
|
|
94
|
+
)
|
|
95
|
+
abs_filepath = os.path.realpath(os.path.normpath(filepath))
|
|
96
|
+
try:
|
|
97
|
+
common = os.path.commonpath([abs_filepath, base_dir])
|
|
98
|
+
if common != base_dir:
|
|
99
|
+
return jsonify({"error": "Access denied"}), 403
|
|
100
|
+
except ValueError:
|
|
101
|
+
# Different drives on Windows
|
|
102
|
+
return jsonify({"error": "Access denied"}), 403
|
|
103
|
+
if not os.path.exists(abs_filepath) or not os.path.isfile(
|
|
104
|
+
abs_filepath
|
|
105
|
+
):
|
|
106
|
+
return jsonify({"error": "File not found"}), 404
|
|
107
|
+
directory = os.path.dirname(abs_filepath)
|
|
108
|
+
filename = os.path.basename(abs_filepath)
|
|
109
|
+
as_attachment = (
|
|
110
|
+
request.args.get("download", default="false").lower() == "true"
|
|
111
|
+
)
|
|
112
|
+
return send_from_directory(
|
|
113
|
+
directory, filename, as_attachment=as_attachment
|
|
114
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Diagnostics routes for server status and troubleshooting info."""
|
|
2
|
+
|
|
3
|
+
from __future__ import (
|
|
4
|
+
annotations,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from flask import (
|
|
12
|
+
jsonify,
|
|
13
|
+
request,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from flask import (
|
|
18
|
+
Flask,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from pumaguard.web_ui import (
|
|
22
|
+
WebUI,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_diagnostics_routes(app: "Flask", webui: "WebUI") -> None:
|
|
27
|
+
"""Register diagnostics endpoints for status and debug info."""
|
|
28
|
+
|
|
29
|
+
@app.route("/api/status", methods=["GET"])
|
|
30
|
+
def get_status():
|
|
31
|
+
origin = request.headers.get("Origin", "No Origin header")
|
|
32
|
+
host = request.headers.get("Host", "No Host header")
|
|
33
|
+
return jsonify(
|
|
34
|
+
{
|
|
35
|
+
"status": "running",
|
|
36
|
+
"version": "1.0.0",
|
|
37
|
+
"directories_count": len(webui.image_directories),
|
|
38
|
+
"host": webui.host,
|
|
39
|
+
"port": webui.port,
|
|
40
|
+
"request_origin": origin,
|
|
41
|
+
"request_host": host,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@app.route("/api/diagnostic", methods=["GET"])
|
|
46
|
+
def get_diagnostic():
|
|
47
|
+
diagnostic_info = {
|
|
48
|
+
"server": {
|
|
49
|
+
"host": webui.host,
|
|
50
|
+
"port": webui.port,
|
|
51
|
+
"flutter_dir": str(webui.flutter_dir),
|
|
52
|
+
"build_dir": str(webui.build_dir),
|
|
53
|
+
"build_exists": webui.build_dir.exists(),
|
|
54
|
+
"mdns_enabled": webui.mdns_enabled,
|
|
55
|
+
"mdns_name": webui.mdns_name if webui.mdns_enabled else None,
|
|
56
|
+
"mdns_url": (
|
|
57
|
+
f"http://{webui.mdns_name}.local:{webui.port}"
|
|
58
|
+
if webui.mdns_enabled
|
|
59
|
+
else None
|
|
60
|
+
),
|
|
61
|
+
"local_ip": webui._get_local_ip(), # pylint: disable=protected-access
|
|
62
|
+
},
|
|
63
|
+
"request": {
|
|
64
|
+
"url": request.url,
|
|
65
|
+
"base_url": request.base_url,
|
|
66
|
+
"host": request.headers.get("Host", "N/A"),
|
|
67
|
+
"origin": request.headers.get("Origin", "N/A"),
|
|
68
|
+
"referer": request.headers.get("Referer", "N/A"),
|
|
69
|
+
"user_agent": request.headers.get("User-Agent", "N/A"),
|
|
70
|
+
},
|
|
71
|
+
"expected_behavior": {
|
|
72
|
+
"flutter_app_should_detect": (
|
|
73
|
+
f"{request.scheme}://{request.host}"
|
|
74
|
+
),
|
|
75
|
+
"api_calls_should_go_to": (
|
|
76
|
+
f"{request.scheme}://{request.host}/api/..."
|
|
77
|
+
),
|
|
78
|
+
},
|
|
79
|
+
"troubleshooting": {
|
|
80
|
+
"if_api_calls_go_to_localhost": (
|
|
81
|
+
"Browser is using cached old JavaScript - clear cache"
|
|
82
|
+
),
|
|
83
|
+
"if_page_doesnt_load": (
|
|
84
|
+
"Check that Flutter app is built: make build-ui"
|
|
85
|
+
),
|
|
86
|
+
"if_cors_errors": "Check browser console for details",
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
return jsonify(diagnostic_info)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Directory management routes for adding and removing watch folders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import (
|
|
4
|
+
annotations,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from flask import (
|
|
13
|
+
jsonify,
|
|
14
|
+
request,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from flask import (
|
|
19
|
+
Flask,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from pumaguard.web_ui import (
|
|
23
|
+
WebUI,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_directories_routes(app: "Flask", webui: "WebUI") -> None:
|
|
30
|
+
"""Register directory endpoints for list, add, remove."""
|
|
31
|
+
|
|
32
|
+
@app.route("/api/directories", methods=["GET"])
|
|
33
|
+
def get_directories():
|
|
34
|
+
return jsonify({"directories": webui.image_directories})
|
|
35
|
+
|
|
36
|
+
@app.route("/api/directories", methods=["POST"])
|
|
37
|
+
def add_directory():
|
|
38
|
+
data = request.json
|
|
39
|
+
directory = data.get("directory") if data else None
|
|
40
|
+
if not directory:
|
|
41
|
+
return jsonify({"error": "No directory provided"}), 400
|
|
42
|
+
# Validate existence happens inside webui.add_image_directory
|
|
43
|
+
if directory not in webui.image_directories:
|
|
44
|
+
webui.image_directories.append(directory)
|
|
45
|
+
logger.info("Added image directory: %s", directory)
|
|
46
|
+
if webui.folder_manager is not None:
|
|
47
|
+
webui.folder_manager.register_folder(
|
|
48
|
+
directory, webui.watch_method
|
|
49
|
+
)
|
|
50
|
+
logger.info(
|
|
51
|
+
"Registered folder with manager: %s (method: %s)",
|
|
52
|
+
directory,
|
|
53
|
+
webui.watch_method,
|
|
54
|
+
)
|
|
55
|
+
return jsonify(
|
|
56
|
+
{"success": True, "directories": webui.image_directories}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@app.route("/api/directories/<int:index>", methods=["DELETE"])
|
|
60
|
+
def remove_directory(index: int):
|
|
61
|
+
if 0 <= index < len(webui.image_directories):
|
|
62
|
+
removed = webui.image_directories.pop(index)
|
|
63
|
+
logger.info("Removed image directory: %s", removed)
|
|
64
|
+
return jsonify(
|
|
65
|
+
{"success": True, "directories": webui.image_directories}
|
|
66
|
+
)
|
|
67
|
+
return jsonify({"error": "Invalid index"}), 400
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Folders routes for browsing and listing images.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import (
|
|
6
|
+
annotations,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import (
|
|
11
|
+
TYPE_CHECKING,
|
|
12
|
+
cast,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from flask import (
|
|
16
|
+
jsonify,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from flask import (
|
|
21
|
+
Flask,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from pumaguard.web_ui import (
|
|
25
|
+
WebUI,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register_folders_routes(app: "Flask", webui: "WebUI") -> None:
|
|
32
|
+
"""Register folder endpoints for list and browse images."""
|
|
33
|
+
|
|
34
|
+
@app.route("/api/folders", methods=["GET"])
|
|
35
|
+
def get_folders():
|
|
36
|
+
folders = []
|
|
37
|
+
for directory in webui.image_directories:
|
|
38
|
+
if not os.path.exists(directory):
|
|
39
|
+
continue
|
|
40
|
+
image_count = 0
|
|
41
|
+
for filename in os.listdir(directory):
|
|
42
|
+
filepath = os.path.join(directory, filename)
|
|
43
|
+
if os.path.isfile(filepath):
|
|
44
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
45
|
+
if ext in IMAGE_EXTS:
|
|
46
|
+
image_count += 1
|
|
47
|
+
folders.append(
|
|
48
|
+
{
|
|
49
|
+
"path": directory,
|
|
50
|
+
"name": os.path.basename(directory),
|
|
51
|
+
"image_count": image_count,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
return jsonify({"folders": folders})
|
|
55
|
+
|
|
56
|
+
@app.route("/api/folders/<path:folder_path>/images", methods=["GET"])
|
|
57
|
+
def get_folder_images(folder_path: str):
|
|
58
|
+
# Try to resolve folder_path relative to each allowed image directory
|
|
59
|
+
abs_folder = None
|
|
60
|
+
resolved_base = None
|
|
61
|
+
for directory in webui.image_directories:
|
|
62
|
+
abs_directory = os.path.realpath(os.path.normpath(directory))
|
|
63
|
+
# Always join user input to base, then normalize
|
|
64
|
+
candidate_folder = os.path.realpath(
|
|
65
|
+
os.path.join(abs_directory, folder_path)
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
common = os.path.commonpath([candidate_folder, abs_directory])
|
|
69
|
+
if common == abs_directory:
|
|
70
|
+
abs_folder = candidate_folder
|
|
71
|
+
resolved_base = abs_directory
|
|
72
|
+
break
|
|
73
|
+
except ValueError:
|
|
74
|
+
# Different drives on Windows
|
|
75
|
+
continue
|
|
76
|
+
if abs_folder is None:
|
|
77
|
+
# Ensure file is within the allowed folder
|
|
78
|
+
return jsonify({"error": "Access denied"}), 403
|
|
79
|
+
if not os.path.exists(abs_folder) or not os.path.isdir(abs_folder):
|
|
80
|
+
return jsonify({"error": "Folder not found"}), 404
|
|
81
|
+
images = []
|
|
82
|
+
for filename in os.listdir(abs_folder):
|
|
83
|
+
filepath = os.path.join(abs_folder, filename)
|
|
84
|
+
resolved_filepath = os.path.realpath(os.path.normpath(filepath))
|
|
85
|
+
if (
|
|
86
|
+
os.path.commonpath([resolved_filepath, abs_folder])
|
|
87
|
+
!= abs_folder
|
|
88
|
+
):
|
|
89
|
+
continue
|
|
90
|
+
if os.path.isfile(resolved_filepath):
|
|
91
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
92
|
+
if ext in IMAGE_EXTS:
|
|
93
|
+
stat = os.stat(resolved_filepath)
|
|
94
|
+
rel_file_path = os.path.relpath(
|
|
95
|
+
resolved_filepath, resolved_base
|
|
96
|
+
)
|
|
97
|
+
images.append(
|
|
98
|
+
{
|
|
99
|
+
"filename": filename,
|
|
100
|
+
"path": rel_file_path,
|
|
101
|
+
"size": stat.st_size,
|
|
102
|
+
"modified": stat.st_mtime,
|
|
103
|
+
"created": stat.st_ctime,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
images.sort(key=lambda x: cast(float, x["modified"]), reverse=True)
|
|
108
|
+
# Return only relative folder path to root and the root directory name
|
|
109
|
+
if resolved_base is not None:
|
|
110
|
+
rel_folder_path = os.path.relpath(abs_folder, resolved_base)
|
|
111
|
+
folder_name = os.path.basename(resolved_base)
|
|
112
|
+
else:
|
|
113
|
+
rel_folder_path = ""
|
|
114
|
+
folder_name = ""
|
|
115
|
+
return jsonify(
|
|
116
|
+
{
|
|
117
|
+
"images": images,
|
|
118
|
+
"folder": rel_folder_path,
|
|
119
|
+
"base": folder_name,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Photos routes for listing, fetching, and deleting images."""
|
|
2
|
+
|
|
3
|
+
from __future__ import (
|
|
4
|
+
annotations,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from flask import (
|
|
13
|
+
jsonify,
|
|
14
|
+
send_from_directory,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from flask import (
|
|
19
|
+
Flask,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from pumaguard.web_ui import (
|
|
23
|
+
WebUI,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_photos_routes(app: "Flask", webui: "WebUI") -> None:
|
|
30
|
+
"""Register photo endpoints for list, get, and delete."""
|
|
31
|
+
|
|
32
|
+
@app.route("/api/photos", methods=["GET"])
|
|
33
|
+
def get_photos():
|
|
34
|
+
photos: list[dict] = []
|
|
35
|
+
for directory in webui.image_directories:
|
|
36
|
+
if not os.path.exists(directory):
|
|
37
|
+
continue
|
|
38
|
+
for filename in os.listdir(directory):
|
|
39
|
+
filepath = os.path.join(directory, filename)
|
|
40
|
+
if os.path.isfile(filepath):
|
|
41
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
42
|
+
if ext in IMAGE_EXTS:
|
|
43
|
+
stat = os.stat(filepath)
|
|
44
|
+
photos.append(
|
|
45
|
+
{
|
|
46
|
+
"filename": filename,
|
|
47
|
+
"path": filepath,
|
|
48
|
+
"directory": directory,
|
|
49
|
+
"size": stat.st_size,
|
|
50
|
+
"modified": stat.st_mtime,
|
|
51
|
+
"created": stat.st_ctime,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
photos.sort(key=lambda x: x["modified"], reverse=True)
|
|
55
|
+
return jsonify({"photos": photos, "total": len(photos)})
|
|
56
|
+
|
|
57
|
+
@app.route("/api/photos/<path:filepath>", methods=["GET"])
|
|
58
|
+
def get_photo(filepath: str):
|
|
59
|
+
# Safely resolve user provided path against allowed directories
|
|
60
|
+
abs_filepath = None
|
|
61
|
+
for directory in webui.image_directories:
|
|
62
|
+
abs_directory = os.path.realpath(directory)
|
|
63
|
+
joined_path = os.path.join(abs_directory, filepath)
|
|
64
|
+
candidate = os.path.realpath(joined_path)
|
|
65
|
+
try:
|
|
66
|
+
common = os.path.commonpath([candidate, abs_directory])
|
|
67
|
+
if common == abs_directory:
|
|
68
|
+
abs_filepath = candidate
|
|
69
|
+
break
|
|
70
|
+
except ValueError:
|
|
71
|
+
# Different drives on Windows
|
|
72
|
+
continue
|
|
73
|
+
if abs_filepath is None:
|
|
74
|
+
return jsonify({"error": "Access denied"}), 403
|
|
75
|
+
if not os.path.exists(abs_filepath) or not os.path.isfile(
|
|
76
|
+
abs_filepath
|
|
77
|
+
):
|
|
78
|
+
return jsonify({"error": "File not found"}), 404
|
|
79
|
+
ext = os.path.splitext(abs_filepath)[1].lower()
|
|
80
|
+
if ext not in IMAGE_EXTS:
|
|
81
|
+
return jsonify({"error": "Access denied"}), 403
|
|
82
|
+
directory = os.path.dirname(abs_filepath)
|
|
83
|
+
filename = os.path.basename(abs_filepath)
|
|
84
|
+
return send_from_directory(directory, filename)
|
|
85
|
+
|
|
86
|
+
@app.route("/api/photos/<path:filepath>", methods=["DELETE"])
|
|
87
|
+
def delete_photo(filepath: str):
|
|
88
|
+
# Safely resolve user provided path against allowed directories
|
|
89
|
+
abs_filepath = None
|
|
90
|
+
for directory in webui.image_directories:
|
|
91
|
+
abs_directory = os.path.realpath(directory)
|
|
92
|
+
joined_path = os.path.join(abs_directory, filepath)
|
|
93
|
+
candidate = os.path.realpath(joined_path)
|
|
94
|
+
try:
|
|
95
|
+
common = os.path.commonpath([candidate, abs_directory])
|
|
96
|
+
if common == abs_directory:
|
|
97
|
+
abs_filepath = candidate
|
|
98
|
+
break
|
|
99
|
+
except ValueError:
|
|
100
|
+
# Different drives on Windows
|
|
101
|
+
continue
|
|
102
|
+
if abs_filepath is None:
|
|
103
|
+
return jsonify({"error": "Access denied"}), 403
|
|
104
|
+
if not os.path.exists(abs_filepath) or not os.path.isfile(
|
|
105
|
+
abs_filepath
|
|
106
|
+
):
|
|
107
|
+
return jsonify({"error": "File not found"}), 404
|
|
108
|
+
ext = os.path.splitext(abs_filepath)[1].lower()
|
|
109
|
+
if ext not in IMAGE_EXTS:
|
|
110
|
+
return jsonify({"error": "Access denied"}), 403
|
|
111
|
+
os.remove(abs_filepath)
|
|
112
|
+
return jsonify({"success": True, "message": "Photo deleted"})
|