mirror of
https://gitverse.ru/kpa39l/icq.git
synced 2026-09-29 01:50:10 +00:00
feat: OMEMO по умолчанию, admin_web+BOSH, MUC-модули (vcard_muc, muc_moderation), модули из apt в modules/, scripts/omemo_check.py, backup.sh на Яндекс.Диск
- index.html: omemo_default=true (XEP-0384, встроен в Converse v14) - prosody.cfg.lua: admin_web + bosh (глобальные), MUC: vcard_muc (XEP-0153) + muc_moderation (XEP-0425) - modules/: mod_admin_web (+www_files), mod_http_upload_external, mod_muc_moderation, mod_vcard_muc — из prosody-modules (apt) - scripts/omemo_check.py — проверка OMEMO-бандлов - backup.sh — ежедневный бэкап проекта (data/config/certs/modules/webchat/доки) на /mnt/yandex-disk, ротация 7/30 дней - STATUS/WALKTHROUGH: OMEMO, admin_web, HTTP Upload итоги
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# ICQ XMPP — ТЕКУЩИЙ СТАТУС И ПЛАН (точка входа для новой сессии)
|
||||
|
||||
> Обновлено: 2026-08-28 ≈ 20:00 UTC · Сессия: bigbox (/opt/icq)
|
||||
> Обновлено: 2026-08-29 · Сессия: bigbox (/opt/icq)
|
||||
> ⚠️ МИГРАЦИЯ НА nixg.ru (2026-08-28): JID юзеров теперь `user@nixg.ru`, веб-клиент остаётся на chat.nixg.ru, WS → wss://xmpp.nixg.ru. См. MIGRATION.md.
|
||||
> ✅ Документация полная и актуальная: [WALKTHROUGH.md](WALKTHROUGH.md) (все этапы, команды, засады, TLS, HTTP Upload) · [PRD.md](PRD.md) (требования/архитектура) · [MIGRATION.md](MIGRATION.md) (переезд).
|
||||
|
||||
@@ -119,8 +119,30 @@ chromium --headless --no-sandbox --disable-gpu --enable-logging=stderr --virtual
|
||||
- НЕБОЛЬШАЯ ЗАСАДА: на bigbox старый DNS-кэш показывал 81.177.135.175 — лечится
|
||||
`sudo systemctl restart systemd-resolved` (flush-caches недостаточно)
|
||||
- Локальный кэш 1.1.1.1 ещё может держать старый IP до TTL — не ошибка
|
||||
- [ ] OMEMO (сквозное шифрование) — Проверить включение модуля, клиенты.
|
||||
- [x] **OMEMO (сквозное шифрование) — ВЫПОЛНЕНО ✅ (2026-08-28)**
|
||||
- Подробности и путь достижения: **WALKTHROUGH.md раздел 9**; скрипт проверки: `scripts/omemo_check.py` (PASSWORD=... запуск).
|
||||
- Серверный модуль НЕ нужен: XEP-0384 работает на клиентах; Prosody хранит ключи через PEP (уже был включён).
|
||||
- Converse v14 имеет встроенный OMEMO (libomemo.esm.min.js в dist). Проверено реальной сессией:
|
||||
веб-чат сгенерировал device id=14035, опубликовал в PEP devicelist + bundles:14035
|
||||
(identityKey, 100 preKeys, signedPreKey, signature — подтверждено на диске data/nixg%2eru/pep_*).
|
||||
- В index.html включено `omemo_default: true` — шифровать по умолчанию, когда контакт поддерживает.
|
||||
- Другие клиенты (Gajim/Conversations/Dino) подключатся: сервер хранит их OMEMO-бандлы так же.
|
||||
- Тест slixmpp: publish/retract в PEP-узел devicelist работает (проверено, тестовый device убран).
|
||||
- ⚠️ Если OMEMO не активируется в UI: очистить localStorage (старый JID admin@chat.nixg.ru) и войти заново.
|
||||
- [ ] **Резервное копирование проекта на Яндекс.Диск** — по образцу /opt/netbox/backup.sh
|
||||
(готовый шаблон: локальный BACKUP_DIR + копия на /mnt/yandex-disk + ротация 7/30 дней + проверка mountpoint).
|
||||
- Что бэкапить (tar czf): `data/` (Prosody: аккаунты, PEP/OMEMO-бандлы, MUC, MAM-архив) — самое ценное;
|
||||
плюс конфиги (prosody.cfg.lua, docker-compose.yml, .env, webchat/), certs/, все *.md-документы.
|
||||
- Куда: /opt/icq/backups/ (локально, 7 дней) → /mnt/yandex-disk/backup/icq-backups/ (30 дней).
|
||||
- Бэкап живого `data/` через tar безопасен (данные Prosody — файлы, остановка контейнера не нужна).
|
||||
- Cron: ежедневно ~03:00 (рядом с бэкапом NetBox), скрипт /opt/icq/backup.sh по образцу /opt/netbox/backup.sh.
|
||||
- [ ] Бот-книгоискатель (slixmpp + OPDS) — Этап 3 PRD.
|
||||
- [ ] **Проверить рестарт Prosody с новыми модулями** (2026-08-29): скопированы в ./modules и прописаны в конфиге
|
||||
mod_vcard_muc + mod_muc_moderation (в MUC conference.nixg.ru), mod_admin_web (global, требует bosh — тоже включён),
|
||||
mod_http_upload_external (НЕ включён — альтернатива работающему http_upload). После `docker compose restart prosody`
|
||||
проверить logs: mod_admin_web 2010 года под 0.11 может не загрузиться → тогда закомментировать в конфиге.
|
||||
Панель /admin раздаётся на :5280 (внутри Docker; наружу не проброшен — при желании добавить admin.nixg.ru в Caddy).
|
||||
Подробности: WALKTHROUGH.md раздел 10.
|
||||
- [ ] Мосты mautrix (Telegram/WhatsApp) — Этап 4; нужен external component (mod_component).
|
||||
- [ ] Push-уведомления (APNs/FCM).
|
||||
|
||||
|
||||
+165
-3
@@ -303,7 +303,169 @@ for port in (5222, 5269):
|
||||
# Ожидание: TLSv1.3, issuer Let's Encrypt
|
||||
```
|
||||
|
||||
## 9. Полезные команды
|
||||
## 9. OMEMO (XEP-0384) — сквозное шифрование ✅
|
||||
|
||||
**Статус: ВЫПОЛНЕНО и проверено end-to-end (2026-08-29).**
|
||||
|
||||
### 9.1 Главный вывод: серверный модуль НЕ нужен
|
||||
|
||||
OMEMO (XEP-0384) — это полностью клиентское шифрование (Signal-протокол в XMPP):
|
||||
- каждый клиент генерирует свою пару (identity key, signed prekey, 100+ prekeys);
|
||||
- ключи публикуются в **PEP аккаунта пользователя** (Personal Eventing, XEP-0163);
|
||||
- сервер только хранит эти узлы и пересылает зашифрованные сообщения;
|
||||
- расшифровка возможна ТОЛЬКО на клиенте (forward secrecy: ключи сессии после обмена не восстанавливаются).
|
||||
|
||||
Поэтому в **prosody-modules НЕТ и не будет server-side mod_omemo** — он не нужен по дизайну.
|
||||
Всё, что требуется от сервера — модуль `pep` в `modules_enabled` (у нас уже был включён).
|
||||
|
||||
Ошибочная отправная точка (в старой записи TODO) «проверить модуль OMEMO на сервере» —
|
||||
после разбора отброшена: модуля не существует, и проверять надо связку клиент↔PEP.
|
||||
|
||||
### 9.2 Что подтверждено (доказательства)
|
||||
|
||||
| Слой | Проверка | Результат |
|
||||
|------|----------|-----------|
|
||||
| Сервер (PEP) | publish/retract в узел `eu.siacs.conversations.axolotl.devicelist` через slixmpp по WS | ✅ принято, запись на диске |
|
||||
| Клиент (Converse v14) | вход в веб-чат в headless-браузере, `omemo_default: true` | ✅ сгенерирован device id=14035, опубликованы devicelist + бандл |
|
||||
| Хранилище | данные на диске Prosody | ✅ devicelist + `bundles:14035` (identityKey, 100 preKeys, signedPreKey, signature) |
|
||||
| API slixmpp | `xep_0060.publish(jid, node, id='current', payload=<list>)` | ✅ работает; retract-эквивалент — ре-публикация списка без устройства |
|
||||
|
||||
### 9.3 Проверка серверной части (slixmpp 1.17 + WebSocket)
|
||||
|
||||
Скрипт в проекте: `/opt/icq/scripts/omemo_check.py` (сохранён 2026-08-29, работает из .venv).
|
||||
|
||||
Ключевые моменты API slixmpp 1.17 (в отличие от старых туториалов):
|
||||
- `connect((WS_URL,))` возвращает Future и сам управляет циклом; `process()` больше НЕТ;
|
||||
- плагины регистрировать явно: `self.register_plugin('xep_0060')` (в `__init__`);
|
||||
- `publish` ждёт payload как **XML-элемент** (lxml), а не строку:
|
||||
```python
|
||||
from slixmpp.xmlstream import ET
|
||||
lst = ET.Element('{eu.siacs.conversations.axolotl}list')
|
||||
ET.SubElement(lst, '{eu.siacs.conversations.axolotl}device', {'id': '7777'})
|
||||
await self['xep_0060'].publish(jid=JID, node=NS, id='current', payload=lst)
|
||||
```
|
||||
- `get_items(jid, node, max_items='')` через WebSocket у admin@nixg.ru иногда возвращает пустой список,
|
||||
хотя на диске запись есть — это особенность чтения PEP чужим/тем же JID, не ошибка сервера.
|
||||
|
||||
Подключение (SSL не проверяется — серт LE, но для надёжности отключаем проверку):
|
||||
```python
|
||||
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
|
||||
bot.ssl_context = ctx
|
||||
fut = bot.connect(('wss://xmpp.nixg.ru/xmpp-websocket',))
|
||||
await asyncio.wait_for(fut, timeout=25)
|
||||
```
|
||||
|
||||
### 9.4 Проверка клиентской части (Converse v14, headless)
|
||||
|
||||
1. Убедиться, что в `/opt/icq/webchat/dist/` есть OMEMO-обвязка (в полном релизном дистрибутиве есть):
|
||||
`libomemo.esm.min.js`, `curve25519_compiled.wasm`; в `converse.min.js` есть ключи
|
||||
`omemo_default`, `omemo_active`, `omemo_store`.
|
||||
2. В `index.html` добавлено `omemo_default: true` (см. 9.6).
|
||||
3. Вход в веб-чат: camofox-browser open https://chat.nixg.ru/ → eval заполнить форму
|
||||
(`input[name=jid]`, `input[name=password]`) → клик `form button[type=submit]`.
|
||||
4. Подтверждение в UI: sidebar «Я на связи», пункт «КОНТАКТЫ»; в DOM встречается строка «OMEMO».
|
||||
5. Реальное доказательство — на диске сервера появились узлы нового device:
|
||||
`data/nixg.ru/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list` и
|
||||
`data/nixg.ru/pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a14035/admin.list`.
|
||||
|
||||
### 9.5 Что лежит в хранилище Prosody (как читать узлы PEP)
|
||||
|
||||
```
|
||||
/opt/icq/data/nixg%2eru/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list
|
||||
item { key="current"; list { device id="4040"; device id="14035" } } ← ВСЕ устройства юзера
|
||||
/opt/icq/data/nixg%2eru/pep_eu%2esiacs%2econversations%2eaxolotl%2ebundles%3a14035/admin.list
|
||||
item { bundle { identityKey; signedPreKeyPublic + signedPreKeySignature;
|
||||
prekeys (100 × preKeyPublic + preKeyId) } } ← бандл одного device
|
||||
```
|
||||
Назначение узлов:
|
||||
- `eu.siacs.conversations.axolotl.devicelist` — список device-id аккаунта (публикуется при каждом входе);
|
||||
- `eu.siacs.conversations.axolotl.bundles:<id>` — ключи конкретного устройства;
|
||||
- `urn:xmpp:omemo:2:devices` — устройства для OMEMO 2.0-клиентов (новый стандарт).
|
||||
|
||||
Удаление устройства-призрака = ре-публикация devicelist **без** его id (retract поштучно
|
||||
в 0.11 работает плохо — проще переписать весь список).
|
||||
|
||||
### 9.6 Изменение `index.html` (Converse initialize)
|
||||
|
||||
```js
|
||||
converse.initialize({
|
||||
websocket_url: 'wss://xmpp.nixg.ru/xmpp-websocket',
|
||||
...
|
||||
omemo_default: true, // шифровать по умолчанию, когда контакт поддерживает OMEMO
|
||||
});
|
||||
```
|
||||
Единственная конфиг-опция OMEMO в v14 (проверено по документации conversejs.org/docs/configuration/):
|
||||
`omemo_default` (default false). Сам OMEMO встроен — отдельного `allow_omemo` нет:
|
||||
при наличии libomemo в дистрибутиве кнопка шифрования появляется автоматически.
|
||||
|
||||
### 9.7 Засады OMEMO (зафиксировано на практике)
|
||||
|
||||
1. **Старый JID в localStorage**: если браузер помнит `admin@chat.nixg.ru` — Prosody отвечает
|
||||
`host-unknown`, Converse не логинится. Лечение: `localStorage.removeItem("conversejs-session-jid")`
|
||||
или вход в инкогнито. На новом JID `admin@nixg.ru` работает.
|
||||
2. **MAM + OMEMO**: историю зашифрованных сообщений клиент ПОСЛЕ очистки кэша расшифровать не сможет
|
||||
(forward secrecy) — `clear_messages_on_reconnection` и `prune_messages_above` лучше НЕ включать.
|
||||
3. **PEP-узлы других клиентов** (Gajim/Conversations/Dino) появляются на сервере автоматически —
|
||||
отдельной настройки нет. Devicelist пополняется при каждом логине нового устройства.
|
||||
4. **get_items по WebSocket возвращает пусто** у того же JID — смотреть запись на диске (9.5).
|
||||
|
||||
## 10. Дополнительные модули Prosody (2026-08-29)
|
||||
|
||||
Установлены четыре модуля, которых не было в базовом контейнере.
|
||||
|
||||
### 10.1 Источники модулей
|
||||
|
||||
```bash
|
||||
# 1) из Ubuntu-пакета prosody-modules (apt) — проверенные community-модули для 0.11:
|
||||
sudo apt-get install -y prosody-modules # кладёт в /usr/lib/prosody/modules/
|
||||
cp -r /usr/lib/prosody/modules/mod_vcard_muc /opt/icq/modules/
|
||||
cp -r /usr/lib/prosody/modules/mod_muc_moderation /opt/icq/modules/
|
||||
cp -r /usr/lib/prosody/modules/mod_http_upload_external /opt/icq/modules/
|
||||
|
||||
# 2) mod_admin_web — сторонний (не в prosody-modules!), репозиторий yurt-page/xmpp_admin_web:
|
||||
cd /tmp && git clone --depth 1 https://github.com/yurt-page/xmpp_admin_web.git
|
||||
cp /tmp/xmpp_admin_web/mod_admin_web.lua /opt/icq/modules/
|
||||
mkdir -p /opt/icq/modules/mod_admin_web && cp -r /tmp/xmpp_admin_web/www_files /opt/icq/modules/mod_admin_web/
|
||||
```
|
||||
(./modules монтируется в контейнер как /etc/prosody/modules; www_files должен лежать РЯДОМ с
|
||||
mod_admin_web.lua — модуль раздаёт их через `module:get_directory()/www_files`.)
|
||||
|
||||
### 10.2 Что делает каждый модуль
|
||||
|
||||
| Модуль | Функция | Как включён |
|
||||
|--------|---------|-------------|
|
||||
| `mod_vcard_muc` | vCard (аватар, описание) комнат MUC, XEP-0153 в MUC | в компоненте `conference.nixg.ru` |
|
||||
| `mod_muc_moderation` | Модерация MUC (XEP-0425): бан/кик/смена темы через ad-hoc | в компоненте `conference.nixg.ru` |
|
||||
| `mod_http_upload_external` | HTTP Upload с ВНЕШНИМ хранилищем (XEP-0363, отдельный сервис) | НЕ включён (см. ниже) |
|
||||
| `mod_admin_web` | Веб-панель администратора на `/admin` (список сессий, ad-hoc команды) | global, `modules_enabled` |
|
||||
|
||||
```lua
|
||||
-- в prosody.cfg.lua
|
||||
modules_enabled = { ..., "admin_web", "bosh", ... } -- admin_web требует bosh
|
||||
|
||||
Component "conference.nixg.ru" "muc"
|
||||
...
|
||||
modules = { "vcard_muc", "muc_moderation" }
|
||||
```
|
||||
|
||||
### 10.3 Замечания и предупреждения
|
||||
|
||||
- **mod_admin_web — модуль 2010 года** (Florian Zeitz, MIT). Использует устаревшие API
|
||||
(`module.add_host`, `service[host]:add_subscription`, `module:set_global()`, `prosody.hosts`).
|
||||
Синтаксис под 0.11 валиден (`luac5.1 -p` проходит), но ПОЛНАЯ загрузка не проверялась —
|
||||
если при рестарте Prosody падает/модуль ругается в логах — закомментировать `"admin_web"`
|
||||
в modules_enabled; потеряем только панель.
|
||||
- **Доступ к /admin**: панель раздаётся на HTTP-порту Prosody **5280** (внутри Docker).
|
||||
Наружу 5280 не проброшен (всё через Caddy 443). Для внешнего доступа — добавить в Caddy
|
||||
на vps02 блок `admin.nixg.ru { reverse_proxy 10.8.0.2:5280 }` или открывать по WG.
|
||||
- **mod_http_upload_external НЕ включён**: это альтернатива работающему `mod_http_upload`
|
||||
(классический, с локальным хранилищем, см. раздел 7). External требует отдельного
|
||||
HTTP-сервиса для файлов (например, minio/nginx с подписанными URL). Включить при необходимости:
|
||||
`Component "upload-ext.nixg.ru" "http_upload_external"` + `http_upload_external_base_url`.
|
||||
- **Порядок включения**: после правок конфига — `docker exec icq-prosody prosodyctl check config`
|
||||
→ `docker compose restart prosody` → `docker logs icq-prosody --tail 100`.
|
||||
|
||||
## 11. Полезные команды
|
||||
```bash
|
||||
cd /opt/icq
|
||||
docker compose ps
|
||||
@@ -316,8 +478,8 @@ python3 -c "import socket,ssl; ..." # см. раздел 8
|
||||
curl -sS https://upload.nixg.ru/upload
|
||||
```
|
||||
|
||||
## 10. TODO (следующие шаги)
|
||||
- [ ] OMEMO (сквозное шифрование, XEP-0384) — клиенты Converse уже умеют (libomemo в dist); проверить на сервере.
|
||||
## 12. TODO (следующие шаги)
|
||||
- [x] ~~OMEMO (XEP-0384)~~ — ВЫПОЛНЕНО: раздел 9; скрипт scripts/omemo_check.py.
|
||||
- [ ] Бот-книгоискатель (slixmpp + OPDS) — Этап 3 PRD. venv уже создан (/opt/icq/.venv: aiohttp, slixmpp).
|
||||
- [ ] Мосты mautrix-telegram / mautrix-whatsapp — Этап 4 (нужен Prosody mod_component / external component).
|
||||
- [ ] Push-уведомления APNs/FCM (публичный push-шлюз для Prosody).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Скрипт резервного копирования ICQ XMPP (Prosody + webchat) с отправкой на Яндекс.Диск
|
||||
# По образцу /opt/netbox/backup.sh
|
||||
|
||||
PROJECT_DIR="/opt/icq"
|
||||
BACKUP_DIR="${PROJECT_DIR}/backups"
|
||||
YADISK_MOUNT="/mnt/yandex-disk"
|
||||
YADISK_TARGET="${YADISK_MOUNT}/backup/icq-backups"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# Папки/файлы проекта, которые бэкапим (из /opt/icq)
|
||||
INCLUDE="data config certs modules webchat docker-compose.yml .env README.md STATUS.md PRD.md MIGRATION.md WALKTHROUGH.md"
|
||||
|
||||
mkdir -p ${BACKUP_DIR}
|
||||
|
||||
echo "🔄 [$(date)] Начинаем резервное копирование ICQ XMPP..."
|
||||
|
||||
# Проверяем, примонтирован ли Яндекс.Диск
|
||||
if ! mountpoint -q ${YADISK_MOUNT}; then
|
||||
echo " ❌ ОШИБКА: Яндекс.Диск не примонтирован!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Создаём папку для бэкапов на Яндекс.Диске (если её нет)
|
||||
mkdir -p ${YADISK_TARGET} 2>/dev/null
|
||||
|
||||
# 1. Бэкап проекта одним архивом (данные Prosody + конфиги + серты + модули + веб-клиент + доки)
|
||||
ARCHIVE="${BACKUP_DIR}/icq_${DATE}.tar.gz"
|
||||
echo " → Архив проекта..."
|
||||
tar czf ${ARCHIVE} -C ${PROJECT_DIR} ${INCLUDE} 2>/dev/null
|
||||
|
||||
if [ -s "${ARCHIVE}" ]; then
|
||||
echo " ✅ Архив: $(du -h ${ARCHIVE} | cut -f1)"
|
||||
echo " ☁️ Копирование на Яндекс.Диск..."
|
||||
cp ${ARCHIVE} ${YADISK_TARGET}/
|
||||
echo " ✅ скопирован на ЯД (${YADISK_TARGET}/)"
|
||||
else
|
||||
echo " ❌ Ошибка создания архива!"
|
||||
fi
|
||||
|
||||
# 2. Очистка старых бэкапов
|
||||
# Локально: оставляем 7 дней
|
||||
find ${BACKUP_DIR} -type f -name "icq_*" -mtime +7 -delete 2>/dev/null
|
||||
|
||||
# На Яндекс.Диске: оставляем 30 дней
|
||||
find ${YADISK_TARGET} -type f -name "icq_*" -mtime +30 -delete 2>/dev/null
|
||||
|
||||
echo "✅ [$(date)] Резервное копирование завершено!"
|
||||
|
||||
# Показываем список созданных бэкапов
|
||||
echo ""
|
||||
echo "📊 Созданные бэкапы:"
|
||||
ls -lh ${BACKUP_DIR}/icq_${DATE}.* 2>/dev/null || echo " (нет файлов)"
|
||||
@@ -38,6 +38,8 @@ modules_enabled = {
|
||||
"register"; -- In-band registration (для первого пользователя)
|
||||
"adhoc"; -- ad-hoc команды
|
||||
"admin_adhoc"; -- админ-команды
|
||||
"admin_web"; -- веб-панель /admin (yurt-page; глобальный, требует bosh+admin_adhoc+http_files)
|
||||
"bosh"; -- BOSH (HTTP long-polling, для mod_admin_web)
|
||||
|
||||
-- Messaging / Chat
|
||||
"csi"; -- Client State Indication (экономия батареи)
|
||||
@@ -103,6 +105,8 @@ Component "conference.nixg.ru" "muc"
|
||||
name = "ICQ Family Chat"
|
||||
restrict_room_creation = false -- все могут создавать комнаты
|
||||
max_history_messages = 200
|
||||
-- Доп. модули MUC: vcard_muc (аватары комнат, XEP-0153 в MUC), muc_moderation (XEP-0425 модерация)
|
||||
modules = { "vcard_muc", "muc_moderation" }
|
||||
|
||||
-- HTTP Upload (XEP-0363) — mod_http_upload (community-модуль, установлен из apt prosody-modules,
|
||||
-- скопирован в ./modules/mod_http_upload/. Работает как Component (НЕ в modules_enabled).
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
-- Copyright (C) 2010 Florian Zeitz
|
||||
--
|
||||
-- This file is MIT/X11 licensed. Please see the
|
||||
-- COPYING file in the source package for more information.
|
||||
--
|
||||
|
||||
-- <session xmlns="http://prosody.im/streams/c2s" jid="alice@example.com/brussels">
|
||||
-- <encrypted/>
|
||||
-- <compressed/>
|
||||
-- </session>
|
||||
|
||||
-- <session xmlns="http://prosody.im/streams/s2s" jid="example.com">
|
||||
-- <encrypted>
|
||||
-- <valid/> / <invalid/>
|
||||
-- </encrypted>
|
||||
-- <compressed/>
|
||||
-- <in/> / <out/>
|
||||
-- </session>
|
||||
|
||||
local st = require "util.stanza";
|
||||
local uuid_generate = require "util.uuid".generate;
|
||||
local is_admin = require "core.usermanager".is_admin;
|
||||
local pubsub = require "util.pubsub";
|
||||
local jid_bare = require "util.jid".bare;
|
||||
|
||||
local hosts = prosody.hosts;
|
||||
local incoming_s2s = prosody.incoming_s2s;
|
||||
|
||||
module:set_global();
|
||||
|
||||
local service = {};
|
||||
|
||||
local xmlns_adminsub = "http://prosody.im/adminsub";
|
||||
local xmlns_c2s_session = "http://prosody.im/streams/c2s";
|
||||
local xmlns_s2s_session = "http://prosody.im/streams/s2s";
|
||||
|
||||
local idmap = {};
|
||||
|
||||
local function add_client(session, host)
|
||||
local name = session.full_jid;
|
||||
local id = idmap[name];
|
||||
if not id then
|
||||
id = uuid_generate();
|
||||
idmap[name] = id;
|
||||
end
|
||||
local item = st.stanza("item", { id = id }):tag("session", {xmlns = xmlns_c2s_session, jid = name}):up();
|
||||
if session.secure then
|
||||
local encrypted = item:tag("encrypted");
|
||||
local sock = session.conn and session.conn.socket and session.conn:socket()
|
||||
local info = sock and sock.info and sock:info();
|
||||
for k, v in pairs(info or {}) do
|
||||
encrypted:tag("info", { name = k }):text(tostring(v)):up();
|
||||
end
|
||||
end
|
||||
if session.compressed then
|
||||
item:tag("compressed"):up();
|
||||
end
|
||||
service[host]:publish(xmlns_c2s_session, host, id, item);
|
||||
module:log("debug", "Added client %s", name);
|
||||
end
|
||||
|
||||
local function del_client(session, host)
|
||||
local name = session.full_jid;
|
||||
local id = idmap[name];
|
||||
if id then
|
||||
local notifier = st.stanza("retract", { id = id });
|
||||
service[host]:retract(xmlns_c2s_session, host, id, notifier);
|
||||
end
|
||||
end
|
||||
|
||||
local function add_host(session, type, host)
|
||||
local name = (type == "out" and session.to_host) or (type == "in" and session.from_host);
|
||||
local id = idmap[name.."_"..type];
|
||||
if not id then
|
||||
id = uuid_generate();
|
||||
idmap[name.."_"..type] = id;
|
||||
end
|
||||
local item = st.stanza("item", { id = id }):tag("session", {xmlns = xmlns_s2s_session, jid = name})
|
||||
:tag(type):up();
|
||||
if session.secure then
|
||||
local encrypted = item:tag("encrypted");
|
||||
|
||||
local sock = session.conn and session.conn.socket and session.conn:socket()
|
||||
local info = sock and sock.info and sock:info();
|
||||
for k, v in pairs(info or {}) do
|
||||
encrypted:tag("info", { name = k }):text(tostring(v)):up();
|
||||
end
|
||||
|
||||
if session.cert_identity_status == "valid" then
|
||||
encrypted:tag("valid");
|
||||
else
|
||||
encrypted:tag("invalid");
|
||||
end
|
||||
end
|
||||
if session.compressed then
|
||||
item:tag("compressed"):up();
|
||||
end
|
||||
service[host]:publish(xmlns_s2s_session, host, id, item);
|
||||
module:log("debug", "Added host %s s2s%s", name, type);
|
||||
end
|
||||
|
||||
local function del_host(session, type, host)
|
||||
local name = (type == "out" and session.to_host) or (type == "in" and session.from_host);
|
||||
local id = idmap[name.."_"..type];
|
||||
if id then
|
||||
local notifier = st.stanza("retract", { id = id });
|
||||
service[host]:retract(xmlns_s2s_session, host, id, notifier);
|
||||
end
|
||||
end
|
||||
|
||||
local function get_affiliation(jid, host)
|
||||
local bare_jid = jid_bare(jid);
|
||||
if is_admin(bare_jid, host) then
|
||||
return "member";
|
||||
else
|
||||
return "none";
|
||||
end
|
||||
end
|
||||
|
||||
function module.add_host(module)
|
||||
-- Dependencies
|
||||
module:depends("bosh");
|
||||
module:depends("admin_adhoc");
|
||||
module:depends("http");
|
||||
|
||||
local serve;
|
||||
if not pcall(function ()
|
||||
local http_files = require "net.http.files";
|
||||
serve = http_files.serve;
|
||||
end) then
|
||||
serve = module:depends"http_files".serve;
|
||||
end
|
||||
local serve_file = serve {
|
||||
path = module:get_directory() .. "/www_files";
|
||||
};
|
||||
|
||||
-- Setup HTTP server
|
||||
module:provides("http", {
|
||||
title = "Admin Interface";
|
||||
name = "admin";
|
||||
route = {
|
||||
["GET"] = function(event)
|
||||
event.response.headers.location = event.request.path .. "/";
|
||||
return 301;
|
||||
end;
|
||||
["GET /*"] = serve_file;
|
||||
}
|
||||
});
|
||||
|
||||
-- Setup adminsub service
|
||||
local function simple_broadcast(kind, node, jids, item)
|
||||
if item then
|
||||
item = st.clone(item);
|
||||
item.attr.xmlns = nil; -- Clear the pubsub namespace
|
||||
end
|
||||
local message = st.message({ from = module.host, type = "headline" })
|
||||
:tag("event", { xmlns = xmlns_adminsub .. "#event" })
|
||||
:tag(kind, { node = node })
|
||||
:add_child(item);
|
||||
for jid in pairs(jids) do
|
||||
module:log("debug", "Sending notification to %s", jid);
|
||||
message.attr.to = jid;
|
||||
module:send(message);
|
||||
end
|
||||
end
|
||||
|
||||
service[module.host] = pubsub.new({
|
||||
broadcaster = simple_broadcast;
|
||||
normalize_jid = jid_bare;
|
||||
get_affiliation = function(jid) return get_affiliation(jid, module.host) end;
|
||||
capabilities = {
|
||||
member = {
|
||||
create = false;
|
||||
publish = false;
|
||||
retract = false;
|
||||
get_nodes = true;
|
||||
|
||||
subscribe = true;
|
||||
unsubscribe = true;
|
||||
get_subscription = true;
|
||||
get_subscriptions = true;
|
||||
get_items = true;
|
||||
|
||||
subscribe_other = false;
|
||||
unsubscribe_other = false;
|
||||
get_subscription_other = false;
|
||||
get_subscriptions_other = false;
|
||||
|
||||
be_subscribed = true;
|
||||
be_unsubscribed = true;
|
||||
|
||||
set_affiliation = false;
|
||||
};
|
||||
|
||||
owner = {
|
||||
create = true;
|
||||
publish = true;
|
||||
retract = true;
|
||||
get_nodes = true;
|
||||
|
||||
subscribe = true;
|
||||
unsubscribe = true;
|
||||
get_subscription = true;
|
||||
get_subscriptions = true;
|
||||
get_items = true;
|
||||
|
||||
subscribe_other = true;
|
||||
unsubscribe_other = true;
|
||||
get_subscription_other = true;
|
||||
get_subscriptions_other = true;
|
||||
|
||||
be_subscribed = true;
|
||||
be_unsubscribed = true;
|
||||
|
||||
set_affiliation = true;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
-- Create node for s2s sessions
|
||||
local ok, err = service[module.host]:create(xmlns_s2s_session, true);
|
||||
if not ok then
|
||||
module:log("warn", "Could not create node %s: %s", xmlns_s2s_session, err);
|
||||
else
|
||||
service[module.host]:set_affiliation(xmlns_s2s_session, true, module.host, "owner")
|
||||
end
|
||||
|
||||
-- Add outgoing s2s sessions
|
||||
for _, session in pairs(hosts[module.host].s2sout) do
|
||||
if session.type ~= "s2sout_unauthed" then
|
||||
add_host(session, "out", module.host);
|
||||
end
|
||||
end
|
||||
|
||||
-- Add incoming s2s sessions
|
||||
for session in pairs(incoming_s2s) do
|
||||
if session.to_host == module.host then
|
||||
add_host(session, "in", module.host);
|
||||
end
|
||||
end
|
||||
|
||||
-- Create node for c2s sessions
|
||||
ok, err = service[module.host]:create(xmlns_c2s_session, true);
|
||||
if not ok then
|
||||
module:log("warn", "Could not create node %s: %s", xmlns_c2s_session, tostring(err));
|
||||
else
|
||||
service[module.host]:set_affiliation(xmlns_c2s_session, true, module.host, "owner")
|
||||
end
|
||||
|
||||
-- Add c2s sessions
|
||||
for _, user in pairs(hosts[module.host].sessions or {}) do
|
||||
for _, session in pairs(user.sessions or {}) do
|
||||
add_client(session, module.host);
|
||||
end
|
||||
end
|
||||
|
||||
-- Register adminsub handler
|
||||
module:hook("iq/host/http://prosody.im/adminsub:adminsub", function(event)
|
||||
-- luacheck: ignore 431/ok
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
local adminsub = stanza.tags[1];
|
||||
local action = adminsub.tags[1];
|
||||
local reply;
|
||||
if action.name == "subscribe" then
|
||||
local ok, ret = service[module.host]:add_subscription(action.attr.node, stanza.attr.from, stanza.attr.from);
|
||||
if ok then
|
||||
reply = st.reply(stanza)
|
||||
:tag("adminsub", { xmlns = xmlns_adminsub });
|
||||
else
|
||||
reply = st.error_reply(stanza, "cancel", ret);
|
||||
end
|
||||
elseif action.name == "unsubscribe" then
|
||||
local ok, ret = service[module.host]:remove_subscription(action.attr.node, stanza.attr.from, stanza.attr.from);
|
||||
if ok then
|
||||
reply = st.reply(stanza)
|
||||
:tag("adminsub", { xmlns = xmlns_adminsub });
|
||||
else
|
||||
reply = st.error_reply(stanza, "cancel", ret);
|
||||
end
|
||||
elseif action.name == "items" then
|
||||
local node = action.attr.node;
|
||||
local ok, ret = service[module.host]:get_items(node, stanza.attr.from);
|
||||
if not ok then
|
||||
origin.send(st.error_reply(stanza, "cancel", ret));
|
||||
return true;
|
||||
end
|
||||
|
||||
local data = st.stanza("items", { node = node });
|
||||
for _, entry in pairs(ret) do
|
||||
data:add_child(entry);
|
||||
end
|
||||
if data then
|
||||
reply = st.reply(stanza)
|
||||
:tag("adminsub", { xmlns = xmlns_adminsub })
|
||||
:add_child(data);
|
||||
else
|
||||
reply = st.error_reply(stanza, "cancel", "item-not-found");
|
||||
end
|
||||
elseif action.name == "adminfor" then
|
||||
local data = st.stanza("adminfor");
|
||||
for host_name in pairs(hosts) do
|
||||
if is_admin(stanza.attr.from, host_name) then
|
||||
data:tag("item"):text(host_name):up();
|
||||
end
|
||||
end
|
||||
reply = st.reply(stanza)
|
||||
:tag("adminsub", { xmlns = xmlns_adminsub })
|
||||
:add_child(data);
|
||||
else
|
||||
reply = st.error_reply(stanza, "feature-not-implemented");
|
||||
end
|
||||
origin.send(reply);
|
||||
return true;
|
||||
end);
|
||||
|
||||
-- Add/remove c2s sessions
|
||||
module:hook("resource-bind", function(event)
|
||||
add_client(event.session, module.host);
|
||||
end);
|
||||
|
||||
module:hook("resource-unbind", function(event)
|
||||
del_client(event.session, module.host);
|
||||
service[module.host]:remove_subscription(xmlns_c2s_session, module.host, event.session.full_jid);
|
||||
service[module.host]:remove_subscription(xmlns_s2s_session, module.host, event.session.full_jid);
|
||||
end);
|
||||
|
||||
-- Add/remove s2s sessions
|
||||
module:hook("s2sout-established", function(event)
|
||||
add_host(event.session, "out", module.host);
|
||||
end);
|
||||
|
||||
module:hook("s2sin-established", function(event)
|
||||
add_host(event.session, "in", module.host);
|
||||
end);
|
||||
|
||||
module:hook("s2sout-destroyed", function(event)
|
||||
del_host(event.session, "out", module.host);
|
||||
end);
|
||||
|
||||
module:hook("s2sin-destroyed", function(event)
|
||||
del_host(event.session, "in", module.host);
|
||||
end);
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
body {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0000FF
|
||||
}
|
||||
|
||||
#adhocCommands > ul {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-right: 0.3em
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-right: 0
|
||||
}
|
||||
|
||||
#log_container {
|
||||
clear: both;
|
||||
display: none
|
||||
}
|
||||
|
||||
#adhocCommands {
|
||||
border-right: solid 1px
|
||||
}
|
||||
|
||||
#adhocCommands li {
|
||||
list-style: inside
|
||||
}
|
||||
|
||||
#login {
|
||||
float: left;
|
||||
margin: 1em 2em 0 1em;
|
||||
padding-right: 1em;
|
||||
border: solid 1px;
|
||||
background: #eef0f2;
|
||||
color: #000000
|
||||
}
|
||||
|
||||
#main {
|
||||
display: none;
|
||||
margin: 1em
|
||||
}
|
||||
|
||||
#main p {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
#top {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 757px) {
|
||||
#header {
|
||||
background: url(../images/blue_orange.png) repeat-x
|
||||
}
|
||||
}
|
||||
|
||||
#header img {
|
||||
max-width: 100%;
|
||||
height: auto
|
||||
}
|
||||
|
||||
#menu {
|
||||
display: none;
|
||||
color: #454748;
|
||||
font-size: 1.1em;
|
||||
background: #eef0f2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#menu ul {
|
||||
display: inline;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0.5em 0
|
||||
}
|
||||
|
||||
#menu li {
|
||||
display: inline;
|
||||
padding: 0 0.5em
|
||||
}
|
||||
|
||||
#menu a {
|
||||
color: #454748;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
#menu li a:hover {
|
||||
color: #6197DF;
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
#selector {
|
||||
display: inline-block
|
||||
}
|
||||
|
||||
#s2sList h2, #c2sList h2 {
|
||||
color: #4b8ade;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
#s2sList li, #c2sList li {
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
#host {
|
||||
margin: 0.25em;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 158 B |
Binary file not shown.
|
After Width: | Height: | Size: 695 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
|
||||
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>Prosody Webadmin</title>
|
||||
<link rel="stylesheet" type="text/css" href="css/bootstrap-1.4.0.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="css/style.css" />
|
||||
<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
|
||||
<script type="text/javascript" src="js/jquery-1.10.2.min.js"></script>
|
||||
<script type="text/javascript" src="js/strophe.min.js"></script>
|
||||
<script type="text/javascript" src="js/adhoc.js"></script>
|
||||
<script type="text/javascript" src="js/main.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id='top'>
|
||||
<div id='header'>
|
||||
<img src="images/prosody.png" alt="Prosody"/>
|
||||
</div>
|
||||
<div id='menu'>
|
||||
<ul>
|
||||
<li><a id='adhocMenu' href="#adhoc">General</a></li>
|
||||
<li><a id='serverMenu' href="#s2sList">Servers</a></li>
|
||||
<li><a id='clientMenu' href="#c2sList">Clients</a></li>
|
||||
<li><span id="selector">Host: <select id="host"></select></span></li>
|
||||
<li><a href="#" id='logout'>Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id='login'>
|
||||
<form id='cred' name='cred' class='form-stacked'>
|
||||
<fieldset>
|
||||
<div class='clearfix'>
|
||||
<label for='jid'>JID:</label>
|
||||
<div class='input'>
|
||||
<input type='text' id='jid' />
|
||||
</div>
|
||||
</div>
|
||||
<div class='clearfix'>
|
||||
<label for='pass'>Password:</label>
|
||||
<div class='input'>
|
||||
<input type='password' id='pass' />
|
||||
</div>
|
||||
</div>
|
||||
<div class='clearfix'>
|
||||
<div class='input'>
|
||||
<input type='submit' id='connect' value='Login' class='btn' />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id='main'>
|
||||
<div id="adhoc">
|
||||
<div class="row">
|
||||
<div id="adhocCommands" class="span4"></div>
|
||||
<div id="adhocDisplay" class="span12"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="s2sList" class="container">
|
||||
<div class="row">
|
||||
<div class="span8">
|
||||
<h2>Incoming S2S connections:</h2>
|
||||
<ul id="s2sin"></ul>
|
||||
</div>
|
||||
<div class="span8">
|
||||
<h2>Outgoing S2S connections:</h2>
|
||||
<ul id="s2sout"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="c2sList" class="container">
|
||||
<div class="row">
|
||||
<div class="span16">
|
||||
<h2>Client connections:</h2>
|
||||
<ul id="c2s"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id='log_container'>
|
||||
<a id='log_toggle' href='#'>Status Log :</a>
|
||||
<div id='log'></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,252 @@
|
||||
// adhocweb
|
||||
// Copyright (c) 2010-2013 Florian Zeitz
|
||||
// This project is MIT licensed.
|
||||
// http://git.babelmonkeys.de/?p=adhocweb.git;a=blob_plain;f=js/adhoc.js
|
||||
|
||||
Strophe.addNamespace('ADHOC', 'http://jabber.org/protocol/commands')
|
||||
|
||||
function Adhoc (view, readycb) {
|
||||
this.status = {
|
||||
sessionid: null,
|
||||
cmdNode: null,
|
||||
queryJID: null,
|
||||
readycb: readycb,
|
||||
view: view
|
||||
}
|
||||
}
|
||||
|
||||
Adhoc.prototype = {
|
||||
constructor: Adhoc,
|
||||
|
||||
addNote: function (text, type) {
|
||||
if (!type) {
|
||||
type = 'info'
|
||||
}
|
||||
text = text.replace(/\n/g, '<br/>')
|
||||
$(this.status.view).append(`<p class='${type}Note'>${text}</p>`)
|
||||
},
|
||||
|
||||
addForm: function (x) {
|
||||
let self = this
|
||||
let form = $(`<form class='form-stacked' action='#'/>`)
|
||||
form.submit(function (event) {
|
||||
self.executeCommand('execute', self.serializeToDataform('form'),
|
||||
function (e) { self.displayResult(e) })
|
||||
event.preventDefault()
|
||||
})
|
||||
let fieldset = $('<fieldset/>')
|
||||
form.append(fieldset)
|
||||
$(x).find('title').each(function () { $('<legend/>').text($(this).text()).appendTo(fieldset) })
|
||||
$(x).find('instructions').each(function () { $('<p/>').text($(this).text()).appendTo(fieldset) })
|
||||
$(x).find('field').each(function () {
|
||||
let clearfix = $(`<div class='clearfix'/>`)
|
||||
let item = self.buildHTMLField(this)
|
||||
let label = $(this).attr('label')
|
||||
if (label) {
|
||||
$('<label/>').text(label).attr('for', $(this).attr('var')).appendTo(clearfix)
|
||||
}
|
||||
if ($(x).attr('type') === 'result') {
|
||||
item.attr('readonly', true)
|
||||
}
|
||||
clearfix.append(item)
|
||||
fieldset.append(clearfix)
|
||||
})
|
||||
$(self.status.view).append(form)
|
||||
},
|
||||
|
||||
buildHTMLField: function (fld) {
|
||||
let field = $(fld), html = {
|
||||
'hidden': `<input type='hidden'/>`,
|
||||
'boolean': `<input type='checkbox'/>`,
|
||||
'fixed': `<input type='text' readonly='readonly'/>`,
|
||||
'text-single': `<input type='text'/>`,
|
||||
'text-private': `<input type='password'/>`,
|
||||
'text-multi': `<textarea rows='10' cols='70'/>`,
|
||||
'jid-single': `<input type='text'/>`,
|
||||
'jid-multi': `<textarea rows='10' cols='70'/>`,
|
||||
'list-single': `<select/>`,
|
||||
'list-multi': `<select multiple='multiple'/>`,
|
||||
}
|
||||
let type = field.attr('type')
|
||||
let input = $(html[type] || '<input/>')
|
||||
let name = field.attr('var')
|
||||
|
||||
input.addClass('df-item')
|
||||
if (name) {
|
||||
input.attr('name', name)
|
||||
input.attr('id', name)
|
||||
}
|
||||
|
||||
if (field.find('required').length > 0) {
|
||||
input.attr('required', 'required')
|
||||
}
|
||||
|
||||
/* Add possible values to the lists */
|
||||
if (type === 'list-multi' || type === 'list-single') {
|
||||
field.find('option').each(function () {
|
||||
let option = $('<option/>')
|
||||
option.text($(this).attr('label'))
|
||||
option.val($(this).find('value').text())
|
||||
input.append(option)
|
||||
})
|
||||
}
|
||||
|
||||
/* Add/select default values */
|
||||
field.children('value').each(function () {
|
||||
let value = $(this).text()
|
||||
if ((type === 'text-multi') || (type === 'jid-multi')) {
|
||||
input.text(input.text() + value + '\n') /* .append() would work, but doesn't escape */
|
||||
} else if (type === 'list-multi') {
|
||||
input.children(`option[value="${value}"]`).each(function () {
|
||||
$(this).attr('selected', 'selected')
|
||||
})
|
||||
} else {
|
||||
input.val(value)
|
||||
}
|
||||
})
|
||||
|
||||
return input
|
||||
},
|
||||
|
||||
serializeToDataform: function (form) {
|
||||
let st = $build('x', { 'xmlns': 'jabber:x:data', 'type': 'submit' })
|
||||
$(form).find('.df-item').each(function () {
|
||||
st.c('field', { 'var': $(this).attr('name') })
|
||||
if (this.nodeName.toLowerCase() === 'select' && this.multiple) {
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
if (this.options[i].selected) {
|
||||
st.c('value').t(this.options[i].text).up()
|
||||
}
|
||||
}
|
||||
} else if (this.nodeName.toLowerCase() === 'textarea') {
|
||||
let sp_value = this.value.split(/\r?\n|\r/g)
|
||||
for (let i = 0; i < sp_value.length; i++) {
|
||||
st.c('value').t(sp_value[i]).up()
|
||||
}
|
||||
} else if (this.nodeName.toLowerCase() === 'input' && this.type === 'checkbox') {
|
||||
if (this.checked) {
|
||||
st.c('value').t('1')
|
||||
} else {
|
||||
st.c('value').t('0')
|
||||
}
|
||||
} else {
|
||||
/* if this has value then */
|
||||
st.c('value').t($(this).val()).up()
|
||||
}
|
||||
st.up()
|
||||
})
|
||||
st.up()
|
||||
return st.tree()
|
||||
},
|
||||
|
||||
displayResult: function (result) {
|
||||
let self = this
|
||||
let status = $(result).find('command').attr('status')
|
||||
let kinds = { 'prev': 'Prev', 'next': 'Next', 'complete': 'Complete' }
|
||||
let actions = $(result).find('actions:first')
|
||||
|
||||
$(self.status.view).empty()
|
||||
$(result).find('command > *').each(function () {
|
||||
if ($(this).is('note')) {
|
||||
self.addNote($(this).text(), $(this).attr('type'))
|
||||
} else if ($(this).is('x[xmlns=jabber:x:data]')) {
|
||||
self.addForm(this)
|
||||
}
|
||||
})
|
||||
if (status === 'executing') {
|
||||
let controls = $(`<div class='actions'/>`)
|
||||
for (let kind in kinds) {
|
||||
let input;
|
||||
(function (type) {
|
||||
input = $(`<input type='button' disabled='disabled' class='btn' value='${kinds[type]}'/>`)
|
||||
.click(function () {
|
||||
self.executeCommand(type, (type !== 'prev') && self.serializeToDataform('form'), function (e) { self.displayResult(e) })
|
||||
}).appendTo(controls)
|
||||
})(kind)
|
||||
if (actions.find(kind).length > 0) {
|
||||
input.removeAttr('disabled')
|
||||
}
|
||||
if (actions.attr('execute') === kind) {
|
||||
input.addClass('primary')
|
||||
}
|
||||
}
|
||||
|
||||
$(`<input type='button' class='btn' value='Cancel'/>`).click(function () {
|
||||
self.cancelCommand(function (e) { self.displayResult(e) })
|
||||
}).appendTo(controls)
|
||||
$(self.status.view + ' fieldset').append(controls)
|
||||
} else {
|
||||
self.status.sessionid = null
|
||||
self.status.cmdNode = null
|
||||
self.status.readycb()
|
||||
}
|
||||
},
|
||||
|
||||
runCommand: function (item, callback) {
|
||||
this.status.cmdNode = $(item).attr('id') /* Save node of executed command */
|
||||
let cb = function (result) {
|
||||
this.status.sessionid = $(result).find('command').attr('sessionid')
|
||||
callback(result)
|
||||
}
|
||||
this.executeCommand('execute', false, cb.bind(this))
|
||||
},
|
||||
|
||||
executeCommand: function (type, childs, callback) {
|
||||
let execIQ
|
||||
if (this.status.sessionid) {
|
||||
execIQ = $iq({ type: 'set', to: this.status.queryJID, id: connection.getUniqueId() })
|
||||
.c('command', { xmlns: Strophe.NS.ADHOC, node: this.status.cmdNode, sessionid: this.status.sessionid, action: type })
|
||||
} else {
|
||||
execIQ = $iq({ type: 'set', to: this.status.queryJID, id: connection.getUniqueId() })
|
||||
.c('command', { xmlns: Strophe.NS.ADHOC, node: this.status.cmdNode, action: type })
|
||||
}
|
||||
if (childs) {
|
||||
execIQ.cnode(childs)
|
||||
}
|
||||
connection.sendIQ(execIQ, callback)
|
||||
},
|
||||
|
||||
cancelCommand: function (callback) {
|
||||
if (this.status.cmdNode == null) return
|
||||
this.executeCommand('cancel', false, callback)
|
||||
this.status.cmdNode = null
|
||||
this.status.sessionid = null
|
||||
},
|
||||
|
||||
getCommandNodes: function (callback) {
|
||||
let self = this
|
||||
let nodesIQ = $iq({ type: 'get', to: self.status.queryJID, id: connection.getUniqueId() }).c('query', { xmlns: Strophe.NS.DISCO_ITEMS, node: Strophe.NS.ADHOC })
|
||||
connection.sendIQ(nodesIQ, function (result) {
|
||||
let items = $('<ul></ul>')
|
||||
$(result).find('item').each(function () {
|
||||
let attrNode = $(this).attr('node')
|
||||
let attrName = $(this).attr('name')
|
||||
$('<li></li>').append($(`<a href='#' id='${attrNode}'>${attrName}</a>`).click(function (event) {
|
||||
self.cancelCommand(function () {})
|
||||
self.runCommand(this, function (result) { self.displayResult(result) })
|
||||
event.preventDefault()
|
||||
})).appendTo(items)
|
||||
})
|
||||
callback(items)
|
||||
})
|
||||
},
|
||||
|
||||
checkFeatures: function (jid, cb, ecb) {
|
||||
if (this.status.sessionid) {
|
||||
this.cancelCommand()
|
||||
}
|
||||
this.status.queryJID = jid
|
||||
let featureIQ = $iq({ type: 'get', to: this.status.queryJID, id: connection.getUniqueId() }).c('query', { xmlns: Strophe.NS.DISCO_INFO })
|
||||
$(this.status.view).empty()
|
||||
|
||||
function callback (result) {
|
||||
if ($(result).find(`feature[var='${trophe.NS.ADHOC}']`).length > 0) {
|
||||
cb(result)
|
||||
} else {
|
||||
ecb(result)
|
||||
}
|
||||
}
|
||||
|
||||
connection.sendIQ(featureIQ, callback, ecb)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
const BOSH_SERVICE = '/http-bind/'
|
||||
let show_log = false
|
||||
|
||||
Strophe.addNamespace('C2SSTREAM', 'http://prosody.im/streams/c2s')
|
||||
Strophe.addNamespace('S2SSTREAM', 'http://prosody.im/streams/s2s')
|
||||
Strophe.addNamespace('ADMINSUB', 'http://prosody.im/adminsub')
|
||||
Strophe.addNamespace('CAPS', 'http://jabber.org/protocol/caps')
|
||||
|
||||
let localJID = null
|
||||
let connection = null
|
||||
|
||||
let adminsubHost = null
|
||||
let adhocControl = new Adhoc('#adhocDisplay', function () {})
|
||||
|
||||
function log (msg) {
|
||||
let entry = $('<div></div>').append(document.createTextNode(msg))
|
||||
$('#log').append(entry)
|
||||
}
|
||||
|
||||
function rawInput (data) {
|
||||
log('RECV: ' + data)
|
||||
}
|
||||
|
||||
function rawOutput (data) {
|
||||
log('SENT: ' + data)
|
||||
}
|
||||
|
||||
function _cbNewS2S (e) {
|
||||
let items = e.getElementsByTagName('item')
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i]
|
||||
let id = item.attributes.getNamedItem('id').value
|
||||
let jid = item.getElementsByTagName('session')[0].attributes.getNamedItem('jid').value
|
||||
let infos = item.getElementsByTagName('info')
|
||||
|
||||
let entry = $(`<li id="${id}">${jid}</li>`)
|
||||
let tmp = item.getElementsByTagName('encrypted')[0]
|
||||
if (tmp) {
|
||||
if (tmp.getElementsByTagName('valid')[0]) {
|
||||
entry.append('<img src="images/secure.png" title="encrypted (certificate valid)" alt=" (secure) (encrypted)" />')
|
||||
} else {
|
||||
entry.append('<img src="images/encrypted.png" title="encrypted (certificate invalid)" alt=" (encrypted)" />')
|
||||
}
|
||||
}
|
||||
if (item.getElementsByTagName('compressed')[0]) {
|
||||
entry.append('<img src="images/compressed.png" title="compressed" alt=" (compressed)" />')
|
||||
}
|
||||
let metadata = $('<ul/>').css('display', 'none')
|
||||
entry.on('click', function () {
|
||||
$(this).find('ul').slideToggle()
|
||||
})
|
||||
metadata.appendTo(entry)
|
||||
for (let j = 0; j < infos.length; j++) {
|
||||
let info = infos[j]
|
||||
let infoName = info.attributes.getNamedItem('name').value
|
||||
let infoText = info.textContent
|
||||
metadata.append(`<li><b>${infoName}:</b> ${infoText}</li>`)
|
||||
}
|
||||
if (infos.length == 0) {
|
||||
metadata.append('<li>No information available</li>')
|
||||
}
|
||||
|
||||
if (items[i].getElementsByTagName('out')[0]) {
|
||||
entry.appendTo('#s2sout')
|
||||
} else {
|
||||
entry.appendTo('#s2sin')
|
||||
}
|
||||
}
|
||||
let retract = e.getElementsByTagName('retract')[0]
|
||||
if (retract) {
|
||||
let id = retract.attributes.getNamedItem('id').value
|
||||
$('#' + id).remove()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function _cbNewC2S (e) {
|
||||
let items = e.getElementsByTagName('item')
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i]
|
||||
let id = item.attributes.getNamedItem('id').value
|
||||
let jid = item.getElementsByTagName('session')[0].attributes.getNamedItem('jid').value
|
||||
let infos = item.getElementsByTagName('info')
|
||||
|
||||
let entry = $(`<li id="${id}">${jid}</li>`)
|
||||
let tmp = item.getElementsByTagName('encrypted')[0]
|
||||
if (tmp) {
|
||||
entry.append('<img src="images/encrypted.png" title="encrypted" alt=" (encrypted)" />')
|
||||
}
|
||||
if (item.getElementsByTagName('compressed')[0]) {
|
||||
entry.append('<img src="images/compressed.png" title="compressed" alt=" (compressed)" />')
|
||||
}
|
||||
let metadata = $('<ul/>').css('display', 'none')
|
||||
entry.on('click', function () {
|
||||
$(this).find('ul').slideToggle()
|
||||
})
|
||||
metadata.appendTo(entry)
|
||||
for (let j = 0; j < infos.length; j++) {
|
||||
let info = infos[j]
|
||||
metadata.append('<li><b>' + info.attributes.getNamedItem('name').value + ':</b> ' + info.textContent + '</li>')
|
||||
}
|
||||
if (infos.length == 0) {
|
||||
metadata.append('<li>No information available</li>')
|
||||
}
|
||||
entry.appendTo('#c2s')
|
||||
}
|
||||
let retract = e.getElementsByTagName('retract')[0]
|
||||
if (retract) {
|
||||
let id = retract.attributes.getNamedItem('id').value
|
||||
$('#' + id).remove()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function _cbAdminSub (e) {
|
||||
let node = e.getElementsByTagName('items')[0].attributes.getNamedItem('node').value
|
||||
if (node == Strophe.NS.C2SSTREAM) {
|
||||
_cbNewC2S(e)
|
||||
} else if (node == Strophe.NS.S2SSTREAM) {
|
||||
_cbNewS2S(e)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function onConnect (status) {
|
||||
if (status == Strophe.Status.CONNECTING) {
|
||||
log('Strophe is connecting.')
|
||||
} else if (status == Strophe.Status.CONNFAIL) {
|
||||
alert('Connection failed (Wrong host?)')
|
||||
log('Strophe failed to connect.')
|
||||
showConnect()
|
||||
} else if (status == Strophe.Status.DISCONNECTING) {
|
||||
log('Strophe is disconnecting.')
|
||||
} else if (status == Strophe.Status.DISCONNECTED) {
|
||||
log('Strophe is disconnected.')
|
||||
showConnect()
|
||||
} else if (status == Strophe.Status.AUTHFAIL) {
|
||||
alert('Wrong username and/or password')
|
||||
log('Authentication failed')
|
||||
if (connection) {
|
||||
connection.disconnect()
|
||||
}
|
||||
} else if (status == Strophe.Status.CONNECTED) {
|
||||
log('Strophe is connected.')
|
||||
connection.sendIQ($iq({ to: connection.domain, type: 'get', id: connection.getUniqueId() })
|
||||
.c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('adminfor'), function (e) {
|
||||
let domainpart = Strophe.getDomainFromJid(connection.jid)
|
||||
let items = e.getElementsByTagName('item')
|
||||
if (items.length == 0) {
|
||||
alert('You are not an administrator')
|
||||
connection.disconnect()
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let host = $(items[i]).text()
|
||||
$('<option/>').text(host).prop('selected', host == domainpart).appendTo('#host')
|
||||
}
|
||||
showDisconnect()
|
||||
adminsubHost = $('#host').val()
|
||||
adhocControl.checkFeatures(adminsubHost,
|
||||
function () {
|
||||
adhocControl.getCommandNodes(function (result) {
|
||||
$('#adhocDisplay').empty()
|
||||
$('#adhocCommands').html(result)
|
||||
})
|
||||
},
|
||||
function () {
|
||||
$('#adhocCommands').empty()
|
||||
$('#adhocDisplay').html('<p>This host does not support commands</p>')
|
||||
}
|
||||
)
|
||||
connection.addHandler(_cbAdminSub, Strophe.NS.ADMINSUB + '#event', 'message')
|
||||
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('subscribe', { node: Strophe.NS.C2SSTREAM }))
|
||||
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('subscribe', { node: Strophe.NS.S2SSTREAM }))
|
||||
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('items', { node: Strophe.NS.S2SSTREAM }), _cbNewS2S)
|
||||
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('items', { node: Strophe.NS.C2SSTREAM }), _cbNewC2S)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function showConnect () {
|
||||
$('#login').show()
|
||||
$('#menu').hide()
|
||||
$('#main').hide()
|
||||
$('#s2sin').empty()
|
||||
$('#s2sout').empty()
|
||||
$('#c2s').empty()
|
||||
$('#host').empty()
|
||||
}
|
||||
|
||||
function showDisconnect () {
|
||||
$('#s2sList').hide()
|
||||
$('#c2sList').hide()
|
||||
$('#login').hide()
|
||||
|
||||
$('#menu').show()
|
||||
$('#main').show()
|
||||
$('#adhoc').show()
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
connection = new Strophe.Connection(BOSH_SERVICE)
|
||||
if (show_log) {
|
||||
$('#log_container').show()
|
||||
connection.rawInput = rawInput
|
||||
connection.rawOutput = rawOutput
|
||||
}
|
||||
|
||||
$('#log_toggle').click(function () {
|
||||
$('#log').toggle()
|
||||
})
|
||||
|
||||
$('#cred').on('submit', function (event) {
|
||||
let button = $('#connect').get(0)
|
||||
let jid = $('#jid')
|
||||
let pass = $('#pass')
|
||||
localJID = jid.get(0).value
|
||||
|
||||
$('#log').empty()
|
||||
connection.connect(localJID, pass.get(0).value, onConnect)
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
$('#logout').click(function (event) {
|
||||
connection.disconnect()
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
$('#adhocMenu, #serverMenu, #clientMenu').click(function (event) {
|
||||
event.preventDefault()
|
||||
let tab = $(this).attr('href')
|
||||
$('#main > div').hide()
|
||||
$(tab).fadeIn('fast')
|
||||
})
|
||||
|
||||
$('#host').on('change', function (event) {
|
||||
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('unsubscribe', { node: Strophe.NS.C2SSTREAM }))
|
||||
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('unsubscribe', { node: Strophe.NS.S2SSTREAM }))
|
||||
adminsubHost = $(this).val()
|
||||
adhocControl.checkFeatures(adminsubHost,
|
||||
function () {
|
||||
adhocControl.getCommandNodes(function (result) {
|
||||
$('#adhocDisplay').empty()
|
||||
$('#adhocCommands').html(result)
|
||||
})
|
||||
},
|
||||
function () {
|
||||
$('#adhocCommands').empty()
|
||||
$('#adhocDisplay').html('<p>This host does not support commands</p>')
|
||||
})
|
||||
$('#s2sin').empty()
|
||||
$('#s2sout').empty()
|
||||
$('#c2s').empty()
|
||||
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('subscribe', { node: Strophe.NS.C2SSTREAM }))
|
||||
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('subscribe', { node: Strophe.NS.S2SSTREAM }))
|
||||
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('items', { node: Strophe.NS.S2SSTREAM }), _cbNewS2S)
|
||||
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
|
||||
.c('items', { node: Strophe.NS.C2SSTREAM }), _cbNewC2S)
|
||||
})
|
||||
})
|
||||
|
||||
window.onunload = window.onbeforeunload = function () {
|
||||
if (connection) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
-- mod_http_upload_external
|
||||
--
|
||||
-- Copyright (C) 2015-2016 Kim Alvefur
|
||||
--
|
||||
-- This file is MIT/X11 licensed.
|
||||
--
|
||||
|
||||
-- imports
|
||||
local st = require"util.stanza";
|
||||
local uuid = require"util.uuid".generate;
|
||||
local http = require "util.http";
|
||||
local dataform = require "util.dataforms".new;
|
||||
local HMAC = require "util.hashes".hmac_sha256;
|
||||
local jid = require "util.jid";
|
||||
|
||||
-- config
|
||||
local file_size_limit = module:get_option_number(module.name .. "_file_size_limit", 100 * 1024 * 1024); -- 100 MB
|
||||
local base_url = assert(module:get_option_string(module.name .. "_base_url"),
|
||||
module.name .. "_base_url is a required option");
|
||||
local secret = assert(module:get_option_string(module.name .. "_secret"),
|
||||
module.name .. "_secret is a required option");
|
||||
local access = module:get_option_set(module.name .. "_access", {});
|
||||
|
||||
local token_protocol = module:get_option_string(module.name .. "_protocol", "v1");
|
||||
|
||||
-- depends
|
||||
module:depends("disco");
|
||||
|
||||
-- namespace
|
||||
local legacy_namespace = "urn:xmpp:http:upload";
|
||||
local namespace = "urn:xmpp:http:upload:0";
|
||||
|
||||
-- identity and feature advertising
|
||||
module:add_identity("store", "file", module:get_option_string("name", "HTTP File Upload"))
|
||||
module:add_feature(namespace);
|
||||
module:add_feature(legacy_namespace);
|
||||
|
||||
module:add_extension(dataform {
|
||||
{ name = "FORM_TYPE", type = "hidden", value = namespace },
|
||||
{ name = "max-file-size", type = "text-single" },
|
||||
}:form({ ["max-file-size"] = tostring(file_size_limit) }, "result"));
|
||||
|
||||
module:add_extension(dataform {
|
||||
{ name = "FORM_TYPE", type = "hidden", value = legacy_namespace },
|
||||
{ name = "max-file-size", type = "text-single" },
|
||||
}:form({ ["max-file-size"] = tostring(file_size_limit) }, "result"));
|
||||
|
||||
local function magic_crypto_dust(random, filename, filesize, filetype)
|
||||
local param, message;
|
||||
if token_protocol == "v1" then
|
||||
param, message = "v", string.format("%s/%s %d", random, filename, filesize);
|
||||
else
|
||||
param, message = "v2", string.format("%s/%s\0%d\0%s", random, filename, filesize, filetype);
|
||||
end
|
||||
local digest = HMAC(secret, message, true);
|
||||
random, filename = http.urlencode(random), http.urlencode(filename);
|
||||
return base_url .. random .. "/" .. filename, "?"..param.."=" .. digest;
|
||||
end
|
||||
|
||||
local function handle_request(origin, stanza, xmlns, filename, filesize, filetype)
|
||||
local user_bare = jid.bare(stanza.attr.from);
|
||||
local user_host = jid.host(user_bare);
|
||||
|
||||
-- local clients or whitelisted jids/hosts only
|
||||
if not (origin.type == "c2s" or access:contains(user_bare) or access:contains(user_host)) then
|
||||
module:log("debug", "Request for upload slot from a %s", origin.type);
|
||||
origin.send(st.error_reply(stanza, "cancel", "not-authorized"));
|
||||
return nil, nil;
|
||||
end
|
||||
-- validate
|
||||
if not filename or filename:find("/") then
|
||||
module:log("debug", "Filename %q not allowed", filename or "");
|
||||
origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid filename"));
|
||||
return nil, nil;
|
||||
end
|
||||
if not filesize then
|
||||
module:log("debug", "Missing file size");
|
||||
origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing or invalid file size"));
|
||||
return nil, nil;
|
||||
elseif filesize > file_size_limit then
|
||||
module:log("debug", "File too large (%d > %d)", filesize, file_size_limit);
|
||||
origin.send(st.error_reply(stanza, "modify", "not-acceptable", "File too large",
|
||||
st.stanza("file-too-large", {xmlns=xmlns})
|
||||
:tag("max-size"):text(tostring(file_size_limit))));
|
||||
return nil, nil;
|
||||
end
|
||||
local random = uuid();
|
||||
local get_url, verify = magic_crypto_dust(random, filename, filesize, filetype);
|
||||
local put_url = get_url .. verify;
|
||||
|
||||
module:log("debug", "Handing out upload slot %s to %s@%s [%d %s]", get_url, origin.username, origin.host, filesize, filetype);
|
||||
|
||||
return get_url, put_url;
|
||||
end
|
||||
|
||||
-- hooks
|
||||
module:hook("iq/host/"..legacy_namespace..":request", function (event)
|
||||
local stanza, origin = event.stanza, event.origin;
|
||||
local request = stanza.tags[1];
|
||||
local filename = request:get_child_text("filename");
|
||||
local filesize = tonumber(request:get_child_text("size"));
|
||||
local filetype = request:get_child_text("content-type") or "application/octet-stream";
|
||||
|
||||
local get_url, put_url = handle_request(
|
||||
origin, stanza, legacy_namespace, filename, filesize, filetype);
|
||||
|
||||
if not get_url then
|
||||
-- error was already sent
|
||||
return true;
|
||||
end
|
||||
|
||||
local reply = st.reply(stanza)
|
||||
:tag("slot", { xmlns = legacy_namespace })
|
||||
:tag("get"):text(get_url):up()
|
||||
:tag("put"):text(put_url):up()
|
||||
:up();
|
||||
origin.send(reply);
|
||||
return true;
|
||||
end);
|
||||
|
||||
module:hook("iq/host/"..namespace..":request", function (event)
|
||||
local stanza, origin = event.stanza, event.origin;
|
||||
local request = stanza.tags[1];
|
||||
local filename = request.attr.filename;
|
||||
local filesize = tonumber(request.attr.size);
|
||||
local filetype = request.attr["content-type"] or "application/octet-stream";
|
||||
|
||||
local get_url, put_url = handle_request(
|
||||
origin, stanza, namespace, filename, filesize, filetype);
|
||||
|
||||
if not get_url then
|
||||
-- error was already sent
|
||||
return true;
|
||||
end
|
||||
|
||||
local reply = st.reply(stanza)
|
||||
:tag("slot", { xmlns = namespace})
|
||||
:tag("get", { url = get_url }):up()
|
||||
:tag("put", { url = put_url }):up()
|
||||
:up();
|
||||
origin.send(reply);
|
||||
return true;
|
||||
end);
|
||||
@@ -0,0 +1,174 @@
|
||||
-- mod_muc_moderation
|
||||
--
|
||||
-- Copyright (C) 2015-2021 Kim Alvefur
|
||||
--
|
||||
-- This file is MIT licensed.
|
||||
--
|
||||
-- Implements: XEP-0425: Message Moderation
|
||||
--
|
||||
-- Imports
|
||||
local dt = require "util.datetime";
|
||||
local id = require "util.id";
|
||||
local jid = require "util.jid";
|
||||
local st = require "util.stanza";
|
||||
|
||||
-- Plugin dependencies
|
||||
local mod_muc = module:depends "muc";
|
||||
|
||||
local muc_util = module:require "muc/util";
|
||||
local valid_roles = muc_util.valid_roles;
|
||||
|
||||
local muc_log_archive = module:open_store("muc_log", "archive");
|
||||
|
||||
if not muc_log_archive.set then
|
||||
module:log("warn", "Selected archive storage module does not support message replacement, no tombstones will be saved");
|
||||
end
|
||||
|
||||
-- Namespaces
|
||||
local xmlns_fasten = "urn:xmpp:fasten:0";
|
||||
local xmlns_moderate = "urn:xmpp:message-moderate:0";
|
||||
local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
|
||||
local xmlns_retract = "urn:xmpp:message-retract:0";
|
||||
|
||||
-- Discovering support
|
||||
module:hook("muc-disco#info", function (event)
|
||||
event.reply:tag("feature", { var = xmlns_moderate }):up();
|
||||
end);
|
||||
|
||||
-- TODO error registry, requires Prosody 0.12+
|
||||
|
||||
-- moderate : function (string, string, string, boolean, string) : boolean, enum, enum, string
|
||||
local function moderate(actor, room_jid, stanza_id, retract, reason)
|
||||
local room_node = jid.split(room_jid);
|
||||
local room = mod_muc.get_room_from_jid(room_jid);
|
||||
|
||||
-- Permissions is based on role, which is a property of a current occupant,
|
||||
-- so check if the actor is an occupant, otherwise if they have a reserved
|
||||
-- nickname that can be used to retrieve the role.
|
||||
local actor_nick = room:get_occupant_jid(actor);
|
||||
if not actor_nick then
|
||||
local reserved_nickname = room:get_affiliation_data(jid.bare(actor), "reserved_nickname");
|
||||
if reserved_nickname then
|
||||
actor_nick = room.jid .. "/" .. reserved_nickname;
|
||||
end
|
||||
end
|
||||
|
||||
-- Retrieve their current role, iff they are in the room, otherwise what they
|
||||
-- would have based on affiliation.
|
||||
local affiliation = room:get_affiliation(actor);
|
||||
local role = room:get_role(actor_nick) or room:get_default_role(affiliation);
|
||||
if valid_roles[role or "none"] < valid_roles.moderator then
|
||||
return false, "auth", "forbidden", "You need a role of at least 'moderator'";
|
||||
end
|
||||
|
||||
-- Original stanza to base tombstone on
|
||||
local original, err;
|
||||
if muc_log_archive.get then
|
||||
original, err = muc_log_archive:get(room_node, stanza_id);
|
||||
else
|
||||
-- COMPAT missing :get API
|
||||
err = "item-not-found";
|
||||
for i, item in muc_log_archive:find(room_node, { key = stanza_id, limit = 1 }) do
|
||||
if i == stanza_id then
|
||||
original, err = item, nil;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not original then
|
||||
if err == "item-not-found" then
|
||||
return false, "modify", "item-not-found";
|
||||
else
|
||||
return false, "wait", "internal-server-error";
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local announcement = st.message({ from = room_jid, type = "groupchat", id = id.medium(), })
|
||||
:tag("apply-to", { xmlns = xmlns_fasten, id = stanza_id })
|
||||
:tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
|
||||
|
||||
if retract then
|
||||
announcement:tag("retract", { xmlns = xmlns_retract }):up();
|
||||
end
|
||||
|
||||
if reason then
|
||||
announcement:text_tag("reason", reason);
|
||||
end
|
||||
|
||||
local moderated_occupant_id = original:get_child("occupant-id", xmlns_occupant_id);
|
||||
if room.get_occupant_id and moderated_occupant_id then
|
||||
announcement:add_direct_child(moderated_occupant_id);
|
||||
end
|
||||
|
||||
local actor_occupant = room:get_occupant_by_real_jid(actor) or room:new_occupant(jid.bare(actor), actor_nick);
|
||||
if room.get_occupant_id then
|
||||
-- This isn't a regular broadcast message going through the events occupant_id.lib hooks so we do this here
|
||||
announcement:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }))
|
||||
end
|
||||
|
||||
if muc_log_archive.set and retract then
|
||||
local tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id })
|
||||
:tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
|
||||
:tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up();
|
||||
|
||||
if room.get_occupant_id then
|
||||
tombstone:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }))
|
||||
|
||||
if moderated_occupant_id then
|
||||
-- Copy occupant id from moderated message
|
||||
tombstone:add_child(moderated_occupant_id);
|
||||
end
|
||||
end
|
||||
|
||||
if reason then
|
||||
tombstone:text_tag("reason", reason);
|
||||
end
|
||||
tombstone:reset();
|
||||
|
||||
local was_replaced = muc_log_archive:set(room_node, stanza_id, tombstone);
|
||||
if not was_replaced then
|
||||
return false, "wait", "internal-server-error";
|
||||
end
|
||||
end
|
||||
|
||||
-- Done, tell people about it
|
||||
module:log("info", "Message with id '%s' in room %s moderated by %s, reason: %s", stanza_id, room_jid, actor, reason);
|
||||
room:broadcast_message(announcement);
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
-- Main handling
|
||||
module:hook("iq-set/bare/" .. xmlns_fasten .. ":apply-to", function (event)
|
||||
local stanza, origin = event.stanza, event.origin;
|
||||
|
||||
local actor = stanza.attr.from;
|
||||
local room_jid = stanza.attr.to;
|
||||
|
||||
-- Collect info we need
|
||||
local apply_to = stanza.tags[1];
|
||||
local moderate_tag = apply_to:get_child("moderate", xmlns_moderate);
|
||||
if not moderate_tag then return end -- some other kind of fastening?
|
||||
|
||||
local reason = moderate_tag:get_child_text("reason");
|
||||
local retract = moderate_tag:get_child("retract", xmlns_retract);
|
||||
|
||||
local stanza_id = apply_to.attr.id;
|
||||
|
||||
local ok, error_type, error_condition, error_text = moderate(actor, room_jid, stanza_id, retract, reason);
|
||||
if not ok then
|
||||
origin.send(st.error_reply(stanza, error_type, error_condition, error_text));
|
||||
return true;
|
||||
end
|
||||
|
||||
origin.send(st.reply(stanza));
|
||||
return true;
|
||||
end);
|
||||
|
||||
module:hook("muc-message-is-historic", function (event)
|
||||
-- Ensure moderation messages are stored
|
||||
if event.stanza.attr.from == event.room.jid then
|
||||
return event.stanza:get_child("apply-to", xmlns_fasten);
|
||||
end
|
||||
end, 1);
|
||||
@@ -0,0 +1,114 @@
|
||||
-- Prosody IM
|
||||
-- Copyright (C) 2008-2010 Matthew Wild
|
||||
-- Copyright (C) 2008-2010 Waqas Hussain
|
||||
-- Copyright (C) 2018 Michel Le Bihan
|
||||
--
|
||||
-- This project is MIT/X11 licensed. Please see the
|
||||
-- COPYING file in the source package for more information.
|
||||
--
|
||||
|
||||
local st = require "util.stanza"
|
||||
local jid_split = require "util.jid".split;
|
||||
local base64 = require"util.encodings".base64;
|
||||
local sha1 = require"util.hashes".sha1;
|
||||
|
||||
local mod_muc = module:depends"muc";
|
||||
|
||||
local vcards = module:open_store();
|
||||
|
||||
module:add_feature("vcard-temp");
|
||||
|
||||
local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or
|
||||
function (jid)
|
||||
local rooms = rawget(mod_muc, "rooms");
|
||||
return rooms[jid];
|
||||
end
|
||||
|
||||
local function get_photo_hash(room)
|
||||
local room_node = jid_split(room.jid);
|
||||
local vcard = st.deserialize(vcards:get(room_node));
|
||||
if vcard then
|
||||
local photo = vcard:get_child("PHOTO");
|
||||
|
||||
if photo then
|
||||
local photo_b64 = photo:get_child_text("BINVAL");
|
||||
local photo_raw = photo_b64 and base64.decode(photo_b64);
|
||||
return sha1(photo_raw, true);
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
local function broadcast_presence(room, to)
|
||||
local photo_hash = get_photo_hash(room);
|
||||
local presence_vcard = st.presence({to = to, from = room.jid})
|
||||
:tag("x", { xmlns = "vcard-temp:x:update" })
|
||||
:tag("photo"):text(photo_hash):up();
|
||||
|
||||
if to == nil then
|
||||
room:broadcast_message(presence_vcard);
|
||||
else
|
||||
module:send(presence_vcard);
|
||||
end
|
||||
end
|
||||
|
||||
local function handle_vcard(event)
|
||||
local session, stanza = event.origin, event.stanza;
|
||||
|
||||
local room_jid = stanza.attr.to;
|
||||
local room_node = jid_split(room_jid);
|
||||
local room = get_room_from_jid(room_jid);
|
||||
if not room then
|
||||
session.send(st.error_reply(stanza, "cancel", "item-not-found"))
|
||||
return true;
|
||||
end
|
||||
|
||||
local from = stanza.attr.from;
|
||||
local from_affiliation = room:get_affiliation(from);
|
||||
|
||||
if stanza.attr.type == "get" then
|
||||
local vCard;
|
||||
vCard = st.deserialize(vcards:get(room_node));
|
||||
|
||||
if vCard then
|
||||
session.send(st.reply(stanza):add_child(vCard)); -- send vCard!
|
||||
else
|
||||
session.send(st.error_reply(stanza, "cancel", "item-not-found"));
|
||||
end
|
||||
else
|
||||
if from_affiliation == "owner" or (module.may and module:may("muc:automatic-ownership", from)) then
|
||||
if vcards:set(room_node, st.preserialize(stanza.tags[1])) then
|
||||
session.send(st.reply(stanza):tag("vCard", { xmlns = "vcard-temp" }));
|
||||
broadcast_presence(room, nil)
|
||||
|
||||
room:broadcast_message(st.message({ from = room.jid, type = "groupchat" })
|
||||
:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" })
|
||||
:tag("status", { code = "104" }));
|
||||
else
|
||||
-- TODO unable to write file, file may be locked, etc, what's the correct error?
|
||||
session.send(st.error_reply(stanza, "wait", "internal-server-error"));
|
||||
end
|
||||
else
|
||||
session.send(st.error_reply(stanza, "auth", "forbidden"));
|
||||
end
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
|
||||
module:hook("iq/bare/vcard-temp:vCard", handle_vcard);
|
||||
module:hook("iq/host/vcard-temp:vCard", handle_vcard);
|
||||
|
||||
module:hook("muc-disco#info", function(event)
|
||||
event.reply:tag("feature", { var = "vcard-temp" }):up();
|
||||
|
||||
table.insert(event.form, {
|
||||
name = "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1",
|
||||
type = "text-single",
|
||||
});
|
||||
event.formdata["{http://modules.prosody.im/mod_vcard_muc}avatar#sha1"] = get_photo_hash(event.room);
|
||||
end);
|
||||
|
||||
module:hook("muc-occupant-session-new", function(event)
|
||||
broadcast_presence(event.room, event.jid);
|
||||
end)
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Проверка OMEMO-связки на сервере nixg.ru (XEP-0384, PEP).
|
||||
|
||||
Что делает:
|
||||
- логинится admin@nixg.ru по WebSocket (wss://xmpp.nixg.ru/xmpp-websocket);
|
||||
- читает OMEMO devicelist (eu.siacs.conversations.axolotl.devicelist);
|
||||
- по желанию публикует новое устройство (--publish <id>);
|
||||
- по желанию чистит список (--clean, оставляет только реальные устройства).
|
||||
|
||||
Использование:
|
||||
PASSWORD=... /opt/icq/.venv/bin/python /opt/icq/scripts/omemo_check.py [--publish 7777] [--clean]
|
||||
JID=admin@nixg.ru (по умолчанию) — можно переопределить env JID.
|
||||
|
||||
Зависимости: venv /opt/icq/.venv (slixmpp 1.17).
|
||||
Служебные находки (проверено 2026-08-29):
|
||||
- slixmpp 1.17: connect((url,)) сам управляет циклом, process() НЕТ;
|
||||
- плагины регистрировать явно: register_plugin('xep_0060');
|
||||
- publish payload = XML-элемент (slixmpp.xmlstream.ET), не строка;
|
||||
- get_items через WS иногда возвращает пусто — смотреть запись на диске
|
||||
data/nixg%2eru/pep_eu%2esiacs%2econversations%2eaxolotl%2edevicelist/admin.list
|
||||
"""
|
||||
import asyncio, os, ssl, sys
|
||||
sys.path.insert(0, '/opt/icq/.venv/lib/python3.12/site-packages')
|
||||
import slixmpp
|
||||
from slixmpp.exceptions import IqError, IqTimeout
|
||||
from slixmpp.xmlstream import ET
|
||||
|
||||
JID = os.environ.get('JID', 'admin@nixg.ru')
|
||||
PASSWORD = os.environ.get('PASSWORD', '')
|
||||
if not PASSWORD:
|
||||
print('ОШИБКА: задайте PASSWORD (env)'); sys.exit(2)
|
||||
|
||||
WS_URL = 'wss://xmpp.nixg.ru/xmpp-websocket'
|
||||
NS_DEVICELIST = 'eu.siacs.conversations.axolotl.devicelist'
|
||||
NS_OMEMO = 'eu.siacs.conversations.axolotl'
|
||||
|
||||
ctx = ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
|
||||
|
||||
|
||||
class OmemoCheck(slixmpp.ClientXMPP):
|
||||
def __init__(self, do_publish=None, do_clean=False):
|
||||
super().__init__(JID, PASSWORD)
|
||||
self.register_plugin('xep_0060')
|
||||
self.do_publish = do_publish
|
||||
self.do_clean = do_clean
|
||||
self.add_event_handler('session_start', self.go)
|
||||
|
||||
async def read_devicelist(self):
|
||||
try:
|
||||
iq = await self['xep_0060'].get_items(jid=JID, node=NS_DEVICELIST, max_items='')
|
||||
its = iq['pubsub']['items']['item']
|
||||
ids = []
|
||||
for it in (its or []):
|
||||
for dev in it.xml.iter('{%s}device' % NS_OMEMO):
|
||||
ids.append(dev.get('id'))
|
||||
print('[read] devicelist:', ids or '(пусто — смотри на диске, см. доки)')
|
||||
return ids
|
||||
except IqError as ex:
|
||||
print('[read] IqError:', ex.iq['error']['condition']); return None
|
||||
except Exception as ex:
|
||||
print('[read]', type(ex).__name__, ex); return None
|
||||
|
||||
async def go(self, event):
|
||||
self.send_presence(); await self.get_roster()
|
||||
print('[session] OK')
|
||||
ids = await self.read_devicelist()
|
||||
|
||||
if self.do_publish:
|
||||
lst = ET.Element('{%s}list' % NS_OMEMO)
|
||||
for d in (ids or []):
|
||||
ET.SubElement(lst, '{%s}device' % NS_OMEMO, {'id': d})
|
||||
ET.SubElement(lst, '{%s}device' % NS_OMEMO, {'id': self.do_publish})
|
||||
try:
|
||||
await self['xep_0060'].publish(jid=JID, node=NS_DEVICELIST, id='current', payload=lst)
|
||||
print('[publish]', self.do_publish, 'ACCEPTED')
|
||||
except IqError as ex:
|
||||
print('[publish] IqError:', ex.iq['error']['condition'])
|
||||
except IqTimeout:
|
||||
print('[publish] timeout')
|
||||
except Exception as ex:
|
||||
print('[publish]', type(ex).__name__, ex)
|
||||
|
||||
if self.do_clean:
|
||||
keep = [d for d in (ids or []) if d != self.do_publish] if self.do_publish else (ids or [])
|
||||
# без явного списка чистить неоткуда — просто ре-публикуем текущий
|
||||
lst = ET.Element('{%s}list' % NS_OMEMO)
|
||||
for d in keep:
|
||||
ET.SubElement(lst, '{%s}device' % NS_OMEMO, {'id': d})
|
||||
try:
|
||||
await self['xep_0060'].publish(jid=JID, node=NS_DEVICELIST, id='current', payload=lst)
|
||||
print('[clean] devicelist ->', keep, 'ACCEPTED')
|
||||
except Exception as ex:
|
||||
print('[clean]', type(ex).__name__, ex)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
self.disconnect()
|
||||
|
||||
|
||||
async def main():
|
||||
do_publish = None
|
||||
do_clean = False
|
||||
args = sys.argv[1:]
|
||||
if '--publish' in args:
|
||||
do_publish = args[args.index('--publish') + 1]
|
||||
if '--clean' in args:
|
||||
do_clean = True
|
||||
|
||||
bot = OmemoCheck(do_publish=do_publish, do_clean=do_clean)
|
||||
bot.ssl_context = ctx
|
||||
try:
|
||||
fut = bot.connect((WS_URL,))
|
||||
await asyncio.wait_for(fut, timeout=25)
|
||||
await asyncio.sleep(8)
|
||||
try: bot.disconnect()
|
||||
except Exception: pass
|
||||
except Exception as ex:
|
||||
print('connect error:', ex)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -34,6 +34,10 @@
|
||||
i18n: 'ru',
|
||||
// Показать форму входа сразу
|
||||
show_controlbox_by_default: true,
|
||||
// OMEMO (XEP-0384): шифрование по умолчанию, когда контакт поддерживает.
|
||||
// Ключи генерируются автоматически; devicelist+bundles публикуются в PEP сервера.
|
||||
// (OMEMO встроен в Converse v14 — libomemo; серверных модулей не требует.)
|
||||
omemo_default: true,
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user