This document describes the Google Cloud Storage (GCS) attachment system currently in production.
All user uploads and model-generated images are stored in private Google Cloud Storage with signed URLs for secure, temporary access. No files are stored locally beyond ephemeral streaming.
- Private storage: All objects in GCS bucket with IAM-based access control
- Signed URLs: Time-limited access tokens (default 7-day TTL)
- Automatic refresh: Expired URLs are regenerated when serving chat history
- Metadata in SQLite: Database stores blob names, URLs, and expiration timestamps
- Background cleanup: Scheduled job removes expired attachments from GCS and database
- Size validation: Configurable upload limits (default 10MB)
Upload Request → FastAPI → AttachmentService
↓
[Validate size/type]
↓
[Upload to GCS]
↓
[Generate signed URL]
↓
[Store metadata in SQLite]
↓
Return signed URL to client
When serving chat history:
History Request → Repository → Attachments with expired URLs
↓
[Auto-regenerate signed URLs]
↓
Update database + return
# Required
GCS_BUCKET_NAME=your-bucket-name
GCP_PROJECT_ID=your-project-id
GOOGLE_APPLICATION_CREDENTIALS=credentials/sa.json
# Optional (with defaults)
ATTACHMENTS_MAX_SIZE_BYTES=10485760 # 10MB
ATTACHMENTS_RETENTION_DAYS=7The service account must have these IAM roles on the bucket:
storage.objects.create- Upload new attachmentsstorage.objects.get- Generate signed URLsstorage.objects.delete- Cleanup expired attachments
Example IAM policy:
{
"bindings": [
{
"role": "roles/storage.objectAdmin",
"members": ["serviceAccount:your-sa@project.iam.gserviceaccount.com"]
}
]
}Attachments use a structured path:
{session_id}/{attachment_id}__{sanitized_filename}
Example:
b9647eebdf3063cb0ddf9b55914aaeb2/a7de4893__screenshot.png
This ensures:
- Session-based organization
- No filename collisions
- Safe characters only (alphanumeric, underscore, hyphen, dot)
CREATE TABLE attachments (
attachment_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
gcs_blob TEXT NOT NULL, -- Full GCS object path
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
signed_url TEXT NOT NULL, -- Current valid URL
signed_url_expires_at TEXT NOT NULL, -- ISO8601 timestamp
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES conversations(session_id)
);ALLOWED_ATTACHMENT_MIME_TYPES = {
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"application/pdf",
}Additional types can be added to AttachmentService.ALLOWED_ATTACHMENT_MIME_TYPES.
Request:
POST /api/uploads
Content-Type: multipart/form-data
file: (binary)
session_id: "abc123..."Response:
{
"attachment": {
"id": "a7de4893...",
"displayUrl": "https://storage.googleapis.com/bucket/...?X-Goog-Signature=...",
"deliveryUrl": "https://storage.googleapis.com/bucket/...?X-Goog-Signature=...",
"mimeType": "image/png",
"sizeBytes": 245678
}
}The displayUrl and deliveryUrl are identical signed URLs valid for 7 days (configurable via ATTACHMENTS_RETENTION_DAYS).
When fetching messages, the backend:
- Queries messages with attachment references
- Checks each attachment's
signed_url_expires_at - If expired, generates new signed URL and updates database
- Returns messages with fresh URLs
This ensures URLs in responses are always valid.
src/backend/services/
gcs.py # GCS client wrapper (upload, delete, sign)
attachments.py # Service layer (validation, metadata)
attachments_naming.py # Blob name sanitization
src/backend/routers/
uploads.py # POST /api/uploads endpoint
src/backend/repository.py
# SQLite operations for attachments table
GCSService (services/gcs.py):
upload_bytes(blob_name, data, content_type)- Upload to GCSdelete_blob(blob_name)- Remove objectsign_get_url(blob_name, expires_delta)- Generate signed URL
AttachmentService (services/attachments.py):
save_user_upload(session_id, upload)- Handle FastAPI UploadFilesave_model_image_bytes(session_id, data, mime_type)- Store generated imagesdelete_attachment(attachment_id)- Remove from GCS and databaseensure_fresh_signed_url(attachment)- Regenerate if expired
Located in src/backend/tasks/cleanup.py, runs periodically to:
- Query attachments where
signed_url_expires_at < now() - Delete GCS objects via
gcs.delete_blob() - Remove database records
Currently triggered on application startup. For production, consider:
- Cron job calling cleanup endpoint
- Celery/Redis-based task queue
- Cloud Scheduler (if running on GCP)
As a safety net, configure a bucket lifecycle rule:
{
"lifecycle": {
"rule": [
{
"action": {"type": "Delete"},
"condition": {
"age": 9,
"matchesPrefix": [""]
}
}
]
}
}This auto-deletes objects older than retention period + buffer (e.g., 7 days retention + 2 days = 9 days).
If migrating from local disk storage:
- Old
storage_pathcolumn is ignored (can be dropped after migration) - New uploads go directly to GCS
- Old files remain locally until manually archived or deleted
- No automatic backfill of old attachments to GCS
If GCS is unavailable:
- Set
LEGACY_ATTACHMENTS_DIR=/path/to/local/storage(emergency fallback) - Code can fall back to local storage for MCP tools that need filesystem access
- Note: Main upload endpoint requires GCS, no automatic local fallback
Tests are in tests/test_attachments.py and cover:
- Upload validation (size, type)
- GCS blob creation
- Signed URL generation
- Expired URL refresh
- Cleanup operations
Run tests:
uv run pytest tests/test_attachments.py -vNote: Tests use mocked GCS client to avoid real API calls and costs.
Solution:
- Verify service account has
storage.objects.createpermission - Check
GOOGLE_APPLICATION_CREDENTIALSpoints to valid JSON - Ensure bucket exists:
gsutil ls gs://your-bucket-name
Possible causes:
- Signed URL expired (should auto-refresh on next history fetch)
- CORS not configured on bucket
- Network/firewall blocking GCS
Debug steps:
# Check URL manually
curl -I "https://storage.googleapis.com/bucket/path?X-Goog-Signature=..."
# Check database
sqlite3 data/chat_sessions.db "SELECT attachment_id, signed_url_expires_at FROM attachments;"
# Force refresh by fetching history
curl http://localhost:8000/api/chat/session/{id}/messagesSolution:
- Check logs for scheduled task errors
- Manually trigger: call cleanup function from Python shell
- Verify retention period is reasonable (not too long)
- Upload latency: Network latency to GCS (typically 50-200ms)
- Signed URL generation: Cryptographic signing (~5ms per URL)
- Database queries: Indexed by
attachment_idandsession_id(fast) - Concurrent uploads: GCS handles parallelism well, no bottleneck
For high-volume scenarios:
- Consider GCS multipart upload for files >5MB
- Batch signed URL generation
- Use GCS CDN if serving globally
- Keep bucket private: Never enable public access
- Short TTL: Use shortest viable signed URL expiration
- Rotate service accounts: Periodically generate new SA keys
- Audit access: Enable GCS audit logs
- Validate uploads: Always check MIME type and size server-side
- No sensitive filenames: Sanitize to prevent info disclosure
- REFERENCE.md - Operations guide with troubleshooting
- README.md - Project setup and quick start