Compare commits

..

5 Commits

Author SHA1 Message Date
grendervill a7b4e3318e build(desktop): цели make, README и устойчивость обёртки к правам на логи
- Makefile: `desktop-check`, `desktop-build` (с ключом подписи обновлений,
  без ключа — сборка без артефактов обновления) и `desktop-run`; кэши
  cargo и npm держатся в .cache/, как у остальных инструментов проекта.
- desktop/README.md: сборка, запуск, аргументы командной строки, настройки,
  deep links и открытые пункты Фазы 6.
- bundle.targets = all: бандлы по умолчанию для текущей ОС (macOS: .app/.dmg).
- Логирование подключается в setup и больше не роняет приложение, если
  каталог логов недоступен на запись (песочница, домашний каталог только
  для чтения).
- Разбор аргументов вынесен в Cli::from_args и покрыт тестами
  (--instance, --minimized, --settings, --autostart on|off).
- cargo fmt по модулям обёртки.
2026-09-22 21:55:19 +03:00
grendervill 4a299155e7 fix(web): ключи settings.channel.background.* в ru и en
Раздел «Фон комнаты» запрашивал вложенные ключи, а строки лежали на уровень
выше (settings.channel.title и соседние), поэтому в интерфейсе виднелись
сырые ключи i18n. Строки перенесены под settings.channel.background в обеих
локалях, паритет ru/en сохранён — web/tests/i18n.test.ts зелёный.
2026-09-22 21:52:20 +03:00
grendervill 751ecc6d45 fix(web): отзыв сессий уводит на вход и при открытом сервере
Сервер отзывал сессии кадром {"op":4,"reason":"…","resumable":false} и закрывал
соединение, но устройство с открытым сервером (/app/<сервер>/<комната>)
оставалось в приложении: уход на /login зависел только от 401 на запросе
профиля, а перечитывание профиля в этой ветке не срабатывало (пункт 12
матрицы 11.6, проверено перехватом WS на стенде).

Теперь признак invalidated в сторе шлюза читает AuthGuard и сразу уводит на
экран входа — в любом состоянии приложения, не дожидаясь ответа REST.
Признак снимается при успешном входе, иначе форма входа зацикливалась бы.

Тесты: «отзыв сессии на открытом сервере уводит на /login»; живая проверка
build/verify-session-invalidation.mjs дополнена сценарием с открытым сервером.
2026-09-22 21:52:16 +03:00
grendervill ea1b4a7a90 fix(web): возврат в сервер после кика виден в рейке без перезагрузки
По GUILD_CREATE клиент забирал сводку серверов через fetchQuery, а список
myGuilds кэшируется на 30 секунд: после кика и повторного входа сервер
возвращался на сервере, но плитка в рейке не появлялась до перезагрузки
(AGENT.md 11.6).

Теперь обработчик GUILD_CREATE сначала помечает список серверов устаревшим и
только потом читает его, поэтому запрос уходит в REST даже при свежем кэше.
Сценарий кика в пункте 11 матрицы проверяет плитку в рейке, а не только
состав участников по ответу сервера.

Тест: «GUILD_CREATE перечитывает список серверов, а не берёт свежий кэш».
2026-09-22 21:52:12 +03:00
grendervill a6cec5919a fix(gateway): READY отдаёт права участника вместо пустого списка
Снапшот READY инициализировал my_permissions пустым списком и нигде его не
заполнял, поэтому у всех — включая владельца сервера — клиент выключал
композер («В эту комнату нельзя писать»), прятал шестерёнку, приглашения и
кнопку саундборда: состав прав в снапшоте не совпадал с REST
(GET /users/@me/guilds отдавал полный список).

Права считает тот же движок и тот же кэш, что REST (permissions.Calculator,
переданный в NewSnapshot), а инстанс-админ получает максимальный набор даже
на чужом сервере, где он не участник (AGENT.md 7.19, 11.6).

