LAN_Portal/chat/chat.php
2026-10-16 16:22:59 -04:00

154 lines
8.1 KiB
PHP
Executable file

<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Simple chat: visitors pick a name, messages are kept in chat/data/messages.json.
const CHAT_FILE = __DIR__ . '/data/messages.json';
const CHAT_KEEP = 200; // messages stored
const CHAT_SHOW = 100; // messages displayed
// ---- Features ----------------------------------------------------------------
// Every chat/features/*.php is included, and every features/*.css and features/*.js is loaded
// (in filename order). A PHP feature registers callbacks with chat_on(). Hooks:
// before_send($msg) -> return the (changed) message array, or throw new Exception('reason') to reject it
// render_text($html, $msg) -> $html is the escaped message text; return it changed (still safe HTML)
// msg_top($html, $msg) -> $html starts as ''; return it with HTML added above a message (e.g. a quote)
// msg_bottom($html, $msg) -> same, for items shown below a message (e.g. buttons)
// form_extra() -> echo HTML inside the send form
// below_chat() -> echo HTML after the send form
// JavaScript features can listen for the 'chat:updated' event on #chat (fired when the log changes).
$chatHooks = [];
function chat_on($hook, $fn) { global $chatHooks; $chatHooks[$hook][] = $fn; }
function chat_run($hook, $value = null, ...$args) {
global $chatHooks;
foreach ($chatHooks[$hook] ?? [] as $fn) {
$r = $fn($value, ...$args);
if ($r !== null) $value = $r;
}
return $value;
}
function chat_who() { return substr(sha1($_SERVER['REMOTE_ADDR'] ?? ''), 0, 8); } // short per-device id
function chat_files($ext) { return array_map('basename', glob(__DIR__ . '/features/*.' . $ext) ?: []); }
function chat_load_features() { foreach (chat_files('php') as $f) include __DIR__ . '/features/' . $f; }
// Returns the stored messages, [] if none yet, or null if the file exists but is not valid JSON.
function chat_read() {
if (!is_file(CHAT_FILE)) return [];
$raw = trim((string)@file_get_contents(CHAT_FILE));
if ($raw === '') return [];
$msgs = json_decode($raw, true);
return is_array($msgs) ? $msgs : null;
}
// Lock, re-read, apply one change, write atomically. Returns an error message or null.
function chat_update($change) {
$lock = @fopen(__DIR__ . '/data/.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX)) return 'Chat storage is not writable. Check the chat/data permissions.';
$msgs = chat_read();
if ($msgs === null) return 'chat/data/messages.json is malformed. Nothing was saved.';
$err = $change($msgs);
if ($err) return $err;
$json = json_encode(array_values(array_slice($msgs, -CHAT_KEEP)), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tmp = @tempnam(__DIR__ . '/data', 'msg');
if ($json === false || !$tmp || file_put_contents($tmp, $json) !== strlen($json) || !@rename($tmp, CHAT_FILE)) {
if ($tmp) @unlink($tmp);
return 'Could not save. Check the chat/data permissions.';
}
return null;
}
// Handles chat_action=send (anyone) and chat_action=delete (admin only). Returns [type, message] or null.
function chat_handle() {
global $isAdmin, $csrf;
$act = $_POST['chat_action'] ?? null;
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !is_string($act)) return null;
if ($act === 'send') {
$name = (string)preg_replace('/\s+/u', ' ', trim((string)($_POST['chat_name'] ?? '')));
$text = trim((string)($_POST['chat_text'] ?? ''));
if (!preg_match('/^[^\x00-\x1F\x7F]{1,20}$/u', $name)) return ['danger', 'Pick a name (1 to 20 characters).'];
if (!preg_match('/^[^\x00-\x1F\x7F]{1,500}$/u', $text)) return ['danger', 'A message must be 1 to 500 characters on one line.'];
$who = chat_who();
$err = chat_update(function (&$msgs) use ($name, $text, $who) {
$recent = 0; // at most 5 messages per 10 seconds from one address
foreach ($msgs as $m) {
if (($m['who'] ?? '') === $who && ($m['t'] ?? 0) > time() - 10) $recent++;
}
if ($recent >= 5) return 'Slow down a little.';
$last = end($msgs);
$msg = ['id' => ($last['id'] ?? 0) + 1, 't' => time(), 'name' => $name, 'text' => $text, 'who' => $who];
try { $msg = chat_run('before_send', $msg); } catch (Exception $e) { return $e->getMessage(); }
if (!is_array($msg) || !is_string($msg['text'] ?? null) || !is_string($msg['name'] ?? null)) return 'Message rejected.';
$msgs[] = $msg;
return null;
});
return $err ? ['danger', $err] : null;
}
if ($act === 'delete') {
if (!$isAdmin || !hash_equals($csrf, (string)($_POST['csrf'] ?? ''))) return ['danger', 'Not allowed.'];
$id = (int)($_POST['chat_id'] ?? 0);
$err = chat_update(function (&$msgs) use ($id) {
$msgs = array_values(array_filter($msgs, function ($m) use ($id) { return ($m['id'] ?? 0) !== $id; }));
return null;
});
return $err ? ['danger', $err] : null;
}
return null;
}
chat_load_features();
$chatNotice = chat_handle();
$chatMsgs = chat_read();
if ($chatMsgs === null) {
$chatNotice = ['danger', 'chat/data/messages.json is malformed. Fix or remove it.'];
$chatMsgs = [];
}
$chatName = is_string($_POST['chat_name'] ?? null) ? $_POST['chat_name'] : '';
$chatText = ($chatNotice && is_string($_POST['chat_text'] ?? null)) ? $_POST['chat_text'] : '';
?>
<section id="chat" class="mb-4">
<h2 class="h4">Chat</h2>
<div id="chat-notice"><?php if ($chatNotice): ?><div class="alert alert-<?= e($chatNotice[0]) ?>"><?= e($chatNotice[1]) ?></div><?php endif; ?></div>
<div id="chat-log" class="card card-body overflow-auto" style="height:20rem">
<?php if (!$chatMsgs): ?><p class="text-body-secondary mb-0">No messages yet. Say hello!</p><?php endif; ?>
<?php foreach (array_slice($chatMsgs, -CHAT_SHOW) as $chatM):
if (!is_array($chatM)) continue;
$chatT = (int)($chatM['t'] ?? 0);
$chatN = (string)($chatM['name'] ?? ''); ?>
<div class="chat-msg text-break mb-1" data-id="<?= (int)($chatM['id'] ?? 0) ?>">
<?= chat_run('msg_top', '', $chatM) ?>
<small class="text-body-secondary" title="<?= e(date('Y-m-d H:i:s', $chatT)) ?>"><?= e(date('H:i', $chatT)) ?></small>
<strong style="color:hsl(<?= abs(crc32(strtolower($chatN))) % 360 ?>, 65%, 50%)"><?= e($chatN) ?>:</strong>
<?= chat_run('render_text', e($chatM['text'] ?? ''), $chatM) ?>
<?php if ($isAdmin): ?>
<form method="post" class="d-inline">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="chat_action" value="delete">
<input type="hidden" name="chat_id" value="<?= (int)($chatM['id'] ?? 0) ?>">
<button class="btn btn-sm btn-link text-danger p-0" aria-label="Delete message" onclick="return confirm('Delete this message?')">✕</button>
</form>
<?php endif; ?>
<?php $chatBottom = chat_run('msg_bottom', '', $chatM); if ($chatBottom !== ''): ?>
<div class="d-flex flex-wrap align-items-center gap-1 mt-1"><?= $chatBottom ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<form method="post" class="mt-2">
<input type="hidden" name="chat_action" value="send">
<div class="row g-2">
<div class="col-12 col-sm-3"><input name="chat_name" class="form-control" placeholder="Your name" maxlength="20" required autocomplete="nickname" value="<?= e($chatName) ?>"></div>
<div class="col-8 col-sm-7"><input name="chat_text" class="form-control" placeholder="Message" maxlength="500" required autocomplete="off" value="<?= e($chatText) ?>"></div>
<div class="col-4 col-sm-2"><button class="btn btn-accent w-100">Send</button></div>
</div>
<?php chat_run('form_extra'); ?>
</form>
<?php chat_run('below_chat'); ?>
<script src="chat/chat-name.js" defer></script>
<script src="chat/chat-live.js" defer></script>
<?php foreach (chat_files('css') as $chatF): ?><link rel="stylesheet" href="chat/features/<?= e(rawurlencode($chatF)) ?>"><?php endforeach; ?>
<?php foreach (chat_files('js') as $chatF): ?><script src="chat/features/<?= e(rawurlencode($chatF)) ?>" defer></script><?php endforeach; ?>
</section>