Authorization, favorites and players have been moved to the API server

This commit is contained in:
2025-07-07 18:08:42 +03:00
parent 6bf00451fa
commit 02dedbb8f7
12 changed files with 1735 additions and 5 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
.env.local
node_modules

1048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,12 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.5.0",
"nodemailer": "^6.9.9",
"uuid": "^9.0.0",
"cheerio": "^1.0.0-rc.12",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"vercel": "^39.3.0" "vercel": "^39.3.0"
}, },

28
src/db.js Normal file
View File

@@ -0,0 +1,28 @@
const { MongoClient } = require('mongodb');
const uri = process.env.MONGODB_URI || process.env.mongodb_uri || process.env.MONGO_URI;
if (!uri) {
throw new Error('MONGODB_URI environment variable is not set');
}
let client;
let clientPromise;
if (process.env.NODE_ENV === 'development') {
if (!global._mongoClientPromise) {
client = new MongoClient(uri);
global._mongoClientPromise = client.connect();
}
clientPromise = global._mongoClientPromise;
} else {
client = new MongoClient(uri);
clientPromise = client.connect();
}
async function getDb() {
const _client = await clientPromise;
return _client.db();
}
module.exports = { getDb };

View File

@@ -33,6 +33,7 @@ const swaggerOptions = {
description: process.env.NODE_ENV === 'production' ? 'Production server' : 'Development server' description: process.env.NODE_ENV === 'production' ? 'Production server' : 'Development server'
} }
], ],
security: [{ bearerAuth: [] }],
tags: [ tags: [
{ {
name: 'movies', name: 'movies',
@@ -45,9 +46,28 @@ const swaggerOptions = {
{ {
name: 'health', name: 'health',
description: 'Проверка работоспособности API' description: 'Проверка работоспособности API'
},
{
name: 'auth',
description: 'Операции авторизации'
},
{
name: 'favorites',
description: 'Операции с избранным'
},
{
name: 'players',
description: 'Плееры Alloha и Lumex'
} }
], ],
components: { components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
},
schemas: { schemas: {
Movie: { Movie: {
type: 'object', type: 'object',
@@ -226,11 +246,18 @@ const moviesRouter = require('./routes/movies');
const tvRouter = require('./routes/tv'); const tvRouter = require('./routes/tv');
const imagesRouter = require('./routes/images'); const imagesRouter = require('./routes/images');
const categoriesRouter = require('./routes/categories'); const categoriesRouter = require('./routes/categories');
const favoritesRouter = require('./routes/favorites');
const playersRouter = require('./routes/players');
require('./utils/cleanup');
const authRouter = require('./routes/auth');
app.use('/movies', moviesRouter); app.use('/movies', moviesRouter);
app.use('/tv', tvRouter); app.use('/tv', tvRouter);
app.use('/images', imagesRouter); app.use('/images', imagesRouter);
app.use('/categories', categoriesRouter); app.use('/categories', categoriesRouter);
app.use('/favorites', favoritesRouter);
app.use('/players', playersRouter);
app.use('/auth', authRouter);
/** /**
* @swagger * @swagger

35
src/middleware/auth.js Normal file
View File

@@ -0,0 +1,35 @@
const jwt = require('jsonwebtoken');
/**
* Express middleware to protect routes with JWT authentication.
* Attaches the decoded token to req.user on success.
*/
function authRequired(req, res, next) {
try {
const authHeader = req.headers['authorization'];
if (!authHeader) {
return res.status(401).json({ error: 'Authorization header missing' });
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({ error: 'Invalid Authorization header format' });
}
const token = parts[1];
const secret = process.env.JWT_SECRET || process.env.jwt_secret;
if (!secret) {
console.error('JWT_SECRET not set');
return res.status(500).json({ error: 'Server configuration error' });
}
const decoded = jwt.verify(token, secret);
req.user = decoded;
next();
} catch (err) {
console.error('JWT auth error:', err);
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = authRequired;

209
src/routes/auth.js Normal file
View File

@@ -0,0 +1,209 @@
const { Router } = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const { getDb } = require('../db');
const { sendVerificationEmail } = require('../utils/mailer');
/**
* @swagger
* tags:
* name: auth
* description: Операции авторизации
*/
const router = Router();
// Helper to generate 6-digit code
function generateCode() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
// Register
/**
* @swagger
* /auth/register:
* post:
* tags: [auth]
* summary: Регистрация пользователя
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* password:
* type: string
* name:
* type: string
* responses:
* 200:
* description: OK
*/
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const db = await getDb();
const existing = await db.collection('users').findOne({ email });
if (existing) return res.status(400).json({ error: 'Email already registered' });
const hashed = await bcrypt.hash(password, 12);
const code = generateCode();
const codeExpires = new Date(Date.now() + 10 * 60 * 1000);
await db.collection('users').insertOne({
email,
password: hashed,
name: name || email,
verified: false,
verificationCode: code,
verificationExpires: codeExpires,
isAdmin: false,
adminVerified: false,
createdAt: new Date()
});
await sendVerificationEmail(email, code);
res.json({ success: true, message: 'Registered. Check email for code.' });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Registration failed' });
}
});
// Verify email
/**
* @swagger
* /auth/verify:
* post:
* tags: [auth]
* summary: Подтверждение email
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* code:
* type: string
* responses:
* 200:
* description: OK
*/
router.post('/verify', async (req, res) => {
try {
const { email, code } = req.body;
const db = await getDb();
const user = await db.collection('users').findOne({ email });
if (!user) return res.status(400).json({ error: 'User not found' });
if (user.verified) return res.json({ success: true, message: 'Already verified' });
if (user.verificationCode !== code || user.verificationExpires < new Date()) {
return res.status(400).json({ error: 'Invalid or expired code' });
}
await db.collection('users').updateOne({ email }, { $set: { verified: true }, $unset: { verificationCode: '', verificationExpires: '' } });
res.json({ success: true });
} catch (err) {
console.error('Verify error:', err);
res.status(500).json({ error: 'Verification failed' });
}
});
// Resend code
/**
* @swagger
* /auth/resend-code:
* post:
* tags: [auth]
* summary: Повторная отправка кода подтверждения
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* responses:
* 200:
* description: OK
*/
router.post('/resend-code', async (req, res) => {
try {
const { email } = req.body;
const db = await getDb();
const user = await db.collection('users').findOne({ email });
if (!user) return res.status(400).json({ error: 'User not found' });
const code = generateCode();
const codeExpires = new Date(Date.now() + 10 * 60 * 1000);
await db.collection('users').updateOne({ email }, { $set: { verificationCode: code, verificationExpires: codeExpires } });
await sendVerificationEmail(email, code);
res.json({ success: true });
} catch (err) {
console.error('Resend code error:', err);
res.status(500).json({ error: 'Failed to resend code' });
}
});
// Login
/**
* @swagger
* /auth/login:
* post:
* tags: [auth]
* summary: Логин пользователя
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* password:
* type: string
*
* responses:
* 200:
* description: JWT token
*/
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const db = await getDb();
const user = await db.collection('users').findOne({ email });
if (!user) return res.status(400).json({ error: 'User not found' });
if (!user.verified) {
return res.status(403).json({ error: 'Account not activated. Please verify your email.' });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(400).json({ error: 'Invalid password' });
const payload = {
id: user._id.toString(),
email: user.email,
name: user.name || '',
verified: user.verified,
isAdmin: user.isAdmin,
adminVerified: user.adminVerified
};
const secret = process.env.JWT_SECRET || process.env.jwt_secret;
const token = jwt.sign(payload, secret, { expiresIn: '7d', jwtid: uuidv4() });
res.json({ token, user: { name: user.name || '', email: user.email } });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
module.exports = router;

152
src/routes/favorites.js Normal file
View File

@@ -0,0 +1,152 @@
const { Router } = require('express');
const { getDb } = require('../db');
const authRequired = require('../middleware/auth');
/**
* @swagger
* tags:
* name: favorites
* description: Операции с избранным
*/
const router = Router();
// Apply auth middleware to all favorites routes
router.use(authRequired);
/**
* @swagger
* /favorites:
* get:
* tags: [favorites]
* summary: Получить список избранного пользователя
* security:
* - bearerAuth: []
* responses:
* 200:
* description: OK
*/
router.get('/', async (req, res) => {
try {
const db = await getDb();
const userId = req.user.email || req.user.id;
const items = await db
.collection('favorites')
.find({ userId })
.toArray();
res.json(items);
} catch (err) {
console.error('Get favorites error:', err);
res.status(500).json({ error: 'Failed to fetch favorites' });
}
});
/**
* @swagger
* /favorites/check/{mediaId}:
* get:
* tags: [favorites]
* summary: Проверить, находится ли элемент в избранном
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: mediaId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get('/check/:mediaId', async (req, res) => {
try {
const { mediaId } = req.params;
const db = await getDb();
const exists = await db
.collection('favorites')
.findOne({ userId: req.user.email || req.user.id, mediaId });
res.json({ exists: !!exists });
} catch (err) {
console.error('Check favorite error:', err);
res.status(500).json({ error: 'Failed to check favorite' });
}
});
/**
* @swagger
* /favorites/{mediaId}:
* post:
* tags: [favorites]
* summary: Добавить элемент в избранное
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: mediaId
* required: true
* schema:
* type: string
* - in: query
* name: mediaType
* required: true
* schema:
* type: string
* enum: [movie, tv]
* responses:
* 200:
* description: OK
*/
router.post('/:mediaId', async (req, res) => {
try {
const { mediaId } = req.params;
const { mediaType } = req.query;
if (!mediaType) return res.status(400).json({ error: 'mediaType required' });
const db = await getDb();
await db.collection('favorites').insertOne({
userId: req.user.email || req.user.id,
mediaId,
mediaType,
createdAt: new Date()
});
res.json({ success: true });
} catch (err) {
if (err.code === 11000) {
return res.status(409).json({ error: 'Already in favorites' });
}
console.error('Add favorite error:', err);
res.status(500).json({ error: 'Failed to add favorite' });
}
});
/**
* @swagger
* /favorites/{mediaId}:
* delete:
* tags: [favorites]
* summary: Удалить элемент из избранного
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: mediaId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.delete('/:mediaId', async (req, res) => {
try {
const { mediaId } = req.params;
const db = await getDb();
await db.collection('favorites').deleteOne({ userId: req.user.email || req.user.id, mediaId });
res.json({ success: true });
} catch (err) {
console.error('Delete favorite error:', err);
res.status(500).json({ error: 'Failed to delete favorite' });
}
});
module.exports = router;

110
src/routes/players.js Normal file
View File

@@ -0,0 +1,110 @@
const { Router } = require('express');
const fetch = require('node-fetch');
const router = Router();
/**
* @swagger
* tags:
* name: players
* description: Плееры Alloha и Lumex
*/
/**
* @swagger
* /players/alloha:
* get:
* tags: [players]
* summary: Получить iframe от Alloha по IMDb ID или TMDB ID
* parameters:
* - in: query
* name: imdb_id
* schema:
* type: string
* description: IMDb ID (например tt0111161)
* - in: query
* name: tmdb_id
* schema:
* type: string
* description: TMDB ID (числовой)
* responses:
* 200:
* description: OK
*/
router.get('/alloha', async (req, res) => {
try {
const { imdb_id: imdbId, tmdb_id: tmdbId } = req.query;
if (!imdbId && !tmdbId) {
return res.status(400).json({ error: 'imdb_id or tmdb_id query param is required' });
}
const token = process.env.ALLOHA_TOKEN;
if (!token) {
return res.status(500).json({ error: 'Server misconfiguration: ALLOHA_TOKEN missing' });
}
const idParam = imdbId ? `imdb=${encodeURIComponent(imdbId)}` : `tmdb=${encodeURIComponent(tmdbId)}`;
const apiUrl = `https://api.alloha.tv/?token=${token}&${idParam}`;
const apiRes = await fetch(apiUrl);
if (!apiRes.ok) {
console.error('Alloha response error', apiRes.status);
return res.status(apiRes.status).json({ error: 'Failed to fetch from Alloha' });
}
const json = await apiRes.json();
if (json.status !== 'success' || !json.data?.iframe) {
return res.status(404).json({ error: 'Video not found' });
}
let iframeCode = json.data.iframe;
// If Alloha returns just a URL, wrap it in an iframe
if (!iframeCode.includes('<')) {
iframeCode = `<iframe src="${iframeCode}" allowfullscreen style="border:none;width:100%;height:100%"></iframe>`;
}
// If iframe markup already provided
const htmlDoc = `<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Alloha Player</title><style>html,body{margin:0;height:100%;}</style></head><body>${iframeCode}</body></html>`;
res.set('Content-Type', 'text/html');
return res.send(htmlDoc);
} catch (e) {
console.error('Alloha route error:', e);
res.status(500).json({ error: 'Internal Server Error' });
}
});
/**
* @swagger
* /players/lumex:
* get:
* tags: [players]
* summary: Получить URL плеера Lumex
* parameters:
* - in: query
* name: imdb_id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: OK
*/
router.get('/lumex', (req, res) => {
try {
const { imdb_id: imdbId } = req.query;
if (!imdbId) return res.status(400).json({ error: 'imdb_id required' });
const baseUrl = process.env.LUMEX_URL || process.env.NEXT_PUBLIC_LUMEX_URL;
if (!baseUrl) return res.status(500).json({ error: 'Server misconfiguration: LUMEX_URL missing' });
const url = `${baseUrl}?imdb_id=${encodeURIComponent(imdbId)}`;
const iframe = `<iframe src=\"${url}\" allowfullscreen loading=\"lazy\" style=\"border:none;width:100%;height:100%;\"></iframe>`;
const htmlDoc = `<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Lumex Player</title><style>html,body{margin:0;height:100%;}</style></head><body>${iframe}</body></html>`;
res.set('Content-Type', 'text/html');
res.send(htmlDoc);
} catch (e) {
console.error('Lumex route error:', e);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

54
src/utils/adblock.js Normal file
View File

@@ -0,0 +1,54 @@
const fs = require('fs');
const path = require('path');
const cheerio = require('cheerio');
// Lazy-loaded in-memory set of ad domains
let adDomains = null;
function loadAdDomains() {
if (adDomains) return adDomains;
adDomains = new Set();
try {
const listPath = path.join(__dirname, '..', '..', 'easylist.txt');
const data = fs.readFileSync(listPath, 'utf8');
const lines = data.split('\n');
const domainRegex = /^\|\|([^\/^]+)\^/; // matches ||domain.com^
for (const line of lines) {
const m = domainRegex.exec(line.trim());
if (m) {
adDomains.add(m[1].replace(/^www\./, ''));
}
}
console.log(`Adblock: loaded ${adDomains.size} domains from easylist.txt`);
} catch (e) {
console.error('Adblock: failed to load easylist.txt', e);
adDomains = new Set();
}
return adDomains;
}
function cleanHtml(html) {
const domains = loadAdDomains();
const $ = cheerio.load(html);
const removed = [];
$('script[src], iframe[src], img[src], link[href]').each((_, el) => {
const attr = $(el).attr('src') || $(el).attr('href');
if (!attr) return;
try {
const host = new URL(attr, 'https://dummy-base/').hostname.replace(/^www\./, '');
if (domains.has(host)) {
removed.push(host);
$(el).remove();
}
} catch (_) {
// ignore invalid URLs
}
});
if (removed.length) {
const unique = [...new Set(removed)];
console.log(`Adblock removed resources from: ${unique.join(', ')}`);
}
return $.html();
}
module.exports = { cleanHtml };

23
src/utils/cleanup.js Normal file
View File

@@ -0,0 +1,23 @@
const { getDb } = require('../db');
// Delete unverified users older than 7 days
async function deleteStaleUsers() {
try {
const db = await getDb();
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const result = await db.collection('users').deleteMany({ verified: false, createdAt: { $lt: weekAgo } });
if (result.deletedCount) {
console.log(`Cleanup: removed ${result.deletedCount} stale unverified users`);
}
} catch (e) {
console.error('Cleanup error:', e);
}
}
// run once at startup and then every 24h
(async () => {
await deleteStaleUsers();
setInterval(deleteStaleUsers, 24 * 60 * 60 * 1000);
})();
module.exports = { deleteStaleUsers };

45
src/utils/mailer.js Normal file
View File

@@ -0,0 +1,45 @@
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.GMAIL_USER || process.env.gmail_user,
pass: process.env.GMAIL_APP_PASSWORD || process.env.gmail_app_password
}
});
async function sendVerificationEmail(to, code) {
try {
await transporter.sendMail({
from: process.env.GMAIL_USER || process.env.gmail_user,
to,
subject: 'Подтверждение регистрации Neo Movies',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #2196f3;">Neo Movies</h1>
<p>Здравствуйте!</p>
<p>Для завершения регистрации введите этот код:</p>
<div style="
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
text-align: center;
font-size: 24px;
letter-spacing: 4px;
margin: 20px 0;
">
${code}
</div>
<p>Код действителен в течение 10 минут.</p>
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
</div>
`
});
return { success: true };
} catch (err) {
console.error('Error sending verification email:', err);
return { error: 'Failed to send email' };
}
}
module.exports = { sendVerificationEmail };