- Fix first daily record 422 (expectedVersion now accepts 0)
- Add period_window + summarize; /api/summary and /api/tasks/{id}/summary
aggregate each metric inside its own day/week/month/lifetime window,
reading raw facts only (never the rebuildable month archives)
- Drive frontend rendering from per-page layout JSON with a component
registry; scope/cardinality enforced server-side, invalid layouts fall back
- Add quick checkin and duration input in H:M:S that round-trips integer seconds
- Fix heatmap/completedAt timezone drift to Asia/Shanghai
- Add server-side session TTL (30d) matching the login cookie
- Handle date.min/date.max boundaries without OverflowError
- Half-up rounding to match browser Math.round
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
2.5 KiB
Python
82 lines
2.5 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import secrets
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 # 与登录 Cookie 的 max_age 一致
|
|
|
|
|
|
def make_password_hash(password: str, iterations: int = 310_000) -> str:
|
|
salt = secrets.token_hex(16)
|
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), iterations).hex()
|
|
return f"pbkdf2_sha256${iterations}${salt}${digest}"
|
|
|
|
|
|
def verify_password(password: str, encoded: str) -> bool:
|
|
try:
|
|
algorithm, iterations_text, salt, expected = encoded.split("$", 3)
|
|
if algorithm != "pbkdf2_sha256":
|
|
return False
|
|
digest = hashlib.pbkdf2_hmac(
|
|
"sha256", password.encode(), bytes.fromhex(salt), int(iterations_text)
|
|
).hex()
|
|
return hmac.compare_digest(digest, expected)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
csrf: str
|
|
created_at: float = field(default_factory=time.time)
|
|
|
|
|
|
class SessionManager:
|
|
def __init__(self, password_hash: str | None, ttl_seconds: float = SESSION_TTL_SECONDS):
|
|
self.password_hash = password_hash
|
|
self.ttl_seconds = ttl_seconds
|
|
self.sessions: dict[str, Session] = {}
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return bool(self.password_hash)
|
|
|
|
@classmethod
|
|
def from_environment(cls) -> "SessionManager":
|
|
encoded = os.getenv("PPM_PASSWORD_HASH", "").strip()
|
|
plain = os.getenv("PPM_PASSWORD", "")
|
|
if not encoded and plain:
|
|
encoded = make_password_hash(plain)
|
|
return cls(encoded or None)
|
|
|
|
def _expire(self) -> None:
|
|
cutoff = time.time() - self.ttl_seconds
|
|
expired = [sid for sid, session in self.sessions.items() if session.created_at < cutoff]
|
|
for sid in expired:
|
|
self.sessions.pop(sid, None)
|
|
|
|
def login(self, password: str) -> tuple[str, Session] | None:
|
|
if not self.password_hash or not verify_password(password, self.password_hash):
|
|
return None
|
|
self._expire()
|
|
session_id = secrets.token_urlsafe(32)
|
|
session = Session(csrf=secrets.token_urlsafe(32))
|
|
self.sessions[session_id] = session
|
|
return session_id, session
|
|
|
|
def get(self, session_id: str | None) -> Session | None:
|
|
if not session_id:
|
|
return None
|
|
self._expire()
|
|
return self.sessions.get(session_id)
|
|
|
|
def logout(self, session_id: str | None) -> None:
|
|
if session_id:
|
|
self.sessions.pop(session_id, None)
|
|
|