mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Add a "Cancel subscription" button beside "Manage Subscription" in Settings. It opens a dialog with one optional question (five answers, no default, a note box under the two that want detail), then schedules the Stripe subscription to end at the close of the current period without a trip to the portal. The answer is stored in a new subscription_cancellations table and shown, with an all-time tally, on the admin dashboard; the category is also mirrored onto Stripe's cancellation feedback, the free text stays local. The cancel route claims the local cancel flag with a conditional update before calling Stripe, so two racing requests cannot both write a reason row, and hands the claim back when Stripe refuses. A subscription Stripe no longer knows answers 409 with a pointer to the portal instead of a 500. The route carries an account-keyed rate limit on top of the shared IP one. Two fixes found on the way: the pinned Stripe API version reports current_period_end on the subscription item rather than the subscription, so the sync stored null for every period end; a shared helper now reads the item first. And the RadioGroup styles targeted a data-checked attribute radix never writes, so the checked state was invisible in the light theme.
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
import { createHash } from 'crypto';
|
|
import { db } from '@/lib/db';
|
|
import { NextResponse } from 'next/server';
|
|
import { logError, logWarn } from '@/lib/logger';
|
|
|
|
const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
|
|
const globalForRateLimitCleanup = globalThis as unknown as {
|
|
rateLimitCleanupIntervalStarted?: boolean;
|
|
};
|
|
|
|
interface RateLimitConfig {
|
|
windowMs: number; // Time window in milliseconds
|
|
maxRequests: number; // Max requests per window
|
|
}
|
|
|
|
interface RateLimitResult {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
resetAt: Date;
|
|
}
|
|
|
|
const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
|
|
function isRateLimitDisabled(): boolean {
|
|
const rawValue = process.env.DISABLE_RATE_LIMIT?.trim().toLowerCase();
|
|
return rawValue !== undefined && TRUTHY_ENV_VALUES.has(rawValue);
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'production' && isRateLimitDisabled()) {
|
|
throw new Error(
|
|
'DISABLE_RATE_LIMIT must not be set in production. ' +
|
|
'Remove or unset the environment variable before deploying.'
|
|
);
|
|
}
|
|
|
|
// Without a proxy mode every caller resolves to 127.0.0.1, so the limiter counts the whole
|
|
// world in one bucket. That is the deliberate trade-off (trusting a spoofable header is
|
|
// worse), but a deployment behind a proxy should know it is running with global rather
|
|
// than per-client limits rather than discover it under load.
|
|
if (process.env.NODE_ENV === 'production' && !process.env.TRUSTED_PROXY_MODE?.trim()) {
|
|
logWarn(
|
|
'TRUSTED_PROXY_MODE is not set. Every request resolves to 127.0.0.1, so rate limits ' +
|
|
'apply per process rather than per client. Set TRUSTED_PROXY_MODE=cloudflare or ' +
|
|
'TRUSTED_PROXY_MODE=nginx once you have confirmed your proxy overwrites the ' +
|
|
'corresponding header on every inbound request.'
|
|
);
|
|
}
|
|
|
|
// Industry-standard rate limit defaults per action
|
|
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
|
// Auth — strict to prevent brute force / credential stuffing
|
|
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
|
|
'share-unlock': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min per IP
|
|
'share-unlock-token': { windowMs: 15 * 60 * 1000, maxRequests: 8 }, // 8 per 15 min per IP+token
|
|
|
|
// Content creation — moderate limits
|
|
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
|
|
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
|
|
'billing-cancel': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour per account
|
|
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
|
|
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
|
|
'asset-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
|
|
'asset-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'asset-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'asset-r2-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'subtitle-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
|
|
'subtitle-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'subtitle-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
|
|
// Search — debounced on client but protect against scripted callers
|
|
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
|
|
|
|
// Watch progress — allow frequent updates but prevent abuse
|
|
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
|
|
|
|
// Exports
|
|
'comment-export': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
|
|
// Downloads — strict enough to limit upstream probing/cost abuse
|
|
'video-download': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
|
|
'video-download-prepare': { windowMs: 60 * 1000, maxRequests: 5 }, // 5 per minute
|
|
'project-download': { windowMs: 60 * 1000, maxRequests: 3 }, // 3 per minute
|
|
|
|
// Email verification
|
|
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
|
|
'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
|
|
// Onboarding — one-time action, very strict. Both are keyed by user id, not IP:
|
|
// an office behind one address must not be able to lock its colleagues out of
|
|
// finishing onboarding.
|
|
'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
'onboarding-source': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
|
|
// Member management
|
|
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
|
// Unauthenticated invitation preview (accept landing + invited sign-up). Two buckets,
|
|
// as with share-unlock: a generous per-IP one that bounds token enumeration, and a
|
|
// tight per-IP+token one that stops repeated probing of a single invitation.
|
|
'invitation-preview': { windowMs: 15 * 60 * 1000, maxRequests: 120 }, // 120 per 15 min per IP
|
|
'invitation-preview-token': { windowMs: 15 * 60 * 1000, maxRequests: 12 }, // 12 per 15 min per IP+token
|
|
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
|
|
// Mutations (update/delete) — moderate
|
|
mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
|
|
|
|
// Analytics beacon — anonymous and public, so bound it per IP
|
|
'analytics-beacon': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
|
|
|
// Anonymous visitor events recorded server-side from the landing pages. Bounds
|
|
// a flood that would otherwise write two rows per request forever, and is
|
|
// deliberately generous: these are the denominator of every rate on the
|
|
// scoreboard, so a limit that bites real traffic costs more than the flood it
|
|
// stops. Only applied when the client IP is real — see isClientIpTrustworthy.
|
|
'analytics-visitor': { windowMs: 60 * 60 * 1000, maxRequests: 240 }, // 240 per hour
|
|
|
|
// General reads — generous
|
|
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
|
|
};
|
|
|
|
// Column widths of rate_limits.key and rate_limits.action in prisma/schema.prisma. A value
|
|
// wider than its column would fail the INSERT with SQLSTATE 22001.
|
|
const RATE_LIMIT_KEY_MAX_LENGTH = 255;
|
|
const RATE_LIMIT_ACTION_MAX_LENGTH = 50;
|
|
|
|
/**
|
|
* Fits a value to its column without ever giving up on counting it. A SHA-256 hex digest
|
|
* is 64 characters, so it is truncated for the narrower action column; 50 hex characters
|
|
* is 200 bits, far past any collision that matters for a rate limit bucket.
|
|
*/
|
|
function fitToColumn(value: string, maxLength: number): string {
|
|
if (value.length <= maxLength) return value;
|
|
return createHash('sha256').update(value).digest('hex').slice(0, maxLength);
|
|
}
|
|
|
|
/**
|
|
* Check and update rate limit for a given key and action
|
|
* Uses PostgreSQL UNLOGGED table for performance
|
|
*/
|
|
export async function checkRateLimit(
|
|
key: string,
|
|
action: string,
|
|
config?: RateLimitConfig
|
|
): Promise<RateLimitResult> {
|
|
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
|
|
|
|
if (isRateLimitDisabled()) {
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests,
|
|
resetAt: new Date(Date.now() + windowMs),
|
|
};
|
|
}
|
|
|
|
const windowSeconds = Math.floor(windowMs / 1000);
|
|
|
|
// Anything wider than its column is replaced by a digest rather than skipped. Skipping
|
|
// meant the limit stopped applying altogether, and letting the value through meant the
|
|
// INSERT failed with SQLSTATE 22001 and the catch below allowed the request anyway.
|
|
// Both were fail-open. A digest is stable, so the same caller keeps the same bucket.
|
|
const storedKey = fitToColumn(key, RATE_LIMIT_KEY_MAX_LENGTH);
|
|
const storedAction = fitToColumn(action, RATE_LIMIT_ACTION_MAX_LENGTH);
|
|
|
|
try {
|
|
// Atomic upsert with window check
|
|
// If window expired, reset count; otherwise increment
|
|
const result = await db.$queryRaw<
|
|
Array<{
|
|
count: number;
|
|
window_start: Date;
|
|
is_new_window: boolean;
|
|
}>
|
|
>`
|
|
INSERT INTO rate_limits (key, action, count, window_start)
|
|
VALUES (${storedKey}, ${storedAction}, 1, NOW())
|
|
ON CONFLICT (key, action) DO UPDATE SET
|
|
count = CASE
|
|
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
|
THEN 1
|
|
ELSE rate_limits.count + 1
|
|
END,
|
|
window_start = CASE
|
|
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
|
THEN NOW()
|
|
ELSE rate_limits.window_start
|
|
END
|
|
RETURNING count, window_start,
|
|
(window_start = NOW()) as is_new_window
|
|
`;
|
|
|
|
const record = result[0];
|
|
const resetAt = new Date(record.window_start.getTime() + windowMs);
|
|
const remaining = Math.max(0, maxRequests - record.count);
|
|
const allowed = record.count <= maxRequests;
|
|
|
|
return { allowed, remaining, resetAt };
|
|
} catch (error) {
|
|
// If table doesn't exist, allow the request but log warning
|
|
logError('Rate limit check failed (table may not exist):', error);
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests,
|
|
resetAt: new Date(Date.now() + windowMs),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Basic IP format validation — IPv4 or IPv6 (loose check, rejects obvious garbage)
|
|
const IP_PATTERN = /^[\da-fA-F.:]+$/;
|
|
|
|
function isPlausibleIp(value: string): boolean {
|
|
return value.length <= 45 && IP_PATTERN.test(value);
|
|
}
|
|
|
|
/**
|
|
* Get client IP from request headers.
|
|
*
|
|
* Trusting proxy-injected headers is only safe when a known trusted proxy sits in front
|
|
* of this server and strips or overwrites those headers before forwarding requests.
|
|
* Set TRUSTED_PROXY_MODE to opt in:
|
|
*
|
|
* TRUSTED_PROXY_MODE=cloudflare — trust cf-connecting-ip (Cloudflare edge)
|
|
* TRUSTED_PROXY_MODE=nginx — trust x-real-ip / x-forwarded-for (Nginx real_ip_header)
|
|
*
|
|
* Without TRUSTED_PROXY_MODE set, no proxy headers are trusted: all requests appear
|
|
* as 127.0.0.1, which means rate limits apply per-process rather than per-client IP.
|
|
* In that configuration, prefer session/user-keyed rate limits for authenticated endpoints.
|
|
*
|
|
* WARNING: Do not set TRUSTED_PROXY_MODE unless you have confirmed that your proxy
|
|
* strips or overwrites the corresponding headers on every inbound request. Failing to
|
|
* do so allows clients to spoof their IP and bypass rate limits.
|
|
*/
|
|
export function getClientIp(request: Request): string {
|
|
return getClientIpFromHeaders(request.headers);
|
|
}
|
|
|
|
/**
|
|
* Same resolution as {@link getClientIp}, for callers that only have headers rather than a
|
|
* Request — server components reading `await headers()`.
|
|
*/
|
|
export function getClientIpFromHeaders(headers: Headers): string {
|
|
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
|
|
|
|
if (mode === 'cloudflare') {
|
|
// cf-connecting-ip is injected by Cloudflare and cannot be set by clients
|
|
// when origin access is restricted to Cloudflare's IP ranges.
|
|
const cfIp = headers.get('cf-connecting-ip');
|
|
if (cfIp && isPlausibleIp(cfIp)) {
|
|
return cfIp;
|
|
}
|
|
}
|
|
|
|
if (mode === 'nginx') {
|
|
// x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable
|
|
// by clients when set_real_ip_from is configured for the upstream proxy).
|
|
const realIp = headers.get('x-real-ip');
|
|
if (realIp && isPlausibleIp(realIp)) return realIp;
|
|
|
|
// x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used.
|
|
const forwardedFor = headers.get('x-forwarded-for');
|
|
if (forwardedFor) {
|
|
const entries = forwardedFor.split(',');
|
|
const last = entries[entries.length - 1].trim();
|
|
if (isPlausibleIp(last)) return last;
|
|
}
|
|
}
|
|
|
|
// No trusted proxy configured — fall back to a constant value.
|
|
// Rate limiting will apply per-process; use userId-keyed limits for authenticated endpoints.
|
|
return '127.0.0.1';
|
|
}
|
|
|
|
/**
|
|
* Whether {@link getClientIp} resolves to the caller rather than to 127.0.0.1.
|
|
*
|
|
* Without TRUSTED_PROXY_MODE every request shares one bucket. That is a usable
|
|
* global brake on an endpoint nobody hits in a loop, and useless on a landing
|
|
* page: the bucket would empty on real traffic long before it emptied on an
|
|
* attacker, and the counting this whole subsystem exists for would stop. Callers
|
|
* that only make sense per-client check this first.
|
|
*/
|
|
export function isClientIpTrustworthy(): boolean {
|
|
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
|
|
return mode === 'cloudflare' || mode === 'nginx';
|
|
}
|
|
|
|
/**
|
|
* Create rate limit headers for response
|
|
*/
|
|
export function rateLimitHeaders(result: RateLimitResult, maxRequests: number): HeadersInit {
|
|
return {
|
|
'X-RateLimit-Limit': maxRequests.toString(),
|
|
'X-RateLimit-Remaining': result.remaining.toString(),
|
|
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cleanup old rate limit entries (call periodically)
|
|
*/
|
|
export async function cleanupRateLimits(): Promise<void> {
|
|
try {
|
|
await db.$executeRaw`SELECT cleanup_rate_limits()`;
|
|
} catch (error) {
|
|
logError('Rate limit cleanup failed:', error);
|
|
}
|
|
}
|
|
|
|
// Start cleanup interval once per process to avoid duplicate scheduling on module reload.
|
|
if (
|
|
!globalForRateLimitCleanup.rateLimitCleanupIntervalStarted &&
|
|
typeof setInterval !== 'undefined'
|
|
) {
|
|
const interval = setInterval(() => {
|
|
cleanupRateLimits().catch((err) => logError('Unexpected error:', err));
|
|
}, RATE_LIMIT_CLEANUP_INTERVAL_MS);
|
|
|
|
// Avoid keeping Node.js process alive because of housekeeping timers.
|
|
interval.unref?.();
|
|
globalForRateLimitCleanup.rateLimitCleanupIntervalStarted = true;
|
|
}
|
|
|
|
/**
|
|
* One-call rate limit check that returns a 429 NextResponse if blocked, or null if allowed.
|
|
* Use at the top of any API handler:
|
|
* const limited = await rateLimit(request, 'comment');
|
|
* if (limited) return limited;
|
|
*/
|
|
export async function rateLimit(
|
|
request: Request,
|
|
action: string,
|
|
config?: RateLimitConfig
|
|
): Promise<NextResponse | null> {
|
|
const ip = getClientIp(request);
|
|
const cfg = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
|
|
const result = await checkRateLimit(ip, action, cfg);
|
|
|
|
if (!result.allowed) {
|
|
return NextResponse.json(
|
|
{ error: 'Too many requests. Please try again later.' },
|
|
{
|
|
status: 429,
|
|
headers: rateLimitHeaders(result, cfg.maxRequests),
|
|
}
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|