162759c599
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.
Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.
Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.
Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.
Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.
Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
74 lines
3.2 KiB
Go
74 lines
3.2 KiB
Go
import { AxiosResponse } from "axios";
|
||
|
||
/**
|
||
* Скачивание бинарного ответа API одним способом на всю панель.
|
||
*
|
||
* Раньше эта последовательность — Blob, createObjectURL, временная ссылка,
|
||
* клик, revokeObjectURL — была скопирована в трёх местах, и все три копии
|
||
* успели разойтись: страница пиров разбирала Content-Disposition с запасным
|
||
* именем файла, а обе страницы логов падали на `undefined.split(...)`, если
|
||
* заголовка не оказалось.
|
||
*
|
||
* Хуже расхождения был порядок на страницах логов:
|
||
*
|
||
* const response = await exportLogApi(...); // <- запрос ВНЕ try
|
||
* try { ... } catch (e) { } // <- и пустой catch
|
||
*
|
||
* Сетевой запрос стоял ПЕРЕД try, поэтому его отказ этим catch не ловился
|
||
* вовсе, а всё, что происходило внутри, глушилось молча. Оператор нажимал
|
||
* «Экспорт» и не получал ни файла, ни объяснения.
|
||
*/
|
||
|
||
/** Имя файла по умолчанию, если сервер его не назвал. */
|
||
const CONTENT_DISPOSITION_PREFIX = "attachment; filename=";
|
||
|
||
/**
|
||
* Имя файла из заголовка ответа.
|
||
*
|
||
* Отсутствующий или незнакомый заголовок — не отказ: у скачивания есть
|
||
* разумное запасное имя, и терять из-за формата заголовка уже полученный файл
|
||
* незачем.
|
||
*/
|
||
export function fileNameFromResponse(
|
||
response: AxiosResponse,
|
||
fallback: string
|
||
): string {
|
||
const header = response.headers?.["content-disposition"];
|
||
if (typeof header !== "string") {
|
||
return fallback;
|
||
}
|
||
const index = header.indexOf(CONTENT_DISPOSITION_PREFIX);
|
||
if (index < 0) {
|
||
return fallback;
|
||
}
|
||
const name = header.slice(index + CONTENT_DISPOSITION_PREFIX.length).trim();
|
||
return name === "" ? fallback : name;
|
||
}
|
||
|
||
/**
|
||
* Отдаёт бинарный ответ пользователю как файл.
|
||
*
|
||
* Временная ссылка УДАЛЯЕТСЯ из документа, а objectURL освобождается в
|
||
* `finally`. Прежние копии оставляли `<a>` в body навсегда — по одному
|
||
* элементу на каждое нажатие «Экспорт» за всю жизнь вкладки.
|
||
*/
|
||
export function saveResponseAsFile(
|
||
response: AxiosResponse,
|
||
fallbackFileName: string
|
||
): void {
|
||
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||
const url = window.URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
|
||
try {
|
||
anchor.href = url;
|
||
anchor.download = fileNameFromResponse(response, fallbackFileName);
|
||
anchor.style.display = "none";
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
} finally {
|
||
anchor.remove();
|
||
window.URL.revokeObjectURL(url);
|
||
}
|
||
}
|