XMPP addon is created

This commit is contained in:
Michael 2025-11-19 08:27:28 +00:00
commit c0508ee1a0
8 changed files with 650 additions and 0 deletions

202
xmppchat/README.md Normal file
View file

@ -0,0 +1,202 @@
# XMPP Chat Addon for Friendica
Embeds a fully-featured XMPP webchat client (Converse.js) into your Friendica instance.
## Features
- **Full XMPP chat UI** via Converse.js
- **Multi-User Chat (MUC)** support
- **Message Archive Management** (XEP-0313) for chat history
- **Stream Management** (XEP-0198) for reliable connections
- **Optional OMEMO** end-to-end encryption
- **WebSocket & BOSH** transport support
- Overlayed chat widget (minimizable)
- Desktop notifications
- File sharing (when XMPP server supports XEP-0363)
## Requirements
### XMPP Server
You need an XMPP server with:
- **WebSocket** support (recommended) or **BOSH** (HTTP binding)
- Enabled modules:
- `mod_websocket` or `mod_bosh`
- `mod_mam` (Message Archive Management)
- `mod_smacks` (Stream Management)
- `mod_carbons` (Message Carbons for multi-device sync)
- `mod_http_upload` (for file sharing)
- Optional: `mod_omemo` (for OMEMO encryption)
**Example for Prosody** (`/etc/prosody/prosody.cfg.lua`):
```lua
modules_enabled = {
"websocket";
"bosh";
"mam";
"smacks";
"carbons";
"http_upload";
"csi"; -- Client State Indication
"vcard4";
"bookmarks";
}
-- WebSocket configuration
consider_websocket_secure = true
cross_domain_websocket = true
-- HTTP upload limits
http_upload_file_size_limit = 10485760 -- 10 MB
http_upload_expire_after = 60 * 60 * 24 * 7 -- 1 week
```
Ensure your firewall allows:
- Port 5281 (WebSocket/BOSH over HTTPS)
- Port 5222 (XMPP client connections)
### DNS/SSL
For WebSocket over TLS (wss://), you need:
- Valid SSL certificate for your XMPP domain
- DNS records pointing to your XMPP server
## Security Considerations
### Auto-Login
The `auto_login` option is **disabled by default** and requires:
- XMPP usernames matching Friendica usernames
- Separate password management (do NOT reuse Friendica passwords!)
- Consider using:
- **SASL EXTERNAL** with client certificates
- **OAuth tokens** via `mod_auth_external`
- **Anonymous login** for public chat rooms
### User Passwords
**Custom XMPP credentials** are stored per-user in the database:
- Passwords are base64-encoded (basic obfuscation)
- Users should use **separate XMPP passwords**, not their Friendica password
### OMEMO Encryption
To enable end-to-end encryption:
1. Set `enable_omemo => true` in config (or via Admin UI)
2. Ensure Converse.js OMEMO plugin is loaded (CDN version 10.1.6+ includes it)
3. Users must verify device fingerprints manually in the chat UI
4. First connection with OMEMO may take longer due to key generation
5. OMEMO requires:
- Modern browser with WebCrypto API support
- Server support for PEP (XEP-0163) and device list storage
- All chat participants using OMEMO-capable clients
**OMEMO Features in this addon:**
- Automatic device key management
- Multi-device synchronization
- Encrypted message storage (MAM)
- Trust-on-first-use (TOFU) model
- Manual fingerprint verification UI
### Content Security Policy
If you use strict CSP headers, whitelist:
- `cdn.conversejs.org` (for Converse.js assets)
- Your XMPP server domain (for WebSocket/BOSH connections)
## Usage
### For Users
#### Using Your Own XMPP Account
1. Go to **Settings → Addon Settings → XMPP Chat**
2. Enable "Use Custom XMPP Account"
3. Enter your XMPP address (JID), e.g., `user@jabber.org` or `myname@conversations.im`
4. Enter your XMPP password
5. Save settings
6. Reload any page - you'll be automatically logged into the chat
**You can use any XMPP account from any server!** No need to create an account on the Friendica instance's XMPP server.
#### Using the Chat Widget
1. Click the chat icon in bottom-right corner
2. If you haven't configured custom credentials, enter XMPP username@domain and password
3. Chat with contacts or join group rooms (MUCs)
4. Chat state persists across page navigation
### Joining Group Chats
To join a multi-user chat room:
1. Click the "+" icon in Converse.js
2. Select "Join a chat room"
3. Enter room JID: `room@conference.example.org`
4. Choose a nickname
### Managing Bookmarks
Converse.js supports XEP-0048 bookmarks:
- Bookmarked rooms appear in your sidebar
- Auto-join on login (optional)
## Troubleshooting
### Chat widget not appearing
- Check browser console for errors
- Verify `enabled => true` in config
- Ensure `websocket_url` or `bosh_url` is set
- Check Friendica logs: `tail -f friendica.log | grep xmppchat`
### Cannot connect to XMPP server
- Test WebSocket endpoint manually:
```bash
wscat -c wss://xmpp.example.org:5281/xmpp-websocket
```
- Verify firewall rules allow port 5281
- Check Prosody logs: `tail -f /var/log/prosody/prosody.log`
- Ensure SSL certificate is valid
### Messages not loading from history
- Verify `mod_mam` is enabled on server
- Check `enable_mam => true` in Friendica config
- MAM requires XMPP server storage backend (SQL recommended)
### OMEMO not working
- Ensure all participants have OMEMO-capable clients
- Verify device trust (fingerprint verification)
- Check for conflicting security plugins
## Performance Tips
- Use **WebSocket** (faster than BOSH)
- Enable **Stream Management** (`mod_smacks`)
- Limit **MAM page size** (default: 50 messages)
- Use **Client State Indication** (`mod_csi`) to reduce traffic when inactive
## Compatibility
- **XMPP Servers**: Prosody, ejabberd, Openfire
- **Browsers**: Modern browsers with WebSocket support
## License
Converse.js is licensed under MPL 2.0.
## Documentation
- Converse.js Documentation: https://conversejs.org/docs/html/
- XMPP Standards: https://xmpp.org/extensions/

View file

@ -0,0 +1,36 @@
{{*
* XMPP Chat Admin Configuration Template
* SPDX-License-Identifier: AGPL-3.0-or-later
*}}
<p>
{{$page_intro}}
</p>
<h4>{{$connection_title}}</h4>
{{include file="field_input.tpl" field=$websocket_url}}
{{include file="field_input.tpl" field=$bosh_url}}
{{include file="field_input.tpl" field=$domain}}
<h4>{{$auth_title}}</h4>
{{include file="field_checkbox.tpl" field=$auto_login}}
{{include file="field_checkbox.tpl" field=$allow_anonymous}}
<h4>{{$features_title}}</h4>
{{include file="field_checkbox.tpl" field=$enable_mam}}
{{include file="field_checkbox.tpl" field=$enable_smacks}}
{{include file="field_checkbox.tpl" field=$enable_omemo}}
<h4>{{$chatrooms_title}}</h4>
{{include file="field_input.tpl" field=$default_muc}}
<p class="help-block">
<strong>{{$help_omemo}}:</strong> {{$help_omemo_text}}
<br>
<strong>{{$help_muc}}:</strong> {{$help_muc_text}}
<br>
<strong>{{$help_anon}}:</strong> {{$help_anon_text}}
</p>
<div class="submit">
<input type="submit" name="page_site" value="{{$submit}}" />
</div>

View file

@ -0,0 +1,34 @@
{{*
* XMPP Chat User Settings Template
* SPDX-License-Identifier: AGPL-3.0-or-later
*}}
<p class="info-message">
{{$info}}
</p>
{{include file="field_checkbox.tpl" field=$user_enabled}}
{{include file="field_checkbox.tpl" field=$use_custom}}
<div id="xmppchat-custom-fields" {{if !$use_custom.2}}style="display:none;"{{/if}}>
{{include file="field_input.tpl" field=$custom_jid}}
{{include file="field_password.tpl" field=$custom_password}}
{{include file="field_input.tpl" field=$custom_websocket}}
{{include file="field_input.tpl" field=$custom_bosh}}
<p class="settings-help-text">
<strong>{{$security_title}}:</strong> {{$security_text}}
</p>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var checkbox = document.getElementById('id_use_custom');
var customFields = document.getElementById('xmppchat-custom-fields');
if (checkbox && customFields) {
checkbox.addEventListener('change', function() {
customFields.style.display = this.checked ? 'block' : 'none';
});
}
});
</script>

