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_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
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": {
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 : '';
|
||||||
|
|
||||||
|
|||||||
@@ -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 : '';
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 : '';
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user