diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..6662571 --- /dev/null +++ b/backend/.dockerignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index c80a749..b58fc52 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # ── Build stage: compile C extensions ────────────────────────────────── -FROM python:3.12-slim AS builder +FROM python:3.12.9-slim-bookworm AS builder WORKDIR /build @@ -11,24 +11,25 @@ COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # ── 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 # Copy pre-built Python packages from builder COPY --from=builder /install /usr/local -# Copy application code -COPY . . +# Copy application code with correct ownership — avoids redundant chown layer +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 EXPOSE 8000 -# Run migrations and start server -# --no-server-header: suppresses uvicorn version disclosure -# --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 '*'"] +# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5) +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/alembic/versions/053_add_composite_indexes.py b/backend/alembic/versions/053_add_composite_indexes.py new file mode 100644 index 0000000..758d280 --- /dev/null +++ b/backend/alembic/versions/053_add_composite_indexes.py @@ -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") diff --git a/backend/app/database.py b/backend/app/database.py index 1743bc1..39142db 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -2,11 +2,15 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess from sqlalchemy.orm import declarative_base from app.config import settings -# Create async engine +# Create async engine with tuned pool (AW-7) engine = create_async_engine( settings.DATABASE_URL, echo=False, - future=True + future=True, + pool_size=10, + max_overflow=5, + pool_pre_ping=True, + pool_recycle=1800, ) # Create async session factory diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index 7798f0c..a09e4d5 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -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: + """Stage a sent record — caller must commit (AW-4: bulk commit per user).""" db.add(NtfySent(notification_key=key, user_id=user_id)) - await db.commit() # ── 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 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: await _dispatch_reminders(db, settings, now, sent_keys) + await db.commit() if settings.ntfy_events_enabled: await _dispatch_events(db, settings, now, sent_keys) + await db.commit() if settings.ntfy_todos_enabled: await _dispatch_todos(db, settings, now.date(), sent_keys) + await db.commit() if settings.ntfy_projects_enabled: await _dispatch_projects(db, settings, now.date(), sent_keys) + await db.commit() async def _purge_old_sent_records(db: AsyncSession) -> None: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index d1357f5..8e9feef 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -51,7 +51,7 @@ from app.schemas.admin import ( UserListResponse, ) 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 @@ -225,7 +225,7 @@ async def create_user( new_user = User( username=data.username, umbral_name=data.username, - password_hash=hash_password(data.password), + password_hash=await ahash_password(data.password), role=data.role, email=email, first_name=data.first_name, @@ -341,7 +341,7 @@ async def reset_user_password( raise HTTPException(status_code=404, detail="User not found") 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.last_password_change_at = datetime.now() @@ -740,18 +740,18 @@ async def admin_dashboard( _actor: User = Depends(get_current_user), ): """Aggregate stats for the admin portal dashboard.""" - total_users = await db.scalar( - sa.select(sa.func.count()).select_from(User) - ) - active_users = await db.scalar( - sa.select(sa.func.count()).select_from(User).where(User.is_active == True) - ) - admin_count = await db.scalar( - sa.select(sa.func.count()).select_from(User).where(User.role == "admin") - ) - totp_count = await db.scalar( - sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True) + # AW-6: Single conditional aggregation instead of 5 separate COUNT queries + user_stats = await db.execute( + sa.select( + sa.func.count().label("total"), + sa.func.count().filter(User.is_active == True).label("active"), + sa.func.count().filter(User.role == "admin").label("admins"), + sa.func.count().filter(User.totp_enabled == True).label("totp"), + ).select_from(User) ) + row = user_stats.one() + total_users, active_users, admin_count, totp_count = row.tuple() + active_sessions = await db.scalar( sa.select(sa.func.count()).select_from(UserSession).where( UserSession.revoked == False, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0e9c847..6a85c6b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -37,6 +37,9 @@ from app.schemas.auth import ( ProfileUpdate, ProfileResponse, ) from app.services.auth import ( + ahash_password, + averify_password, + averify_password_with_upgrade, hash_password, verify_password, verify_password_with_upgrade, @@ -101,25 +104,22 @@ async def get_current_user( if user_id is None or session_id is None: raise HTTPException(status_code=401, detail="Malformed session token") - # Verify session is active in DB (covers revocation + expiry) - session_result = await db.execute( - select(UserSession).where( + # AC-1: Single JOIN query for session + user (was 2 sequential queries) + result = await db.execute( + select(UserSession, User) + .join(User, UserSession.user_id == User.id) + .where( UserSession.id == session_id, UserSession.user_id == user_id, UserSession.revoked == False, UserSession.expires_at > datetime.now(), + User.is_active == True, ) ) - db_session = session_result.scalar_one_or_none() - if not db_session: - raise HTTPException(status_code=401, detail="Session has been revoked or expired") - - 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") + row = result.one_or_none() + if not row: + raise HTTPException(status_code=401, detail="Session expired or user inactive") + db_session, user = row.tuple() # 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). @@ -149,19 +149,26 @@ async def get_current_user( async def get_current_settings( + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Settings: """ Convenience dependency for routers that need Settings access. 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( select(Settings).where(Settings.user_id == current_user.id) ) settings_obj = result.scalar_one_or_none() if not settings_obj: raise HTTPException(status_code=500, detail="Settings not found for user") + request.state.settings = settings_obj return settings_obj @@ -299,7 +306,7 @@ async def setup( if user_count.scalar_one() > 0: 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( username=data.username, umbral_name=data.username, @@ -352,12 +359,12 @@ async def login( if not user: # M-02: Run Argon2id against a dummy hash so the response time is # 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") # M-02: Run password verification BEFORE lockout check so Argon2id always # 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) @@ -465,7 +472,7 @@ async def register( 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.") - password_hash = hash_password(data.password) + password_hash = await ahash_password(data.password) # SEC-01: Explicit field assignment — never **data.model_dump() new_user = User( username=data.username, @@ -630,7 +637,7 @@ async def verify_password( """ 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: await _record_failed_login(db, current_user) 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.""" 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: await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid current password") @@ -664,7 +671,7 @@ async def change_password( if data.new_password == data.old_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() # Clear forced password change flag if set (SEC-12) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 7314287..cd2b1f2 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -4,7 +4,7 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from typing import List, Optional from datetime import date, timedelta -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from app.database import get_db from app.models.project import Project @@ -20,6 +20,7 @@ router = APIRouter() class ReorderItem(BaseModel): + model_config = ConfigDict(extra="forbid") id: int sort_order: int @@ -294,16 +295,20 @@ async def reorder_tasks( if not project: raise HTTPException(status_code=404, detail="Project not found") - for item in items: - task_result = await db.execute( - select(ProjectTask).where( - ProjectTask.id == item.id, - ProjectTask.project_id == project_id - ) + # 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( + select(ProjectTask).where( + ProjectTask.id.in_(task_ids), + ProjectTask.project_id == project_id, ) - task = task_result.scalar_one_or_none() - if task: - task.sort_order = item.sort_order + ) + tasks_by_id = {t.id: t for t in task_result.scalars().all()} + + 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() diff --git a/backend/app/routers/totp.py b/backend/app/routers/totp.py index 1371bf8..9839b18 100644 --- a/backend/app/routers/totp.py +++ b/backend/app/routers/totp.py @@ -17,6 +17,7 @@ Security: - Failed TOTP attempts increment user.failed_login_count (shared lockout counter) - totp-verify uses mfa_token (not session cookie) — user is not yet authenticated """ +import asyncio import uuid import secrets 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.services.audit import get_client_ip from app.services.auth import ( - verify_password_with_upgrade, - hash_password, + averify_password_with_upgrade, verify_mfa_token, verify_mfa_enforce_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: """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: - 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)) await db.commit() @@ -145,9 +147,12 @@ async def _verify_backup_code( ) 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: 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() await db.commit() return True @@ -355,7 +360,8 @@ async def totp_disable( raise HTTPException(status_code=400, detail="TOTP is not enabled") # 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: raise HTTPException(status_code=401, detail="Invalid password") @@ -391,7 +397,8 @@ async def regenerate_backup_codes( if not current_user.totp_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: raise HTTPException(status_code=401, detail="Invalid password") diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index c6ef16a..c12ca34 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -6,6 +6,8 @@ Password strategy: - Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately rehashed to Argon2id on first successful use. """ +import asyncio + from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError 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 +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py index 71bba15..dc05c7f 100644 --- a/backend/app/services/calendar_sharing.py +++ b/backend/app/services/calendar_sharing.py @@ -7,7 +7,7 @@ import logging from datetime import datetime, timedelta from fastapi import HTTPException -from sqlalchemy import delete, select, text +from sqlalchemy import delete, select, text, update from sqlalchemy.ext.asyncio import AsyncSession 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 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( - select(CalendarMember).where( - CalendarMember.calendar_id == calendar_id, - CalendarMember.user_id == user_id, - CalendarMember.status == "accepted", + AW-5: Single query with LEFT JOIN instead of 2 sequential queries. + """ + result = await db.execute( + select( + Calendar.user_id, + CalendarMember.permission, ) + .outerjoin( + CalendarMember, + (CalendarMember.calendar_id == Calendar.id) + & (CalendarMember.user_id == user_id) + & (CalendarMember.status == "accepted"), + ) + .where(Calendar.id == calendar_id) ) - row = member.scalar_one_or_none() - 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( @@ -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}, ) - # 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 - for cal_id in all_cal_ids: - remaining = await db.execute( - select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) + if all_cal_ids: + # Find which calendars still have members + 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(): - cal_result = await db.execute( - select(Calendar).where(Calendar.id == cal_id) + cals_with_members = {row[0] for row in has_members_result.all()} + + # 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 diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..7edc73b --- /dev/null +++ b/backend/entrypoint.sh @@ -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 '*' diff --git a/backend/start.sh b/backend/start.sh deleted file mode 100644 index 770cff7..0000000 --- a/backend/start.sh +++ /dev/null @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 1b56b01..2dc1a43 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,11 +5,18 @@ services: env_file: .env volumes: - postgres_data:/var/lib/postgresql/data + networks: + - backend_net healthcheck: test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] interval: 5s timeout: 5s retries: 5 + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" backend: build: ./backend @@ -18,11 +25,20 @@ services: depends_on: db: condition: service_healthy + networks: + - backend_net + - frontend_net healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] interval: 10s timeout: 5s retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" frontend: build: ./frontend @@ -32,6 +48,24 @@ services: depends_on: backend: 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: postgres_data: + +networks: + backend_net: + driver: bridge + frontend_net: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b800409 --- /dev/null +++ b/frontend/.dockerignore @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ef60a12..88d4008 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,13 +1,13 @@ # Build stage -FROM node:20-alpine AS build +FROM node:20.18-alpine AS build WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm install +# Install dependencies from lockfile (DW-3) +RUN npm ci # Copy source files COPY . . @@ -16,7 +16,7 @@ COPY . . RUN npm run build # 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 --from=build /app/dist /usr/share/nginx/html diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c3936cc..36a506d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -41,7 +41,7 @@ server { gzip on; gzip_vary on; 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) location ~ /\.(?!well-known) { @@ -122,28 +122,12 @@ server { include /etc/nginx/proxy-params.conf; } - # API proxy + # API proxy (catch-all for non-rate-limited endpoints) location /api { - proxy_pass http://backend:8000; - proxy_http_version 1.1; proxy_set_header Upgrade $http_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; - - # 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; + include /etc/nginx/proxy-params.conf; } # SPA fallback - serve index.html for all routes diff --git a/frontend/proxy-params.conf b/frontend/proxy-params.conf index 4b18310..7ff2efb 100644 --- a/frontend/proxy-params.conf +++ b/frontend/proxy-params.conf @@ -4,3 +4,13 @@ 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; + +# 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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1bb5810..17cc135 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,19 +3,24 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import LockScreen from '@/components/auth/LockScreen'; 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 RouteFallback = () => ( +
+ {format(now, 'h:mm a')} + | + {format(now, 'EEEE, MMMM d, yyyy')} +
+ {updatedAgo && ( + <> + · + Updated {updatedAgo} + + > + )} +- {format(clockNow, 'h:mm a')} - | - {format(clockNow, 'EEEE, MMMM d, yyyy')} -
- {updatedAgo && ( - <> - · - Updated {updatedAgo} - - > - )} -