chore: prepare production deploy with API hardening and Railway adapter
* Harden API routes with rate limits, upload cap, and edit dedupe. Protect expensive endpoints from abuse, reject oversized mood uploads, dedupe concurrent edit-images calls, and surface Kakao search failures instead of silent mock fallback. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: switch to adapter-node for Railway deploy --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { JobNotFoundError } from '$lib/server/flowerFlow/jobStore.js';
|
||||
import { describeAiError } from '$lib/server/aiError.js';
|
||||
import { consumeRateLimit } from '$lib/server/rateLimit.js';
|
||||
|
||||
/**
|
||||
* @param {unknown} error
|
||||
@@ -45,14 +46,39 @@ export function toErrorResponse(error) {
|
||||
/**
|
||||
* @param {unknown} body
|
||||
* @param {number} [status]
|
||||
* @param {Record<string, string>} [extraHeaders]
|
||||
*/
|
||||
export function json(body, status = 200) {
|
||||
export function json(body, status = 200, extraHeaders = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: { 'Content-Type': 'application/json', ...extraHeaders }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} clientAddress
|
||||
* @param {import('$lib/server/rateLimit.js').RateLimitConfig} config
|
||||
* @param {string} [scope]
|
||||
*/
|
||||
export function enforceRateLimit(clientAddress, config, scope = 'api') {
|
||||
const key = `${scope}:${clientAddress || 'unknown'}`;
|
||||
const result = consumeRateLimit(key, config);
|
||||
|
||||
if (result.ok) return null;
|
||||
|
||||
const retryAfterSec = Math.ceil(result.retryAfterMs / 1000);
|
||||
return json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
code: 'rate_limited',
|
||||
retryable: true,
|
||||
retryAfterMs: result.retryAfterMs
|
||||
},
|
||||
429,
|
||||
{ 'Retry-After': String(retryAfterSec) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} formData
|
||||
* @param {string} field
|
||||
|
||||
39
src/lib/server/rateLimit.js
Normal file
39
src/lib/server/rateLimit.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/** @type {Map<string, number[]>} */
|
||||
const buckets = new Map();
|
||||
|
||||
/** @typedef {{ limit: number, windowMs: number }} RateLimitConfig */
|
||||
|
||||
export const RATE_LIMITS = {
|
||||
/** Creates a job and runs vision analysis. */
|
||||
moodAnalysis: { limit: 15, windowMs: 60 * 60 * 1000 },
|
||||
/** Initial bouquet image generation. */
|
||||
imageGeneration: { limit: 20, windowMs: 60 * 60 * 1000 },
|
||||
/** Bouquet photo edits (expensive). */
|
||||
imageEdit: { limit: 40, windowMs: 60 * 60 * 1000 },
|
||||
/** Text recipe + florist note endpoints. */
|
||||
textAi: { limit: 60, windowMs: 60 * 60 * 1000 },
|
||||
/** Kakao shop search proxy. */
|
||||
mapShops: { limit: 120, windowMs: 60 * 60 * 1000 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixed-window counter stored in memory. Good enough for a single Node instance;
|
||||
* on multi-instance/serverless each instance tracks independently.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {RateLimitConfig} config
|
||||
* @returns {{ ok: true } | { ok: false, retryAfterMs: number }}
|
||||
*/
|
||||
export function consumeRateLimit(key, config) {
|
||||
const now = Date.now();
|
||||
const recent = (buckets.get(key) ?? []).filter((timestamp) => now - timestamp < config.windowMs);
|
||||
|
||||
if (recent.length >= config.limit) {
|
||||
const oldest = recent[0] ?? now;
|
||||
return { ok: false, retryAfterMs: Math.max(config.windowMs - (now - oldest), 1000) };
|
||||
}
|
||||
|
||||
recent.push(now);
|
||||
buckets.set(key, recent);
|
||||
return { ok: true };
|
||||
}
|
||||
4
src/lib/server/uploadLimits.js
Normal file
4
src/lib/server/uploadLimits.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Moodboard / SNS upload for vision analysis */
|
||||
export const MAX_MOOD_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export const MAX_MOOD_IMAGE_LABEL = '10 MB';
|
||||
Reference in New Issue
Block a user