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

@@ -26,6 +26,15 @@ SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY= SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_STORAGE_BUCKET=flower-bouquets SUPABASE_STORAGE_BUCKET=flower-bouquets
# adapter-node (Railway / any Node host)
# Default body limit is 512K — mood-analysis allows up to 10 MB.
BODY_SIZE_LIMIT=10M
# Public URL after deploy (required for CSRF / form actions).
# ORIGIN=https://your-app.up.railway.app
# Real client IP behind Railway's proxy (for rate limiting).
# ADDRESS_HEADER=x-forwarded-for
# XFF_DEPTH=1
# Dev seed button: shown only when `npm run dev` (production build hides it). # Dev seed button: shown only when `npm run dev` (production build hides it).
# To mute during local dev, set DEV_SEED_MUTED = true in DevSeedButton.svelte. # To mute during local dev, set DEV_SEED_MUTED = true in DevSeedButton.svelte.
# Replace static/dev/bouquet-{s,m,l}.jpg with real photos for richer UI previews. # Replace static/dev/bouquet-{s,m,l}.jpg with real photos for richer UI previews.

1
.node-version Normal file
View File

@@ -0,0 +1 @@
22

682
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"start": "node build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
@@ -15,7 +16,6 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.4", "@eslint/compat": "^2.0.4",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
@@ -34,6 +34,7 @@
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@supabase/supabase-js": "^2.108.1", "@supabase/supabase-js": "^2.108.1",
"@sveltejs/adapter-node": "^5.5.4",
"openai": "^6.42.0", "openai": "^6.42.0",
"p5": "^2.3.0" "p5": "^2.3.0"
} }

View File

@@ -1,5 +1,6 @@
import { JobNotFoundError } from '$lib/server/flowerFlow/jobStore.js'; import { JobNotFoundError } from '$lib/server/flowerFlow/jobStore.js';
import { describeAiError } from '$lib/server/aiError.js'; import { describeAiError } from '$lib/server/aiError.js';
import { consumeRateLimit } from '$lib/server/rateLimit.js';
/** /**
* @param {unknown} error * @param {unknown} error
@@ -45,14 +46,39 @@ export function toErrorResponse(error) {
/** /**
* @param {unknown} body * @param {unknown} body
* @param {number} [status] * @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), { return new Response(JSON.stringify(body), {
status, 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 {FormData} formData
* @param {string} field * @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 isImageGenerationConfigured
} from '$lib/server/gemini/image.js'; } from '$lib/server/gemini/image.js';
import { applyRecipeEdit } from '$lib/server/gemini/text.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 * @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} */ /** @type {import('./$types').RequestHandler} */
export async function POST({ request }) { export async function POST({ request, getClientAddress }) {
try { try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.imageEdit, 'edit-images');
if (limited) return limited;
const body = await readJsonBody(request); const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : ''; const jobId = typeof body.jobId === 'string' ? body.jobId : '';
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
@@ -62,48 +133,16 @@ export async function POST({ request }) {
); );
} }
const priorRecipe = normalizeRecipeLists(job.recipe); const { recipe, imagePrompt, images } = await editForJob(jobId, job, {
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,
mode, mode,
selection, prompt,
recipe: updatedRecipe, selection
recipeChanged
}); });
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({ return json({
jobId, jobId,
recipe: updatedRecipe, recipe,
imagePrompt: editPrompt, imagePrompt,
images, images,
mock: !isImageGenerationConfigured() mock: !isImageGenerationConfigured()
}); });

View File

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

View File

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

View File

@@ -1,16 +1,31 @@
import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js'; import { createJob, updateJob } from '$lib/server/flowerFlow/jobStore.js';
import { analyzeImageMood } from '$lib/server/gemini/vision.js'; import { analyzeImageMood } from '$lib/server/gemini/vision.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.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} */ /** @type {import('./$types').RequestHandler} */
export async function POST({ request }) { export async function POST({ request, getClientAddress }) {
try { try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.moodAnalysis, 'mood-analysis');
if (limited) return limited;
const formData = await request.formData(); const formData = await request.formData();
const image = formData.get('image'); const image = formData.get('image');
if (!(image instanceof File)) { 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); 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 { normalizeRecipeLists } from '$lib/flowerFlow/resolveRecipeFlowers.js';
import { buildBouquetRecipe } from '$lib/server/gemini/text.js'; import { buildBouquetRecipe } from '$lib/server/gemini/text.js';
import { isGeminiConfigured } from '$lib/server/gemini/client.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} */ /** @type {import('./$types').RequestHandler} */
export async function POST({ request }) { export async function POST({ request, getClientAddress }) {
try { try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.textAi, 'recipe');
if (limited) return limited;
const body = await readJsonBody(request); const body = await readJsonBody(request);
const jobId = typeof body.jobId === 'string' ? body.jobId : ''; const jobId = typeof body.jobId === 'string' ? body.jobId : '';

View File

@@ -1,9 +1,13 @@
import { env } from '$env/dynamic/private'; 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} */ /** @type {import('./$types').RequestHandler} */
export async function GET({ url }) { export async function GET({ url, getClientAddress }) {
try { try {
const limited = enforceRateLimit(getClientAddress(), RATE_LIMITS.mapShops, 'map-shops');
if (limited) return limited;
const lat = Number(url.searchParams.get('lat') ?? '37.5665'); const lat = Number(url.searchParams.get('lat') ?? '37.5665');
const lng = Number(url.searchParams.get('lng') ?? '126.978'); const lng = Number(url.searchParams.get('lng') ?? '126.978');
@@ -29,7 +33,18 @@ export async function GET({ url }) {
}); });
if (!response.ok) { 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(); const data = await response.json();

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@@ -7,9 +7,6 @@ const config = {
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
}, },
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter() adapter: adapter()
} }
}; };