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

View File

@@ -10,7 +10,8 @@ import {
isImageGenerationConfigured
} from '$lib/server/gemini/image.js';
import { applyRecipeEdit } from '$lib/server/gemini/text.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { enforceRateLimit, json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
/**
* @param {unknown} value
@@ -28,9 +29,79 @@ function isPointArray(value) {
);
}
/**
* Dedupe concurrent edits for the same job (double-submit / rapid clicks).
* @type {Map<string, Promise<{ recipe: import('$lib/server/flowerFlow/jobStore.js').BouquetRecipe, imagePrompt: string, images: { primary: import('$lib/server/flowerFlow/jobStore.js').GeneratedImage } }>>}
*/
const inFlight = new Map();
/**
* @param {string} jobId
* @param {import('$lib/server/flowerFlow/jobStore.js').FlowerJob} job
* @param {{ mode: 'area' | 'whole', prompt: string, selection: Array<{ x: number, y: number }> }} instruction
*/
function editForJob(jobId, job, instruction) {
const existing = inFlight.get(jobId);
if (existing) return existing;
const task = (async () => {
const priorRecipe = normalizeRecipeLists(job.recipe);
const updatedRecipe = await applyRecipeEdit(job.recipe, instruction.prompt);
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
const sourceImage = await loadGeneratedImageBytes(job.images.primary);
const editPrompt = formatBouquetEditPrompt({
userPrompt: instruction.prompt,
mode: instruction.mode,
selection: instruction.selection,
recipe: updatedRecipe,
recipeChanged
});
const provider = getImageProvider();
const mask =
instruction.mode === 'area' && instruction.selection.length >= 3
? buildAreaEditMask(
sourceImage,
instruction.selection,
provider === 'gemini' ? 'gemini' : 'openai'
)
: null;
console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${provider} mode=${instruction.mode}${mask ? ' (masked)' : ''} → editing...`
);
const generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask });
const images = await uploadGeneratedImages(
jobId,
generatedImage,
`edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
await updateJob(jobId, {
recipe: updatedRecipe,
imagePrompt: editPrompt,
images,
floristNote: null
});
console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
);
return { recipe: updatedRecipe, imagePrompt: editPrompt, images };
})().finally(() => {
inFlight.delete(jobId);
});
inFlight.set(jobId, task);
return task;
}
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
export async function POST({ request, getClientAddress }) {
try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.imageEdit, 'edit-images');
if (limited) return limited;
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
@@ -62,48 +133,16 @@ export async function POST({ request }) {
);
}
const priorRecipe = normalizeRecipeLists(job.recipe);
const updatedRecipe = await applyRecipeEdit(job.recipe, prompt);
const recipeChanged = JSON.stringify(updatedRecipe) !== JSON.stringify(priorRecipe);
const sourceImage = await loadGeneratedImageBytes(job.images.primary);
const editPrompt = formatBouquetEditPrompt({
userPrompt: prompt,
const { recipe, imagePrompt, images } = await editForJob(jobId, job, {
mode,
selection,
recipe: updatedRecipe,
recipeChanged
prompt,
selection
});
const provider = getImageProvider();
const mask =
mode === 'area' && selection.length >= 3
? buildAreaEditMask(sourceImage, selection, provider === 'gemini' ? 'gemini' : 'openai')
: null;
console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} provider=${provider} mode=${mode}${mask ? ' (masked)' : ''} → editing...`
);
const generatedImage = await editBouquetImage(sourceImage, editPrompt, { mask });
const images = await uploadGeneratedImages(
jobId,
generatedImage,
`edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
await updateJob(jobId, {
recipe: updatedRecipe,
imagePrompt: editPrompt,
images,
floristNote: null
});
console.log(
`[flower-flow] edit-images job=${jobId.slice(0, 8)} OK (mock=${!isImageGenerationConfigured()})`
);
return json({
jobId,
recipe: updatedRecipe,
imagePrompt: editPrompt,
recipe,
imagePrompt,
images,
mock: !isImageGenerationConfigured()
});

View File

@@ -1,11 +1,15 @@
import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { buildFloristNote } from '$lib/server/gemini/text.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
export async function POST({ request, getClientAddress }) {
try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.textAi, 'finalize');
if (limited) return limited;
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';

View File

@@ -7,7 +7,8 @@ import {
isImageGenerationConfigured
} from '$lib/server/gemini/image.js';
import { uploadGeneratedImages } from '$lib/server/flowerFlow/imageStorage.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js';
/**
* @param {import('$lib/server/flowerFlow/jobStore.js').GeneratedImage | undefined} image
@@ -45,8 +46,15 @@ function generateForJob(jobId, recipe) {
}
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
export async function POST({ request, getClientAddress }) {
try {
const limited = enforceRateLimit(
getClientAddress(),
RATE_LIMITS.imageGeneration,
'generate-images'
);
if (limited) return limited;
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';

View File

@@ -1,16 +1,31 @@
import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { analyzeImageMood } from '$lib/server/gemini/vision.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, readUserInput, toErrorResponse } from '$lib/server/http.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { MAX_MOOD_IMAGE_BYTES, MAX_MOOD_IMAGE_LABEL } from '$lib/server/uploadLimits.js';
import { enforceRateLimit, json, readUserInput, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
export async function POST({ request, getClientAddress }) {
try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.moodAnalysis, 'mood-analysis');
if (limited) return limited;
const formData = await request.formData();
const image = formData.get('image');
if (!(image instanceof File)) {
return json({ error: 'image file is required' }, 400);
return json({ error: 'image file is required', code: 'bad_request' }, 400);
}
if (image.size > MAX_MOOD_IMAGE_BYTES) {
return json(
{
error: `Image must be ${MAX_MOOD_IMAGE_LABEL} or smaller.`,
code: 'bad_request'
},
400
);
}
const userInput = readUserInput(formData);

View File

@@ -2,11 +2,15 @@ import { requireJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { buildBouquetRecipe } from '$lib/server/gemini/text.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.js';
import { json, readJsonBody, toErrorResponse } from '$lib/server/http.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { json, readJsonBody, enforceRateLimit, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function POST({ request }) {
export async function POST({ request, getClientAddress }) {
try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.textAi, 'recipe');
if (limited) return limited;
const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : '';

View File

@@ -1,9 +1,13 @@
import { env } from '$env/dynamic/private';
import { json, toErrorResponse } from '$lib/server/http.js';
import { RATE_LIMITS } from '$lib/server/rateLimit.js';
import { enforceRateLimit, json, toErrorResponse } from '$lib/server/http.js';
/** @type {import('./$types').RequestHandler} */
export async function GET({ url }) {
export async function GET({ url, getClientAddress }) {
try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.mapShops, 'map-shops');
if (limited) return limited;
const lat = Number(url.searchParams.get('lat') ?? '37.5665');
const lng = Number(url.searchParams.get('lng') ?? '126.978');
@@ -29,7 +33,18 @@ export async function GET({ url }) {
});
if (!response.ok) {
return json({ mock: true, shops: mockShops(lat, lng) });
const detail = await response.text().catch(() => '');
console.error(
`[map] Kakao shop search failed status=${response.status}`,
detail.slice(0, 300)
);
return json(
{
error: 'Flower shop search is temporarily unavailable. Please try again.',
code: 'map_unavailable'
},
502
);
}
const data = await response.json();