528 lines
22 KiB
JavaScript
528 lines
22 KiB
JavaScript
/**
|
||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||
* All rights reserved.
|
||
*
|
||
* This software is proprietary and confidential.
|
||
* Unauthorized copying, modification, or distribution is prohibited.
|
||
*
|
||
* For licensing inquiries: info@hb3-accelerator.com
|
||
* Website: https://hb3-accelerator.com
|
||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||
*/
|
||
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const db = require('../db');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const multer = require('multer');
|
||
const vectorSearchClient = require('../services/vectorSearchClient');
|
||
|
||
const FIELDS_TO_EXCLUDE = ['image', 'tags'];
|
||
|
||
// Проверка и создание общей таблицы для всех админов
|
||
async function ensureAdminPagesTable(fields) {
|
||
fields = fields.filter(f => !FIELDS_TO_EXCLUDE.includes(f));
|
||
const tableName = `admin_pages_simple`;
|
||
|
||
// Получаем ключ шифрования
|
||
// Получаем ключ шифрования через унифицированную утилиту
|
||
const encryptionUtils = require('../utils/encryptionUtils');
|
||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||
|
||
// Проверяем, есть ли таблица
|
||
const existsRes = await db.getQuery()(
|
||
`SELECT to_regclass($1) as exists`, [tableName]
|
||
);
|
||
|
||
if (!existsRes.rows[0].exists) {
|
||
// Формируем SQL для создания таблицы с зашифрованными полями
|
||
let columns = [
|
||
'id SERIAL PRIMARY KEY',
|
||
'author_address_encrypted TEXT NOT NULL', // Зашифрованный адрес автора
|
||
'created_at TIMESTAMP DEFAULT NOW()',
|
||
'updated_at TIMESTAMP DEFAULT NOW()'
|
||
];
|
||
for (const field of fields) {
|
||
columns.push(`"${field}_encrypted" TEXT`);
|
||
}
|
||
const sql = `CREATE TABLE ${tableName} (${columns.join(', ')})`;
|
||
await db.getQuery()(sql);
|
||
} else {
|
||
// Проверяем, есть ли все нужные столбцы, и добавляем недостающие
|
||
const colRes = await db.getQuery()(
|
||
`SELECT column_name FROM information_schema.columns WHERE table_name = $1`, [tableName]
|
||
);
|
||
const existingCols = colRes.rows.map(r => r.column_name);
|
||
|
||
// Добавляем поле author_address_encrypted если его нет
|
||
if (!existingCols.includes('author_address_encrypted')) {
|
||
await db.getQuery()(
|
||
`ALTER TABLE ${tableName} ADD COLUMN author_address_encrypted TEXT`
|
||
);
|
||
}
|
||
|
||
for (const field of fields) {
|
||
const encryptedField = `${field}_encrypted`;
|
||
if (!existingCols.includes(encryptedField)) {
|
||
await db.getQuery()(
|
||
`ALTER TABLE ${tableName} ADD COLUMN "${encryptedField}" TEXT`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return { tableName, encryptionKey };
|
||
}
|
||
|
||
// Конфигурация загрузки файлов для юридических документов
|
||
// Храним файлы там, откуда их раздаёт express.static('/uploads', path.join(__dirname, 'uploads'))
|
||
const uploadsRoot = path.join(__dirname, '..', 'uploads');
|
||
const legalDir = path.join(uploadsRoot, 'legal');
|
||
if (!fs.existsSync(legalDir)) {
|
||
try { fs.mkdirSync(legalDir, { recursive: true }); } catch (e) {}
|
||
}
|
||
const storage = multer.diskStorage({
|
||
destination: function (req, file, cb) {
|
||
cb(null, legalDir);
|
||
},
|
||
filename: function (req, file, cb) {
|
||
const safeName = Date.now() + '-' + (file.originalname || 'file');
|
||
cb(null, safeName);
|
||
}
|
||
});
|
||
const upload = multer({ storage });
|
||
|
||
function stripHtml(html) {
|
||
if (!html) return '';
|
||
return String(html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
// Создать страницу (только для админа)
|
||
router.post('/', upload.single('file'), async (req, res) => {
|
||
if (!req.session || !req.session.authenticated) {
|
||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||
}
|
||
if (!req.session.address) {
|
||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||
}
|
||
|
||
// Проверяем роль админа через токены в кошельке
|
||
const authService = require('../services/auth-service');
|
||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||
if (!userAccessLevel.hasAccess) {
|
||
return res.status(403).json({ error: 'Only admin can create pages' });
|
||
}
|
||
|
||
const authorAddress = req.session.address;
|
||
const tableName = `admin_pages_simple`;
|
||
|
||
// Собираем данные страницы
|
||
const bodyRaw = req.body || {};
|
||
const pageData = {
|
||
title: bodyRaw.title || '',
|
||
summary: bodyRaw.summary || '',
|
||
content: bodyRaw.content || '',
|
||
seo: bodyRaw.seo ? (typeof bodyRaw.seo === 'string' ? bodyRaw.seo : JSON.stringify(bodyRaw.seo)) : null,
|
||
status: bodyRaw.status || 'draft',
|
||
settings: bodyRaw.settings ? (typeof bodyRaw.settings === 'string' ? bodyRaw.settings : JSON.stringify(bodyRaw.settings)) : null,
|
||
visibility: bodyRaw.visibility || 'public',
|
||
required_permission: bodyRaw.required_permission || null,
|
||
format: bodyRaw.format || (req.file ? (req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf') : 'html'),
|
||
mime_type: req.file ? (req.file.mimetype || null) : (bodyRaw.mime_type || (bodyRaw.format === 'html' ? 'text/html' : null)),
|
||
storage_type: req.file ? 'file' : (bodyRaw.storage_type || 'embedded'),
|
||
file_path: req.file ? path.join('/uploads', 'legal', path.basename(req.file.path)) : (bodyRaw.file_path || null),
|
||
size_bytes: req.file ? req.file.size : (bodyRaw.size_bytes || null),
|
||
checksum: bodyRaw.checksum || null
|
||
};
|
||
|
||
// Формируем SQL для вставки данных (только непустые поля)
|
||
const dataEntries = Object.entries(pageData).filter(([, v]) => v !== undefined);
|
||
const colNames = ['author_address', ...dataEntries.map(([k]) => k)].join(', ');
|
||
const values = [authorAddress, ...dataEntries.map(([, v]) => v)];
|
||
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
||
|
||
const sql = `INSERT INTO ${tableName} (${colNames}) VALUES (${placeholders}) RETURNING *`;
|
||
const { rows } = await db.getQuery()(sql, values);
|
||
const created = rows[0];
|
||
|
||
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||
// Автоматическая индексация при создании отключена
|
||
|
||
res.json(created);
|
||
});
|
||
|
||
// Получить все страницы админов
|
||
router.get('/', async (req, res) => {
|
||
if (!req.session || !req.session.authenticated) {
|
||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||
}
|
||
if (!req.session.address) {
|
||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||
}
|
||
|
||
// Проверяем роль админа через токены в кошельке
|
||
const authService = require('../services/auth-service');
|
||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||
if (!userAccessLevel.hasAccess) {
|
||
return res.status(403).json({ error: 'Only admin can view pages' });
|
||
}
|
||
|
||
const tableName = `admin_pages_simple`;
|
||
|
||
// Получаем ключ шифрования
|
||
// Получаем ключ шифрования через унифицированную утилиту
|
||
const encryptionUtils = require('../utils/encryptionUtils');
|
||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||
|
||
// Проверяем, есть ли таблица
|
||
const existsRes = await db.getQuery()(
|
||
`SELECT to_regclass($1) as exists`, [tableName]
|
||
);
|
||
if (!existsRes.rows[0].exists) return res.json([]);
|
||
|
||
// Получаем все страницы всех админов
|
||
const { rows } = await db.getQuery()(`
|
||
SELECT * FROM ${tableName}
|
||
ORDER BY created_at DESC
|
||
`);
|
||
|
||
res.json(rows);
|
||
});
|
||
|
||
// Получить одну страницу по id (только для админа)
|
||
router.get('/:id', async (req, res) => {
|
||
if (!req.session || !req.session.authenticated) {
|
||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||
}
|
||
if (!req.session.address) {
|
||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||
}
|
||
|
||
// Проверяем роль админа через токены в кошельке
|
||
const authService = require('../services/auth-service');
|
||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||
if (!userAccessLevel.hasAccess) {
|
||
return res.status(403).json({ error: 'Only admin can view pages' });
|
||
}
|
||
|
||
const tableName = `admin_pages_simple`;
|
||
const existsRes = await db.getQuery()(
|
||
`SELECT to_regclass($1) as exists`, [tableName]
|
||
);
|
||
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
|
||
|
||
const { rows } = await db.getQuery()(
|
||
`SELECT * FROM ${tableName} WHERE id = $1`,
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||
res.json(rows[0]);
|
||
});
|
||
|
||
// Ручная переиндексация документа в vector-search (только для админа)
|
||
router.post('/:id/reindex', async (req, res) => {
|
||
if (!req.session || !req.session.authenticated) {
|
||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||
}
|
||
if (!req.session.address) {
|
||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||
}
|
||
|
||
// Проверяем роль админа через токены в кошельке
|
||
const authService = require('../services/auth-service');
|
||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||
if (!userAccessLevel.hasAccess) {
|
||
return res.status(403).json({ error: 'Only admin can reindex pages' });
|
||
}
|
||
|
||
const tableName = `admin_pages_simple`;
|
||
const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] );
|
||
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
|
||
|
||
const { rows } = await db.getQuery()( `SELECT * FROM ${tableName} WHERE id = $1`, [req.params.id] );
|
||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||
const page = rows[0];
|
||
|
||
if (page.format !== 'html') {
|
||
return res.status(422).json({ error: 'Индексация поддерживается только для HTML' });
|
||
}
|
||
|
||
const text = stripHtml(page.content || '');
|
||
if (!text) {
|
||
return res.status(422).json({ error: 'Пустое содержимое для индексации' });
|
||
}
|
||
|
||
try {
|
||
const url = page.visibility === 'public' && page.status === 'published'
|
||
? `/public/page/${page.id}`
|
||
: `/content/page/${page.id}`;
|
||
|
||
// Удаляем старые чанки документа перед реиндексацией
|
||
// Удаляем возможные чанки (doc_id_chunk_0, doc_id_chunk_1, ...) и сам документ (doc_id)
|
||
const oldRowIds = [String(page.id)]; // Удаляем основной документ
|
||
// Также удаляем возможные чанки (до 100 чанков на документ)
|
||
for (let i = 0; i < 100; i++) {
|
||
oldRowIds.push(`${page.id}_chunk_${i}`);
|
||
}
|
||
|
||
try {
|
||
await vectorSearchClient.remove('legal_docs', oldRowIds);
|
||
console.log(`[pages] Удалены старые чанки документа ${page.id} перед реиндексацией`);
|
||
} catch (removeError) {
|
||
console.warn(`[pages] Ошибка удаления старых чанков (продолжаем индексацию):`, removeError.message);
|
||
// Продолжаем индексацию даже если удаление не удалось
|
||
}
|
||
|
||
// Используем Semantic Chunking для разбивки документа
|
||
const semanticChunkingService = require('../services/semanticChunkingService');
|
||
const docLength = text.length;
|
||
const useLLM = docLength <= 8000;
|
||
|
||
const chunks = await semanticChunkingService.chunkDocument(text, {
|
||
maxChunkSize: 1500,
|
||
overlap: 200,
|
||
useLLM
|
||
});
|
||
|
||
// Индексируем каждый чанк отдельно
|
||
const rowsToUpsert = chunks.map((chunk, index) => ({
|
||
row_id: `${page.id}_chunk_${index}`,
|
||
text: chunk.text,
|
||
metadata: {
|
||
doc_id: page.id,
|
||
chunk_index: index,
|
||
section: chunk.metadata?.section || 'Документ',
|
||
parent_doc_id: page.id,
|
||
title: page.title,
|
||
url: `${url}#chunk_${index}`,
|
||
visibility: page.visibility,
|
||
required_permission: page.required_permission,
|
||
format: page.format,
|
||
updated_at: page.updated_at || null,
|
||
isComplete: chunk.metadata?.isComplete || false
|
||
}
|
||
}));
|
||
|
||
if (chunks.length > 1) {
|
||
console.log(`[pages] Документ ${page.id} разбит на ${chunks.length} чанков при реиндексации`);
|
||
await vectorSearchClient.upsert('legal_docs', rowsToUpsert);
|
||
} else {
|
||
// Если чанк один, индексируем как раньше
|
||
await vectorSearchClient.upsert('legal_docs', [{
|
||
row_id: page.id,
|
||
text,
|
||
metadata: {
|
||
doc_id: page.id,
|
||
title: page.title,
|
||
url,
|
||
visibility: page.visibility,
|
||
required_permission: page.required_permission,
|
||
format: page.format,
|
||
updated_at: page.updated_at || null
|
||
}
|
||
}]);
|
||
}
|
||
|
||
res.json({ success: true, chunksCount: chunks.length });
|
||
} catch (e) {
|
||
console.error('[pages] manual reindex error:', e.message);
|
||
res.status(500).json({ error: 'Ошибка индексации' });
|
||
}
|
||
});
|
||
|
||
// Редактировать страницу по id
|
||
router.patch('/:id', upload.single('file'), async (req, res) => {
|
||
if (!req.session || !req.session.authenticated) {
|
||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||
}
|
||
if (!req.session.address) {
|
||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||
}
|
||
|
||
// Проверяем роль админа через токены в кошельке
|
||
const authService = require('../services/auth-service');
|
||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||
if (!userAccessLevel.hasAccess) {
|
||
return res.status(403).json({ error: 'Only admin can edit pages' });
|
||
}
|
||
|
||
const tableName = `admin_pages_simple`;
|
||
const existsRes = await db.getQuery()(
|
||
`SELECT to_regclass($1) as exists`, [tableName]
|
||
);
|
||
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
|
||
|
||
const incoming = req.body || {};
|
||
const updateData = {};
|
||
for (const [k, v] of Object.entries(incoming)) {
|
||
if (FIELDS_TO_EXCLUDE.includes(k)) continue;
|
||
updateData[k] = typeof v === 'object' ? JSON.stringify(v) : v;
|
||
}
|
||
if (req.file) {
|
||
updateData.format = req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf';
|
||
updateData.mime_type = req.file.mimetype || null;
|
||
updateData.storage_type = 'file';
|
||
updateData.file_path = path.join('/uploads', 'legal', path.basename(req.file.path));
|
||
updateData.size_bytes = req.file.size;
|
||
}
|
||
const entries = Object.entries(updateData);
|
||
if (!entries.length) return res.status(400).json({ error: 'No fields to update' });
|
||
const setClause = entries.map(([f], i) => `"${f}" = $${i + 1}`).join(', ');
|
||
const values = entries.map(([, v]) => v);
|
||
values.push(req.params.id);
|
||
|
||
const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $${entries.length + 1} RETURNING *`;
|
||
const { rows } = await db.getQuery()(sql, values);
|
||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||
const updated = rows[0];
|
||
|
||
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||
// Автоматическая индексация при обновлении отключена
|
||
|
||
res.json(updated);
|
||
});
|
||
|
||
// Удалить страницу по id
|
||
router.delete('/:id', async (req, res) => {
|
||
if (!req.session || !req.session.authenticated) {
|
||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||
}
|
||
if (!req.session.address) {
|
||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||
}
|
||
|
||
// Проверяем роль админа через токены в кошельке
|
||
const authService = require('../services/auth-service');
|
||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||
if (!userAccessLevel.hasAccess) {
|
||
return res.status(403).json({ error: 'Only admin can delete pages' });
|
||
}
|
||
|
||
const tableName = `admin_pages_simple`;
|
||
const existsRes = await db.getQuery()(
|
||
`SELECT to_regclass($1) as exists`, [tableName]
|
||
);
|
||
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
|
||
|
||
const { rows } = await db.getQuery()(
|
||
`DELETE FROM ${tableName} WHERE id = $1 RETURNING *`,
|
||
[req.params.id]
|
||
);
|
||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||
const deleted = rows[0];
|
||
try {
|
||
if (deleted && deleted.format === 'html') {
|
||
// Удаляем документ и все его чанки
|
||
const rowIdsToDelete = [String(deleted.id)]; // Основной документ
|
||
// Удаляем возможные чанки (до 100 чанков на документ)
|
||
for (let i = 0; i < 100; i++) {
|
||
rowIdsToDelete.push(`${deleted.id}_chunk_${i}`);
|
||
}
|
||
await vectorSearchClient.remove('legal_docs', rowIdsToDelete);
|
||
console.log(`[pages] Удалены документ ${deleted.id} и все его чанки из векторного поиска`);
|
||
}
|
||
} catch (e) {
|
||
console.error('[pages] vector remove error:', e.message);
|
||
}
|
||
res.json(deleted);
|
||
});
|
||
|
||
// Публичные маршруты для просмотра страниц (доступны всем пользователям)
|
||
// Получить все опубликованные страницы
|
||
router.get('/public/all', async (req, res) => {
|
||
try {
|
||
const tableName = `admin_pages_simple`;
|
||
|
||
// Проверяем, есть ли таблица
|
||
const existsRes = await db.getQuery()(
|
||
`SELECT to_regclass($1) as exists`, [tableName]
|
||
);
|
||
|
||
if (!existsRes.rows[0].exists) {
|
||
return res.json([]);
|
||
}
|
||
|
||
// Получаем все опубликованные страницы всех админов
|
||
const { rows } = await db.getQuery()(`
|
||
SELECT * FROM ${tableName}
|
||
WHERE status = 'published'
|
||
ORDER BY created_at DESC
|
||
`);
|
||
|
||
res.json(rows);
|
||
} catch (error) {
|
||
console.error('Ошибка получения публичных страниц:', error);
|
||
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// Внутренние документы (доступны аутентифицированным пользователям с доступом)
|
||
router.get('/internal/all', async (req, res) => {
|
||
try {
|
||
if (!req.session || !req.session.authenticated) {
|
||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||
}
|
||
if (!req.session.address) {
|
||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||
}
|
||
|
||
const tableName = `admin_pages_simple`;
|
||
const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] );
|
||
if (!existsRes.rows[0].exists) {
|
||
return res.json([]);
|
||
}
|
||
|
||
const authService = require('../services/auth-service');
|
||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||
if (!userAccessLevel.hasAccess) {
|
||
return res.status(403).json({ error: 'Only internal users can view pages' });
|
||
}
|
||
|
||
// READONLY/EDITOR видят внутренние опубликованные; EDITOR может видеть и черновики
|
||
const role = userAccessLevel.level; // 'readonly' | 'editor'
|
||
let sql;
|
||
if (role === 'editor') {
|
||
sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' ORDER BY created_at DESC`;
|
||
} else {
|
||
sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' AND status = 'published' ORDER BY created_at DESC`;
|
||
}
|
||
const { rows } = await db.getQuery()(sql);
|
||
res.json(rows);
|
||
} catch (error) {
|
||
console.error('Ошибка получения внутренних страниц:', error);
|
||
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// Получить одну опубликованную страницу по id
|
||
router.get('/public/:id', async (req, res) => {
|
||
try {
|
||
const tableName = `admin_pages_simple`;
|
||
|
||
// Проверяем, есть ли таблица
|
||
const existsRes = await db.getQuery()(
|
||
`SELECT to_regclass($1) as exists`, [tableName]
|
||
);
|
||
|
||
if (!existsRes.rows[0].exists) {
|
||
return res.status(404).json({ error: 'Страница не найдена или не опубликована' });
|
||
}
|
||
|
||
// Ищем страницу среди всех админов
|
||
const { rows } = await db.getQuery()(`
|
||
SELECT * FROM ${tableName}
|
||
WHERE id = $1 AND status = 'published'
|
||
`, [req.params.id]);
|
||
|
||
if (rows.length > 0) {
|
||
return res.json(rows[0]);
|
||
}
|
||
|
||
res.status(404).json({ error: 'Страница не найдена или не опубликована' });
|
||
} catch (error) {
|
||
console.error('Ошибка получения публичной страницы:', error);
|
||
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|