This page walks through the full source code of CypherBay, file by file, with explanations. Nothing is simplified or paraphrased. The code shown here is exactly what runs in production and what you will find in the repository.
Architecture overview
CypherBay has no database. Every session is a single JSON file on disk. There are no accounts, no user records, no persistent identity of any kind. The server is a small collection of PHP scripts that accept HTTP requests, read and write those JSON files, and return results. That is the entire backend.
All encryption happens in the browser. Before a message or file ever leaves your device, it is encrypted using AES-256-GCM with a key that the server has never seen and cannot derive. What the server stores is ciphertext: opaque bytes that mean nothing without the password you chose when you created the session.
Communication between the browser and the server uses plain HTTP polling. Every 2500 milliseconds the client asks the server for new messages since the last timestamp it received. There are no WebSockets, no server-sent events, and no persistent connections. This keeps the server implementation minimal and makes it trivial to self-host on any shared PHP host.
Sessions expire after one hour of inactivity. A separate cleanup script, meant to run as a cron job, deletes session files that have not been touched within that window.
Encryption: js/crypto.js
This is the entire cryptographic layer. It lives in a single file and uses only the Web Crypto API built into every modern browser. No external libraries are involved.
js/crypto.jsconst CypherCrypto = {
SALT_PREFIX: 'CypherBay-v2-',
PBKDF2_ITERATIONS: 310000,
async deriveKey(password, sessionId) {
const salt = new TextEncoder().encode(this.SALT_PREFIX + sessionId);
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey']
);
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: this.PBKDF2_ITERATIONS,
hash: 'SHA-256'
},
keyMaterial,
{
name: 'AES-GCM',
length: 256
},
false,
['encrypt', 'decrypt']
);
},
async encrypt(plaintext, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
new TextEncoder().encode(plaintext)
);
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return this.arrayBufferToBase64(combined);
},
async decrypt(encryptedData, key) {
try {
const combined = this.base64ToArrayBuffer(encryptedData);
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
ciphertext
);
return new TextDecoder().decode(plaintext);
} catch (e) {
return null;
}
},
generateSessionId() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const randomBytes = crypto.getRandomValues(new Uint8Array(16));
let result = '';
for (let i = 0; i < randomBytes.length; i++) {
result += chars[randomBytes[i] % chars.length];
}
return `${result.slice(0, 4)}-${result.slice(4, 8)}-${result.slice(8, 12)}-${result.slice(12, 16)}`;
},
generateMessageId() {
const timestamp = Date.now().toString(36);
const random = crypto.getRandomValues(new Uint8Array(4));
const randomStr = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join('');
return `${timestamp}-${randomStr}`;
},
arrayBufferToBase64(buffer) {
let binary = '';
for (let i = 0; i < buffer.length; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary);
},
base64ToArrayBuffer(base64) {
const binary = atob(base64);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
buffer[i] = binary.charCodeAt(i);
}
return buffer;
},
isValidSessionId(sessionId) {
return /^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(sessionId);
}
};
window.CypherCrypto = CypherCrypto;
Key derivation. deriveKey takes the user's password and the session ID, constructs a salt string of the form CypherBay-v2-{sessionId}, and runs PBKDF2 with SHA-256 for 310,000 iterations. The result is an AES-256-GCM key that is marked non-extractable by the browser, so it cannot be serialised or exfiltrated by JavaScript running on the page. Because the salt includes the session ID, the same password used in two different sessions produces two completely different keys.
Encryption. encrypt generates a fresh 12-byte IV from the browser's CSPRNG on every call, runs AES-GCM with that IV, and prepends the IV directly to the ciphertext before base64-encoding the whole thing. The IV is not secret, it just has to be unique per message. Prepending it means the receiver always knows where to find it without any out-of-band signalling.
Decryption. decrypt splits the incoming bytes at position 12 to recover the IV and ciphertext separately, then calls the browser's decrypt. If decryption fails for any reason, it returns null rather than throwing, so the caller can decide how to handle a bad message without an uncaught exception crashing the chat.
Session IDs. generateSessionId draws 16 random bytes from the CSPRNG, maps each byte into the 36-character alphabet by taking the byte value modulo 36, and formats the result as four groups of four characters separated by hyphens. The resulting IDs look like a3k2-wx9m-4bqr-zz01. The modulo operation introduces a small bias because 256 is not a multiple of 36, but the practical impact on session ID unpredictability is negligible given 16 bytes of entropy.
Session data format
Every active session is stored as a single JSON file at data/sessions/{sessionId}.json. The structure looks like this:
{
"messages": [
{
"payload": "base64-encoded-ciphertext",
"timestamp": 1720000000000
}
],
"typing": {
"payload": "base64-encoded-ciphertext",
"timestamp": 1720000001234
},
"lastActivity": 1720000001,
"created": 1719999900
}
The payload field in every message is the base64 string produced by CypherCrypto.encrypt. The server never decodes it, never inspects its content, and never logs it. From the server's perspective every payload is an opaque string of unknown length. The timestamp fields are millisecond Unix timestamps. lastActivity and created are second-precision Unix timestamps used for session expiry. The typing key is optional and is overwritten on every typing event rather than accumulated into an array.
Sessions cap at 100 messages and also discard any message older than one hour. Both checks happen on every write in send.php, so the file never grows unbounded even without the cron job running.
API reference
Every endpoint enforces HTTPS, either directly or by reading the X-Forwarded-Proto header when the server sits behind a proxy. All responses are JSON. All endpoints send a strict Content-Security-Policy: default-src 'none' header because they serve data, not pages.
| Endpoint | Method | What it does | Rate limit |
|---|---|---|---|
/api/create.php |
POST | Create a new session | 5 per minute |
/api/messages.php |
GET | Poll for new messages | 300 per minute |
/api/send.php |
POST | Send a message or typing indicator | 80 per minute (messages only) |
/api/upload.php |
POST | Upload an encrypted file | 5 per minute |
/api/file.php |
GET | Download an encrypted file blob | none |
/api/delete.php |
POST | Delete a session immediately | 10 per minute |
api/create.php
Creates a new session file. The client generates the session ID in the browser before calling this endpoint, using CypherCrypto.generateSessionId(). The server validates the format with a regex, then opens the file in exclusive-create mode (fopen(..., 'x')). That mode fails atomically if the file already exists, so two concurrent requests with the same session ID cannot both succeed.
<?php
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: no-referrer');
header("Content-Security-Policy: default-src 'none'");
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') {
if (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) || $_SERVER['HTTP_X_FORWARDED_PROTO'] !== 'https') {
http_response_code(403);
echo json_encode(['error' => 'HTTPS required']);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
require_once __DIR__ . '/ratelimit.php';
if (!enforceRateLimit(5, 60, 'create')) {
http_response_code(429);
echo json_encode(['error' => 'Too many requests']);
exit;
}
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data || !isset($data['sessionId'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request']);
exit;
}
$sessionId = $data['sessionId'];
if (!preg_match('/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/', $sessionId)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid session ID']);
exit;
}
$dataDir = __DIR__ . '/../data/sessions';
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0700, true);
}
if (!is_dir($dataDir)) {
http_response_code(500);
echo json_encode(['error' => 'Server storage not available']);
exit;
}
$sessionFile = $dataDir . '/' . $sessionId . '.json';
// режим 'x': эксклюзивное создание, ошибка, если файл уже существует
$fp = @fopen($sessionFile, 'x');
if (!$fp) {
http_response_code(409);
echo json_encode(['error' => 'Session already exists']);
exit;
}
$sessionData = ['messages' => [], 'lastActivity' => time(), 'created' => time()];
fwrite($fp, json_encode($sessionData));
fclose($fp);
http_response_code(201);
echo json_encode(['success' => true]);
api/messages.php
The polling endpoint. The client sends a since timestamp and receives back only the messages that arrived after that point, plus any active typing indicator that is less than 4 seconds old. Using a shared read lock (LOCK_SH) means multiple simultaneous polls from different participants in the same session do not block each other.
<?php
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: no-referrer');
header("Content-Security-Policy: default-src 'none'");
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') {
if (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) || $_SERVER['HTTP_X_FORWARDED_PROTO'] !== 'https') {
http_response_code(403);
echo json_encode(['error' => 'HTTPS required']);
exit;
}
}
require_once __DIR__ . '/ratelimit.php';
if (!enforceRateLimit(300, 60, 'messages')) {
http_response_code(429);
echo json_encode(['error' => 'Too many requests']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$sessionId = isset($_GET['sessionId']) ? $_GET['sessionId'] : '';
$since = isset($_GET['since']) ? (int)$_GET['since'] : 0;
if (!preg_match('/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/', $sessionId)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid session ID']);
exit;
}
$dataDir = __DIR__ . '/../data/sessions';
$sessionFile = $dataDir . '/' . $sessionId . '.json';
if (!file_exists($sessionFile)) {
http_response_code(404);
echo json_encode(['error' => 'Session not found']);
exit;
}
$fp = fopen($sessionFile, 'r');
if (!$fp) {
echo json_encode(['messages' => []]);
exit;
}
flock($fp, LOCK_SH);
$content = stream_get_contents($fp);
flock($fp, LOCK_UN);
fclose($fp);
$sessionData = json_decode($content, true);
if (!$sessionData || !isset($sessionData['messages'])) {
echo json_encode(['messages' => []]);
exit;
}
$messages = array_filter($sessionData['messages'], function($msg) use ($since) {
return $msg['timestamp'] > $since;
});
$messages = array_values($messages);
$response = ['messages' => $messages];
if (isset($sessionData['typing'])) {
$typingAge = round(microtime(true) * 1000) - $sessionData['typing']['timestamp'];
if ($typingAge < 4000) {
$response['typing'] = $sessionData['typing'];
}
}
echo json_encode($response);
api/send.php
Handles both regular messages and typing indicators in the same endpoint. If the request body contains a typing key, it is treated as a typing event and skips the rate limit check entirely. That is intentional: typing events are frequent and ephemeral, and consuming the same rate limit bucket as real messages would make it easy to exhaust the limit by just typing. The exclusive write lock (LOCK_EX) prevents two concurrent senders from corrupting the session file.
<?php
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: no-referrer');
header("Content-Security-Policy: default-src 'none'");
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') {
if (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) || $_SERVER['HTTP_X_FORWARDED_PROTO'] !== 'https') {
http_response_code(403);
echo json_encode(['error' => 'HTTPS required']);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data || !isset($data['sessionId'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request']);
exit;
}
$sessionId = $data['sessionId'];
$isTyping = isset($data['typing']) && is_string($data['typing']);
if (!$isTyping) {
require_once __DIR__ . '/ratelimit.php';
if (!enforceRateLimit(80, 60, 'send')) {
http_response_code(429);
echo json_encode(['error' => 'Too many requests']);
exit;
}
}
$payload = isset($data['payload']) ? $data['payload'] : null;
if (!$isTyping && !$payload) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request']);
exit;
}
if (!preg_match('/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/', $sessionId)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid session ID']);
exit;
}
if (!$isTyping && (!is_string($payload) || strlen($payload) > 100000)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid payload']);
exit;
}
if ($isTyping && strlen($data['typing']) > 5000) {
http_response_code(400);
echo json_encode(['error' => 'Invalid typing data']);
exit;
}
$dataDir = __DIR__ . '/../data/sessions';
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0700, true);
}
if (!is_dir($dataDir)) {
http_response_code(500);
echo json_encode(['error' => 'Server storage not available']);
exit;
}
$sessionFile = $dataDir . '/' . $sessionId . '.json';
if (!file_exists($sessionFile)) {
http_response_code(404);
echo json_encode(['error' => 'Session not found']);
exit;
}
$fp = @fopen($sessionFile, 'r+');
if (!$fp) {
http_response_code(500);
echo json_encode(['error' => 'Server error']);
exit;
}
if (!flock($fp, LOCK_EX)) {
fclose($fp);
http_response_code(500);
echo json_encode(['error' => 'Server error']);
exit;
}
$sessionData = ['messages' => [], 'lastActivity' => time()];
$content = stream_get_contents($fp);
if ($content) {
$saved = json_decode($content, true);
if ($saved !== null && is_array($saved)) {
$sessionData = array_merge($sessionData, $saved);
if (!is_array($sessionData['messages'])) {
$sessionData['messages'] = [];
}
}
}
if ($isTyping) {
$sessionData['typing'] = [
'payload' => $data['typing'],
'timestamp' => round(microtime(true) * 1000)
];
} else {
$message = [
'payload' => $payload,
'timestamp' => round(microtime(true) * 1000)
];
$sessionData['messages'][] = $message;
if (count($sessionData['messages']) > 100) {
$sessionData['messages'] = array_slice($sessionData['messages'], -100);
}
$oneHourAgo = (microtime(true) * 1000) - (3600 * 1000);
$sessionData['messages'] = array_values(array_filter($sessionData['messages'], function($msg) use ($oneHourAgo) {
return $msg['timestamp'] > $oneHourAgo;
}));
}
$sessionData['lastActivity'] = time();
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($sessionData));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
if ($isTyping) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => true, 'timestamp' => $message['timestamp']]);
}
api/upload.php
Accepts a file uploaded via a normal multipart/form-data POST. The file is an encrypted blob the browser prepared before sending, so the server never sees the original content, the filename, or the MIME type. It is stored under a random 32-character hex ID in data/uploads/. On every upload, any file in that directory older than seven days is deleted first. The maximum accepted size is 25 MB.
<?php
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: no-referrer');
header("Content-Security-Policy: default-src 'none'");
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') {
if (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) || $_SERVER['HTTP_X_FORWARDED_PROTO'] !== 'https') {
http_response_code(403);
echo json_encode(['error' => 'HTTPS required']);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
require_once __DIR__ . '/ratelimit.php';
if (!enforceRateLimit(5, 60, 'upload')) {
http_response_code(429);
echo json_encode(['error' => 'Too many uploads. Try again in a minute.']);
exit;
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$code = isset($_FILES['file']) ? $_FILES['file']['error'] : 'no_file';
http_response_code(400);
echo json_encode(['error' => 'No file uploaded', 'code' => $code]);
exit;
}
$file = $_FILES['file'];
$maxSize = 25 * 1024 * 1024;
if ($file['size'] > $maxSize) {
http_response_code(400);
echo json_encode(['error' => 'File too large (max 25 MB)']);
exit;
}
$uploadDir = __DIR__ . '/../data/uploads';
if (!is_dir($uploadDir)) {
@mkdir($uploadDir, 0755, true);
}
if (!is_dir($uploadDir)) {
http_response_code(500);
echo json_encode(['error' => 'Server storage not available']);
exit;
}
// удаляем зашифрованные файлы старше 7 дней при каждой загрузке
$cutoff = time() - 7 * 24 * 3600;
foreach (glob($uploadDir . '/*') ?: [] as $f) {
if (is_file($f) && filemtime($f) < $cutoff) @unlink($f);
}
$id = bin2hex(random_bytes(16));
$dataPath = $uploadDir . '/' . $id;
if (!move_uploaded_file($file['tmp_name'], $dataPath)) {
http_response_code(500);
echo json_encode(['error' => 'Could not save file on server']);
exit;
}
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$url = $scheme . '://' . $host . '/api/file.php?id=' . $id;
echo json_encode(['success' => true, 'url' => $url]);
api/file.php
Serves the encrypted blob back to the browser. The response is application/octet-stream with no filename or MIME type hint in the headers because the server genuinely does not know either. The browser's JavaScript decrypts the bytes, recovers the original filename and type from within the decrypted payload, and presents the file to the user.
<?php
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header("Content-Security-Policy: default-src 'none'");
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
exit;
}
$id = isset($_GET['id']) ? $_GET['id'] : '';
if (!preg_match('/^[a-f0-9]{32}$/', $id)) {
http_response_code(400);
exit('Invalid file ID');
}
$uploadDir = __DIR__ . '/../data/uploads';
$dataPath = $uploadDir . '/' . $id;
if (!file_exists($dataPath)) {
http_response_code(404);
exit('File not found or expired');
}
// отдаём зашифрованный blob как есть, расшифровка происходит в браузере клиента
header('Content-Type: application/octet-stream');
header('Content-Length: ' . filesize($dataPath));
header('Content-Disposition: attachment; filename="' . $id . '.bin"');
header('Cache-Control: private, max-age=604800');
readfile($dataPath);
api/delete.php
Deletes a session file immediately. Used when a participant leaves the chat and wants to remove the session from the server at once rather than waiting for the cron job. Because the session file holds only ciphertext, deleting it is the only meaningful way to destroy the conversation record.
api/delete.php<?php
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: no-referrer');
header("Content-Security-Policy: default-src 'none'");
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === 'off') {
if (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) || $_SERVER['HTTP_X_FORWARDED_PROTO'] !== 'https') {
http_response_code(403);
echo json_encode(['error' => 'HTTPS required']);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
require_once __DIR__ . '/ratelimit.php';
if (!enforceRateLimit(10, 60, 'delete')) {
http_response_code(429);
echo json_encode(['error' => 'Too many requests']);
exit;
}
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data || !isset($data['sessionId'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request']);
exit;
}
$sessionId = $data['sessionId'];
if (!preg_match('/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/', $sessionId)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid session ID']);
exit;
}
$dataDir = __DIR__ . '/../data/sessions';
$sessionFile = $dataDir . '/' . $sessionId . '.json';
if (!file_exists($sessionFile)) {
http_response_code(404);
echo json_encode(['error' => 'Session not found']);
exit;
}
if (unlink($sessionFile)) {
echo json_encode(['success' => true]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Failed to delete session']);
}
Rate limiting: api/ratelimit.php
The rate limiter is included by every endpoint that needs it. It stores one JSON file per IP address per action type under data/ratelimit/. The IP address is hashed with SHA-256 before it becomes a filename, so no raw IP addresses are written to disk. Each file holds a counter and a window start timestamp. If the window has expired, the counter resets to zero. An exclusive file lock prevents races between concurrent requests from the same IP.
When the rate limit directory cannot be created or a file cannot be opened, the function returns true (allow) rather than blocking. Failing open is a deliberate choice: a storage problem should not take the service down.
<?php
function enforceRateLimit($maxRequests, $windowSeconds, $key = '') {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$hash = hash('sha256', $ip . $key);
$dir = __DIR__ . '/../data/ratelimit';
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
if (!is_dir($dir)) {
return true; // не удалось создать директорию, пропускаем запрос, а не падаем
}
$file = $dir . '/' . substr($hash, 0, 40) . '.json';
$now = time();
$fp = @fopen($file, 'c+');
if (!$fp) {
return true;
}
if (!flock($fp, LOCK_EX)) {
fclose($fp);
return true;
}
$content = stream_get_contents($fp);
$data = $content ? json_decode($content, true) : null;
if (!$data || ($now - $data['window_start']) >= $windowSeconds) {
$data = ['count' => 0, 'window_start' => $now];
}
$data['count']++;
$allowed = $data['count'] <= $maxRequests;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $allowed;
}
Cleanup: api/cleanup.php
This script is only meant to run from the command line as a cron job. Calling it over HTTP returns a 404 immediately. It deletes any session file whose lastActivity timestamp is more than one hour old, and also sweeps the rate limit directory of any files older than one hour. A reasonable cron schedule is every five minutes.
<?php
if (php_sapi_name() !== 'cli') {
http_response_code(404);
exit;
}
$dataDir = __DIR__ . '/../data/sessions';
if (!is_dir($dataDir)) {
exit;
}
$files = glob($dataDir . '/*.json');
$now = time();
$maxAge = 3600;
foreach ($files as $file) {
$content = file_get_contents($file);
$data = json_decode($content, true);
if ($data && isset($data['lastActivity'])) {
$age = $now - $data['lastActivity'];
if ($age > $maxAge) {
unlink($file);
}
} else {
$fileAge = $now - filemtime($file);
if ($fileAge > $maxAge) {
unlink($file);
}
}
}
$ratelimitDir = __DIR__ . '/../data/ratelimit';
if (is_dir($ratelimitDir)) {
foreach (glob($ratelimitDir . '/*.json') as $file) {
if ($now - filemtime($file) > 3600) {
unlink($file);
}
}
}
echo "Cleanup completed\n";
Add it to cron with something like:
*/5 * * * * php /var/www/cypherbay/api/cleanup.php
Self-hosting
CypherBay runs on any server that has PHP 7.4 or newer and a writable directory for storing session data. There is no database, no Composer dependency, and no build step. Clone the repository, point your web server at the document root, make sure data/ is writable by the PHP process, and add the cron job above. That is everything.
If you run it behind a reverse proxy like Nginx, set the X-Forwarded-Proto: https header so the HTTPS check in each API endpoint passes correctly.
The full source is available at github.com/cypherbay-net/webchat under the license in the repository root. Pull requests and bug reports are welcome.