Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 15 additions & 58 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,48 +23,15 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build

- name: Stage release bundle
- name: Create release zip
run: |
set -eux
mkdir -p _release/web _release/api _release/db

# Web: Next.js standalone is self-contained; just copy static + public next to it.
cp -r apps/web/.next/standalone/. _release/web/
mkdir -p _release/web/apps/web/.next
cp -r apps/web/.next/static _release/web/apps/web/.next/static
cp -r apps/web/public _release/web/apps/web/public

# API: pnpm deploy produces a self-contained dir (dist + node_modules + package.json)
pnpm --filter @repo/api deploy --prod _release/api
# DB: same trick, plus the drizzle SQL + migrate.mjs.
pnpm --filter @repo/database deploy --prod _release/db
cp -r packages/database/drizzle _release/db/drizzle
cp packages/database/migrate.mjs _release/db/migrate.mjs

cp ecosystem.config.cjs _release/ecosystem.config.cjs
zip -r release.zip . -x "node_modules/*" ".git/*" ".next/*" "apps/web/.next/*" "apps/api/dist/*" "stress-tests/*"

- name: Write .env from secret
env:
PROD_ENV: ${{ secrets.PROD_ENV }}
run: |
# File is world-readable on the runner so the scp-action's
# Docker container can read it. It only lives here for ~10s
# before the runner is destroyed. Once it lands on the VM
# the next step chmod's it back to 600.
printf '%s\n' "$PROD_ENV" > _release/.env
printf '%s\n' "$PROD_ENV" > _env

- name: Copy files to VM
uses: appleboy/scp-action@v0.1.7
Expand All @@ -73,12 +40,10 @@ jobs:
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
source: "_release/*,_release/.env"
source: "release.zip,_env"
target: "/home/dittya/projects/canvasflow"
strip_components: 1
overwrite: true

- name: Migrate + reload PM2
- name: Deploy via Docker Compose
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
Expand All @@ -88,30 +53,22 @@ jobs:
script: |
set -eu
cd /home/dittya/projects/canvasflow

# Extract release bundle
unzip -o release.zip
rm release.zip
mv _env .env
chmod 600 .env

# dotenv reads .env from cwd in each process — symlink so
# both api and web (and the migrator) all find it.
ln -sf "$PWD/.env" api/.env
ln -sf "$PWD/.env" web/apps/web/.env
ln -sf "$PWD/.env" db/.env

# Apply pending DB migrations. If this fails, pm2 reload is
# never reached and the running app keeps serving the old code.
set -a; . ./.env; set +a
(cd db && node migrate.mjs)
# Build and deploy Docker containers
docker compose -f docker-compose.prod.yml up --build -d

# Start or reload PM2.
if pm2 describe canvasflow-api >/dev/null 2>&1; then
pm2 reload ecosystem.config.cjs --update-env
else
pm2 start ecosystem.config.cjs
pm2 save
fi
# Cleanup builder artifacts and dangling cache
docker image prune -f

- name: Summary
if: success()
run: |
echo "### ✅ Deployed from \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Deployed from \`${{ github.sha }}\` via Docker Compose" >> "$GITHUB_STEP_SUMMARY"
echo "- web: https://canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY"
echo "- api: https://api.canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ yarn-error.log*
.DS_Store
*.pem

.kiro
.kiro
stress-tests/
18 changes: 0 additions & 18 deletions apps/api/src/lib/rate-limit-store.ts

This file was deleted.

107 changes: 107 additions & 0 deletions apps/api/src/lib/rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Request, Response, NextFunction } from "express";
import { randomUUID } from "node:crypto";
import { isRedisConfigured, redisKey, redisReady } from "@repo/redis";

interface RateLimiterOptions {
bucketName: string;
max: number; // Bucket capacity (max burst size)
windowMs: number; // Time window in milliseconds (leak duration)
message?: string | object;
}

const gcraScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local emission_interval = window_ms / capacity
local limit = capacity * emission_interval

local tat = tonumber(redis.call('GET', key))

if not tat then
tat = now
else
tat = math.max(tat, now)
end

local tat_diff = tat - now

