Files
imgPub/server/index.js
2025-11-21 01:21:37 +03:00

588 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.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}`);
});