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:
Chaewon Lee
2026-06-15 10:33:57 +09:00
committed by GitHub
parent 84c8a0aac9
commit 0f102eb289
14 changed files with 813 additions and 147 deletions

View File

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

View 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 };
}

View 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';