diff --git a/.env.example b/.env.example
index dfd98be..a778496 100644
--- a/.env.example
+++ b/.env.example
@@ -15,3 +15,12 @@ CLIENT_ORIGIN="http://localhost:5173"
ZAI_GLM_API_KEY="YOUR_ZAI_GLM_API_KEY"
ZAI_GLM_MODEL="glm-4.6"
ZAI_GLM_API_URL="https://api.z.ai/api/anthropic"
+
+# Diğer çeviri modelleri (opsiyonel)
+OPENAI_API_KEY=""
+OPENAI_MODEL="gpt-4o-mini"
+DEEPSEEK_API_KEY=""
+DEEPSEEK_MODEL="deepseek-chat"
+
+# Varsayılan aktif çeviri modeli (glm | openai | deepseek)
+TRANSLATION_ACTIVE_MODEL="glm"
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index e5d033a..273f816 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -28,6 +28,12 @@ services:
- ZAI_GLM_API_KEY=${ZAI_GLM_API_KEY}
- ZAI_GLM_MODEL=${ZAI_GLM_MODEL:-glm-4.6}
- ZAI_GLM_API_URL=${ZAI_GLM_API_URL:-https://api.z.ai/api/anthropic}
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
+ - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
+ - DEEPSEEK_MODEL=${DEEPSEEK_MODEL:-deepseek-chat}
+ - TRANSLATION_ACTIVE_MODEL=${TRANSLATION_ACTIVE_MODEL:-glm}
+ - ADMIN_ALLOWED_EMAILS=${ADMIN_ALLOWED_EMAILS}
ports:
- "4000:4000"
volumes:
diff --git a/package.json b/package.json
index ac4db3c..a0d7011 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"react-dropzone": "^14.2.3",
"react-easy-crop": "^5.0.7",
"react-router-dom": "^7.0.2",
+ "socket.io-client": "^4.8.1",
"tesseract.js": "^5.1.1",
"zustand": "^5.0.2"
},
diff --git a/server/index.js b/server/index.js
index f58bfc5..5616780 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,5 +1,6 @@
import 'dotenv/config';
import express from 'express';
+import { createServer } from 'http';
import cors from 'cors';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
@@ -9,6 +10,7 @@ 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',
@@ -24,10 +26,62 @@ requiredEnv.forEach((key) => {
});
const __dirname = dirname(fileURLToPath(import.meta.url));
-const app = express();
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 {
@@ -57,7 +111,12 @@ const enforceClientOrigin = (req, res, 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 { translateWithGlm } from './src/services/glmClient.js';
+import {
+ getActiveTranslationModel,
+ listTranslationModels,
+ setActiveTranslationModel,
+ translateWithActiveModel,
+} from './src/services/translationService.js';
app.use(
cors({
@@ -82,6 +141,8 @@ const createToken = (user) =>
sub: user.id,
email: user.email,
username: user.username,
+ name: user.name,
+ role: user.role || 'user',
},
JWT_SECRET,
{ expiresIn: '7d' },
@@ -92,8 +153,16 @@ const mapUserRecord = (record) => ({
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;
@@ -109,6 +178,85 @@ const authMiddleware = (req, res, next) => {
}
};
+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);
@@ -142,8 +290,9 @@ authRouter.post('/register', async (req, res) => {
email: normalizedEmail,
username,
password_hash: passwordHash,
+ role: 'user',
})
- .select('id,email,name,username')
+ .select('id,email,name,username,role')
.single();
if (insertError || !createdUser) {
@@ -151,6 +300,12 @@ authRouter.post('/register', async (req, res) => {
}
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) });
});
@@ -163,7 +318,7 @@ authRouter.post('/login', async (req, res) => {
const normalizedEmail = email.trim().toLowerCase();
const { data: userRecord, error: fetchError } = await supabase
.from(USERS_TABLE)
- .select('id,email,name,username,password_hash')
+ .select('id,email,name,username,password_hash,role')
.eq('email', normalizedEmail)
.maybeSingle();
@@ -214,7 +369,7 @@ authRouter.post('/oauth', async (req, res) => {
const { data: existingUser, error: fetchError } = await supabase
.from(USERS_TABLE)
- .select('id,email,name,username')
+ .select('id,email,name,username,role')
.eq('email', email)
.maybeSingle();
@@ -226,13 +381,17 @@ authRouter.post('/oauth', async (req, res) => {
if (!userRecord) {
const { data: insertedUser, error: insertError } = await supabase
.from(USERS_TABLE)
- .insert({
- name,
- email,
- username,
- password_hash: 'GOOGLE_OAUTH',
- })
- .select('id,email,name,username')
+ .upsert(
+ {
+ name,
+ email,
+ username,
+ password_hash: 'GOOGLE_OAUTH',
+ role: 'user',
+ },
+ { onConflict: 'email' },
+ )
+ .select('id,email,name,username,role')
.single();
if (insertError || !insertedUser) {
@@ -242,6 +401,7 @@ authRouter.post('/oauth', async (req, res) => {
}
const token = createToken(userRecord);
+ emitStats();
return res.json({ token, user: mapUserRecord(userRecord) });
} catch (error) {
console.error('Google OAuth hatası:', error);
@@ -252,7 +412,7 @@ authRouter.post('/oauth', async (req, res) => {
authRouter.get('/me', authMiddleware, async (req, res) => {
const { data: userRecord, error } = await supabase
.from(USERS_TABLE)
- .select('id,email,name,username')
+ .select('id,email,name,username,role')
.eq('id', req.user.sub)
.single();
@@ -282,6 +442,24 @@ authRouter.post('/forgot-password', async (req, res) => {
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()) {
@@ -290,11 +468,23 @@ app.post('/translate', authMiddleware, async (req, res) => {
console.log('[Translate] İstek alındı', { length: text.length, snippet: text.slice(0, 60) });
try {
- const translated = await translateWithGlm(text);
- console.log('[Translate] Çeviri başarıyla döndü');
- return res.json({ text: translated });
+ 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ı.' });
}
});
@@ -369,9 +559,21 @@ app.post('/generate-epub', authMiddleware, async (req, res) => {
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' });
}
});
@@ -380,6 +582,6 @@ app.get('/', (_, res) => {
res.json({ status: 'ok' });
});
-app.listen(PORT, () => {
+server.listen(PORT, () => {
console.log(`imgPub EPUB server listening on port ${PORT}`);
});
diff --git a/server/package.json b/server/package.json
index b56f448..d5ca6b7 100644
--- a/server/package.json
+++ b/server/package.json
@@ -15,6 +15,7 @@
"epub-gen": "^0.1.0",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
+ "socket.io": "^4.8.1",
"nodemon": "^3.1.4",
"uuid": "^9.0.1"
}
diff --git a/server/src/services/glmClient.js b/server/src/services/glmClient.js
index b474ecf..299e5c1 100644
--- a/server/src/services/glmClient.js
+++ b/server/src/services/glmClient.js
@@ -78,8 +78,10 @@ const extractContent = (payload) => {
return '';
};
+export const isGlmConfigured = () => Boolean(GLM_API_KEY);
+
export const translateWithGlm = async (text) => {
- if (!GLM_API_KEY) {
+ if (!isGlmConfigured()) {
throw new Error('ZAI_GLM_API_KEY veya ANTHROPIC_API_KEY tanımlı değil.');
}
diff --git a/server/src/services/translationService.js b/server/src/services/translationService.js
new file mode 100644
index 0000000..7306dc3
--- /dev/null
+++ b/server/src/services/translationService.js
@@ -0,0 +1,77 @@
+import { translateWithGlm, isGlmConfigured } from './glmClient.js';
+
+const AVAILABLE_PROVIDERS = {
+ glm: {
+ id: 'glm',
+ label: 'GLM / Anthropic',
+ isConfigured: () => isGlmConfigured(),
+ translate: translateWithGlm,
+ envKeys: ['ZAI_GLM_API_KEY', 'ANTHROPIC_API_KEY'],
+ },
+ openai: {
+ id: 'openai',
+ label: 'OpenAI ChatGPT',
+ isConfigured: () => Boolean(process.env.OPENAI_API_KEY),
+ translate: async () => {
+ throw new Error('OpenAI çeviri modeli henüz yapılandırılmadı.');
+ },
+ envKeys: ['OPENAI_API_KEY'],
+ },
+ deepseek: {
+ id: 'deepseek',
+ label: 'DeepSeek',
+ isConfigured: () => Boolean(process.env.DEEPSEEK_API_KEY),
+ translate: async () => {
+ throw new Error('DeepSeek çeviri modeli henüz yapılandırılmadı.');
+ },
+ envKeys: ['DEEPSEEK_API_KEY'],
+ },
+};
+
+const normalizedModel = (value) => (value || '').toString().trim().toLowerCase();
+
+const resolveInitialModel = () => {
+ const preferred = normalizedModel(process.env.TRANSLATION_ACTIVE_MODEL || 'glm');
+ const preferredProvider = AVAILABLE_PROVIDERS[preferred];
+ if (preferredProvider && preferredProvider.isConfigured()) {
+ return preferredProvider.id;
+ }
+
+ const firstConfigured = Object.values(AVAILABLE_PROVIDERS).find((provider) =>
+ provider.isConfigured(),
+ );
+ return firstConfigured ? firstConfigured.id : 'glm';
+};
+
+let activeModel = resolveInitialModel();
+
+export const getActiveTranslationModel = () => activeModel;
+
+export const setActiveTranslationModel = (modelId) => {
+ const provider = AVAILABLE_PROVIDERS[normalizedModel(modelId)];
+ if (!provider) {
+ throw new Error('Geçersiz çeviri modeli.');
+ }
+ if (!provider.isConfigured()) {
+ throw new Error(`${provider.label} için API anahtarı tanımlı değil.`);
+ }
+ activeModel = provider.id;
+ return activeModel;
+};
+
+export const listTranslationModels = () =>
+ Object.values(AVAILABLE_PROVIDERS).map((provider) => ({
+ id: provider.id,
+ label: provider.label,
+ configured: provider.isConfigured(),
+ active: provider.id === activeModel,
+ requiredEnv: provider.envKeys,
+ }));
+
+export const translateWithActiveModel = async (text) => {
+ const provider = AVAILABLE_PROVIDERS[activeModel];
+ if (!provider || !provider.isConfigured()) {
+ throw new Error('Aktif çeviri modeli yapılandırılmadı.');
+ }
+ return provider.translate(text);
+};
diff --git a/src/App.jsx b/src/App.jsx
index c5c2dd9..20cad9f 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -15,10 +15,11 @@ import {
Stepper,
Typography,
} from '@mui/material';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from './store/useAppStore';
import { fetchCurrentUser, loginWithGoogle, logoutUser } from './utils/authApi';
import { supabaseClient } from './lib/supabaseClient';
+import { createSocketClient } from './lib/socketClient';
export const wizardSteps = [
{ label: 'Yükle', path: '/' },
@@ -43,6 +44,9 @@ const App = () => {
const currentUser = useAppStore((state) => state.currentUser);
const authToken = useAppStore((state) => state.authToken);
const [menuAnchor, setMenuAnchor] = useState(null);
+ const isAdminPage = location.pathname === '/myadminpage';
+ const isAdmin = (currentUser?.role || 'user') === 'admin';
+ const presenceSocket = useRef(null);
const handleSnackbarClose = (_, reason) => {
if (reason === 'clickaway') return;
@@ -53,6 +57,29 @@ const App = () => {
initializeAuth();
}, [initializeAuth]);
+ // Pasif kullanıcılar için arka planda socket bağlantısı (aktif kullanıcı sayımı)
+ useEffect(() => {
+ if (!authToken) {
+ presenceSocket.current?.disconnect();
+ presenceSocket.current = null;
+ return undefined;
+ }
+
+ // Bağlantı varsa önce kapat
+ if (presenceSocket.current) {
+ presenceSocket.current.disconnect();
+ presenceSocket.current = null;
+ }
+
+ const client = createSocketClient(authToken);
+ presenceSocket.current = client;
+ client.connect();
+
+ return () => {
+ client.disconnect();
+ };
+ }, [authToken]);
+
useEffect(() => {
const syncUser = async () => {
if (!authToken) return;
@@ -184,6 +211,16 @@ const App = () => {