kitchen/backend/api/public.py
jtricerolph bcc94024e3 Pre-deploy security/correctness fixes (port log E17)
- Remove dead kitchen->KDS internal API (api/internal.py, verify_internal_secret)
  — KDS reads kitchen_db directly (E16), nothing ever called this endpoint
- Add expires_at to dispute_attachments; public attachment links now expire
  after 30 days instead of staying valid forever (A4)
- Add services/upload_validation.py: sniff real file content via python-magic
  instead of trusting the client-supplied Content-Type header, plus a 20MB
  cap. Applied across invoices/logbook/food_flags/credit_notes/disputes
  upload endpoints (A5) — disputes previously had no file-type check at all
- Fix nginx client_max_body_size drift (800m -> the plan's intended 20m)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 14:44:58 +00:00

127 lines
4.4 KiB
Python

"""
Public API endpoints - NO AUTHENTICATION REQUIRED.
These endpoints are designed for sharing with external parties (e.g., suppliers)
via hash-based URLs that don't require login.
"""
import os
from datetime import datetime
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import Depends
from database import get_db
from models.dispute import DisputeAttachment
from models.settings import KitchenSettings
from services.nextcloud_service import NextcloudService
router = APIRouter()
@router.get("/attachments/{public_hash}")
async def get_public_attachment(
public_hash: str,
db: AsyncSession = Depends(get_db)
):
"""
View a dispute attachment publicly via its hash.
This endpoint does NOT require authentication, allowing suppliers
to view attached images/documents via shareable links in emails.
"""
# Find attachment by public hash
result = await db.execute(
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# A4 — public links expire; don't serve a stale/leaked link forever
if attachment.expires_at and attachment.expires_at < datetime.utcnow():
raise HTTPException(status_code=410, detail="This link has expired")
# Get file content
content = None
# Try local file first
if attachment.file_storage_location == "local" and attachment.file_path:
if os.path.exists(attachment.file_path):
with open(attachment.file_path, 'rb') as f:
content = f.read()
# Try Nextcloud if local not found
if content is None and attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path:
# Get kitchen settings for Nextcloud credentials
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == attachment.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
if settings and settings.nextcloud_enabled:
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, nc_content = await nc.download_file(attachment.nextcloud_path)
await nc.close()
if success:
content = nc_content
if content is None:
raise HTTPException(status_code=404, detail="File not found")
# Determine if browser should display inline or download
# Images and PDFs display inline, others download
inline_types = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf'
]
disposition = "inline" if attachment.file_type in inline_types else "attachment"
return Response(
content=content,
media_type=attachment.file_type,
headers={
"Content-Disposition": f'{disposition}; filename="{attachment.file_name}"',
"Cache-Control": "private, max-age=3600" # Cache for 1 hour
}
)
@router.get("/attachments/{public_hash}/info")
async def get_public_attachment_info(
public_hash: str,
db: AsyncSession = Depends(get_db)
):
"""
Get attachment metadata without downloading the file.
Useful for email previews or link unfurling.
"""
result = await db.execute(
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
if attachment.expires_at and attachment.expires_at < datetime.utcnow():
raise HTTPException(status_code=410, detail="This link has expired")
return {
"file_name": attachment.file_name,
"file_type": attachment.file_type,
"file_size_bytes": attachment.file_size_bytes,
"attachment_type": attachment.attachment_type,
"description": attachment.description,
"uploaded_at": attachment.uploaded_at.isoformat() if attachment.uploaded_at else None,
"expires_at": attachment.expires_at.isoformat() if attachment.expires_at else None
}