Distribution PDF Security Migration
Overview
Distribution PDFs contain sensitive financial information (investor names, amounts, profit distributions, affiliate commissions) and were previously stored in public/distribution-pdfs/, making them accessible to anyone with the URL.
This migration moves PDFs to private storage with authenticated access.
Changes Made
1. New Authenticated Download API
- Endpoint:
GET /api/admin/distribution-pdfs/{id}/download - Authentication: Founder-only (checks JWT cookie)
- Functionality: Serves PDF files from private storage
- Backward Compatible: Handles both old
/distribution-pdfs/and new/storage/paths
2. Private Storage Directory
- Location:
storage/distribution-pdfs/(outsidepublic/) - Docker Volume: Mounted in
docker-compose.ymlfor persistence - Gitignore: Added to prevent committing sensitive files
3. Updated PDF Generation
app/api/cycles/[id]/generate-pdf/route.ts- Saves tostorage/scripts/regenerate-pdf.ts- Saves tostorage/
4. Updated UI
app/admin/distribution-pdfs/page.tsx- Uses authenticated API endpoint- View and Download buttons now route through
/api/admin/distribution-pdfs/{id}/download
Deployment Steps
Development/Local Testing
# 1. Run migration script to move existing PDFs
./opp migrate-pdfs-to-private
# 2. Verify PDFs are accessible at /admin/distribution-pdfs
# 3. Test download functionality
# 4. Test PDF viewer modal
# 5. After verification, remove old PDFs (optional)
rm -rf public/distribution-pdfs/*.pdf
Production Deployment
# ON PRODUCTION HOST (SSH into jeangrey)
# 1. Create storage directory on host
cd ~/public_html/reprise
mkdir -p storage/distribution-pdfs
# 2. Copy existing PDFs from Docker to host storage
docker cp opportunitydao-app:/app/public/distribution-pdfs/. ./storage/distribution-pdfs/
# 3. Set proper ownership
chown -R opportunitydao:opportunitydao ./storage
# 4. Deploy new code (git push triggers CI/CD)
# This will:
# - Update app with new authenticated endpoint
# - Mount storage/ directory via docker-compose.yml
# - Start serving PDFs from private storage
# 5. After deployment, run migration script inside container
docker-compose exec app npx tsx scripts/migrate-pdfs-to-private-storage.ts
# 6. Verify PDFs are accessible
# Navigate to https://opportunitydao.com/admin/distribution-pdfs
# Test download and view functionality
# 7. Clean up old public PDFs (after confirming everything works)
docker-compose exec app rm -rf /app/public/distribution-pdfs/*.pdf
Docker Volume Configuration
The docker-compose.yml now includes:
volumes:
# Private storage for sensitive user-generated files (PDFs, uploads)
# Persists across container restarts and deployments
- ./storage:/app/storage
This ensures:
- ✅ PDFs persist across deployments
- ✅ No data loss on container restart
- ✅ Files are on host filesystem (easy backup)
- ✅ Files are NOT in
public/(not web-accessible)
Security Benefits
Before (Insecure)
- ❌ PDFs in
public/distribution-pdfs/ - ❌ Accessible to anyone with URL:
https://domain.com/distribution-pdfs/Profit_Distribution_Cycle1.pdf - ❌ No authentication required
- ❌ Sensitive financial data exposed
After (Secure)
- ✅ PDFs in
storage/distribution-pdfs/(private) - ✅ Accessible only through authenticated API endpoint
- ✅ Founder-only access (JWT verification)
- ✅ Audit trail (API logs who accessed what)
- ✅ Backward compatible (old paths still work during migration)
Migration Script Details
Command: ./opp migrate-pdfs-to-private
What it does:
- Creates
storage/distribution-pdfs/directory - Finds all PDF records in database
- Copies files from
public/distribution-pdfs/tostorage/distribution-pdfs/ - Updates database records with new file paths
- Provides summary of migration results
Safe to run multiple times - skips already migrated PDFs
Rollback Plan
If issues arise during production deployment:
# 1. Revert code changes
git revert <commit-hash>
git push origin main
# 2. PDFs remain in both locations, so old code still works
# 3. Clean up after confirming rollback works
rm -rf ~/public_html/reprise/storage/distribution-pdfs
Testing Checklist
- View PDF list at
/admin/distribution-pdfs - Download a PDF (verify it downloads)
- View a PDF in modal (verify it displays)
- Try to access old public URL directly (should fail after cleanup)
- Try to access download API without authentication (should return 401)
- Generate new PDF for a settled cycle (should save to
storage/) - Regenerate old PDF using CLI (should save to
storage/)
Future Enhancements
Consider adding:
- Investor-specific PDF access (let investors download their own cycle reports)
- PDF download audit logging
- Automatic old file cleanup after successful migration
- S3/cloud storage for scalability
- PDF encryption at rest