upd
This commit is contained in:
@@ -97,6 +97,7 @@
|
|||||||
<span id="ws-text">Подключение...</span>
|
<span id="ws-text">Подключение...</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="logout-btn" onclick="doLogout()" class="hidden text-xs text-gray-500 hover:text-gray-300 px-3 py-1 rounded-lg transition-colors" style="background:#1e293b">Выйти</button>
|
<button id="logout-btn" onclick="doLogout()" class="hidden text-xs text-gray-500 hover:text-gray-300 px-3 py-1 rounded-lg transition-colors" style="background:#1e293b">Выйти</button>
|
||||||
|
<span id="user-info" class="hidden text-xs text-gray-400 px-2"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -201,6 +202,54 @@
|
|||||||
<p class="text-xs text-gray-500 mb-4">Источники определяются в коде приложения. Здесь можно управлять доменами для каждого источника.</p>
|
<p class="text-xs text-gray-500 mb-4">Источники определяются в коде приложения. Здесь можно управлять доменами для каждого источника.</p>
|
||||||
<div id="sources-list" class="flex flex-col gap-3"></div>
|
<div id="sources-list" class="flex flex-col gap-3"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Управление пользователями (только для admin) -->
|
||||||
|
<div id="users-section" class="hidden px-5 py-4 border-t border-gray-800">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider">Пользователи</h3>
|
||||||
|
<button onclick="openAddUserModal()" class="text-xs px-3 py-1.5 rounded-lg font-semibold text-white" style="background:#4f46e5">+ Добавить</button>
|
||||||
|
</div>
|
||||||
|
<div id="users-list" class="flex flex-col gap-2"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Смена своего пароля -->
|
||||||
|
<div class="px-5 py-4 border-t border-gray-800">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Сменить пароль</h3>
|
||||||
|
<div class="flex flex-col gap-2 max-w-sm">
|
||||||
|
<input id="chpwd-new" type="password" placeholder="Новый пароль"
|
||||||
|
class="px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none"
|
||||||
|
style="background:#0f1117">
|
||||||
|
<button onclick="changeOwnPassword()" class="text-xs px-4 py-2 rounded-lg font-semibold text-white" style="background:#4f46e5">Сохранить пароль</button>
|
||||||
|
<div id="chpwd-msg" class="text-xs hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Modal -->
|
||||||
|
<div id="user-modal" class="fixed inset-0 z-[60] hidden items-center justify-center" style="background:rgba(0,0,0,0.75)">
|
||||||
|
<div class="card rounded-2xl w-full max-w-md mx-4 p-6 flex flex-col gap-4">
|
||||||
|
<h3 class="font-semibold text-white text-base" id="user-modal-title">Добавить пользователя</h3>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-400 mb-1 block">Логин</label>
|
||||||
|
<input id="user-modal-username" type="text" class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none" style="background:#0f1117" placeholder="username">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-400 mb-1 block">Пароль <span id="user-modal-pwd-hint" class="text-gray-600">(оставьте пустым чтобы не менять)</span></label>
|
||||||
|
<input id="user-modal-password" type="password" class="w-full px-3 py-2 rounded-lg text-sm text-white border border-gray-700 focus:border-indigo-500 outline-none" style="background:#0f1117" placeholder="••••••">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-400 mb-1 block">Роль</label>
|
||||||
|
<select id="user-modal-role" class="w-full px-3 py-2 text-sm rounded-lg border border-gray-700" style="background:#0f1117;color:#e2e8f0">
|
||||||
|
<option value="user">user — обычный</option>
|
||||||
|
<option value="admin">admin — администратор</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="user-modal-error" class="text-xs text-red-400 hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 justify-end mt-2">
|
||||||
|
<button onclick="closeUserModal()" class="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white" style="background:#1e293b">Отмена</button>
|
||||||
|
<button onclick="saveUserModal()" id="user-modal-save" class="px-4 py-2 rounded-lg text-sm font-semibold text-white" style="background:#4f46e5">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,6 +377,7 @@ const state = {
|
|||||||
chapters: {}, // manga_url → [chapter, ...]
|
chapters: {}, // manga_url → [chapter, ...]
|
||||||
filter: 'all',
|
filter: 'all',
|
||||||
sources: [], // [{id, slug, display_name, domains}]
|
sources: [], // [{id, slug, display_name, domains}]
|
||||||
|
currentUser: null, // {id, username, role}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────
|
// ── Auth ─────────────────────────────────────
|
||||||
@@ -341,14 +391,30 @@ function showLoginScreen() {
|
|||||||
function hideLoginScreen() {
|
function hideLoginScreen() {
|
||||||
document.getElementById('login-screen').classList.add('hidden');
|
document.getElementById('login-screen').classList.add('hidden');
|
||||||
document.getElementById('logout-btn').classList.remove('hidden');
|
document.getElementById('logout-btn').classList.remove('hidden');
|
||||||
|
// Обновляем отображение текущего пользователя в хедере
|
||||||
|
const uinfo = document.getElementById('user-info');
|
||||||
|
if(uinfo && state.currentUser) {
|
||||||
|
const roleLabel = state.currentUser.role === 'admin' ? '👑' : '👤';
|
||||||
|
uinfo.textContent = `${roleLabel} ${state.currentUser.username}`;
|
||||||
|
uinfo.classList.remove('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/auth/check');
|
const r = await fetch('/api/auth/check');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if(!data.auth_enabled) { hideLoginScreen(); return true; }
|
if(data.authenticated && data.user) {
|
||||||
if(data.authenticated) { hideLoginScreen(); return true; }
|
state.currentUser = data.user;
|
||||||
|
hideLoginScreen();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if(!data.auth_enabled) {
|
||||||
|
// auth отключён (обратная совместимость)
|
||||||
|
state.currentUser = {id: 0, username: 'guest', role: 'admin'};
|
||||||
|
hideLoginScreen();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
showLoginScreen();
|
showLoginScreen();
|
||||||
return false;
|
return false;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -376,6 +442,8 @@ async function doLogin() {
|
|||||||
err.classList.remove('hidden');
|
err.classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const d = await r.json().catch(()=>({}));
|
||||||
|
if(d.user) state.currentUser = d.user;
|
||||||
hideLoginScreen();
|
hideLoginScreen();
|
||||||
await initApp();
|
await initApp();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -388,9 +456,12 @@ async function doLogin() {
|
|||||||
|
|
||||||
async function doLogout() {
|
async function doLogout() {
|
||||||
await fetch('/api/logout', {method:'POST'}).catch(()=>{});
|
await fetch('/api/logout', {method:'POST'}).catch(()=>{});
|
||||||
|
state.currentUser = null;
|
||||||
showLoginScreen();
|
showLoginScreen();
|
||||||
document.getElementById('login-input').value = '';
|
document.getElementById('login-input').value = '';
|
||||||
document.getElementById('password-input').value = '';
|
document.getElementById('password-input').value = '';
|
||||||
|
const uinfo = document.getElementById('user-info');
|
||||||
|
if(uinfo) uinfo.classList.add('hidden');
|
||||||
// Сбрасываем состояние
|
// Сбрасываем состояние
|
||||||
Object.keys(state.mangas).forEach(k => delete state.mangas[k]);
|
Object.keys(state.mangas).forEach(k => delete state.mangas[k]);
|
||||||
Object.keys(state.chapters).forEach(k => delete state.chapters[k]);
|
Object.keys(state.chapters).forEach(k => delete state.chapters[k]);
|
||||||
@@ -462,6 +533,8 @@ function handleEvent(msg) {
|
|||||||
const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null;
|
const srcInfo = msg.source_id ? (state.sources.find(s => s.id === msg.source_id) || null) : null;
|
||||||
state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format,
|
state.mangas[msg.url] = { url: msg.url, title: msg.url, status: 'queued', format: msg.format,
|
||||||
chapters_total: 0, chapters_done: 0, size_human: '—',
|
chapters_total: 0, chapters_done: 0, size_human: '—',
|
||||||
|
added_by: msg.added_by || null,
|
||||||
|
added_by_username: msg.added_by_username || null,
|
||||||
source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null };
|
source: srcInfo ? {id: srcInfo.id, slug: srcInfo.slug, display_name: srcInfo.display_name} : null };
|
||||||
} else {
|
} else {
|
||||||
state.mangas[msg.url].status = 'queued';
|
state.mangas[msg.url].status = 'queued';
|
||||||
@@ -982,6 +1055,18 @@ async function stopManga(url) {
|
|||||||
await fetch('/api/mangas/stop?url='+encodeURIComponent(url), {method:'POST'});
|
await fetch('/api/mangas/stop?url='+encodeURIComponent(url), {method:'POST'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers доступа ─────────────────────────
|
||||||
|
function isAdmin() {
|
||||||
|
return state.currentUser && state.currentUser.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManage(manga) {
|
||||||
|
if(!state.currentUser) return false;
|
||||||
|
if(state.currentUser.role === 'admin') return true;
|
||||||
|
if(!manga) return false;
|
||||||
|
return manga.added_by === state.currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
async function resumeManga(url) {
|
async function resumeManga(url) {
|
||||||
const r = await fetch('/api/mangas/resume?url='+encodeURIComponent(url), {method:'POST'});
|
const r = await fetch('/api/mangas/resume?url='+encodeURIComponent(url), {method:'POST'});
|
||||||
if(r.ok && state.mangas[url]) {
|
if(r.ok && state.mangas[url]) {
|
||||||
@@ -990,6 +1075,163 @@ async function resumeManga(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Users management (admin only) ─────────────
|
||||||
|
let _userModalEditId = null;
|
||||||
|
|
||||||
|
function showUsersSection() {
|
||||||
|
if(isAdmin()) {
|
||||||
|
document.getElementById('users-section').classList.remove('hidden');
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
if(!isAdmin()) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/users');
|
||||||
|
if(!r.ok) return;
|
||||||
|
const users = await r.json();
|
||||||
|
renderUsers(users);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(users) {
|
||||||
|
const el = document.getElementById('users-list');
|
||||||
|
if(!el) return;
|
||||||
|
if(!users.length) {
|
||||||
|
el.innerHTML = '<div class="text-xs text-gray-500">Нет пользователей</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roleColors = {admin: 'color:#fbbf24;background:#292103', user: 'color:#86efac;background:#032911'};
|
||||||
|
el.innerHTML = users.map(u => `
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 rounded-lg" style="background:#1e293b">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-white font-medium">${escHtml(u.username)}</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-semibold" style="${roleColors[u.role] || ''}">
|
||||||
|
${u.role === 'admin' ? '👑 admin' : '👤 user'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="openEditUserModal(${u.id}, '${escHtml(u.username)}', '${u.role}')"
|
||||||
|
class="text-xs px-2 py-1 rounded text-gray-400 hover:text-white" style="background:#334155">✏️</button>
|
||||||
|
${u.id !== state.currentUser?.id ? `<button onclick="confirmDeleteUser(${u.id}, '${escHtml(u.username)}')"
|
||||||
|
class="text-xs px-2 py-1 rounded text-red-400 hover:text-red-300" style="background:#2d1111">✕</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddUserModal() {
|
||||||
|
_userModalEditId = null;
|
||||||
|
document.getElementById('user-modal-title').textContent = 'Добавить пользователя';
|
||||||
|
document.getElementById('user-modal-username').value = '';
|
||||||
|
document.getElementById('user-modal-username').disabled = false;
|
||||||
|
document.getElementById('user-modal-password').value = '';
|
||||||
|
document.getElementById('user-modal-pwd-hint').style.display = 'none';
|
||||||
|
document.getElementById('user-modal-role').value = 'user';
|
||||||
|
document.getElementById('user-modal-error').classList.add('hidden');
|
||||||
|
document.getElementById('user-modal').style.display = 'flex';
|
||||||
|
document.getElementById('user-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditUserModal(id, username, role) {
|
||||||
|
_userModalEditId = id;
|
||||||
|
document.getElementById('user-modal-title').textContent = 'Редактировать пользователя';
|
||||||
|
document.getElementById('user-modal-username').value = username;
|
||||||
|
document.getElementById('user-modal-username').disabled = false;
|
||||||
|
document.getElementById('user-modal-password').value = '';
|
||||||
|
document.getElementById('user-modal-pwd-hint').style.display = '';
|
||||||
|
document.getElementById('user-modal-role').value = role;
|
||||||
|
document.getElementById('user-modal-error').classList.add('hidden');
|
||||||
|
document.getElementById('user-modal').style.display = 'flex';
|
||||||
|
document.getElementById('user-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserModal() {
|
||||||
|
document.getElementById('user-modal').style.display = 'none';
|
||||||
|
document.getElementById('user-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserModal() {
|
||||||
|
const errEl = document.getElementById('user-modal-error');
|
||||||
|
const btn = document.getElementById('user-modal-save');
|
||||||
|
const username = document.getElementById('user-modal-username').value.trim();
|
||||||
|
const password = document.getElementById('user-modal-password').value;
|
||||||
|
const role = document.getElementById('user-modal-role').value;
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
let r;
|
||||||
|
if(_userModalEditId === null) {
|
||||||
|
// Создание
|
||||||
|
if(!username || !password) { errEl.textContent = 'Логин и пароль обязательны'; errEl.classList.remove('hidden'); return; }
|
||||||
|
r = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({username, password, role}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Редактирование
|
||||||
|
const body = {role};
|
||||||
|
if(username) body.username = username;
|
||||||
|
if(password) body.password = password;
|
||||||
|
r = await fetch(`/api/users/${_userModalEditId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(!r.ok) {
|
||||||
|
const d = await r.json().catch(()=>({}));
|
||||||
|
errEl.textContent = d.detail || 'Ошибка';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeUserModal();
|
||||||
|
loadUsers();
|
||||||
|
} catch(e) {
|
||||||
|
errEl.textContent = 'Ошибка сети';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteUser(id, username) {
|
||||||
|
if(!confirm(`Удалить пользователя «${username}»?`)) return;
|
||||||
|
const r = await fetch(`/api/users/${id}`, {method: 'DELETE'});
|
||||||
|
if(r.ok) {
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
const d = await r.json().catch(()=>({}));
|
||||||
|
alert(d.detail || 'Ошибка удаления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeOwnPassword() {
|
||||||
|
if(!state.currentUser) return;
|
||||||
|
const pwd = document.getElementById('chpwd-new').value;
|
||||||
|
const msg = document.getElementById('chpwd-msg');
|
||||||
|
msg.classList.add('hidden');
|
||||||
|
if(!pwd) { msg.textContent = 'Введите новый пароль'; msg.className = 'text-xs text-red-400'; msg.classList.remove('hidden'); return; }
|
||||||
|
const r = await fetch(`/api/users/${state.currentUser.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({password: pwd}),
|
||||||
|
});
|
||||||
|
if(r.ok) {
|
||||||
|
msg.textContent = '✓ Пароль сохранён';
|
||||||
|
msg.className = 'text-xs text-green-400';
|
||||||
|
msg.classList.remove('hidden');
|
||||||
|
document.getElementById('chpwd-new').value = '';
|
||||||
|
} else {
|
||||||
|
const d = await r.json().catch(()=>({}));
|
||||||
|
msg.textContent = d.detail || 'Ошибка';
|
||||||
|
msg.className = 'text-xs text-red-400';
|
||||||
|
msg.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sources ───────────────────────────────────
|
// ── Sources ───────────────────────────────────
|
||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
try {
|
try {
|
||||||
@@ -1022,17 +1264,17 @@ function renderSources() {
|
|||||||
${s.domains.map(d => `
|
${s.domains.map(d => `
|
||||||
<span class="flex items-center gap-1 text-xs px-2 py-1 rounded" style="background:#1e293b;color:#94a3b8">
|
<span class="flex items-center gap-1 text-xs px-2 py-1 rounded" style="background:#1e293b;color:#94a3b8">
|
||||||
${escHtml(d)}
|
${escHtml(d)}
|
||||||
<button onclick="removeDomain(${s.id}, '${escHtml(d)}')"
|
${isAdmin() ? `<button onclick="removeDomain(${s.id}, '${escHtml(d)}')"
|
||||||
title="Удалить домен"
|
title="Удалить домен"
|
||||||
style="color:#ef4444;background:none;border:none;cursor:pointer;padding:0 2px;font-size:0.8rem;line-height:1">✕</button>
|
style="color:#ef4444;background:none;border:none;cursor:pointer;padding:0 2px;font-size:0.8rem;line-height:1">✕</button>` : ''}
|
||||||
</span>
|
</span>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
<span id="add-domain-area-${s.id}">
|
${isAdmin() ? `<span id="add-domain-area-${s.id}">
|
||||||
<button onclick="showAddDomain(${s.id})"
|
<button onclick="showAddDomain(${s.id})"
|
||||||
style="font-size:0.7rem;padding:3px 8px;border-radius:4px;background:#1e293b;color:#6ee7b7;border:1px dashed #334155;cursor:pointer">
|
style="font-size:0.7rem;padding:3px 8px;border-radius:4px;background:#1e293b;color:#6ee7b7;border:1px dashed #334155;cursor:pointer">
|
||||||
+ домен
|
+ домен
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -1494,6 +1736,7 @@ function renderMangaRow(m) {
|
|||||||
<span data-r="timer">${_timerHtml(m)}</span>
|
<span data-r="timer">${_timerHtml(m)}</span>
|
||||||
<span data-r="qpos">${m.queue_position ? `<span class="text-yellow-400">Позиция в очереди: ${m.queue_position}</span>` : ''}</span>
|
<span data-r="qpos">${m.queue_position ? `<span class="text-yellow-400">Позиция в очереди: ${m.queue_position}</span>` : ''}</span>
|
||||||
<span data-r="checked">${m.last_checked_at ? `<span title="${escHtml(m.last_checked_at)}">🔍 ${_relTime(m.last_checked_at)}</span>` : ''}</span>
|
<span data-r="checked">${m.last_checked_at ? `<span title="${escHtml(m.last_checked_at)}">🔍 ${_relTime(m.last_checked_at)}</span>` : ''}</span>
|
||||||
|
${isAdmin() && m.added_by_username ? `<span class="text-gray-600">👤 ${escHtml(m.added_by_username)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div data-r="progress">${progressHtml}</div>
|
<div data-r="progress">${progressHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1520,6 +1763,8 @@ function _relTime(iso) {
|
|||||||
function _rowButtons(m) {
|
function _rowButtons(m) {
|
||||||
const u = escHtml(m.url);
|
const u = escHtml(m.url);
|
||||||
const isActive = m.status === 'downloading' || m.status === 'queued';
|
const isActive = m.status === 'downloading' || m.status === 'queued';
|
||||||
|
const manage = canManage(m);
|
||||||
|
const admin = isAdmin();
|
||||||
return `
|
return `
|
||||||
<button onclick="openDetail('${u}','overview')"
|
<button onclick="openDetail('${u}','overview')"
|
||||||
title="Информация о манге"
|
title="Информация о манге"
|
||||||
@@ -1529,21 +1774,24 @@ function _rowButtons(m) {
|
|||||||
title="${m.errors_count} проблем при загрузке"
|
title="${m.errors_count} проблем при загрузке"
|
||||||
style="background:#450a0a;color:#fca5a5;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">⚠️ ${m.errors_count}</button>`
|
style="background:#450a0a;color:#fca5a5;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">⚠️ ${m.errors_count}</button>`
|
||||||
: ''}
|
: ''}
|
||||||
${isActive
|
${isActive && manage
|
||||||
? `<button onclick="stopManga('${u}')" class="btn-danger" title="Остановить" style="background:#7c2d12;color:#fdba74">⏸</button>`
|
? `<button onclick="stopManga('${u}')" class="btn-danger" title="Остановить" style="background:#7c2d12;color:#fdba74">⏸</button>`
|
||||||
: ''}
|
: ''}
|
||||||
${m.status === 'stopped' || m.status === 'failed'
|
${(m.status === 'stopped' || m.status === 'failed') && manage
|
||||||
? `<button onclick="resumeManga('${u}')" title="Возобновить" style="background:#14532d;color:#86efac;padding:4px 12px;border-radius:6px;font-size:0.75rem;cursor:pointer">▶</button>`
|
? `<button onclick="resumeManga('${u}')" title="Возобновить" style="background:#14532d;color:#86efac;padding:4px 12px;border-radius:6px;font-size:0.75rem;cursor:pointer">▶</button>`
|
||||||
: ''}
|
: ''}
|
||||||
${m.status === 'queued'
|
${m.status === 'queued' && admin
|
||||||
? `<button onclick="prioritizeManga('${u}')" title="Загрузить первой" style="background:#312e81;color:#a5b4fc;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">🚀</button>`
|
? `<button onclick="prioritizeManga('${u}')" title="Загрузить первой" style="background:#312e81;color:#a5b4fc;padding:4px 8px;border-radius:6px;font-size:0.75rem;cursor:pointer">🚀</button>`
|
||||||
: ''}
|
: ''}
|
||||||
<button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button>
|
${admin
|
||||||
|
? `<button onclick="deleteManga('${u}')" class="btn-danger" title="Удалить">✕</button>`
|
||||||
|
: ''}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _rowAuto(m) {
|
function _rowAuto(m) {
|
||||||
if(m.pub_status !== 'ongoing') return '';
|
if(m.pub_status !== 'ongoing') return '';
|
||||||
|
if(!canManage(m)) return ''; // только владелец или admin
|
||||||
const u = escHtml(m.url);
|
const u = escHtml(m.url);
|
||||||
const autoOn = m.auto_update == 1;
|
const autoOn = m.auto_update == 1;
|
||||||
return `
|
return `
|
||||||
@@ -1747,6 +1995,11 @@ function renderModalBody(data) {
|
|||||||
<span class="text-gray-400 font-semibold">Добавлена:</span>
|
<span class="text-gray-400 font-semibold">Добавлена:</span>
|
||||||
<span class="text-gray-300">${new Date(data.added_at+'Z').toLocaleString('ru-RU')}</span>
|
<span class="text-gray-300">${new Date(data.added_at+'Z').toLocaleString('ru-RU')}</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
${data.added_by_username ? `
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<span class="text-gray-400 font-semibold">Добавил:</span>
|
||||||
|
<span class="text-gray-300">👤 ${escHtml(data.added_by_username)}</span>
|
||||||
|
</div>` : ''}
|
||||||
${data.started_at ? `
|
${data.started_at ? `
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<span class="text-gray-400 font-semibold">Начало загрузки:</span>
|
<span class="text-gray-400 font-semibold">Начало загрузки:</span>
|
||||||
@@ -1776,35 +2029,42 @@ function renderModalBody(data) {
|
|||||||
</details>` : '<div class="text-xs text-gray-500 mb-3">Файлов на диске нет</div>'}
|
</details>` : '<div class="text-xs text-gray-500 mb-3">Файлов на диске нет</div>'}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-800">
|
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-800">
|
||||||
|
${canManage(data) ? `
|
||||||
<button onclick="openEditMeta('${escHtml(data.url)}')"
|
<button onclick="openEditMeta('${escHtml(data.url)}')"
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||||
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
|
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
|
||||||
✏️ Редактировать название
|
✏️ Редактировать название
|
||||||
</button>
|
</button>` : ''}
|
||||||
${data.status !== 'downloading' ? `
|
${data.status !== 'downloading' && canManage(data) ? `
|
||||||
<button onclick="openRenameFolder('${escHtml(data.url)}', '${escHtml(data.folder_name || '')}')"
|
<button onclick="openRenameFolder('${escHtml(data.url)}', '${escHtml(data.folder_name || '')}')"
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||||
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
|
style="background:#1e293b;color:#94a3b8;border:1px solid #334155">
|
||||||
📁 Переименовать папку
|
📁 Переименовать папку
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
${data.status === 'done' ? `
|
${data.status === 'done' && canManage(data) ? `
|
||||||
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
|
<button id="modal-refresh-meta-btn" onclick="refreshMetaModal('${escHtml(data.url)}')"
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||||
style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
|
style="background:#1e1b4b;color:#a78bfa;border:1px solid #312e81">
|
||||||
🏷 Обновить метатеги
|
🏷 Обновить метатеги
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
${data.status !== 'downloading' && data.status !== 'queued' ? `
|
${data.status !== 'downloading' && data.status !== 'queued' && isAdmin() ? `
|
||||||
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"
|
<button onclick="forceRedownloadModal('${escHtml(data.url)}')"
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||||
style="background:#0c1a2e;color:#93c5fd;border:1px solid #1e3a5f">
|
style="background:#0c1a2e;color:#93c5fd;border:1px solid #1e3a5f">
|
||||||
↺ Скачать заново
|
↺ Скачать заново
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
${data.status !== 'downloading' ? `
|
${data.status !== 'downloading' && isAdmin() ? `
|
||||||
<button onclick="openSwitchSourceModal('${escHtml(data.url)}')"
|
<button onclick="openSwitchSourceModal('${escHtml(data.url)}')"
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||||
style="background:#0f2a1e;color:#6ee7b7;border:1px solid #1e3a2e">
|
style="background:#0f2a1e;color:#6ee7b7;border:1px solid #1e3a2e">
|
||||||
↔ Сменить источник
|
↔ Сменить источник
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
|
${isAdmin() ? `
|
||||||
|
<button onclick="closeModal(); deleteManga('${escHtml(data.url)}')"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ml-auto"
|
||||||
|
style="background:#450a0a;color:#fca5a5;border:1px solid #7f1d1d">
|
||||||
|
🗑 Удалить
|
||||||
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1837,11 +2097,11 @@ function renderModalBody(data) {
|
|||||||
${errors.map(c => renderErrorRow(c)).join('')}
|
${errors.map(c => renderErrorRow(c)).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 pt-3 border-t border-gray-800">
|
<div class="mt-4 pt-3 border-t border-gray-800">
|
||||||
<button onclick="retryErrors('${escHtml(data.url)}')"
|
${canManage(data) ? `<button onclick="retryErrors('${escHtml(data.url)}')"
|
||||||
class="text-xs px-4 py-2 rounded-lg font-semibold"
|
class="text-xs px-4 py-2 rounded-lg font-semibold"
|
||||||
style="background:#312e81;color:#a5b4fc">
|
style="background:#312e81;color:#a5b4fc">
|
||||||
🔄 Повторить все неудачные
|
🔄 Повторить все неудачные
|
||||||
</button>
|
</button>` : ''}
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1871,21 +2131,21 @@ function renderErrorRow(c) {
|
|||||||
<span class="text-lg flex-shrink-0">${isPartial ? '⚠️' : '❌'}</span>
|
<span class="text-lg flex-shrink-0">${isPartial ? '⚠️' : '❌'}</span>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="text-xs font-semibold ${isPartial ? 'text-orange-400' : 'text-red-400'}">${escHtml(c.error_label)}</span>
|
<span class="text-xs font-semibold ${isPartial ? 'text-orange-400' : 'text-red-400'}">${escHtml(c.error_label || '')}</span>
|
||||||
<span class="text-xs text-gray-500">Том ${c.volume || '?'} · Гл. ${c.number || '?'}</span>
|
<span class="text-xs text-gray-500">Том ${c.volume || '?'} · Гл. ${c.number || '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-300 mt-0.5 truncate">${escHtml(c.title || '')}</div>
|
<div class="text-xs text-gray-300 mt-0.5 truncate">${escHtml(c.title || '')}</div>
|
||||||
${isPartial ? `
|
${isPartial ? `
|
||||||
<div class="mt-1.5">
|
<div class="mt-1.5">
|
||||||
<div class="progress-bar" style="height:3px">
|
<div class="progress-bar" style="height:3px">
|
||||||
<div class="progress-fill progress-fill-${pct < 50 ? 'blue' : 'green'}" style="width:${pct}%"></div>
|
<div class="progress-fill progress-fill-green" style="width:${pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 mt-0.5">${pDone} из ${pTotal} стр. (${pct}%)</div>
|
<span class="text-xs text-gray-500">${pDone}/${pTotal} стр. (${pct}%)</span>
|
||||||
</div>` : ''}
|
</div>` : `
|
||||||
<a href="${escHtml(c.chapter_url || '')}" target="_blank"
|
<a href="${escHtml(c.chapter_url || '')}" target="_blank"
|
||||||
class="text-xs text-indigo-400 hover:underline mt-0.5 inline-block truncate max-w-full">
|
class="text-xs text-indigo-400 hover:underline mt-0.5 inline-block truncate max-w-full">
|
||||||
${escHtml(c.chapter_url || '')}
|
${escHtml(c.chapter_url || '')}
|
||||||
</a>
|
</a>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
811
src/api.py
811
src/api.py
File diff suppressed because it is too large
Load Diff
36
src/auth.py
Normal file
36
src/auth.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Утилиты авторизации: хеширование паролей, генерация токенов сессий.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
COOKIE_NAME = "manga_session"
|
||||||
|
COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 дней
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Хеширует пароль: pbkdf2:iterations:salt:key_hex"""
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
key = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256", password.encode("utf-8"), salt.encode("utf-8"), 260_000
|
||||||
|
)
|
||||||
|
return f"pbkdf2:260000:{salt}:{key.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str) -> bool:
|
||||||
|
"""Проверяет пароль против сохранённого хеша."""
|
||||||
|
try:
|
||||||
|
_, iterations, salt, stored_key = hashed.split(":")
|
||||||
|
key = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256", password.encode("utf-8"), salt.encode("utf-8"), int(iterations)
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(key.hex(), stored_key)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_token() -> str:
|
||||||
|
"""Генерирует безопасный случайный токен сессии (48 байт)."""
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
122
src/state.py
122
src/state.py
@@ -95,6 +95,24 @@ class StateDB:
|
|||||||
domain TEXT UNIQUE NOT NULL
|
domain TEXT UNIQUE NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT,
|
||||||
|
expires_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
# Migrate old DB: add missing columns
|
# Migrate old DB: add missing columns
|
||||||
migrations = [
|
migrations = [
|
||||||
("chapters", "pages_total", "INTEGER DEFAULT 0"),
|
("chapters", "pages_total", "INTEGER DEFAULT 0"),
|
||||||
@@ -108,6 +126,7 @@ class StateDB:
|
|||||||
("mangas", "finished_at", "TEXT"),
|
("mangas", "finished_at", "TEXT"),
|
||||||
("mangas", "folder_name", "TEXT"),
|
("mangas", "folder_name", "TEXT"),
|
||||||
("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
|
("mangas", "source_id", "INTEGER REFERENCES sources(id)"),
|
||||||
|
("mangas", "added_by", "INTEGER REFERENCES users(id)"),
|
||||||
]
|
]
|
||||||
for table, col, typedef in migrations:
|
for table, col, typedef in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -285,15 +304,16 @@ class StateDB:
|
|||||||
|
|
||||||
# ── Mangas ────────────────────────────────────
|
# ── Mangas ────────────────────────────────────
|
||||||
|
|
||||||
def add_manga(self, url: str, fmt: str = "cbz", source_id: Optional[int] = None) -> bool:
|
def add_manga(self, url: str, fmt: str = "cbz", source_id: Optional[int] = None,
|
||||||
|
added_by: Optional[int] = None) -> bool:
|
||||||
"""Добавляет мангу в очередь. Возвращает True если новая."""
|
"""Добавляет мангу в очередь. Возвращает True если новая."""
|
||||||
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
|
cur = self.conn.execute("SELECT id FROM mangas WHERE url=?", (url,))
|
||||||
if cur.fetchone():
|
if cur.fetchone():
|
||||||
return False
|
return False
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
INSERT INTO mangas (url, format, status, source_id, added_at, updated_at)
|
INSERT INTO mangas (url, format, status, source_id, added_by, added_at, updated_at)
|
||||||
VALUES (?, ?, 'queued', ?, ?, ?)
|
VALUES (?, ?, 'queued', ?, ?, ?, ?)
|
||||||
""", (url, fmt, source_id, _now(), _now()))
|
""", (url, fmt, source_id, added_by, _now(), _now()))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -385,12 +405,20 @@ class StateDB:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def get_manga(self, url: str) -> Optional[dict]:
|
def get_manga(self, url: str) -> Optional[dict]:
|
||||||
cur = self.conn.execute("SELECT * FROM mangas WHERE url=?", (url,))
|
cur = self.conn.execute("""
|
||||||
|
SELECT m.*, u.username AS added_by_username
|
||||||
|
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
|
||||||
|
WHERE m.url=?
|
||||||
|
""", (url,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def get_all_mangas(self) -> list[dict]:
|
def get_all_mangas(self) -> list[dict]:
|
||||||
cur = self.conn.execute("SELECT * FROM mangas ORDER BY added_at DESC")
|
cur = self.conn.execute("""
|
||||||
|
SELECT m.*, u.username AS added_by_username
|
||||||
|
FROM mangas m LEFT JOIN users u ON u.id = m.added_by
|
||||||
|
ORDER BY m.added_at DESC
|
||||||
|
""")
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def get_manga_format(self, url: str) -> str:
|
def get_manga_format(self, url: str) -> str:
|
||||||
@@ -506,6 +534,88 @@ class StateDB:
|
|||||||
""")
|
""")
|
||||||
return [dict(r) for r in cur.fetchall()]
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# ── Users ─────────────────────────────────────
|
||||||
|
|
||||||
|
def create_user(self, username: str, hashed_password: str, role: str = "user") -> dict:
|
||||||
|
"""Создаёт пользователя. Возвращает dict без поля password."""
|
||||||
|
self.conn.execute("""
|
||||||
|
INSERT INTO users (username, password, role, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (username, hashed_password, role, _now(), _now()))
|
||||||
|
self.conn.commit()
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT id, username, role, created_at FROM users WHERE username=?", (username,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: int) -> Optional[dict]:
|
||||||
|
row = self.conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> Optional[dict]:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM users WHERE username=?", (username,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_all_users(self) -> list[dict]:
|
||||||
|
"""Возвращает всех пользователей без поля password."""
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT id, username, role, created_at, updated_at FROM users ORDER BY id"
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def count_admins(self) -> int:
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE role='admin'"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
def update_user(self, user_id: int, **kwargs) -> None:
|
||||||
|
"""Обновляет поля пользователя. Разрешённые поля: username, password, role."""
|
||||||
|
allowed = {"username", "password", "role"}
|
||||||
|
updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
||||||
|
if not updates:
|
||||||
|
return
|
||||||
|
updates["updated_at"] = _now()
|
||||||
|
sets = ", ".join(f"{k}=?" for k in updates)
|
||||||
|
self.conn.execute(
|
||||||
|
f"UPDATE users SET {sets} WHERE id=?", [*updates.values(), user_id]
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_user(self, user_id: int) -> None:
|
||||||
|
"""Удаляет пользователя и все его сессии."""
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
|
||||||
|
self.conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ── Sessions ──────────────────────────────────
|
||||||
|
|
||||||
|
def create_session(self, token: str, user_id: int, expires_at: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)",
|
||||||
|
(token, user_id, _now(), expires_at)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_session(self, token: str) -> Optional[dict]:
|
||||||
|
"""Возвращает сессию если действующая (не истекла)."""
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE token=? AND expires_at > ?",
|
||||||
|
(token, _now())
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_session(self, token: str) -> None:
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE token=?", (token,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self) -> int:
|
||||||
|
"""Удаляет истёкшие сессии. Возвращает количество удалённых."""
|
||||||
|
cur = self.conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (_now(),))
|
||||||
|
self.conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user