Compare commits

...

4 Commits

Author SHA1 Message Date
1ebc41b9d7 L-03: Session 7-day sliding window with 30-day hard ceiling
Reduce session expiry from 30 days to 7 days of inactivity while
preserving a 30-day absolute token lifetime for itsdangerous:

- SESSION_MAX_AGE_DAYS=7: sliding window for DB expires_at + cookie
- SESSION_TOKEN_HARD_CEILING_DAYS=30: itsdangerous max_age (prevents
  rejecting renewed tokens whose creation timestamp exceeds 7 days)
- get_current_user: silently extends expires_at and re-issues cookie
  when >1 day has elapsed since last renewal
- Active users never notice; 7 days of inactivity forces re-login;
  30-day absolute ceiling forces re-login regardless of activity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:45:15 +08:00
8e27f2920b M-02: Timing-safe login prevents username enumeration
When a login targets a non-existent username, run Argon2id against a
pre-computed dummy hash so response time (~60ms) matches wrong-password
attempts. Also reorder the login flow to run verify_password_with_upgrade
BEFORE the lockout check, preventing timing side-channels that could
distinguish locked accounts from wrong passwords.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:44:27 +08:00
2f58282c31 M-01+M-03: Add input validation and extra=forbid to all request schemas
- Add max_length constraints to all string fields in request schemas,
  matching DB column limits (title:255, description:5000, etc.)
- Add min_length=1 to required name/title fields
- Add ConfigDict(extra="forbid") to all request schemas to reject
  unknown fields (prevents silent field injection)
- Add Path(ge=1, le=2147483647) to all integer path parameters across
  all routers to prevent integer overflow → 500 errors
- Add max_length to TOTP inline schemas (code:6, mfa_token:256, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:43:55 +08:00
581efa183a H-01: Add global CSRF middleware for all mutating endpoints
Replace the admin-only verify_xhr dependency with a pure ASGI
CSRFHeaderMiddleware that validates X-Requested-With: XMLHttpRequest
on all POST/PUT/PATCH/DELETE requests globally. Pre-auth endpoints
(login, setup, register, totp-verify, enforce-setup/confirm) are
exempt. This closes the CSRF gap where non-admin routes accepted
requests without origin validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:39:42 +08:00
26 changed files with 327 additions and 198 deletions

View File

@ -9,8 +9,9 @@ class Settings(BaseSettings):
COOKIE_SECURE: bool = False COOKIE_SECURE: bool = False
OPENWEATHERMAP_API_KEY: str = "" OPENWEATHERMAP_API_KEY: str = ""
# Session config # Session config — sliding window
SESSION_MAX_AGE_DAYS: int = 30 SESSION_MAX_AGE_DAYS: int = 7 # Sliding window: inactive sessions expire after 7 days
SESSION_TOKEN_HARD_CEILING_DAYS: int = 30 # Absolute token lifetime for itsdangerous max_age
# MFA token config (short-lived token bridging password OK → TOTP verification) # MFA token config (short-lived token bridging password OK → TOTP verification)
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes

View File

@ -19,6 +19,59 @@ from app.models import system_config as _system_config_model # noqa: F401
from app.models import audit_log as _audit_log_model # noqa: F401 from app.models import audit_log as _audit_log_model # noqa: F401
# ---------------------------------------------------------------------------
# Pure ASGI CSRF middleware — SEC-08 (global)
# ---------------------------------------------------------------------------
class CSRFHeaderMiddleware:
"""
Require X-Requested-With: XMLHttpRequest on all state-mutating requests.
Browsers never send this header cross-origin without a CORS preflight,
which our CORS policy blocks. This prevents CSRF attacks from simple
form submissions and cross-origin fetches.
Uses pure ASGI (not BaseHTTPMiddleware) to avoid streaming/memory overhead.
"""
_EXEMPT_PATHS = frozenset({
"/health",
"/",
"/api/auth/login",
"/api/auth/setup",
"/api/auth/register",
"/api/auth/totp-verify",
"/api/auth/totp/enforce-setup",
"/api/auth/totp/enforce-confirm",
})
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
method = scope.get("method", "")
path = scope.get("path", "")
if method in self._MUTATING_METHODS and path not in self._EXEMPT_PATHS:
headers = dict(scope.get("headers", []))
if headers.get(b"x-requested-with") != b"XMLHttpRequest":
body = b'{"detail":"Invalid request origin"}'
await send({
"type": "http.response.start",
"status": 403,
"headers": [
[b"content-type", b"application/json"],
[b"content-length", str(len(body)).encode()],
],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
@ -47,7 +100,12 @@ app = FastAPI(
openapi_url="/openapi.json" if _is_dev else None, openapi_url="/openapi.json" if _is_dev else None,
) )
# CORS configuration # Middleware stack — added in reverse order (last added = outermost).
# CSRF is added first (innermost), then CORS wraps it (outermost).
# This ensures CORS headers appear on CSRF 403 responses.
app.add_middleware(CSRFHeaderMiddleware)
# CORS configuration — outermost layer
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()], allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],

