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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user