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: `
${sanitizeHtml(text)}
`, }, ]; 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}`); });