support for multiple users

This commit is contained in:
2025-12-06 01:21:19 +01:00
parent 4e2875579c
commit 2df7625657
20 changed files with 2313 additions and 65 deletions

91
src/api/anilist.ts Normal file
View File

@@ -0,0 +1,91 @@
import { FastifyInstance } from "fastify";
import { run } from "../shared/database";
async function anilist(fastify: FastifyInstance) {
fastify.get("/anilist", async (request, reply) => {
try {
const { code, state } = request.query as { code?: string; state?: string };
if (!code) return reply.status(400).send("No code");
if (!state) return reply.status(400).send("No user state");
const userId = Number(state);
if (!userId || isNaN(userId)) {
return reply.status(400).send("Invalid user id");
}
const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: process.env.ANILIST_CLIENT_ID,
client_secret: process.env.ANILIST_CLIENT_SECRET,
redirect_uri: "http://localhost:54322/api/anilist",
code
})
});
const tokenData = await tokenRes.json();
if (!tokenData.access_token) {
console.error("AniList token error:", tokenData);
return reply.status(500).send("Failed to get AniList token");
}
const userRes = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
const userData = await userRes.json();
const anilistUserId = userData?.data?.Viewer?.id;
if (!anilistUserId) {
console.error("AniList Viewer error:", userData);
return reply.status(500).send("Failed to fetch AniList user");
}
const expiresAt = new Date(
Date.now() + tokenData.expires_in * 1000
).toISOString();
await run(
`
INSERT INTO UserIntegration
(user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
token_type = excluded.token_type,
anilist_user_id = excluded.anilist_user_id,
expires_at = excluded.expires_at
`,
[
userId,
"AniList",
tokenData.access_token,
tokenData.refresh_token,
tokenData.token_type,
anilistUserId,
expiresAt
],
"userdata"
);
return reply.redirect("http://localhost:54322/?anilist=success");
} catch (e) {
console.error("AniList error:", e);
return reply.redirect("http://localhost:54322/?anilist=error");
}
});
}
export default anilist;

View File

@@ -0,0 +1,174 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import * as userService from './user.service';
import jwt from "jsonwebtoken";
interface UserIdBody { userId: number; }
interface UserIdParams { id: string; }
interface CreateUserBody { username: string; profilePictureUrl?: string; }
interface UpdateUserBody { username?: string; profilePictureUrl?: string | null; password?: string; }
interface ConnectBody { accessToken: string; anilistUserId: number; expiresIn: number; }
interface DBRunResult { changes: number; lastID: number; }
export async function login(req: FastifyRequest, reply: FastifyReply) {
const { userId } = req.body as { userId: number };
if (!userId || typeof userId !== "number" || userId <= 0) {
return reply.code(400).send({ error: "Invalid userId provided" });
}
if (!await userService.userExists(userId)) {
return reply.code(404).send({ error: "User not found in local database" });
}
const token = jwt.sign(
{ id: userId },
process.env.JWT_SECRET!,
{ expiresIn: "7d" }
);
return reply.code(200).send({
success: true,
token
});
}
export async function getAllUsers(req: FastifyRequest, reply: FastifyReply) {
try {
const users: any = await userService.getAllUsers();
return { users };
} catch (err) {
console.error("Get All Users Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to retrieve user list" });
}
}
export async function createUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { username, profilePictureUrl } = req.body as CreateUserBody;
if (!username) {
return reply.code(400).send({ error: "Missing required field: username" });
}
const result: any = await userService.createUser(username, profilePictureUrl);
return reply.code(201).send({ success: true, userId: result.lastID, username });
} catch (err) {
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
return reply.code(409).send({ error: "Username already exists." });
}
console.error("Create User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to create user" });
}
}
export async function getUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as UserIdParams;
const userId = parseInt(id, 10);
const user: any = await userService.getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
return { user };
} catch (err) {
console.error("Get User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to retrieve user" });
}
}
export async function updateUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as UserIdParams;
const userId = parseInt(id, 10);
const updates = req.body as UpdateUserBody;
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: "No update fields provided" });
}
const result: DBRunResult = await userService.updateUser(userId, updates);
if (result && result.changes > 0) {
return { success: true, message: "User updated successfully" };
} else {
return reply.code(404).send({ error: "User not found or nothing to update" });
}
} catch (err) {
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
return reply.code(409).send({ error: "Username already exists or is invalid." });
}
console.error("Update User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to update user" });
}
}
export async function deleteUser(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const result = await userService.deleteUser(userId);
if (result && result.changes > 0) {
return { success: true, message: "User deleted successfully" };
} else {
return reply.code(404).send({ error: "User not found" });
}
} catch (err) {
console.error("Delete User Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to delete user" });
}
}
export async function getIntegrationStatus(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const integration = await userService.getAniListIntegration(userId);
return reply.code(200).send(integration);
} catch (err) {
console.error("Get Integration Status Error:", (err as Error).message);
return reply.code(500).send({ error: "Failed to check integration status" });
}
}
export async function disconnectAniList(req: FastifyRequest, reply: FastifyReply) {
try {
const { id } = req.params as { id: string };
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const result = await userService.removeAniListIntegration(userId);
if (result.changes === 0) {
return reply.code(404).send({ error: "AniList integration not found" });
}
return reply.send({ success: true });
} catch (err) {
console.error("Disconnect AniList Error:", err);
return reply.code(500).send({ error: "Failed to disconnect AniList" });
}
}

