<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
final class Messaging
{
private const SCHEMA_VERSION = '0.1.0';
private const MAX_CIPHERTEXT_BYTES = 2_000_000;
private const MAX_HEADER_BYTES = 64_000;
public static function hooks(): void
{
add_action('rest_api_init', [self::class, 'register_routes']);
// Schema upgrades are write operations. Never run them as a side
// effect of a public page or REST request.
add_action('admin_init', [self::class, 'maybe_upgrade'], 1);
}
public static function table_name(): string
{
return self::table('messages');
}
public static function can_access_message(int $message_id, int $user_id): bool
{
global $wpdb;
return (bool) $wpdb->get_var($wpdb->prepare(
'SELECT m.id FROM ' . self::table('messages') . ' m INNER JOIN ' . self::table('message_participants') . ' p ON p.conversation_id = m.conversation_id WHERE m.id = %d AND p.user_id = %d AND p.left_at IS NULL LIMIT 1',
$message_id,
$user_id
));
}
public static function install(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$devices = self::table('message_devices');
$conversations = self::table('message_conversations');
$participants = self::table('message_participants');
$messages = self::table('messages');
dbDelta("CREATE TABLE {$devices} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
device_id varchar(80) NOT NULL,
identity_key_b64 text NOT NULL,
signed_prekey_b64 text NOT NULL,
signed_prekey_signature_b64 text NOT NULL,
prekey_bundle_json longtext NOT NULL,
created_at datetime NOT NULL,
last_seen_at datetime NOT NULL,
revoked_at datetime NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY user_device (user_id,device_id),
KEY user_active (user_id,revoked_at)
) {$charset_collate};");
dbDelta("CREATE TABLE {$conversations} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
kind varchar(20) NOT NULL DEFAULT 'direct',
created_by bigint(20) unsigned NOT NULL,
status varchar(20) NOT NULL DEFAULT 'open',
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
KEY status_updated (status,updated_at),
KEY created_by (created_by)
) {$charset_collate};");
dbDelta("CREATE TABLE {$participants} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
conversation_id bigint(20) unsigned NOT NULL,
user_id bigint(20) unsigned NOT NULL,
role varchar(20) NOT NULL DEFAULT 'member',
joined_at datetime NOT NULL,
left_at datetime NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY conversation_user (conversation_id,user_id),
KEY user_active (user_id,left_at)
) {$charset_collate};");
dbDelta("CREATE TABLE {$messages} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
conversation_id bigint(20) unsigned NOT NULL,
sender_user_id bigint(20) unsigned NOT NULL,
sender_device_id varchar(80) NOT NULL,
envelope_version varchar(32) NOT NULL,
crypto_suite varchar(64) NOT NULL,
ciphertext_b64 longtext NOT NULL,
header_b64 longtext NOT NULL,
client_message_id varchar(128) NOT NULL,
created_at datetime NOT NULL,
expires_at datetime NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY conversation_id (conversation_id,id),
UNIQUE KEY message_dedupe (conversation_id,sender_user_id,client_message_id)
) {$charset_collate};");
update_option('meinweimar_messaging_schema_version', self::SCHEMA_VERSION, false);
}
public static function maybe_upgrade(): void
{
if (!current_user_can('manage_options')) {
return;
}
$schema_incomplete = self::schema_incomplete();
// A failed read is an uncertain database state. Do not turn it into
// an install/upgrade attempt; the next healthy admin request can
// retry without touching public traffic.
if (null === $schema_incomplete) {
return;
}
if (self::SCHEMA_VERSION !== (string) get_option('meinweimar_messaging_schema_version', '') || $schema_incomplete) {
self::install();
}
}
private static function schema_incomplete(): ?bool
{
global $wpdb;
$required_columns = [
self::table('message_devices') => [
'user_id', 'device_id', 'identity_key_b64', 'signed_prekey_b64',
'signed_prekey_signature_b64', 'prekey_bundle_json', 'revoked_at',
],
self::table('message_conversations') => [
'kind', 'created_by', 'status', 'created_at', 'updated_at',
],
self::table('message_participants') => [
'conversation_id', 'user_id', 'role', 'joined_at', 'left_at',
],
self::table('messages') => [
'conversation_id', 'sender_user_id', 'sender_device_id',
'envelope_version', 'crypto_suite', 'ciphertext_b64',
'header_b64', 'client_message_id', 'created_at', 'expires_at',
],
];
foreach ($required_columns as $table => $columns_required) {
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '') {
return null;
}
if ($exists !== $table) {
return true;
}
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$columns = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($columns)) {
return null;
}
if ([] !== array_diff($columns_required, $columns)) {
return true;
}
}
return false;
}
public static function register_routes(): void
{
register_rest_route('meinweimar/v1', '/messaging/devices', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list_own_devices'],
'permission_callback' => [self::class, 'require_login'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'register_device'],
'permission_callback' => [self::class, 'require_login'],
],
]);
register_rest_route('meinweimar/v1', '/messaging/devices/(?P<device_id>[A-Za-z0-9._:-]+)', [
'methods' => WP_REST_Server::DELETABLE,
'callback' => [self::class, 'revoke_device'],
'permission_callback' => [self::class, 'require_login'],
]);
register_rest_route('meinweimar/v1', '/messaging/users/(?P<user_id>\d+)/devices', [
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list_user_devices'],
'permission_callback' => [self::class, 'require_login'],
]);
register_rest_route('meinweimar/v1', '/messaging/conversations', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list_conversations'],
'permission_callback' => [self::class, 'require_login'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'create_conversation'],
'permission_callback' => [self::class, 'require_login'],
],
]);
register_rest_route('meinweimar/v1', '/messaging/conversations/(?P<id>\d+)/messages', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list_messages'],
'permission_callback' => [self::class, 'require_login'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'store_message'],
'permission_callback' => [self::class, 'require_login'],
],
]);
}
public static function require_login(): bool|WP_Error
{
return is_user_logged_in()
? true
: new WP_Error('meinweimar_auth_required', __('Anmeldung erforderlich.', 'meinweimar-core'), ['status' => 401]);
}
public static function list_own_devices(): WP_REST_Response
{
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
'SELECT device_id, identity_key_b64, signed_prekey_b64, signed_prekey_signature_b64, prekey_bundle_json, created_at, last_seen_at FROM ' . self::table('message_devices') . ' WHERE user_id = %d AND revoked_at IS NULL ORDER BY last_seen_at DESC',
get_current_user_id()
), ARRAY_A);
return new WP_REST_Response(['devices' => array_map([self::class, 'decode_device_row'], $rows)]);
}
public static function list_user_devices(WP_REST_Request $request): WP_REST_Response
{
global $wpdb;
$user_id = absint($request['user_id']);
if (Safety::is_blocked_between(get_current_user_id(), $user_id)) {
return new WP_REST_Response(['user_id' => $user_id, 'devices' => [], 'available' => false]);
}
$rows = $wpdb->get_results($wpdb->prepare(
'SELECT device_id, identity_key_b64, signed_prekey_b64, signed_prekey_signature_b64, prekey_bundle_json, created_at, last_seen_at FROM ' . self::table('message_devices') . ' WHERE user_id = %d AND revoked_at IS NULL ORDER BY last_seen_at DESC',
$user_id
), ARRAY_A);
return new WP_REST_Response(['user_id' => $user_id, 'devices' => array_map([self::class, 'decode_device_row'], $rows)]);
}
public static function register_device(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$payload = self::payload($request);
$device_id = self::device_id($payload['device_id'] ?? '');
$identity_key = self::base64_field($payload['identity_key_b64'] ?? '', 4096);
$signed_prekey = self::base64_field($payload['signed_prekey_b64'] ?? '', 4096);
$signature = self::base64_field($payload['signed_prekey_signature_b64'] ?? '', 4096);
$bundle = $payload['prekey_bundle'] ?? null;
if (!$device_id || !$identity_key || !$signed_prekey || !$signature || !is_array($bundle) || self::contains_private_key_material($bundle)) {
return new WP_Error('meinweimar_invalid_device_bundle', __('Ein vollständiges öffentliches Geräte-/Prekey-Bundle ist erforderlich.', 'meinweimar-core'), ['status' => 400]);
}
$bundle_json = wp_json_encode($bundle, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!$bundle_json || strlen($bundle_json) > 100000) {
return new WP_Error('meinweimar_invalid_device_bundle', __('Das Geräte-Bundle ist ungültig oder zu groß.', 'meinweimar-core'), ['status' => 400]);
}
$now = current_time('mysql', true);
$sql = 'INSERT INTO ' . self::table('message_devices') . ' (user_id,device_id,identity_key_b64,signed_prekey_b64,signed_prekey_signature_b64,prekey_bundle_json,created_at,last_seen_at,revoked_at) VALUES (%d,%s,%s,%s,%s,%s,%s,%s,NULL) ON DUPLICATE KEY UPDATE identity_key_b64 = VALUES(identity_key_b64), signed_prekey_b64 = VALUES(signed_prekey_b64), signed_prekey_signature_b64 = VALUES(signed_prekey_signature_b64), prekey_bundle_json = VALUES(prekey_bundle_json), last_seen_at = VALUES(last_seen_at), revoked_at = NULL';
$result = $wpdb->query($wpdb->prepare($sql, get_current_user_id(), $device_id, $identity_key, $signed_prekey, $signature, $bundle_json, $now, $now));
if (false === $result) {
return new WP_Error('meinweimar_device_store_failed', __('Das Geräte-Bundle konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
return new WP_REST_Response(['device_id' => $device_id, 'registered' => true], 201);
}
public static function revoke_device(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$device_id = self::device_id($request['device_id'] ?? '');
if (!$device_id) {
return new WP_Error('meinweimar_invalid_device_id', __('Die Geräte-ID ist ungültig.', 'meinweimar-core'), ['status' => 400]);
}
$updated = $wpdb->query($wpdb->prepare(
'UPDATE ' . self::table('message_devices') . ' SET revoked_at = %s WHERE user_id = %d AND device_id = %s AND revoked_at IS NULL',
current_time('mysql', true),
get_current_user_id(),
$device_id
));
return new WP_REST_Response([
'device_id' => $device_id,
'revoked' => $updated > 0,
]);
}
public static function create_conversation(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$payload = self::payload($request);
$participant_ids = array_map('absint', (array) ($payload['participant_user_ids'] ?? []));
$participant_ids[] = get_current_user_id();
$participant_ids = array_values(array_unique(array_filter($participant_ids)));
if (count($participant_ids) < 2 || count($participant_ids) > 50) {
return new WP_Error('meinweimar_invalid_participants', __('Eine Unterhaltung benötigt mindestens zwei und höchstens 50 Teilnehmer.', 'meinweimar-core'), ['status' => 400]);
}
foreach ($participant_ids as $user_id) {
if (!get_userdata($user_id)) {
return new WP_Error('meinweimar_unknown_participant', __('Mindestens ein Teilnehmer existiert nicht.', 'meinweimar-core'), ['status' => 400]);
}
if ($user_id !== get_current_user_id() && Safety::is_blocked_between(get_current_user_id(), $user_id)) {
return new WP_Error('meinweimar_participant_blocked', __('Mindestens ein Teilnehmer ist für diese Unterhaltung nicht verfügbar.', 'meinweimar-core'), ['status' => 403]);
}
}
$now = current_time('mysql', true);
$kind = count($participant_ids) === 2 ? 'direct' : 'group';
$wpdb->query('START TRANSACTION');
$inserted = $wpdb->insert(self::table('message_conversations'), [
'kind' => $kind,
'created_by' => get_current_user_id(),
'status' => 'open',
'created_at' => $now,
'updated_at' => $now,
], ['%s', '%d', '%s', '%s', '%s']);
if (!$inserted) {
$wpdb->query('ROLLBACK');
return new WP_Error('meinweimar_conversation_store_failed', __('Die Unterhaltung konnte nicht angelegt werden.', 'meinweimar-core'), ['status' => 500]);
}
$conversation_id = (int) $wpdb->insert_id;
foreach ($participant_ids as $user_id) {
$participant_inserted = $wpdb->insert(self::table('message_participants'), [
'conversation_id' => $conversation_id,
'user_id' => $user_id,
'role' => $user_id === get_current_user_id() ? 'owner' : 'member',
'joined_at' => $now,
], ['%d', '%d', '%s', '%s']);
if (!$participant_inserted) {
$wpdb->query('ROLLBACK');
return new WP_Error('meinweimar_participant_store_failed', __('Die Teilnehmer konnten nicht vollständig gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
}
$wpdb->query('COMMIT');
return new WP_REST_Response(['conversation_id' => $conversation_id, 'kind' => $kind, 'participant_user_ids' => $participant_ids], 201);
}
public static function list_conversations(): WP_REST_Response
{
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
'SELECT c.id, c.kind, c.status, c.created_by, c.created_at, c.updated_at FROM ' . self::table('message_conversations') . ' c INNER JOIN ' . self::table('message_participants') . ' p ON p.conversation_id = c.id WHERE p.user_id = %d AND p.left_at IS NULL ORDER BY c.updated_at DESC LIMIT 100',
get_current_user_id()
), ARRAY_A);
return new WP_REST_Response(['conversations' => $rows]);
}
public static function list_messages(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$conversation_id = absint($request['id']);
$access = self::check_membership($conversation_id);
if (is_wp_error($access)) {
return $access;
}
global $wpdb;
$limit = max(1, min(100, absint($request->get_param('limit') ?: 50)));
$before = max(0, absint($request->get_param('before')));
$where = 'conversation_id = %d';
$params = [$conversation_id];
if ($before) {
$where .= ' AND id < %d';
$params[] = $before;
}
$params[] = $limit;
$rows = $wpdb->get_results($wpdb->prepare(
'SELECT id, conversation_id, sender_user_id, sender_device_id, envelope_version, crypto_suite, ciphertext_b64, header_b64, client_message_id, created_at FROM ' . self::table('messages') . ' WHERE ' . $where . ' ORDER BY id DESC LIMIT %d',
...$params
), ARRAY_A);
return new WP_REST_Response(['messages' => array_reverse($rows), 'encrypted_only' => true]);
}
public static function store_message(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$conversation_id = absint($request['id']);
$access = self::check_membership($conversation_id);
if (is_wp_error($access)) {
return $access;
}
$payload = self::payload($request);
$device_id = self::device_id($payload['sender_device_id'] ?? '');
$ciphertext = self::base64_field($payload['ciphertext_b64'] ?? '', self::MAX_CIPHERTEXT_BYTES);
$header = self::base64_field($payload['header_b64'] ?? '', self::MAX_HEADER_BYTES);
$client_message_id = self::opaque_text($payload['client_message_id'] ?? '', 128);
$crypto_suite = self::opaque_text($payload['crypto_suite'] ?? '', 64);
$envelope_version = self::opaque_text($payload['envelope_version'] ?? '', 32);
if (!$device_id || !$ciphertext || !$header || !$client_message_id || 'signal-protocol' !== $crypto_suite || 'mw-e2ee-envelope-v1' !== $envelope_version) {
return new WP_Error('meinweimar_invalid_envelope', __('Es wurde kein gültiger verschlüsselter Nachrichtenumschlag übergeben.', 'meinweimar-core'), ['status' => 400]);
}
if (!self::device_belongs_to_user($device_id, get_current_user_id())) {
return new WP_Error('meinweimar_unknown_sender_device', __('Das Absendergerät ist nicht für diesen Account registriert.', 'meinweimar-core'), ['status' => 403]);
}
$existing = $wpdb->get_row($wpdb->prepare(
'SELECT id, created_at FROM ' . self::table('messages') . ' WHERE conversation_id = %d AND sender_user_id = %d AND client_message_id = %s LIMIT 1',
$conversation_id,
get_current_user_id(),
$client_message_id
), ARRAY_A);
if ($existing) {
return new WP_REST_Response(['message_id' => (int) $existing['id'], 'client_message_id' => $client_message_id, 'stored' => true, 'replayed' => true], 200);
}
$now = current_time('mysql', true);
$inserted = $wpdb->insert(self::table('messages'), [
'conversation_id' => $conversation_id,
'sender_user_id' => get_current_user_id(),
'sender_device_id' => $device_id,
'envelope_version' => $envelope_version,
'crypto_suite' => $crypto_suite,
'ciphertext_b64' => $ciphertext,
'header_b64' => $header,
'client_message_id' => $client_message_id,
'created_at' => $now,
], ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
if (!$inserted) {
return new WP_Error('meinweimar_message_store_failed', __('Der verschlüsselte Nachrichtenumschlag konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
$wpdb->update(self::table('message_conversations'), ['updated_at' => $now], ['id' => $conversation_id], ['%s'], ['%d']);
return new WP_REST_Response(['message_id' => (int) $wpdb->insert_id, 'client_message_id' => $client_message_id, 'stored' => true, 'replayed' => false], 201);
}
private static function check_membership(int $conversation_id): true|WP_Error
{
global $wpdb;
$exists = $wpdb->get_var($wpdb->prepare(
'SELECT p.id FROM ' . self::table('message_participants') . ' p INNER JOIN ' . self::table('message_conversations') . ' c ON c.id = p.conversation_id WHERE p.conversation_id = %d AND p.user_id = %d AND p.left_at IS NULL AND c.status = %s LIMIT 1',
$conversation_id,
get_current_user_id(),
'open'
));
if (!$exists) {
return new WP_Error('meinweimar_conversation_forbidden', __('Kein Zugriff auf diese Unterhaltung.', 'meinweimar-core'), ['status' => 403]);
}
$participant_ids = $wpdb->get_col($wpdb->prepare(
'SELECT user_id FROM ' . self::table('message_participants') . ' WHERE conversation_id = %d AND left_at IS NULL',
$conversation_id
));
foreach ((array) $participant_ids as $participant_id) {
if (Safety::is_blocked_between(get_current_user_id(), (int) $participant_id)) {
return new WP_Error('meinweimar_conversation_blocked', __('Der Zugriff auf diese Unterhaltung ist durch eine Blockierung eingeschränkt.', 'meinweimar-core'), ['status' => 403]);
}
}
return true;
}
private static function device_belongs_to_user(string $device_id, int $user_id): bool
{
global $wpdb;
return (bool) $wpdb->get_var($wpdb->prepare(
'SELECT id FROM ' . self::table('message_devices') . ' WHERE device_id = %s AND user_id = %d AND revoked_at IS NULL LIMIT 1',
$device_id,
$user_id
));
}
private static function decode_device_row(array $row): array
{
$bundle = json_decode((string) ($row['prekey_bundle_json'] ?? ''), true);
return [
'device_id' => (string) ($row['device_id'] ?? ''),
'identity_key_b64' => (string) ($row['identity_key_b64'] ?? ''),
'signed_prekey_b64' => (string) ($row['signed_prekey_b64'] ?? ''),
'signed_prekey_signature_b64' => (string) ($row['signed_prekey_signature_b64'] ?? ''),
'prekey_bundle' => is_array($bundle) ? $bundle : [],
'created_at' => (string) ($row['created_at'] ?? ''),
'last_seen_at' => (string) ($row['last_seen_at'] ?? ''),
];
}
private static function payload(WP_REST_Request $request): array
{
$payload = $request->get_json_params();
return is_array($payload) ? $payload : $request->get_params();
}
private static function opaque_text($value, int $max_length): string
{
if (!is_scalar($value)) {
return '';
}
$value = trim((string) $value);
if (strlen($value) > $max_length || preg_match('/[\x00-\x1F\x7F]/', $value)) {
return '';
}
return $value;
}
private static function device_id($value): string
{
$value = self::opaque_text($value, 80);
return $value && preg_match('/^[A-Za-z0-9._:-]+$/D', $value) ? $value : '';
}
private static function base64_field($value, int $max_decoded_bytes): string
{
$value = self::opaque_text($value, (int) ceil($max_decoded_bytes * 1.4) + 128);
if (!$value) {
return '';
}
$decoded = base64_decode($value, true);
return false !== $decoded && strlen($decoded) <= $max_decoded_bytes ? $value : '';
}
private static function contains_private_key_material(array $value): bool
{
foreach ($value as $key => $item) {
if (preg_match('/private|secret|seed|privatekey|secretkey/i', (string) $key)) {
return true;
}
if (is_array($item) && self::contains_private_key_material($item)) {
return true;
}
}
return false;
}
private static function table(string $suffix): string
{
global $wpdb;
return $wpdb->prefix . 'mw_' . $suffix;
}
}
