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

34 KiB
Raw Blame History

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

# 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)

// 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
# 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

// 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

// 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

// 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

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

// 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
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

// 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)

// 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)

// 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)

// 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

# /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

// 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

-- 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

# /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)

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)

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

// 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.