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>
This commit is contained in:
MobKBK
2026-08-24 12:11:03 +08:00
parent bcc2abe623
commit 02b81f0113
16 changed files with 705 additions and 45 deletions

View File

@@ -403,7 +403,11 @@ class JsonRepository:
windows = {
task["taskId"]: {
metric["metricId"]: period_window(metric["period"], ref, date.fromisoformat(task["startDate"]))
metric["metricId"]: period_window(
metric["period"],
ref,
date.fromisoformat(task["startDate"]) if task.get("startDate") else None,
)
for metric in task["metrics"]
}
for task in tasks
@@ -439,7 +443,7 @@ class JsonRepository:
else:
window_start, window_end = span[0].isoformat(), span[1].isoformat()
metrics.append({
**{key: metric[key] for key in ("metricId", "name", "kind", "period", "unit", "target")},
**{key: metric.get(key) for key in ("metricId", "name", "kind", "period", "unit", "target", "durationUnit")},
"value": value,
"percent": _percent(value, metric["target"]),
"windowStart": window_start,
@@ -527,3 +531,108 @@ class JsonRepository:
"archives": archives,
}
def stats(self) -> dict[str, Any]:
with self._lock:
tasks = [self._read(path) for path in self.tasks_dir.glob("*.json")]
daily_files = [self._read(path) for path in self.daily_dir.glob("*/*.json")]
revisions = [path for path in self.revisions_dir.glob("*/*.json")]
archives = [path for path in self.archive_dir.glob("*/*.json")]
def bytes_of(paths):
total = 0
for path in paths:
try:
total += path.stat().st_size
except OSError:
pass
return total
return {
"schemaVersion": SCHEMA_VERSION,
"taskCount": len(tasks),
"activeTaskCount": len([t for t in tasks if t and not t.get("deletedAt")]),
"deletedTaskCount": len([t for t in tasks if t and t.get("deletedAt")]),
"dailyFactCount": len(daily_files),
"revisionCount": len(revisions),
"archiveCount": len(archives),
"storageBytes": bytes_of(
list(self.tasks_dir.glob("*.json"))
+ list(self.daily_dir.glob("*/*.json"))
+ list(self.revisions_dir.glob("*/*.json"))
+ list(self.archive_dir.glob("*/*.json"))
),
}
def _validate_identifier(self, value: Any, field: str) -> str:
"""Task/revision identifiers must be plain, path-safe tokens."""
if not isinstance(value, str) or not value or "/" in value or "\\" in value or value in {".", ".."} or ":" in value:
raise ValidationError(f"备份 {field} 非法")
return value
def import_all(self, payload: dict[str, Any]) -> int:
"""Restore an export produced by ``export_all``.
Replaces the entire data set. The payload is fully validated and staged
into a temp directory before the live directories are swapped, so a
malformed backup never leaves a partially imported repository.
"""
with self._lock:
if payload.get("schemaVersion") != SCHEMA_VERSION:
raise ValidationError("备份 schemaVersion 不受支持")
meta = payload.get("meta")
tasks = payload.get("tasks")
revisions = payload.get("revisions") or []
daily = payload.get("daily") or []
if not isinstance(meta, dict) or not isinstance(tasks, list):
raise ValidationError("备份数据缺少 meta/tasks")
# 第一遍:全部校验并规整为「目标路径 -> 内容」,不落盘。
staged: list[tuple[Path, Any]] = []
seen_tasks: set[str] = set()
for task in tasks:
if not isinstance(task, dict):
raise ValidationError("备份任务数据无效")
task_id = self._validate_identifier(task.get("taskId"), "taskId")
if task_id in seen_tasks:
raise ValidationError("备份包含重复任务")
seen_tasks.add(task_id)
staged.append((self._task_path(task_id), task))
revision_folders: set[Path] = set()
for revision in revisions:
if not isinstance(revision, dict):
continue
task_id = self._validate_identifier(revision.get("taskId"), "taskId")
rev = self._validate_identifier(str(revision.get("revision")), "revision")
folder = self.revisions_dir / task_id
revision_folders.add(folder)
staged.append((folder / f"{rev}.json", revision))
daily_dates: set[date] = set()
for fact in daily:
if not isinstance(fact, dict) or not fact.get("date"):
raise ValidationError("备份每日事实无效")
day = date.fromisoformat(str(fact["date"]))
if day in daily_dates:
raise ValidationError("备份包含重复每日事实")
daily_dates.add(day)
staged.append((self._daily_path(day), fact))
# 原子替换整个数据集:先清空旧数据,再整体写入。
for folder in (self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir):
for path in folder.glob("*"):
if path.is_dir():
for child in path.iterdir():
child.unlink()
path.rmdir()
else:
path.unlink()
for path, value in staged:
self._atomic_write(path, value)
self._atomic_write(self.meta_path, meta)
# 归档是可重建派生数据,导入后重建所有涉及月份的归档。
for day in sorted(daily_dates):
self._rebuild_month(day.year, day.month)
return len(seen_tasks)