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(/\n/g, '
');
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: `