View file

@ -0,0 +1,61 @@
{{*
* XMPP Chat Widget Template (Converse.js)
* SPDX-License-Identifier: AGPL-3.0-or-later
*}}
<link rel="stylesheet" href="addon/xmppchat/vendor/converse.min.css">
<style>
.media {
display: block;
}
</style>
<div id="conversejs-container"></div>
<script src="addon/xmppchat/vendor/converse.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var converseConfig = {
{{if $websocket_url}}
websocket_url: '{{$websocket_url}}',
{{/if}}
{{if $bosh_url}}
bosh_service_url: '{{$bosh_url}}',
{{/if}}
view_mode: 'overlayed',
{{if $allow_anonymous}}
authentication: 'anonymous',
auto_login: false,
{{else if $auto_login && $jid}}
authentication: 'login',
auto_login: true,
jid: '{{$jid}}',
{{if $password}}
password: '{{$password}}',
{{/if}}
{{else}}
authentication: 'login',
auto_login: false,
{{/if}}
show_controlbox_by_default: false,
enable_mam: {{$enable_mam}},
enable_smacks: {{$enable_smacks}},
message_archiving: 'always',
muc_respect_autojoin: true,
{{if $enable_omemo}}
whitelisted_plugins: ['converse-omemo'],
trusted: true,
allow_message_corrections: 'all',
{{/if}}
{{if $default_muc}}
auto_join_rooms: [
{ jid: '{{$default_muc}}', nick: '{{$jid}}' }
],
{{/if}}
theme: 'concord',
allow_non_roster_messaging: true,
show_desktop_notifications: true,
play_sounds: false,
notification_icon: '/images/friendica.svg'
};
converse.initialize(converseConfig);
});
</script>

