Compare commits

..

No commits in common. "c986028f51326c1f24bc8603e886f0f1c4536d34" and "ccfc5e151a531c40dbfbbc8f9fc68bc2f61468fc" have entirely different histories.

8 changed files with 19 additions and 70 deletions

View File

@ -18,9 +18,6 @@ ENVIRONMENT=development
# CORS allowed origins (comma-separated, default: http://localhost:5173) # CORS allowed origins (comma-separated, default: http://localhost:5173)
# CORS_ORIGINS=https://umbra.example.com # CORS_ORIGINS=https://umbra.example.com
# Public URL for ntfy notification click links (default: http://localhost)
# UMBRA_URL=https://umbra.example.com
# Timezone (applied to backend + db containers via env_file) # Timezone (applied to backend + db containers via env_file)
TZ=Australia/Perth TZ=Australia/Perth

View File

@ -1,22 +1,16 @@
# ── Build stage: compile C extensions ──────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
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-slim
WORKDIR /app WORKDIR /app
# Copy pre-built Python packages from builder # Install system dependencies
COPY --from=builder /install /usr/local RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . COPY . .
@ -25,6 +19,7 @@ COPY . .
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser USER appuser
# Expose port
EXPOSE 8000 EXPOSE 8000
# Run migrations and start server # Run migrations and start server

View File

@ -23,12 +23,6 @@ class Settings(BaseSettings):
# CORS allowed origins (comma-separated) # CORS allowed origins (comma-separated)
CORS_ORIGINS: str = "http://localhost:5173" CORS_ORIGINS: str = "http://localhost:5173"
# Public-facing URL used in ntfy notification click links
UMBRA_URL: str = "http://localhost"
# Concurrent session limit per user (oldest evicted when exceeded)
MAX_SESSIONS_PER_USER: int = 10
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",

View File

@ -35,7 +35,7 @@ from app.services.ntfy_templates import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.config import settings as app_settings UMBRA_URL = "http://10.0.69.35"
# ── Dedup helpers ───────────────────────────────────────────────────────────── # ── Dedup helpers ─────────────────────────────────────────────────────────────
@ -92,7 +92,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
) )
sent = await send_ntfy_notification( sent = await send_ntfy_notification(
settings=settings, settings=settings,
click_url=app_settings.UMBRA_URL, click_url=UMBRA_URL,
**payload, **payload,
) )
if sent: if sent:
@ -144,7 +144,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime,
) )
sent = await send_ntfy_notification( sent = await send_ntfy_notification(
settings=settings, settings=settings,
click_url=app_settings.UMBRA_URL, click_url=UMBRA_URL,
**payload, **payload,
) )
if sent: if sent:
@ -184,7 +184,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys
) )
sent = await send_ntfy_notification( sent = await send_ntfy_notification(
settings=settings, settings=settings,
click_url=app_settings.UMBRA_URL, click_url=UMBRA_URL,
**payload, **payload,
) )
if sent: if sent:
@ -223,7 +223,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_k
) )
sent = await send_ntfy_notification( sent = await send_ntfy_notification(
settings=settings, settings=settings,
click_url=app_settings.UMBRA_URL, click_url=UMBRA_URL,
**payload, **payload,
) )
if sent: if sent:

View File

@ -770,9 +770,7 @@ async def get_audit_log(
) )
if action: if action:
# Escape LIKE metacharacters so user input is treated literally base_q = base_q.where(AuditLog.action.like(f"{action}%"))
safe_action = action.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
base_q = base_q.where(AuditLog.action.like(f"{safe_action}%", escape="\\"))
if target_user_id is not None: if target_user_id is not None:
base_q = base_q.where(AuditLog.target_user_id == target_user_id) base_q = base_q.where(AuditLog.target_user_id == target_user_id)

View File

@ -66,7 +66,6 @@ def _set_session_cookie(response: Response, token: str) -> None:
secure=app_settings.COOKIE_SECURE, secure=app_settings.COOKIE_SECURE,
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400, max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
samesite="lax", samesite="lax",
path="/",
) )
@ -220,26 +219,6 @@ async def _create_db_session(
) )
db.add(db_session) db.add(db_session)
await db.flush() await db.flush()
# Enforce concurrent session limit: revoke oldest sessions beyond the cap
active_sessions = (
await db.execute(
select(UserSession)
.where(
UserSession.user_id == user.id,
UserSession.revoked == False, # noqa: E712
UserSession.expires_at > datetime.now(),
)
.order_by(UserSession.created_at.asc())
)
).scalars().all()
max_sessions = app_settings.MAX_SESSIONS_PER_USER
if len(active_sessions) > max_sessions:
for old_session in active_sessions[: len(active_sessions) - max_sessions]:
old_session.revoked = True
await db.flush()
token = create_session_token(user.id, session_id) token = create_session_token(user.id, session_id)
return session_id, token return session_id, token

View File

@ -2,7 +2,6 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.config import settings as app_settings
from app.models.settings import Settings from app.models.settings import Settings
from app.models.user import User from app.models.user import User
from app.schemas.settings import SettingsUpdate, SettingsResponse from app.schemas.settings import SettingsUpdate, SettingsResponse
@ -109,7 +108,7 @@ async def test_ntfy(
message="If you see this, your ntfy integration is working correctly.", message="If you see this, your ntfy integration is working correctly.",
tags=["white_check_mark"], tags=["white_check_mark"],
priority=3, priority=3,
click_url=app_settings.UMBRA_URL, click_url="http://10.0.69.35",
) )
if not success: if not success:

View File

@ -21,26 +21,15 @@ server {
# Suppress nginx version in Server header # Suppress nginx version in Server header
server_tokens off; server_tokens off;
# ── Real client IP restoration (PT-01) ────────────────────────────
# Pangolin (TLS-terminating reverse proxy) connects via Docker bridge.
# Restore the real client IP from X-Forwarded-For so that limit_req_zone
# (which keys on $binary_remote_addr) throttles per-client, not per-proxy.
# Safe to trust all sources: nginx is only reachable via Docker networking,
# never directly internet-facing. Tighten if deployment model changes.
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Gzip compression # Gzip compression
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;
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04) # Block dotfiles (except .well-known for ACME/Let's Encrypt)
location ~ /\.(?!well-known) { location ~ /\.(?!well-known) {
default_type application/json; return 404;
return 404 '{"detail":"Not Found"}';
} }
# Rate-limited auth endpoints (keep in sync with proxy-params.conf) # Rate-limited auth endpoints (keep in sync with proxy-params.conf)
@ -115,7 +104,6 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" 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 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;
} }
# Security headers # Security headers
@ -123,5 +111,4 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" 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 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;
} }