first commit

This commit is contained in:
2026-08-13 18:36:31 +09:00
commit cee36bf12d
48 changed files with 4212 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import Fastify from 'fastify';
import { ZodError } from 'zod';
import { loadConfig } from './config/env.js';
import authPlugin from './plugins/auth.js';
import mongoPlugin from './plugins/mongo.js';
import staticPlugin from './plugins/static.js';
import accessRoutes from './routes/access.routes.js';
import importRoutes from './routes/import.routes.js';
import recipeRoutes from './routes/recipe.routes.js';
import { createSourceExtractor } from './services/extractors/index.js';
import { ImageStorageService } from './services/image-storage.service.js';
import { RecipeParserService } from './services/recipe-parser.service.js';
import { AppError } from './utils/errors.js';
function registerErrorHandling(app) {
app.setErrorHandler((error, request, reply) => {
if (error instanceof ZodError) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: error.issues[0]?.message ?? '입력값을 확인해 주세요.',
details: error.issues,
},
});
}
if (error instanceof AppError) {
if (error.statusCode >= 500) request.log.error({ error }, error.message);
return reply.code(error.statusCode).send({
error: { code: error.code, message: error.message },
});
}
request.log.error({ error }, '처리되지 않은 서버 오류');
return reply.code(500).send({
error: {
code: 'INTERNAL_ERROR',
message: '요청을 처리하지 못했습니다.',
},
});
});
app.setNotFoundHandler((_request, reply) => reply.code(404).send({
error: { code: 'NOT_FOUND', message: '요청한 경로를 찾을 수 없습니다.' },
}));
}
export async function buildApp({ config = loadConfig(), dependencies = {} } = {}) {
const app = Fastify({
logger: config.nodeEnv === 'test' ? false : { level: config.logLevel },
trustProxy: config.nodeEnv === 'production',
});
app.decorate('config', config);
registerErrorHandling(app);
if (
dependencies.allowedEmailRepository
&& dependencies.recipeRepository
&& dependencies.userRepository
) {
app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository);
app.decorate('recipeRepository', dependencies.recipeRepository);
app.decorate('userRepository', dependencies.userRepository);
} else {
await app.register(mongoPlugin, { config });
}
await app.register(authPlugin, {
config,
googleClient: dependencies.googleClient,
});
app.decorate(
'sourceExtractor',
dependencies.sourceExtractor ?? createSourceExtractor({ config }),
);
app.decorate(
'recipeParser',
dependencies.recipeParser ?? new RecipeParserService(config.minimax),
);
app.decorate(
'imageStorage',
dependencies.imageStorage ?? new ImageStorageService({ root: config.imageRoot }),
);
await app.register(accessRoutes);
await app.register(importRoutes);
await app.register(recipeRoutes);
await app.register(staticPlugin, { config });
await app.ready();
return app;
}
+175
View File
@@ -0,0 +1,175 @@
import path from 'node:path';
import { config as loadDotEnv } from 'dotenv';
import { z } from 'zod';
const blankToUndefined = (value) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
};
const optionalText = z.preprocess(blankToUndefined, z.string().min(1).optional());
const WINDOWS_DEVELOPMENT_PROFILE = Object.freeze({
nodeEnv: 'development',
mongoUri: 'mongodb://192.168.0.240:27017/our_recipe_atlas',
mongoFallbackUri: 'mongodb://172.16.0.7:27017/our_recipe_atlas',
imageRoot: './data/images',
});
const LINUX_PRODUCTION_PROFILE = Object.freeze({
nodeEnv: 'production',
mongoUri: 'mongodb://192.168.0.240:27017/our_recipe_atlas',
mongoFallbackUri: 'mongodb://172.16.0.7:27017/our_recipe_atlas',
imageRoot: '/mnt/recipe-ssd/our_recipe_atlas/images',
});
export function runtimeProfileForPlatform(platform, requestedNodeEnv) {
if (platform === 'linux') return LINUX_PRODUCTION_PROFILE;
if (platform === 'win32') return WINDOWS_DEVELOPMENT_PROFILE;
return Object.freeze({
...WINDOWS_DEVELOPMENT_PROFILE,
nodeEnv: requestedNodeEnv ?? 'development',
});
}
export function mongoCollectionNames(nodeEnv) {
const suffix = nodeEnv === 'development' ? '_dev' : nodeEnv === 'test' ? '_test' : '';
return Object.freeze({
allowedEmails: 'allowed_google_emails',
recipes: `recipes${suffix}`,
users: `users${suffix}`,
});
}
function mongoDatabaseName(uri) {
const schemeEnd = uri.indexOf('://');
const pathStart = uri.indexOf('/', schemeEnd + 3);
if (schemeEnd < 0 || pathStart < 0) return '';
return uri.slice(pathStart + 1).split(/[/?#]/, 1)[0];
}
function isAbsoluteOnSupportedOs(value) {
return path.win32.isAbsolute(value) || path.posix.isAbsolute(value);
}
const envSchema = z
.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
HOST: z.string().min(1).default('0.0.0.0'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
PUBLIC_BASE_URL: z.url().default('http://localhost:3000'),
MONGO_URI: z.string().min(1),
MONGO_FALLBACK_URI: optionalText,
IMAGE_ROOT: z.string().min(1),
GOOGLE_CLIENT_ID: optionalText,
GOOGLE_CLIENT_SECRET: optionalText,
GOOGLE_CALLBACK_URL: z.url().default('http://localhost:3000/auth/google/callback'),
ALLOWED_GOOGLE_EMAILS: z.string().default(''),
AUTH_JWT_SECRET: optionalText,
AUTH_SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(720).default(24),
MINIMAX_API_KEY: optionalText,
MINIMAX_BASE_URL: z.url().default('https://api.minimax.io/v1'),
MINIMAX_MODEL: z.string().min(1).default('MiniMax-M2.7'),
INSTAGRAM_SESSION_COOKIE: optionalText,
})
.superRefine((value, context) => {
const mongoUris = [value.MONGO_URI, value.MONGO_FALLBACK_URI].filter(Boolean);
const databaseNames = mongoUris.map(mongoDatabaseName);
if (databaseNames.some((name) => !name)) {
context.addIssue({
code: 'custom',
message: 'MongoDB URI에는 database 이름이 필요합니다.',
path: ['MONGO_URI'],
});
} else if (new Set(databaseNames).size > 1) {
context.addIssue({
code: 'custom',
message: 'MONGO_URI와 MONGO_FALLBACK_URI는 같은 database를 가리켜야 합니다.',
path: ['MONGO_FALLBACK_URI'],
});
}
if (value.NODE_ENV === 'production' && !isAbsoluteOnSupportedOs(value.IMAGE_ROOT)) {
context.addIssue({
code: 'custom',
message: '운영 환경의 IMAGE_ROOT는 절대 경로여야 합니다.',
path: ['IMAGE_ROOT'],
});
}
if (Boolean(value.GOOGLE_CLIENT_ID) !== Boolean(value.GOOGLE_CLIENT_SECRET)) {
context.addIssue({
code: 'custom',
message: 'GOOGLE_CLIENT_ID와 GOOGLE_CLIENT_SECRET은 함께 설정해야 합니다.',
path: ['GOOGLE_CLIENT_ID'],
});
}
if (value.NODE_ENV === 'production' && !value.AUTH_JWT_SECRET) {
context.addIssue({
code: 'custom',
message: '운영 환경에서는 AUTH_JWT_SECRET이 필요합니다.',
path: ['AUTH_JWT_SECRET'],
});
}
});
export function loadConfig(
source = process.env,
{ loadEnvFile = false, platform = process.platform } = {},
) {
if (loadEnvFile) loadDotEnv({ quiet: true });
const runtimeProfile = runtimeProfileForPlatform(platform, source.NODE_ENV);
const parsed = envSchema.safeParse({
...source,
NODE_ENV: runtimeProfile.nodeEnv,
MONGO_URI: runtimeProfile.mongoUri,
MONGO_FALLBACK_URI: runtimeProfile.mongoFallbackUri,
IMAGE_ROOT: runtimeProfile.imageRoot,
});
if (!parsed.success) {
const details = parsed.error.issues
.map((issue) => `${issue.path.join('.') || '환경변수'}: ${issue.message}`)
.join('\n');
throw new Error(`환경변수 설정이 올바르지 않습니다.\n${details}`);
}
const env = parsed.data;
const allowedGoogleEmails = new Set(
env.ALLOWED_GOOGLE_EMAILS.split(',')
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
);
const pathApi = platform === 'win32' ? path.win32 : path.posix;
return Object.freeze({
nodeEnv: env.NODE_ENV,
host: env.HOST,
port: env.PORT,
logLevel: env.LOG_LEVEL,
publicBaseUrl: env.PUBLIC_BASE_URL.replace(/\/$/, ''),
mongoUri: env.MONGO_URI,
mongoFallbackUri: env.MONGO_FALLBACK_URI,
mongoCollections: mongoCollectionNames(env.NODE_ENV),
imageRoot: pathApi.resolve(process.cwd(), env.IMAGE_ROOT),
google: Object.freeze({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
callbackUrl: env.GOOGLE_CALLBACK_URL,
allowedEmails: allowedGoogleEmails,
}),
authJwtSecret: env.AUTH_JWT_SECRET ?? 'development-only-change-this-secret',
authSessionTtlHours: env.AUTH_SESSION_TTL_HOURS,
minimax: Object.freeze({
apiKey: env.MINIMAX_API_KEY,
baseUrl: env.MINIMAX_BASE_URL.replace(/\/$/, ''),
model: env.MINIMAX_MODEL,
}),
instagramSessionCookie: env.INSTAGRAM_SESSION_COOKIE,
});
}
+48
View File
@@ -0,0 +1,48 @@
export const STANDARD_RECIPE_TAGS = Object.freeze([
'한식', '중식', '일식', '양식', '동남아', '멕시칸',
'밥', '면', '국물', '반찬', '샐러드', '고기', '해산물', '간식', '디저트', '음료',
'간단요리', '다이어트', '고단백', '채식', '매운맛', '술안주', '도시락', '에어프라이어',
]);
const standardTagByKey = new Map(
STANDARD_RECIPE_TAGS.map((tag) => [tag.toLocaleLowerCase(), tag]),
);
const TAG_ALIASES = new Map([
['quick meal', '간단요리'],
['간단', '간단요리'],
['간편', '간단요리'],
['간편요리', '간단요리'],
['초간단', '간단요리'],
['중화요리', '중식'],
['식단', '다이어트'],
['다이어트식', '다이어트'],
['찌개', '국물'],
['탕', '국물'],
['수프', '국물'],
['와인안주', '술안주'],
]);
function tagKey(value) {
return String(value ?? '')
.trim()
.replace(/^#+\s*/, '')
.replace(/\s+/g, ' ')
.toLocaleLowerCase();
}
export function normalizeRecipeTags(tags) {
const normalized = [];
const seen = new Set();
for (const value of tags) {
const key = tagKey(value);
const tag = standardTagByKey.get(key) ?? TAG_ALIASES.get(key);
if (!tag || seen.has(tag)) continue;
normalized.push(tag);
seen.add(tag);
if (normalized.length === 3) break;
}
return normalized;
}
+187
View File
@@ -0,0 +1,187 @@
import cookie from '@fastify/cookie';
import jwt from '@fastify/jwt';
import oauthPlugin from '@fastify/oauth2';
import fastifyPlugin from 'fastify-plugin';
import { OAuth2Client } from 'google-auth-library';
import { AppError } from '../utils/errors.js';
export const SESSION_COOKIE_NAME = 'ora_session';
async function authPlugin(fastify, { config, googleClient } = {}) {
const sessionTtlSeconds = config.authSessionTtlHours * 60 * 60;
await fastify.register(cookie);
await fastify.register(jwt, {
secret: config.authJwtSecret,
cookie: {
cookieName: SESSION_COOKIE_NAME,
signed: false,
},
});
fastify.decorateRequest('allowedAccount', null);
async function verifySession(request) {
const user = await request.jwtVerify({ onlyCookie: true });
const issuedAt = Number(user.iat);
const ageSeconds = Math.floor(Date.now() / 1000) - issuedAt;
if (!Number.isSafeInteger(issuedAt) || ageSeconds >= sessionTtlSeconds) {
throw new Error('Session expired');
}
const allowedAccount = await fastify.allowedEmailRepository.findByEmail(user.email);
if (!allowedAccount) throw new Error('Account access revoked');
request.allowedAccount = allowedAccount;
}
fastify.decorate('authenticate', async function authenticate(request) {
try {
await verifySession(request);
} catch {
throw new AppError('로그인이 필요합니다.', {
statusCode: 401,
code: 'AUTH_REQUIRED',
});
}
});
fastify.decorate('authenticatePage', async function authenticatePage(request, reply) {
try {
await verifySession(request);
} catch {
return reply.redirect('/login');
}
});
fastify.decorate(
'authenticateAccessManager',
async function authenticateAccessManager(request) {
try {
await verifySession(request);
} catch {
throw new AppError('로그인이 필요합니다.', {
statusCode: 401,
code: 'AUTH_REQUIRED',
});
}
if (!request.allowedAccount.canManageAccess) {
throw new AppError('허용 계정을 관리할 권한이 없습니다.', {
statusCode: 403,
code: 'ACCESS_MANAGEMENT_FORBIDDEN',
});
}
},
);
const googleConfigured = Boolean(config.google.clientId && config.google.clientSecret);
if (googleConfigured) {
await fastify.register(oauthPlugin, {
name: 'googleOAuth2',
scope: ['openid', 'email', 'profile'],
credentials: {
client: {
id: config.google.clientId,
secret: config.google.clientSecret,
},
auth: oauthPlugin.GOOGLE_CONFIGURATION,
},
startRedirectPath: '/auth/google',
callbackUri: config.google.callbackUrl,
pkce: 'S256',
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: config.nodeEnv === 'production',
path: '/',
},
});
} else {
fastify.get('/auth/google', async () => {
throw new AppError('Google OAuth가 설정되지 않았습니다.', {
statusCode: 503,
code: 'GOOGLE_OAUTH_NOT_CONFIGURED',
});
});
}
fastify.get('/auth/google/callback', async (request, reply) => {
if (!googleConfigured) {
throw new AppError('Google OAuth가 설정되지 않았습니다.', {
statusCode: 503,
code: 'GOOGLE_OAUTH_NOT_CONFIGURED',
});
}
const accessToken = await fastify.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply);
const idToken = accessToken.token.id_token;
if (!idToken) {
throw new AppError('Google ID Token을 받지 못했습니다.', {
statusCode: 502,
code: 'GOOGLE_ID_TOKEN_MISSING',
});
}
const verifier = googleClient ?? new OAuth2Client(config.google.clientId);
const ticket = await verifier.verifyIdToken({
idToken,
audience: config.google.clientId,
});
const payload = ticket.getPayload();
if (!payload?.sub || !payload.email || payload.email_verified !== true) {
throw new AppError('Google 계정 정보를 검증하지 못했습니다.', {
statusCode: 403,
code: 'GOOGLE_ACCOUNT_INVALID',
});
}
const allowedAccount = await fastify.allowedEmailRepository.findByEmail(payload.email);
if (!allowedAccount) {
throw new AppError('허용되지 않은 Google 계정입니다.', {
statusCode: 403,
code: 'GOOGLE_EMAIL_NOT_ALLOWED',
});
}
const user = await fastify.userRepository.upsertGoogleUser({
googleSub: payload.sub,
email: payload.email.toLowerCase(),
name: payload.name ?? payload.email,
picture: payload.picture ?? null,
});
const session = fastify.jwt.sign(
{
sub: user.googleSub,
email: user.email,
name: user.name,
picture: user.picture,
},
{ expiresIn: `${config.authSessionTtlHours}h` },
);
reply.setCookie(SESSION_COOKIE_NAME, session, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: config.nodeEnv === 'production',
maxAge: sessionTtlSeconds,
});
return reply.redirect('/');
});
fastify.get('/auth/me', { preHandler: fastify.authenticate }, async (request) => ({
user: {
googleSub: request.user.sub,
email: request.user.email,
name: request.user.name,
picture: request.user.picture ?? null,
canManageAccess: Boolean(request.allowedAccount?.canManageAccess),
},
}));
fastify.post('/auth/logout', async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' });
return reply.code(204).send();
});
}
export default fastifyPlugin(authPlugin, {
name: 'auth',
dependencies: [],
});
+76
View File
@@ -0,0 +1,76 @@
import { MongoClient } from 'mongodb';
import fastifyPlugin from 'fastify-plugin';
import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js';
import { RecipeRepository } from '../repositories/recipe.repository.js';
import { UserRepository } from '../repositories/user.repository.js';
const SERVER_SELECTION_TIMEOUT_MS = 3000;
export async function connectFirstAvailableMongo(
uris,
{
createClient = (uri) => new MongoClient(uri, {
serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS,
}),
onFailure = () => {},
} = {},
) {
let lastError;
for (const [index, uri] of uris.entries()) {
const client = createClient(uri);
try {
await client.connect();
return { client, connectionIndex: index };
} catch (error) {
lastError = error;
await client.close().catch(() => {});
onFailure(index, error);
}
}
throw lastError;
}
async function mongoPlugin(fastify, { config }) {
const mongoUris = [config.mongoUri, config.mongoFallbackUri].filter(Boolean);
const { client, connectionIndex } = await connectFirstAvailableMongo(mongoUris, {
onFailure(index) {
fastify.log.warn(
{ mongoConnection: index === 0 ? 'primary' : 'fallback' },
'MongoDB 연결 실패',
);
},
});
fastify.log.info(
{ mongoConnection: connectionIndex === 0 ? 'primary' : 'fallback' },
'MongoDB 연결 완료',
);
const db = client.db();
const collections = config.mongoCollections;
const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails);
const recipeRepository = new RecipeRepository(db, collections.recipes);
const userRepository = new UserRepository(db, collections.users);
await Promise.all([
allowedEmailRepository.ensureIndexes(),
recipeRepository.ensureIndexes(),
userRepository.ensureIndexes(),
]);
const seededEmailCount = await allowedEmailRepository.seedIfEmpty(config.google.allowedEmails);
if (seededEmailCount > 0) {
fastify.log.info({ seededEmailCount }, 'Google 로그인 허용 이메일 초기화 완료');
}
fastify.decorate('mongoClient', client);
fastify.decorate('db', db);
fastify.decorate('allowedEmailRepository', allowedEmailRepository);
fastify.decorate('recipeRepository', recipeRepository);
fastify.decorate('userRepository', userRepository);
fastify.addHook('onClose', async () => {
await client.close();
});
}
export default fastifyPlugin(mongoPlugin, { name: 'mongo' });
+30
View File
@@ -0,0 +1,30 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fastifyStatic from '@fastify/static';
import fastifyPlugin from 'fastify-plugin';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const publicRoot = path.join(projectRoot, 'public');
async function staticPlugin(fastify, { config }) {
await fastify.register(fastifyStatic, {
root: config.imageRoot,
prefix: '/media/',
});
await fastify.register(fastifyStatic, {
root: publicRoot,
prefix: '/',
decorateReply: false,
});
fastify.get('/login', async (_request, reply) => reply.sendFile('login.html', publicRoot));
fastify.get('/', { preHandler: fastify.authenticatePage }, async (_request, reply) => (
reply.sendFile('index.html', publicRoot)
));
fastify.get('/recipe/:id', { preHandler: fastify.authenticatePage }, async (_request, reply) => (
reply.sendFile('recipe.html', publicRoot)
));
}
export default fastifyPlugin(staticPlugin, { name: 'static-pages' });
@@ -0,0 +1,63 @@
export function normalizeAllowedEmail(email) {
return email.trim().toLowerCase();
}
export class AllowedEmailRepository {
constructor(db, collectionName = 'allowed_google_emails') {
this.collection = db.collection(collectionName);
}
async ensureIndexes() {
await this.collection.createIndex({ email: 1 }, { unique: true });
}
async seedIfEmpty(emails) {
if (await this.collection.findOne({}, { projection: { _id: 1 } })) return 0;
const normalizedEmails = [...new Set([...emails].map(normalizeAllowedEmail).filter(Boolean))];
if (normalizedEmails.length === 0) return 0;
const now = new Date();
await this.collection.insertMany(normalizedEmails.map((email) => ({
email,
canManageAccess: true,
addedBy: 'bootstrap',
createdAt: now,
})));
return normalizedEmails.length;
}
async findByEmail(email) {
if (typeof email !== 'string') return null;
return this.collection.findOne({ email: normalizeAllowedEmail(email) });
}
async list() {
return this.collection
.find({}, { projection: { _id: 0, email: 1 } })
.sort({ email: 1 })
.toArray();
}
async add(email, addedBy) {
const normalizedEmail = normalizeAllowedEmail(email);
const now = new Date();
return this.collection.findOneAndUpdate(
{ email: normalizedEmail },
{
$setOnInsert: {
email: normalizedEmail,
canManageAccess: false,
addedBy,
createdAt: now,
},
},
{ upsert: true, returnDocument: 'after', includeResultMetadata: false },
);
}
async remove(email) {
const result = await this.collection.deleteOne({ email: normalizeAllowedEmail(email) });
return result.deletedCount === 1;
}
}
+64
View File
@@ -0,0 +1,64 @@
export class RecipeRepository {
constructor(db, collectionName = 'recipes') {
this.collection = db.collection(collectionName);
}
async ensureIndexes() {
await this.collection.createIndex(
{
ownerGoogleSub: 1,
'source.platform': 1,
'source.sourceId': 1,
},
{ unique: true },
);
await this.collection.createIndex({ ownerGoogleSub: 1, createdAt: -1 });
}
async listByOwner(ownerGoogleSub) {
return this.collection
.find(
{ ownerGoogleSub },
{
projection: {
'source.rawText': 0,
'source.metadata.transcript': 0,
},
},
)
.sort({ createdAt: -1 })
.toArray();
}
async findById(ownerGoogleSub, recipeId) {
return this.collection.findOne({ _id: recipeId, ownerGoogleSub });
}
async findBySource(ownerGoogleSub, platform, sourceId) {
return this.collection.findOne({
ownerGoogleSub,
'source.platform': platform,
'source.sourceId': sourceId,
});
}
async create(document) {
await this.collection.insertOne(document);
return document;
}
async update(ownerGoogleSub, recipeId, changes) {
return this.collection.findOneAndUpdate(
{ _id: recipeId, ownerGoogleSub },
{ $set: { ...changes, updatedAt: new Date() } },
{ returnDocument: 'after', includeResultMetadata: false },
);
}
async delete(ownerGoogleSub, recipeId) {
const recipe = await this.findById(ownerGoogleSub, recipeId);
if (!recipe) return null;
await this.collection.deleteOne({ _id: recipeId, ownerGoogleSub });
return recipe;
}
}
+30
View File
@@ -0,0 +1,30 @@
export class UserRepository {
constructor(db, collectionName = 'users') {
this.collection = db.collection(collectionName);
}
async ensureIndexes() {
await this.collection.createIndex({ googleSub: 1 }, { unique: true });
}
async upsertGoogleUser(profile) {
const now = new Date();
const result = await this.collection.findOneAndUpdate(
{ googleSub: profile.googleSub },
{
$set: {
email: profile.email,
name: profile.name,
picture: profile.picture,
lastLoginAt: now,
},
$setOnInsert: {
googleSub: profile.googleSub,
createdAt: now,
},
},
{ upsert: true, returnDocument: 'after', includeResultMetadata: false },
);
return result;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod';
import { NotFoundError, ValidationError } from '../utils/errors.js';
const emailSchema = z.string().trim().toLowerCase().email('올바른 이메일 주소를 입력해 주세요.');
const addAllowedEmailSchema = z.object({ email: emailSchema });
const allowedEmailParamsSchema = z.object({ email: emailSchema });
export default async function accessRoutes(fastify) {
fastify.get(
'/api/allowed-emails',
{ preHandler: fastify.authenticateAccessManager },
async () => ({
emails: (await fastify.allowedEmailRepository.list()).map(({ email }) => email),
}),
);
fastify.post(
'/api/allowed-emails',
{ preHandler: fastify.authenticateAccessManager },
async (request, reply) => {
const { email } = addAllowedEmailSchema.parse(request.body);
await fastify.allowedEmailRepository.add(email, request.user.email);
return reply.code(201).send({ email });
},
);
fastify.delete(
'/api/allowed-emails/:email',
{ preHandler: fastify.authenticateAccessManager },
async (request, reply) => {
const { email } = allowedEmailParamsSchema.parse(request.params);
if (email === request.user.email.toLowerCase()) {
throw new ValidationError('현재 로그인한 관리자 계정은 삭제할 수 없습니다.');
}
if (!await fastify.allowedEmailRepository.remove(email)) {
throw new NotFoundError('허용 이메일을 찾을 수 없습니다.');
}
return reply.code(204).send();
},
);
}
+16
View File
@@ -0,0 +1,16 @@
import { importPreviewRequestSchema, sourceSchema } from '../schemas/recipe.schema.js';
export default async function importRoutes(fastify) {
fastify.post('/api/import/preview', { preHandler: fastify.authenticate }, async (request) => {
const { url } = importPreviewRequestSchema.parse(request.body);
const source = sourceSchema.parse(await fastify.sourceExtractor.extract(url));
const recipe = await fastify.recipeParser.parse(source);
return {
source,
recipe,
imagePreviewUrl: source.thumbnailUrl,
};
});
}
+104
View File
@@ -0,0 +1,104 @@
import { randomUUID } from 'node:crypto';
import { recipePatchSchema, saveRecipeRequestSchema } from '../schemas/recipe.schema.js';
import { ConflictError, NotFoundError, ValidationError } from '../utils/errors.js';
import {
detectPlatform,
extractInstagramShortcode,
extractYouTubeId,
} from '../utils/url.js';
function toStoredSource(source) {
const { sourceUrl, ...rest } = source;
return { ...rest, url: sourceUrl };
}
function isDuplicateKeyError(error) {
return error?.code === 11000;
}
function verifiedSource(source) {
const platform = detectPlatform(source.sourceUrl);
const sourceId = platform === 'youtube'
? extractYouTubeId(source.sourceUrl)
: platform === 'instagram'
? extractInstagramShortcode(source.sourceUrl)
: null;
if (platform !== source.platform || sourceId !== source.sourceId) {
throw new ValidationError('미리보기 원본 정보가 URL과 일치하지 않습니다.');
}
return source;
}
export default async function recipeRoutes(fastify) {
fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => ({
recipes: await fastify.recipeRepository.listByOwner(request.user.sub),
}));
fastify.get('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => {
const recipe = await fastify.recipeRepository.findById(request.user.sub, request.params.id);
if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.');
return { recipe };
});
fastify.post('/api/recipes', { preHandler: fastify.authenticate }, async (request, reply) => {
const input = saveRecipeRequestSchema.parse(request.body);
verifiedSource(input.source);
const ownerGoogleSub = request.user.sub;
const duplicate = await fastify.recipeRepository.findBySource(
ownerGoogleSub,
input.source.platform,
input.source.sourceId,
);
if (duplicate) throw new ConflictError();
const recipeId = randomUUID();
let imagePath = null;
try {
const imageUrl = input.imagePreviewUrl ?? input.source.thumbnailUrl;
if (imageUrl) imagePath = await fastify.imageStorage.saveFromUrl(recipeId, imageUrl);
const now = new Date();
const document = {
_id: recipeId,
ownerGoogleSub,
...input.recipe,
source: toStoredSource(input.source),
imagePath,
ai: {
provider: 'minimax',
model: fastify.config.minimax.model,
},
createdAt: now,
updatedAt: now,
};
const recipe = await fastify.recipeRepository.create(document);
return reply.code(201).send({ recipe });
} catch (error) {
if (imagePath) await fastify.imageStorage.removeRecipe(recipeId).catch(() => {});
if (isDuplicateKeyError(error)) throw new ConflictError();
throw error;
}
});
fastify.patch('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => {
const changes = recipePatchSchema.parse(request.body);
const recipe = await fastify.recipeRepository.update(
request.user.sub,
request.params.id,
changes,
);
if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.');
return { recipe };
});
fastify.delete('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request, reply) => {
const recipe = await fastify.recipeRepository.delete(request.user.sub, request.params.id);
if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.');
if (recipe.imagePath) {
await fastify.imageStorage.removeRecipe(request.params.id).catch((error) => {
request.log.warn({ error, recipeId: request.params.id }, '레시피 이미지 삭제 실패');
});
}
return reply.code(204).send();
});
}
+72
View File
@@ -0,0 +1,72 @@
import { z } from 'zod';
import { normalizeRecipeTags } from '../constants/recipe-tags.js';
const nullableText = z.string().trim().min(1).nullable();
const tagsSchema = z.array(z.string()).transform(normalizeRecipeTags);
export const ingredientItemSchema = z.object({
name: z.string().trim().min(1, '재료 이름이 필요합니다.'),
amount: nullableText.default(null),
});
export const ingredientGroupSchema = z.object({
name: z.string().trim().min(1, '재료 그룹 이름이 필요합니다.'),
items: z.array(ingredientItemSchema),
});
export const recipeStepSchema = z.object({
order: z.number().int().positive(),
text: z.string().trim().min(1, '조리 단계 설명이 필요합니다.'),
timestampSec: z.number().nonnegative().nullable().default(null),
});
export const recipeDraftSchema = z.object({
title: z.string().trim().min(1, '레시피 제목이 필요합니다.'),
summary: nullableText.default(null),
servings: nullableText.default(null),
ingredientGroups: z.array(ingredientGroupSchema),
steps: z.array(recipeStepSchema),
tips: z.array(z.string().trim().min(1)).default([]),
tags: tagsSchema.default([]),
});
export const sourceSchema = z.object({
platform: z.enum(['instagram', 'youtube']),
sourceUrl: z.url().refine((value) => new URL(value).protocol === 'https:', '원본 URL은 HTTPS여야 합니다.'),
sourceId: z.string().trim().min(1),
title: nullableText.default(null),
author: nullableText.default(null),
thumbnailUrl: z.url()
.refine((value) => new URL(value).protocol === 'https:', '이미지 URL은 HTTPS여야 합니다.')
.nullable()
.default(null),
rawText: z.string().trim().min(1, '원본 텍스트가 비어 있습니다.'),
metadata: z.record(z.string(), z.unknown()).default({}),
});
export const importPreviewRequestSchema = z.object({
url: z.url(),
});
export const saveRecipeRequestSchema = z.object({
recipe: recipeDraftSchema,
source: sourceSchema,
imagePreviewUrl: z.url()
.refine((value) => new URL(value).protocol === 'https:', '이미지 URL은 HTTPS여야 합니다.')
.nullable()
.optional(),
});
export const recipePatchSchema = z.object({
title: z.string().trim().min(1, '레시피 제목이 필요합니다.').optional(),
summary: nullableText.optional(),
servings: nullableText.optional(),
ingredientGroups: z.array(ingredientGroupSchema).optional(),
steps: z.array(recipeStepSchema).optional(),
tips: z.array(z.string().trim().min(1)).optional(),
tags: tagsSchema.optional(),
}).refine(
(value) => Object.keys(value).length > 0,
'수정할 내용을 하나 이상 입력해 주세요.',
);
+13
View File
@@ -0,0 +1,13 @@
import { buildApp } from './app.js';
import { loadConfig } from './config/env.js';
const config = loadConfig(process.env, { loadEnvFile: true });
const app = await buildApp({ config });
try {
await app.listen({ host: config.host, port: config.port });
} catch (error) {
app.log.error(error);
process.exitCode = 1;
}
+24
View File
@@ -0,0 +1,24 @@
import { detectPlatform } from '../../utils/url.js';
import { ValidationError } from '../../utils/errors.js';
import { InstagramExtractor } from './instagram.extractor.js';
import { YouTubeExtractor } from './youtube.extractor.js';
export function createSourceExtractor({ config, youtubeExtractor, instagramExtractor } = {}) {
const extractors = {
youtube: youtubeExtractor ?? new YouTubeExtractor(),
instagram: instagramExtractor ?? new InstagramExtractor({
sessionCookie: config?.instagramSessionCookie,
}),
};
return {
async extract(sourceUrl) {
const platform = detectPlatform(sourceUrl);
if (platform === 'unsupported') {
throw new ValidationError('지원하지 않는 URL입니다.');
}
return extractors[platform].extract(sourceUrl);
},
};
}
@@ -0,0 +1,71 @@
import { igApi } from 'insta-fetcher';
import { AppError } from '../../utils/errors.js';
import { canonicalSourceUrl, extractInstagramShortcode } from '../../utils/url.js';
export function normalizeInstagramSessionCookie(value) {
const cookie = value?.trim();
if (!cookie) return cookie;
const normalized = cookie.includes('=') ? cookie : `sessionid=${cookie}`;
return normalized.endsWith(';') ? normalized : `${normalized};`;
}
export class InstagramExtractor {
constructor({ sessionCookie, clientFactory } = {}) {
this.sessionCookie = normalizeInstagramSessionCookie(sessionCookie);
this.clientFactory = clientFactory ?? ((cookie) => new igApi(cookie));
}
async extract(sourceUrl) {
if (!this.sessionCookie) {
throw new AppError('Instagram session cookie가 설정되지 않았습니다.', {
statusCode: 503,
code: 'INSTAGRAM_NOT_CONFIGURED',
});
}
const sourceId = extractInstagramShortcode(sourceUrl);
let post;
let thumbnailUrl;
try {
const client = await this.clientFactory(this.sessionCookie);
post = await client.fetchPost(canonicalSourceUrl(sourceUrl));
thumbnailUrl = post.links?.find((link) => link.type === 'image')?.url ?? null;
if (!thumbnailUrl && post.media_id && typeof client.fetchPostByMediaId === 'function') {
const metadata = await client.fetchPostByMediaId(post.media_id).catch(() => null);
thumbnailUrl = metadata?.items?.[0]?.image_versions2?.candidates?.[0]?.url ?? null;
}
} catch (error) {
throw new AppError('Instagram 게시물 정보를 가져오지 못했습니다.', {
statusCode: 502,
code: 'INSTAGRAM_EXTRACTION_FAILED',
cause: error,
});
}
const caption = post?.caption?.trim();
if (!caption) {
throw new AppError('Instagram 게시물의 caption을 찾을 수 없습니다.', {
statusCode: 422,
code: 'INSTAGRAM_CAPTION_NOT_FOUND',
});
}
const firstLine = caption.split(/\r?\n/).find((line) => line.trim())?.trim() ?? null;
return {
platform: 'instagram',
sourceUrl: canonicalSourceUrl(sourceUrl),
sourceId: post.shortcode || sourceId,
title: firstLine,
author: post.username || null,
thumbnailUrl,
rawText: caption,
metadata: {
postType: post.postType ?? null,
takenAt: post.taken_at_timestamp ?? null,
},
};
}
}
@@ -0,0 +1,88 @@
import { Innertube } from 'youtubei.js';
import { AppError } from '../../utils/errors.js';
import { canonicalSourceUrl, extractYouTubeId } from '../../utils/url.js';
function textValue(value) {
if (value == null) return '';
return typeof value === 'string' ? value : value.toString();
}
export function readTranscriptSegments(transcriptInfo) {
const segments = transcriptInfo?.transcript?.content?.body?.initial_segments ?? [];
return segments
.filter((segment) => segment?.snippet && segment?.start_ms != null)
.map((segment) => ({
startSec: Number(segment.start_ms) / 1000,
endSec: Number(segment.end_ms) / 1000,
text: textValue(segment.snippet).trim(),
}))
.filter((segment) => Number.isFinite(segment.startSec) && segment.text);
}
export class YouTubeExtractor {
constructor({ clientFactory } = {}) {
this.clientFactory = clientFactory ?? (() => Innertube.create({
lang: 'ko',
location: 'KR',
retrieve_player: false,
}));
}
async extract(sourceUrl) {
const sourceId = extractYouTubeId(sourceUrl);
let info;
try {
const client = await this.clientFactory();
info = await client.getInfo(sourceId);
} catch (error) {
throw new AppError('YouTube 정보를 가져오지 못했습니다.', {
statusCode: 502,
code: 'YOUTUBE_EXTRACTION_FAILED',
cause: error,
});
}
const basic = info.basic_info ?? {};
const description = basic.short_description?.trim() ?? '';
let transcriptSegments = [];
try {
transcriptSegments = readTranscriptSegments(await info.getTranscript());
} catch {
// 자막이 비활성화된 영상은 설명만으로 계속 분석한다.
}
if (!description && transcriptSegments.length === 0) {
throw new AppError('YouTube 자막과 설명을 찾을 수 없습니다.', {
statusCode: 422,
code: 'YOUTUBE_TEXT_NOT_FOUND',
});
}
const transcriptText = transcriptSegments
.map((segment) => `[${segment.startSec}] ${segment.text}`)
.join('\n');
const rawText = [
description && `[DESCRIPTION]\n${description}`,
transcriptText && `[TRANSCRIPT]\n${transcriptText}`,
].filter(Boolean).join('\n\n');
const thumbnails = basic.thumbnail ?? [];
const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1] : null;
return {
platform: 'youtube',
sourceUrl: canonicalSourceUrl(sourceUrl),
sourceId,
title: basic.title?.trim() || null,
author: basic.author?.trim() || basic.channel?.name?.trim() || null,
thumbnailUrl: thumbnail?.url ?? null,
rawText,
metadata: {
durationSec: basic.duration ?? null,
transcript: transcriptSegments,
},
};
}
}
+135
View File
@@ -0,0 +1,135 @@
import dns from 'node:dns/promises';
import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import sharp from 'sharp';
import { AppError, ValidationError } from '../utils/errors.js';
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
const SAFE_RECIPE_ID = /^[A-Za-z0-9-]+$/;
function isPrivateIpv4(address) {
const parts = address.split('.').map(Number);
const [a, b] = parts;
return a === 0
|| a === 10
|| a === 127
|| (a === 100 && b >= 64 && b <= 127)
|| (a === 169 && b === 254)
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && (b === 0 || b === 168))
|| (a === 198 && (b === 18 || b === 19 || b === 51))
|| (a === 203 && b === 0)
|| a >= 224;
}
export function isPrivateAddress(address) {
const normalized = address.toLowerCase();
if (normalized.startsWith('::ffff:')) return isPrivateAddress(normalized.slice(7));
const version = net.isIP(normalized);
if (version === 4) return isPrivateIpv4(normalized);
if (version === 6) {
return normalized === '::'
|| normalized === '::1'
|| normalized.startsWith('fc')
|| normalized.startsWith('fd')
|| /^fe[89ab]/.test(normalized);
}
return true;
}
export function buildRelativeImagePath(recipeId) {
if (!SAFE_RECIPE_ID.test(recipeId)) {
throw new ValidationError('올바르지 않은 recipe ID입니다.');
}
return path.posix.join('recipes', recipeId, 'cover.webp');
}
export function resolveImagePath(root, relativePath, pathApi = path) {
return pathApi.resolve(root, ...relativePath.split('/'));
}
async function assertPublicUrl(value, lookup) {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password) {
throw new ValidationError('이미지 URL은 인증 정보가 없는 HTTPS URL이어야 합니다.');
}
if (url.hostname.toLowerCase() === 'localhost') {
throw new ValidationError('내부 네트워크의 이미지는 가져올 수 없습니다.');
}
const directIpVersion = net.isIP(url.hostname);
const addresses = directIpVersion
? [{ address: url.hostname }]
: await lookup(url.hostname, { all: true, verbatim: true });
if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
throw new ValidationError('내부 네트워크의 이미지는 가져올 수 없습니다.');
}
return url;
}
export class ImageStorageService {
constructor({ root, fetchImpl = fetch, lookup = dns.lookup } = {}) {
this.root = root;
this.fetchImpl = fetchImpl;
this.lookup = lookup;
}
async fetchImage(sourceUrl) {
let currentUrl = await assertPublicUrl(sourceUrl, this.lookup);
for (let redirects = 0; redirects <= 3; redirects += 1) {
const response = await this.fetchImpl(currentUrl, { redirect: 'manual' });
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
if (!location || redirects === 3) break;
currentUrl = await assertPublicUrl(new URL(location, currentUrl).toString(), this.lookup);
continue;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.startsWith('image/')) throw new Error('응답이 이미지가 아닙니다.');
const declaredSize = Number(response.headers.get('content-length') ?? 0);
if (declaredSize > MAX_IMAGE_BYTES) throw new Error('이미지가 너무 큽니다.');
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > MAX_IMAGE_BYTES) throw new Error('이미지가 너무 큽니다.');
return buffer;
}
throw new Error('이미지 redirect를 처리하지 못했습니다.');
}
async saveFromUrl(recipeId, sourceUrl) {
const relativePath = buildRelativeImagePath(recipeId);
const outputPath = resolveImagePath(this.root, relativePath);
const recipeDirectory = path.dirname(outputPath);
const temporaryPath = path.join(recipeDirectory, 'cover.tmp.webp');
try {
const input = await this.fetchImage(sourceUrl);
await fs.mkdir(recipeDirectory, { recursive: true });
await sharp(input)
.rotate()
.resize({ width: 1280, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(temporaryPath);
await fs.rm(outputPath, { force: true });
await fs.rename(temporaryPath, outputPath);
return relativePath;
} catch (error) {
await fs.rm(temporaryPath, { force: true }).catch(() => {});
throw new AppError('레시피 이미지를 저장하지 못했습니다.', {
statusCode: 502,
code: 'IMAGE_STORAGE_FAILED',
cause: error,
});
}
}
async removeRecipe(recipeId) {
const relativePath = buildRelativeImagePath(recipeId);
const directory = path.dirname(resolveImagePath(this.root, relativePath));
await fs.rm(directory, { recursive: true, force: true });
}
}
+124
View File
@@ -0,0 +1,124 @@
import OpenAI from 'openai';
import { STANDARD_RECIPE_TAGS } from '../constants/recipe-tags.js';
import { recipeDraftSchema } from '../schemas/recipe.schema.js';
import { AppError } from '../utils/errors.js';
const STANDARD_TAGS = STANDARD_RECIPE_TAGS.join(', ');
const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데이터 변환기입니다.
반드시 JSON 객체만 출력하세요.
원칙:
- 원문에 없는 재료, 수량, 조리 순서를 추측하거나 추가하지 않습니다.
- 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다.
- g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다.
- 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다.
- YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다.
- Instagram 단계의 timestampSec은 null입니다.
- 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다.
- 이 작업은 정보 추출이므로 깊은 분석은 필요하지 않습니다.
- tags는 다음 표준 태그에서만 최대 3개를 선택합니다: ${STANDARD_TAGS}
- 재료명, 요리 고유명사, 식사 시간대는 tags에 넣지 않습니다.
출력 형식:
{
"title": "string",
"summary": "string|null",
"servings": "string|null",
"ingredientGroups": [{"name":"string","items":[{"name":"string","amount":"string|null"}]}],
"steps": [{"order":1,"text":"string","timestampSec":null}],
"tips": ["string"],
"tags": ["string"]
}`;
export function normalizeModelJson(content) {
if (typeof content !== 'string' || !content.trim()) {
throw new Error('AI 응답이 비어 있습니다.');
}
const withoutThinking = content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
const withoutFence = withoutThinking
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
const start = withoutFence.indexOf('{');
const end = withoutFence.lastIndexOf('}');
if (start < 0 || end <= start) throw new Error('JSON 객체를 찾을 수 없습니다.');
return withoutFence.slice(start, end + 1);
}
export function normalizeRecipePayload(value) {
if (!value || typeof value !== 'object' || !Array.isArray(value.ingredientGroups)) {
return value;
}
return {
...value,
ingredientGroups: value.ingredientGroups.map((group) => {
if (!group || typeof group !== 'object') return group;
const name = typeof group.name === 'string' ? group.name.trim() : '';
return { ...group, name: name || '재료' };
}),
};
}
function buildUserPrompt(source) {
const lines = [
`SOURCE_PLATFORM: ${source.platform}`,
source.title ? `TITLE:\n${source.title}` : null,
`SOURCE_TEXT:\n${source.rawText}`,
];
return lines.filter(Boolean).join('\n\n');
}
export class RecipeParserService {
constructor({ apiKey, baseUrl, model, client } = {}) {
this.model = model;
this.client = client ?? (apiKey ? new OpenAI({ apiKey, baseURL: baseUrl }) : null);
}
async parse(source) {
if (!this.client) {
throw new AppError('MiniMax API가 설정되지 않았습니다.', {
statusCode: 503,
code: 'MINIMAX_NOT_CONFIGURED',
});
}
let lastError;
let previousOutput = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const response = await this.client.chat.completions.create({
model: this.model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{
role: 'user',
content: attempt === 0
? buildUserPrompt(source)
: `${buildUserPrompt(source)}\n\n이전 출력은 유효한 JSON 스키마가 아니었습니다. 수정해서 JSON 객체만 다시 출력하세요.\n이전 출력:\n${previousOutput}`,
},
],
reasoning_split: true,
max_completion_tokens: 8192,
temperature: 0.2,
stream: false,
});
previousOutput = response.choices?.[0]?.message?.content ?? '';
const parsed = JSON.parse(normalizeModelJson(previousOutput));
return recipeDraftSchema.parse(normalizeRecipePayload(parsed));
} catch (error) {
lastError = error;
}
}
throw new AppError('AI 응답을 처리하지 못했습니다.', {
statusCode: 502,
code: 'AI_RESPONSE_INVALID',
cause: lastError,
});
}
}
+27
View File
@@ -0,0 +1,27 @@
export class AppError extends Error {
constructor(message, { statusCode = 500, code = 'INTERNAL_ERROR', cause } = {}) {
super(message, { cause });
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
export class ValidationError extends AppError {
constructor(message, options = {}) {
super(message, { ...options, statusCode: 400, code: options.code ?? 'VALIDATION_ERROR' });
}
}
export class NotFoundError extends AppError {
constructor(message = '요청한 항목을 찾을 수 없습니다.') {
super(message, { statusCode: 404, code: 'NOT_FOUND' });
}
}
export class ConflictError extends AppError {
constructor(message = '이미 등록된 레시피입니다.') {
super(message, { statusCode: 409, code: 'DUPLICATE_RECIPE' });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { ValidationError } from './errors.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'music.youtube.com',
'youtu.be',
'www.youtu.be',
]);
const INSTAGRAM_HOSTS = new Set([
'instagram.com',
'www.instagram.com',
'm.instagram.com',
]);
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
const INSTAGRAM_SHORTCODE_PATTERN = /^[A-Za-z0-9_-]+$/;
export function parseSourceUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new ValidationError('올바른 Instagram 또는 YouTube URL을 입력해 주세요.');
}
if (url.protocol !== 'https:') {
throw new ValidationError('가져오기 URL은 HTTPS여야 합니다.');
}
url.hash = '';
return url;
}
export function detectPlatform(value) {
const url = parseSourceUrl(value);
if (YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) return 'youtube';
if (INSTAGRAM_HOSTS.has(url.hostname.toLowerCase())) return 'instagram';
return 'unsupported';
}
export function extractYouTubeId(value) {
const url = parseSourceUrl(value);
if (!YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) {
throw new ValidationError('지원하지 않는 YouTube URL입니다.');
}
const pathParts = url.pathname.split('/').filter(Boolean);
let videoId = null;
if (url.hostname.toLowerCase().endsWith('youtu.be')) {
[videoId] = pathParts;
} else if (url.pathname === '/watch') {
videoId = url.searchParams.get('v');
} else if (['shorts', 'embed', 'live'].includes(pathParts[0])) {
videoId = pathParts[1];
}
if (!videoId || !YOUTUBE_ID_PATTERN.test(videoId)) {
throw new ValidationError('YouTube 영상 ID를 확인할 수 없습니다.');
}
return videoId;
}
export function extractInstagramShortcode(value) {
const url = parseSourceUrl(value);
if (!INSTAGRAM_HOSTS.has(url.hostname.toLowerCase())) {
throw new ValidationError('지원하지 않는 Instagram URL입니다.');
}
const [kind, shortcode] = url.pathname.split('/').filter(Boolean);
if (!['p', 'reel', 'reels', 'tv'].includes(kind) || !INSTAGRAM_SHORTCODE_PATTERN.test(shortcode ?? '')) {
throw new ValidationError('Instagram 게시물 shortcode를 확인할 수 없습니다.');
}
return shortcode;
}
export function canonicalSourceUrl(value) {
const platform = detectPlatform(value);
if (platform === 'youtube') {
return `https://www.youtube.com/watch?v=${extractYouTubeId(value)}`;
}
if (platform === 'instagram') {
const url = parseSourceUrl(value);
const [kind] = url.pathname.split('/').filter(Boolean);
const normalizedKind = kind === 'reels' ? 'reel' : kind;
return `https://www.instagram.com/${normalizedKind}/${extractInstagramShortcode(value)}/`;
}
throw new ValidationError('지원하지 않는 URL입니다.');
}