first commit
This commit is contained in:
+92
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
});
|
||||
@@ -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' });
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
'수정할 내용을 하나 이상 입력해 주세요.',
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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입니다.');
|
||||
}
|
||||
Reference in New Issue
Block a user