View File

@ -4,7 +4,7 @@ Admin router — full user management, system config, and audit log.
Security measures implemented: Security measures implemented:
SEC-02: Session revocation on role change SEC-02: Session revocation on role change
SEC-05: Block admin self-actions (own role/password/MFA/active status) SEC-05: Block admin self-actions (own role/password/MFA/active status)
SEC-08: X-Requested-With header check (verify_xhr) on all state-mutating requests SEC-08: X-Requested-With validation (now handled globally by CSRFHeaderMiddleware)
SEC-13: Session revocation + ntfy alert on MFA disable SEC-13: Session revocation + ntfy alert on MFA disable
All routes require the `require_admin` dependency (which chains through All routes require the `require_admin` dependency (which chains through
@ -15,7 +15,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
@ -48,26 +48,12 @@ from app.services.audit import log_audit_event
from app.services.auth import hash_password from app.services.auth import hash_password
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CSRF guard — SEC-08 # Router — all endpoints inherit require_admin
# --------------------------------------------------------------------------- # (SEC-08 CSRF validation is now handled globally by CSRFHeaderMiddleware)
async def verify_xhr(request: Request) -> None:
"""
Lightweight CSRF mitigation: require X-Requested-With on state-mutating
requests. Browsers never send this header cross-origin without CORS
pre-flight, which our CORS policy blocks.
"""
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("X-Requested-With") != "XMLHttpRequest":
raise HTTPException(status_code=403, detail="Invalid request origin")
# ---------------------------------------------------------------------------
# Router — all endpoints inherit require_admin + verify_xhr
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
router = APIRouter( router = APIRouter(
dependencies=[Depends(require_admin), Depends(verify_xhr)], dependencies=[Depends(require_admin)],
) )
@ -145,7 +131,7 @@ async def list_users(
@router.get("/users/{user_id}", response_model=UserDetailResponse) @router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user( async def get_user(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user), _actor: User = Depends(get_current_user),
): ):
@ -221,8 +207,8 @@ async def create_user(
@router.put("/users/{user_id}/role") @router.put("/users/{user_id}/role")
async def update_user_role( async def update_user_role(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
data: UpdateUserRoleRequest, data: UpdateUserRoleRequest = ...,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user), actor: User = Depends(get_current_user),
@ -275,8 +261,8 @@ async def update_user_role(
@router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse) @router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse)
async def reset_user_password( async def reset_user_password(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
request: Request, request: Request = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user), actor: User = Depends(get_current_user),
): ):
@ -320,8 +306,8 @@ async def reset_user_password(
@router.post("/users/{user_id}/disable-mfa") @router.post("/users/{user_id}/disable-mfa")
async def disable_user_mfa( async def disable_user_mfa(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
request: Request, request: Request = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user), actor: User = Depends(get_current_user),
): ):
@ -370,8 +356,8 @@ async def disable_user_mfa(
@router.put("/users/{user_id}/enforce-mfa") @router.put("/users/{user_id}/enforce-mfa")
async def toggle_mfa_enforce( async def toggle_mfa_enforce(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
data: ToggleMfaEnforceRequest, data: ToggleMfaEnforceRequest = ...,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user), actor: User = Depends(get_current_user),
@ -405,8 +391,8 @@ async def toggle_mfa_enforce(
@router.put("/users/{user_id}/active") @router.put("/users/{user_id}/active")
async def toggle_user_active( async def toggle_user_active(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
data: ToggleActiveRequest, data: ToggleActiveRequest = ...,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user), actor: User = Depends(get_current_user),
@ -448,8 +434,8 @@ async def toggle_user_active(
@router.delete("/users/{user_id}/sessions") @router.delete("/users/{user_id}/sessions")
async def revoke_user_sessions( async def revoke_user_sessions(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
request: Request, request: Request = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user), actor: User = Depends(get_current_user),
): ):
@ -479,7 +465,7 @@ async def revoke_user_sessions(
@router.get("/users/{user_id}/sessions") @router.get("/users/{user_id}/sessions")
async def list_user_sessions( async def list_user_sessions(
user_id: int, user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user), _actor: User = Depends(get_current_user),
): ):

View File

@ -36,6 +36,7 @@ from app.schemas.auth import (
) )
from app.services.auth import ( from app.services.auth import (
hash_password, hash_password,
verify_password,
verify_password_with_upgrade, verify_password_with_upgrade,
create_session_token, create_session_token,
verify_session_token, verify_session_token,
@ -47,6 +48,12 @@ from app.config import settings as app_settings
router = APIRouter() router = APIRouter()
# Pre-computed dummy hash for timing equalization (M-02).
# When a login attempt targets a non-existent username, we still run
# Argon2id verification against this dummy hash so the response time
# is indistinguishable from a wrong-password attempt.
_DUMMY_HASH = hash_password("timing-equalization-dummy")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cookie helper # Cookie helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -68,11 +75,16 @@ def _set_session_cookie(response: Response, token: str) -> None:
async def get_current_user( async def get_current_user(
request: Request, request: Request,
response: Response,
session_cookie: Optional[str] = Cookie(None, alias="session"), session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> User: ) -> User:
""" """
Dependency that verifies the session cookie and returns the authenticated User. Dependency that verifies the session cookie and returns the authenticated User.
L-03 sliding window: if the session has less than
(SESSION_MAX_AGE_DAYS - 1) days remaining, silently extend expires_at
and re-issue the cookie so active users never hit expiration.
""" """
if not session_cookie: if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
@ -106,6 +118,17 @@ async def get_current_user(
if not user: if not user:
raise HTTPException(status_code=401, detail="User not found or inactive") raise HTTPException(status_code=401, detail="User not found or inactive")
# 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).
now = datetime.now()
renewal_threshold = timedelta(days=app_settings.SESSION_MAX_AGE_DAYS - 1)
if db_session.expires_at - now < renewal_threshold:
db_session.expires_at = now + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
await db.flush()
# Re-issue cookie with fresh signed token to reset browser max_age timer
fresh_token = create_session_token(user_id, session_id)
_set_session_cookie(response, fresh_token)
return user return user
@ -287,12 +310,17 @@ async def login(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: 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)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
await _check_account_lockout(user) # 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 = verify_password_with_upgrade(data.password, user.password_hash)
await _check_account_lockout(user)
if not valid: if not valid:
await _record_failed_login(db, user) await _record_failed_login(db, user)
await log_audit_event( await log_audit_event(

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update from sqlalchemy import select, update
from typing import List from typing import List
@ -48,8 +48,8 @@ async def create_calendar(
@router.put("/{calendar_id}", response_model=CalendarResponse) @router.put("/{calendar_id}", response_model=CalendarResponse)
async def update_calendar( async def update_calendar(
calendar_id: int, calendar_id: int = Path(ge=1, le=2147483647),
calendar_update: CalendarUpdate, calendar_update: CalendarUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -77,7 +77,7 @@ async def update_calendar(
@router.delete("/{calendar_id}", status_code=204) @router.delete("/{calendar_id}", status_code=204)
async def delete_calendar( async def delete_calendar(
calendar_id: int, calendar_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@ -43,8 +43,8 @@ async def create_template(
@router.put("/{template_id}", response_model=EventTemplateResponse) @router.put("/{template_id}", response_model=EventTemplateResponse)
async def update_template( async def update_template(
template_id: int, template_id: int = Path(ge=1, le=2147483647),
payload: EventTemplateUpdate, payload: EventTemplateUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -68,7 +68,7 @@ async def update_template(
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template( async def delete_template(
template_id: int, template_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):

View File

@ -1,5 +1,5 @@
import json import json
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete from sqlalchemy import select, delete
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -272,7 +272,7 @@ async def create_event(
@router.get("/{event_id}", response_model=CalendarEventResponse) @router.get("/{event_id}", response_model=CalendarEventResponse)
async def get_event( async def get_event(
event_id: int, event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -296,8 +296,8 @@ async def get_event(
@router.put("/{event_id}", response_model=CalendarEventResponse) @router.put("/{event_id}", response_model=CalendarEventResponse)
async def update_event( async def update_event(
event_id: int, event_id: int = Path(ge=1, le=2147483647),
event_update: CalendarEventUpdate, event_update: CalendarEventUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -421,7 +421,7 @@ async def update_event(
@router.delete("/{event_id}", status_code=204) @router.delete("/{event_id}", status_code=204)
async def delete_event( async def delete_event(
event_id: int, event_id: int = Path(ge=1, le=2147483647),
scope: Optional[Literal["this", "this_and_future"]] = Query(None), scope: Optional[Literal["this", "this_and_future"]] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_ from sqlalchemy import select, or_
from datetime import datetime, timezone from datetime import datetime, timezone
@ -120,7 +120,7 @@ async def create_location(
@router.get("/{location_id}", response_model=LocationResponse) @router.get("/{location_id}", response_model=LocationResponse)
async def get_location( async def get_location(
location_id: int, location_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -138,8 +138,8 @@ async def get_location(
@router.put("/{location_id}", response_model=LocationResponse) @router.put("/{location_id}", response_model=LocationResponse)
async def update_location( async def update_location(
location_id: int, location_id: int = Path(ge=1, le=2147483647),
location_update: LocationUpdate, location_update: LocationUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -168,7 +168,7 @@ async def update_location(
@router.delete("/{location_id}", status_code=204) @router.delete("/{location_id}", status_code=204)
async def delete_location( async def delete_location(
location_id: int, location_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_ from sqlalchemy import select, or_
from datetime import datetime, timezone from datetime import datetime, timezone
@ -91,7 +91,7 @@ async def create_person(
@router.get("/{person_id}", response_model=PersonResponse) @router.get("/{person_id}", response_model=PersonResponse)
async def get_person( async def get_person(
person_id: int, person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -109,8 +109,8 @@ async def get_person(
@router.put("/{person_id}", response_model=PersonResponse) @router.put("/{person_id}", response_model=PersonResponse)
async def update_person( async def update_person(
person_id: int, person_id: int = Path(ge=1, le=2147483647),
person_update: PersonUpdate, person_update: PersonUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -146,7 +146,7 @@ async def update_person(
@router.delete("/{person_id}", status_code=204) @router.delete("/{person_id}", status_code=204)
async def delete_person( async def delete_person(
person_id: int, person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -128,7 +128,7 @@ async def create_project(
@router.get("/{project_id}", response_model=ProjectResponse) @router.get("/{project_id}", response_model=ProjectResponse)
async def get_project( async def get_project(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -149,8 +149,8 @@ async def get_project(
@router.put("/{project_id}", response_model=ProjectResponse) @router.put("/{project_id}", response_model=ProjectResponse)
async def update_project( async def update_project(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
project_update: ProjectUpdate, project_update: ProjectUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -178,7 +178,7 @@ async def update_project(
@router.delete("/{project_id}", status_code=204) @router.delete("/{project_id}", status_code=204)
async def delete_project( async def delete_project(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -199,7 +199,7 @@ async def delete_project(
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse]) @router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks( async def get_project_tasks(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -230,8 +230,8 @@ async def get_project_tasks(
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201) @router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
async def create_project_task( async def create_project_task(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
task: ProjectTaskCreate, task: ProjectTaskCreate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -279,8 +279,8 @@ async def create_project_task(
@router.put("/{project_id}/tasks/reorder", status_code=200) @router.put("/{project_id}/tasks/reorder", status_code=200)
async def reorder_tasks( async def reorder_tasks(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
items: List[ReorderItem], items: List[ReorderItem] = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -312,9 +312,9 @@ async def reorder_tasks(
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
async def update_project_task( async def update_project_task(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
task_id: int, task_id: int = Path(ge=1, le=2147483647),
task_update: ProjectTaskUpdate, task_update: ProjectTaskUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -356,8 +356,8 @@ async def update_project_task(
@router.delete("/{project_id}/tasks/{task_id}", status_code=204) @router.delete("/{project_id}/tasks/{task_id}", status_code=204)
async def delete_project_task( async def delete_project_task(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
task_id: int, task_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -388,9 +388,9 @@ async def delete_project_task(
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) @router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
async def create_task_comment( async def create_task_comment(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
task_id: int, task_id: int = Path(ge=1, le=2147483647),
comment: TaskCommentCreate, comment: TaskCommentCreate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -423,9 +423,9 @@ async def create_task_comment(
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204) @router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
async def delete_task_comment( async def delete_task_comment(
project_id: int, project_id: int = Path(ge=1, le=2147483647),
task_id: int, task_id: int = Path(ge=1, le=2147483647),
comment_id: int, comment_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):

View File

@ -1,6 +1,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_ from sqlalchemy import select, and_, or_
from typing import Optional, List from typing import Optional, List
@ -69,8 +69,8 @@ async def get_due_reminders(
@router.patch("/{reminder_id}/snooze", response_model=ReminderResponse) @router.patch("/{reminder_id}/snooze", response_model=ReminderResponse)
async def snooze_reminder( async def snooze_reminder(
reminder_id: int, reminder_id: int = Path(ge=1, le=2147483647),
body: ReminderSnooze, body: ReminderSnooze = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -115,7 +115,7 @@ async def create_reminder(
@router.get("/{reminder_id}", response_model=ReminderResponse) @router.get("/{reminder_id}", response_model=ReminderResponse)
async def get_reminder( async def get_reminder(
reminder_id: int, reminder_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -136,8 +136,8 @@ async def get_reminder(
@router.put("/{reminder_id}", response_model=ReminderResponse) @router.put("/{reminder_id}", response_model=ReminderResponse)
async def update_reminder( async def update_reminder(
reminder_id: int, reminder_id: int = Path(ge=1, le=2147483647),
reminder_update: ReminderUpdate, reminder_update: ReminderUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -175,7 +175,7 @@ async def update_reminder(
@router.delete("/{reminder_id}", status_code=204) @router.delete("/{reminder_id}", status_code=204)
async def delete_reminder( async def delete_reminder(
reminder_id: int, reminder_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@ -199,7 +199,7 @@ async def delete_reminder(
@router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse) @router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse)
async def dismiss_reminder( async def dismiss_reminder(
reminder_id: int, reminder_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, func
from typing import Optional, List from typing import Optional, List
@ -174,7 +174,7 @@ async def create_todo(
@router.get("/{todo_id}", response_model=TodoResponse) @router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo( async def get_todo(
todo_id: int, todo_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -192,8 +192,8 @@ async def get_todo(
@router.put("/{todo_id}", response_model=TodoResponse) @router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo( async def update_todo(
todo_id: int, todo_id: int = Path(ge=1, le=2147483647),
todo_update: TodoUpdate, todo_update: TodoUpdate = ...,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings), current_settings: Settings = Depends(get_current_settings),
@ -249,7 +249,7 @@ async def update_todo(
@router.delete("/{todo_id}", status_code=204) @router.delete("/{todo_id}", status_code=204)
async def delete_todo( async def delete_todo(
todo_id: int, todo_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -270,7 +270,7 @@ async def delete_todo(
@router.patch("/{todo_id}/toggle", response_model=TodoResponse) @router.patch("/{todo_id}/toggle", response_model=TodoResponse)
async def toggle_todo( async def toggle_todo(
todo_id: int, todo_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings), current_settings: Settings = Depends(get_current_settings),

View File

@ -24,7 +24,7 @@ from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete from sqlalchemy import select, delete
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -76,32 +76,38 @@ _ph = PasswordHasher(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TOTPConfirmRequest(BaseModel): class TOTPConfirmRequest(BaseModel):
code: str model_config = ConfigDict(extra="forbid")
code: str = Field(min_length=6, max_length=6)
class TOTPVerifyRequest(BaseModel): class TOTPVerifyRequest(BaseModel):
mfa_token: str model_config = ConfigDict(extra="forbid")
code: Optional[str] = None # 6-digit TOTP code mfa_token: str = Field(max_length=256)
backup_code: Optional[str] = None # Alternative: XXXX-XXXX backup code code: Optional[str] = Field(None, min_length=6, max_length=6) # 6-digit TOTP code
backup_code: Optional[str] = Field(None, max_length=9) # XXXX-XXXX backup code
class TOTPDisableRequest(BaseModel): class TOTPDisableRequest(BaseModel):
password: str model_config = ConfigDict(extra="forbid")
code: str # Current TOTP code required to disable password: str = Field(max_length=128)
code: str = Field(min_length=6, max_length=6) # Current TOTP code required to disable
class BackupCodesRegenerateRequest(BaseModel): class BackupCodesRegenerateRequest(BaseModel):
password: str model_config = ConfigDict(extra="forbid")
code: str # Current TOTP code required to regenerate password: str = Field(max_length=128)
code: str = Field(min_length=6, max_length=6) # Current TOTP code required to regenerate
class EnforceSetupRequest(BaseModel): class EnforceSetupRequest(BaseModel):
mfa_token: str model_config = ConfigDict(extra="forbid")
mfa_token: str = Field(max_length=256)
class EnforceConfirmRequest(BaseModel): class EnforceConfirmRequest(BaseModel):
mfa_token: str model_config = ConfigDict(extra="forbid")
code: str # 6-digit TOTP code from authenticator app mfa_token: str = Field(max_length=256)
code: str = Field(min_length=6, max_length=6) # 6-digit TOTP code from authenticator app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -84,6 +84,8 @@ class LoginRequest(BaseModel):
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
old_password: str old_password: str
new_password: str new_password: str
@ -94,6 +96,8 @@ class ChangePasswordRequest(BaseModel):
class VerifyPasswordRequest(BaseModel): class VerifyPasswordRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str password: str
@field_validator("password") @field_validator("password")

View File

@ -1,16 +1,20 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
class CalendarCreate(BaseModel): class CalendarCreate(BaseModel):
name: str model_config = ConfigDict(extra="forbid")
color: str = "#3b82f6"
name: str = Field(min_length=1, max_length=100)
color: str = Field("#3b82f6", max_length=20)
class CalendarUpdate(BaseModel): class CalendarUpdate(BaseModel):
name: Optional[str] = None model_config = ConfigDict(extra="forbid")
color: Optional[str] = None
name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, max_length=20)
is_visible: Optional[bool] = None is_visible: Optional[bool] = None

View File

@ -39,12 +39,14 @@ def _coerce_recurrence_rule(v):
class CalendarEventCreate(BaseModel): class CalendarEventCreate(BaseModel):
title: str model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
start_datetime: datetime start_datetime: datetime
end_datetime: datetime end_datetime: datetime
all_day: bool = False all_day: bool = False
color: Optional[str] = None color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = None location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None recurrence_rule: Optional[RecurrenceRule] = None
is_starred: bool = False is_starred: bool = False
@ -57,12 +59,14 @@ class CalendarEventCreate(BaseModel):
class CalendarEventUpdate(BaseModel): class CalendarEventUpdate(BaseModel):
title: Optional[str] = None model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
start_datetime: Optional[datetime] = None start_datetime: Optional[datetime] = None
end_datetime: Optional[datetime] = None end_datetime: Optional[datetime] = None
all_day: Optional[bool] = None all_day: Optional[bool] = None
color: Optional[str] = None color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = None location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None recurrence_rule: Optional[RecurrenceRule] = None
is_starred: Optional[bool] = None is_starred: Optional[bool] = None

View File

@ -1,25 +1,29 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
class EventTemplateCreate(BaseModel): class EventTemplateCreate(BaseModel):
name: str model_config = ConfigDict(extra="forbid")
title: str
description: Optional[str] = None name: str = Field(min_length=1, max_length=255)
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
calendar_id: Optional[int] = None calendar_id: Optional[int] = None
recurrence_rule: Optional[str] = None recurrence_rule: Optional[str] = Field(None, max_length=5000)
all_day: bool = False all_day: bool = False
location_id: Optional[int] = None location_id: Optional[int] = None
is_starred: bool = False is_starred: bool = False
class EventTemplateUpdate(BaseModel): class EventTemplateUpdate(BaseModel):
name: Optional[str] = None model_config = ConfigDict(extra="forbid")
title: Optional[str] = None
description: Optional[str] = None name: Optional[str] = Field(None, min_length=1, max_length=255)
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
calendar_id: Optional[int] = None calendar_id: Optional[int] = None
recurrence_rule: Optional[str] = None recurrence_rule: Optional[str] = Field(None, max_length=5000)
all_day: Optional[bool] = None all_day: Optional[bool] = None
location_id: Optional[int] = None location_id: Optional[int] = None
is_starred: Optional[bool] = None is_starred: Optional[bool] = None

View File

@ -1,5 +1,5 @@
import re import re
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime from datetime import datetime
from typing import Optional, Literal from typing import Optional, Literal
@ -14,13 +14,15 @@ class LocationSearchResult(BaseModel):
class LocationCreate(BaseModel): class LocationCreate(BaseModel):
name: str model_config = ConfigDict(extra="forbid")
address: str
category: str = "other" name: str = Field(min_length=1, max_length=255)
notes: Optional[str] = None address: str = Field(min_length=1, max_length=2000)
category: str = Field("other", max_length=100)
notes: Optional[str] = Field(None, max_length=5000)
is_frequent: bool = False is_frequent: bool = False
contact_number: Optional[str] = None contact_number: Optional[str] = Field(None, max_length=50)
email: Optional[str] = None email: Optional[str] = Field(None, max_length=255)
@field_validator('email') @field_validator('email')
@classmethod @classmethod
@ -31,13 +33,15 @@ class LocationCreate(BaseModel):
class LocationUpdate(BaseModel): class LocationUpdate(BaseModel):
name: Optional[str] = None model_config = ConfigDict(extra="forbid")
address: Optional[str] = None
category: Optional[str] = None name: Optional[str] = Field(None, min_length=1, max_length=255)
notes: Optional[str] = None address: Optional[str] = Field(None, min_length=1, max_length=2000)
category: Optional[str] = Field(None, max_length=100)
notes: Optional[str] = Field(None, max_length=5000)
is_frequent: Optional[bool] = None is_frequent: Optional[bool] = None
contact_number: Optional[str] = None contact_number: Optional[str] = Field(None, max_length=50)
email: Optional[str] = None email: Optional[str] = Field(None, max_length=255)
@field_validator('email') @field_validator('email')
@classmethod @classmethod

View File

@ -1,5 +1,5 @@
import re import re
from pydantic import BaseModel, ConfigDict, model_validator, field_validator from pydantic import BaseModel, ConfigDict, Field, model_validator, field_validator
from datetime import datetime, date from datetime import datetime, date
from typing import Optional from typing import Optional
@ -7,20 +7,22 @@ _EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
class PersonCreate(BaseModel): class PersonCreate(BaseModel):
name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone model_config = ConfigDict(extra="forbid")
first_name: Optional[str] = None
last_name: Optional[str] = None name: Optional[str] = Field(None, max_length=255) # legacy fallback — auto-split into first/last if provided alone
nickname: Optional[str] = None first_name: Optional[str] = Field(None, max_length=100)
email: Optional[str] = None last_name: Optional[str] = Field(None, max_length=100)
phone: Optional[str] = None nickname: Optional[str] = Field(None, max_length=100)
mobile: Optional[str] = None email: Optional[str] = Field(None, max_length=255)
address: Optional[str] = None phone: Optional[str] = Field(None, max_length=50)
mobile: Optional[str] = Field(None, max_length=50)
address: Optional[str] = Field(None, max_length=2000)
birthday: Optional[date] = None birthday: Optional[date] = None
category: Optional[str] = None category: Optional[str] = Field(None, max_length=100)
is_favourite: bool = False is_favourite: bool = False
company: Optional[str] = None company: Optional[str] = Field(None, max_length=255)
job_title: Optional[str] = None job_title: Optional[str] = Field(None, max_length=255)
notes: Optional[str] = None notes: Optional[str] = Field(None, max_length=5000)
@model_validator(mode='after') @model_validator(mode='after')
def require_some_name(self) -> 'PersonCreate': def require_some_name(self) -> 'PersonCreate':
@ -42,20 +44,22 @@ class PersonCreate(BaseModel):
class PersonUpdate(BaseModel): class PersonUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
# name is intentionally omitted — always computed from first/last/nickname # name is intentionally omitted — always computed from first/last/nickname
first_name: Optional[str] = None first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = None last_name: Optional[str] = Field(None, max_length=100)
nickname: Optional[str] = None nickname: Optional[str] = Field(None, max_length=100)
email: Optional[str] = None email: Optional[str] = Field(None, max_length=255)
phone: Optional[str] = None phone: Optional[str] = Field(None, max_length=50)
mobile: Optional[str] = None mobile: Optional[str] = Field(None, max_length=50)
address: Optional[str] = None address: Optional[str] = Field(None, max_length=2000)
birthday: Optional[date] = None birthday: Optional[date] = None
category: Optional[str] = None category: Optional[str] = Field(None, max_length=100)
is_favourite: Optional[bool] = None is_favourite: Optional[bool] = None
company: Optional[str] = None company: Optional[str] = Field(None, max_length=255)
job_title: Optional[str] = None job_title: Optional[str] = Field(None, max_length=255)
notes: Optional[str] = None notes: Optional[str] = Field(None, max_length=5000)
@field_validator('email') @field_validator('email')
@classmethod @classmethod

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List, Literal
from app.schemas.project_task import ProjectTaskResponse from app.schemas.project_task import ProjectTaskResponse
@ -7,19 +7,23 @@ ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "r
class ProjectCreate(BaseModel): class ProjectCreate(BaseModel):
name: str model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
name: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: ProjectStatus = "not_started" status: ProjectStatus = "not_started"
color: Optional[str] = None color: Optional[str] = Field(None, max_length=20)
due_date: Optional[date] = None due_date: Optional[date] = None
is_tracked: bool = False is_tracked: bool = False
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
name: Optional[str] = None model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: Optional[ProjectStatus] = None status: Optional[ProjectStatus] = None
color: Optional[str] = None color: Optional[str] = Field(None, max_length=20)
due_date: Optional[date] = None due_date: Optional[date] = None
is_tracked: Optional[bool] = None is_tracked: Optional[bool] = None

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List, Literal
from app.schemas.task_comment import TaskCommentResponse from app.schemas.task_comment import TaskCommentResponse
@ -8,8 +8,10 @@ TaskPriority = Literal["none", "low", "medium", "high"]
class ProjectTaskCreate(BaseModel): class ProjectTaskCreate(BaseModel):
title: str model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: TaskStatus = "pending" status: TaskStatus = "pending"
priority: TaskPriority = "medium" priority: TaskPriority = "medium"
due_date: Optional[date] = None due_date: Optional[date] = None
@ -19,8 +21,10 @@ class ProjectTaskCreate(BaseModel):
class ProjectTaskUpdate(BaseModel): class ProjectTaskUpdate(BaseModel):
title: Optional[str] = None model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: Optional[TaskStatus] = None status: Optional[TaskStatus] = None
priority: Optional[TaskPriority] = None priority: Optional[TaskPriority] = None
due_date: Optional[date] = None due_date: Optional[date] = None

View File

@ -1,19 +1,23 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
class ReminderCreate(BaseModel): class ReminderCreate(BaseModel):
title: str model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
remind_at: Optional[datetime] = None remind_at: Optional[datetime] = None
is_active: bool = True is_active: bool = True
recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None
class ReminderUpdate(BaseModel): class ReminderUpdate(BaseModel):
title: Optional[str] = None model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
remind_at: Optional[datetime] = None remind_at: Optional[datetime] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
is_dismissed: Optional[bool] = None is_dismissed: Optional[bool] = None
@ -21,6 +25,8 @@ class ReminderUpdate(BaseModel):
class ReminderSnooze(BaseModel): class ReminderSnooze(BaseModel):
model_config = ConfigDict(extra="forbid")
minutes: Literal[5, 10, 15] minutes: Literal[5, 10, 15]
client_now: Optional[datetime] = None client_now: Optional[datetime] = None

View File

@ -1,5 +1,5 @@
import re import re
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
@ -9,17 +9,19 @@ _NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
accent_color: Optional[AccentColor] = None accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None upcoming_days: int | None = None
preferred_name: str | None = None preferred_name: str | None = Field(None, max_length=100)
weather_city: str | None = None weather_city: str | None = Field(None, max_length=100)
weather_lat: float | None = None weather_lat: float | None = None
weather_lon: float | None = None weather_lon: float | None = None
first_day_of_week: int | None = None first_day_of_week: int | None = None
# ntfy configuration fields # ntfy configuration fields
ntfy_server_url: Optional[str] = None ntfy_server_url: Optional[str] = Field(None, max_length=500)
ntfy_topic: Optional[str] = None ntfy_topic: Optional[str] = Field(None, max_length=100)
# Empty string means "clear the token"; None means "leave unchanged" # Empty string means "clear the token"; None means "leave unchanged"
ntfy_auth_token: Optional[str] = None ntfy_auth_token: Optional[str] = None
ntfy_enabled: Optional[bool] = None ntfy_enabled: Optional[bool] = None

View File

@ -1,9 +1,11 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime from datetime import datetime
class TaskCommentCreate(BaseModel): class TaskCommentCreate(BaseModel):
content: str model_config = ConfigDict(extra="forbid")
content: str = Field(min_length=1, max_length=10000)
class TaskCommentResponse(BaseModel): class TaskCommentResponse(BaseModel):

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date, time from datetime import datetime, date, time
from typing import Optional, Literal from typing import Optional, Literal
@ -7,24 +7,28 @@ RecurrenceRule = Literal["daily", "weekly", "monthly"]
class TodoCreate(BaseModel): class TodoCreate(BaseModel):
title: str model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
priority: TodoPriority = "medium" priority: TodoPriority = "medium"
due_date: Optional[date] = None due_date: Optional[date] = None
due_time: Optional[time] = None due_time: Optional[time] = None
category: Optional[str] = None category: Optional[str] = Field(None, max_length=100)
recurrence_rule: Optional[RecurrenceRule] = None recurrence_rule: Optional[RecurrenceRule] = None
project_id: Optional[int] = None project_id: Optional[int] = None
class TodoUpdate(BaseModel): class TodoUpdate(BaseModel):
title: Optional[str] = None model_config = ConfigDict(extra="forbid")
description: Optional[str] = None
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
priority: Optional[TodoPriority] = None priority: Optional[TodoPriority] = None
due_date: Optional[date] = None due_date: Optional[date] = None
due_time: Optional[time] = None due_time: Optional[time] = None
completed: Optional[bool] = None completed: Optional[bool] = None
category: Optional[str] = None category: Optional[str] = Field(None, max_length=100)
recurrence_rule: Optional[RecurrenceRule] = None recurrence_rule: Optional[RecurrenceRule] = None
project_id: Optional[int] = None project_id: Optional[int] = None

View File

@ -88,10 +88,14 @@ def create_session_token(user_id: int, session_id: str) -> str:
def verify_session_token(token: str, max_age: int | None = None) -> dict | None: def verify_session_token(token: str, max_age: int | None = None) -> dict | None:
""" """
Verify a session cookie and return its payload dict, or None if invalid/expired. Verify a session cookie and return its payload dict, or None if invalid/expired.
max_age defaults to SESSION_MAX_AGE_DAYS from config.
max_age defaults to SESSION_TOKEN_HARD_CEILING_DAYS (absolute token lifetime).
The sliding window (SESSION_MAX_AGE_DAYS) is enforced via DB expires_at checks,
not by itsdangerous this decoupling prevents the serializer from rejecting
renewed tokens that were created more than SESSION_MAX_AGE_DAYS ago.
""" """
if max_age is None: if max_age is None:
max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400 max_age = app_settings.SESSION_TOKEN_HARD_CEILING_DAYS * 86400
try: try:
return _serializer.loads(token, max_age=max_age) return _serializer.loads(token, max_age=max_age)
except (BadSignature, SignatureExpired): except (BadSignature, SignatureExpired):