View File

@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import * as controller from './user.controller';
async function userRoutes(fastify: FastifyInstance) {
fastify.post("/login", controller.login);
fastify.get('/users', controller.getAllUsers);
fastify.post('/users', controller.createUser);
fastify.get('/users/:id', controller.getUser);
fastify.put('/users/:id', controller.updateUser);
fastify.delete('/users/:id', controller.deleteUser);
fastify.get('/users/:id/integration', controller.getIntegrationStatus);
fastify.delete('/users/:id/integration', controller.disconnectAniList);
}
export default userRoutes;

View File

@@ -0,0 +1,133 @@
import {queryAll, queryOne, run} from '../../shared/database';
const USER_DB_NAME = 'userdata';
interface User { id: number; username: string; profile_picture_url: string | null; }
export async function userExists(id: number): Promise<boolean> {
const sql = 'SELECT 1 FROM User WHERE id = ?';
const row = queryOne(sql, [id], USER_DB_NAME);
return !!row;
}
export async function createUser(username: string, profilePictureUrl?: string): Promise<{ lastID: number }> {
const sql = `
INSERT INTO User (username, profile_picture_url)
VALUES (?, ?)
`;
const params = [username, profilePictureUrl || null];
const result = await run(sql, params, USER_DB_NAME);
return { lastID: result.lastID };
}
export async function updateUser(userId: number, updates: any): Promise<any> {
const fields: string[] = [];
const values: (string | number | null)[] = [];
if (updates.username !== undefined) {
fields.push('username = ?');
values.push(updates.username);
}
if (updates.profilePictureUrl !== undefined) {
fields.push('profile_picture_url = ?');
values.push(updates.profilePictureUrl);
}
if (updates.password !== undefined) {
}
if (fields.length === 0) {
return { changes: 0, lastID: userId };
}
const setClause = fields.join(', ');
const sql = `UPDATE User SET ${setClause} WHERE id = ?`;
values.push(userId);
return await run(sql, values, USER_DB_NAME);
}
export async function deleteUser(userId: number): Promise<any> {
await run(
`DELETE FROM ListEntry WHERE user_id = ?`,
[userId],
USER_DB_NAME
);
await run(
`DELETE FROM UserIntegration WHERE user_id = ?`,
[userId],
USER_DB_NAME
);
const result = await run(
`DELETE FROM User WHERE id = ?`,
[userId],
USER_DB_NAME
);
return result;
}
export async function getAllUsers(): Promise<User[]> {
const sql = 'SELECT id, username, profile_picture_url FROM User ORDER BY id';
const users = await queryAll(sql, [], USER_DB_NAME);
return users.map((user: any) => ({
id: user.id,
username: user.username,
profile_picture_url: user.profile_picture_url || null
})) as User[];
}
export async function getUserById(id: number): Promise<User | null> {
const sql = `
SELECT id, username, profile_picture_url, email
FROM User
WHERE id = ?
`;
const user = await queryOne(sql, [id], USER_DB_NAME);
if (!user) return null;
return {
id: user.id,
username: user.username,
profile_picture_url: user.profile_picture_url || null
};
}
export async function getAniListIntegration(userId: number) {
const sql = `
SELECT anilist_user_id, expires_at
FROM UserIntegration
WHERE user_id = ? AND platform = ?
`;
const row = await queryOne(sql, [userId, "AniList"], USER_DB_NAME);
if (!row) {
return { connected: false };
}
return {
connected: true,
anilistUserId: row.anilist_user_id,
expiresAt: row.expires_at
};
}
export async function removeAniListIntegration(userId: number) {
const sql = `
DELETE FROM UserIntegration
WHERE user_id = ? AND platform = ?
`;
return run(sql, [userId, "AniList"], USER_DB_NAME);
}

