Import documentation: CONTINUE.md

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:15:39 +03:30
parent 64ae9731e7
commit a2432de7db
+972
View File
@@ -0,0 +1,972 @@
# 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.