Compare commits
10
Commits
d0ed8790b9
...
89020fb528
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89020fb528 | ||
|
|
30a095ce41 | ||
|
|
6f32ea43d9 | ||
|
|
1a494896a5 | ||
|
|
a2432de7db | ||
|
|
64ae9731e7 | ||
|
|
0d17401286 | ||
|
|
84e561f2c7 | ||
|
|
dc6c010b0b | ||
|
|
0713e9004e |
@@ -0,0 +1,61 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Commands
|
||||
|
||||
Run from `cshare-app/`:
|
||||
|
||||
```sh
|
||||
npm run dev # Vite dev server
|
||||
npm run build # Build + precompress (creates .gz/.br)
|
||||
npm run check # lint + build (run before committing)
|
||||
npm run lint # ESLint
|
||||
```
|
||||
|
||||
Run from `backend/`:
|
||||
|
||||
```sh
|
||||
npm run dev # tsx watch src/server.ts
|
||||
npm test # tsx --test test/*.test.ts
|
||||
npm run build # tsc
|
||||
npm run check # test + build (run before committing)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Two packages:** `cshare-app/` (React 19 + Vite 8, JavaScript/JSX, no TypeScript) and `backend/` (Express 5 + TypeScript strict mode).
|
||||
|
||||
**Backend storage:** Currently uses local JSON (`backend/data/db.json`) and disk storage. PostgreSQL/Redis/R2 adapters are planned in `CONTINUE.md` but not yet implemented.
|
||||
|
||||
**Frontend routing:** React Router v7 with lazy-loaded routes. All routes defined in `src/App.jsx`.
|
||||
|
||||
**Styling:** Tailwind CSS 4 via `@tailwindcss/vite` plugin (not PostCSS). Custom design system in `src/index.css` with CSS variables and component classes (`.btn`, `.card`, `.input`).
|
||||
|
||||
**Toast system:** `ToastProvider` wraps app in `App.jsx`. Use `useToast()` hook → `addToast(message, type)` where type is `'success' | 'info' | 'error'`.
|
||||
|
||||
**API client:** `src/lib/api.js` contains `uploadFile` (XHR with progress), `listFiles`, `deleteFile`, `getDashboardStats`, `resolveLink`, `downloadUrl`, `previewUrl`.
|
||||
|
||||
## Environment
|
||||
|
||||
**Frontend:** Set `VITE_API_BASE_URL=http://localhost:4001` only when API runs on different port. Omit in production if using Nginx reverse proxy (frontend calls `window.location.origin`).
|
||||
|
||||
**Backend:** Requires `.env` with `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `ADMIN_API_KEY` (64+ char random strings). See `backend/.env.example`.
|
||||
|
||||
**Build gotcha:** `dist/` may be owned by root. If `npm run build` fails with `EACCES`, run `sudo chown -R $(whoami) cshare-app/dist/`.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `CONTINUE.md` — Full production roadmap (Postgres, Redis, R2, auth, Stripe implementation plan)
|
||||
- `CLAUDE.md` — Detailed architecture reference
|
||||
- `PRODUCTION.md` — Deployment and Nginx configuration
|
||||
- `cshare-app/src/App.jsx` — All route definitions
|
||||
- `cshare-app/src/lib/api.js` — API client
|
||||
- `backend/src/server.ts` — Express app bootstrap
|
||||
- `backend/ecosystem.config.cjs` — PM2 configuration (reads `.env` file)
|
||||
|
||||
## Style
|
||||
|
||||
**Frontend:** PascalCase for components (`UploadPage.jsx`), camelCase for hooks (`useLenis.js`) and data modules (`mockData.js`). Two-space indentation, single quotes, semicolons.
|
||||
|
||||
**Backend:** TypeScript strict mode, named exports. Route modules named by resource (`files.ts`, `links.ts`, `auth.ts`).
|
||||
|
||||
ESLint configured in `cshare-app/eslint.config.js` (flat config, React hooks + refresh plugins).
|
||||
@@ -0,0 +1,68 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
CShare is a file-sharing web application with a React frontend and an Express/TypeScript backend. The frontend is feature-rich with 12+ pages; the backend is live and working with local JSON storage. The backend (Postgres, R2 storage, auth, Stripe) is the next major work item (see `CONTINUE.md`).
|
||||
|
||||
## Commands
|
||||
|
||||
All commands run from `/srv/CShare/cshare-app/`:
|
||||
|
||||
```bash
|
||||
npm run dev # Start Vite dev server with HMR
|
||||
npm run build # Production build to dist/
|
||||
npm run lint # ESLint (flat config, React hooks + refresh plugins)
|
||||
npm run preview # Serve production build locally
|
||||
```
|
||||
|
||||
No test framework is configured yet.
|
||||
|
||||
Backend commands run from `/srv/CShare/backend/`:
|
||||
|
||||
```bash
|
||||
npm run dev # Start API with tsx watch
|
||||
npm test # Run test suite (tsx --test)
|
||||
npm run build # Compile TypeScript to dist/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Stack:** React 19 + Vite 8, Tailwind CSS 4 (via `@tailwindcss/vite` plugin), no TypeScript. Backend: Express 5 + TypeScript + tsx.
|
||||
|
||||
**Routing:** React Router v7 with lazy-loaded routes and real URLs. All routes defined in `App.jsx`. Page keys: `/`, `/upload`, `/dashboard`, `/preview/:linkId`, `/pricing`, `/docs`, `/about`, `/blog`, `/careers`, `/contact`, `/privacy`, `/terms`, `/security`, `/gdpr`. A catch-all `*` route renders `NotFoundPage`.
|
||||
|
||||
**Directory layout (inside `cshare-app/src/`):**
|
||||
- `pages/` — One component per view. Pages receive route props; `LandingPage` and `UploadPage` also accept external data props.
|
||||
- `components/` — `Navbar` (sticky, mobile hamburger), `Footer` (hidden on landing/fileview), `Icons` (17 hand-drawn SVG icons), `Toast` (`ToastProvider` context + `useToast` hook), `PageTransition` (route-level fade+slide wrapper), `SkeletonLoader` (shimmer skeleton components), `StorageBar` (segmented storage usage bar), `ActivityFeed` (recent activity feed), `FileDetailSlideOver` (slide-over panel for file details/analytics).
|
||||
- `hooks/` — `useLenis` (smooth scroll), `useScrollReveal` (intersection observer for below-fold animations).
|
||||
- `data/mockData.js` — 6 mock files + 1 `sharedFile` object, mock activity data, storage breakdown, and helper functions (`formatFileSize`, `formatDate`, `formatDateRelative`, `formatActivityTime`, `getFileIcon`, `getFileIconColor`).
|
||||
- `lib/api.js` — API client with `uploadFile` (XHR with progress), `listFiles`, `deleteFile`, `getDashboardStats`, `resolveLink`, `downloadUrl`, `previewUrl`.
|
||||
- `assets/` — Static images (hero.png, framework SVGs).
|
||||
|
||||
**Backend layout (inside `backend/src/`):**
|
||||
- `routes/` — `files.ts`, `auth.ts`, `links.ts`, `dashboard.ts`, `admin.ts`
|
||||
- `middleware/` — `session.ts`, `errorHandler.ts`, `rateLimit.ts`, `adminAuth.ts`
|
||||
- `services/` — `store.ts` (JSON file storage)
|
||||
- `utils/` — `crypto.ts`, `files.ts`, `gdrive.ts`, `preview.ts`
|
||||
- `server.ts` — Express app bootstrap
|
||||
- `worker.ts` — Background worker entry
|
||||
|
||||
**Toast system:** `ToastProvider` wraps the app in `App.jsx`. Components call `useToast()` to get `addToast(message, type)` — types are `'success'`, `'info'`, `'error'`. Auto-dismisses after 3s.
|
||||
|
||||
**Styling:** `index.css` defines the full design system via CSS custom properties and component classes (`.btn`, `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-danger`, `.card`, `.input`, `.heading-*`, `.text-*`). Tailwind utilities are used alongside these custom classes. Dark charcoal background (`#0D0D0F`) with electric blue accent (`#4A90FF`) — zero purple anywhere. Keyframe animations available: `fadeIn`, `fadeInUp`, `slideDown`, `scaleIn`, `shimmer`, `progressPulse`, `gradientMove`, `float`, `spin`, `slideInRight`, `staggerIn`, `pageFadeIn`, `copyPop`, `iconBounce`, `orbFloat1-3`, `dashMarch`, `shimmerBar`, `pulseGlow`, `countPop`, `revealLine`, `borderGlow`, `gentlePulse`.
|
||||
|
||||
**Fonts:** Three families loaded via `@font-face` from `/public/fonts/` — Syne (headings), Outfit (body), Playfair (decorative).
|
||||
|
||||
**Data flow:** Frontend calls real API endpoints via `lib/api.js`. Dashboard loads files and stats from the backend; falls back to mock data when API is unavailable. Upload uses real XHR with progress tracking. The backend stores data in `data/db.json`.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `CONTINUE.md` — Full production roadmap: what's built and what's next (Postgres, Redis, R2, auth, Stripe, workers, hardening).
|
||||
- `AGENTS.md` — Repository guidelines: coding style, build commands, commit conventions.
|
||||
- `docs/superpowers/specs/2026-05-30-dashboard-polish-design.md` — Design spec for dashboard enhancement and app polish (frontend + backend analytics API).
|
||||
|
||||
## Build Note
|
||||
|
||||
The `dist/` directory may be owned by root (from a previous build). If `npm run build` fails with `EACCES`, fix ownership first: `sudo chown -R $(whoami) cshare-app/dist/`.
|
||||
+972
@@ -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.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Production Notes
|
||||
|
||||
## Build
|
||||
|
||||
Frontend:
|
||||
|
||||
```sh
|
||||
cd cshare-app
|
||||
npm ci
|
||||
npm run check
|
||||
```
|
||||
|
||||
`npm run build` also creates `.gz` and `.br` siblings for compressible files in `dist/`.
|
||||
|
||||
Backend:
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
npm ci
|
||||
npm run check
|
||||
NODE_ENV=production npm start
|
||||
```
|
||||
|
||||
## Required Environment
|
||||
|
||||
Set these before starting the backend in production:
|
||||
|
||||
```sh
|
||||
NODE_ENV=production
|
||||
PORT=4001
|
||||
FRONTEND_URL=https://cshare.app
|
||||
PUBLIC_APP_URL=https://cshare.app
|
||||
JWT_ACCESS_SECRET=<64+ random chars>
|
||||
JWT_REFRESH_SECRET=<64+ random chars>
|
||||
ADMIN_API_KEY=<64+ random chars>
|
||||
PREVIEW_MAX_FILE_SIZE_BYTES=25000000
|
||||
DATA_DIR=/var/lib/cshare
|
||||
```
|
||||
|
||||
Set `VITE_API_BASE_URL` for the frontend only when the API is not served from the same origin. If omitted in production, the frontend calls `window.location.origin`, which works with an Nginx reverse proxy that forwards `/api` to the backend.
|
||||
|
||||
## Runtime
|
||||
|
||||
- Serve `cshare-app/dist/` with Nginx or another static host.
|
||||
- Cache hashed files under `/assets/` aggressively.
|
||||
- Serve precompressed `.br` first and `.gz` second when the client supports them.
|
||||
- Do not cache `/index.html`; it must be revalidated so deploys pick up new asset hashes.
|
||||
- Proxy `/api`, `/health`, and file download routes to the backend.
|
||||
- Keep `backend/data/` outside the repo and backed up if using the local disk adapter.
|
||||
- Protect every `/api/admin/*` request with `x-admin-key: $ADMIN_API_KEY`.
|
||||
- Small safe file previews are served from `/api/v1/links/:linkId/preview`; files over `PREVIEW_MAX_FILE_SIZE_BYTES` remain download-only.
|
||||
- The admin API is disabled unless `ADMIN_API_KEY` is set.
|
||||
|
||||
Example Nginx compression settings:
|
||||
|
||||
```nginx
|
||||
brotli on;
|
||||
brotli_static on;
|
||||
gzip on;
|
||||
gzip_static on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
```
|
||||
|
||||
`brotli_static` requires an Nginx build with the Brotli module. If that module is unavailable, keep `gzip_static on` and serve the generated `.gz` files.
|
||||
|
||||
The current backend is production-shaped but still uses local JSON and disk storage. For durable production use, replace it with the PostgreSQL, Redis, and R2 adapters described in `CONTINUE.md`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# CShare
|
||||
|
||||
CShare is a production-shaped file sharing app with a Vite/React frontend and a TypeScript/Express backend. It supports file upload, expiring share links, recipient previews for small safe files, downloads, dashboard stats, and an admin API.
|
||||
|
||||
## Project Layout
|
||||
|
||||
- `cshare-app/` - React frontend, Vite build, precompressed static assets.
|
||||
- `backend/` - Express API, local disk storage adapter, JSON metadata store, auth primitives, admin API.
|
||||
- `PRODUCTION.md` - deployment notes, required env, Nginx guidance.
|
||||
- `deploy/` - sample service and reverse-proxy config.
|
||||
|
||||
## Local Development
|
||||
|
||||
Backend:
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```sh
|
||||
cd cshare-app
|
||||
npm install
|
||||
VITE_API_BASE_URL=http://localhost:4001 npm run dev
|
||||
```
|
||||
|
||||
## Final Checks
|
||||
|
||||
```sh
|
||||
cd backend && npm run check
|
||||
cd ../cshare-app && npm run check
|
||||
```
|
||||
|
||||
Frontend builds create gzip and Brotli assets in `cshare-app/dist/`. Backend production startup requires strong `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, and `ADMIN_API_KEY` values.
|
||||
|
||||
## Publish Notes
|
||||
|
||||
Before publishing, configure the environment in `PRODUCTION.md`, serve `cshare-app/dist/` with a static host or Nginx, and proxy `/api` plus `/health` to the backend. The included local JSON/disk persistence is suitable for a single-server launch; move to PostgreSQL/R2 before depending on durable multi-server storage.
|
||||
@@ -0,0 +1,30 @@
|
||||
# CShare Backend
|
||||
|
||||
This is the local production-path API scaffold from `CONTINUE.md`. It uses Express 5 and TypeScript with local JSON metadata plus disk storage so upload, share, preview, download, dashboard, and auth flows work without external infrastructure.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
npm run check
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
The API listens on `http://localhost:4001` by default. Frontend requests use `VITE_API_BASE_URL`; in production, the frontend falls back to same-origin API calls when the variable is omitted.
|
||||
|
||||
## Implemented Locally
|
||||
|
||||
- `GET /health`
|
||||
- `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/refresh`, `POST /api/auth/logout`, `GET /api/auth/me`
|
||||
- `POST /api/v1/files/upload`
|
||||
- `GET /api/v1/files`, `GET /api/v1/files/:id`, `DELETE /api/v1/files/:id`
|
||||
- `GET /api/v1/links/:linkId`, `GET /api/v1/links/:linkId/download`
|
||||
- `GET /api/v1/links/:linkId/preview`
|
||||
- `GET /api/v1/dashboard/stats`
|
||||
- `GET /api/admin/overview`, `/api/admin/files`, `/api/admin/users`, `/api/admin/events` protected by `x-admin-key`
|
||||
|
||||
## Production Adapters Still Needed
|
||||
|
||||
The remaining roadmap items require provisioned services and credentials: PostgreSQL/Drizzle migrations, Redis/BullMQ, Cloudflare R2, Resend, Stripe, Sentry, PM2, Nginx, TLS, firewall rules, backups, and DNS. The local API keeps storage and persistence isolated so those adapters can replace the JSON/disk implementation without changing frontend routes.
|
||||
@@ -0,0 +1,25 @@
|
||||
# CShare Frontend
|
||||
|
||||
React/Vite frontend for CShare.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
npm run check
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
`npm run build` emits production assets to `dist/` and precompresses them with gzip and Brotli.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set `VITE_API_BASE_URL` when the API is hosted on a different origin:
|
||||
|
||||
```sh
|
||||
VITE_API_BASE_URL=https://api.example.com
|
||||
```
|
||||
|
||||
If omitted in production, the app uses same-origin API calls, which is the preferred setup behind an Nginx reverse proxy.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,167 @@
|
||||
# CShare Dashboard Enhancement & App Polish
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved
|
||||
**Approach:** Parallel — backend analytics API built simultaneously with frontend enhancements
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
CShare has a working frontend (12+ pages, React Router v7, dark theme design system) and a live backend (Express 5 + TypeScript, local JSON storage). The biggest gaps are in the dashboard experience and overall app polish. This spec covers both tracks.
|
||||
|
||||
**Strategy:** Start simple, grow. Focus on the dashboard (where returning users live) and app-wide polish (makes everything feel trustworthy). The backend analytics API is built in parallel, and the frontend starts with mock data then wires to real endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Track 1: Backend — Analytics & Dashboard API
|
||||
|
||||
### New/Expanded Endpoints
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/v1/dashboard/stats` | Aggregate: total files, storage used, total views, total downloads, active links, expiring soon count |
|
||||
| GET | `/api/v1/dashboard/activity` | Recent activity feed: last 20 events (views, downloads, uploads) with timestamps |
|
||||
| GET | `/api/v1/files/:id/analytics` | Per-file: view count, download count, daily breakdown (last 30 days), top referrers |
|
||||
| POST | `/api/v1/events` | Record a view/download event (called when links are resolved) |
|
||||
| GET | `/api/v1/dashboard/storage` | Storage breakdown by file type, monthly upload volume |
|
||||
|
||||
### Event Recording
|
||||
|
||||
- `GET /api/v1/links/:linkId` automatically records a `view` event
|
||||
- `GET /api/v1/links/:linkId/download` records a `download` event
|
||||
- Events include: timestamp, hashed IP, user agent
|
||||
- Stored in the existing events table
|
||||
|
||||
### Stats Computation
|
||||
|
||||
- Dashboard stats computed on-the-fly from DB (no caching initially)
|
||||
- If performance becomes an issue, add Redis caching with 5-minute TTL
|
||||
- Per-file analytics queries use a 30-day window by default
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
- `backend/src/routes/analytics.ts` — new file for analytics endpoints
|
||||
- `backend/src/routes/dashboard.ts` — expand existing file
|
||||
- `backend/src/services/analytics.ts` — new service for event recording and aggregation
|
||||
- `backend/src/routes/links.ts` — modify to record events on link resolution
|
||||
|
||||
---
|
||||
|
||||
## Track 2: Frontend — Dashboard Enhancement
|
||||
|
||||
**Principle:** Enhance the existing `DashboardPage.jsx`, do not rewrite.
|
||||
|
||||
### Stat Cards Enhancement
|
||||
|
||||
- Animated count-up on first render (number rolls from 0 to actual value)
|
||||
- Subtle gradient backgrounds per metric theme
|
||||
- Trend indicators comparing to previous period (up/down arrow + percentage)
|
||||
|
||||
### File Table Enhancement
|
||||
|
||||
- Inline file type icons with color coding (using existing `getFileIcon` and `getFileIconColor`)
|
||||
- Quick-action buttons on hover: copy link, download, delete
|
||||
- Click-to-expand row revealing file details: analytics preview, share link, expiry countdown
|
||||
- Better sort controls: date, name, size, type with visual indicators
|
||||
|
||||
### New Components (added within existing dashboard structure)
|
||||
|
||||
- **Search + filters bar** — above the table, type filter chips, date range, size range
|
||||
- **Storage usage bar** — near stats cards, shows used vs. available
|
||||
- **Recent activity feed** — compact section below stats, last 5-10 events with timestamps and badges
|
||||
- **File detail slide-over** — triggered by clicking a file row, contains:
|
||||
- File info (name, size, type, upload date, expiry)
|
||||
- Share link with copy button
|
||||
- QR code for the share link
|
||||
- Analytics mini-chart (views/downloads over last 30 days)
|
||||
- Delete action with confirmation
|
||||
- **Batch operations** — checkbox column, bulk delete, bulk copy links
|
||||
|
||||
### Polish Details
|
||||
|
||||
- Smooth animations on card hover, row expand, filter changes
|
||||
- Skeleton loading states while data loads
|
||||
- Better empty state for new users with upload CTA
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
- `cshare-app/src/pages/DashboardPage.jsx` — enhance existing
|
||||
- `cshare-app/src/components/StatsCards.jsx` — new, extracted stat cards section
|
||||
- `cshare-app/src/components/FileDetailSlideOver.jsx` — new slide-over panel
|
||||
- `cshare-app/src/components/ActivityFeed.jsx` — new activity feed component
|
||||
- `cshare-app/src/components/StorageBar.jsx` — new storage usage indicator
|
||||
- `cshare-app/src/components/SkeletonLoader.jsx` — new reusable skeleton
|
||||
|
||||
---
|
||||
|
||||
## Track 3: Frontend — App-Wide Polish & Micro-Interactions
|
||||
|
||||
### Animations & Transitions
|
||||
|
||||
- Page transitions: subtle fade + slide between route changes
|
||||
- Staggered entrance animations on card grids and file lists (elements appear sequentially)
|
||||
- Button press feedback: scale-down on mousedown, spring-back on release
|
||||
- Card hover: gentle lift with shadow deepening
|
||||
|
||||
### Loading States
|
||||
|
||||
- Skeleton screens for file lists, stat cards, and page content (no spinners)
|
||||
- Upload progress: smoother easing curves on the progress bar
|
||||
- File preview loading: blur-up placeholder technique
|
||||
|
||||
### Mobile Refinements
|
||||
|
||||
- Swipe gestures on file cards (swipe left to reveal delete)
|
||||
- Better touch targets: minimum 44px hit areas
|
||||
- Bottom sheet for file details on mobile (instead of slide-over)
|
||||
- Pull-to-refresh on dashboard
|
||||
|
||||
### Micro-Interactions
|
||||
|
||||
- Copy-to-clipboard: checkmark animation + toast
|
||||
- Delete confirmation: slide-in modal with undo option
|
||||
- Share link generation: animated link appearance with "Copied!" feedback
|
||||
- File type icons: subtle bounce on appearance
|
||||
|
||||
### Other
|
||||
|
||||
- 404 page with CShare branding and "Upload a file" CTA
|
||||
- Better scroll-to-top on navigation
|
||||
- Enhance existing `useScrollReveal` for below-fold content
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
- `cshare-app/src/pages/NotFoundPage.jsx` — new 404 page
|
||||
- `cshare-app/src/components/PageTransition.jsx` — new route transition wrapper
|
||||
- `cshare-app/src/hooks/useCountUp.js` — new animated number hook
|
||||
- `cshare-app/src/hooks/useSwipe.js` — new swipe gesture hook
|
||||
- `cshare-app/src/components/SkeletonLoader.jsx` — reusable skeleton
|
||||
- `cshare-app/src/index.css` — new animation keyframes and utility classes
|
||||
- Various page files — apply transitions and polish
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Skeleton + loading states** — immediately improves perceived performance
|
||||
2. **Dashboard stat cards enhancement** — animated count-up, trend indicators
|
||||
3. **File table enhancements** — hover actions, expand rows, sort controls
|
||||
4. **Backend analytics API** — event recording, stats endpoints (parallel with #2-3)
|
||||
5. **File detail slide-over** — info, share link, QR code, analytics chart
|
||||
6. **Activity feed + storage bar** — new dashboard sections
|
||||
7. **Page transitions** — route-level fade + slide
|
||||
8. **Micro-interactions** — copy feedback, delete undo, icon bounce
|
||||
9. **Mobile refinements** — swipe, bottom sheet, touch targets
|
||||
10. **404 page** — standalone task
|
||||
11. **Wire frontend to real API** — swap mock data for analytics endpoints
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Authentication pages (login/register/account) — separate effort
|
||||
- Stripe payments integration
|
||||
- PostgreSQL migration
|
||||
- Cloudflare R2 storage migration
|
||||
- Email verification flows
|
||||
Reference in New Issue
Block a user