Files
CShare/CONTINUE.md
T
arsam a2432de7db Import documentation: CONTINUE.md
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:15:39 +03:30

973 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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://<account>.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<void> {
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<string, string> }
// 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.