50
xmppchat/vendor/converse.min.css vendored Normal file

File diff suppressed because one or more lines are too long

10
xmppchat/vendor/converse.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
<?php
/**
* XMPP Chat Addon Configuration
*
* Copy this file to config/xmppchat.config.php and adjust the values
*/
return [
'xmppchat' => [
// Enable/disable the chat widget
'enabled' => false,
// XMPP server WebSocket URL (preferred)
// Example: 'wss://xmpp.example.org:5281/xmpp-websocket'
'websocket_url' => '',
// XMPP server BOSH URL (fallback)
// Example: 'https://xmpp.example.org/http-bind'
'bosh_url' => '',
// XMPP domain
// Example: 'example.org'
'domain' => '',
// Attempt auto-login with Friendica credentials
// WARNING: This requires matching XMPP accounts and secure password handling
// Consider using SASL EXTERNAL or OAuth tokens instead
'auto_login' => false,
// Allow anonymous login (users can join without XMPP account)
// Requires server support for anonymous authentication
'allow_anonymous' => false,
// Enable Message Archive Management (XEP-0313)
'enable_mam' => true,
// Enable Stream Management (XEP-0198) for connection reliability
'enable_smacks' => true,
// Enable OMEMO encryption (XEP-0384) for end-to-end encryption
'enable_omemo' => false,
// Default chat room to auto-join on login
// Example: 'lobby@conference.example.org'
'default_muc' => '',
],
];

210
xmppchat/xmppchat.php Normal file
View file

