aboutsummaryrefslogtreecommitdiff
path: root/api/chat/lib.php
blob: 94afe94b92d16ed2df917fd0122d1c89b790eebb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
<?php
// api/chat/lib.php — conversation store for the newOS4 <-> Hermes channel.
// One file per thread: data/chat/<thread>.json, written atomically (tmp + rename).
declare(strict_types=1);

require_once __DIR__ . '/../debug/config.php';   // error_response() / json_response() / load_json()

define('CHAT_DIR', __DIR__ . '/../../data/chat');
define('CHAT_MAX_MESSAGES', 200);
define('CHAT_MAX_TEXT', 4000);
define('CHAT_RATE_MAX', 20);        // messages per thread …
define('CHAT_RATE_WINDOW', 600);    // … per 10 minutes
define('CHAT_IP_MAX', 40);          // a token is mintable, an IP is not
define('CHAT_MAX_THREADS', 500);    // hard ceiling on disk growth

if (!is_dir(CHAT_DIR)) {
    @mkdir(CHAT_DIR, 0775, true);
    @chgrp(CHAT_DIR, 'www-data');
}

// Machine-readable failure: the sender must be able to tell "try again" from
// "your text is wrong" without parsing prose. 401/413/507 are permanent for that
// payload; 429 is the only one worth retrying.
function chat_error(string $msg, int $status, string $code, array $extra = []): void {
    if (!headers_sent()) {
        http_response_code($status);
        header('Content-Type: application/json');
    }
    $body = array_merge(['success' => false, 'code' => $code, 'error' => $msg], $extra);
    echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

function chat_token(): string {
    $t = $_SERVER['HTTP_X_CHAT_TOKEN'] ?? '';
    if (!preg_match('/^[a-f0-9]{64}$/', $t)) {
        chat_error('missing or malformed X-Chat-Token', 401, 'bad_token');
    }
    return $t;
}

function chat_thread(string $token): string {
    return substr(hash('sha256', $token), 0, 16);
}

function chat_path(string $thread): string {
    if (!preg_match('/^[a-f0-9]{16}$/', $thread)) {
        error_response('invalid thread', 400);
    }
    return CHAT_DIR . '/' . $thread . '.json';
}

function chat_read(string $thread): array {
    $path = chat_path($thread);
    if (!is_file($path)) return [];
    $raw = @file_get_contents($path);
    if ($raw === false) error_response('thread is unreadable', 500);
    $all = json_decode($raw, true);
    // A corrupt file must NOT read as "empty": the next append would then rewrite the
    // thread with a single message and the history is gone for good.
    if (!is_array($all)) error_response('thread file is corrupt: ' . basename($path), 500);
    return $all;
}

// Atomic replace: a reader sees the old file or the new one, never a truncated one.
function chat_write(string $thread, array $messages): bool {
    $path = chat_path($thread);
    $tmp  = $path . '.tmp-' . bin2hex(random_bytes(4));
    $json = json_encode(array_values($messages), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($json === false) return false;
    $fp = fopen($tmp, 'wb');
    if (!$fp) return false;
    $ok = (fwrite($fp, $json) === strlen($json));
    fflush($fp);
    fclose($fp);
    if (!$ok) { @unlink($tmp); return false; }
    @chmod($tmp, 0664);
    return @rename($tmp, $path);
}

// Append under a real lock: read-modify-write on one handle so two concurrent
// POSTs cannot each write back a stale copy (lost update).
function chat_append(string $thread, string $author, string $text): array {
    $lock = fopen(CHAT_DIR . '/.lock-' . $thread, 'c');
    // FPM creates as www-data 0644; the host-side CLI (darkbot, group www-data) must be
    // able to open the same lock, so group gets write too.
    if ($lock) @chmod(CHAT_DIR . '/.lock-' . $thread, 0664);
    if (!$lock || !flock($lock, LOCK_EX)) {
        if ($lock) fclose($lock);
        error_response('failed to lock thread', 500);
    }
    $msg = [];
    $persisted = false;
    try {
        $messages = chat_read($thread);
        $lastEpoch = $messages ? (int)($messages[count($messages) - 1]['epoch'] ?? 0) : 0;
        $now = (int)round(microtime(true) * 1000);
        $msg = [
            'id'     => 'MSG-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(3)),
            'ts'     => gmdate('c'),      // UTC, always — never a local offset
            // strictly increasing: two posts in the same millisecond would otherwise share a
            // cursor value and the second would never be delivered to a `since` reader
            'epoch'  => max($now, $lastEpoch + 1),
            'author' => $author,
            'text'   => $text,
        ];
        $messages[] = $msg;
        $cutoff = (time() - 30 * 86400) * 1000;
        $messages = array_values(array_filter(
            $messages,
            fn($m) => (int)($m['epoch'] ?? (strtotime((string)($m['ts'] ?? '')) * 1000)) >= $cutoff
        ));
        if (count($messages) > CHAT_MAX_MESSAGES) {
            $messages = array_slice($messages, -CHAT_MAX_MESSAGES);
        }
        $persisted = chat_write($thread, $messages);
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
    // error_response() exits, and PHP does not run finally blocks on exit — so the
    // failure must be reported after the lock is released, never from inside it.
    if (!$persisted) error_response('failed to persist message', 500);
    return $msg;
}

// Two windows: per thread (the conversation) and per IP (a token is mintable, so the
// thread window alone is bypassable by rotating tokens). Returns the reason and the
// wait, so the caller can send Retry-After instead of an unparseable sentence.
function chat_rate_check(string $thread): array {
    $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    $t = chat_window_check('t-' . $thread, CHAT_RATE_MAX);
    if (!$t['ok']) return ['ok' => false, 'scope' => 'thread'] + $t;
    $i = chat_window_check('i-' . hash('sha256', $ip), CHAT_IP_MAX);
    if (!$i['ok']) return ['ok' => false, 'scope' => 'ip'] + $i;
    return ['ok' => true, 'scope' => '', 'retry_after_ms' => 0];
}

// Sliding window. The wait is the time until the oldest hit leaves the window,
// which is the shortest time after which a retry can succeed.
function chat_window_check(string $key, int $max): array {
    $file = CHAT_DIR . '/.rate-' . $key . '.json';
    $fp = fopen($file, 'c+');
    if (!$fp) return ['ok' => true, 'retry_after_ms' => 0];   // never drop a message over a stats file
    if (!flock($fp, LOCK_EX)) { fclose($fp); return ['ok' => true, 'retry_after_ms' => 0]; }
    $raw  = stream_get_contents($fp);
    $hits = json_decode($raw ?: '[]', true);
    if (!is_array($hits)) $hits = [];
    $now  = time();
    $hits = array_values(array_filter($hits, fn($t) => (int)$t > $now - CHAT_RATE_WINDOW));
    sort($hits);
    $ok = count($hits) < $max;
    if ($ok) $hits[] = $now;
    ftruncate($fp, 0);
    rewind($fp);
    fwrite($fp, json_encode($hits));
    fflush($fp);
    flock($fp, LOCK_UN);
    fclose($fp);
    @chmod($file, 0664);
    if ($ok) return ['ok' => true, 'retry_after_ms' => 0];
    $oldest = (int)($hits[0] ?? $now);
    $wait   = max(0, ($oldest + CHAT_RATE_WINDOW) - $now) * 1000;
    return ['ok' => false, 'retry_after_ms' => $wait];
}

function chat_thread_count(): int {
    $g = glob(CHAT_DIR . '/*.json');
    return $g ? count($g) : 0;
}