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:
@@ -26,6 +26,15 @@ SUPABASE_URL=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
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).
|
||||
# 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.
|
||||
|
||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
682
package-lock.json
generated
682
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "node build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
@@ -15,7 +16,6 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
@@ -34,6 +34,7 @@
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@supabase/supabase-js": "^2.108.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"openai": "^6.42.0",
|
||||
"p5": "^2.3.0"
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
@@ -7,9 +7,6 @@ const config = {
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
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()
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user