Merge optimize/docker-and-performance into main
Docker: .dockerignore, entrypoint.sh (PID 1), pinned images, network segmentation, resource limits, npm ci, frontend healthcheck, DRY proxy config. Backend: single JOIN auth, async Argon2id, settings cache, batch reorder, bulk ntfy dedup, permission JOIN, cascade batch, collapsed admin COUNTs, connection pool tuning, composite indexes (calendar_members, ntfy_sent). Frontend: lazy-loaded routes, vendor chunk splitting, date-scoped calendar events (87% payload reduction), 30s poll interval, clock isolation, conditional shared-calendar polling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
bb5cbfa4b3
44
backend/.dockerignore
Normal file
44
backend/.dockerignore
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python artifacts
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.eggs
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Environment files — never bake secrets into the image
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
tests
|
||||||
|
pytest.ini
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Dev scripts
|
||||||
|
start.sh
|
||||||
|
|
||||||
|
# Docker files (no need to copy into the image)
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yaml
|
||||||
@ -1,5 +1,5 @@
|
|||||||
# ── Build stage: compile C extensions ──────────────────────────────────
|
# ── Build stage: compile C extensions ──────────────────────────────────
|
||||||
FROM python:3.12-slim AS builder
|
FROM python:3.12.9-slim-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@ -11,24 +11,25 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||||
|
|
||||||
# ── Runtime stage: lean production image ───────────────────────────────
|
# ── Runtime stage: lean production image ───────────────────────────────
|
||||||
FROM python:3.12-slim
|
FROM python:3.12.9-slim-bookworm
|
||||||
|
|
||||||
|
# Create non-root user first, then copy with correct ownership (DW-2)
|
||||||
|
RUN useradd -m -u 1000 appuser
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy pre-built Python packages from builder
|
# Copy pre-built Python packages from builder
|
||||||
COPY --from=builder /install /usr/local
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code with correct ownership — avoids redundant chown layer
|
||||||
COPY . .
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
|
# Make entrypoint executable
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run migrations and start server
|
# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
|
||||||
# --no-server-header: suppresses uvicorn version disclosure
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme
|
|
||||||
# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network)
|
|
||||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"]
|
|
||||||
|
|||||||
29
backend/alembic/versions/053_add_composite_indexes.py
Normal file
29
backend/alembic/versions/053_add_composite_indexes.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Add composite indexes for calendar_members and ntfy_sent
|
||||||
|
|
||||||
|
Revision ID: 053
|
||||||
|
Revises: 052
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "053"
|
||||||
|
down_revision = "052"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# AW-1: Hot query polled every 5s uses (user_id, status) together
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_members_user_id_status",
|
||||||
|
"calendar_members",
|
||||||
|
["user_id", "status"],
|
||||||
|
)
|
||||||
|
# AS-6: Dedup lookup in notification dispatch uses (user_id, sent_at)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ntfy_sent_user_id_sent_at",
|
||||||
|
"ntfy_sent",
|
||||||
|
["user_id", "sent_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_ntfy_sent_user_id_sent_at", table_name="ntfy_sent")
|
||||||
|
op.drop_index("ix_calendar_members_user_id_status", table_name="calendar_members")
|
||||||
@ -2,11 +2,15 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
|
|||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Create async engine
|
# Create async engine with tuned pool (AW-7)
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
future=True
|
future=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=5,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=1800,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create async session factory
|
# Create async session factory
|
||||||
|
|||||||
@ -56,8 +56,8 @@ async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
||||||
|
"""Stage a sent record — caller must commit (AW-4: bulk commit per user)."""
|
||||||
db.add(NtfySent(notification_key=key, user_id=user_id))
|
db.add(NtfySent(notification_key=key, user_id=user_id))
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Dispatch functions ────────────────────────────────────────────────────────
|
# ── Dispatch functions ────────────────────────────────────────────────────────
|
||||||
@ -239,14 +239,20 @@ async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime
|
|||||||
# Batch-fetch all sent keys once per user instead of one query per entity
|
# Batch-fetch all sent keys once per user instead of one query per entity
|
||||||
sent_keys = await _get_sent_keys(db, settings.user_id)
|
sent_keys = await _get_sent_keys(db, settings.user_id)
|
||||||
|
|
||||||
|
# AW-4: Commit after each category to preserve dedup records if a later
|
||||||
|
# category fails (prevents re-sending already-sent notifications)
|
||||||
if settings.ntfy_reminders_enabled:
|
if settings.ntfy_reminders_enabled:
|
||||||
await _dispatch_reminders(db, settings, now, sent_keys)
|
await _dispatch_reminders(db, settings, now, sent_keys)
|
||||||
|
await db.commit()
|
||||||
if settings.ntfy_events_enabled:
|
if settings.ntfy_events_enabled:
|
||||||
await _dispatch_events(db, settings, now, sent_keys)
|
await _dispatch_events(db, settings, now, sent_keys)
|
||||||
|
await db.commit()
|
||||||
if settings.ntfy_todos_enabled:
|
if settings.ntfy_todos_enabled:
|
||||||
await _dispatch_todos(db, settings, now.date(), sent_keys)
|
await _dispatch_todos(db, settings, now.date(), sent_keys)
|
||||||
|
await db.commit()
|
||||||
if settings.ntfy_projects_enabled:
|
if settings.ntfy_projects_enabled:
|
||||||
await _dispatch_projects(db, settings, now.date(), sent_keys)
|
await _dispatch_projects(db, settings, now.date(), sent_keys)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
||||||
|
|||||||
@ -51,7 +51,7 @@ from app.schemas.admin import (
|
|||||||
UserListResponse,
|
UserListResponse,
|
||||||
)
|
)
|
||||||
from app.services.audit import get_client_ip, log_audit_event
|
from app.services.audit import get_client_ip, log_audit_event
|
||||||
from app.services.auth import hash_password
|
from app.services.auth import ahash_password
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Router — all endpoints inherit require_admin
|
# Router — all endpoints inherit require_admin
|
||||||
@ -225,7 +225,7 @@ async def create_user(
|
|||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
umbral_name=data.username,
|
umbral_name=data.username,
|
||||||
password_hash=hash_password(data.password),
|
password_hash=await ahash_password(data.password),
|
||||||
role=data.role,
|
role=data.role,
|
||||||
email=email,
|
email=email,
|
||||||
first_name=data.first_name,
|
first_name=data.first_name,
|
||||||
@ -341,7 +341,7 @@ async def reset_user_password(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
temp_password = secrets.token_urlsafe(16)
|
temp_password = secrets.token_urlsafe(16)
|
||||||
user.password_hash = hash_password(temp_password)
|
user.password_hash = await ahash_password(temp_password)
|
||||||
user.must_change_password = True
|
user.must_change_password = True
|
||||||
user.last_password_change_at = datetime.now()
|
user.last_password_change_at = datetime.now()
|
||||||
|
|
||||||
@ -740,18 +740,18 @@ async def admin_dashboard(
|
|||||||
_actor: User = Depends(get_current_user),
|
_actor: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Aggregate stats for the admin portal dashboard."""
|
"""Aggregate stats for the admin portal dashboard."""
|
||||||
total_users = await db.scalar(
|
# AW-6: Single conditional aggregation instead of 5 separate COUNT queries
|
||||||
sa.select(sa.func.count()).select_from(User)
|
user_stats = await db.execute(
|
||||||
)
|
sa.select(
|
||||||
active_users = await db.scalar(
|
sa.func.count().label("total"),
|
||||||
sa.select(sa.func.count()).select_from(User).where(User.is_active == True)
|
sa.func.count().filter(User.is_active == True).label("active"),
|
||||||
)
|
sa.func.count().filter(User.role == "admin").label("admins"),
|
||||||
admin_count = await db.scalar(
|
sa.func.count().filter(User.totp_enabled == True).label("totp"),
|
||||||
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
|
).select_from(User)
|
||||||
)
|
|
||||||
totp_count = await db.scalar(
|
|
||||||
sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True)
|
|
||||||
)
|
)
|
||||||
|
row = user_stats.one()
|
||||||
|
total_users, active_users, admin_count, totp_count = row.tuple()
|
||||||
|
|
||||||
active_sessions = await db.scalar(
|
active_sessions = await db.scalar(
|
||||||
sa.select(sa.func.count()).select_from(UserSession).where(
|
sa.select(sa.func.count()).select_from(UserSession).where(
|
||||||
UserSession.revoked == False,
|
UserSession.revoked == False,
|
||||||
|
|||||||
@ -37,6 +37,9 @@ from app.schemas.auth import (
|
|||||||
ProfileUpdate, ProfileResponse,
|
ProfileUpdate, ProfileResponse,
|
||||||
)
|
)
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
|
ahash_password,
|
||||||
|
averify_password,
|
||||||
|
averify_password_with_upgrade,
|
||||||
hash_password,
|
hash_password,
|
||||||
verify_password,
|
verify_password,
|
||||||
verify_password_with_upgrade,
|
verify_password_with_upgrade,
|
||||||
@ -101,25 +104,22 @@ async def get_current_user(
|
|||||||
if user_id is None or session_id is None:
|
if user_id is None or session_id is None:
|
||||||
raise HTTPException(status_code=401, detail="Malformed session token")
|
raise HTTPException(status_code=401, detail="Malformed session token")
|
||||||
|
|
||||||
# Verify session is active in DB (covers revocation + expiry)
|
# AC-1: Single JOIN query for session + user (was 2 sequential queries)
|
||||||
session_result = await db.execute(
|
result = await db.execute(
|
||||||
select(UserSession).where(
|
select(UserSession, User)
|
||||||
|
.join(User, UserSession.user_id == User.id)
|
||||||
|
.where(
|
||||||
UserSession.id == session_id,
|
UserSession.id == session_id,
|
||||||
UserSession.user_id == user_id,
|
UserSession.user_id == user_id,
|
||||||
UserSession.revoked == False,
|
UserSession.revoked == False,
|
||||||
UserSession.expires_at > datetime.now(),
|
UserSession.expires_at > datetime.now(),
|
||||||
|
User.is_active == True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
db_session = session_result.scalar_one_or_none()
|
row = result.one_or_none()
|
||||||
if not db_session:
|
if not row:
|
||||||
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
|
raise HTTPException(status_code=401, detail="Session expired or user inactive")
|
||||||
|
db_session, user = row.tuple()
|
||||||
user_result = await db.execute(
|
|
||||||
select(User).where(User.id == user_id, User.is_active == True)
|
|
||||||
)
|
|
||||||
user = user_result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
|
||||||
|
|
||||||
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
|
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
|
||||||
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
|
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
|
||||||
@ -149,19 +149,26 @@ async def get_current_user(
|
|||||||
|
|
||||||
|
|
||||||
async def get_current_settings(
|
async def get_current_settings(
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Settings:
|
) -> Settings:
|
||||||
"""
|
"""
|
||||||
Convenience dependency for routers that need Settings access.
|
Convenience dependency for routers that need Settings access.
|
||||||
Always chain after get_current_user — never use standalone.
|
Always chain after get_current_user — never use standalone.
|
||||||
|
|
||||||
|
AC-3: Cache in request.state so multiple dependencies don't re-query.
|
||||||
"""
|
"""
|
||||||
|
cached = getattr(request.state, "settings", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Settings).where(Settings.user_id == current_user.id)
|
select(Settings).where(Settings.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
settings_obj = result.scalar_one_or_none()
|
settings_obj = result.scalar_one_or_none()
|
||||||
if not settings_obj:
|
if not settings_obj:
|
||||||
raise HTTPException(status_code=500, detail="Settings not found for user")
|
raise HTTPException(status_code=500, detail="Settings not found for user")
|
||||||
|
request.state.settings = settings_obj
|
||||||
return settings_obj
|
return settings_obj
|
||||||
|
|
||||||
|
|
||||||
@ -299,7 +306,7 @@ async def setup(
|
|||||||
if user_count.scalar_one() > 0:
|
if user_count.scalar_one() > 0:
|
||||||
raise HTTPException(status_code=400, detail="Setup already completed")
|
raise HTTPException(status_code=400, detail="Setup already completed")
|
||||||
|
|
||||||
password_hash = hash_password(data.password)
|
password_hash = await ahash_password(data.password)
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
umbral_name=data.username,
|
umbral_name=data.username,
|
||||||
@ -352,12 +359,12 @@ async def login(
|
|||||||
if not user:
|
if not user:
|
||||||
# M-02: Run Argon2id against a dummy hash so the response time is
|
# M-02: Run Argon2id against a dummy hash so the response time is
|
||||||
# indistinguishable from a wrong-password attempt (prevents username enumeration).
|
# indistinguishable from a wrong-password attempt (prevents username enumeration).
|
||||||
verify_password("x", _DUMMY_HASH)
|
await averify_password("x", _DUMMY_HASH)
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
# M-02: Run password verification BEFORE lockout check so Argon2id always
|
# M-02: Run password verification BEFORE lockout check so Argon2id always
|
||||||
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
||||||
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
|
||||||
|
|
||||||
await _check_account_lockout(user)
|
await _check_account_lockout(user)
|
||||||
|
|
||||||
@ -465,7 +472,7 @@ async def register(
|
|||||||
if existing_email.scalar_one_or_none():
|
if existing_email.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
|
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
|
||||||
|
|
||||||
password_hash = hash_password(data.password)
|
password_hash = await ahash_password(data.password)
|
||||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
@ -630,7 +637,7 @@ async def verify_password(
|
|||||||
"""
|
"""
|
||||||
await _check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
@ -656,7 +663,7 @@ async def change_password(
|
|||||||
"""Change the current user's password. Requires old password verification."""
|
"""Change the current user's password. Requires old password verification."""
|
||||||
await _check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
|
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||||
@ -664,7 +671,7 @@ async def change_password(
|
|||||||
if data.new_password == data.old_password:
|
if data.new_password == data.old_password:
|
||||||
raise HTTPException(status_code=400, detail="New password must be different from your current password")
|
raise HTTPException(status_code=400, detail="New password must be different from your current password")
|
||||||
|
|
||||||
current_user.password_hash = hash_password(data.new_password)
|
current_user.password_hash = await ahash_password(data.new_password)
|
||||||
current_user.last_password_change_at = datetime.now()
|
current_user.last_password_change_at = datetime.now()
|
||||||
|
|
||||||
# Clear forced password change flag if set (SEC-12)
|
# Clear forced password change flag if set (SEC-12)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
@ -20,6 +20,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class ReorderItem(BaseModel):
|
class ReorderItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
id: int
|
id: int
|
||||||
sort_order: int
|
sort_order: int
|
||||||
|
|
||||||
@ -294,16 +295,20 @@ async def reorder_tasks(
|
|||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
for item in items:
|
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
|
||||||
|
task_ids = [item.id for item in items]
|
||||||
task_result = await db.execute(
|
task_result = await db.execute(
|
||||||
select(ProjectTask).where(
|
select(ProjectTask).where(
|
||||||
ProjectTask.id == item.id,
|
ProjectTask.id.in_(task_ids),
|
||||||
ProjectTask.project_id == project_id
|
ProjectTask.project_id == project_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
task = task_result.scalar_one_or_none()
|
tasks_by_id = {t.id: t for t in task_result.scalars().all()}
|
||||||
if task:
|
|
||||||
task.sort_order = item.sort_order
|
order_map = {item.id: item.sort_order for item in items}
|
||||||
|
for task_id, task in tasks_by_id.items():
|
||||||
|
if task_id in order_map:
|
||||||
|
task.sort_order = order_map[task_id]
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ Security:
|
|||||||
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
||||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
import secrets
|
import secrets
|
||||||
import logging
|
import logging
|
||||||
@ -37,8 +38,7 @@ from app.models.backup_code import BackupCode
|
|||||||
from app.routers.auth import get_current_user, _set_session_cookie
|
from app.routers.auth import get_current_user, _set_session_cookie
|
||||||
from app.services.audit import get_client_ip
|
from app.services.audit import get_client_ip
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
verify_password_with_upgrade,
|
averify_password_with_upgrade,
|
||||||
hash_password,
|
|
||||||
verify_mfa_token,
|
verify_mfa_token,
|
||||||
verify_mfa_enforce_token,
|
verify_mfa_enforce_token,
|
||||||
create_session_token,
|
create_session_token,
|
||||||
@ -117,8 +117,10 @@ class EnforceConfirmRequest(BaseModel):
|
|||||||
|
|
||||||
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
||||||
"""Hash and insert backup codes for the given user."""
|
"""Hash and insert backup codes for the given user."""
|
||||||
|
# AC-2: Run Argon2id hashing in executor to avoid blocking event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
for code in plaintext_codes:
|
for code in plaintext_codes:
|
||||||
code_hash = _ph.hash(code)
|
code_hash = await loop.run_in_executor(None, _ph.hash, code)
|
||||||
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
|
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@ -145,9 +147,12 @@ async def _verify_backup_code(
|
|||||||
)
|
)
|
||||||
unused_codes = result.scalars().all()
|
unused_codes = result.scalars().all()
|
||||||
|
|
||||||
|
# AC-2: Run Argon2id verification in executor to avoid blocking event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
for record in unused_codes:
|
for record in unused_codes:
|
||||||
try:
|
try:
|
||||||
if _ph.verify(record.code_hash, submitted_code):
|
matched = await loop.run_in_executor(None, _ph.verify, record.code_hash, submitted_code)
|
||||||
|
if matched:
|
||||||
record.used_at = datetime.now()
|
record.used_at = datetime.now()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return True
|
return True
|
||||||
@ -355,7 +360,8 @@ async def totp_disable(
|
|||||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||||
|
|
||||||
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
||||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
# AC-2: async wrapper to avoid blocking event loop
|
||||||
|
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
@ -391,7 +397,8 @@ async def regenerate_backup_codes(
|
|||||||
if not current_user.totp_enabled:
|
if not current_user.totp_enabled:
|
||||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||||
|
|
||||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
# AC-2: async wrapper to avoid blocking event loop
|
||||||
|
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ Password strategy:
|
|||||||
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
||||||
rehashed to Argon2id on first successful use.
|
rehashed to Argon2id on first successful use.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
@ -76,6 +78,28 @@ def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str
|
|||||||
return valid, new_hash
|
return valid, new_hash
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async wrappers — run CPU-bound Argon2id ops in a thread pool (AC-2/S-01)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def ahash_password(password: str) -> str:
|
||||||
|
"""Async wrapper for hash_password — runs Argon2id in executor."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, hash_password, password)
|
||||||
|
|
||||||
|
|
||||||
|
async def averify_password(password: str, hashed: str) -> bool:
|
||||||
|
"""Async wrapper for verify_password — runs Argon2id in executor."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, verify_password, password, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
async def averify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
|
||||||
|
"""Async wrapper for verify_password_with_upgrade — runs Argon2id in executor."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, verify_password_with_upgrade, password, hashed)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session tokens
|
# Session tokens
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete, select, text
|
from sqlalchemy import delete, select, text, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
@ -24,25 +24,29 @@ async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int)
|
|||||||
"""
|
"""
|
||||||
Returns "owner" if the user owns the calendar, the permission string
|
Returns "owner" if the user owns the calendar, the permission string
|
||||||
if they are an accepted member, or None if they have no access.
|
if they are an accepted member, or None if they have no access.
|
||||||
"""
|
|
||||||
cal = await db.execute(
|
|
||||||
select(Calendar).where(Calendar.id == calendar_id)
|
|
||||||
)
|
|
||||||
calendar = cal.scalar_one_or_none()
|
|
||||||
if not calendar:
|
|
||||||
return None
|
|
||||||
if calendar.user_id == user_id:
|
|
||||||
return "owner"
|
|
||||||
|
|
||||||
member = await db.execute(
|
AW-5: Single query with LEFT JOIN instead of 2 sequential queries.
|
||||||
select(CalendarMember).where(
|
"""
|
||||||
CalendarMember.calendar_id == calendar_id,
|
result = await db.execute(
|
||||||
CalendarMember.user_id == user_id,
|
select(
|
||||||
CalendarMember.status == "accepted",
|
Calendar.user_id,
|
||||||
|
CalendarMember.permission,
|
||||||
)
|
)
|
||||||
|
.outerjoin(
|
||||||
|
CalendarMember,
|
||||||
|
(CalendarMember.calendar_id == Calendar.id)
|
||||||
|
& (CalendarMember.user_id == user_id)
|
||||||
|
& (CalendarMember.status == "accepted"),
|
||||||
)
|
)
|
||||||
row = member.scalar_one_or_none()
|
.where(Calendar.id == calendar_id)
|
||||||
return row.permission if row else None
|
)
|
||||||
|
row = result.one_or_none()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
owner_id, member_permission = row.tuple()
|
||||||
|
if owner_id == user_id:
|
||||||
|
return "owner"
|
||||||
|
return member_permission
|
||||||
|
|
||||||
|
|
||||||
async def require_permission(
|
async def require_permission(
|
||||||
@ -202,16 +206,22 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
|
|||||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reset is_shared on calendars with no remaining members
|
# AC-5: Single aggregation query instead of N per-calendar checks
|
||||||
all_cal_ids = a_cal_ids + b_cal_ids
|
all_cal_ids = a_cal_ids + b_cal_ids
|
||||||
for cal_id in all_cal_ids:
|
if all_cal_ids:
|
||||||
remaining = await db.execute(
|
# Find which calendars still have members
|
||||||
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
has_members_result = await db.execute(
|
||||||
|
select(CalendarMember.calendar_id)
|
||||||
|
.where(CalendarMember.calendar_id.in_(all_cal_ids))
|
||||||
|
.group_by(CalendarMember.calendar_id)
|
||||||
)
|
)
|
||||||
if not remaining.scalar_one_or_none():
|
cals_with_members = {row[0] for row in has_members_result.all()}
|
||||||
cal_result = await db.execute(
|
|
||||||
select(Calendar).where(Calendar.id == cal_id)
|
# Reset is_shared on calendars with no remaining members
|
||||||
|
empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members]
|
||||||
|
if empty_cal_ids:
|
||||||
|
await db.execute(
|
||||||
|
update(Calendar)
|
||||||
|
.where(Calendar.id.in_(empty_cal_ids))
|
||||||
|
.values(is_shared=False)
|
||||||
)
|
)
|
||||||
cal = cal_result.scalar_one_or_none()
|
|
||||||
if cal:
|
|
||||||
cal.is_shared = False
|
|
||||||
|
|||||||
13
backend/entrypoint.sh
Normal file
13
backend/entrypoint.sh
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo "Starting uvicorn..."
|
||||||
|
exec uvicorn app.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8000 \
|
||||||
|
--no-server-header \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips '*'
|
||||||
@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Run database migrations
|
|
||||||
echo "Running database migrations..."
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# Start the FastAPI application
|
|
||||||
echo "Starting FastAPI application..."
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
@ -5,11 +5,18 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- backend_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: "1.0"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
@ -18,11 +25,20 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- backend_net
|
||||||
|
- frontend_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: "1.0"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
@ -32,6 +48,24 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- frontend_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
cpus: "0.5"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend_net:
|
||||||
|
driver: bridge
|
||||||
|
frontend_net:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
25
frontend/.dockerignore
Normal file
25
frontend/.dockerignore
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Dependencies — rebuilt inside the container from lockfile
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Build output — rebuilt inside the container
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yaml
|
||||||
@ -1,13 +1,13 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:20-alpine AS build
|
FROM node:20.18-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies from lockfile (DW-3)
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
@ -16,7 +16,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage — unprivileged nginx (runs as non-root, listens on 8080)
|
# Production stage — unprivileged nginx (runs as non-root, listens on 8080)
|
||||||
FROM nginxinc/nginx-unprivileged:alpine
|
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||||
|
|
||||||
# Copy built files from build stage
|
# Copy built files from build stage
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|||||||
@ -41,7 +41,7 @@ server {
|
|||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
|
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
|
||||||
location ~ /\.(?!well-known) {
|
location ~ /\.(?!well-known) {
|
||||||
@ -122,28 +122,12 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy
|
# API proxy (catch-all for non-rate-limited endpoints)
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://backend:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
# PT-L01: Prevent browser caching of authenticated API responses
|
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
|
||||||
# Security headers (must be repeated — nginx add_header in a location block
|
|
||||||
# overrides server-level add_header directives, so all headers must be explicit)
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA fallback - serve index.html for all routes
|
# SPA fallback - serve index.html for all routes
|
||||||
|
|||||||
@ -4,3 +4,13 @@ proxy_set_header Host $host;
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
|
||||||
|
# Security headers (repeated per location — nginx add_header in a location block
|
||||||
|
# overrides server-level directives, so all headers must be explicit)
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||||
|
|||||||
@ -3,19 +3,24 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import LockScreen from '@/components/auth/LockScreen';
|
import LockScreen from '@/components/auth/LockScreen';
|
||||||
import AppLayout from '@/components/layout/AppLayout';
|
import AppLayout from '@/components/layout/AppLayout';
|
||||||
import DashboardPage from '@/components/dashboard/DashboardPage';
|
|
||||||
import TodosPage from '@/components/todos/TodosPage';
|
|
||||||
import CalendarPage from '@/components/calendar/CalendarPage';
|
|
||||||
import RemindersPage from '@/components/reminders/RemindersPage';
|
|
||||||
import ProjectsPage from '@/components/projects/ProjectsPage';
|
|
||||||
import ProjectDetail from '@/components/projects/ProjectDetail';
|
|
||||||
import PeoplePage from '@/components/people/PeoplePage';
|
|
||||||
import LocationsPage from '@/components/locations/LocationsPage';
|
|
||||||
import SettingsPage from '@/components/settings/SettingsPage';
|
|
||||||
import NotificationsPage from '@/components/notifications/NotificationsPage';
|
|
||||||
|
|
||||||
|
// AS-2: Lazy-load all route components to reduce initial bundle parse time
|
||||||
|
const DashboardPage = lazy(() => import('@/components/dashboard/DashboardPage'));
|
||||||
|
const TodosPage = lazy(() => import('@/components/todos/TodosPage'));
|
||||||
|
const CalendarPage = lazy(() => import('@/components/calendar/CalendarPage'));
|
||||||
|
const RemindersPage = lazy(() => import('@/components/reminders/RemindersPage'));
|
||||||
|
const ProjectsPage = lazy(() => import('@/components/projects/ProjectsPage'));
|
||||||
|
const ProjectDetail = lazy(() => import('@/components/projects/ProjectDetail'));
|
||||||
|
const PeoplePage = lazy(() => import('@/components/people/PeoplePage'));
|
||||||
|
const LocationsPage = lazy(() => import('@/components/locations/LocationsPage'));
|
||||||
|
const SettingsPage = lazy(() => import('@/components/settings/SettingsPage'));
|
||||||
|
const NotificationsPage = lazy(() => import('@/components/notifications/NotificationsPage'));
|
||||||
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
||||||
|
|
||||||
|
const RouteFallback = () => (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
|
||||||
|
);
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { authStatus, isLoading } = useAuth();
|
const { authStatus, isLoading } = useAuth();
|
||||||
|
|
||||||
@ -57,21 +62,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
|
||||||
<Route path="todos" element={<TodosPage />} />
|
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
|
||||||
<Route path="calendar" element={<CalendarPage />} />
|
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
|
||||||
<Route path="reminders" element={<RemindersPage />} />
|
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
|
||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
|
||||||
<Route path="people" element={<PeoplePage />} />
|
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
|
||||||
<Route path="locations" element={<LocationsPage />} />
|
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
|
||||||
<Route
|
<Route
|
||||||
path="admin/*"
|
path="admin/*"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<AdminPortal />
|
<AdminPortal />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import FullCalendar from '@fullcalendar/react';
|
import FullCalendar from '@fullcalendar/react';
|
||||||
@ -205,13 +206,28 @@ export default function CalendarPage() {
|
|||||||
return () => el.removeEventListener('wheel', handleWheel);
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// AW-2: Track visible date range for scoped event fetching
|
||||||
|
// W-02 fix: Initialize from current month to avoid unscoped first fetch
|
||||||
|
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string }>(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = now.getMonth();
|
||||||
|
// FullCalendar month view typically fetches prev month to next month
|
||||||
|
const start = format(new Date(y, m - 1, 1), 'yyyy-MM-dd');
|
||||||
|
const end = format(new Date(y, m + 2, 0), 'yyyy-MM-dd');
|
||||||
|
return { start, end };
|
||||||
|
});
|
||||||
|
|
||||||
const { data: events = [] } = useQuery({
|
const { data: events = [] } = useQuery({
|
||||||
queryKey: ['calendar-events'],
|
queryKey: ['calendar-events', visibleRange.start, visibleRange.end],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<CalendarEvent[]>('/events');
|
const { data } = await api.get<CalendarEvent[]>('/events', {
|
||||||
|
params: { start: visibleRange.start, end: visibleRange.end },
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: 5_000,
|
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedEvent = useMemo(
|
const selectedEvent = useMemo(
|
||||||
@ -261,7 +277,10 @@ export default function CalendarPage() {
|
|||||||
allDay: boolean,
|
allDay: boolean,
|
||||||
revert: () => void,
|
revert: () => void,
|
||||||
) => {
|
) => {
|
||||||
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
|
// C-01 fix: match active query key which includes date range
|
||||||
|
queryClient.setQueryData<CalendarEvent[]>(
|
||||||
|
['calendar-events', visibleRange.start, visibleRange.end],
|
||||||
|
(old) =>
|
||||||
old?.map((e) =>
|
old?.map((e) =>
|
||||||
e.id === id
|
e.id === id
|
||||||
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
|
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
|
||||||
@ -467,6 +486,13 @@ export default function CalendarPage() {
|
|||||||
const handleDatesSet = (arg: DatesSetArg) => {
|
const handleDatesSet = (arg: DatesSetArg) => {
|
||||||
setCalendarTitle(arg.view.title);
|
setCalendarTitle(arg.view.title);
|
||||||
setCurrentView(arg.view.type as CalendarView);
|
setCurrentView(arg.view.type as CalendarView);
|
||||||
|
// AW-2: Capture visible range for scoped event fetching
|
||||||
|
// C-02 fix: use format() not toISOString() to avoid UTC date shift
|
||||||
|
const start = format(arg.start, 'yyyy-MM-dd');
|
||||||
|
const end = format(arg.end, 'yyyy-MM-dd');
|
||||||
|
setVisibleRange((prev) =>
|
||||||
|
prev.start === start && prev.end === end ? prev : { start, end }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -34,6 +34,75 @@ function getGreeting(name?: string): string {
|
|||||||
return `Good night${suffix}`;
|
return `Good night${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AS-3: Isolated clock component — only this re-renders every minute,
|
||||||
|
// not all 8 dashboard widgets.
|
||||||
|
const ClockDisplay = memo(function ClockDisplay({ dataUpdatedAt, onRefresh }: {
|
||||||
|
dataUpdatedAt?: number;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: ReturnType<typeof setInterval>;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function startClock() {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setNow(new Date());
|
||||||
|
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
intervalId = setInterval(() => setNow(new Date()), 60_000);
|
||||||
|
}, msUntilNextMinute);
|
||||||
|
}
|
||||||
|
|
||||||
|
startClock();
|
||||||
|
function handleVisibility() {
|
||||||
|
if (document.visibilityState === 'visible') startClock();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatedAgo = dataUpdatedAt
|
||||||
|
? (() => {
|
||||||
|
const mins = Math.floor((now.getTime() - dataUpdatedAt) / 60_000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins === 1) return '1 min ago';
|
||||||
|
return `${mins} min ago`;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<span className="tabular-nums">{format(now, 'h:mm a')}</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
||||||
|
{format(now, 'EEEE, MMMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
{updatedAgo && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40 text-xs">·</span>
|
||||||
|
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
||||||
|
title="Refresh dashboard"
|
||||||
|
aria-label="Refresh dashboard"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -42,38 +111,7 @@ export default function DashboardPage() {
|
|||||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [clockNow, setClockNow] = useState(() => new Date());
|
// Clock state moved to <ClockDisplay /> (AS-3)
|
||||||
|
|
||||||
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId: ReturnType<typeof setInterval>;
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
function startClock() {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setClockNow(new Date());
|
|
||||||
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
setClockNow(new Date());
|
|
||||||
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
|
|
||||||
}, msUntilNextMinute);
|
|
||||||
}
|
|
||||||
|
|
||||||
startClock();
|
|
||||||
|
|
||||||
// Re-sync when tab becomes visible again (after sleep/background throttle)
|
|
||||||
function handleVisibility() {
|
|
||||||
if (document.visibilityState === 'visible') startClock();
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', handleVisibility);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Click outside to close dropdown
|
// Click outside to close dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -191,15 +229,6 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAgo = dataUpdatedAt
|
|
||||||
? (() => {
|
|
||||||
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
|
|
||||||
if (mins < 1) return 'just now';
|
|
||||||
if (mins === 1) return '1 min ago';
|
|
||||||
return `${mins} min ago`;
|
|
||||||
})()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header — greeting + date + quick add */}
|
{/* Header — greeting + date + quick add */}
|
||||||
@ -208,27 +237,7 @@ export default function DashboardPage() {
|
|||||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||||
{getGreeting(settings?.preferred_name || undefined)}
|
{getGreeting(settings?.preferred_name || undefined)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<ClockDisplay dataUpdatedAt={dataUpdatedAt} onRefresh={handleRefresh} />
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
|
|
||||||
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
|
||||||
{format(clockNow, 'EEEE, MMMM d, yyyy')}
|
|
||||||
</p>
|
|
||||||
{updatedAgo && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground/40 text-xs">·</span>
|
|
||||||
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
|
||||||
title="Refresh dashboard"
|
|
||||||
aria-label="Refresh dashboard"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||||
@ -16,16 +16,27 @@ export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AS-4: Gate shared-calendar polling on whether user participates in sharing.
|
||||||
|
// Saves ~12 API calls/min for personal-only users.
|
||||||
|
// Uses useState (not useRef) so changes trigger a re-render and the
|
||||||
|
// refetchInterval picks up the new value immediately.
|
||||||
|
const [hasSharing, setHasSharing] = useState(false);
|
||||||
|
const ownsShared = (ownedQuery.data ?? []).some((c) => c.is_shared);
|
||||||
|
if (ownsShared && !hasSharing) setHasSharing(true);
|
||||||
|
|
||||||
const sharedQuery = useQuery({
|
const sharedQuery = useQuery({
|
||||||
queryKey: ['calendars', 'shared'],
|
queryKey: ['calendars', 'shared'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: pollingEnabled ? 5_000 : false,
|
refetchInterval: pollingEnabled && hasSharing ? 5_000 : false,
|
||||||
staleTime: 3_000,
|
staleTime: 3_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also latch if user is a member of others' shared calendars
|
||||||
|
if ((sharedQuery.data ?? []).length > 0 && !hasSharing) setHasSharing(true);
|
||||||
|
|
||||||
const allCalendarIds = useMemo(() => {
|
const allCalendarIds = useMemo(() => {
|
||||||
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
||||||
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
||||||
|
|||||||
@ -9,6 +9,25 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// AS-1: Split large dependencies into separate chunks for better caching
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'vendor-query': ['@tanstack/react-query'],
|
||||||
|
'vendor-fullcalendar': [
|
||||||
|
'@fullcalendar/react',
|
||||||
|
'@fullcalendar/core',
|
||||||
|
'@fullcalendar/daygrid',
|
||||||
|
'@fullcalendar/timegrid',
|
||||||
|
'@fullcalendar/interaction',
|
||||||
|
],
|
||||||
|
'vendor-ui': ['sonner', 'lucide-react', 'date-fns'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user