# CShare — Full Production Roadmap This document covers everything built so far and everything that needs to be implemented to take CShare from a frontend prototype to a production-grade file-sharing service. Read it top to bottom before starting any backend work. --- ## What Is Already Built (Frontend) ### Pages - `/` — Landing page (Playfair italic hero, split layout, live upload preview, features grid, how-it-works) - `/upload` — Drag-and-drop upload zone with simulated progress, share link generation, "Share All" - `/dashboard` — File table + mobile card layout, search, stat cards, delete with confirm, stats modal - `/preview` — File view/download page (recipient side), countdown expiry timer - `/pricing` — 3-tier pricing page (Free / Pro / Team) - `/docs` — Documentation with sticky sidebar and API reference - `/about`, `/blog`, `/careers`, `/contact` — Company pages - `/privacy`, `/terms`, `/security`, `/gdpr` — Legal pages ### Components & System - `Navbar` — sticky, mobile hamburger, React Router wired - `Footer` — all links routed - `Toast` — context-based success/info/error notifications - `Icons` — 17 SVG icons - Design system in `index.css` — CSS variables, button/card/input classes, keyframes - Lenis smooth scrolling - React Router v7 with real URLs ### Stack - React 19, Vite 8, Tailwind CSS 4 - Outfit (body) + Syne (headings) + Playfair Display (hero) — self-hosted TTFs - Electric blue accent (`#4A90FF`), dark charcoal background (`#0D0D0F`) --- ## What Needs to Be Built --- ## 1. Backend — Node.js / Express API ### Stack Decision ``` Runtime: Node.js 20 LTS Framework: Express 5 (or Fastify for higher throughput) Language: TypeScript (strict mode) Process mgr: PM2 (production) / tsx watch (development) ``` ### Directory Structure ``` /backend /src /config # env, constants, feature flags /db # Drizzle ORM schema + migrations /middleware # auth, rate-limit, error handler, logger /routes files.ts # upload, download, delete, list auth.ts # register, login, logout, refresh links.ts # generate, resolve, validate analytics.ts # view/download events, stats admin.ts # internal admin endpoints /services storage.ts # S3/R2 abstraction expiry.ts # cron-based file expiry worker email.ts # Nodemailer / Resend wrapper analytics.ts # event recording + aggregation /workers expiry.worker.ts # scheduled deletion cleanup.worker.ts # orphaned object cleanup /utils crypto.ts # link ID generation, hash helpers validate.ts # Zod schemas for all request bodies mime.ts # content-type detection + sanitization server.ts # Express app bootstrap worker.ts # standalone worker entry (BullMQ) /migrations # SQL migration files (Drizzle Kit) package.json tsconfig.json .env.example ``` ### Environment Variables ```bash # Server PORT=3001 NODE_ENV=production # Database DATABASE_URL=postgresql://user:pass@localhost:5432/cshare # Redis REDIS_URL=redis://localhost:6379 # Storage (Cloudflare R2 or AWS S3) STORAGE_ENDPOINT=https://.r2.cloudflarestorage.com STORAGE_BUCKET=cshare-files STORAGE_ACCESS_KEY= STORAGE_SECRET_KEY= STORAGE_PUBLIC_URL=https://files.asabbagh.com # Auth JWT_ACCESS_SECRET=<64-char random hex> JWT_REFRESH_SECRET=<64-char random hex> ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=30d # Email EMAIL_FROM=noreply@cshare.app RESEND_API_KEY= # Security BCRYPT_ROUNDS=12 MAX_FILE_SIZE_FREE=104857600 # 100 MB MAX_FILE_SIZE_PRO=524288000 # 500 MB FREE_UPLOAD_MONTHLY_LIMIT=5 # Rate limiting RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_MAX_UPLOAD=10 RATE_LIMIT_MAX_DOWNLOAD=60 # Frontend URL (CORS) FRONTEND_URL=https://cshare.app ``` --- ## 2. Database — PostgreSQL ### Why PostgreSQL - Transactional integrity for file + link creation (atomically or not at all) - Row-level expiry queries with proper index support - JSONB for extensible metadata without schema churn - Native UUID support - Strong enum types for plan, status ### Schema (Drizzle ORM) ```typescript // db/schema.ts import { pgTable, uuid, text, integer, bigint, boolean, timestamp, pgEnum, jsonb, index } from 'drizzle-orm/pg-core'; // ── Enums ────────────────────────────────────────────────────────── export const planEnum = pgEnum('plan', ['free', 'pro', 'team']); export const fileStatusEnum = pgEnum('file_status', ['pending', 'uploaded', 'deleted', 'expired']); // ── Users ────────────────────────────────────────────────────────── export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), email: text('email').notNull().unique(), passwordHash: text('password_hash').notNull(), plan: planEnum('plan').notNull().default('free'), verified: boolean('verified').notNull().default(false), uploadCount: integer('upload_count').notNull().default(0), // rolling monthly counter uploadResetAt: timestamp('upload_reset_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (t) => ({ emailIdx: index('users_email_idx').on(t.email), })); // ── Refresh tokens ───────────────────────────────────────────────── export const refreshTokens = pgTable('refresh_tokens', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), tokenHash: text('token_hash').notNull().unique(), // SHA-256 of the raw token expiresAt: timestamp('expires_at').notNull(), revokedAt: timestamp('revoked_at'), createdAt: timestamp('created_at').notNull().defaultNow(), }, (t) => ({ userIdx: index('refresh_tokens_user_idx').on(t.userId), hashIdx: index('refresh_tokens_hash_idx').on(t.tokenHash), })); // ── Files ────────────────────────────────────────────────────────── export const files = pgTable('files', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), // null = anonymous storageKey: text('storage_key').notNull().unique(), // e.g. "uploads/2026/05/abc123.pdf" originalName: text('original_name').notNull(), mimeType: text('mime_type').notNull(), sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(), status: fileStatusEnum('file_status').notNull().default('pending'), passwordHash: text('password_hash'), // null = no password expiresAt: timestamp('expires_at').notNull(), deletedAt: timestamp('deleted_at'), metadata: jsonb('metadata').default({}), // extensible bag createdAt: timestamp('created_at').notNull().defaultNow(), }, (t) => ({ userIdx: index('files_user_idx').on(t.userId), statusIdx: index('files_status_idx').on(t.status), expiryIdx: index('files_expires_at_idx').on(t.expiresAt), // for expiry worker queries })); // ── Share links ──────────────────────────────────────────────────── export const links = pgTable('links', { id: text('id').primaryKey(), // short random ID, e.g. "x7k2m9" fileId: uuid('file_id').notNull().references(() => files.id, { onDelete: 'cascade' }), active: boolean('active').notNull().default(true), createdAt: timestamp('created_at').notNull().defaultNow(), }, (t) => ({ fileIdx: index('links_file_idx').on(t.fileId), })); // ── Analytics events ─────────────────────────────────────────────── export const events = pgTable('events', { id: uuid('id').primaryKey().defaultRandom(), linkId: text('link_id').notNull().references(() => links.id, { onDelete: 'cascade' }), type: text('type').notNull(), // 'view' | 'download' ip: text('ip'), // hashed, not raw userAgent: text('user_agent'), createdAt: timestamp('created_at').notNull().defaultNow(), }, (t) => ({ linkIdx: index('events_link_idx').on(t.linkId), typeIdx: index('events_type_idx').on(t.type), timeIdx: index('events_created_at_idx').on(t.createdAt), })); // ── Email verification tokens ────────────────────────────────────── export const verificationTokens = pgTable('verification_tokens', { token: text('token').primaryKey(), // random UUID stored as-is (not sensitive long-term) userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), expiresAt: timestamp('expires_at').notNull(), usedAt: timestamp('used_at'), }); // ── Password reset tokens ────────────────────────────────────────── export const passwordResetTokens = pgTable('password_reset_tokens', { tokenHash: text('token_hash').primaryKey(), // SHA-256 userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), expiresAt: timestamp('expires_at').notNull(), usedAt: timestamp('used_at'), }); // ── API keys (Pro/Team) ──────────────────────────────────────────── export const apiKeys = pgTable('api_keys', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), name: text('name').notNull(), keyHash: text('key_hash').notNull().unique(), // SHA-256 of raw key prefix: text('prefix').notNull(), // first 8 chars for display, e.g. "csh_abc1" lastUsedAt: timestamp('last_used_at'), expiresAt: timestamp('expires_at'), revokedAt: timestamp('revoked_at'), createdAt: timestamp('created_at').notNull().defaultNow(), }, (t) => ({ userIdx: index('api_keys_user_idx').on(t.userId), hashIdx: index('api_keys_hash_idx').on(t.keyHash), })); ``` ### Migrations Strategy - Use **Drizzle Kit** (`drizzle-kit generate`, `drizzle-kit migrate`) - Migration files committed to git and run in CI before deploy - Never edit past migrations — always add new ones - Production: run migrations as a pre-start step, not in the application boot path ```bash # Generate migration after schema change npx drizzle-kit generate # Run pending migrations npx drizzle-kit migrate # Inspect current DB state npx drizzle-kit studio ``` ### Key Indexes to Add Post-Launch (monitor query plans first) - Partial index on `files` where `status = 'uploaded'` and `expires_at < now()` — used by the expiry worker every minute - Partial index on `events` for last-30-days window queries — used by analytics --- ## 3. Redis ### Why Redis - Rate limiting (sliding window counters, per-IP and per-user) - Upload session state (presigned URL → file ID mapping while upload is in flight) - Short-lived caches (file metadata for hot links, avoids DB hit on every download) - BullMQ job queue (expiry worker, email queue, cleanup worker) - Session invalidation list (jti blocklist for logged-out JWTs before they expire) ### Key Namespaces ``` # Rate limiting rl:upload:{ip} # sliding window, 10 uploads/min per IP rl:download:{ip} # sliding window, 60 downloads/min per IP rl:auth:{ip} # 10 attempts/15 min, then lockout # Upload sessions (TTL: 30 min) upload_session:{sessionId} -> { fileId, userId, expectedSize, mimeType } # File metadata cache (TTL: 5 min) file_meta:{fileId} -> JSON of file row (invalidated on delete) link_meta:{linkId} -> JSON of link + file join # JWT blocklist (jti → expiry timestamp) jwt_blocklist:{jti} -> "1" # TTL = token's remaining lifetime # Monthly upload counter per free user (TTL: rolling 31 days) upload_count:{userId}:{YYYY-MM} -> integer # BullMQ queues (managed by BullMQ, don't write to these directly) bull:expiry # file expiry jobs bull:email # transactional email jobs bull:cleanup # orphan storage cleanup jobs ``` ### Rate Limiting Implementation ```typescript // middleware/rateLimit.ts import { RateLimiterRedis } from 'rate-limiter-flexible'; export const uploadLimiter = new RateLimiterRedis({ storeClient: redis, keyPrefix: 'rl:upload', points: 10, // 10 uploads duration: 60, // per 60 seconds blockDuration: 300, // block for 5 min if exceeded }); export const downloadLimiter = new RateLimiterRedis({ storeClient: redis, keyPrefix: 'rl:download', points: 60, duration: 60, }); export const authLimiter = new RateLimiterRedis({ storeClient: redis, keyPrefix: 'rl:auth', points: 10, duration: 900, // 15 minutes blockDuration: 3600, // 1 hour lockout }); ``` --- ## 4. File Storage — Cloudflare R2 ### Why R2 over S3 - Zero egress fees (biggest cost driver for a file-sharing service) - S3-compatible API — drop-in replacement - Built-in CDN via Cloudflare network - Cheaper storage per GB ### Upload Flow ``` Client Backend R2 | | | |-- POST /api/upload -->| | | (file stream) | | | |-- PutObject ----->| | | (stream body) | | |<-- ETag ----------| | | | | |-- INSERT file row | | |-- INSERT link row | |<-- { linkId, url } --| | ``` **Alternative for large files — presigned URLs:** ``` Client Backend R2 | | | |-- POST /api/upload/init -->| | | |-- createPresign ->| | |<-- presignedUrl --| |<-- { uploadUrl, sessionId } --| | | | | |---- PUT presignedUrl ------------------>| |<---- 200 --------------------------------| | | | |-- POST /api/upload/complete -->| | | { sessionId } | | | |-- HeadObject ---->| | |<-- metadata ------| | |-- INSERT file/link| |<-- { linkId, url } --| | ``` ### Storage Key Format ``` uploads/{year}/{month}/{uuidv4}/{sanitized-filename} Example: uploads/2026/05/f47ac10b-58cc-4372-a567-0e02b2c3d479/Q4-report.pdf ``` - UUID in path prevents enumeration - Year/month prefix enables efficient lifecycle rules - Sanitized filename strips path traversal characters ### File Deletion ```typescript // services/storage.ts async function deleteFile(storageKey: string): Promise { await s3.send(new DeleteObjectCommand({ Bucket: process.env.STORAGE_BUCKET, Key: storageKey, })); } ``` Always delete from storage AND update DB status in the same operation. If storage delete fails, mark the file as `pending_deletion` and retry via the cleanup worker. ### Content-Type Handling - Extract MIME type from actual file bytes (use `file-type` library), not from the browser's reported Content-Type - Serve files with `Content-Disposition: attachment` to prevent browser execution of uploaded HTML/JS - Never serve with `Content-Type: text/html` regardless of what was uploaded --- ## 5. Authentication & Authorization ### Strategy - **JWT access tokens** (short-lived, 15 min) — stored in memory on the client, never localStorage - **HTTP-only refresh tokens** (30 days) — cookie, not accessible to JavaScript - **API keys** for programmatic access (Pro/Team) — hashed in DB, `csh_` prefix ### Auth Endpoints ``` POST /api/auth/register { email, password } POST /api/auth/login { email, password } -> sets refresh cookie POST /api/auth/refresh (reads refresh cookie) -> new access token POST /api/auth/logout revokes refresh token, clears cookie POST /api/auth/verify-email { token } POST /api/auth/forgot-password { email } POST /api/auth/reset-password { token, newPassword } GET /api/auth/me returns current user ``` ### Token Lifecycle ```typescript // Access token payload { sub: userId, // UUID email: user.email, plan: 'pro', jti: randomUUID(), // for blocklist iat: now, exp: now + 15min, } // Refresh token (stored in DB as SHA-256 hash) // Raw token sent as HTTP-only cookie, never logged ``` ### Password Hashing ```typescript import bcrypt from 'bcrypt'; const ROUNDS = parseInt(process.env.BCRYPT_ROUNDS); // 12 const hash = await bcrypt.hash(password, ROUNDS); const valid = await bcrypt.compare(password, hash); ``` ### Plan-based Authorization Middleware ```typescript // middleware/auth.ts export const requirePlan = (minPlan: 'free' | 'pro' | 'team') => (req, res, next) => { const PLAN_RANK = { free: 0, pro: 1, team: 2 }; if (PLAN_RANK[req.user.plan] < PLAN_RANK[minPlan]) { return res.status(403).json({ error: 'Plan upgrade required' }); } next(); }; // Usage router.post('/upload', requireAuth, requirePlan('pro'), uploadHandler); ``` --- ## 6. API Routes ### Files ``` POST /api/v1/files/upload Multipart upload (stream to R2) POST /api/v1/files/upload/init Init presigned upload (returns URL + sessionId) POST /api/v1/files/upload/complete Finalize presigned upload GET /api/v1/files List user's files (paginated) GET /api/v1/files/:id Get file metadata DELETE /api/v1/files/:id Delete file + storage object + invalidate links PATCH /api/v1/files/:id Update expiry, password, name ``` ### Links ``` GET /api/v1/links/:linkId Resolve link -> file metadata (recipient endpoint) GET /api/v1/links/:linkId/download Redirect to signed R2 URL (or stream) POST /api/v1/links/:linkId/verify Verify password for password-protected links ``` ### Analytics ``` POST /api/v1/events Record view/download event (called from link resolution) GET /api/v1/files/:id/analytics View + download counts, timeline GET /api/v1/dashboard/stats Aggregate stats for the current user ``` ### Auth (see above) ### Response Format ```typescript // Success { data: T, meta?: { page, limit, total } } // Error { error: string, code: string, details?: Record } // HTTP status codes used consistently: // 200 — OK // 201 — Created // 204 — No Content (delete success) // 400 — Validation error // 401 — Unauthenticated // 403 — Forbidden (plan limit or wrong owner) // 404 — Not found (or intentionally opaque for security) // 409 — Conflict (e.g., email already registered) // 413 — Payload too large // 422 — Unprocessable entity // 429 — Rate limited // 500 — Internal server error (never expose stack traces) ``` ### Request Validation (Zod) ```typescript // routes/files.ts const uploadSchema = z.object({ expiresIn: z.number().int().min(3600).max(7776000).default(604800), // 1h to 90d password: z.string().min(4).max(128).optional(), }); router.post('/upload', requireAuth, upload.single('file'), async (req, res) => { const body = uploadSchema.safeParse(req.body); if (!body.success) return res.status(400).json({ error: 'Validation failed', details: body.error.flatten() }); // ... }); ``` --- ## 7. Background Workers ### Expiry Worker (runs every 60 seconds) ```typescript // workers/expiry.worker.ts import { CronJob } from 'cron'; async function runExpiry() { const expired = await db .select({ id: files.id, storageKey: files.storageKey }) .from(files) .where( and( eq(files.status, 'uploaded'), lt(files.expiresAt, new Date()), ) ) .limit(100); // process in batches for (const file of expired) { try { await deleteFromStorage(file.storageKey); await db.update(files) .set({ status: 'expired', deletedAt: new Date() }) .where(eq(files.id, file.id)); await db.update(links).set({ active: false }).where(eq(links.fileId, file.id)); await redis.del(`file_meta:${file.id}`); } catch (err) { logger.error({ fileId: file.id, err }, 'Expiry failed — will retry next tick'); } } } new CronJob('* * * * *', runExpiry).start(); ``` ### Cleanup Worker (runs every 6 hours) - List all objects in R2 bucket - Cross-reference with DB — any storage key not in DB is an orphan - Delete orphans (they're files that failed mid-upload and never got a DB row) ### Email Worker (BullMQ) ```typescript // Queue jobs for: // - Email verification on register // - Password reset // - File expiry warning (24h before, Pro plan) // - Usage limit warning (at 80% of monthly Free quota) const emailQueue = new Queue('email', { connection: redis }); // Producer await emailQueue.add('verify-email', { to: user.email, userId: user.id, token: verificationToken, }); // Consumer const worker = new Worker('email', async (job) => { if (job.name === 'verify-email') await sendVerificationEmail(job.data); if (job.name === 'reset-password') await sendResetEmail(job.data); }, { connection: redis }); ``` --- ## 8. Infrastructure & Deployment ### Architecture Overview ``` Internet │ ▼ Cloudflare (DNS + DDoS + CDN for static assets) │ ├──> Nginx (reverse proxy, port 443 → 3000 frontend, 3001 backend) │ SSL termination (Let's Encrypt via Certbot) │ ├──> Node.js API (PM2, port 3001) │ └── connects to PostgreSQL (port 5432, local socket) │ └── connects to Redis (port 6379, local socket) │ ├──> PostgreSQL 16 (local, not exposed to internet) │ ├──> Redis 7 (local, not exposed to internet) │ └──> Cloudflare R2 (file storage, via HTTPS API) ``` ### Nginx Config for API ```nginx # /etc/nginx/sites-enabled/cshare-api server { listen 3001; server_name 188.245.39.229; # Max upload size (must match backend limit) client_max_body_size 520M; client_body_buffer_size 10M; # Timeouts for large file uploads proxy_read_timeout 600s; proxy_send_timeout 600s; proxy_connect_timeout 10s; location / { proxy_pass http://127.0.0.1:4000; # internal port proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; } } ``` ### PM2 Ecosystem File ```javascript // ecosystem.config.js module.exports = { apps: [ { name: 'cshare-api', script: 'dist/server.js', instances: 'max', // one per CPU core exec_mode: 'cluster', watch: false, env_production: { NODE_ENV: 'production', }, error_file: '/var/log/cshare/api-error.log', out_file: '/var/log/cshare/api-out.log', merge_logs: true, log_date_format: 'YYYY-MM-DD HH:mm:ss Z', }, { name: 'cshare-worker', script: 'dist/worker.js', instances: 1, // single worker instance exec_mode: 'fork', watch: false, env_production: { NODE_ENV: 'production', }, }, ], }; ``` ### PostgreSQL Hardening ```sql -- Create dedicated app user, never use superuser CREATE USER cshare WITH PASSWORD 'strong-random-password'; CREATE DATABASE cshare OWNER cshare; GRANT ALL PRIVILEGES ON DATABASE cshare TO cshare; -- In postgresql.conf max_connections = 100 shared_buffers = 256MB -- 25% of RAM on a 1GB server effective_cache_size = 768MB work_mem = 4MB maintenance_work_mem = 64MB wal_level = replica -- enables point-in-time recovery archive_mode = on log_min_duration_statement = 500ms -- log slow queries ``` ### Redis Hardening ```conf # /etc/redis/redis.conf bind 127.0.0.1 # local only, never expose to internet requirepass strong-random-password maxmemory 256mb maxmemory-policy allkeys-lru save 900 1 # RDB snapshot save 300 10 appendonly yes # AOF for durability appendfsync everysec ``` --- ## 9. Security Checklist ### Input/Output - [ ] All file sizes validated server-side before writing to storage - [ ] MIME type detected from bytes, not from Content-Type header - [ ] All file names sanitized (strip `../`, null bytes, control characters) - [ ] Download served with `Content-Disposition: attachment; filename="..."` (prevents XSS via HTML files) - [ ] `X-Content-Type-Options: nosniff` on all responses - [ ] All HTML rendered by the frontend — API is JSON only, no server-side HTML ### Auth - [ ] Passwords hashed with bcrypt (rounds ≥ 12) - [ ] Refresh tokens stored as SHA-256 hash in DB (never raw) - [ ] JWT `jti` blocklist in Redis for immediate invalidation on logout - [ ] API keys stored as SHA-256 hash (never recoverable after generation) - [ ] Email verification required before upload on registered accounts - [ ] Rate limiting on auth endpoints (10 attempts / 15 min per IP) ### Infrastructure - [ ] PostgreSQL not accessible from internet (bind to `127.0.0.1` or Unix socket) - [ ] Redis not accessible from internet - [ ] All secrets in `.env`, never committed to git - [ ] `.env.example` committed with placeholder values only - [ ] UFW firewall — only 22, 80, 443, 3000 exposed externally - [ ] Fail2ban on SSH - [ ] Non-root user to run Node processes - [ ] Let's Encrypt TLS certificate auto-renewed via Certbot systemd timer ### Headers (set via Helmet.js) ```typescript app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], // Tailwind needs this imgSrc: ["'self'", "data:"], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], upgradeInsecureRequests: [], }, }, crossOriginEmbedderPolicy: false, // needed for some browser download behaviors })); ``` --- ## 10. Frontend Integration Points When the backend is ready, each of these frontend mocks needs to be replaced: ### UploadPage.jsx - Replace `simulateProgress` with a real `XMLHttpRequest` or `fetch` with `ReadableStream` to track upload progress - Replace `generateShareLink` with `POST /api/v1/files/upload` - On success, store the real link returned by the API ### DashboardPage.jsx - Replace `mockFiles` import with `GET /api/v1/files` (paginated) - Replace `deleteFile` local state mutation with `DELETE /api/v1/files/:id` - Replace static `totalViews` / `totalDownloads` with `GET /api/v1/dashboard/stats` ### FileViewPage.jsx - Replace `sharedFile` import with `GET /api/v1/links/:linkId` - Replace fake download with `GET /api/v1/links/:linkId/download` (redirect to signed R2 URL) - Handle password-protected links (prompt UI → `POST /api/v1/links/:linkId/verify`) ### Navbar + auth state - Add a `useAuth` context that holds the decoded access token payload - Conditionally show "Log in" / "Sign up" vs. user avatar dropdown - Refresh access token silently using the HTTP-only cookie before it expires ### New pages needed on the frontend - `/login` — email + password form - `/register` — email + password + plan selection - `/account` — manage subscription, change password, generate API keys, delete account - `/verify-email` — handles the `?token=` query param from the verification email - `/reset-password` — handles the `?token=` query param - `404.jsx` — for expired links and unknown routes --- ## 11. Payments — Stripe ### Integration Points ``` POST /api/v1/billing/checkout Create Stripe Checkout session (upgrade to Pro/Team) POST /api/v1/billing/portal Create Stripe Customer Portal session (manage subscription) POST /api/v1/billing/webhook Stripe webhook handler (subscription events) GET /api/v1/billing/status Current subscription status for logged-in user ``` ### Webhook Events to Handle ``` checkout.session.completed -> upgrade user plan in DB customer.subscription.updated -> sync plan changes customer.subscription.deleted -> downgrade to free, enforce limits immediately invoice.payment_failed -> email user, flag account ``` ### Plan Enforcement - On upload: check `user.plan` + `user.uploadCount` for Free users - Monthly counter (`upload_count:{userId}:{YYYY-MM}` in Redis) resets on the first of each month via a cron job - On downgrade: do not delete existing files, but block new uploads until user is under the Free limit --- ## 12. Observability ### Logging (Pino) ```typescript import pino from 'pino'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); // Log shape for every request logger.info({ method, path, statusCode, durationMs, userId, fileId }); // Never log: passwords, tokens, file contents, full IP addresses ``` ### Metrics (to export to Prometheus or Datadog) ``` cshare_uploads_total counter, labels: plan, status cshare_upload_bytes_total counter cshare_downloads_total counter cshare_active_files_gauge gauge (refreshed every 5 min) cshare_expired_files_total counter cshare_api_request_duration_ms histogram, labels: route, method, status cshare_db_query_duration_ms histogram cshare_redis_hit_ratio gauge ``` ### Error Tracking - Integrate Sentry (`@sentry/node`) for unhandled exceptions and slow transactions - Set up a Sentry project, add `SENTRY_DSN` to env - Sample rate: 100% errors, 10% performance traces in production ### Health Check Endpoint ```typescript // GET /health { status: 'ok', db: 'connected', // result of `SELECT 1` redis: 'connected', // result of PING storage: 'reachable', // result of HeadBucket uptime: 12345, // process.uptime() version: '1.0.0', } ``` --- ## 13. Implementation Order Work in this exact sequence to always have something deployable: ``` Phase 1 — Data Layer (no network, just local) [ ] PostgreSQL installed + hardened [ ] Redis installed + hardened [ ] Drizzle schema written + first migration run [ ] Test: can create a user, a file row, and query it Phase 2 — API Core (no auth, no storage) [ ] Express app with Helmet, CORS, logger, error handler [ ] POST /api/v1/files/upload (local disk for now, fake link response) [ ] GET /api/v1/links/:linkId (returns hardcoded metadata) [ ] Frontend: wire UploadPage to the real endpoint, replace simulateProgress [ ] Frontend: wire FileViewPage to the real endpoint [ ] Confirm end-to-end works with local disk storage Phase 3 — Storage [ ] Cloudflare R2 bucket created + access keys [ ] storage.ts service (upload, delete, signed URL generation) [ ] Replace local disk with R2 in upload handler [ ] Test: upload → get link → download file → file is actually the right bytes Phase 4 — Auth [ ] POST /api/auth/register + bcrypt [ ] POST /api/auth/login + JWT issuance + refresh cookie [ ] POST /api/auth/refresh + GET /api/auth/me [ ] POST /api/auth/logout + blocklist jti in Redis [ ] requireAuth middleware on upload/list/delete endpoints [ ] Frontend: /login and /register pages + useAuth context Phase 5 — Rate Limiting + Plan Enforcement [ ] rate-limiter-flexible on upload, download, auth routes [ ] Free monthly upload counter in Redis [ ] Plan checks in upload handler (size limit, count limit) Phase 6 — Workers [ ] Expiry worker (cron every 60s) [ ] BullMQ email queue + Resend integration [ ] Email verification flow (register → email → /verify-email) [ ] Password reset flow Phase 7 — Dashboard & Analytics [ ] GET /api/v1/files (list with pagination) [ ] DELETE /api/v1/files/:id [ ] POST /api/v1/events (record view/download) [ ] GET /api/v1/dashboard/stats [ ] Frontend: wire DashboardPage to real data Phase 8 — Payments [ ] Stripe account + products (Pro, Team) [ ] POST /api/v1/billing/checkout + portal [ ] POST /api/v1/billing/webhook [ ] Frontend: /pricing Buy buttons → Stripe Checkout Phase 9 — Production hardening [ ] Sentry integration (backend + frontend) [ ] Pino structured logging [ ] PM2 cluster mode [ ] Nginx tuned for large file uploads [ ] UFW + Fail2ban [ ] Let's Encrypt TLS [ ] Cloudflare proxying for cshare.app domain [ ] Uptime monitoring (UptimeRobot or Better Uptime) [ ] Weekly DB backups to a separate R2 bucket (pg_dump → upload) [ ] Runbook: how to roll back a migration, restart workers, rotate secrets ``` --- ## 14. Estimated Effort | Phase | Complexity | Notes | |---|---|---| | Data Layer | Low | Schema is defined above — mostly setup | | API Core | Medium | Express boilerplate + multipart upload | | Storage | Low | S3 SDK is straightforward | | Auth | High | Many edge cases: token refresh, blocklist, verification | | Rate Limiting | Low | `rate-limiter-flexible` handles the hard parts | | Workers | Medium | Expiry is simple; BullMQ setup takes time | | Dashboard | Medium | Pagination + analytics aggregation | | Payments | High | Stripe webhook reliability is tricky | | Hardening | Medium | Mostly config, but easy to get wrong | Total realistic estimate for a solo developer: **6–10 weeks** for Phases 1–8 at a sustainable pace.