Files
personal-progress-monitor/app/layouts.py
MobKBK 02b81f0113 feat: harden auth for public deploy + gantt, duration units, docker packaging
Security (fail-secure for production):
- Refuse to start without PPM_PASSWORD/PPM_PASSWORD_HASH unless
  PPM_ALLOW_NO_AUTH=1 (local dev only)
- Add login rate limiting (5 failures / 60s per client), enforced inside
  SessionManager.login so it cannot be bypassed at the endpoint layer
- compose defaults PPM_COOKIE_SECURE=true for HTTPS behind reverse proxy
- Add .dockerignore to keep runtime data/out of the image

Features:
- Task timeline gantt on home page (week/month zoom, horizontal scroll,
  infinite-arrow for open-ended dates, CSS Grid layout)
- Duration metrics gain a second/minute/hour unit for input & display
  (canonical storage stays integer seconds)
- Per-metric progress bars on home page (no more merged average)
- Backfill historical records via date picker in the today-record card
- Admin panel: stats overview, JSON import/restore, full task list mgmt

Fixes:
- Nullable task startDate; summarize handles missing start date
- Task dialog cancel/close no longer submits the form
- Import validates whole backup first, rejects path traversal, rebuilds
  archives, and replaces stale files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 12:11:03 +08:00

106 lines
3.3 KiB
Python

from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
ALLOWED_COMPONENTS = {
"task-list",
"overview-summary",
"task-progress-grid",
"task-gantt",
"task-header",
"today-record",
"metric-progress",
"activity-heatmap",
"task-settings",
}
# 每个组件允许出现的页面与区域。组件在各自区域内必须单例,避免重复 ID
# 与重复事件绑定;跨页/跨区错放(例如把 today-record 放进首页)视为非法布局。
COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = {
"task-list": (("home", "task-detail"), "sidebar"),
"overview-summary": (("home",), "main"),
"task-progress-grid": (("home",), "main"),
"task-gantt": (("home",), "main"),
"task-header": (("task-detail",), "main"),
"today-record": (("task-detail",), "main"),
"metric-progress": (("task-detail",), "main"),
"activity-heatmap": (("task-detail",), "main"),
"task-settings": (("task-detail",), "main"),
}
FALLBACKS = {
"home": {
"schemaVersion": 1,
"page": "home",
"regions": {
"sidebar": [{"component": "task-list"}],
"main": [
{"component": "overview-summary"},
{"component": "task-progress-grid"},
{"component": "task-gantt"},
],
},
},
"task-detail": {
"schemaVersion": 1,
"page": "task-detail",
"regions": {
"sidebar": [{"component": "task-list"}],
"main": [
{"component": "task-header"},
{"component": "today-record"},
{"component": "metric-progress"},
{"component": "activity-heatmap"},
{"component": "task-settings"},
],
},
},
}
def validate_layout(layout: Any, expected_page: str) -> bool:
if not isinstance(layout, dict):
return False
if layout.get("schemaVersion") != 1 or layout.get("page") != expected_page:
return False
regions = layout.get("regions")
if not isinstance(regions, dict) or set(regions) - {"sidebar", "main"}:
return False
for region, items in regions.items():
if not isinstance(items, list):
return False
seen: set[str] = set()
for item in items:
if not isinstance(item, dict) or set(item) - {"component", "options"}:
return False
component = item.get("component")
if component not in ALLOWED_COMPONENTS:
return False
if "options" in item and not isinstance(item["options"], dict):
return False
pages, expected_region = COMPONENT_SCOPES[component]
if expected_page not in pages or region != expected_region:
return False
if component in seen:
return False
seen.add(component)
return True
def load_layout(layout_dir: Path, page: str) -> dict[str, Any]:
if page not in FALLBACKS:
raise KeyError(page)
try:
with (layout_dir / f"{page}.json").open("r", encoding="utf-8") as handle:
layout = json.load(handle)
if validate_layout(layout, page):
return layout
except (OSError, json.JSONDecodeError):
pass
return deepcopy(FALLBACKS[page])