Тесты: TestReadyCarriesMyPermissions (непустые права роли по умолчанию,
VIEW_CHANNEL/SEND_MESSAGES и согласие с can_send комнаты) и
TestReadyGivesInstanceAdminAllPermissions.
2026-09-22 21:52:09 +03:00
18 changed files with 423 additions and 35 deletions
+33
View File
@@ -186,6 +186,39 @@ web-test: ## Тесты web-клиента (vitest)
web-build: ## Сборка web-клиента в web/dist web-build: ## Сборка web-клиента в web/dist
cd $(WEB_DIR) && npm run build cd $(WEB_DIR) && npm run build
# Desktop-обёртка (Фаза 6, docs/client-tauri.md). Кэши инструментов — в .cache,
# адрес инстанса по умолчанию задаёт сама обёртка (https://gl.mhspx.su).
DESKTOP_DIR := $(ROOT)/desktop
DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri
DESKTOP_KEY ?= $(BUILD_DIR)/desktop-keys/glchat-updater.key
TAURI_CLI ?= npx --yes @tauri-apps/cli@^2
export CARGO_HOME ?= $(CACHE_DIR)/cargo
export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm
.PHONY: desktop-check
desktop-check: ## Проверки Rust-обёртки: fmt, clippy, тесты
cd $(DESKTOP_TAURI_DIR) && cargo fmt --check
cd $(DESKTOP_TAURI_DIR) && cargo clippy --release --all-targets
cd $(DESKTOP_TAURI_DIR) && cargo test --release
.PHONY: desktop-build
desktop-build: ## Собрать desktop-приложение (бандлы по умолчанию для текущей ОС)
@if [ -f "$(DESKTOP_KEY)" ]; then \
echo " ключ подписи обновлений: $(DESKTOP_KEY)"; \
cd $(DESKTOP_TAURI_DIR) && \
TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \
$(TAURI_CLI) build; \
else \
echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \
cd $(DESKTOP_TAURI_DIR) && \
$(TAURI_CLI) build --config '{"bundle":{"createUpdaterArtifacts":false}}'; \
fi
.PHONY: desktop-run
desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=…
open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(ARGS)
.PHONY: docker-build .PHONY: docker-build
docker-build: ## Собрать образ приложения docker-build: ## Собрать образ приложения
docker build \ docker build \
+90
View File
@@ -0,0 +1,90 @@
# desktop — desktop-обёртка glchat (Tauri 2)
Тот же веб-клиент из `web/`, обёрнутый в Tauri 2 (docs/client-tauri.md). Отдельного
UI-кода нет: страница инстанса загружается в системный webview, а обёртка даёт
платформенную интеграцию — трей, нативные уведомления, глобальный push-to-talk,
автозапуск, deep links и автообновление.
```
desktop/
└── src-tauri/ # Rust-проект обёртки
├── src/ # окно, трей, шорткаты, уведомления, deep links, обновления
├── capabilities/ # allowlist IPC: локальная страница настроек и домен инстанса
├── ui/ # локальная страница настроек обёртки (tauri://localhost)
├── icons/ # иконки приложения (сгенерированы из web/public/icons)
└── tauri.conf.json # конфигурация сборки и плагинов
```
## Сборка и запуск
```bash
make desktop-check # cargo fmt --check, clippy, тесты (кэш в .cache/)
make desktop-build # бандлы текущей ОС (macOS: .app и .dmg)
open desktop/src-tauri/target/release/bundle/macos/glchat.app
```
Требуется Rust 1.77+ и Node 22 (Tauri CLI ставится через `npx`). Кэши `cargo` и
`npm` держатся в `.cache/` — как у остальных инструментов проекта.
Сборка релиза с артефактами автообновления использует ключ подписи:
```bash
# ключ и пароль не коммитятся: приватный ключ живёт в CI-секретах,
# локально — в build/desktop-keys/ (каталог build/ в .gitignore)
npx @tauri-apps/cli@^2 signer generate -w build/desktop-keys/glchat-updater.key -p '…'
make desktop-build
```
Публичный ключ уже вписан в `src-tauri/tauri.conf.json` и в `UPDATER_PUBKEY`
(`src/updates.rs`). Без локального ключа `make desktop-build` собирает бандл без
артефактов обновления, чтобы сборка не падала у тех, у кого ключа нет.
## Аргументы командной строки
| Аргумент | Что делает |
|---|---|
| `--instance <url>` | адрес инстанса вместо сохранённого (`--instance=https://…` тоже работает) |
| `--minimized` | запуск свёрнутым в трей (используется автозапуском) |
| `--settings` | сразу открыть страницу настроек обёртки |
| `--autostart on\|off` | включить или выключить автозапуск при установке скриптом |
## Настройки
Хранятся в `settings.json` каталога конфигурации приложения
(`~/Library/Application Support/su.mhspx.glchat/` на macOS):
| Ключ | Смысл |
|---|---|
| `instanceUrl` | адрес клиента, по умолчанию `https://gl.mhspx.su` |
| `closeToTray` | закрытие окна сворачивает приложение в трей |
| `autostart` | запуск при входе в систему (`--minimized`) |
| `notifications` | показывать системные уведомления |
| `pttShortcut`, `muteShortcut`, `deafenShortcut` | глобальные комбинации (пусто — выключено) |
Меняются из меню трея и со страницы настроек обёртки (трей → «Настройки обёртки…»).
Сессия и токены живут только в cookie webview: обёртка их не хранит.
## Deep links
Схема `glchat://` (регистрируется бандлом):
- `glchat://invite/<code>` → страница приглашения;
- `glchat://guild/<guildId>/channel/<channelId>` → сервер и комната.
Ссылка, пришедшая до загрузки клиента (холодный старт), ждёт в очереди и
доставляется после `desktop_ready`.
## Что не сделано (открытые пункты Фазы 6)
- **Манифест обновлений на сервере**: `GET /updates/{target}/{arch}/{current_version}`
ещё не публикуется — нужна статика на стороне инстанса (Caddy) и релизный
контур с приватным ключом в CI. Клиентская часть готова: подпись проверяется,
downgrade отклоняется, при недоступном манифесте ошибка только логируется.
- **Клик по уведомлению** → открытие канала: у `tauri-plugin-notification` нет
колбэка действия на desktop, нужен свой мост к `UNUserNotificationCenter`.
- **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения.
- **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных
экранов-инструкций у обёртки нет.
- **Подпись и нотаризация сборки** macOS/Windows: Developer ID/EV только в CI.
- **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и
проверялись только macOS-arm64.
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+12 -2
View File
@@ -18,7 +18,12 @@ pub fn install<R: Runtime>(app: &AppHandle<R>) {
app.deep_link().on_open_url(move |event| { app.deep_link().on_open_url(move |event| {
for url in event.urls() { for url in event.urls() {
log::info!("deep link: {url}"); log::info!("deep link: {url}");
bridge::send(&handle, &WebMessage::DeepLink { url: url.to_string() }); bridge::send(
&handle,
&WebMessage::DeepLink {
url: url.to_string(),
},
);
} }
}); });
@@ -31,7 +36,12 @@ pub fn install<R: Runtime>(app: &AppHandle<R>) {
// Ссылка могла быть передана аргументом командной строки (Windows/Linux). // Ссылка могла быть передана аргументом командной строки (Windows/Linux).
if let Ok(Some(urls)) = app.deep_link().get_current() { if let Ok(Some(urls)) = app.deep_link().get_current() {
for url in urls { for url in urls {
bridge::send(app, &WebMessage::DeepLink { url: url.to_string() }); bridge::send(
app,
&WebMessage::DeepLink {
url: url.to_string(),
},
);
} }
} }
} }
+5 -1
View File
@@ -23,7 +23,11 @@ pub fn normalize(input: &str) -> Result<Url, String> {
let url = Url::parse(&candidate).map_err(|error| format!("некорректный адрес: {error}"))?; let url = Url::parse(&candidate).map_err(|error| format!("некорректный адрес: {error}"))?;
match url.scheme() { match url.scheme() {
"http" | "https" => {} "http" | "https" => {}
other => return Err(format!("поддерживаются только http и https, а не «{other}»")), other => {
return Err(format!(
"поддерживаются только http и https, а не «{other}»"
))
}
} }
if url.host_str().unwrap_or_default().is_empty() { if url.host_str().unwrap_or_default().is_empty() {
return Err("в адресе нет имени хоста".to_string()); return Err("в адресе нет имени хоста".to_string());
+97 -8
View File
@@ -18,9 +18,10 @@ mod tray;
mod updates; mod updates;
mod window; mod window;
use std::path::Path;
use std::time::Duration; use std::time::Duration;
use tauri::{Manager, WindowEvent}; use tauri::{AppHandle, Manager, WindowEvent};
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
@@ -47,8 +48,13 @@ struct Cli {
impl Cli { impl Cli {
fn parse() -> Self { fn parse() -> Self {
Self::from_args(std::env::args().skip(1))
}
/// Разбор аргументов отделён от окружения, чтобы его можно было тестировать.
fn from_args<I: IntoIterator<Item = String>>(args: I) -> Self {
let mut cli = Self::default(); let mut cli = Self::default();
let mut args = std::env::args().skip(1); let mut args = args.into_iter();
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
"--instance" => cli.instance = args.next(), "--instance" => cli.instance = args.next(),
@@ -91,11 +97,6 @@ pub fn run() {
bridge::send(app, &bridge::WebMessage::DeepLink { url }); bridge::send(app, &bridge::WebMessage::DeepLink { url });
} }
})) }))
.plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -148,8 +149,45 @@ pub fn run() {
.expect("не удалось запустить glchat desktop"); .expect("не удалось запустить glchat desktop");
} }
/// Подключает логирование: файл в каталоге логов приложения и stdout.
///
/// Если каталог логов недоступен на запись (например, приложение запущено из
/// песочницы или домашний каталог только для чтения), логи остаются только в
/// stdout: падать из-за прав на каталог приложение не должно.
fn install_logging(app: &AppHandle) {
let mut builder = tauri_plugin_log::Builder::default().level(log::LevelFilter::Info);
match app.path().app_log_dir() {
Ok(dir) if directory_is_writable(&dir) => {
builder = builder.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir { file_name: None },
));
}
Ok(dir) => eprintln!("каталог логов {} недоступен на запись", dir.display()),
Err(error) => eprintln!("не удалось определить каталог логов: {error}"),
}
if let Err(error) = app.plugin(builder.build()) {
eprintln!("не удалось подключить логирование: {error}");
}
}
/// Проверяет, можно ли писать в каталог (создаёт и удаляет пробный файл).
fn directory_is_writable(dir: &Path) -> bool {
if std::fs::create_dir_all(dir).is_err() {
return false;
}
let probe = dir.join(".glchat-write-probe");
match std::fs::write(&probe, b"") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
/// Начальная настройка: настройки, трей, окно, шорткаты, deep links. /// Начальная настройка: настройки, трей, окно, шорткаты, deep links.
fn setup(app: &tauri::AppHandle, cli: &Cli) { fn setup(app: &tauri::AppHandle, cli: &Cli) {
install_logging(app);
let mut current = settings::load(app); let mut current = settings::load(app);
if let Some(instance) = &cli.instance { if let Some(instance) = &cli.instance {
match instance::normalize(instance) { match instance::normalize(instance) {
@@ -187,7 +225,11 @@ fn setup(app: &tauri::AppHandle, cli: &Cli) {
log::info!( log::info!(
"обёртка запущена: адрес={}, окно={}, автозапуск={}", "обёртка запущена: адрес={}, окно={}, автозапуск={}",
current.instance_url, current.instance_url,
if cli.minimized { "свёрнуто" } else { "видимо" }, if cli.minimized {
"свёрнуто"
} else {
"видимо"
},
current.autostart current.autostart
); );
@@ -241,3 +283,50 @@ fn spawn_autostart_prompt(app: tauri::AppHandle) {
}); });
}); });
} }
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Cli {
Cli::from_args(args.iter().map(|value| value.to_string()))
}
#[test]
fn разбирает_аргументы_запуска() {
let cli = parse(&[
"--instance",
"https://example.com",
"--minimized",
"--settings",
]);
assert_eq!(cli.instance.as_deref(), Some("https://example.com"));
assert!(cli.minimized);
assert!(cli.settings);
assert_eq!(cli.autostart, None);
}
#[test]
fn разбирает_аргументы_со_значением_через_равно() {
let cli = parse(&["--instance=gl.example.org", "--autostart=off"]);
assert_eq!(cli.instance.as_deref(), Some("gl.example.org"));
assert_eq!(cli.autostart, Some(false));
}
#[test]
fn разбирает_переключатель_автозапуска() {
assert_eq!(parse(&["--autostart", "on"]).autostart, Some(true));
assert_eq!(parse(&["--autostart", "off"]).autostart, Some(false));
assert_eq!(parse(&["--autostart", "мусор"]).autostart, None);
// Аргумент без значения не должен ничего ломать.
assert_eq!(parse(&["--autostart"]).autostart, None);
}
#[test]
fn игнорирует_неизвестные_аргументы() {
let cli = parse(&["--unknown", "value", "мусор"]);
assert_eq!(cli.instance, None);
assert!(!cli.minimized);
assert!(!cli.settings);
}
}
+5 -1
View File
@@ -51,7 +51,11 @@ pub fn set_badge<R: Runtime>(app: &AppHandle<R>, count: u32) {
let count = app.state::<AppState>().set_badge(count); let count = app.state::<AppState>().set_badge(count);
if let Some(window) = app.get_webview_window(WEB_WINDOW) { if let Some(window) = app.get_webview_window(WEB_WINDOW) {
// На части платформ бейдж не поддерживается — это не ошибка. // На части платформ бейдж не поддерживается — это не ошибка.
let _ = window.set_badge_count(if count == 0 { None } else { Some(i64::from(count)) }); let _ = window.set_badge_count(if count == 0 {
None
} else {
Some(i64::from(count))
});
} }
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) { if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
let _ = tray.set_title(if count == 0 { let _ = tray.set_title(if count == 0 {
+5 -1
View File
@@ -13,7 +13,11 @@ use crate::settings::Settings;
use crate::state::AppState; use crate::state::AppState;
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации. /// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
pub fn on_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: &Shortcut, event: tauri_plugin_global_shortcut::ShortcutEvent) { pub fn on_shortcut<R: Runtime>(
app: &AppHandle<R>,
shortcut: &Shortcut,
event: tauri_plugin_global_shortcut::ShortcutEvent,
) {
let settings = app.state::<AppState>().settings(); let settings = app.state::<AppState>().settings();
let pressed = event.state == ShortcutState::Pressed; let pressed = event.state == ShortcutState::Pressed;
+6 -1
View File
@@ -76,7 +76,12 @@ pub async fn check<R: Runtime>(app: AppHandle<R>, interactive: bool) {
log::info!("доступно обновление {version}"); log::info!("доступно обновление {version}");
// Обновление показываем в интерфейсе, установку спрашиваем отдельно: // Обновление показываем в интерфейсе, установку спрашиваем отдельно:
// во время звонка перезапуск приложения недопустим без согласия. // во время звонка перезапуск приложения недопустим без согласия.
bridge::send(&app, &WebMessage::UpdateAvailable { version: version.clone() }); bridge::send(
&app,
&WebMessage::UpdateAvailable {
version: version.clone(),
},
);
prompt_install(app, update, version).await; prompt_install(app, update, version).await;
} }
Ok(None) => { Ok(None) => {
+1 -4
View File
@@ -15,10 +15,7 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": [ "targets": "all",
"app",
"dmg"
],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"category": "SocialNetworking", "category": "SocialNetworking",
"shortDescription": "glchat — self-hosted платформа общения", "shortDescription": "glchat — self-hosted платформа общения",
+66
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -214,6 +215,71 @@ func TestHelloAndReady(t *testing.T) {
} }
} }
// TestReadyCarriesMyPermissions проверяет, что снапшот несёт права участника:
// пустой `my_permissions` выключал композер и управление сервером у всех,
// включая владельца (AGENT.md 11.6).
func TestReadyCarriesMyPermissions(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "perm_user", "perm@example.com")
joinMainGuildForTest(t, f, token)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
var snapshot gateway.Ready
if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
perms := snapshot.Guilds[0].MyPerms
if len(perms) == 0 {
t.Fatal("READY must carry my_permissions of the default role")
}
// Роль @user даёт эти права каждому участнику (AGENT.md 6.3).
for _, want := range []string{"VIEW_CHANNEL", "SEND_MESSAGES"} {
if !slices.Contains(perms, want) {
t.Fatalf("my_permissions = %v, want %s", perms, want)
}
}
if !snapshot.Guilds[0].Channels[0].CanSend {
t.Fatal("my_permissions must agree with channel can_send")
}
}
// TestReadyGivesInstanceAdminAllPermissions: у инстанс-админа права
// максимальные даже на чужом сервере, где он не участник (AGENT.md 7.19).
func TestReadyGivesInstanceAdminAllPermissions(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
token := registerUser(t, f, "admin_user", "admin@example.com")
// Инстанс-админ — участник главного сервера: список серверов берётся из
// членства, а права на них максимальные.
joinMainGuildForTest(t, f, token)
user, _, err := f.auth.ResolveSession(ctx, token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
if err := f.store.SetInstanceAdmin(ctx, user.ID, true); err != nil {
t.Fatalf("SetInstanceAdmin: %v", err)
}
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
var snapshot gateway.Ready
if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
if len(snapshot.Guilds) != 1 {
t.Fatalf("instance admin must see the guild, got %d", len(snapshot.Guilds))
}
perms := snapshot.Guilds[0].MyPerms
for _, want := range []string{"ADMINISTRATOR", "SEND_MESSAGES", "MANAGE_ROLES", "BAN_MEMBERS"} {
if !slices.Contains(perms, want) {
t.Fatalf("my_permissions инстанс-админа = %v, want %s", perms, want)
}
}
}
func TestReadyHidesPrivateChannels(t *testing.T) { func TestReadyHidesPrivateChannels(t *testing.T) {
f := newFixture(t) f := newFixture(t)
ctx := context.Background() ctx := context.Background()
+13 -1
View File
@@ -140,7 +140,7 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
Roles: []ReadyRole{}, Roles: []ReadyRole{},
MemberIDs: []string{}, MemberIDs: []string{},
MyRoles: []string{}, MyRoles: []string{},
MyPerms: []string{}, MyPerms: readyPermissions(resolved),
Emojis: []ReadyEmoji{}, Emojis: []ReadyEmoji{},
VoiceStates: []ReadyVoiceState{}, VoiceStates: []ReadyVoiceState{},
Sounds: []ReadySound{}, Sounds: []ReadySound{},
@@ -257,6 +257,18 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
return readyGuild, nil return readyGuild, nil
} }
// readyPermissions отдаёт права пользователя на сервере списком имён — тем же
// движком и в том же виде, что REST (`permissions.Names`). Без них клиент
// выключал композер и прятал управление сервером (AGENT.md 11.6).
func readyPermissions(resolved permissions.Resolved) []string {
// У инстанс-админа права максимальные на любом сервере, включая чужой, где
// он не участник (AGENT.md 7.19): в маске прав роли их нет.
if resolved.IsInstanceAdmin {
return permissions.Names(permissions.AllPermissions)
}
return permissions.Names(resolved.Guild)
}
func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel { func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel {
payload := ReadyChannel{ payload := ReadyChannel{
ID: formatID(channel.ID), ID: formatID(channel.ID),
+9
View File
@@ -3,6 +3,7 @@ import { Navigate, Outlet, useLocation } from 'react-router';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { useCurrentUser } from '@/lib/hooks'; import { useCurrentUser } from '@/lib/hooks';
import { isUnauthorized } from '@/lib/http'; import { isUnauthorized } from '@/lib/http';
import { useGatewayStore } from '@/stores/gateway';
interface AuthGuardProps { interface AuthGuardProps {
/** /**
@@ -19,6 +20,14 @@ interface AuthGuardProps {
export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps) { export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps) {
const location = useLocation(); const location = useLocation();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
// Сервер отозвал сессии (logout-all, смена пароля, бан): шлюз уже сообщил об
// этом, поэтому на экран входа уходим сразу — не дожидаясь ответа 401 на
// запрос профиля и независимо от того, открыт ли сервер (AGENT.md 11.6).
const invalidated = useGatewayStore((state) => state.invalidated);
if (invalidated) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
if (currentUser.isPending) { if (currentUser.isPending) {
return ( return (
+9 -7
View File
@@ -1179,13 +1179,15 @@
"deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.", "deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.",
"stepUpBody": "Creating a webhook requires confirming your identity: enter your password{{totp}}." "stepUpBody": "Creating a webhook requires confirming your identity: enter your password{{totp}}."
}, },
"title": "Channel background", "background": {
"description": "An image behind the message feed. Animated GIF and WebP keep their animation; with the system reduce-motion setting the first frame is shown.", "title": "Channel background",
"empty": "No background set.", "description": "An image behind the message feed. Animated GIF and WebP keep their animation; with the system reduce-motion setting the first frame is shown.",
"upload": "Upload background", "empty": "No background set.",
"remove": "Remove background", "upload": "Upload background",
"notImage": "An image file is required", "remove": "Remove background",
"tooLarge": "File is larger than 5 MB", "notImage": "An image file is required",
"tooLarge": "File is larger than 5 MB"
},
"permissions": { "permissions": {
"title": "Channel access", "title": "Channel access",
"description": "Channel permissions on top of server ones: a private channel is hidden from @everyone, access is granted to roles and individual members.", "description": "Channel permissions on top of server ones: a private channel is hidden from @everyone, access is granted to roles and individual members.",
+9 -7
View File
@@ -1179,13 +1179,15 @@
"deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.", "deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.",
"stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}." "stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}."
}, },
"title": "Фон комнаты", "background": {
"description": "Картинка за лентой сообщений. Анимированные GIF и WebP сохраняют анимацию; при системной настройке «уменьшить движение» показывается первый кадр.", "title": "Фон комнаты",
"empty": "Фон не задан.", "description": "Картинка за лентой сообщений. Анимированные GIF и WebP сохраняют анимацию; при системной настройке «уменьшить движение» показывается первый кадр.",
"upload": "Загрузить фон", "empty": "Фон не задан.",
"remove": "Убрать фон", "upload": "Загрузить фон",
"notImage": "Нужен файл изображения", "remove": "Убрать фон",
"tooLarge": "Файл больше 5 МБ", "notImage": "Нужен файл изображения",
"tooLarge": "Файл больше 5 МБ"
},
"permissions": { "permissions": {
"title": "Доступ к комнате", "title": "Доступ к комнате",
"description": "Права комнаты поверх серверных: приватная комната скрыта для @everyone, доступ выдают роли и отдельные участники.", "description": "Права комнаты поверх серверных: приватная комната скрыта для @everyone, доступ выдают роли и отдельные участники.",
+4 -1
View File
@@ -10,6 +10,7 @@ import { Button, Card } from '@/components/ui/primitives';
import { getQueryClient } from '@/lib/queryClient'; import { getQueryClient } from '@/lib/queryClient';
import { useInstance } from '@/lib/hooks'; import { useInstance } from '@/lib/hooks';
import { errorCode } from '@/lib/format'; import { errorCode } from '@/lib/format';
import { useGatewayStore } from '@/stores/gateway';
interface LocationState { interface LocationState {
from?: string; from?: string;
@@ -45,7 +46,9 @@ export default function LoginPage() {
return login(code === '' ? { email, password } : { email, password, totp_code: code }); return login(code === '' ? { email, password } : { email, password, totp_code: code });
}, },
onSuccess: async () => { onSuccess: async () => {
// Профиль перечитываем заново: cookie уже выставлена сервером. // Профиль перечитываем заново: cookie уже выставлена сервером. Признак
// отозванной сессии снимаем — иначе AuthGuard увёл бы обратно на /login.
useGatewayStore.getState().reset();
await getQueryClient().invalidateQueries(); await getQueryClient().invalidateQueries();
void navigate(from === '/login' ? '/app' : from, { replace: true }); void navigate(from === '/login' ? '/app' : from, { replace: true });
}, },
+36
View File
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
import { GatewayOp, parseGatewaySnapshot } from '@/api/gateway'; import { GatewayOp, parseGatewaySnapshot } from '@/api/gateway';
import { guildMembersQueryKey, guildQueryKey, guildRolesQueryKey } from '@/api/guilds'; import { guildMembersQueryKey, guildQueryKey, guildRolesQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { setQueryClient } from '@/lib/queryClient'; import { setQueryClient } from '@/lib/queryClient';
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway'; import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
@@ -330,6 +331,41 @@ describe('диспетчер событий шлюза', () => {
).toBe(true); ).toBe(true);
}); });
it('GUILD_CREATE перечитывает список серверов, а не берёт свежий кэш', async () => {
// Возврат в сервер после кика: список серверов уже читался меньше 30 секунд
// назад, поэтому fetchQuery вернул бы устаревший кэш без нового сервера.
const { client } = makeRecordingClient();
const guild = {
id: 'g-5',
name: 'Вернулся',
owner_id: 'user-1',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: [],
};
const fetchMock = installFetch([
{
match: '/api/v1/users/@me/guilds',
response: () => json({ guilds: [guild] }),
},
]);
await client.fetchQuery({
queryKey: myGuildsQueryKey,
queryFn: () => fetchMyGuilds(),
});
dispatchGatewayEvent({ op: 0, t: 'GUILD_CREATE', s: 14, d: { guild_id: 'g-5' } });
await waitFor(() => {
expect(useSessionStore.getState().guilds.some((item) => item.id === 'g-5')).toBe(true);
});
expect(
recordedRequests(fetchMock).filter((request) => request.url.includes('/users/@me/guilds')),
'список серверов перечитан по REST',
).toHaveLength(2);
});
it('неизвестные и повреждённые события не ломают диспетчер', () => { it('неизвестные и повреждённые события не ломают диспетчер', () => {
seedSession(); seedSession();
+22
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import { dispatchGatewayEvent } from '@/stores/gateway';
import { apiError, installFetch, installFailingFetch, json, makeUser, renderApp } from './helpers'; import { apiError, installFetch, installFailingFetch, json, makeUser, renderApp } from './helpers';
describe('защита маршрутов', () => { describe('защита маршрутов', () => {
@@ -17,6 +18,27 @@ describe('защита маршрутов', () => {
expect(await screen.findByLabelText('Почта')).toBeVisible(); expect(await screen.findByLabelText('Почта')).toBeVisible();
}); });
it('отзыв сессии на открытом сервере уводит на /login', async () => {
const user = makeUser();
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const { router } = renderApp('/app/g-1/c-1');
await waitFor(() => {
expect(screen.getByTestId('guild-rail')).toBeVisible();
});
// Сервер отозвал сессии (logout-all, смена пароля): приходит INVALID_SESSION,
// и уход на экран входа не зависит от ответа запроса профиля (AGENT.md 11.6).
dispatchGatewayEvent({ op: 0, t: 'SESSION_INVALIDATED', s: 1, d: { reason: 'logout_all' } });
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(await screen.findByLabelText('Почта')).toBeVisible();
});
it('пользователя с onboarding_completed=false отправляет на /onboarding', async () => { it('пользователя с onboarding_completed=false отправляет на /onboarding', async () => {
const user = makeUser({ onboarding_completed: false }); const user = makeUser({ onboarding_completed: false });
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]); installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);