<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
final class Safety
{
private const SCHEMA_VERSION = '0.1.0';
private const MAX_REPORTS_PER_HOUR = 20;
public static function hooks(): void
{
add_action('rest_api_init', [self::class, 'register_routes']);
}
public static function install(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$reports = self::table('reports');
$blocks = self::table('blocks');
dbDelta("CREATE TABLE {$reports} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
reporter_user_id bigint(20) unsigned NOT NULL,
target_type varchar(40) NOT NULL,
target_id bigint(20) unsigned NOT NULL,
reason varchar(40) NOT NULL,
note text NOT NULL,
status varchar(20) NOT NULL DEFAULT 'open',
moderator_user_id bigint(20) unsigned NULL DEFAULT NULL,
decision_note text NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
KEY target_status (target_type,target_id,status),
KEY reporter_status (reporter_user_id,status)
) {$charset_collate};");
dbDelta("CREATE TABLE {$blocks} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
blocker_user_id bigint(20) unsigned NOT NULL,
blocked_user_id bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY user_pair (blocker_user_id,blocked_user_id),
KEY blocked_user (blocked_user_id)
) {$charset_collate};");
update_option('meinweimar_safety_schema_version', self::SCHEMA_VERSION, false);
}
public static function register_routes(): void
{
register_rest_route('meinweimar/v1', '/reports', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list_reports'],
'permission_callback' => [AccountApi::class, 'require_login'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'create_report'],
'permission_callback' => [AccountApi::class, 'require_login'],
],
]);
register_rest_route('meinweimar/v1', '/reports/(?P<id>\d+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'get_report'],
'permission_callback' => [AccountApi::class, 'require_login'],
]);
register_rest_route('meinweimar/v1', '/reports/(?P<id>\d+)/decision', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'decide_report'],
'permission_callback' => [Permissions::class, 'can_review_reports'],
]);
register_rest_route('meinweimar/v1', '/blocks', [
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list_blocks'],
'permission_callback' => [AccountApi::class, 'require_login'],
]);
register_rest_route('meinweimar/v1', '/blocks/(?P<user_id>\d+)', [
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'create_block'],
'permission_callback' => [AccountApi::class, 'require_login'],
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [self::class, 'delete_block'],
'permission_callback' => [AccountApi::class, 'require_login'],
],
]);
}
public static function list_reports(): WP_REST_Response
{
global $wpdb;
$where = Permissions::can_review_reports() ? '1=1' : $wpdb->prepare('reporter_user_id = %d', get_current_user_id());
$rows = $wpdb->get_results('SELECT id, reporter_user_id, target_type, target_id, reason, note, status, moderator_user_id, decision_note, created_at, updated_at FROM ' . self::table('reports') . ' WHERE ' . $where . ' ORDER BY updated_at DESC LIMIT 200', ARRAY_A);
return new WP_REST_Response(['reports' => array_map([self::class, 'normalize_report'], $rows)]);
}
public static function create_report(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$payload = $request->get_json_params();
$payload = is_array($payload) ? $payload : $request->get_params();
$target_type = sanitize_key((string) ($payload['target_type'] ?? ''));
$target_id = absint($payload['target_id'] ?? 0);
$reason = sanitize_key((string) ($payload['reason'] ?? ''));
$note = sanitize_textarea_field((string) ($payload['note'] ?? ''));
if (!in_array($reason, ['spam', 'abuse', 'safety', 'copyright', 'privacy', 'other'], true)) {
return new WP_Error('meinweimar_invalid_report_reason', __('Der Meldegrund ist ungültig.', 'meinweimar-core'), ['status' => 400]);
}
if (!self::target_exists($target_type, $target_id)) {
return new WP_Error('meinweimar_invalid_report_target', __('Das Meldeziel wurde nicht gefunden.', 'meinweimar-core'), ['status' => 404]);
}
$reporter_id = get_current_user_id();
$recent_since = gmdate('Y-m-d H:i:s', time() - HOUR_IN_SECONDS);
$recent_count = (int) $wpdb->get_var($wpdb->prepare(
'SELECT COUNT(*) FROM ' . self::table('reports') . ' WHERE reporter_user_id = %d AND created_at >= %s',
$reporter_id,
$recent_since
));
if ($recent_count >= self::MAX_REPORTS_PER_HOUR) {
return new WP_Error('meinweimar_report_rate_limited', __('Zu viele Meldungen in kurzer Zeit. Bitte später erneut versuchen.', 'meinweimar-core'), ['status' => 429]);
}
$existing = $wpdb->get_var($wpdb->prepare(
'SELECT id FROM ' . self::table('reports') . ' WHERE reporter_user_id = %d AND target_type = %s AND target_id = %d AND reason = %s AND status IN (%s,%s) ORDER BY id DESC LIMIT 1',
$reporter_id,
$target_type,
$target_id,
$reason,
'open',
'reviewing'
));
if ($existing) {
return new WP_Error('meinweimar_report_exists', __('Für dieses Ziel und diesen Grund liegt bereits eine offene Meldung vor.', 'meinweimar-core'), [
'status' => 409,
'report_id' => (int) $existing,
]);
}
$now = current_time('mysql', true);
$inserted = $wpdb->insert(self::table('reports'), [
'reporter_user_id' => $reporter_id,
'target_type' => $target_type,
'target_id' => $target_id,
'reason' => $reason,
'note' => self::limit($note, 2000),
'status' => 'open',
'decision_note' => '',
'created_at' => $now,
'updated_at' => $now,
], ['%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s']);
if (!$inserted) {
return new WP_Error('meinweimar_report_store_failed', __('Die Meldung konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
return new WP_REST_Response(['report' => self::normalize_report(self::get_by_id((int) $wpdb->insert_id))], 201);
}
public static function get_report(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$report = self::get_by_id(absint($request['id']));
if (!$report) {
return new WP_Error('meinweimar_report_not_found', __('Meldung nicht gefunden.', 'meinweimar-core'), ['status' => 404]);
}
if (!Permissions::can_review_reports() && (int) $report['reporter_user_id'] !== get_current_user_id()) {
return new WP_Error('meinweimar_report_forbidden', __('Kein Zugriff auf diese Meldung.', 'meinweimar-core'), ['status' => 403]);
}
return new WP_REST_Response(['report' => self::normalize_report($report)]);
}
public static function decide_report(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$report_id = absint($request['id']);
$payload = $request->get_json_params();
$payload = is_array($payload) ? $payload : $request->get_params();
$status = sanitize_key((string) ($payload['status'] ?? ''));
$report = self::decide_for_admin($report_id, $status, (string) ($payload['decision_note'] ?? ''));
if (is_wp_error($report)) {
return $report;
}
return new WP_REST_Response(['report' => $report]);
}
/**
* Shared moderation path for REST clients and the WordPress moderation UI.
* The caller must already have passed the appropriate capability check.
*/
public static function decide_for_admin(int $report_id, string $status, string $decision_note = ''): array|WP_Error
{
global $wpdb;
if (!self::get_by_id($report_id)) {
return new WP_Error('meinweimar_report_not_found', __('Meldung nicht gefunden.', 'meinweimar-core'), ['status' => 404]);
}
if (!in_array($status, ['reviewing', 'actioned', 'dismissed'], true)) {
return new WP_Error('meinweimar_invalid_report_decision', __('Die Moderationsentscheidung ist ungültig.', 'meinweimar-core'), ['status' => 400]);
}
$updated = $wpdb->update(self::table('reports'), [
'status' => $status,
'moderator_user_id' => get_current_user_id(),
'decision_note' => self::limit(sanitize_textarea_field($decision_note), 2000),
'updated_at' => current_time('mysql', true),
], ['id' => $report_id], ['%s', '%d', '%s', '%s'], ['%d']);
if (false === $updated) {
return new WP_Error('meinweimar_report_update_failed', __('Die Moderationsentscheidung konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
return self::normalize_report(self::get_by_id($report_id));
}
/**
* Returns moderation-safe report rows for the WP-Admin queue.
*/
public static function admin_queue(string $status = ''): array
{
global $wpdb;
$where = '';
$params = [];
if (in_array($status, ['open', 'reviewing', 'actioned', 'dismissed'], true)) {
$where = ' WHERE status = %s';
$params[] = $status;
}
$sql = 'SELECT id, reporter_user_id, target_type, target_id, reason, note, status, moderator_user_id, decision_note, created_at, updated_at FROM ' . self::table('reports') . $where . ' ORDER BY updated_at DESC LIMIT 200';
$rows = $params ? $wpdb->get_results($wpdb->prepare($sql, ...$params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A);
return array_map([self::class, 'normalize_report'], is_array($rows) ? $rows : []);
}
public static function list_blocks(): WP_REST_Response
{
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare('SELECT blocked_user_id, created_at FROM ' . self::table('blocks') . ' WHERE blocker_user_id = %d ORDER BY created_at DESC', get_current_user_id()), ARRAY_A);
return new WP_REST_Response(['blocks' => array_map(static fn (array $row): array => ['user_id' => (int) $row['blocked_user_id'], 'created_at' => (string) $row['created_at']], $rows)]);
}
public static function create_block(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$blocked_user_id = absint($request['user_id']);
if (!$blocked_user_id || $blocked_user_id === get_current_user_id() || !get_userdata($blocked_user_id)) {
return new WP_Error('meinweimar_invalid_block_target', __('Dieses Profil kann nicht blockiert werden.', 'meinweimar-core'), ['status' => 400]);
}
global $wpdb;
$existing = $wpdb->get_var($wpdb->prepare(
'SELECT id FROM ' . self::table('blocks') . ' WHERE blocker_user_id = %d AND blocked_user_id = %d LIMIT 1',
get_current_user_id(),
$blocked_user_id
));
if ($existing) {
return new WP_REST_Response(['user_id' => $blocked_user_id, 'blocked' => true, 'created' => false], 200);
}
$inserted = $wpdb->insert(self::table('blocks'), [
'blocker_user_id' => get_current_user_id(),
'blocked_user_id' => $blocked_user_id,
'created_at' => current_time('mysql', true),
], ['%d', '%d', '%s']);
if (!$inserted) {
return new WP_Error('meinweimar_block_store_failed', __('Die Blockierung konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
return new WP_REST_Response(['user_id' => $blocked_user_id, 'blocked' => true, 'created' => true], 201);
}
public static function delete_block(WP_REST_Request $request): WP_REST_Response
{
global $wpdb;
$blocked_user_id = absint($request['user_id']);
$wpdb->delete(self::table('blocks'), ['blocker_user_id' => get_current_user_id(), 'blocked_user_id' => $blocked_user_id], ['%d', '%d']);
return new WP_REST_Response(['user_id' => $blocked_user_id, 'blocked' => false]);
}
/**
* Returns true when either user has blocked the other user.
* Messaging uses this as a server-side safety boundary; it does not expose
* which side created the block.
*/
public static function is_blocked_between(int $first_user_id, int $second_user_id): bool
{
if (!$first_user_id || !$second_user_id || $first_user_id === $second_user_id) {
return false;
}
global $wpdb;
return (bool) $wpdb->get_var($wpdb->prepare(
'SELECT id FROM ' . self::table('blocks') . ' WHERE (blocker_user_id = %d AND blocked_user_id = %d) OR (blocker_user_id = %d AND blocked_user_id = %d) LIMIT 1',
$first_user_id,
$second_user_id,
$second_user_id,
$first_user_id
));
}
private static function target_exists(string $target_type, int $target_id): bool
{
if ('profile' === $target_type) {
return (bool) get_userdata($target_id);
}
if ('message' === $target_type) {
return Messaging::can_access_message($target_id, get_current_user_id());
}
$post_type = ContentTypes::post_type_for_route($target_type);
$post = $post_type ? get_post($target_id) : null;
return (bool) ($post && $post->post_type === $post_type && 'publish' === $post->post_status);
}
private static function get_by_id(int $report_id): ?array
{
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare('SELECT id, reporter_user_id, target_type, target_id, reason, note, status, moderator_user_id, decision_note, created_at, updated_at FROM ' . self::table('reports') . ' WHERE id = %d LIMIT 1', $report_id), ARRAY_A);
return is_array($row) ? $row : null;
}
private static function normalize_report(?array $row): array
{
$row = is_array($row) ? $row : [];
return [
'id' => absint($row['id'] ?? 0),
'reporter_user_id' => absint($row['reporter_user_id'] ?? 0),
'target_type' => (string) ($row['target_type'] ?? ''),
'target_id' => absint($row['target_id'] ?? 0),
'reason' => (string) ($row['reason'] ?? ''),
'note' => (string) ($row['note'] ?? ''),
'status' => (string) ($row['status'] ?? 'open'),
'moderator_user_id' => absint($row['moderator_user_id'] ?? 0),
'decision_note' => (string) ($row['decision_note'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
private static function limit(string $value, int $max): string
{
return function_exists('mb_substr') ? mb_substr($value, 0, $max) : substr($value, 0, $max);
}
private static function table(string $suffix): string
{
global $wpdb;
return $wpdb->prefix . 'mw_' . $suffix;
}
}
