154 lines
8.1 KiB
PHP
Executable file
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>
|