Date: May 2, 2026
Developer: Renilson Jr.
Focus: Documents Blueprint - Upload, Download, Serve, Quota Limits
Repository: https://github.com/renilsonjr/vaultdocs
Branch: main
File: app/services/storage_service.py
Classes:
StorageService(ABC) - Abstract base class defining the interfaceLocalStorageService- Local filesystem implementationAzureBlobService- Stub for future Azure integrationStorageError- Custom exception
Key Methods:
upload(file, tenant_id, node_id, filename)→ Returns storage_pathdelete(storage_path)→ Removes file from storageget_download_url(storage_path, expires_in_seconds)→ Generates download URLsanitize_filename(filename)→ Security: prevents directory traversal
Security Features:
secure_filename()from werkzeug - prevents../../etc/passwdattacks- Multi-tenant isolation via directory structure:
{base}/{tenant}/{node}/{file} - Relative path storage in database for backend portability
Files Created:
app/blueprints/documents/routes.py- Upload & download endpointsapp/blueprints/documents/schemas.py- Marshmallow validationapp/blueprints/documents/__init__.py- Blueprint package
Endpoints:
- Accepts multipart/form-data with file + metadata
- Validates MIME type (pdf, images, docx, xlsx, txt)
- Enforces file size limits by plan:
- Free: 25 MB
- Professional: 100 MB
- Enterprise: 500 MB
- Checks permissions (admin/owner can upload)
- Creates document node in database
- Returns 201 with document details
- Validates document exists and belongs to user's tenant
- Generates time-limited download URL (1 hour expiry)
- Returns download_url and expires_at
- Returns 200 with URL details
Business Logic:
- Parent node validation (cannot upload to document node)
- Tenant isolation (cross-tenant access blocked)
- Soft delete awareness (ignores deleted documents)
- Original filename preserved in metadata
Files Created:
app/blueprints/files/routes.py- File serving endpointapp/blueprints/files/__init__.py- Empty (blueprint package marker)
Endpoint:
- Public endpoint (no JWT required - security via obscure UUIDs)
- Uses Flask's
send_file()to serve file bytes - Auto-detects Content-Type from file extension
- Sets proper headers for browser display/download
Security Model:
- Download endpoint validates permissions before giving URL
- File serving URL contains UUIDs (hard to guess)
- In production: will use Azure SAS tokens with expiration
Model: app/models/node.py
New Fields Added:
storage_path = db.Column(db.String(500), nullable=True) # Relative path
file_size = db.Column(db.BigInteger, nullable=True) # Bytes (changed from Integer)
mime_type = db.Column(db.String(100), nullable=True) # e.g., 'application/pdf'Why BigInteger:
- Integer max: ~2.1 GB (too small)
- BigInteger max: ~9 exabytes (future-proof)
File: app/config.py
Added Settings:
# Storage Backend
STORAGE_BACKEND = 'local' # or 'azure'
STORAGE_BASE_PATH = '/tmp/vaultdocs-uploads' # macOS compatible
# File Size Limits (per upload)
MAX_FILE_SIZE_FREE = 25 * 1024 * 1024 # 25 MB
MAX_FILE_SIZE_PRO = 100 * 1024 * 1024 # 100 MB
MAX_FILE_SIZE_ENTERPRISE = 500 * 1024 * 1024 # 500 MB
# Free Plan Quotas
STORAGE_QUOTA_FREE = 3 * 1024 * 1024 * 1024 # 3 GB total
DOCUMENT_LIMIT_FREE = 50 # 50 documents maxFile: app/__init__.py
Added:
# Initialize Storage Service
from app.services.storage_service import LocalStorageService, AzureBlobService
if app.config['STORAGE_BACKEND'] == 'azure':
app.storage = AzureBlobService(...)
else:
app.storage = LocalStorageService(
base_path=app.config['STORAGE_BASE_PATH']
)
# Register Blueprints
from .blueprints.documents.routes import documents_bp
from .blueprints.files.routes import files_bp
app.register_blueprint(documents_bp)
app.register_blueprint(files_bp)File: tests/integration/test_documents_api.py
Tests:
- ✅
test_upload_pdf_success- Upload works correctly - ✅
test_download_document_success- Download URL generated - ✅
test_serve_file_success- File bytes served correctly - ✅
test_upload_no_file_returns_400- Error: no file - ✅
test_upload_disallowed_mime_type_returns_400- Error: wrong MIME - ✅
test_upload_to_document_node_returns_422- Error: invalid parent
Run All Tests:
./.venv/bin/python -m pytest tests/integration/test_documents_api.py -vStatus: Implementation started, tests being written
Goal: Enforce Free plan limits:
- Storage quota: 3 GB total
- Document count: 50 documents max
Code Location: app/blueprints/documents/routes.py (quota checks in upload endpoint)
Tests to Complete:
test_upload_free_plan_storage_quota_exceededtest_upload_free_plan_document_count_exceeded
ALLOWED_MIME_TYPES = {
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # docx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # xlsx
'text/plain'
}User uploads file via UI ↓ POST /api/v1/documents (multipart/form-data) ↓ Backend validates:
File present & not empty MIME type allowed File size within plan limit User has permission (admin/owner) Parent node valid (not a document) Quota not exceeded (Free plan only) ↓
Backend processes:
Sanitize filename (security) Upload to storage: /tmp/vaultdocs-uploads/{tenant}/{node}/{file} Create Node record in database Store metadata (original_filename, etc.) ↓
Return 201 with document details ↓ User clicks "Download" in UI ↓ GET /api/v1/documents/{id}/download ↓ Backend validates:
Document exists Belongs to user's tenant Not soft-deleted ↓
Return 200 with download_url (storage_path) and expires_at ↓ Browser/App requests file ↓ GET /api/v1/files/{tenant}/{node}/{filename} ↓ Backend serves file bytes with correct Content-Type
vaultdocs/ ├── app/ │ ├── init.py # Storage init + blueprint registration │ ├── config.py # Storage config added │ ├── models/ │ │ └── node.py # BigInteger file_size + storage fields │ ├── services/ │ │ ├── init.py │ │ └── storage_service.py # NEW - Storage abstraction │ ├── blueprints/ │ │ ├── documents/ # NEW │ │ │ ├── init.py │ │ │ ├── routes.py # Upload + download endpoints │ │ │ └── schemas.py # Validation │ │ └── files/ # NEW │ │ ├── init.py │ │ └── routes.py # File serving │ └── ... ├── tests/ │ └── integration/ │ └── test_documents_api.py # NEW - 6 tests passing └── ...
# Commit 1
git commit -m "feat(documents): implement file upload with storage abstraction"
# - Storage service (LocalStorageService, AzureBlobService)
# - Upload endpoint with validation
# - Node model updates (storage_path, file_size, mime_type)
# Commit 2
git commit -m "feat(documents): add download endpoint and validation tests"
# - Download URL endpoint
# - Error validation tests (no file, wrong MIME, invalid parent)
# Commit 3
git commit -m "feat(documents): add file serving endpoint"
# - Files blueprint for serving bytes
# - GET /api/v1/files/:tenant/:node/:filenamefrom abc import ABC, abstractmethod
class StorageService(ABC):
@abstractmethod
def upload(self, file, ...):
pass # Subclasses MUST implementWhy: Creates a contract - guarantees all storage backends have the same methods.
# Can swap implementations without changing business logic:
if config['STORAGE_BACKEND'] == 'azure':
app.storage = AzureBlobService()
else:
app.storage = LocalStorageService()
# Business code doesn't care which one:
app.storage.upload(file, ...) # Works for both!from werkzeug.utils import secure_filename
# Attack attempt:
filename = "../../etc/passwd"
# After sanitization:
safe = secure_filename(filename) # → "etc_passwd"Prevents: Directory traversal attacks.
# Directory structure:
/tmp/vaultdocs-uploads/
├── tenant-uuid-1/
│ └── node-uuid-a/
│ └── invoice.pdf
└── tenant-uuid-2/
└── node-uuid-b/
└── contract.pdfWhy: Tenant A cannot access Tenant B's files (filesystem-level isolation).
# Database stores:
storage_path = "tenant-123/node-456/file.pdf" # ✅ Relative
# NOT:
storage_path = "/tmp/vaultdocs-uploads/tenant-123/..." # ❌ AbsoluteWhy: When migrating to Azure, path changes but relative part stays same.
# Wrong (max ~2.1 GB):
file_size = db.Column(db.Integer)
# Right (max ~9 exabytes):
file_size = db.Column(db.BigInteger)Cause: Multi-line commit message with unescaped quotes
Solution: Use single-line messages or git commit (opens editor)
Solution: Select lines, press Cmd + ] to indent right, Cmd + [ to indent left
Solution: Use /tmp/vaultdocs-uploads instead
Solution: Keep blueprint __init__.py files empty (they're just package markers)
- Finish implementing quota checks
- Run and fix the 2 quota tests
- Commit quota implementation
GET /api/v1/nodes/:id/children- List docs in folder- Pagination support
DELETE /api/v1/documents/:id- Soft delete + file removal
- File size limits per plan
- Reader permission denied
- Cross-tenant access blocked
cd /Users/renilsonjr/vaultdocs
source .venv/bin/activate
# Run all document tests
pytest tests/integration/test_documents_api.py -v
# Check uploaded files
ls -lh /tmp/vaultdocs-uploads/
# Start dev server
flask run"Let's continue VaultDocs - Documents blueprint. We just finished file upload/download/serving (6 tests passing). I'm currently implementing quota limits. Let me share the checkpoint file."
Then upload this file: SESSION_CHECKPOINT_2026-05-02.md
- Database: PostgreSQL (vaultdocs_test recreated with BigInteger)
- Storage: Local filesystem at
/tmp/vaultdocs-uploads/ - Tests: 6 passing, 2 in progress (quota limits)
- Git: All work committed and pushed to main
- Learning Style: Manual typing, detailed explanations, TDD workflow
Session Complete! Great work today! 🎉