688
src/scripts/users.js Normal file
View File

@@ -0,0 +1,688 @@
const API_BASE = '/api';
let users = [];
let selectedFile = null;
let currentUserId = null;
const usersGrid = document.getElementById('usersGrid');
const btnAddUser = document.getElementById('btnAddUser');
const modalCreateUser = document.getElementById('modalCreateUser');
const closeCreateModal = document.getElementById('closeCreateModal');
const cancelCreate = document.getElementById('cancelCreate');
const createUserForm = document.getElementById('createUserForm');
const usernameInput = document.getElementById('username');
const avatarInput = document.getElementById('avatarInput');
const avatarPreview = document.getElementById('avatarPreview');
const avatarUploadArea = document.getElementById('avatarUploadArea');
const modalUserActions = document.getElementById('modalUserActions');
const closeActionsModal = document.getElementById('closeActionsModal');
const actionsModalTitle = document.getElementById('actionsModalTitle');
const modalEditUser = document.getElementById('modalEditUser');
const closeEditModal = document.getElementById('closeEditModal');
const cancelEdit = document.getElementById('cancelEdit');
const editUserForm = document.getElementById('editUserForm');
const editUsernameInput = document.getElementById('editUsername');
const editAvatarPreview = document.getElementById('editAvatarPreview');
const editAvatarUploadArea = document.getElementById('editAvatarUploadArea');
const editAvatarInput = document.getElementById('editAvatarInput');
const modalAniList = document.getElementById('modalAniList');
const closeAniListModal = document.getElementById('closeAniListModal');
const aniListContent = document.getElementById('aniListContent');
const toastContainer = document.getElementById('userToastContainer');
const params = new URLSearchParams(window.location.search);
const anilistStatus = params.get("anilist");
if (anilistStatus === "success") {
showUserToast("✅ AniList connected successfully!");
}
if (anilistStatus === "error") {
showUserToast("❌ Failed to connect AniList");
}
document.addEventListener('DOMContentLoaded', () => {
initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview');
initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview');
loadUsers();
attachEventListeners();
});
function authFetch(url, options = {}) {
const token = localStorage.getItem('token');
const headers = {
...(options.headers || {}),
};
if (token) headers.Authorization = `Bearer ${token}`;
return fetch(url, { ...options, headers });
}
function showUserToast(message, type = 'info') {
if (!toastContainer) return;
const toast = document.createElement('div');
toast.className = `wb-toast ${type}`;
toast.textContent = message;
toastContainer.prepend(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove());
}, 4000);
}
function attachEventListeners() {
if (btnAddUser) btnAddUser.addEventListener('click', openCreateModal);
if (closeCreateModal) closeCreateModal.addEventListener('click', closeModal);
if (cancelCreate) cancelCreate.addEventListener('click', closeModal);
if (closeAniListModal) closeAniListModal.addEventListener('click', closeModal);
if (closeActionsModal) closeActionsModal.addEventListener('click', closeModal);
if (closeEditModal) closeEditModal.addEventListener('click', closeModal);
if (cancelEdit) cancelEdit.addEventListener('click', closeModal);
if (createUserForm) createUserForm.addEventListener('submit', handleCreateUser);
if (editUserForm) editUserForm.addEventListener('submit', handleEditUser);
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) closeModal();
});
});
}
function initAvatarUpload(uploadAreaId, fileInputId, previewId) {
const uploadArea = document.getElementById(uploadAreaId);
const fileInput = document.getElementById(fileInputId);
const preview = document.getElementById(previewId);
if (!uploadArea || !fileInput) return;
uploadArea.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) handleFileSelect(file, previewId);
});
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
handleFileSelect(file, previewId);
}
});
}
function handleFileSelect(file, previewId) {
if (!file.type.startsWith('image/')) {
showUserToast('Please select an image file', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
showUserToast('Image size must be less than 5MB', 'error');
return;
}
selectedFile = file;
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.getElementById(previewId);
if (preview) preview.innerHTML = `<img src="${e.target.result}" alt="Avatar preview">`;
};
reader.readAsDataURL(file);
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = (err) => reject(err);
reader.readAsDataURL(file);
});
}
async function loadUsers() {
try {
const res = await fetch(`${API_BASE}/users`);
if (!res.ok) throw new Error('Failed to fetch users');
const data = await res.json();
users = data.users || [];
renderUsers();
} catch (err) {
console.error('Error loading users:', err);
showEmptyState();
}
}
async function createUser(username, profilePictureUrl) {
try {
const body = { username };
if (profilePictureUrl) body.profilePictureUrl = profilePictureUrl;
const res = await fetch(`${API_BASE}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error creating user');
}
return await res.json();
} catch (err) {
throw err;
}
}
async function updateUser(userId, username, profilePictureUrl) {
try {
const updates = { username };
if (profilePictureUrl !== undefined) updates.profilePictureUrl = profilePictureUrl;
const res = await fetch(`${API_BASE}/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error updating user');
}
return await res.json();
} catch (err) {
throw err;
}
}
async function deleteUser(userId) {
try {
const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error deleting user');
}
return await res.json();
} catch (err) {
throw err;
}
}
function renderUsers() {
if (!usersGrid) return;
if (users.length === 0) {
showEmptyState();
return;
}
usersGrid.innerHTML = '';
users.forEach(user => {
const userCard = createUserCard(user);
usersGrid.appendChild(userCard);
});
}
function createUserCard(user) {
const card = document.createElement('div');
card.className = 'user-card';
card.addEventListener('click', (e) => {
if (!e.target.closest('.user-config-btn')) {
loginUser(user.id);
}
});
const avatarContent = user.profile_picture_url
? `<img src="${user.profile_picture_url}" alt="${user.username}">`
: `<div class="user-avatar-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>`;
card.innerHTML = `
<div class="user-avatar">${avatarContent}</div>
<div class="user-info">
<div class="user-name">${user.username}</div>
<div class="user-status">
<span class="status-dot"></span>
<span>Available</span>
</div>
</div>
<button class="user-config-btn" title="Manage User" data-user-id="${user.id}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0-.33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1.51-1V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
`;
const configBtn = card.querySelector('.user-config-btn');
configBtn.addEventListener('click', (e) => {
e.stopPropagation();
openUserActionsModal(user.id);
});
return card;
}
function showEmptyState() {
if (!usersGrid) return;
usersGrid.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
<h2 class="empty-title">No Users Yet</h2>
<p class="empty-text">Create your first profile to get started</p>
</div>
`;
}
function openCreateModal() {
modalCreateUser.classList.add('active');
if (usernameInput) usernameInput.focus();
selectedFile = null;
if (avatarPreview) {
avatarPreview.innerHTML = `
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
`;
}
}
function closeModal() {
modalCreateUser.classList.remove('active');
modalAniList.classList.remove('active');
modalUserActions.classList.remove('active');
modalEditUser.classList.remove('active');
if (createUserForm) createUserForm.reset();
if (editUserForm) editUserForm.reset();
selectedFile = null;
const modalHeader = modalAniList.querySelector('.modal-header h2');
if (modalHeader) modalHeader.textContent = 'AniList Integration';
}
async function handleCreateUser(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
if (!username) {
showUserToast('Please enter a username', 'error');
return;
}
const submitBtn = createUserForm.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating...';
try {
let profilePictureUrl = null;
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
await createUser(username, profilePictureUrl);
closeModal();
await loadUsers();
showUserToast(`User ${username} created successfully!`, 'success');
} catch (err) {
console.error(err);
showUserToast(err.message || 'Error creating user', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create User';
}
}
function openUserActionsModal(userId) {
currentUserId = userId;
const user = users.find(u => u.id === userId);
if (!user) return;
modalAniList.classList.remove('active');
modalEditUser.classList.remove('active');
actionsModalTitle.textContent = `Manage ${user.username}`;
const content = document.getElementById('actionsModalContent');
if (!content) return;
content.innerHTML = `
<div class="manage-actions-modal">
<button class="btn-action edit" onclick="openEditModal(${user.id})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
Edit Profile
</button>
<button class="btn-action anilist" onclick="openAniListModal(${user.id})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
AniList Integration
</button>
<button class="btn-action delete" onclick="handleDeleteConfirmation(${user.id})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="18" y1="9" x2="12" y2="15"></line><line x1="12" y1="9" x2="18" y2="15"></line></svg>
Delete Profile
</button>
<button class="btn-action cancel" onclick="closeModal()">
Cancel
</button>
</div>
`;
modalUserActions.classList.add('active');
}
window.openEditModal = function(userId) {
currentUserId = userId;
modalUserActions.classList.remove('active');
const user = users.find(u => u.id === userId);
if (!user) return;
editUsernameInput.value = user.username || '';
selectedFile = null;
const avatarPlaceholder = `
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
`;
if (user.profile_picture_url) {
editAvatarPreview.innerHTML = `<img src="${user.profile_picture_url}" alt="Avatar preview">`;
} else {
editAvatarPreview.innerHTML = avatarPlaceholder;
}
if (editAvatarInput) editAvatarInput.value = '';
modalEditUser.classList.add('active');
};
async function handleEditUser(e) {
e.preventDefault();
const user = users.find(u => u.id === currentUserId);
if (!user) return;
const username = editUsernameInput.value.trim();
if (!username) {
showUserToast('Please enter a username', 'error');
return;
}
const submitBtn = editUserForm.querySelector('.btn-primary');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
try {
let profilePictureUrl;
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
await updateUser(currentUserId, username, profilePictureUrl);
closeModal();
await loadUsers();
showUserToast('Profile updated successfully!', 'success');
} catch (err) {
console.error(err);
showUserToast(err.message || 'Error updating user', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Save Changes';
}
}
window.handleDeleteConfirmation = function(userId) {
const user = users.find(u => u.id === userId);
if (!user) return;
closeModal();
showConfirmationModal(
'Confirm Deletion',
`Are you absolutely sure you want to delete profile ${user.username}? This action cannot be undone.`,
`handleConfirmedDeleteUser(${userId})`
);
};
window.handleConfirmedDeleteUser = async function(userId) {
closeModal();
showUserToast('Deleting user...', 'info');
try {
await deleteUser(userId);
await loadUsers();
showUserToast('User deleted successfully!', 'success');
} catch (err) {
console.error(err);
showUserToast('Error deleting user', 'error');
}
};
function showConfirmationModal(title, message, confirmAction) {
closeModal();
const modalHeader = modalAniList.querySelector('.modal-header h2');
if (modalHeader) modalHeader.textContent = title;
aniListContent.innerHTML = `
<div style="text-align: center; padding: 1rem;">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" style="opacity: 0.8; margin: 0 auto 1rem; display: block;">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<p style="color: var(--text-secondary); margin-bottom: 2rem; font-size: 1rem;">
${message}
</p>
<div style="display: flex; gap: 1rem;">
<button class="btn-secondary" style="flex: 1;" onclick="closeModal()">
Cancel
</button>
<button class="btn-disconnect" style="flex: 1; background: #ef4444; color: white;" onclick="window.${confirmAction}">
Confirm Delete
</button>
</div>
</div>
`;
modalAniList.classList.add('active');
}
function openAniListModal(userId) {
currentUserId = userId;
aniListContent.innerHTML = `<div style="text-align: center; padding: 2rem;">Loading integration status...</div>`;
modalUserActions.classList.remove('active');
modalEditUser.classList.remove('active');
getIntegrationStatus(userId).then(integration => {
aniListContent.innerHTML = `
<div class="anilist-status">
${integration.connected ? `
<div class="anilist-connected">
<div class="anilist-icon">
<img
src="https://anilist.co/img/icons/icon.svg"
alt="AniList"
style="width:40px; height:40px;"
>
</div>
<div class="anilist-info">
<h3>Connected to AniList</h3>
<p>User ID: ${integration.anilistUserId}</p>
<p style="font-size: 0.75rem;">Expires: ${new Date(integration.expiresAt).toLocaleDateString()}</p>
</div>
</div>
<button class="btn-disconnect" onclick="handleDisconnectAniList()">
Disconnect AniList
</button>
` : `
<div style="text-align: center; padding: 1rem;">
<h3 style="margin-bottom: 0.5rem;">Connect with AniList</h3>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
Sync your anime list by logging in with AniList.
</p>
<div style="display:flex; justify-content:center;">
<button class="btn-connect" onclick="redirectToAniListLogin()">
Login with AniList
</button>
</div>
<p style="font-size:0.85rem; margin-top:1rem; color:var(--text-secondary);">You will be redirected and then returned here.</p>
</div>
`}
</div>
`;
modalAniList.classList.add('active');
}).catch(err => {
console.error(err);
aniListContent.innerHTML = `<div style="text-align:center;padding:1rem;color:var(--text-secondary)">Error loading integration status.</div>`;
modalAniList.classList.add('active');
});
}
async function redirectToAniListLogin() {
try {
const res = await fetch(`/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: currentUserId })
});
if (!res.ok) throw new Error('Login failed before AniList redirect');
const data = await res.json();
localStorage.setItem('token', data.token);
token = data.token;
localStorage.setItem('anilist_link_user', currentUserId);
const clientId = 32898;
const redirectUri = encodeURIComponent(
window.location.origin + '/api/anilist'
);
const state = encodeURIComponent(currentUserId);
window.location.href =
`https://anilist.co/api/v2/oauth/authorize` +
`?client_id=${clientId}` +
`&response_type=code` +
`&redirect_uri=${redirectUri}` +
`&state=${state}`;
} catch (err) {
console.error(err);
showUserToast('Error starting AniList login', 'error');
}
}
async function getIntegrationStatus(userId) {
try {
const res = await fetch(`${API_BASE}/users/${userId}/integration`);
if (!res.ok) {
return { connected: false };
}
return await res.json();
} catch (err) {
console.error('getIntegrationStatus error', err);
return { connected: false };
}
}
async function disconnectAniList(userId) {
try {
const res = await authFetch(`${API_BASE}/users/${userId}/integration`, {
method: 'DELETE'
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Error disconnecting AniList');
}
return await res.json();
} catch (err) {
throw err;
}
}
window.handleDisconnectAniList = async function() {
if (!confirm('Are you sure you want to disconnect AniList?')) return;
try {
await disconnectAniList(currentUserId);
showUserToast('AniList disconnected successfully', 'success');
await openAniListModal(currentUserId);
} catch (err) {
console.error(err);
showUserToast('Error disconnecting AniList', 'error');
}
};
async function loginUser(userId) {
try {
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Login failed');
}
const data = await res.json();
localStorage.setItem('token', data.token);
window.location.href = '/anime';
} catch (err) {
console.error('Login error', err);
showUserToast('Login failed', 'error');
}
}
window.openAniListModal = openAniListModal;
window.openEditModal = window.openEditModal;
window.handleDeleteConfirmation = window.handleDeleteConfirmation;
window.handleConfirmedDeleteUser = window.handleConfirmedDeleteUser;
window.redirectToAniListLogin = redirectToAniListLogin;

