diff --git a/.env.example b/.env.example index 21f156e..42ec4e3 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,6 @@ AI_MODEL=qwen2.5-1.5b-instruct-q4_k_m.gguf # Репозиторий Hugging Face, откуда скачивается модель AI_MODEL_REPO=Qwen/Qwen2.5-1.5B-Instruct-GGUF # Промпт по умолчанию для автоисправления текстов (переопределяет встроенный, если в настройках не сохранён свой) -AI_PROMPT=Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений. \ No newline at end of file +AI_PROMPT=Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений. +# Таймаут запроса к AI (мс), по умолчанию 120000 (2 минуты) +AI_REQUEST_TIMEOUT_MS=120000 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 61a80c6..c1add30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500} 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} NODE_ENV: production TZ: Europe/Moscow depends_on: diff --git a/server.js b/server.js index 4e222ca..4ef970b 100644 --- a/server.js +++ b/server.js @@ -810,6 +810,17 @@ function optTime(v) { return v; } +function optDate(v) { + if (v === null || v === undefined) return null; + if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date'); + return v; +} + +function optBool(v) { + if (v === null || v === undefined) return null; + return !!v; +} + function reqToken(v) { if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token'); return v; @@ -898,17 +909,52 @@ function normalizeRestoreData(data) { user_id: reqInt(x.user_id), branch_id: reqInt(x.branch_id), })); + const entry_photos = (data.entry_photos || []).map(x => ({ + id: reqInt(x.id), + entry_id: reqInt(x.entry_id), + photo_path: reqUploadPath(x.photo_path, 255), + caption: optStr(x.caption, 10000), + sort_order: optInt(x.sort_order, -2147483648, 2147483647), + created_at: optTs(x.created_at), + })); + const group_photos = (data.group_photos || []).map(x => ({ + id: reqInt(x.id), + group_id: reqInt(x.group_id), + photo_path: reqUploadPath(x.photo_path, 255), + caption: optStr(x.caption, 10000), + taken_at: optDate(x.taken_at), + sort_order: optInt(x.sort_order, -2147483648, 2147483647), + created_at: optTs(x.created_at), + })); + const share_links = (data.share_links || []).map(x => ({ + id: reqInt(x.id), + token: optStr(x.token, 40), + name: reqStr(x.name, 200), + group_id: optInt(x.group_id, 0, 2147483647), + student_name: optStr(x.student_name, 150), + date_from: optDate(x.date_from), + date_to: optDate(x.date_to), + show_student_names: optBool(x.show_student_names), + expires_at: optTs(x.expires_at), + access_password_hash: optStr(x.access_password_hash, 255), + message: optStr(x.message, 2000), + link_url: optStr(x.link_url, 500), + show_student_message: optBool(x.show_student_message), + show_entry_date: optBool(x.show_entry_date), + show_group_photos: optBool(x.show_group_photos), + created_at: optTs(x.created_at), + })); const settings = {}; for (const [k, v] of Object.entries(data.settings || {})) { settings[reqStr(k, 100)] = reqStr(String(v), 10000); } - return { groups, students, entries, project_files, settings, branches, users, user_branches }; + return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links }; } app.get('/api/backup', requireAdmin, async (req, res) => { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { - const [g, s, e, st, pf, br, us, ub] = await Promise.all([ + const [g, s, e, st, pf, br, us, ub, gp, ep] = await Promise.all([ pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'), @@ -917,10 +963,12 @@ app.get('/api/backup', requireAdmin, async (req, res) => { pool.query('SELECT * FROM branches ORDER BY id'), pool.query('SELECT * FROM users ORDER BY id'), pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'), + pool.query('SELECT * FROM group_photos ORDER BY id'), + pool.query('SELECT * FROM entry_photos ORDER BY id'), ]); const settings = {}; st.rows.forEach(r => { settings[r.key] = r.value; }); - const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows }; + const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows }; fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true }); const dir = path.join(__dirname, 'uploads'); @@ -1050,6 +1098,18 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [x.id, x.entry_id, x.token, x.path, x.name, x.created_at] ); } + for (const x of ndata.group_photos) { + await client.query( + 'INSERT INTO group_photos (id, group_id, photo_path, caption, taken_at, sort_order, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [x.id, x.group_id, x.photo_path, x.caption, x.taken_at, x.sort_order, x.created_at] + ); + } + for (const x of ndata.entry_photos) { + await client.query( + 'INSERT INTO entry_photos (id, entry_id, photo_path, caption, sort_order, created_at) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.entry_id, x.photo_path, x.caption, x.sort_order, x.created_at] + ); + } for (const x of ndata.users) { await client.query( 'INSERT INTO users (id, username, password_hash, name, role, is_active, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', @@ -1068,7 +1128,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [k, String(v ?? '')] ); } - for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users']) { + for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos']) { const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl); await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]); } diff --git a/worker.js b/worker.js index 0727d82..f7b3806 100644 --- a/worker.js +++ b/worker.js @@ -1,6 +1,6 @@ const IDLE_MIN_MS = 2000; const IDLE_MAX_MS = 60000; -const REQUEST_TIMEOUT_MS = 30000; +const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10); const MAX_INPUT_CHARS = 2000; const MIN_TEXT_CHARS = 4;