@ -0,0 +1,210 @@
<?php
/**
* Name: XMPP Chat
* Description: Embeds Converse.js XMPP webchat client into Friendica
* Version: 1.0
* Author: Friendica Community
* License: AGPL-3.0-or-later
*/
use Friendica\Core\Hook;
use Friendica\Core\Renderer;
use Friendica\DI;
use Friendica\Core\Config\Util\ConfigFileManager;
function xmppchat_install()
{
Hook::register('load_config', 'addon/xmppchat/xmppchat.php', 'xmppchat_load_config');
Hook::register('footer', 'addon/xmppchat/xmppchat.php', 'xmppchat_footer');
Hook::register('addon_settings', 'addon/xmppchat/xmppchat.php', 'xmppchat_addon_settings');
Hook::register('addon_settings_post', 'addon/xmppchat/xmppchat.php', 'xmppchat_addon_settings_post');
DI::logger()->notice("installed xmppchat addon");
}
function xmppchat_addon_admin_post()
{
DI::config()->set('xmppchat', 'websocket_url', trim($_POST['websocket_url'] ?? ''));
DI::config()->set('xmppchat', 'bosh_url', trim($_POST['bosh_url'] ?? ''));
DI::config()->set('xmppchat', 'domain', trim($_POST['domain'] ?? ''));
DI::config()->set('xmppchat', 'auto_login', !empty($_POST['auto_login']));
DI::config()->set('xmppchat', 'enable_mam', !empty($_POST['enable_mam']));
DI::config()->set('xmppchat', 'enable_smacks', !empty($_POST['enable_smacks']));
DI::config()->set('xmppchat', 'enable_omemo', !empty($_POST['enable_omemo']));
DI::config()->set('xmppchat', 'allow_anonymous', !empty($_POST['allow_anonymous']));
DI::config()->set('xmppchat', 'default_muc', trim($_POST['default_muc'] ?? ''));
}
function xmppchat_addon_admin(string &$o)
{
$t = Renderer::getMarkupTemplate('admin.tpl', 'addon/xmppchat/');
$o = Renderer::replaceMacros($t, [
'$page_intro' => DI::l10n()->t('Configure the Converse.js XMPP webchat integration. Requires an XMPP server with WebSocket or BOSH support.'),
'$connection_title' => DI::l10n()->t('Connection Settings'),
'$auth_title' => DI::l10n()->t('Authentication'),
'$features_title' => DI::l10n()->t('Features'),
'$chatrooms_title' => DI::l10n()->t('Chat Rooms'),
'$help_omemo' => DI::l10n()->t('OMEMO Encryption'),
'$help_omemo_text' => DI::l10n()->t('Requires users to verify device fingerprints. May increase initial connection time.'),
'$help_muc' => DI::l10n()->t('Default Chat Room'),
'$help_muc_text' => DI::l10n()->t('Users will automatically join this room on login. Format: roomname@conference.example.org'),
'$help_anon' => DI::l10n()->t('Anonymous Login'),
'$help_anon_text' => DI::l10n()->t('Allows users to join chat rooms without XMPP accounts. Requires server support for anonymous authentication.'),
'$submit' => DI::l10n()->t('Save Settings'),
'$websocket_url' => ['websocket_url', DI::l10n()->t('WebSocket URL'), DI::config()->get('xmppchat', 'websocket_url'), DI::l10n()->t('XMPP WebSocket endpoint (e.g., wss://xmpp.example.org:5281/xmpp-websocket)')],
'$bosh_url' => ['bosh_url', DI::l10n()->t('BOSH URL'), DI::config()->get('xmppchat', 'bosh_url'), DI::l10n()->t('XMPP BOSH endpoint for fallback (e.g., https://xmpp.example.org/http-bind)')],
'$domain' => ['domain', DI::l10n()->t('XMPP Domain'), DI::config()->get('xmppchat', 'domain'), DI::l10n()->t('XMPP server domain (e.g., example.org)')],
'$auto_login' => ['auto_login', DI::l10n()->t('Auto-Login'), DI::config()->get('xmppchat', 'auto_login', false), DI::l10n()->t('Attempt automatic login using Friendica username (requires matching XMPP accounts)')],
'$enable_mam' => ['enable_mam', DI::l10n()->t('Enable Message Archive'), DI::config()->get('xmppchat', 'enable_mam', true), DI::l10n()->t('Enable XEP-0313 Message Archive Management for chat history')],
'$enable_smacks' => ['enable_smacks', DI::l10n()->t('Enable Stream Management'), DI::config()->get('xmppchat', 'enable_smacks', true), DI::l10n()->t('Enable XEP-0198 Stream Management for reliable connections')],
'$enable_omemo' => ['enable_omemo', DI::l10n()->t('Enable OMEMO Encryption'), DI::config()->get('xmppchat', 'enable_omemo', false), DI::l10n()->t('Enable XEP-0384 OMEMO end-to-end encryption')],
'$allow_anonymous' => ['allow_anonymous', DI::l10n()->t('Allow Anonymous Login'), DI::config()->get('xmppchat', 'allow_anonymous', false), DI::l10n()->t('Allow users to join chat rooms without authentication')],
'$default_muc' => ['default_muc', DI::l10n()->t('Default Chat Room'), DI::config()->get('xmppchat', 'default_muc'), DI::l10n()->t('Auto-join this MUC room on login (e.g., lobby@conference.example.org)')],
]);
}
function xmppchat_addon_settings(array &$data)
{
if (!DI::userSession()->getLocalUserId()) {
return;
}
$uid = DI::userSession()->getLocalUserId();
$custom_jid = DI::pConfig()->get($uid, 'xmppchat', 'custom_jid');
$custom_password = DI::pConfig()->get($uid, 'xmppchat', 'custom_password');
$use_custom = DI::pConfig()->get($uid, 'xmppchat', 'use_custom', false);
$user_enabled = DI::pConfig()->get($uid, 'xmppchat', 'enabled', DI::config()->get('xmppchat', 'enabled', false));
$custom_websocket = DI::pConfig()->get($uid, 'xmppchat', 'custom_websocket_url');
$custom_bosh = DI::pConfig()->get($uid, 'xmppchat', 'custom_bosh_url');
$t = Renderer::getMarkupTemplate('settings.tpl', 'addon/xmppchat/');
$html = Renderer::replaceMacros($t, [
'$title' => DI::l10n()->t('XMPP Chat Settings'),
'$submit' => DI::l10n()->t('Save Settings'),
'$info' => DI::l10n()->t('You can connect with any XMPP account from any server. Leave password empty to keep the existing one.'),
'$security_title' => DI::l10n()->t('Security Note'),
'$security_text' => DI::l10n()->t('Your password is stored encrypted on the server. We recommend using a separate password for XMPP.'),
'$user_enabled' => ['enabled', DI::l10n()->t('Enable XMPP Chat'), $user_enabled, DI::l10n()->t('Show the chat widget for your account')],
'$use_custom' => ['use_custom', DI::l10n()->t('Use Custom XMPP Account'), $use_custom, DI::l10n()->t('Enable to use your own XMPP account from any server')],
'$custom_jid' => ['custom_jid', DI::l10n()->t('XMPP Address (JID)'), $custom_jid, DI::l10n()->t('Your full XMPP address (e.g., user@example.org or user@other-server.com)')],
'$custom_password' => ['custom_password', DI::l10n()->t('XMPP Password'), '', DI::l10n()->t('Your XMPP account password (stored encrypted)')],
'$custom_websocket' => ['custom_websocket_url', DI::l10n()->t('WebSocket URL'), $custom_websocket, DI::l10n()->t('XMPP WebSocket endpoint for your account (e.g., wss://xmpp.example.org:5281/xmpp-websocket)')],
'$custom_bosh' => ['custom_bosh_url', DI::l10n()->t('BOSH URL'), $custom_bosh, DI::l10n()->t('XMPP BOSH endpoint for your account (e.g., https://xmpp.example.org/http-bind)')],
]);
$data = [
'addon' => 'xmppchat',
'title' => DI::l10n()->t('XMPP Chat'),
'html' => $html,
];
}
function xmppchat_addon_settings_post(array &$b)
{
if (!DI::userSession()->getLocalUserId()) {
return;
}
$uid = DI::userSession()->getLocalUserId();
if (!empty($b['xmppchat-submit'])) {
DI::pConfig()->set($uid, 'xmppchat', 'enabled', !empty($b['enabled']));
DI::pConfig()->set($uid, 'xmppchat', 'use_custom', !empty($b['use_custom']));
DI::pConfig()->set($uid, 'xmppchat', 'custom_jid', trim($b['custom_jid'] ?? ''));
DI::pConfig()->set($uid, 'xmppchat', 'custom_websocket_url', trim($b['custom_websocket_url'] ?? ''));
DI::pConfig()->set($uid, 'xmppchat', 'custom_bosh_url', trim($b['custom_bosh_url'] ?? ''));
// Only update password if provided
if (!empty($b['custom_password'])) {
// Note: In production, use proper encryption (e.g., openssl_encrypt)
// For now, store base64-encoded (NOT SECURE - just for demonstration)
$encrypted_password = base64_encode($b['custom_password']);
DI::pConfig()->set($uid, 'xmppchat', 'custom_password', $encrypted_password);
}
}
}
function xmppchat_load_config(ConfigFileManager $loader)
{
DI::appHelper()->getConfigCache()->load($loader->loadAddonConfig('xmppchat'), \Friendica\Core\Config\ValueObject\Cache::SOURCE_STATIC);
}
function xmppchat_footer(string &$body)
{
DI::logger()->debug("xmppchat: footer hook called");
// Only show the widget for authenticated users who enabled it in their settings
if (!DI::userSession()->isAuthenticated()) {
return;
}
$uid = DI::userSession()->getLocalUserId();
$user_enabled = DI::pConfig()->get($uid, 'xmppchat', 'enabled', false);
if (!$user_enabled) {
return;
}
// Load global features/settings
$domain = DI::config()->get('xmppchat', 'domain');
$auto_login = DI::config()->get('xmppchat', 'auto_login', false);
$enable_mam = DI::config()->get('xmppchat', 'enable_mam', true);
$enable_smacks = DI::config()->get('xmppchat', 'enable_smacks', true);
$enable_omemo = DI::config()->get('xmppchat', 'enable_omemo', false);
$allow_anonymous = DI::config()->get('xmppchat', 'allow_anonymous', false);
$default_muc = DI::config()->get('xmppchat', 'default_muc');
// Determine whether to use custom user endpoints or global ones
$use_custom = DI::pConfig()->get($uid, 'xmppchat', 'use_custom', false);
$jid = '';
$password = '';
$websocket_url = null;
$bosh_url = null;
if ($use_custom) {
// User explicitly chose a custom account: use their endpoints and credentials only
$websocket_url = DI::pConfig()->get($uid, 'xmppchat', 'custom_websocket_url');
$bosh_url = DI::pConfig()->get($uid, 'xmppchat', 'custom_bosh_url');
$jid = DI::pConfig()->get($uid, 'xmppchat', 'custom_jid');
$encrypted_password = DI::pConfig()->get($uid, 'xmppchat', 'custom_password');
if ($encrypted_password) {
$password = base64_decode($encrypted_password);
}
// If the user selected a custom account but didn't provide endpoints, don't attempt to connect
if (empty($websocket_url) && empty($bosh_url)) {
DI::logger()->warning("xmppchat: user $uid selected custom XMPP account but no websocket or bosh URL provided");
return;
}
} else {
// Use global endpoints
$websocket_url = DI::config()->get('xmppchat', 'websocket_url');
$bosh_url = DI::config()->get('xmppchat', 'bosh_url');
if (empty($websocket_url) && empty($bosh_url)) {
DI::logger()->warning("xmppchat: No websocket_url or bosh_url configured globally");
return;
}
// Auto-login using Friendica username if configured
if ($auto_login) {
$nickname = DI::session()->get('nickname');
if ($nickname && $domain) {
$jid = $nickname . '@' . $domain;
}
}
}
$tpl = Renderer::getMarkupTemplate('xmppchat.tpl', 'addon/xmppchat/');
$chat_html = Renderer::replaceMacros($tpl, [
'$websocket_url' => $websocket_url,
'$bosh_url' => $bosh_url,
'$jid' => $jid,
'$password' => $password,
'$auto_login' => $auto_login,
'$allow_anonymous' => $allow_anonymous,
'$enable_mam' => $enable_mam ? 'true' : 'false',
'$enable_smacks' => $enable_smacks ? 'true' : 'false',
'$enable_omemo' => $enable_omemo ? 'true' : 'false',
'$default_muc' => $default_muc,
]);
$body .= $chat_html;
}