View File

@@ -8,9 +8,70 @@ const databases = new Map();
const DEFAULT_PATHS = {
anilist: path.join(process.cwd(), 'src', 'metadata', 'anilist_anime.db'),
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
cache: path.join(os.homedir(), "WaifuBoards", "cache.db")
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db")
};
async function ensureUserDataDB(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new sqlite3.Database(
dbPath,
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE
);
return new Promise((resolve, reject) => {
const schema = `
-- Tabla 1: User
CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
profile_picture_url TEXT,
email TEXT UNIQUE,
password_hash TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Tabla 2: UserIntegration (✅ ACTUALIZADA)
CREATE TABLE IF NOT EXISTS UserIntegration (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
platform TEXT NOT NULL DEFAULT 'AniList',
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
token_type TEXT NOT NULL,
anilist_user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
-- Tabla 3: ListEntry
CREATE TABLE IF NOT EXISTS ListEntry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
anime_id INTEGER NOT NULL,
external_id INTEGER,
source TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0,
score INTEGER,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, anime_id),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
`;
db.exec(schema, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
async function ensureExtensionsTable(db) {
return new Promise((resolve, reject) => {
db.exec(`
@@ -128,6 +189,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
}
}
if (name === "userdata") {
ensureUserDataDB(finalPath)
.catch(err => console.error("Error creando userdata:", err));
}
const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
const db = new sqlite3.Database(finalPath, mode, (err) => {

View File

@@ -10,8 +10,6 @@ const BLOCK_LIST = [
"map", "cdn.ampproject.org", "googletagmanager"
];
const ALLOWED_SCRIPTS = [];
async function initHeadless() {
if (browser && browser.isConnected()) return;
@@ -30,7 +28,6 @@ async function initHeadless() {
"--mute-audio",
"--no-first-run",
"--no-zygote",
"--single-process",
"--disable-software-rasterizer",
"--disable-client-side-phishing-detection",
"--no-default-browser-check",
@@ -43,42 +40,103 @@ async function initHeadless() {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36"
});
} catch (error) {
console.error("Error al inicializar browser:", error);
console.error("Error initializing browser:", error);
throw error;
}
}
async function safeEvaluate(page, fn, ...args) {
const maxAttempts = 3;
for (let i = 0; i < maxAttempts; i++) {
try {
// Checkeo de estado de página antes de intentar evaluar
if (page.isClosed()) {
throw new Error("Page is closed before evaluation");
}
return await Promise.race([
page.evaluate(fn, ...args),
new Promise((_, reject) =>
// Timeout más corto podría ser más seguro, e.g., 20000ms
setTimeout(() => reject(new Error("Evaluate timeout")), 30000)
)
]);
} catch (error) {
const errorMsg = (error.message || "").toLowerCase();
const isLastAttempt = i === maxAttempts - 1;
// Priorizar errores irrecuperables de contexto/página cerrada
if (
page.isClosed() ||
errorMsg.includes("closed") ||
errorMsg.includes("target closed") ||
errorMsg.includes("session closed")
) {
console.error("Page context lost or closed, throwing fatal error.");
throw error; // Lanzar inmediatamente, no tiene sentido reintentar
}
// Reintentar solo por errores transitorios de ejecución
if (!isLastAttempt && (
errorMsg.includes("execution context was destroyed") ||
errorMsg.includes("cannot find context") ||
errorMsg.includes("timeout")
)) {
console.warn(`Evaluate attempt ${i + 1} failed, retrying...`, error.message);
await new Promise(r => setTimeout(r, 500 * (i + 1)));
continue;
}
throw error;
}
}
}
async function turboScroll(page) {
try {
await page.evaluate(() => {
if (page.isClosed()) {
console.warn("Page closed, skipping scroll");
return;
}
await safeEvaluate(page, () => {
return new Promise((resolve) => {
let last = 0;
let same = 0;
const timer = setInterval(() => {
const h = document.body.scrollHeight;
window.scrollTo(0, h);
if (h === last) {
same++;
if (same >= 5) {
clearInterval(timer);
resolve();
let lastHeight = 0;
let sameCount = 0;
const scrollInterval = setInterval(() => {
try {
const currentHeight = document.body.scrollHeight;
window.scrollTo(0, currentHeight);
if (currentHeight === lastHeight) {
sameCount++;
if (sameCount >= 5) {
clearInterval(scrollInterval);
resolve();
}
} else {
sameCount = 0;
lastHeight = currentHeight;
}
} else {
same = 0;
last = h;
} catch (err) {
clearInterval(scrollInterval);
resolve();
}
}, 20);
// Safety timeout
setTimeout(() => {
clearInterval(timer);
clearInterval(scrollInterval);
resolve();
}, 10000);
});
});
} catch (error) {
console.error("Error en turboScroll:", error.message);
// No lanzamos el error, continuamos
console.error("Error in turboScroll:", error.message);
}
}
@@ -90,7 +148,6 @@ async function scrape(url, handler, options = {}) {
scrollToBottom = false,
renderWaitTime = 0,
loadImages = true,
blockScripts = true,
retries = 3,
retryDelay = 1000
} = options;
@@ -101,7 +158,7 @@ async function scrape(url, handler, options = {}) {
let page = null;
try {
// Verificar que el browser esté activo
if (!browser || !browser.isConnected()) {
await initHeadless();
}
@@ -109,7 +166,10 @@ async function scrape(url, handler, options = {}) {
page = await context.newPage();
const requests = [];
// Listener para requests
page.on("close", () => {
console.warn("Page closed unexpectedly");
});
page.on("request", req => {
requests.push({
url: req.url(),
@@ -118,7 +178,6 @@ async function scrape(url, handler, options = {}) {
});
});
// Route para bloquear recursos
await page.route("**/*", (route) => {
const req = route.request();
const resUrl = req.url().toLowerCase();
@@ -128,9 +187,7 @@ async function scrape(url, handler, options = {}) {
type === "font" ||
type === "stylesheet" ||
type === "media" ||
type === "manifest" ||
type === "other" ||
(blockScripts && type === "script" && !ALLOWED_SCRIPTS.some(k => resUrl.includes(k)))
type === "other"
) {
return route.abort("blockedbyclient");
}
@@ -148,66 +205,71 @@ async function scrape(url, handler, options = {}) {
route.continue();
});
// Navegar a la URL
await page.goto(url, { waitUntil, timeout });
// Esperar selector si se especifica
if (!page.isClosed()) {
await page.waitForTimeout(500);
}
if (waitSelector) {
try {
await page.waitForSelector(waitSelector, { timeout: Math.min(timeout, 5000) });
await page.waitForSelector(waitSelector, {
timeout: Math.min(timeout, 5000)
});
} catch (e) {
console.warn(`Selector '${waitSelector}' no encontrado, continuando...`);
console.warn(`Selector '${waitSelector}' not found, continuing...`);
}
}
// Scroll si es necesario
if (scrollToBottom) {
await turboScroll(page);
}
// Tiempo de espera adicional para renderizado
if (renderWaitTime > 0) {
await page.waitForTimeout(renderWaitTime);
}
// Ejecutar el handler personalizado
const result = await handler(page);
if (page.isClosed()) {
throw new Error("Page closed before handler execution");
}
const result = await handler(page, safeEvaluate);
// Cerrar la página antes de retornar
await page.close();
return { result, requests };
} catch (error) {
lastError = error;
console.error(`[Intento ${attempt}/${retries}] Error durante el scraping de ${url}:`, error.message);
console.error(`[Attempt ${attempt}/${retries}] Error scraping ${url}:`, error.message);
// Cerrar página si está abierta
if (page && !page.isClosed()) {
try {
await page.close();
} catch (closeError) {
console.error("Error al cerrar página:", closeError.message);
console.error("Error closing page:", closeError.message);
}
}
// Si el browser está cerrado, limpiar referencias
if (error.message.includes("closed") || error.message.includes("Target closed")) {
console.log("Browser cerrado detectado, reiniciando...");
if (
error.message.includes("closed") ||
error.message.includes("Target closed") ||
error.message.includes("Session closed")
) {
console.log("Browser closure detected, reinitializing...");
await closeScraper();
}
// Si no es el último intento, esperar antes de reintentar
if (attempt < retries) {
const delay = retryDelay * attempt; // Backoff exponencial
console.log(`Reintentando en ${delay}ms...`);
const delay = retryDelay * attempt;
console.log(`Retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
// Si llegamos aquí, todos los intentos fallaron
console.error(`Todos los intentos fallaron para ${url}`);
console.error(`All attempts failed for ${url}`);
throw lastError || new Error("Scraping failed after all retries");
}
@@ -218,7 +280,7 @@ async function closeScraper() {
context = null;
}
} catch (error) {
console.error("Error cerrando context:", error.message);
console.error("Error closing context:", error.message);
}
try {
@@ -227,12 +289,13 @@ async function closeScraper() {
browser = null;
}
} catch (error) {
console.error("Error cerrando browser:", error.message);
console.error("Error closing browser:", error.message);
}
}
module.exports = {
initHeadless,
scrape,
closeScraper
closeScraper,
safeEvaluate
};

View File

@@ -3,9 +3,13 @@ import * as fs from 'fs';
import * as path from 'path';
async function viewsRoutes(fastify: FastifyInstance) {
fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'index.html'));
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html'));
reply.type('text/html').send(stream);
});
fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html'));
reply.type('text/html').send(stream);
});