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

@@ -2,7 +2,7 @@ from datetime import date, timedelta
from app.auth import SessionManager, make_password_hash
from app.models import DailyRecordInput, MetricValue, TaskInput
from app.repository import ConflictError, JsonRepository, period_window
from app.repository import ConflictError, JsonRepository, ValidationError, period_window
def task_payload():
@@ -180,3 +180,76 @@ def test_session_ttl_keeps_fresh_sessions():
session_id, session = manager.login("secret")
session.created_at -= 5
assert manager.get(session_id) is session
def test_import_replaces_stale_files(tmp_path):
repo = JsonRepository(tmp_path)
first = repo.create_task(task_payload())
second = repo.create_task(task_payload().model_copy(update={"name": "多出来的任务"}))
assert repo.list_tasks()["items"] and len(repo.list_tasks()["items"]) == 2
# 用只含第一个任务的备份整体替换,第二个任务应被清除。
export = repo.export_all()
export["tasks"] = [first]
export["revisions"] = [rev for rev in export["revisions"] if rev["taskId"] == first["taskId"]]
export["daily"] = []
repo.import_all(export)
remaining = repo.list_tasks(include_deleted=True)["items"]
assert [task["taskId"] for task in remaining] == [first["taskId"]]
def test_import_rejects_path_traversal(tmp_path):
repo = JsonRepository(tmp_path)
payload = repo.export_all()
payload["tasks"] = [{"taskId": "../evil", "schemaVersion": 1, "name": "x", "version": 1, "revision": 1, "createdAt": "x", "updatedAt": "x", "deletedAt": None, "metrics": []}]
try:
repo.import_all(payload)
except ValidationError:
pass
else:
raise AssertionError("路径逃逸的 taskId 应被拒绝")
def test_import_rebuilds_archives(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
metric_id = task["metrics"][0]["metricId"]
repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=1)]))
export = repo.export_all()
# 清空归档目录,模拟“从未归档”的目标状态。
for path in (tmp_path / "archive").glob("*/*.json"):
path.unlink()
assert list((tmp_path / "archive").glob("*/*.json")) == []
repo.import_all(export)
assert list((tmp_path / "archive").glob("*/*.json")), "导入后应重建归档"
def test_session_manager_fail_secure(monkeypatch):
from app.auth import SessionManager
# 清掉所有相关环境变量后,from_environment 应拒绝启动
for key in ("PPM_PASSWORD", "PPM_PASSWORD_HASH", "PPM_ALLOW_NO_AUTH"):
monkeypatch.delenv(key, raising=False)
try:
SessionManager.from_environment()
except RuntimeError:
pass
else:
raise AssertionError("未配置密码时应 fail-secure 拒绝启动")
def test_session_manager_allow_no_auth(monkeypatch):
from app.auth import SessionManager
monkeypatch.delenv("PPM_PASSWORD", raising=False)
monkeypatch.delenv("PPM_PASSWORD_HASH", raising=False)
monkeypatch.setenv("PPM_ALLOW_NO_AUTH", "1")
assert SessionManager.from_environment().enabled is False
def test_login_rate_limiting():
from app.auth import SessionManager
manager = SessionManager(make_password_hash("secret"), max_failures=3, lockout_seconds=60)
for _ in range(3):
assert manager.login("wrong", "1.2.3.4") is None
assert manager.is_login_locked("1.2.3.4") is True
# 正确密码在锁定期也应被拒
assert manager.login("secret", "1.2.3.4") is None