feat: AI photo enhancement (Real-ESRGAN container) + restore original
- new photo-ai service: FastAPI + Real-ESRGAN x2plus on CPU, internal only - async job queue in server (POST enhance-ai / GET status), 5min timeout, sequential processing - keep original photo backup (entries.photo_original_path, uploads/.originals), restore-original endpoint - UI: AI button and restore-original button in enhance modal - db: photo_original_path column (init.sql, migration.sql, runtime ensure)
This commit is contained in:
@@ -27,3 +27,7 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
|
||||
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
||||
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
||||
WG_HANDSHAKE_TIMEOUT=60
|
||||
|
||||
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
||||
PHOTO_AI_URL=
|
||||
PHOTO_AI_MAX_PIXELS=4000000
|
||||
|
||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
photo_path VARCHAR(255),
|
||||
photo_original_path VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -199,3 +199,5 @@ $$ LANGUAGE plpgsql;
|
||||
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||
AI_PROMPT: ${AI_PROMPT:-}
|
||||
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||
NODE_ENV: production
|
||||
TZ: Europe/Moscow
|
||||
depends_on:
|
||||
@@ -113,7 +114,20 @@ services:
|
||||
- "8080:8080"
|
||||
|
||||
|
||||
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
|
||||
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
|
||||
photo-ai:
|
||||
build: ./photo-ai
|
||||
container_name: photo-ai
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||
MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||
TZ: Europe/Moscow
|
||||
volumes:
|
||||
- photo-ai-models:/models
|
||||
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
photo-ai-models:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
RUN pip install --no-cache-dir realesrgan==0.3.0 fastapi "uvicorn[standard]" python-multipart opencv-python-headless numpy
|
||||
|
||||
RUN python - <<'EOF'
|
||||
import basicsr, os
|
||||
p = os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py')
|
||||
s = open(p).read()
|
||||
s = s.replace('torchvision.transforms.functional_tensor', 'torchvision.transforms.functional')
|
||||
open(p, 'w').write(s)
|
||||
print('basicsr patch applied')
|
||||
EOF
|
||||
|
||||
COPY app.py .
|
||||
|
||||
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
|
||||
|
||||
VOLUME /models
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,73 @@
|
||||
import io
|
||||
import os
|
||||
import asyncio
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi.responses import Response
|
||||
import numpy as np
|
||||
import cv2
|
||||
import torch
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
from realesrgan import RealESRGANer
|
||||
|
||||
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth')
|
||||
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth'
|
||||
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000)))
|
||||
|
||||
lock = threading.Lock()
|
||||
upsampler = None
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def ensure_model():
|
||||
if not os.path.exists(MODEL_PATH):
|
||||
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
|
||||
tmp = MODEL_PATH + '.tmp'
|
||||
urllib.request.urlretrieve(MODEL_URL, tmp)
|
||||
os.replace(tmp, MODEL_PATH)
|
||||
|
||||
|
||||
def load_model():
|
||||
global upsampler
|
||||
ensure_model()
|
||||
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
|
||||
upsampler = RealESRGANer(
|
||||
scale=2,
|
||||
model_path=MODEL_PATH,
|
||||
model=model,
|
||||
tile=256,
|
||||
tile_pad=10,
|
||||
pre_pad=0,
|
||||
half=False,
|
||||
device='cpu',
|
||||
)
|
||||
|
||||
|
||||
@app.on_event('startup')
|
||||
async def startup():
|
||||
await asyncio.to_thread(load_model)
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
return {'ok': upsampler is not None}
|
||||
|
||||
|
||||
@app.post('/enhance')
|
||||
async def enhance(image: UploadFile = File(...), scale: int = Form(2)):
|
||||
data = await image.read()
|
||||
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return Response('bad image', status_code=400)
|
||||
if img.shape[0] * img.shape[1] > MAX_PIXELS:
|
||||
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
|
||||
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA)
|
||||
outscale = min(max(int(scale), 2), 4)
|
||||
with lock:
|
||||
out, _ = upsampler.enhance(img, outscale=outscale)
|
||||
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
|
||||
if not ok:
|
||||
return Response('encode failed', status_code=500)
|
||||
return Response(enc.tobytes(), media_type='image/jpeg')
|
||||
@@ -187,6 +187,8 @@
|
||||
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:6px">
|
||||
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
||||
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
|
||||
|
||||
@@ -424,6 +424,8 @@ let enhanceTimer = null;
|
||||
let enhanceSaving = false;
|
||||
let enhanceEngine = 'auto';
|
||||
let enhanceEngineLoaded = false;
|
||||
let photoAiEnabled = false;
|
||||
let photoAiPollTimer = null;
|
||||
|
||||
async function loadEnhanceEngine() {
|
||||
if (enhanceEngineLoaded) return;
|
||||
@@ -432,9 +434,79 @@ async function loadEnhanceEngine() {
|
||||
const r = await fetch(`${API}/api/public-settings`);
|
||||
const f = await r.json();
|
||||
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
|
||||
photoAiEnabled = f.photo_ai_enabled === 'true';
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function runPhotoAi() {
|
||||
if (!enhanceEntryId || enhanceSaving) return;
|
||||
const entryId = enhanceEntryId;
|
||||
if (!confirm('ИИ-восстановление фото (Real-ESRGAN). Может занять до минуты на CPU. Продолжить?')) return;
|
||||
enhanceSaving = true;
|
||||
const btn = document.getElementById('enhanceAiBtn');
|
||||
btn.disabled = true;
|
||||
const loading = document.getElementById('enhanceLoading');
|
||||
const canvas = document.getElementById('enhanceCanvas');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Ошибка запуска ИИ');
|
||||
}
|
||||
const { jobId } = await res.json();
|
||||
canvas.style.display = 'none';
|
||||
loading.style.display = '';
|
||||
loading.textContent = 'ИИ обрабатывает фото... (может занять до минуты)';
|
||||
btn.textContent = '⏳ ИИ...';
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
photoAiPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/${jobId}`, { headers: hdr() });
|
||||
if (!r.ok) throw new Error('Ошибка опроса статуса');
|
||||
const s = await r.json();
|
||||
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
|
||||
else if (s.status === 'error') { clearInterval(photoAiPollTimer); reject(new Error(s.error || 'Ошибка ИИ')); }
|
||||
} catch (e) { clearInterval(photoAiPollTimer); reject(e); }
|
||||
}, 2000);
|
||||
});
|
||||
showToast('Фото восстановлено ИИ');
|
||||
closeEnhance();
|
||||
refreshEntryCard(entryId);
|
||||
} catch (e) {
|
||||
loading.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
alert(e.message);
|
||||
} finally {
|
||||
enhanceSaving = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🤖 ИИ';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOriginal() {
|
||||
if (!enhanceEntryId || enhanceSaving) return;
|
||||
if (!confirm('Вернуть исходную фотографию? Текущая улучшенная версия будет удалена.')) return;
|
||||
enhanceSaving = true;
|
||||
const btn = document.getElementById('enhanceRestoreBtn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/restore-original`, { method: 'POST', headers: hdr() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Ошибка восстановления');
|
||||
}
|
||||
const savedId = enhanceEntryId;
|
||||
showToast('Оригинал возвращён');
|
||||
closeEnhance();
|
||||
refreshEntryCard(savedId);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
enhanceSaving = false;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEnhance(id) {
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
@@ -452,6 +524,10 @@ function openEnhance(id) {
|
||||
document.getElementById('enhanceSharpVal').textContent = '0%';
|
||||
const orig = document.getElementById('enhanceOrig');
|
||||
orig.src = `${API}${mp}`;
|
||||
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
|
||||
loadEnhanceEngine().then(() => {
|
||||
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none';
|
||||
});
|
||||
const canvas = document.getElementById('enhanceCanvas');
|
||||
canvas.style.display = 'none';
|
||||
const loading = document.getElementById('enhanceLoading');
|
||||
@@ -1052,6 +1128,8 @@ document.getElementById('editPhotoPreview').addEventListener('click', e => {
|
||||
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||||
document.getElementById('enhanceCancelBtn').addEventListener('click', closeEnhance);
|
||||
document.getElementById('enhanceAutoBtn').addEventListener('click', autoEnhance);
|
||||
document.getElementById('enhanceAiBtn').addEventListener('click', runPhotoAi);
|
||||
document.getElementById('enhanceRestoreBtn').addEventListener('click', restoreOriginal);
|
||||
document.getElementById('enhanceSaveBtn').addEventListener('click', saveEnhance);
|
||||
document.getElementById('enhanceModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEnhance(); });
|
||||
document.getElementById('enhanceBrightness').addEventListener('input', scheduleEnhancePreview);
|
||||
|
||||
@@ -257,12 +257,14 @@ app.use(helmet({
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(ipGuard);
|
||||
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
|
||||
const ORIGINALS_DIR = path.join(__dirname, 'uploads', '.originals');
|
||||
const THUMB_WIDTH = 480;
|
||||
let sharp = null;
|
||||
try { sharp = require('sharp'); } catch {}
|
||||
if (sharp) {
|
||||
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
|
||||
}
|
||||
try { fs.mkdirSync(ORIGINALS_DIR, { recursive: true }); } catch {}
|
||||
|
||||
function thumbFileFor(fp) {
|
||||
const base = path.basename(fp).replace(/\.[^.]+$/, '') + '.webp';
|
||||
@@ -666,6 +668,10 @@ async function ensureEntryPhotosTable() {
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
|
||||
}
|
||||
|
||||
async function ensurePhotoOriginalColumn() {
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`);
|
||||
}
|
||||
|
||||
async function ensureEntryAiColumns() {
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT`);
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT`);
|
||||
@@ -979,6 +985,7 @@ app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||
}
|
||||
const q = parseFloat(result.photo_capture_quality);
|
||||
result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
|
||||
result.photo_ai_enabled = PHOTO_AI_URL ? 'true' : 'false';
|
||||
return result;
|
||||
});
|
||||
res.json(out);
|
||||
@@ -1060,6 +1067,7 @@ const zlib = require('zlib');
|
||||
const tar = require('tar');
|
||||
const os = require('os');
|
||||
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
||||
const PHOTO_AI_URL = process.env.PHOTO_AI_URL || '';
|
||||
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||
let entryAutoChecker = null;
|
||||
@@ -3670,13 +3678,31 @@ function clampEnhanceParam(v, min, max, def) {
|
||||
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
|
||||
}
|
||||
|
||||
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath) {
|
||||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, entryId]);
|
||||
async function swapEntryPhotoFiles(req, entryId, oldPath, newPath, { keepOriginal = true } = {}) {
|
||||
let originalPath = null;
|
||||
const { rows: prevRows } = await pool.query('SELECT photo_original_path FROM entries WHERE id = $1', [entryId]);
|
||||
const prevOriginal = prevRows.length ? prevRows[0].photo_original_path : null;
|
||||
const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||
if (prevOriginal) {
|
||||
originalPath = prevOriginal;
|
||||
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
|
||||
} else if (keepOriginal && fs.existsSync(oldAbs)) {
|
||||
try {
|
||||
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
|
||||
const backupPath = path.join(ORIGINALS_DIR, backupName);
|
||||
fs.renameSync(oldAbs, backupPath);
|
||||
originalPath = `/uploads/.originals/${backupName}`;
|
||||
} catch (e) {
|
||||
console.error('photo original backup failed:', e);
|
||||
}
|
||||
} else {
|
||||
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
|
||||
}
|
||||
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = $2 WHERE id = $3', [newPath, originalPath, entryId]);
|
||||
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, entryId, oldPath]);
|
||||
safeUnlink(oldPath);
|
||||
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
|
||||
safeUnlink(oldThumb);
|
||||
await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath });
|
||||
await logAudit(req, 'entry.photo.enhance', { entry_id: entryId, old_path: oldPath, new_path: newPath, original_path: originalPath });
|
||||
invalidateEntries();
|
||||
}
|
||||
|
||||
@@ -3740,6 +3766,107 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
const photoAiJobs = new Map();
|
||||
let photoAiChain = Promise.resolve();
|
||||
|
||||
async function runPhotoAiJob(job) {
|
||||
job.status = 'processing';
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entryId]);
|
||||
if (!rows.length || !rows[0].photo_path) throw new Error('У записи нет фото');
|
||||
const photoPath = rows[0].photo_path;
|
||||
const srcAbs = path.join(UPLOADS_DIR, photoPath.replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||
const buf = fs.readFileSync(srcAbs);
|
||||
const fd = new FormData();
|
||||
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||
fd.append('scale', '2');
|
||||
const resp = await fetch(PHOTO_AI_URL.replace(/\/+$/, '') + '/enhance', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
signal: AbortSignal.timeout(300000),
|
||||
});
|
||||
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
|
||||
const out = Buffer.from(await resp.arrayBuffer());
|
||||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, newName), out);
|
||||
await swapEntryPhotoFiles(job.req, job.entryId, photoPath, `/uploads/${newName}`);
|
||||
job.photo_path = `/uploads/${newName}`;
|
||||
job.status = 'done';
|
||||
} catch (e) {
|
||||
job.status = 'error';
|
||||
job.error = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
|
||||
if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' });
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
|
||||
if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||||
if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' });
|
||||
const job = {
|
||||
id: crypto.randomBytes(8).toString('hex'),
|
||||
entryId: parseInt(req.params.id, 10),
|
||||
req: { user: req.user, ip: req.ip },
|
||||
status: 'pending',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
photoAiJobs.set(job.id, job);
|
||||
for (const [id, j] of photoAiJobs) {
|
||||
if (j.status === 'done' || j.status === 'error') {
|
||||
if (Date.now() - new Date(j.created_at).getTime() > 3600000) photoAiJobs.delete(id);
|
||||
}
|
||||
}
|
||||
photoAiChain = photoAiChain.then(() => runPhotoAiJob(job)).catch(() => {});
|
||||
res.json({ jobId: job.id });
|
||||
});
|
||||
|
||||
app.get('/api/entries/:id/photo/enhance-ai/:jobId', requireAuth, async (req, res) => {
|
||||
const job = photoAiJobs.get(req.params.jobId);
|
||||
if (!job || job.entryId !== parseInt(req.params.id, 10)) return res.status(404).json({ error: 'Задание не найдено' });
|
||||
const out = { status: job.status };
|
||||
if (job.status === 'error') out.error = job.error;
|
||||
if (job.status === 'done') out.photo_path = job.photo_path;
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
app.post('/api/entries/:id/photo/restore-original', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT photo_path, photo_original_path FROM entries WHERE id = $1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||||
const origPath = rows[0].photo_original_path;
|
||||
if (!origPath) return res.status(400).json({ error: 'Оригинал не сохранён' });
|
||||
const origName = path.basename(origPath);
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(origName)) return res.status(400).json({ error: 'Некорректный путь оригинала' });
|
||||
const origAbs = path.join(ORIGINALS_DIR, origName);
|
||||
if (!fs.existsSync(origAbs)) return res.status(400).json({ error: 'Файл оригинала не найден' });
|
||||
const newPath = `/uploads/${crypto.randomBytes(12).toString('hex')}${path.extname(origName) || '.jpg'}`;
|
||||
fs.renameSync(origAbs, path.join(UPLOADS_DIR, path.basename(newPath)));
|
||||
const oldPath = rows[0].photo_path;
|
||||
await pool.query('UPDATE entries SET photo_path = $1, photo_original_path = NULL WHERE id = $2', [newPath, req.params.id]);
|
||||
await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
|
||||
const oldAbs = path.join(UPLOADS_DIR, String(oldPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||
try { if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs); } catch {}
|
||||
const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
|
||||
safeUnlink(oldThumb);
|
||||
await logAudit(req, 'entry.photo.restore_original', { entry_id: req.params.id, restored_path: newPath });
|
||||
invalidateEntries();
|
||||
res.json({ ok: true, photo_path: newPath });
|
||||
} catch (e) {
|
||||
console.error('POST /api/entries/:id/photo/restore-original:', e);
|
||||
res.status(500).json({ error: 'Ошибка восстановления оригинала' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
@@ -4216,6 +4343,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
||||
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
||||
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
||||
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
||||
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
||||
|
||||
Reference in New Issue
Block a user