if tat_diff > limit then
return {0, math.ceil(tat_diff - limit)}
else
local new_tat = tat + emission_interval
redis.call('SET', key, new_tat, 'PX', math.ceil(new_tat - now))
return {1, 0}
end
`;

export function leakyBucketRateLimiter(opts: RateLimiterOptions) {
const { bucketName, max, windowMs, message } = opts;

return async (req: Request, res: Response, next: NextFunction) => {
if (!isRedisConfigured()) {
return next();
}

try {
const client = await redisReady();
if (!client) {
return next();
}

// 1. Identify client
let clientKey = "";

// Try resolving auth session token
const sessionToken = req.headers.cookie?.match(/better-auth\.session_token=([^;]+)/)?.[1];
if (sessionToken) {
clientKey = `session:${sessionToken}`;
} else {
// Fallback to visitor cookie
let visitorId = req.cookies?.["cf_visitor_id"] as string | undefined;
if (!visitorId) {
visitorId = randomUUID();
res.cookie("cf_visitor_id", visitorId, {
maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
clientKey = `visitor:${visitorId}`;
}

const key = `${redisKey("rl", bucketName)}:${clientKey}`;
const now = Date.now();

// Execute atomic GCRA script
const result = await client.eval(
gcraScript,
1,
key,
max.toString(),
windowMs.toString(),
now.toString()
) as [number, number];

const [allowed, retryAfterMs] = result;

if (allowed === 1) {
res.setHeader("X-RateLimit-Limit", max);
return next();
} else {
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
res.setHeader("Retry-After", retryAfterSec);
res.setHeader("X-RateLimit-Limit", max);

const errorMsg = message || { error: "Too many requests — slow down and try again shortly." };
return res.status(429).json(typeof errorMsg === "string" ? { error: errorMsg } : errorMsg);
}
} catch (err) {
console.error("[rate-limiter] error executing leaky bucket:", err);
return next();
}
};
}
13 changes: 5 additions & 8 deletions apps/api/src/routes/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import path from "node:path";

import express, { type NextFunction, type Request, type Response } from "express";
import multer, { MulterError } from "multer";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";

import { logger } from "@repo/logger";
import { enqueueUpload, isQueueAvailable } from "@repo/queue";
import { storageKindFor } from "@repo/services/form-upload";
import { formUploadService } from "@repo/trpc/server/services";

import { env } from "../env";
import { redisRateLimitStore } from "../lib/rate-limit-store";
import { leakyBucketRateLimiter } from "../lib/rate-limiter";

export const uploadRouter: express.Router = express.Router();

Expand Down Expand Up @@ -107,13 +107,10 @@ const upload = multer({
},
});

const uploadLimiter = rateLimit({
windowMs: 60_000,
const uploadLimiter = leakyBucketRateLimiter({
bucketName: "upload",
max: env.RATE_LIMIT_UPLOAD_MAX,
standardHeaders: "draft-7",
legacyHeaders: false,
keyGenerator: (req) => ipKeyGenerator(req.ip ?? "unknown"),
store: redisRateLimitStore("upload"),
windowMs: 60_000,
message: { error: "Too many uploads — wait a minute and try again." },
});

Expand Down
25 changes: 9 additions & 16 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import express from "express";
import { logger } from "@repo/logger";
import cors from "cors";
import compression from "compression";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";


import * as trpcExpress from "@trpc/server/adapters/express";
import { generateOpenApiDocument, createOpenApiExpressMiddleware } from "trpc-to-openapi";
Expand All @@ -14,7 +14,7 @@ import { toNodeHandler } from "better-auth/node";

import { env } from "./env";
import { uploadRouter, uploadErrorHandler } from "./routes/upload";
import { redisRateLimitStore } from "./lib/rate-limit-store";
import { leakyBucketRateLimiter } from "./lib/rate-limiter";

export const app = express();

Expand Down Expand Up @@ -75,29 +75,22 @@ app.use(

app.use(cookieParser());

const publicWriteLimiter = rateLimit({
windowMs: 60_000,
const publicWriteLimiter = leakyBucketRateLimiter({
bucketName: "public-write",
max: env.RATE_LIMIT_PUBLIC_WRITE_MAX,
standardHeaders: "draft-7",
legacyHeaders: false,
store: redisRateLimitStore("public-write"),
windowMs: 60_000,
message: { error: "Too many requests — slow down and try again in a minute." },
});

const authGlobalLimiter = rateLimit({
windowMs: 60_000,
const authGlobalLimiter = leakyBucketRateLimiter({
bucketName: "auth-global",
max: env.RATE_LIMIT_AUTH_MAX,
standardHeaders: "draft-7",
legacyHeaders: false,
keyGenerator: (req) =>
req.headers.cookie?.match(/better-auth\.session_token=([^;]+)/)?.[1] ??
ipKeyGenerator(req.ip ?? "unknown"),
store: redisRateLimitStore("auth-global"),
windowMs: 60_000,
message: { error: "Request rate exceeded for this session." },
});

app.use(
["/trpc/analytics.recordFieldAnswer", "/trpc/form.submitForm", "/trpc/feedback.submitFeedback"],
["/trpc/form.submitForm", "/trpc/feedback.submitFeedback"],
publicWriteLimiter,
);

Expand Down
2 changes: 1 addition & 1 deletion apps/api/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export default defineConfig({
minify: false,
sourcemap: false,
banner: {
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`,
js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`,
},
});
4 changes: 2 additions & 2 deletions apps/web/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ export default function AboutPage() {
dropped off, and every response in a table you can export — no spreadsheets to stitch
together first.
</p>
<Link href="/dashboard/analytics" className="hex-btn-ghost mt-7 sm:mt-8">
Explore analytics
<Link href="/dashboard/sketches" className="hex-btn-ghost mt-7 sm:mt-8">
Explore responses
</Link>
</div>
</div>
Expand Down
Loading
Loading