588 lines
17 KiB
JavaScript
588 lines
17 KiB
JavaScript
import 'dotenv/config';
|
||
import express from 'express';
|
||
import { createServer } from 'http';
|
||
import cors from 'cors';
|
||
import bcrypt from 'bcryptjs';
|
||
import jwt from 'jsonwebtoken';
|
||
import { tmpdir } from 'os';
|
||
import { dirname, join } from 'path';
|
||
import { promises as fs } from 'fs';
|
||
import { v4 as uuidV4 } from 'uuid';
|
||
import Epub from 'epub-gen';
|
||
import { fileURLToPath } from 'url';
|
||
import { Server as SocketIOServer } from 'socket.io';
|
||
|
||
const requiredEnv = [
|
||
'SUPABASE_URL',
|
||
'SUPABASE_SERVICE_ROLE_KEY',
|
||
'JWT_SECRET',
|
||
'ZAI_GLM_API_KEY',
|
||
];
|
||
requiredEnv.forEach((key) => {
|
||
if (!process.env[key]) {
|
||
console.error(`Missing required environment variable: ${key}`);
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const PORT = process.env.PORT || 4000;
|
||
const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
||
const allowedOrigins = ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean);
|
||
const ADMIN_EMAILS = (process.env.ADMIN_ALLOWED_EMAILS || '')
|
||
.split(',')
|
||
.map((item) => item.trim().toLowerCase())
|
||
.filter(Boolean);
|
||
const activeUserConnections = new Map();
|
||
const presenceTimers = new Map();
|
||
|
||
const incrementActiveUser = (userId) => {
|
||
if (!userId) return;
|
||
const current = activeUserConnections.get(userId) || 0;
|
||
activeUserConnections.set(userId, current + 1);
|
||
};
|
||
|
||
const decrementActiveUser = (userId) => {
|
||
if (!userId) return;
|
||
const current = activeUserConnections.get(userId) || 0;
|
||
if (current <= 1) {
|
||
activeUserConnections.delete(userId);
|
||
} else {
|
||
activeUserConnections.set(userId, current - 1);
|
||
}
|
||
};
|
||
const app = express();
|
||
const server = createServer(app);
|
||
const io = new SocketIOServer(server, {
|
||
cors: {
|
||
origin: allowedOrigins,
|
||
credentials: true,
|
||
},
|
||
});
|
||
|
||
const emitAdminEvent = (event, payload = {}) => {
|
||
io.to('admins').emit(event, { ...payload, ts: Date.now() });
|
||
};
|
||
|
||
const emitStats = async () => {
|
||
let totalUsers = 0;
|
||
try {
|
||
const { count, error } = await supabase
|
||
.from(USERS_TABLE)
|
||
.select('id', { count: 'exact', head: true });
|
||
if (!error && typeof count === 'number') {
|
||
totalUsers = count;
|
||
}
|
||
} catch (error) {
|
||
console.warn('Kullanıcı sayısı alınamadı', error.message);
|
||
}
|
||
|
||
emitAdminEvent('stats:update', {
|
||
activeUsers: activeUserConnections.size,
|
||
totalUsers,
|
||
});
|
||
};
|
||
|
||
const normalizeOrigin = (value = '') => {
|
||
try {
|
||
return new URL(value).origin;
|
||
} catch (error) {
|
||
return value.replace(/\/$/, '');
|
||
}
|
||
};
|
||
|
||
const trustedOrigins = allowedOrigins.map(normalizeOrigin).filter(Boolean);
|
||
|
||
const enforceClientOrigin = (req, res, next) => {
|
||
if (!trustedOrigins.length) {
|
||
return next();
|
||
}
|
||
const header = req.get('origin') || req.get('referer');
|
||
if (!header) {
|
||
return res.status(403).json({ message: 'Bu istemciye izin verilmiyor.' });
|
||
}
|
||
const requestOrigin = normalizeOrigin(header);
|
||
if (!requestOrigin || !trustedOrigins.includes(requestOrigin)) {
|
||
return res.status(403).json({ message: 'Bu istemciye izin verilmiyor.' });
|
||
}
|
||
return next();
|
||
};
|
||
|
||
const USERS_TABLE = process.env.SUPABASE_USERS_TABLE || 'users';
|
||
const JWT_SECRET = process.env.JWT_SECRET;
|
||
import { supabase } from './src/services/supabaseClient.js';
|
||
import {
|
||
getActiveTranslationModel,
|
||
listTranslationModels,
|
||
setActiveTranslationModel,
|
||
translateWithActiveModel,
|
||
} from './src/services/translationService.js';
|
||
|
||
app.use(
|
||
cors({
|
||
origin: allowedOrigins,
|
||
credentials: true,
|
||
}),
|
||
);
|
||
app.use(express.json({ limit: '10mb' }));
|
||
|
||
const sanitizeHtml = (text = '') =>
|
||
text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
.replace(/\n/g, '<br/>');
|
||
|
||
const createToken = (user) =>
|
||
jwt.sign(
|
||
{
|
||
sub: user.id,
|
||
email: user.email,
|
||
username: user.username,
|
||
name: user.name,
|
||
role: user.role || 'user',
|
||
},
|
||
JWT_SECRET,
|
||
{ expiresIn: '7d' },
|
||
);
|
||
|
||
const mapUserRecord = (record) => ({
|
||
id: record.id,
|
||
email: record.email,
|
||
name: record.name,
|
||
username: record.username,
|
||
role: record.role || 'user',
|
||
});
|
||
|
||
const isAdminAllowed = (user) => {
|
||
if (!user) return false;
|
||
const role = user.role || 'user';
|
||
const email = (user.email || '').toLowerCase();
|
||
return role === 'admin' && ADMIN_EMAILS.includes(email);
|
||
};
|
||
|
||
const authMiddleware = (req, res, next) => {
|
||
const header = req.headers.authorization || '';
|
||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||
if (!token) {
|
||
return res.status(401).json({ message: 'Yetkisiz erişim' });
|
||
}
|
||
try {
|
||
const payload = jwt.verify(token, JWT_SECRET);
|
||
req.user = payload;
|
||
return next();
|
||
} catch (error) {
|
||
return res.status(401).json({ message: 'Oturum süresi doldu, lütfen tekrar giriş yap.' });
|
||
}
|
||
};
|
||
|
||
const adminMiddleware = (req, res, next) => {
|
||
if (!req.user || !isAdminAllowed(req.user)) {
|
||
return res.status(403).json({ message: 'Bu işlem için yetkiniz yok.' });
|
||
}
|
||
return next();
|
||
};
|
||
|
||
io.use((socket, next) => {
|
||
const token =
|
||
socket.handshake.auth?.token ||
|
||
(socket.handshake.headers?.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
||
if (!token) {
|
||
return next(new Error('Yetkisiz erişim'));
|
||
}
|
||
try {
|
||
const payload = jwt.verify(token, JWT_SECRET);
|
||
socket.user = payload;
|
||
return next();
|
||
} catch (error) {
|
||
return next(new Error('Oturum doğrulanamadı'));
|
||
}
|
||
});
|
||
|
||
io.on('connection', (socket) => {
|
||
const email = socket.user?.email || 'bilinmiyor';
|
||
const displayName =
|
||
socket.user?.name || socket.user?.username || (socket.user?.email || '').split('@')[0] || 'kullanıcı';
|
||
console.log(`[Socket] Bağlandı: ${socket.id} (${email})`);
|
||
|
||
const adminUser = isAdminAllowed(socket.user);
|
||
if (adminUser) {
|
||
socket.join('admins');
|
||
}
|
||
socket.emit('connection:ready', { message: 'Socket bağlantısı kuruldu.', admin: adminUser });
|
||
|
||
if (socket.user?.sub) {
|
||
incrementActiveUser(socket.user.sub);
|
||
const existingTimer = presenceTimers.get(socket.user.sub);
|
||
if (existingTimer) {
|
||
clearTimeout(existingTimer);
|
||
presenceTimers.delete(socket.user.sub);
|
||
}
|
||
emitAdminEvent('presence:update', {
|
||
userId: socket.user.sub,
|
||
email: socket.user.email,
|
||
name: displayName,
|
||
active: true,
|
||
});
|
||
emitStats();
|
||
}
|
||
|
||
socket.on('ping', () => {
|
||
socket.emit('pong');
|
||
});
|
||
|
||
socket.on('disconnect', (reason) => {
|
||
console.log(`[Socket] Koptu: ${socket.id} sebep=${reason}`);
|
||
if (socket.user?.sub) {
|
||
decrementActiveUser(socket.user.sub);
|
||
if (activeUserConnections.has(socket.user.sub)) {
|
||
emitStats();
|
||
return;
|
||
}
|
||
// Reconnect sırasında çift log oluşmasını engellemek için kısa gecikme
|
||
const timeoutId = setTimeout(() => {
|
||
presenceTimers.delete(socket.user.sub);
|
||
emitAdminEvent('presence:update', {
|
||
userId: socket.user.sub,
|
||
email: socket.user.email,
|
||
name: displayName,
|
||
active: false,
|
||
});
|
||
emitStats();
|
||
}, 1500);
|
||
presenceTimers.set(socket.user.sub, timeoutId);
|
||
}
|
||
});
|
||
});
|
||
|
||
const authRouter = express.Router();
|
||
authRouter.use(enforceClientOrigin);
|
||
|
||
authRouter.post('/register', async (req, res) => {
|
||
const { name, email, password } = req.body || {};
|
||
if (!name || !email || !password) {
|
||
return res.status(400).json({ message: 'Ad, email ve şifre gereklidir.' });
|
||
}
|
||
const normalizedEmail = email.trim().toLowerCase();
|
||
const username = normalizedEmail.split('@')[0];
|
||
|
||
const { data: existingUser, error: existingError } = await supabase
|
||
.from(USERS_TABLE)
|
||
.select('id')
|
||
.eq('email', normalizedEmail)
|
||
.maybeSingle();
|
||
|
||
if (existingError && existingError.code !== 'PGRST116') {
|
||
return res.status(500).json({ message: 'Kullanıcı kontrolü başarısız.' });
|
||
}
|
||
|
||
if (existingUser) {
|
||
return res.status(409).json({ message: 'Bu email adresi ile zaten bir hesap mevcut.' });
|
||
}
|
||
|
||
const passwordHash = await bcrypt.hash(password, 10);
|
||
const { data: createdUser, error: insertError } = await supabase
|
||
.from(USERS_TABLE)
|
||
.insert({
|
||
name: name.trim(),
|
||
email: normalizedEmail,
|
||
username,
|
||
password_hash: passwordHash,
|
||
role: 'user',
|
||
})
|
||
.select('id,email,name,username,role')
|
||
.single();
|
||
|
||
if (insertError || !createdUser) {
|
||
return res.status(500).json({ message: 'Kullanıcı oluşturulamadı.' });
|
||
}
|
||
|
||
const token = createToken(createdUser);
|
||
emitAdminEvent('user:registered', {
|
||
email: createdUser.email,
|
||
name: createdUser.name,
|
||
id: createdUser.id,
|
||
});
|
||
emitStats();
|
||
return res.status(201).json({ token, user: mapUserRecord(createdUser) });
|
||
});
|
||
|
||
authRouter.post('/login', async (req, res) => {
|
||
const { email, password } = req.body || {};
|
||
if (!email || !password) {
|
||
return res.status(400).json({ message: 'Email ve şifre gereklidir.' });
|
||
}
|
||
|
||
const normalizedEmail = email.trim().toLowerCase();
|
||
const { data: userRecord, error: fetchError } = await supabase
|
||
.from(USERS_TABLE)
|
||
.select('id,email,name,username,password_hash,role')
|
||
.eq('email', normalizedEmail)
|
||
.maybeSingle();
|
||
|
||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||
return res.status(500).json({ message: 'Giriş işlemi başarısız.' });
|
||
}
|
||
if (!userRecord) {
|
||
return res.status(401).json({ message: 'Email veya şifre hatalı.' });
|
||
}
|
||
|
||
const validPassword = await bcrypt.compare(password, userRecord.password_hash);
|
||
if (!validPassword) {
|
||
return res.status(401).json({ message: 'Email veya şifre hatalı.' });
|
||
}
|
||
|
||
const token = createToken(userRecord);
|
||
return res.json({ token, user: mapUserRecord(userRecord) });
|
||
});
|
||
|
||
authRouter.post('/oauth', async (req, res) => {
|
||
const { accessToken } = req.body || {};
|
||
if (!accessToken) {
|
||
return res.status(400).json({ message: 'Geçerli bir Google oturumu bulunamadı.' });
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${process.env.SUPABASE_URL}/auth/v1/user`, {
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
apikey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
return res.status(401).json({ message: 'Google oturumu doğrulanamadı.' });
|
||
}
|
||
|
||
const supabaseUser = await response.json();
|
||
const email = supabaseUser?.email?.toLowerCase();
|
||
if (!email) {
|
||
return res.status(400).json({ message: 'Google hesabında email bilgisi bulunamadı.' });
|
||
}
|
||
const name =
|
||
supabaseUser?.user_metadata?.full_name ||
|
||
supabaseUser?.user_metadata?.name ||
|
||
email.split('@')[0];
|
||
const username = (supabaseUser?.user_metadata?.user_name || email.split('@')[0]).replace(/[^a-zA-Z0-9-_]/g, '');
|
||
|
||
const { data: existingUser, error: fetchError } = await supabase
|
||
.from(USERS_TABLE)
|
||
.select('id,email,name,username,role')
|
||
.eq('email', email)
|
||
.maybeSingle();
|
||
|
||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||
return res.status(500).json({ message: 'Kullanıcı sorgulanamadı.' });
|
||
}
|
||
|
||
let userRecord = existingUser;
|
||
if (!userRecord) {
|
||
const { data: insertedUser, error: insertError } = await supabase
|
||
.from(USERS_TABLE)
|
||
.upsert(
|
||
{
|
||
name,
|
||
email,
|
||
username,
|
||
password_hash: 'GOOGLE_OAUTH',
|
||
role: 'user',
|
||
},
|
||
{ onConflict: 'email' },
|
||
)
|
||
.select('id,email,name,username,role')
|
||
.single();
|
||
|
||
if (insertError || !insertedUser) {
|
||
return res.status(500).json({ message: 'Google hesabı oluşturulamadı.' });
|
||
}
|
||
userRecord = insertedUser;
|
||
}
|
||
|
||
const token = createToken(userRecord);
|
||
emitStats();
|
||
return res.json({ token, user: mapUserRecord(userRecord) });
|
||
} catch (error) {
|
||
console.error('Google OAuth hatası:', error);
|
||
return res.status(500).json({ message: 'Google girişi tamamlanamadı.' });
|
||
}
|
||
});
|
||
|
||
authRouter.get('/me', authMiddleware, async (req, res) => {
|
||
const { data: userRecord, error } = await supabase
|
||
.from(USERS_TABLE)
|
||
.select('id,email,name,username,role')
|
||
.eq('id', req.user.sub)
|
||
.single();
|
||
|
||
if (error || !userRecord) {
|
||
return res.status(404).json({ message: 'Kullanıcı bulunamadı.' });
|
||
}
|
||
|
||
return res.json({ user: mapUserRecord(userRecord) });
|
||
});
|
||
|
||
authRouter.post('/logout', (_req, res) => {
|
||
return res.json({ message: 'Çıkış yapıldı.' });
|
||
});
|
||
|
||
authRouter.post('/forgot-password', async (req, res) => {
|
||
const { email } = req.body || {};
|
||
if (!email) {
|
||
return res.status(400).json({ message: 'Email gereklidir.' });
|
||
}
|
||
|
||
// Supabase Auth kullanılmadığı için burada sadece bilgilendirici bir cevap döndürüyoruz.
|
||
return res.json({
|
||
message:
|
||
'Şifre sıfırlama talebin alındı. Bu demo ortamında e-posta gönderimi aktif değildir.',
|
||
});
|
||
});
|
||
|
||
app.use('/auth', authRouter);
|
||
|
||
app.get('/admin/translation-models', authMiddleware, adminMiddleware, (_req, res) => {
|
||
return res.json({
|
||
active: getActiveTranslationModel(),
|
||
models: listTranslationModels(),
|
||
});
|
||
});
|
||
|
||
app.post('/admin/translation-models', authMiddleware, adminMiddleware, (req, res) => {
|
||
const { model } = req.body || {};
|
||
try {
|
||
const newModel = setActiveTranslationModel(model);
|
||
emitAdminEvent('translate:model-changed', { active: newModel });
|
||
return res.json({ active: newModel, models: listTranslationModels() });
|
||
} catch (error) {
|
||
return res.status(400).json({ message: error.message || 'Model değiştirilemedi.' });
|
||
}
|
||
});
|
||
|
||
app.post('/translate', authMiddleware, async (req, res) => {
|
||
const { text } = req.body || {};
|
||
if (!text || !text.trim()) {
|
||
return res.status(400).json({ message: 'Çevrilecek metin bulunamadı.' });
|
||
}
|
||
|
||
console.log('[Translate] İstek alındı', { length: text.length, snippet: text.slice(0, 60) });
|
||
try {
|
||
const translated = await translateWithActiveModel(text);
|
||
const model = getActiveTranslationModel();
|
||
console.log('[Translate] Çeviri başarıyla döndü', { model });
|
||
emitAdminEvent('translate:completed', {
|
||
userId: req.user?.sub,
|
||
email: req.user?.email,
|
||
length: text.length,
|
||
model,
|
||
});
|
||
return res.json({ text: translated, model });
|
||
} catch (error) {
|
||
console.error('GLM çeviri hatası:', error);
|
||
emitAdminEvent('translate:failed', {
|
||
userId: req.user?.sub,
|
||
email: req.user?.email,
|
||
message: error?.message,
|
||
});
|
||
return res.status(500).json({ message: error.message || 'Çeviri tamamlanamadı.' });
|
||
}
|
||
});
|
||
|
||
app.post('/generate-epub', authMiddleware, async (req, res) => {
|
||
const { text, meta, cover } = req.body || {};
|
||
if (!text || !text.trim()) {
|
||
return res.status(400).json({ message: 'text is required' });
|
||
}
|
||
|
||
const title = meta?.title?.trim() || 'imgPub OCR Export';
|
||
const filename = meta?.filename || `imgpub${Date.now()}.epub`;
|
||
const authors =
|
||
Array.isArray(meta?.authors) && meta.authors.length
|
||
? meta.authors.filter(Boolean)
|
||
: meta?.author
|
||
? [meta.author]
|
||
: ['imgPub'];
|
||
const publisher = meta?.publisher || 'imgPub';
|
||
const language = meta?.language || 'tr';
|
||
const description = meta?.description || title;
|
||
|
||
const content = [
|
||
{
|
||
title,
|
||
data: `<div>${sanitizeHtml(text)}</div>`,
|
||
},
|
||
];
|
||
|
||
const outputPath = join(tmpdir(), `imgpub-${uuidV4()}.epub`);
|
||
let coverPath;
|
||
const metadataPayload = {
|
||
subtitle: meta?.subtitle,
|
||
description: meta?.description,
|
||
categories: Array.isArray(meta?.categories) ? meta.categories : [],
|
||
publishedDate: meta?.publishedDate,
|
||
language: meta?.language,
|
||
pageCount: meta?.pageCount,
|
||
averageRating: meta?.averageRating,
|
||
ratingsCount: meta?.ratingsCount,
|
||
identifiers: Array.isArray(meta?.identifiers) ? meta.identifiers : [],
|
||
infoLink: meta?.infoLink,
|
||
};
|
||
|
||
try {
|
||
if (cover?.data) {
|
||
const coverBuffer = Buffer.from(cover.data, 'base64');
|
||
const coverExtension =
|
||
cover?.mimeType?.split('/').pop() || cover?.filename?.split('.').pop() || 'png';
|
||
coverPath = join(tmpdir(), `imgpub-cover-${uuidV4()}.${coverExtension}`);
|
||
await fs.writeFile(coverPath, coverBuffer);
|
||
}
|
||
|
||
const epubOptions = {
|
||
title,
|
||
author: authors,
|
||
publisher,
|
||
description,
|
||
lang: language,
|
||
content,
|
||
bookMetadata: metadataPayload,
|
||
customOpfTemplatePath: join(__dirname, 'templates', 'content.opf.ejs'),
|
||
};
|
||
if (coverPath) {
|
||
epubOptions.cover = coverPath;
|
||
}
|
||
|
||
const epub = new Epub(epubOptions, outputPath);
|
||
await epub.promise;
|
||
const buffer = await fs.readFile(outputPath);
|
||
await fs.unlink(outputPath).catch(() => {});
|
||
if (coverPath) {
|
||
await fs.unlink(coverPath).catch(() => {});
|
||
}
|
||
emitAdminEvent('epub:generated', {
|
||
userId: req.user?.sub,
|
||
email: req.user?.email,
|
||
filename,
|
||
byteLength: buffer.length,
|
||
language,
|
||
});
|
||
res.json({ filename, data: buffer.toString('base64') });
|
||
} catch (error) {
|
||
console.error('EPUB generation failed:', error);
|
||
emitAdminEvent('epub:failed', {
|
||
userId: req.user?.sub,
|
||
email: req.user?.email,
|
||
message: error?.message,
|
||
});
|
||
res.status(500).json({ message: 'EPUB generation failed' });
|
||
}
|
||
});
|
||
|
||
app.get('/', (_, res) => {
|
||
res.json({ status: 'ok' });
|
||
});
|
||
|
||
server.listen(PORT, () => {
|
||
console.log(`imgPub EPUB server listening on port ${PORT}`);
|
||
});
|