diff --git a/package-lock.json b/package-lock.json index 1f0754c..e354d68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telemax", - "version": "1.3.3-beta.2", + "version": "1.3.3-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telemax", - "version": "1.3.3-beta.2", + "version": "1.3.3-beta.3", "license": "AGPL-3.0-only", "dependencies": { "dotenv": "^17.2.3", diff --git a/package.json b/package.json index 68c39a9..2d1b37f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "telemax", "private": true, "license": "AGPL-3.0-only", - "version": "1.3.3-beta.2", + "version": "1.3.3-beta.3", "type": "module", "scripts": { "dev": "tsx watch src/server/app.ts", diff --git a/src/bridge/fileShare.ts b/src/bridge/fileShare.ts index c8f76dd..5300c61 100644 --- a/src/bridge/fileShare.ts +++ b/src/bridge/fileShare.ts @@ -132,8 +132,20 @@ const HEARTBEAT_STALE_MS = 5 * 60_000; * outlives its expiry (a 4 GB upload over a slow line takes hours). A lock this old is a crash leftover. */ const UPLOAD_LOCK_STALE_MS = 12 * 3600_000; -export const SERVICE_TIMEOUT_TEXT = - 'служба файлов не ответила за 2,5 минуты. Чаще всего так бывает, когда порты 80 и 443 сервера не открываются из интернета (сервер дома за роутером или их закрывает файрвол хостинга). Подробности — в /var/lib/telemax-files/reconcile.log на сервере'; +/** + * A files-service problem told the way the user asked for (2026-10-11): what happened, a plain «why», + * what to do. Callers write «… не удалось — .», so it ends without a full stop. + */ +const explain = (what: string, why: string, todo: string) => `${what}.\n\nПочему: ${why}.\n\nЧто сделать: ${todo}`; +const WHY_PAGE = 'большие файлы передаются через страницу на вашем сервере'; +const WHY_CERT = `${WHY_PAGE}, а ей нужен HTTPS-сертификат. Его выдаёт Let's Encrypt — и перед выдачей проверяет, что адрес действительно ведёт на ваш сервер`; +const NO_FILES = 'а если большие файлы не нужны — выключите их: FILES=off в .env'; + +export const SERVICE_TIMEOUT_TEXT = explain( + 'служба файлов не ответила за 2,5 минуты', + `${WHY_PAGE}, и порты 80 и 443 сервера должны открываться из интернета. Чаще всего они закрыты: сервер дома за роутером или их закрывает файрвол хостинга`, + `пробросьте на сервер порты 80 и 443 на роутере или откройте их в панели хостинга; ${NO_FILES}. Подробности — в /var/lib/telemax-files/reconcile.log на сервере`, +); export class FileShare { readonly dir: string; @@ -522,34 +534,44 @@ export class FileShare { } } -const NO_FILES_HINT = 'Не нужны большие файлы — выключите их: FILES=off в .env.'; - /** - * service.json → a sentence for the user. reconcile.sh passes Caddy's own ACME error through - * («acme: …», English): the common ones become what to do. Pure + exported for tests. + * service.json → what happened, why and what to do (see explain). reconcile.sh passes Caddy's ACME + * error through as «acme: urn:ietf:params:acme:error: - …» (English, maybe cut): the type + * decides first, the wording second. Pure + exported for tests. */ export function describeServiceProblem(st: { state?: string; detail?: string }): string { if (st.state === 'ports-busy') { - return 'порты 80/443 на сервере заняты другим веб-сервером — файлы можно пустить через него (FILES_PUBLIC_URL и FILES_LISTEN в .env, см. README)'; + return explain( + 'порты 80 и 443 на сервере уже заняты другим веб-сервером', + `${WHY_PAGE}, и ей нужны эти порты`, + `пустите файлы через ваш веб-сервер — FILES_PUBLIC_URL и FILES_LISTEN в .env (пример в README); ${NO_FILES}`, + ); + } + if (st.state === 'no-ip') { + return explain('не удалось узнать внешний IPv4-адрес сервера', `${WHY_PAGE}, а ссылка на неё строится по адресу сервера`, 'укажите свой домен — FILES_DOMAIN в .env'); } - if (st.state === 'no-ip') return 'не удалось определить внешний IPv4-адрес сервера (можно указать свой домен: FILES_DOMAIN в .env)'; const detail = (st.detail ?? '').trim(); if (/^acme:/i.test(detail)) { - // The ACME error type (urn:ietf:params:acme:error:) first, the wording second: the text - // may be cut, the type comes first in what reconcile.sh passes. + const fail = (because: string, todo: string) => explain("Let's Encrypt не выдал серверу HTTPS-сертификат", `${WHY_CERT}. Проверка не прошла: ${because}`, todo); if (/acme:error:(connection|tls)\b|timeout during connect|firewall|connection refused|connection reset|no route to host|network unreachable/i.test(detail)) { - return `сервер не виден из интернета на портах 80 и 443, поэтому Let's Encrypt не может выдать ему HTTPS-сертификат. Если сервер дома за роутером — пробросьте на него порты 80 и 443; если их закрывает хостинг — откройте в его панели. ${NO_FILES_HINT}`; + return fail( + 'до сервера не достучаться из интернета на портах 80 и 443', + `если сервер дома за роутером — пробросьте на него порты 80 и 443; если их закрывает хостинг — откройте в его панели; ${NO_FILES}`, + ); } if (/acme:error:(unauthorized|incorrectresponse)\b|invalid response|status code 404|403 forbidden/i.test(detail)) { - return `на портах 80 и 443 по адресу сервера отвечает другое устройство (например, сам роутер), поэтому Let's Encrypt не может выдать сертификат. Пробросьте порты 80 и 443 на этот сервер. ${NO_FILES_HINT}`; + return fail('на портах 80 и 443 по адресу сервера отвечает другое устройство — например, сам роутер', `пробросьте порты 80 и 443 на этот сервер; ${NO_FILES}`); } if (/acme:error:dns\b|nxdomain|no valid a records|no such host|dns problem/i.test(detail)) { - return 'домен из FILES_DOMAIN не указывает на этот сервер — проверьте его A-запись (или уберите FILES_DOMAIN, тогда ссылки пойдут по IP)'; + return fail( + 'домен из настройки FILES_DOMAIN не указывает на этот сервер', + 'проверьте A-запись домена или уберите FILES_DOMAIN из .env — тогда ссылки пойдут по IP-адресу сервера', + ); } if (/acme:error:ratelimited\b|ratelimit|rate limit|too many/i.test(detail)) { - return "Let's Encrypt временно ограничил выдачу сертификатов для этого адреса — попробуйте через час"; + return fail("было слишком много попыток, и Let's Encrypt временно ограничил выдачу для этого адреса", 'подождите час и попробуйте снова'); } - return `не удалось получить HTTPS-сертификат: ${detail.replace(/^acme:\s*/i, '')}`; + return fail(detail.replace(/^acme:\s*/i, ''), `посмотрите /var/lib/telemax-files/reconcile.log на сервере; ${NO_FILES}`); } return `служба файлов не запустилась${detail ? `: ${detail}` : ''}`; } diff --git a/tests/fileShare.test.ts b/tests/fileShare.test.ts index a22e784..0a301d3 100644 --- a/tests/fileShare.test.ts +++ b/tests/fileShare.test.ts @@ -276,7 +276,10 @@ describe('files service problems in plain words', () => { state: 'error', detail: 'acme: error:HTTP 400 urn:ietf:params:acme:error:connection - 1.2.3.4: Timeout during connect (likely firewall problem)', }); - expect(firewall).toContain('не виден из интернета на портах 80 и 443'); + expect(firewall).toContain('не достучаться из интернета на портах 80 и 443'); + expect(firewall).toContain('Почему:'); + expect(firewall).toContain('Что сделать:'); + expect(firewall.endsWith('.')).toBe(false); // callers add the full stop expect(firewall).toContain('пробросьте'); expect(firewall).toContain('FILES=off'); expect(describeServiceProblem({ state: 'error', detail: 'acme: error:403 urn:ietf:params:acme:error:unauthorized - Invalid response from http://1.2.3.4/.well-known' })).toContain( @@ -284,7 +287,7 @@ describe('files service problems in plain words', () => { ); expect(describeServiceProblem({ state: 'error', detail: 'acme: error:DNS problem: NXDOMAIN looking up A for files.example.org' })).toContain('FILES_DOMAIN'); expect(describeServiceProblem({ state: 'error', detail: 'acme: error:urn:ietf:params:acme:error:rateLimited' })).toContain('через час'); - expect(describeServiceProblem({ state: 'error', detail: 'acme: msg:challenge failed oddly' })).toBe('не удалось получить HTTPS-сертификат: msg:challenge failed oddly'); + expect(describeServiceProblem({ state: 'error', detail: 'acme: msg:challenge failed oddly' })).toContain('Проверка не прошла: msg:challenge failed oddly'); // What reconcile.sh really passes (live test 2026-10-11): the type first, the wording may be cut. expect(describeServiceProblem({ state: 'error', detail: 'acme: urn:ietf:params:acme:error:dns - DNS problem: NXDOMAIN looking up A for x' })).toContain('FILES_DOMAIN'); expect(describeServiceProblem({ state: 'error', detail: 'acme: urn:ietf:params:acme:error:connection - 1.2.3.4: Ti' })).toContain('пробросьте');