Add LAN Portal source

This commit is contained in:
clay 2026-10-16 16:22:59 -04:00
commit 7935567a47
31 changed files with 1880 additions and 0 deletions

175
.fuse_hidden0000002c00000003 Executable file
View file

@ -0,0 +1,175 @@
<?php
const PORTAL = true;
const CONFIG_FILE = 'config.json';
const ACCENTS = ['blue', 'green', 'orange', 'purple'];
const MODES = ['auto', 'light', 'dark'];
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function safe_url($u) { return preg_match('~^(https?://|/(?!/))~i', (string)$u) ? $u : '#'; }
$config = json_decode((string)@file_get_contents(CONFIG_FILE), true);
$configError = null;
if (!is_array($config)) {
$configError = 'config.json is missing or malformed, so defaults are shown.';
$config = ['title' => 'Portal', 'welcome' => '', 'links' => []];
}
// Set by admin.php when it exists; otherwise the portal stays read-only.
$isAdmin = false;
$csrf = '';
$flash = null;
if (is_file(__DIR__ . '/admin.php')) require __DIR__ . '/admin.php';
$mode = in_array($config['appearance']['mode'] ?? '', MODES, true) ? $config['appearance']['mode'] : 'auto';
$accent = in_array($config['appearance']['accent'] ?? '', ACCENTS, true) ? $config['appearance']['accent'] : 'blue';
$edit = ($isAdmin && is_string($_GET['edit'] ?? null)) ? $_GET['edit'] : '';
$links = array_values(array_filter($config['links'] ?? [], 'is_array'));
if ($edit === 'new') $links[] = ['id' => 'new', 'enabled' => true];
?>
<!doctype html>
<html lang="en" data-bs-theme="<?= $mode === 'dark' ? 'dark' : 'light' ?>" data-accent="<?= e($accent) ?>"
data-default-mode="<?= e($mode) ?>" data-default-accent="<?= e($accent) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($config['title'] ?? 'Portal') ?></title>
<link rel="stylesheet" href="bootstrap.min.css">
<link rel="stylesheet" href="portal.css">
<script src="theme.js"></script>
</head>
<body>
<main class="container py-4">
<?php if ($flash): ?><div class="alert alert-<?= e($flash[0]) ?>"><?= e($flash[1]) ?></div><?php endif; ?>
<?php if ($configError): ?><div class="alert alert-danger"><?= e($configError) ?></div><?php endif; ?>
<?php if ($isAdmin): ?>
<div class="alert alert-secondary d-flex flex-wrap gap-2 align-items-center">
<strong class="me-auto">Editing mode</strong>
<a class="btn btn-sm btn-outline-secondary" href="?edit=appearance">✎ Site default theme</a>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<button class="btn btn-sm btn-outline-secondary" name="action" value="exit">Exit editing</button>
</form>
</div>
<?php if ($edit === 'appearance'): ?>
<form method="post" class="card card-body mb-4">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<div class="row g-2 mb-2">
<div class="col-sm-6"><label class="form-label">Default mode</label>
<select name="mode" class="form-select">
<?php foreach (MODES as $m): ?><option value="<?= $m ?>"<?= $m === $mode ? ' selected' : '' ?>><?= $m === 'auto' ? 'follow device' : $m ?></option><?php endforeach; ?>
</select></div>
<div class="col-sm-6"><label class="form-label">Default accent</label>
<select name="accent" class="form-select">
<?php foreach (ACCENTS as $a): ?><option value="<?= $a ?>"<?= $a === $accent ? ' selected' : '' ?>><?= $a ?></option><?php endforeach; ?>
</select></div>
</div>
<div><button class="btn btn-accent" name="action" value="save_appearance">Save</button>
<a class="btn btn-outline-secondary" href="?">Cancel</a></div>
</form>
<?php endif; ?>
<?php endif; ?>
<header class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div class="flex-grow-1">
<?php if ($edit === 'site'): ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<label class="form-label">Title</label>
<input name="title" class="form-control mb-2" maxlength="80" required value="<?= e($config['title'] ?? '') ?>">
<label class="form-label">Welcome message</label>
<input name="welcome" class="form-control mb-2" maxlength="300" value="<?= e($config['welcome'] ?? '') ?>">
<button class="btn btn-accent" name="action" value="save_site">Save</button>
<a class="btn btn-outline-secondary" href="?">Cancel</a>
</form>
<?php else: ?>
<h1 class="h2"><?= e($config['title'] ?? '') ?></h1>
<?php if ($isAdmin): ?><a class="edit" href="?edit=site" title="Edit title and welcome" aria-label="Edit title and welcome">✎</a><?php endif; ?>
<p class="lead mb-0"><?= e($config['welcome'] ?? '') ?></p>
<?php endif; ?>
</div>
<div id="appearance" hidden>
<div class="btn-group btn-group-sm" role="group" aria-label="Colour mode">
<button type="button" class="btn btn-outline-secondary" data-mode="light">Light</button>
<button type="button" class="btn btn-outline-secondary" data-mode="dark">Dark</button>
<button type="button" class="btn btn-outline-secondary" data-mode="auto">Device</button>
</div>
<span class="d-inline-flex gap-1 align-middle ms-2">
<?php foreach (ACCENTS as $a): ?><button type="button" class="swatch" data-pick="<?= $a ?>" aria-label="<?= $a ?> accent" title="<?= $a ?>"></button><?php endforeach; ?>
</span>
<button type="button" class="btn btn-sm btn-link" data-reset="1">Reset</button>
</div>
</header>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-4">
<?php foreach ($links as $l):
$id = (string)($l['id'] ?? '');
$on = !empty($l['enabled']);
if (!$on && !$isAdmin) continue; ?>
<div class="col"><div class="card h-100<?= $on ? '' : ' disabled-card' ?>">
<div class="card-body">
<?php if ($isAdmin && $edit === $id && $id !== ''): ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="id" value="<?= e($id) ?>">
<input name="title" class="form-control mb-2" placeholder="Title" maxlength="60" required value="<?= e($l['title'] ?? '') ?>">
<input name="description" class="form-control mb-2" placeholder="Description" maxlength="200" value="<?= e($l['description'] ?? '') ?>">
<input name="url" class="form-control mb-2" placeholder="http://192.168.50.1:8080" maxlength="300" required value="<?= e($l['url'] ?? '') ?>">
<input name="icon" class="form-control mb-2" placeholder="Icon (optional emoji or letters)" maxlength="16" value="<?= e($l['icon'] ?? '') ?>">
<div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="enabled" id="en-<?= e($id) ?>"<?= $on ? ' checked' : '' ?>>
<label class="form-check-label" for="en-<?= e($id) ?>">Enabled</label></div>
<div class="d-flex flex-wrap gap-1">
<button class="btn btn-accent" name="action" value="save_link">Save</button>
<a class="btn btn-outline-secondary" href="?">Cancel</a>
<?php if ($id !== 'new'): ?><button class="btn btn-outline-danger ms-auto" name="action" value="delete_link" formnovalidate onclick="return confirm('Delete this link?')">Delete</button><?php endif; ?>
</div>
</form>
<?php else: ?>
<div class="d-flex gap-3">
<span class="icon" aria-hidden="true"><?= (e($l['icon'] ?? '') ?: '🔗') ?></span>
<div>
<h2 class="h5 card-title mb-1"><a class="text-decoration-none<?= $isAdmin ? '' : ' stretched-link' ?>" href="<?= e(safe_url($l['url'] ?? '')) ?>"><?= e($l['title'] ?? '') ?></a></h2>
<p class="card-text text-body-secondary mb-0"><?= e($l['description'] ?? '') ?></p>
<?php if (!$on): ?><span class="badge text-bg-secondary mt-2">Disabled</span><?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($isAdmin && $edit !== $id && $id !== ''): ?>
<div class="card-footer">
<form method="post" class="d-flex gap-1">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="id" value="<?= e($id) ?>">
<button class="btn btn-sm btn-outline-secondary" name="action" value="up" aria-label="Move up">↑</button>
<button class="btn btn-sm btn-outline-secondary" name="action" value="down" aria-label="Move down">↓</button>
<button class="btn btn-sm btn-outline-secondary" name="action" value="toggle"><?= $on ? 'Disable' : 'Enable' ?></button>
<a class="btn btn-sm btn-outline-secondary ms-auto" href="?edit=<?= urlencode($id) ?>">✎ Edit</a>
</form>
</div>
<?php endif; ?>
</div></div>
<?php endforeach; ?>
<?php if ($isAdmin && $edit !== 'new'): ?>
<div class="col"><a class="card h-100 text-decoration-none" href="?edit=new"><div class="card-body d-flex align-items-center justify-content-center">+ Add link</div></a></div>
<?php endif; ?>
</div>
<?php
// Optional sections. To add a feature, create <name>/<name>.php and add 'name/name.php' to this list.
// Each file renders its own HTML, can use $config, $isAdmin and $csrf, and shares portal.css.
foreach (['files/files.php', 'chat/chat.php'] as $part) {
if (!is_file(__DIR__ . '/' . $part)) continue;
echo '<section class="feature">';
include __DIR__ . '/' . $part;
echo '</section>';
}
?>
</main>
<script src="theme-controls.js"></script>
</body>
</html>

457
AI Instructions.md Executable file
View file

@ -0,0 +1,457 @@
Build a small, offline-capable PHP portal for a Raspberry Pi.
PRIMARY RULE: SIMPLE IS BETTER.
Do not turn this into a framework, CMS, or elaborate plugin system.
Prefer clear, readable code and a few files over abstractions.
Do not add features I have not requested.
PURPOSE
A simple landing page for an offline/local network, with a welcome
message and cards linking to local services such as Kiwix.
File sharing and chat are NOT part of this build. Make it easy to
include additional PHP files later.
ENVIRONMENT
- Raspberry Pi OS / Debian.
- Nginx and PHP-FPM.
- No cloud dependencies, CDN requests, external fonts, or analytics.
- No database, Composer, npm, containers, or build pipeline.
- Plain PHP, HTML, CSS, and minimal vanilla JavaScript.
- Use bootstrap CSS, downloaded and served locally.
- Everything must work without internet access after installation.
FILE STRUCTURE
Use only these four files initially:
index.php
admin.php
config.json
pico.min.css
- index.php displays the portal.
- admin.php optionally adds authentication, inline editing, and saving.
- config.json holds public site settings and service links.
- bootstrap.min.css is the locally downloaded library.
- Keep our small custom CSS and JavaScript in seprate, small files, each js file should only do one or two things.
- Do not add helper files or directories unless genuinely necessary.
- Do not reproduce the third-party stylesheet in your answer;
provide instructions for obtaining a pinned local copy.
PUBLIC PORTAL
Display:
- Site title.
- Welcome message.
- Responsive cards for local-service links.
- Each card has a title, description, and URL, optional image/icon.
- A simple visitor appearance control:
- Light / dark / follow device.
- A few accent-color presets.
- Reset to the site defaults.
Save visitor appearance preferences in browser localStorage.
They must not change the shared JSON configuration.
If localStorage is unavailable, the site should still work.
Use ordinary text fields, not rich text or arbitrary HTML.
Keep the interface clean and usable on phones and desktops.
Use no remote assets, including icons. Inline SVG or simple text
icons are fine.
OPTIONAL ADMIN FUNCTIONALITY, can be done later but plan for it now, include what is need in index.php so we dont have to edit it again later.
I explicitly want authentication through:
?adminpswd=strongpassword
This is a deliberate choice. I understand the URL-password caveat.
Do not replace it with a login page, account system, or different
authentication method. Do not repeat warnings about this decision.
- A correct password unlocks a PHP session.
- Redirect to the same page without the password query parameter
after processing it.
- A missing or incorrect password must not unlock editing.
- Store the password in admin.php, not config.json.
- Provide a small "Exit editing" control to end the admin session.
- Never pass the password to service links.
When authenticated:
- Show pencil/edit icons beside editable items.
- Edit items in place using small forms, not another page or dashboard.
- Provide Save and Cancel.
- Allow editing the site title and welcome message.
- Allow adding, editing, deleting, enabling, and disabling service links.
- Provide simple up/down controls to reorder links.
- Disabled links remain visible to the administrator for editing.
- Allow changing the shared default theme and accent preset.
- Provide a clear save/error message.
Keep all admin-specific PHP handlers
admin.php where practical.
index.php will probably contain a few simple conditional hooks for editing.
Do not create an abstraction layer just to remove those conditionals.
IMPORTANT OPTIONAL-FILE BEHAVIOR
Load admin.php only if it exists, using a fixed local path.
Load it before HTML output so it can handle sessions and saves.
If admin.php is removed:
- The portal must still display normally.
- No editor controls appear.
- No editing/saving functionality remains.
- No warnings, missing-function errors, or broken asset requests occur.
admin.php is an include, not a standalone admin page.
Prevent direct requests from executing its handlers.
JSON CONFIGURATION
Use a small, readable structure such as:
{
"title": "The Offline Box",
"welcome": "Welcome to the local network.",
"appearance": {
"mode": "auto",
"accent": "blue"
},
"links": [
{
"id": "library",
"title": "Offline Library",
"description": "Browse our Kiwix collection",
"url": "http://192.168.50.1:8080",
"enabled": true
}
]
}
The IP address is an example, not a required network configuration.
- Keep passwords and secrets out of config.json.
- Use stable link IDs so edits do not depend on visible card positions.
- Preserve unrelated settings when saving.
- Re-read current settings while holding the save lock, then apply
only the requested change.
- Validate submitted values server-side.
- Write complete JSON safely, with locking and atomic replacement.
- Never silently overwrite malformed JSON with defaults.
- Show a useful error if configuration cannot be read or saved.
- Do not report success when writing fails.
- Keep the implementation short; no configuration framework.
BASIC SAFETY WITHOUT OVERENGINEERING
- Authenticate every modifying request on the server.
- Escape text and attribute output appropriately.
- Never treat visitor input or JSON values as PHP include paths.
- Do not allow editing arbitrary files or executing commands.
- The web-server user should not need write access to PHP source files.
- Explain only the filesystem permissions actually needed.
FUTURE PHP ADDITIONS
Include a short, fixed optional-include list in index.php, initially:
files.php
chat.php
For each filename:
- Include it only if it exists.
- Missing files must be silently skipped.
- Include it within the page content so it can render a section.
- make it easy to add new features later, not just files and chat
- Make the loaded configuration available as $config.
- State that included files share the local CSS.
- Do not create files.php or chat.php in this build.
No automatic directory scanning, dynamic include paths, manifests,
plugin manager, router, or dependency injection.
Local services such as Kiwix simply get a link card; do not implement
or configure those services.
OUT OF SCOPE
- File uploads or downloads.
- Chat.
- Wi-Fi, DHCP, DNS, captive portal, or OS network configuration.
- User registration or multiple administrator accounts.
- Database.
- Rich-text editor.
- Arbitrary custom CSS editor.
- Theme builder.
- Framework or general-purpose plugin architecture.
DELIVERABLES
1. Provide complete index.php, admin.php, and config.json.
2. Provide short setup instructions for Nginx/PHP-FPM.
3. Explain how to obtain the local bootstrap CSS file.
4. Explain how to set the admin password hash.
5. Explain the minimum required file/directory permissions.
6. Explain how to add a service link and a future PHP section.
7. Include a short manual test checklist.
Do not give partial snippets or placeholders in the application code.
Do not claim to have tested anything you did not actually test.
Before finishing, check:
- Correct password enables inline editing.
- Wrong password does not.
- Saves require authenticated POST requests and a valid CSRF token.
- Changes survive a reload.
- Visitor preferences do not modify shared defaults.
- Removing admin.php leaves a working read-only portal.
- Missing files.php and chat.php cause no errors.
- All assets are local.
- There are no unnecessary files or dependencies.
If a design choice is ambiguous, choose the smallest reasonable
implementation and briefly state the choice.
Again: SIMPLE IS BETTER. Build a small portal, not a platform.
You are adding one optional feature to a small offline PHP portal on a Raspberry Pi (Debian / Raspberry Pi OS, Nginx, PHP-FPM 8.x). PRIMARY RULE: SIMPLE IS BETTER. Build a small feature, not a platform. No frameworks, CMS, plugin system, router, dependency injection, or abstraction layers. Prefer clear, readable code in as few files as practical. Do not add features I have not asked for. If a design choice is ambiguous, choose the smallest reasonable option and state it briefly.
HARD ENVIRONMENT RULES
- Everything must work with no internet: no CDNs, remote fonts, remote icons, analytics, or remote images. Text characters or inline SVG for icons.
- No database, Composer, npm, containers, or build pipeline. Plain PHP, HTML, CSS, and minimal vanilla JavaScript. Flat files (JSON, text) for storage.
- Use Bootstrap 5.3 classes for all UI (card, btn, form-control, alert, row/col, badge, list-group, etc.). Bootstrap is already served locally as bootstrap.min.css. Do not copy, edit, or reproduce it.
- Phones and desktops must both work. Keep the interface clean.
PROJECT LAYOUT (web root, for example /var/www/portal)
index.php The portal page. Loads config, optionally includes admin.php, renders the page, then includes each feature file. Only PHP file Nginx executes.
admin.php Optional include: password session, CSRF token, inline editing of the portal's own title, welcome message, and links. Never call its functions from a feature.
bootstrap.min.css Local Bootstrap.
portal.css Shared site styles (accent colours, small tweaks).
theme.js Applies the visitor's saved theme.
theme-controls.js Appearance buttons.
files/ The "files" feature: its code, assets, and data.
chat/ The "chat" feature.
<new-feature>/ Each future feature gets its own directory like these.
Site settings live OUTSIDE the web root in /var/lib/offline-portal/config.json (CONFIG_FILE). Do not read keys other than what you need and NEVER write to it. Features keep their own data inside their own directory.
FEATURE DIRECTORY CONVENTION
For a feature named "new-feature" (lowercase letters, digits, hyphens):
new-feature/new-feature.php Entry file included by index.php. Required.
new-feature/new-feature.css Optional, only if portal.css and Bootstrap are not enough.
new-feature/*.js Optional. Each JS file does only one or two small things. Vanilla JS, no libraries. The feature must still work (or degrade clearly) without JS where practical.
new-feature/data/ Private data (JSON, logs, lock files). Nginx denies web access to every /<feature>/data/ path. Put anything sensitive here and nowhere else.
new-feature/public/ Only if files must be fetched directly by browsers (for example shared files). Anything outside data/ may be publicly readable, so never put private data there.
The only edit allowed outside the feature directory is adding 'new-feature/new-feature.php' to the fixed include list near the bottom of index.php. State that exact one-line edit in your answer. Do not create automatic directory scanning, manifests, dynamic include paths, or a registry.
HOW YOUR ENTRY FILE IS LOADED
index.php runs: foreach ([ ... ] as $part) { if (is_file(__DIR__ . '/' . $part)) include __DIR__ . '/' . $part; }
It is included inside <main class="container"> AFTER page output has started. Therefore:
- Do NOT call header(), setcookie(), session_start(), redirect, send downloads, or emit JSON. Handle POSTs inline: validate, act, and show an alert on the same page. No redirect.
- If a feature genuinely needs headers (file downloads, streaming uploads, a polling/JSON endpoint), STOP and tell me the smallest change needed in index.php or Nginx and why. Do not work around it silently and do not create a second executable PHP file without asking.
- Nginx executes only /index.php. Direct requests to any other .php return 404. Never depend on a feature file being requested directly.
- Forms: use method="post" with NO action attribute so the POST goes to index.php. Use relative URLs without a leading slash for your own assets (for example href="new-feature/new-feature.css") so the portal works from a subpath. Load your own CSS/JS from inside your section: <link rel="stylesheet" href="..."> and <script src="..." defer></script>.
THE CONTRACT FOR YOUR ENTRY FILE
1. First line after <?php: if (!defined('PORTAL')) { http_response_code(403); exit; }
2. Use __DIR__ for every filesystem path (for example __DIR__ . '/data/messages.json'). Never build paths from user input or JSON values without a strict allow-list check.
3. Render ONE <section> containing an <h2>, with Bootstrap classes. Use the existing .btn-accent class for the primary button. Use var(--accent) and Bootstrap's --bs-* CSS variables for any custom colour so light/dark mode and the accent presets keep working. Do not hard-code colours.
4. Available variables: $config (decoded site config), $isAdmin (bool), $csrf (string, meaningful only when $isAdmin). Helpers: e() for HTML escaping, safe_url(). Constants: PORTAL, CONFIG_FILE, ACCENTS, MODES. Do not redeclare them. Do not use $here (it exists only if admin.php exists).
5. You run in the global scope. Do NOT overwrite: $config, $isAdmin, $csrf, $flash, $edit, $links, $l, $id, $on, $mode, $accent, $part, $a, $m, $configError, $isAdmin. Put logic inside functions named with your feature prefix (chat_, new_feature_) and use prefixed variable names.
6. Reserved request names (used by admin.php): POST fields action, id, csrf, title, welcome, description, url, icon, enabled, mode, accent. GET parameters adminpswd and edit. Name ALL your form fields and query parameters with your feature prefix (for example chat_action, chat_text, files_page). Never use a field named "action".
7. admin.php only handles a POST that contains "action". Your POSTs are ignored by it. Your handlers must therefore do their own checks.
8. Escape ALL output with e(). Do not output raw user text as HTML. Plain text fields only; no rich text or arbitrary HTML.
SECURITY (Nginx rules are applied by me; do your part in PHP)
- Admin-only actions: require $isAdmin AND hash_equals($csrf, (string)($_POST['csrf'] ?? '')); include <input type="hidden" name="csrf" value="<?= e($csrf) ?>"> in the form. Modifying actions must be POST only, never GET.
- Public actions (for example a visitor posting a chat message): validate request method, field lengths, count and size limits, and apply a simple rate or size limit. State what you chose.
- Validate every ID, file name, and path with a strict allow-list pattern (for example /^[a-z0-9_-]{1,40}$/). Never trust client file names or MIME types. Never use input in include/require, shell commands, eval, or unserialize.
- Uploaded or user-supplied content must never be executable. If your feature stores anything under a web-served directory, say exactly which Nginx rules you require (for example no PHP execution, forced download, size limits, deny on data/). I will apply them; do not assume they exist.
- Do not use the admin password or pass it to any link. Do not implement your own login. Use $isAdmin from the portal.
STORAGE RULES
- Keep private data in <feature>/data/. Write with flock() and atomic replace: write a temp file in the same directory, then rename(). Check every write. Never report success if a write fails. Never overwrite malformed data with defaults: show a clear error instead. Re-read current data while holding the lock, then apply only the requested change.
- Set explicit size and count limits.
- Permissions model: PHP source and feature directories are owned by root and read-only to www-data. Only <feature>/data/ (and <feature>/public/ if needed) is owned by www-data. Give me the exact commands, for example:
sudo mkdir -p /var/www/portal/new-feature/data
sudo chown www-data:www-data /var/www/portal/new-feature/data
sudo chmod 750 /var/www/portal/new-feature/data
Also tell me any php.ini (upload_max_filesize, post_max_size) or Nginx (client_max_body_size) settings required.
KEEP THE PORTAL WORKING WITHOUT YOU
- If your feature directory or entry file is removed, the portal must render normally with no warnings. index.php already skips a missing file silently.
- If admin.php is removed, $isAdmin is false. Admin-only controls must simply not render. Never call functions defined in admin.php (field(), portal_update(), go()); write small code of your own.
- Never create, require, or depend on files in other feature directories.
SKELETON TO FOLLOW (adapt; keep it this small if possible)
<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
function demo_notice() { /* handle demo_ POSTs; return [type, message] or null */ }
$demoNotice = demo_notice();
?>
<section class="mb-4">
<h2 class="h4">Demo</h2>
<?php if ($demoNotice): ?><div class="alert alert-<?= e($demoNotice[0]) ?>"><?= e($demoNotice[1]) ?></div><?php endif; ?>
<form method="post" class="card card-body">
<input class="form-control mb-2" name="demo_text" maxlength="200">
<button class="btn btn-accent" name="demo_action" value="add">Add</button>
</form>
</section>
DELIVERABLES
1. The complete entry file and any CSS/JS files, with no placeholders or partial snippets.
2. The exact one-line edit to the include list in index.php.
3. Filesystem commands and any Nginx / php.ini requirements (state them explicitly so I can apply them).
4. Short setup notes: how to use it and how to add an item or change a setting.
5. A short manual test checklist: missing or unwritable data directory, malformed data file, invalid input, oversized input, wrong CSRF token (admin actions), feature directory removed (portal still loads cleanly), admin.php removed (read-only and no errors), and the browser network tab showing only local requests.
6. A short list of design choices you made where my request was ambiguous.
Do not claim to have tested anything you did not actually run. Say plainly what is untested.
OLD BELOW
You are adding one optional feature (for example files.php or chat.php) to a small offline PHP portal on a Raspberry Pi (Debian, Nginx, PHP-FPM 8.x). SIMPLE IS BETTER. Do not build a framework, plugin system, router, or abstraction layer. Do not add features I have not asked for. Prefer one readable file.
index.php has two include lists: banner features (shown above the title, no panel, e.g. announcement/announcement.php) and panel features (wrapped in <section class="feature">). Say which list a new feature belongs in and give the exact one-line edit.
HOW THE PORTAL WORKS
- index.php has a fixed list near the bottom: foreach (['files.php', 'chat.php'] as $part) { include if is_file }. Your file is included inside <main>, AFTER the page HTML has started.
- Consequence: your file cannot call header(), redirect, set cookies, or send downloads or JSON. Output has already begun. Handle form POSTs inline (validate, act, show a result message on the page, no redirect). If the feature truly needs headers (file downloads, upload streaming, a JSON endpoint, auto-refresh polling), STOP and tell me the smallest change needed in index.php or Nginx and why. Do not work around it silently.
- Nginx only executes index.php; every other .php is blocked from direct requests (404). Never rely on requesting your file directly.
- Everything works offline. No CDN, remote fonts, remote icons, analytics, Composer, npm, or database. Use plain PHP, flat files, and minimal vanilla JS. Text or inline-SVG icons only.
THE CONTRACT FOR YOUR FILE
1. First line after <?php: if (!defined('PORTAL')) { http_response_code(403); exit; }
2. Render ONE <section> containing an <h2>. It sits in a Bootstrap container, so use Bootstrap 5.3 classes (card, btn, form-control, alert, row/col) and the existing .btn-accent class. Do not edit bootstrap.min.css or portal.css. If you need styles, use as few as possible: a small <style> block inside your section, using var(--accent) and Bootstrap's --bs-* variables so light/dark and accent themes keep working.
3. Available variables: $config (the decoded config.json), $isAdmin (bool), $csrf (string, only meaningful when $isAdmin). Available helpers: e() for HTML escaping, safe_url(). Constants: CONFIG_FILE, ACCENTS, MODES. Do not redeclare these.
4. You run in the global scope. Do not overwrite: $config, $isAdmin, $csrf, $flash, $edit, $links, $l, $id, $on, $mode, $accent, $part, $a, $m. Prefix your own variables and functions with the feature name (chat_, files_), or put logic inside prefixed functions.
5. Escape ALL output with e(). Never use user input or JSON values in include/require paths, shell commands, or file paths without strict validation.
6. Do not modify config.json, admin.php, or index.php, except: adding a new feature's filename to the include list in index.php (tell me exactly the one line to change). Do not add keys to config.json unless I ask. If you must, explain why and keep existing keys untouched.
7. Admin-only actions: require $isAdmin AND hash_equals($csrf, $_POST['csrf'] ?? '') on every POST, and put the token in a hidden "csrf" field. Public actions (for example a visitor posting a chat message) need their own validation: check method, length limits, and rate or size limits; say what you chose.
8. Name your form fields with a feature prefix (for example chat_action) so they cannot collide with admin.php, which reads $_POST['action'] and $_POST['id'] only when a POST arrives with a valid admin session.
STORAGE AND PERMISSIONS
- Store data next to the index.php, owned by www-data.
- Write files with locking (flock) and atomic replace (write temp file in the same directory, then rename). Never report success if a write fails. Never overwrite malformed data with defaults; show a clear error.
- Set explicit size and count limits, and validate file names or IDs with a strict allow-list pattern. Never trust client-supplied names or MIME types.
- Tell me the exact commands (mkdir, chown, chmod) for any directory the feature needs, and any php.ini (upload_max_filesize, post_max_size) or Nginx (client_max_body_size) setting required.
IF admin.php OR YOUR FILE IS REMOVED
- The portal must keep working. If your section is meant to have admin controls, they must simply not appear when $isAdmin is false. Never call functions defined in admin.php (field(), portal_update(), go()); write your own small code instead.
DELIVERABLES
1. The complete feature file, with no placeholders or partial snippets.
2. The exact one-line edit to index.php's include list, if the filename is not already there.
3. Any Nginx, php.ini, or filesystem commands needed.
4. A short manual test checklist for the feature, including: missing data directory, unwritable directory, invalid input, oversized input, wrong CSRF token, removing the feature file (portal still loads cleanly), and checking the network tab for non-local requests.
5. A short list of design choices you made where my request was ambiguous.
Do not claim to have tested anything you did not actually run. Say plainly what is untested.

121
admin.php Executable file
View file

@ -0,0 +1,121 @@
<?php
// Optional editing support, included by index.php. Delete this file for a read-only portal.
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Password hash. Create one with:
// php -r 'echo password_hash("your-password", PASSWORD_DEFAULT), "\n";'
// While this is empty, editing is switched off.
const ADMIN_PASSWORD_HASH = '$2y$12$98qfYdiklqjpY/2S4CL.e./XYEnO22GUjPCsIKxMWTLt4SWerRLkm';
// Only start a session for visitors who already have one or are logging in.
if (isset($_COOKIE[session_name()]) || isset($_GET['adminpswd'])) {
session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
}
$here = '/' . ltrim((string)strtok($_SERVER['REQUEST_URI'], '?'), '/');
function go($query = '') { global $here; header('Location: ' . $here . $query); exit; }
// ?adminpswd=... : check it, then redirect to the same page without it.
if (isset($_GET['adminpswd'])) {
$pw = $_GET['adminpswd'];
if (is_string($pw) && ADMIN_PASSWORD_HASH !== '' && password_verify($pw, ADMIN_PASSWORD_HASH)) {
session_regenerate_id(true);
$_SESSION['admin'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
} else {
$_SESSION['flash'] = ['danger', 'Incorrect password.'];
}
$rest = $_GET;
unset($rest['adminpswd']);
go($rest ? '?' . http_build_query($rest) : '');
}
if (session_status() === PHP_SESSION_ACTIVE) {
$isAdmin = !empty($_SESSION['admin']);
$csrf = (string)($_SESSION['csrf'] ?? '');
if (isset($_SESSION['flash'])) { $flash = $_SESSION['flash']; unset($_SESSION['flash']); }
}
// Read a trimmed single-line text field. Returns null if invalid (empty when required, too long, control characters).
function field($name, $max, $required = true) {
$v = trim((string)($_POST[$name] ?? ''));
if ($v === '') return $required ? null : '';
return (strlen($v) > $max || !preg_match('/^[^\x00-\x1F\x7F]*$/u', $v)) ? null : $v;
}
// Lock, re-read the current JSON, apply one change, write atomically. Returns an error message or null.
function portal_update($change) {
$lock = @fopen(CONFIG_FILE . '.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX)) return 'Could not lock the configuration.';
$cfg = json_decode((string)@file_get_contents(CONFIG_FILE), true);
if (!is_array($cfg)) return 'config.json is missing or malformed. Nothing was saved; fix the file first.';
$err = $change($cfg);
if ($err) return $err;
$json = json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
$tmp = tempnam(dirname(CONFIG_FILE), 'cfg');
if (!$tmp || file_put_contents($tmp, $json) !== strlen($json) || !@rename($tmp, CONFIG_FILE)) {
if ($tmp) @unlink($tmp);
return 'Could not write config.json. Check the directory permissions.';
}
return null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if (!$isAdmin || !hash_equals($csrf, (string)($_POST['csrf'] ?? ''))) {
http_response_code(403);
exit('Not allowed.');
}
$act = (string)($_POST['action'] ?? '');
if ($act === 'exit') { session_destroy(); go(); }
$id = (string)($_POST['id'] ?? '');
$err = portal_update(function (array &$cfg) use ($act, $id) {
$cfg['links'] = $cfg['links'] ?? [];
$i = null;
foreach ($cfg['links'] as $k => $l) { if (($l['id'] ?? '') === $id) $i = $k; }
if ($act === 'save_site') {
$t = field('title', 80);
$w = field('welcome', 300, false);
if ($t === null || $w === null) return 'Title is required (max 80) and welcome is max 300 characters, one line.';
$cfg['title'] = $t;
$cfg['welcome'] = $w;
} elseif ($act === 'save_appearance') {
$m = $_POST['mode'] ?? '';
$a = $_POST['accent'] ?? '';
if (!in_array($m, MODES, true) || !in_array($a, ACCENTS, true)) return 'Invalid appearance choice.';
$cfg['appearance'] = ['mode' => $m, 'accent' => $a] + ($cfg['appearance'] ?? []);
} elseif ($act === 'save_link') {
$t = field('title', 60);
$d = field('description', 200, false);
$u = field('url', 300);
$ic = field('icon', 16, false);
if ($t === null || $d === null || $u === null || $ic === null) return 'Title and URL are required; check the lengths.';
if (!preg_match('~^(https?://[^\s/]+|/(?!/))~i', $u)) return 'URL must start with http://, https:// or a single /.';
$row = ['title' => $t, 'description' => $d, 'url' => $u, 'icon' => $ic, 'enabled' => isset($_POST['enabled'])];
if ($id === 'new') $cfg['links'][] = ['id' => 'l' . bin2hex(random_bytes(4))] + $row;
elseif ($i === null) return 'That link no longer exists.';
else $cfg['links'][$i] = $row + $cfg['links'][$i];
} elseif ($i === null) {
return 'That link no longer exists.';
} elseif ($act === 'toggle') {
$cfg['links'][$i]['enabled'] = empty($cfg['links'][$i]['enabled']);
} elseif ($act === 'delete_link') {
array_splice($cfg['links'], $i, 1);
} elseif ($act === 'up' || $act === 'down') {
$j = $act === 'up' ? $i - 1 : $i + 1;
if (isset($cfg['links'][$j])) {
$tmp = $cfg['links'][$i];
$cfg['links'][$i] = $cfg['links'][$j];
$cfg['links'][$j] = $tmp;
}
} else {
return 'Unknown action.';
}
return null;
});
$_SESSION['flash'] = $err ? ['danger', $err] : ['success', 'Saved.'];
$back = ['save_site' => 'site', 'save_appearance' => 'appearance', 'save_link' => $id][$act] ?? '';
go($err && $back !== '' ? '?edit=' . urlencode($back) : '');
}

88
announcement/announcement.php Executable file
View file

@ -0,0 +1,88 @@
<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Announcement bar shown above the portal title. The admin edits it in place.
// Stored in announcement/data/announcement.json as {"text": "...", "level": "info"}.
const ANN_FILE = __DIR__ . '/data/announcement.json';
const ANN_LEVELS = ['info', 'warning', 'danger']; // Bootstrap alert colours
// Returns the saved announcement, an empty one if none yet, or null if the file is not valid JSON.
function ann_read() {
$empty = ['text' => '', 'level' => 'info'];
if (!is_file(ANN_FILE)) return $empty;
$raw = trim((string)@file_get_contents(ANN_FILE));
if ($raw === '') return $empty;
$d = json_decode($raw, true);
return is_array($d) ? $d + $empty : null;
}
// Writes the announcement atomically. Returns an error message or null.
function ann_save($text, $level) {
$lock = @fopen(__DIR__ . '/data/.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX)) return 'Cannot write to announcement/data. Check its permissions.';
if (ann_read() === null) return 'announcement.json is malformed. Nothing was saved; fix or delete the file.';
$json = json_encode(['text' => $text, 'level' => $level], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tmp = @tempnam(__DIR__ . '/data', 'ann');
if ($json === false || !$tmp || file_put_contents($tmp, $json) !== strlen($json) || !@rename($tmp, ANN_FILE)) {
if ($tmp) @unlink($tmp);
return 'Could not save. Check the announcement/data permissions.';
}
return null;
}
// Save or clear (admin only, with CSRF token).
$annNotice = null;
$annSaved = false;
$annAct = $_POST['announcement_action'] ?? null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($annAct, ['save', 'clear'], true)) {
if (!$isAdmin || !hash_equals($csrf, (string)($_POST['csrf'] ?? ''))) {
$annNotice = ['danger', 'Not allowed.'];
} else {
$annText = $annAct === 'clear' ? '' : trim((string)($_POST['announcement_text'] ?? ''));
$annLevel = $annAct === 'clear' ? 'info' : ($_POST['announcement_level'] ?? '');
if (!in_array($annLevel, ANN_LEVELS, true)) {
$annNotice = ['danger', 'Invalid colour choice.'];
} elseif (!preg_match('/^[^\x00-\x1F\x7F]{0,300}$/u', $annText)) {
$annNotice = ['danger', 'The announcement must be one line, up to 300 characters.'];
} else {
$annErr = ann_save($annText, $annLevel);
$annNotice = $annErr ? ['danger', $annErr] : ['success', 'Announcement saved.'];
$annSaved = !$annErr;
}
}
}
$ann = ann_read();
if ($ann === null) {
$ann = ['text' => '', 'level' => 'info'];
if ($isAdmin && !$annNotice) $annNotice = ['danger', 'announcement.json is malformed. Fix or delete it.'];
}
$annLevelOk = in_array($ann['level'], ANN_LEVELS, true) ? $ann['level'] : 'info';
$annEditing = $isAdmin && $edit === 'announcement' && !$annSaved;
?>
<?php if ($annNotice): ?><div class="alert alert-<?= e($annNotice[0]) ?>"><?= e($annNotice[1]) ?></div><?php endif; ?>
<?php if ($annEditing): ?>
<form method="post" class="card card-body mb-3">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<label class="form-label">Announcement (clear it to hide the bar)</label>
<input name="announcement_text" class="form-control mb-2" maxlength="300" value="<?= e($ann['text']) ?>">
<div class="d-flex flex-wrap gap-2 align-items-center">
<select name="announcement_level" class="form-select w-auto" aria-label="Colour">
<option value="info"<?= $annLevelOk === 'info' ? ' selected' : '' ?>>Info (blue)</option>
<option value="warning"<?= $annLevelOk === 'warning' ? ' selected' : '' ?>>Warning (yellow)</option>
<option value="danger"<?= $annLevelOk === 'danger' ? ' selected' : '' ?>>Urgent (red)</option>
</select>
<button class="btn btn-accent" name="announcement_action" value="save">Save</button>
<a class="btn btn-outline-secondary" href="?">Cancel</a>
<button class="btn btn-outline-danger ms-auto" name="announcement_action" value="clear" formnovalidate>Clear</button>
</div>
</form>
<?php elseif ($ann['text'] !== ''): ?>
<div class="alert alert-<?= e($annLevelOk) ?> d-flex justify-content-between gap-2" role="alert">
<span class="text-break"><?= e($ann['text']) ?></span>
<?php if ($isAdmin): ?><a class="edit" href="?edit=announcement" title="Edit announcement" aria-label="Edit announcement">✎</a><?php endif; ?>
</div>
<?php elseif ($isAdmin): ?>
<p class="small"><a href="?edit=announcement">+ Add announcement</a></p>
<?php endif; ?>

0
announcement/data/.lock Executable file
View file

View file

@ -0,0 +1 @@
{"text":"","level":"info"}

6
bootstrap.min.css vendored Executable file

File diff suppressed because one or more lines are too long

11
chat/README.md Executable file
View file

@ -0,0 +1,11 @@
CHAT FEATURES
Chat is modular. To add a chat feature, create files in chat/features/ named after the feature. Every features/*.php is included, and every features/*.js and features/*.css is loaded, in filename order. Do not edit chat.php, chat-live.js or index.php unless I ask.
PHP: start with if (!defined('PORTAL')) { http_response_code(403); exit; }, then register callbacks with chat_on($hook, $fn). Hooks:
- before_send($msg): return the changed message array, or throw new Exception('reason') to reject.
- render_text($html, $msg): the escaped message text; return it changed. Never un-escape.
- msg_top($html, $msg) and msg_bottom($html, $msg): start with ''; return the string with HTML appended (above or below a message).
- form_extra() and below_chat(): echo HTML inside or after the send form.
Hooks that take $html return the new value, not echo it. Use chat_who() for a per-device id, chat_read() to read messages, and chat_update(function (&$msgs) { ... }) to change stored messages safely. To handle your own POST, check chat_action in the feature file; never use a field named "action".
JS: vanilla, one or two jobs per file, no libraries. The page is re-rendered by polling, so listen for the 'chat:updated' event on #chat instead of assuming the log is static. Messages are .chat-msg elements with data-id. Use Bootstrap classes and theme variables; no hard-coded colours, no remote assets.
Keep it simple, say what you did not test, and list any files you created.

58
chat/chat-live.js Executable file
View file

@ -0,0 +1,58 @@
// Live chat: sends forms without reloading the page and refreshes the message list every 3 seconds.
(function () {
var box = document.getElementById('chat');
var log = document.getElementById('chat-log');
var notice = document.getElementById('chat-notice');
if (!box || !log || !notice || !window.fetch || !window.DOMParser) return;
var busy = false;
log.scrollTop = log.scrollHeight;
// Copy the chat log (and optionally the notice) out of a fresh copy of the page.
// Returns false if the server reported an error.
function show(html, withNotice) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var newLog = doc.getElementById('chat-log');
var newNotice = doc.getElementById('chat-notice');
if (!newLog) return false;
if (log.innerHTML !== newLog.innerHTML) {
var stick = log.scrollHeight - log.scrollTop - log.clientHeight < 40; // only follow if already at the bottom
var top = log.scrollTop;
log.innerHTML = newLog.innerHTML;
log.scrollTop = stick ? log.scrollHeight : top;
box.dispatchEvent(new CustomEvent('chat:updated')); // lets features react
}
if (withNotice) notice.innerHTML = newNotice ? newNotice.innerHTML : '';
return !(newNotice && newNotice.querySelector('.alert-danger'));
}
box.addEventListener('submit', function (e) {
var form = e.target;
e.preventDefault();
if (busy) return;
busy = true;
fetch('', { method: 'POST', body: new FormData(form) })
.then(function (r) { return r.text(); })
.then(function (html) {
var ok = show(html, true);
if (form.chat_text) {
if (ok) form.chat_text.value = '';
form.chat_text.focus();
log.scrollTop = log.scrollHeight;
}
})
.catch(function () {
notice.innerHTML = '<div class="alert alert-danger">Could not reach the server.</div>';
})
.then(function () { busy = false; });
});
var ticks = 0;
setInterval(function () {
if (busy || (document.hidden && ++ticks % 5)) return; // hidden tabs poll every 15 seconds
fetch('', { cache: 'no-store' })
.then(function (r) { return r.text(); })
.then(function (html) { show(html, false); })
.catch(function () {});
}, 3000);
})();

9
chat/chat-name.js Executable file
View file

@ -0,0 +1,9 @@
// Remembers the visitor's chat name in localStorage and fills it in next time.
(function () {
var input = document.querySelector('#chat [name="chat_name"]');
if (!input) return;
try { if (!input.value) input.value = localStorage.getItem('chat-name') || ''; } catch (e) {}
input.addEventListener('input', function () {
try { localStorage.setItem('chat-name', input.value.trim()); } catch (e) {}
});
})();

154
chat/chat.php Executable file
View file

@ -0,0 +1,154 @@
<?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>

0
chat/data/.lock Executable file
View file

19
chat/features/emoji.js Executable file
View file

@ -0,0 +1,19 @@
// Adds a row of quick emoji buttons that insert into the message box.
(function () {
var input = document.querySelector('#chat [name="chat_text"]');
if (!input) return;
var row = document.createElement('div');
row.className = 'mt-2';
['😀', '😂', '👍', '❤️', '🎉', '🙏'].forEach(function (emoji) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-secondary me-1';
b.textContent = emoji;
b.addEventListener('click', function () {
input.setRangeText(emoji, input.selectionStart, input.selectionEnd, 'end');
input.focus();
});
row.appendChild(b);
});
input.form.appendChild(row);
})();

9
chat/features/linkify.php Executable file
View file

@ -0,0 +1,9 @@
<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Makes http(s) links in messages clickable. The text is already HTML-escaped when it arrives here.
chat_on('render_text', function ($html) {
return preg_replace_callback('~\bhttps?://[^\s]*[^\s.,!?;:)]~i', function ($m) {
return '<a href="' . $m[0] . '" target="_blank" rel="noopener noreferrer">' . $m[0] . '</a>';
}, $html);
});

24
chat/features/mentions.js Executable file
View file

@ -0,0 +1,24 @@
// Highlights messages that @mention you. "@john" matches anyone whose name starts with "john".
(function () {
var box = document.getElementById('chat');
var nameInput = box && box.querySelector('[name="chat_name"]');
if (!nameInput) return;
function mark() {
var m = nameInput.value.match(/[\p{L}\p{N}_]+/u);
var key = m ? m[0].toLowerCase() : '';
box.querySelectorAll('.chat-msg').forEach(function (msg) {
var hit = false;
if (key) {
msg.querySelectorAll('.chat-mention').forEach(function (el) {
if (el.dataset.mention.toLowerCase() === key) hit = true;
});
}
['bg-warning-subtle', 'rounded', 'px-1'].forEach(function (c) { msg.classList.toggle(c, hit); });
});
}
box.addEventListener('chat:updated', mark);
nameInput.addEventListener('input', mark);
mark();
})();

8
chat/features/mentions.php Executable file
View file

@ -0,0 +1,8 @@
<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Mentions: "@john" is highlighted. Pairs with mentions.js, which highlights messages that mention you.
// A mention is "@" at the start of a word followed by letters/digits; it matches anyone whose name starts with that.
chat_on('render_text', function ($html) {
return preg_replace('/(?<!\S)@(\w+)/u', '<mark class="chat-mention px-1" data-mention="$1">@$1</mark>', $html);
});

42
chat/features/off/reactions.php Executable file
View file

@ -0,0 +1,42 @@
<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Reactions: emoji buttons under each message; click again to remove yours.
// Stored on the message as "reactions": {"👍": ["<device id>", ...]}. One per emoji per device.
$reactEmoji = ['👍', '❤️', '😂'];
// Handle a click (a plain POST, so it works with or without JavaScript).
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['chat_action'] ?? '') === 'react') {
$reactId = (int)($_POST['chat_id'] ?? 0);
$reactPick = (string)($_POST['chat_emoji'] ?? '');
if ($reactId > 0 && in_array($reactPick, $reactEmoji, true)) {
chat_update(function (&$msgs) use ($reactId, $reactPick) {
foreach ($msgs as &$m) {
if (($m['id'] ?? 0) !== $reactId) continue;
$list = $m['reactions'][$reactPick] ?? [];
if (!is_array($list)) $list = [];
$k = array_search(chat_who(), $list, true);
if ($k === false) $list[] = chat_who(); else array_splice($list, $k, 1);
if ($list) $m['reactions'][$reactPick] = $list; else unset($m['reactions'][$reactPick]);
if (empty($m['reactions'])) unset($m['reactions']);
}
unset($m);
return null;
});
}
}
chat_on('msg_bottom', function ($html, $msg) use ($reactEmoji) {
foreach ($reactEmoji as $emoji) {
$list = $msg['reactions'][$emoji] ?? [];
if (!is_array($list)) $list = [];
$mine = in_array(chat_who(), $list, true);
$html .= '<form method="post" class="d-inline">'
. '<input type="hidden" name="chat_action" value="react">'
. '<input type="hidden" name="chat_id" value="' . (int)($msg['id'] ?? 0) . '">'
. '<input type="hidden" name="chat_emoji" value="' . e($emoji) . '">'
. '<button class="btn btn-sm py-0 px-1 ' . ($mine ? 'btn-accent' : 'btn-outline-secondary') . '">'
. e($emoji) . ($list ? ' ' . count($list) : '') . '</button></form>';
}
return $html;
});

35
chat/features/replies.js Executable file
View file

@ -0,0 +1,35 @@
// Click "Reply" under a message and your next message quotes it. Works with replies.php.
(function () {
var box = document.getElementById('chat');
var input = box && box.querySelector('[name="chat_text"]');
if (!input) return;
var form = input.form;
var hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'chat_reply';
var bar = document.createElement('div');
bar.className = 'small text-body-secondary mb-2';
bar.hidden = true;
form.prepend(hidden, bar);
function clear() { hidden.value = ''; bar.hidden = true; }
box.addEventListener('click', function (e) {
var b = e.target.closest('.chat-reply, .chat-reply-cancel');
if (!b) return;
if (b.classList.contains('chat-reply-cancel')) return clear();
hidden.value = b.dataset.id;
bar.textContent = '↩ Replying to ' + b.dataset.name + ': ' + b.dataset.text + ' ';
var x = document.createElement('button');
x.type = 'button';
x.className = 'btn btn-sm btn-link p-0 chat-reply-cancel';
x.textContent = '✕ Cancel';
bar.appendChild(x);
bar.hidden = false;
input.focus();
});
// Clear after the page script has read the form (it runs after this handler).
form.addEventListener('submit', function () { setTimeout(clear, 0); });
})();

33
chat/features/replies.php Executable file
View file

@ -0,0 +1,33 @@
<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Replies: a message can quote an earlier one. Pairs with replies.js, which adds the "Reply" buttons.
// The quote (name + first 80 characters) is saved with the message, so it survives if the original is deleted.
$replySnippet = function ($text) {
return preg_match('/^.{1,80}/us', (string)$text, $x) ? $x[0] : '';
};
// The send form carries the id of the message being replied to in chat_reply (empty if none).
chat_on('before_send', function ($msg) use ($replySnippet) {
$id = (int)($_POST['chat_reply'] ?? 0);
if ($id > 0) {
foreach (chat_read() ?: [] as $m) {
if (($m['id'] ?? 0) === $id) {
$msg['reply'] = ['id' => $id, 'name' => (string)($m['name'] ?? ''), 'text' => $replySnippet($m['text'] ?? '')];
}
}
}
return $msg;
});
chat_on('msg_top', function ($html, $msg) {
$r = $msg['reply'] ?? null;
if (!is_array($r)) return $html;
return $html . '<div class="border-start border-3 ps-2 small text-body-secondary text-truncate">↩ <strong>'
. e($r['name'] ?? '') . '</strong> ' . e($r['text'] ?? '') . '</div>';
});
chat_on('msg_bottom', function ($html, $msg) use ($replySnippet) {
return $html . '<button type="button" class="btn btn-sm btn-link p-0 chat-reply" data-id="' . (int)($msg['id'] ?? 0)
. '" data-name="' . e($msg['name'] ?? '') . '" data-text="' . e($replySnippet($msg['text'] ?? '')) . '">↩ Reply</button>';
});

24
chat/features/unread.js Executable file
View file

@ -0,0 +1,24 @@
// Shows the number of new messages in the browser tab title while the tab is in the background.
(function () {
var box = document.getElementById('chat');
if (!box) return;
var title = document.title, unread = 0, seen = 0;
function ids() {
return Array.prototype.map.call(box.querySelectorAll('.chat-msg'), function (m) { return +m.dataset.id; });
}
seen = Math.max.apply(null, ids().concat(0));
box.addEventListener('chat:updated', function () {
var fresh = ids().filter(function (id) { return id > seen; });
if (!fresh.length) return;
seen = Math.max.apply(null, fresh.concat(seen));
if (document.hidden) {
unread += fresh.length;
document.title = '(' + unread + ') ' + title;
}
});
document.addEventListener('visibilitychange', function () {
if (!document.hidden) { unread = 0; document.title = title; }
});
})();

18
config.json Executable file
View file

@ -0,0 +1,18 @@
{
"title": "The Offline Box",
"welcome": "Welcome to the local network.",
"appearance": {
"mode": "auto",
"accent": "blue"
},
"links": [
{
"id": "library",
"title": "Offline Library",
"description": "Browse our Kiwix collection",
"url": "http://192.168.50.1:8080",
"icon": "📚",
"enabled": true
}
]
}

126
files/README.txt Executable file
View file

@ -0,0 +1,126 @@
OFFLINE PORTAL FILE SHARING
INSTALL
Extract the files directory into the portal web root. No CSS/JS required.
Add this exact entry to the fixed include list in index.php:
'files/files.php',
Commands (adjust /var/www/portal if necessary):
sudo chown -R root:root /var/www/portal/files
sudo find /var/www/portal/files -type d -exec chmod 755 {} \;
sudo find /var/www/portal/files -type f -exec chmod 644 {} \;
sudo mkdir -p /var/www/portal/files/data/uploads
sudo chown -R www-data:www-data /var/www/portal/files/data
sudo find /var/www/portal/files/data -type d -exec chmod 750 {} \;
sudo find /var/www/portal/files/data -type f -exec chmod 640 {} \;
NGINX — REQUIRED BEFORE USE
Place these inside the existing server block. This example assumes the portal
is served at /. For /portal/, change both location paths to start /portal/files/.
Use the same root and fastcgi_pass socket as the existing index.php handler.
Never enable PHP execution for the entire files directory.
location ^~ /files/data/ {
deny all;
}
location = /files/download.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/files/download.php;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
The socket above is an example: find the actual socket with:
ls /run/php/*-fpm.sock
Keep your existing rule returning 404 for every other PHP request except
/index.php. Exact download location takes precedence. common.php must never
be served as plaintext or executed directly. Keep directory autoindex off.
For an aliased portal, use the actual filesystem path in SCRIPT_FILENAME
instead of $document_root. Do not blindly replace your server configuration.
Set in the existing server block (or raise an existing lower limit):
client_max_body_size 55m;
Set in the active PHP-FPM php.ini, not CLI php.ini:
file_uploads = On
upload_max_filesize = 50M
post_max_size = 55M
max_file_uploads = 1
max_input_time = 300
Reload the actual PHP-FPM service after editing, for example:
sudo systemctl reload php8.4-fpm
sudo nginx -t && sudo systemctl reload nginx
Large/slow uploads may also require adjusting existing web-server/proxy
request timeouts. PHP upload temporary storage must have sufficient space.
USE
Visitors choose one file and click Upload; click its name to download.
Administrator sessions additionally show Delete buttons. Deletion is immediate.
After a successful upload, reload without resubmitting the POST before another
upload. No redirect is used because the feature runs after portal output.
The global upload cooldown often catches immediate refresh resubmissions but
is not an idempotency guarantee.
SETTINGS
Edit the four FILES_* constants at the top of common.php. If changing the
per-file size also change the displayed help text/error, PHP limits, and Nginx
request limit. Private store.json is created on first successful upload.
Do not hand-edit metadata while the site is accepting changes.
DESIGN CHOICES
- Three small PHP files: portal include, standalone download endpoint, shared
feature-only storage helpers. No external feature dependencies.
- All uploaded bytes are private .bin objects keyed by random IDs. Download
filenames preserve ASCII letters, digits, spaces, dots, parentheses, hyphens,
and underscores, capped at 140 characters. Other characters become underscores.
Path components and leading/trailing dots/spaces are removed. Empty names
become download. Duplicate display filenames do not overwrite files.
- Any file content is accepted. MIME types and extensions are NOT trusted as
security boundaries. Uploaded bytes are never included/executed or served
directly; downloads are attachments with nosniff. These protections depend
on the required Nginx private-directory deny and PHP execution restrictions.
Downloads are not scanned, and can be dangerous if opened on a client.
- Public upload limit is global: one upload every 10 seconds. Not a login or
per-user abuse-prevention system. Public uploads are intentionally anonymous
and have no session CSRF token; untrusted clients can consume the public quota.
- 50 MiB per file, 500 metadata entries, 1 GiB stored file budget, 1 MiB metadata,
plus a 10 MiB free-space margin. No zero-byte uploads.
- Admin delete uses the explicitly required csrf field exception to the general
prefixed-field rule. All feature-specific request names start files_.
- No Range/resume, streaming upload, thumbnails, preview, download counter,
confirmations, or JavaScript. Download responses support GET and HEAD.
- Serialized mutations use a separate flock lock and atomic metadata rename.
On upload metadata-save failure the new object is removed. A process crash
can still leave an unlisted object; these count against the storage budget.
Delete removes bytes before metadata: if metadata saving fails, a stale entry
remains with a clear save error; repeat Delete once storage is repaired.
This is not a transactional database and does not promise power-loss durability.
- No read or write of portal config.json and no calls into admin.php.
- Over post_max_size PHP can discard POST entirely: the feature cannot identify
that request. Nginx rejects requests above its own limit; lower PHP limits
may cause a plain page response with no feature error. Align the supplied limits.
MANUAL TEST CHECKLIST (NOT EXECUTED HERE)
[ ] php -l files/files.php; php -l files/common.php; php -l files/download.php
[ ] Upload text/image/archive; download and compare hashes and displayed names.
[ ] Upload duplicate names; confirm both remain independently downloadable.
[ ] Upload awkward paths, quotes, Unicode names; inspect sanitized names.
[ ] Submit array-shaped/missing/invalid fields and IDs; expect clear errors.
[ ] Empty file, >50 MiB, full count/quota, rapid upload; reject without success.
[ ] Missing or unwritable data/uploads; show error; restore permissions afterward.
[ ] Corrupt store.json temporarily; display error and do not replace its contents.
[ ] Wrong/missing admin csrf, or public delete POST; must not delete anything.
[ ] Anonymous direct download works; unknown ID gives 404; POST gives 405.
[ ] HEAD gives attachment headers with no body.
[ ] /files/data/store.json and guessed uploads return 403/404.
[ ] /files/files.php and /files/common.php return 404, never source text.
[ ] Remove admin.php: no Delete buttons, uploads/downloads still work.
[ ] Remove files directory: portal include is skipped without warnings.
[ ] Browser network panel: only local requests.
[ ] Test mobile layout, dark/light themes, and accent button.
TEST STATUS
Generated for review; PHP syntax and runtime behavior, Nginx, concurrent requests,
permissions, and browser behavior have NOT been executed/tested here.

54
files/common.php Executable file
View file

@ -0,0 +1,54 @@
<?php
// Shared only by this feature's entry file and download endpoint.
const FILES_MAX_BYTES = 50 * 1024 * 1024;
const FILES_TOTAL_BYTES = 1024 * 1024 * 1024;
const FILES_MAX_COUNT = 500;
const FILES_INTERVAL = 10;
function files_string($value): string { return is_string($value) ? $value : ''; }
function files_name(string $name): string {
$name = basename(str_replace('\\', '/', $name));
$name = preg_replace('/[^A-Za-z0-9._ ()-]/', '_', $name);
$name = trim(substr($name, 0, 140), " .\t\r\n");
return $name === '' ? 'download' : $name;
}
function files_lock(int $mode) {
if (!is_dir(__DIR__ . '/data') || !is_dir(__DIR__ . '/data/uploads')) {
throw new RuntimeException('Missing files/data or files/data/uploads. Run the setup commands.');
}
$lock = @fopen(__DIR__ . '/data/store.lock', 'c');
if (!$lock || !flock($lock, $mode)) {
if (is_resource($lock)) fclose($lock);
throw new RuntimeException('Cannot lock file storage. Check data directory permissions.');
}
return $lock;
}
function files_read(): array {
$path = __DIR__ . '/data/store.json';
if (!file_exists($path)) return ['items' => [], 'last_upload' => 0];
if (filesize($path) > 1048576) throw new RuntimeException('Metadata exceeds its 1 MiB limit.');
$raw = @file_get_contents($path);
if ($raw === false) throw new RuntimeException('Cannot read file metadata.');
try { $store = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); }
catch (JsonException $ex) { throw new RuntimeException('Malformed file metadata; no changes were made.'); }
if (!is_array($store) || !isset($store['items'], $store['last_upload']) || !is_array($store['items']) || !is_int($store['last_upload']) || count($store['items']) > FILES_MAX_COUNT) {
throw new RuntimeException('Invalid file metadata structure; no changes were made.');
}
foreach ($store['items'] as $key => $item) {
if (!preg_match('/^[a-f0-9]{32}$/D', (string)$key) || !is_array($item) || !isset($item['name'], $item['size'], $item['time']) || !is_string($item['name']) || files_name($item['name']) !== $item['name'] || !is_int($item['size']) || $item['size'] < 0 || $item['size'] > FILES_MAX_BYTES || !is_int($item['time'])) {
throw new RuntimeException('Invalid file metadata entry; no changes were made.');
}
}
return $store;
}
function files_write(array $store): void {
$json = json_encode($store, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
if (strlen($json) > 1048576) throw new RuntimeException('Metadata limit reached.');
$temp = @tempnam(__DIR__ . '/data', 'save-');
if ($temp === false) throw new RuntimeException('Cannot create temporary metadata file.');
try {
if (file_put_contents($temp, $json) !== strlen($json) || !chmod($temp, 0640) || !rename($temp, __DIR__ . '/data/store.json')) {
throw new RuntimeException('Cannot save file metadata. Check permissions and free space.');
}
} finally { if (is_file($temp)) @unlink($temp); }
}

4
files/data/store.json Executable file
View file

@ -0,0 +1,4 @@
{
"items": [],
"last_upload": 1792179269
}

0
files/data/store.lock Executable file
View file

35
files/download.php Executable file
View file

@ -0,0 +1,35 @@
<?php
// The only independently executable feature endpoint. No portal session needed.
require_once __DIR__ . '/common.php';
if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', ['GET', 'HEAD'], true)) {
header('Allow: GET, HEAD'); http_response_code(405); exit;
}
$filesKey = files_string($_GET['files_id'] ?? null);
if (!preg_match('/^[a-f0-9]{32}$/D', $filesKey)) { http_response_code(404); exit('File not found.'); }
$filesStream = null;
try {
$filesLock = files_lock(LOCK_SH);
try {
$filesStore = files_read();
if (!isset($filesStore['items'][$filesKey])) { http_response_code(404); exit('File not found.'); }
$filesItem = $filesStore['items'][$filesKey];
$filesStream = @fopen(__DIR__ . '/data/uploads/' . $filesKey . '.bin', 'rb');
if (!$filesStream) { http_response_code(404); exit('Stored file is missing.'); }
$filesStat = fstat($filesStream);
if (!$filesStat || $filesStat['size'] !== $filesItem['size']) throw new RuntimeException('Stored file size mismatch.');
} finally { flock($filesLock, LOCK_UN); fclose($filesLock); }
} catch (Throwable $filesException) {
if (is_resource($filesStream)) fclose($filesStream);
http_response_code(503); header('Content-Type: text/plain; charset=UTF-8');
exit('File storage is unavailable. Check the portal file-sharing section.');
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filesItem['name'] . '"');
header('Content-Length: ' . $filesItem['size']);
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
@set_time_limit(0);
fpassthru($filesStream);
}
fclose($filesStream);

112
files/files.php Executable file
View file

@ -0,0 +1,112 @@
<?php
if (!defined('PORTAL')) { http_response_code(403); exit; }
require_once __DIR__ . '/common.php';
function files_handle(bool $admin, string $token): ?array {
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !isset($_POST['files_action'])) return null;
$operation = files_string($_POST['files_action']);
if (!in_array($operation, ['upload', 'delete'], true)) throw new RuntimeException('Invalid file action.');
if ($operation === 'delete' && (!$admin || $token === '' || !hash_equals($token, files_string($_POST['csrf'] ?? null)))) {
throw new RuntimeException('Deletion denied: administrator access and a valid CSRF token are required.');
}
$lock = files_lock(LOCK_EX);
try {
$store = files_read();
if ($operation === 'delete') {
$key = files_string($_POST['files_id'] ?? null);
if (!preg_match('/^[a-f0-9]{32}$/D', $key) || !isset($store['items'][$key])) throw new RuntimeException('Unknown file.');
// Remove the actual file first; retain metadata on an unlink failure.
$path = __DIR__ . '/data/uploads/' . $key . '.bin';
if (is_file($path) && !@unlink($path)) throw new RuntimeException('Cannot delete stored file.');
unset($store['items'][$key]);
files_write($store);
return ['success', 'File deleted.'];
}
if (time() - $store['last_upload'] < FILES_INTERVAL) throw new RuntimeException('Please wait 10 seconds between uploads (shared by all visitors).');
$upload = $_FILES['files_upload'] ?? null;
if (!is_array($upload) || !isset($upload['error']) || !is_int($upload['error'])) throw new RuntimeException('Choose one file to upload.');
if ($upload['error'] !== UPLOAD_ERR_OK) throw new RuntimeException('Upload failed (PHP error ' . $upload['error'] . '). Check upload limits and temporary storage.');
$temp = files_string($upload['tmp_name'] ?? null);
if (!is_uploaded_file($temp)) throw new RuntimeException('Invalid uploaded file.');
$size = filesize($temp);
if ($size === false || $size < 1 || $size > FILES_MAX_BYTES) throw new RuntimeException('Files must be between 1 byte and 50 MiB.');
if (count($store['items']) >= FILES_MAX_COUNT) throw new RuntimeException('The 500-file limit has been reached.');
$used = array_sum(array_column($store['items'], 'size'));
// Include orphaned files left by interrupted requests in the storage budget.
$actual = 0;
foreach (glob(__DIR__ . '/data/uploads/*.bin') ?: [] as $stored) {
$bytes = filesize($stored);
if ($bytes === false) throw new RuntimeException('Cannot measure stored files.');
$actual += $bytes;
}
if (max($used, $actual) + $size > FILES_TOTAL_BYTES) throw new RuntimeException('The 1 GiB storage budget would be exceeded.');
$free = disk_free_space(__DIR__ . '/data');
if ($free === false || $free < $size + 10 * 1024 * 1024) throw new RuntimeException('Not enough free disk space.');
$key = bin2hex(random_bytes(16));
$destination = __DIR__ . '/data/uploads/' . $key . '.bin';
if (!move_uploaded_file($temp, $destination)) throw new RuntimeException('Cannot store uploaded file.');
try {
if (!chmod($destination, 0640)) throw new RuntimeException('Cannot set uploaded file permissions.');
$store['items'][$key] = ['name' => files_name(files_string($upload['name'] ?? null)), 'size' => $size, 'time' => time()];
$store['last_upload'] = time();
files_write($store);
} catch (Throwable $ex) {
if (!@unlink($destination)) throw new RuntimeException('Save failed and cleanup also failed. Check data/uploads for an orphan file.');
throw $ex;
}
return ['success', 'Uploaded: ' . $store['items'][$key]['name']];
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
$filesNotice = null;
$filesItems = [];
$filesReady = true;
try {
$filesNotice = files_handle((bool)$isAdmin, (string)($csrf ?? ''));
} catch (Throwable $filesException) {
$filesNotice = ['danger', $filesException->getMessage()];
}
try {
$filesLock = files_lock(LOCK_SH);
try { $filesStore = files_read(); $filesItems = $filesStore['items']; }
finally { flock($filesLock, LOCK_UN); fclose($filesLock); }
uasort($filesItems, fn($filesLeft, $filesRight) => $filesRight['time'] <=> $filesLeft['time']);
} catch (Throwable $filesException) {
$filesReady = false;
$filesNotice = ['danger', $filesException->getMessage()];
}
?>
<section class="mb-4" aria-labelledby="files-heading">
<h2 class="h4" id="files-heading">File sharing</h2>
<?php if ($filesNotice): ?>
<div class="alert alert-<?= e($filesNotice[0]) ?>" role="alert"><?= e($filesNotice[1]) ?></div>
<?php endif; ?>
<p class="text-body-secondary small">Public uploads and downloads. Maximum 50 MiB per file, 500 files, 1 GiB total. One upload every 10 seconds across the site. Downloads are not scanned for malware.</p>
<?php if ($filesReady): ?>
<form method="post" enctype="multipart/form-data" class="card card-body mb-3">
<label for="files-upload" class="form-label">Share a file</label>
<input type="file" class="form-control mb-2" id="files-upload" name="files_upload" required>
<div><button type="submit" class="btn btn-accent" name="files_action" value="upload">Upload</button></div>
</form>
<?php if (!$filesItems): ?><p class="text-body-secondary">No shared files yet.</p><?php endif; ?>
<ul class="list-group">
<?php foreach ($filesItems as $filesKey => $filesItem): ?>
<li class="list-group-item">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="text-break">
<a href="<?= e('files/download.php?files_id=' . rawurlencode($filesKey)) ?>"><?= e($filesItem['name']) ?></a>
<div class="small text-body-secondary"><?= e(number_format($filesItem['size'] / 1048576, 2)) ?> MiB · <?= e(gmdate('Y-m-d H:i', $filesItem['time'])) ?> UTC</div>
</div>
<?php if ($isAdmin): ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="files_id" value="<?= e($filesKey) ?>">
<button class="btn btn-outline-danger btn-sm" name="files_action" value="delete">Delete</button>
</form>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>

187
index.php Executable file
View file

@ -0,0 +1,187 @@
<?php
const PORTAL = true;
const CONFIG_FILE = 'config.json';
const ACCENTS = ['blue', 'green', 'orange', 'purple'];
const MODES = ['auto', 'light', 'dark'];
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
function safe_url($u) { return preg_match('~^(https?://|/(?!/))~i', (string)$u) ? $u : '#'; }
$config = json_decode((string)@file_get_contents(CONFIG_FILE), true);
$configError = null;
if (!is_array($config)) {
$configError = 'config.json is missing or malformed, so defaults are shown.';
$config = ['title' => 'Portal', 'welcome' => '', 'links' => []];
}
// Set by admin.php when it exists; otherwise the portal stays read-only.
$isAdmin = false;
$csrf = '';
$flash = null;
if (is_file(__DIR__ . '/admin.php')) require __DIR__ . '/admin.php';
$mode = in_array($config['appearance']['mode'] ?? '', MODES, true) ? $config['appearance']['mode'] : 'auto';
$accent = in_array($config['appearance']['accent'] ?? '', ACCENTS, true) ? $config['appearance']['accent'] : 'blue';
$edit = ($isAdmin && is_string($_GET['edit'] ?? null)) ? $_GET['edit'] : '';
$links = array_values(array_filter($config['links'] ?? [], 'is_array'));
if ($edit === 'new') $links[] = ['id' => 'new', 'enabled' => true];
?>
<!doctype html>
<html lang="en" data-bs-theme="<?= $mode === 'dark' ? 'dark' : 'light' ?>" data-accent="<?= e($accent) ?>"
data-default-mode="<?= e($mode) ?>" data-default-accent="<?= e($accent) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($config['title'] ?? 'Portal') ?></title>
<link rel="stylesheet" href="bootstrap.min.css">
<link rel="stylesheet" href="portal.css">
<script src="theme.js"></script>
</head>
<body>
<main class="container py-4">
<?php if ($flash): ?><div class="alert alert-<?= e($flash[0]) ?>"><?= e($flash[1]) ?></div><?php endif; ?>
<?php if ($configError): ?><div class="alert alert-danger"><?= e($configError) ?></div><?php endif; ?>
<?php if ($isAdmin): ?>
<div class="alert alert-secondary d-flex flex-wrap gap-2 align-items-center">
<strong class="me-auto">Editing mode</strong>
<a class="btn btn-sm btn-outline-secondary" href="?edit=appearance">✎ Site default theme</a>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<button class="btn btn-sm btn-outline-secondary" name="action" value="exit">Exit editing</button>
</form>
</div>
<?php if ($edit === 'appearance'): ?>
<form method="post" class="card card-body mb-4">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<div class="row g-2 mb-2">
<div class="col-sm-6"><label class="form-label">Default mode</label>
<select name="mode" class="form-select">
<?php foreach (MODES as $m): ?><option value="<?= $m ?>"<?= $m === $mode ? ' selected' : '' ?>><?= $m === 'auto' ? 'follow device' : $m ?></option><?php endforeach; ?>
</select></div>
<div class="col-sm-6"><label class="form-label">Default accent</label>
<select name="accent" class="form-select">
<?php foreach (ACCENTS as $a): ?><option value="<?= $a ?>"<?= $a === $accent ? ' selected' : '' ?>><?= $a ?></option><?php endforeach; ?>
</select></div>
</div>
<div><button class="btn btn-accent" name="action" value="save_appearance">Save</button>
<a class="btn btn-outline-secondary" href="?">Cancel</a></div>
</form>
<?php endif; ?>
<?php endif; ?>
<?php
// Banner features, shown above the title with no panel around them. Add a path here to put a feature at the top.
foreach (['announcement/announcement.php'] as $part) {
if (is_file(__DIR__ . '/' . $part)) include __DIR__ . '/' . $part;
}
?>
<header class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div class="flex-grow-1">
<?php if ($edit === 'site'): ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<label class="form-label">Title</label>
<input name="title" class="form-control mb-2" maxlength="80" required value="<?= e($config['title'] ?? '') ?>">
<label class="form-label">Welcome message</label>
<input name="welcome" class="form-control mb-2" maxlength="300" value="<?= e($config['welcome'] ?? '') ?>">
<button class="btn btn-accent" name="action" value="save_site">Save</button>
<a class="btn btn-outline-secondary" href="?">Cancel</a>
</form>
<?php else: ?>
<h1 class="h2"><?= e($config['title'] ?? '') ?></h1>
<?php if ($isAdmin): ?><a class="edit" href="?edit=site" title="Edit title and welcome" aria-label="Edit title and welcome">✎</a><?php endif; ?>
<p class="lead mb-0"><?= e($config['welcome'] ?? '') ?></p>
<?php endif; ?>
</div>
<div id="appearance" hidden>
<div class="btn-group btn-group-sm" role="group" aria-label="Colour mode">
<button type="button" class="btn btn-outline-secondary" data-mode="light">Light</button>
<button type="button" class="btn btn-outline-secondary" data-mode="dark">Dark</button>
<button type="button" class="btn btn-outline-secondary" data-mode="auto">Device</button>
</div>
<span class="d-inline-flex gap-1 align-middle ms-2">
<?php foreach (ACCENTS as $a): ?><button type="button" class="swatch" data-pick="<?= $a ?>" aria-label="<?= $a ?> accent" title="<?= $a ?>"></button><?php endforeach; ?>
</span>
<button type="button" class="btn btn-sm btn-link" data-reset="1">Reset</button>
</div>
</header>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-4">
<?php foreach ($links as $l):
$id = (string)($l['id'] ?? '');
$on = !empty($l['enabled']);
if (!$on && !$isAdmin) continue; ?>
<div class="col"><div class="card h-100<?= $on ? '' : ' disabled-card' ?>">
<div class="card-body">
<?php if ($isAdmin && $edit === $id && $id !== ''): ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="id" value="<?= e($id) ?>">
<input name="title" class="form-control mb-2" placeholder="Title" maxlength="60" required value="<?= e($l['title'] ?? '') ?>">
<input name="description" class="form-control mb-2" placeholder="Description" maxlength="200" value="<?= e($l['description'] ?? '') ?>">
<input name="url" class="form-control mb-2" placeholder="http://192.168.50.1:8080" maxlength="300" required value="<?= e($l['url'] ?? '') ?>">
<input name="icon" class="form-control mb-2" placeholder="Icon (optional emoji or letters)" maxlength="16" value="<?= e($l['icon'] ?? '') ?>">
<div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="enabled" id="en-<?= e($id) ?>"<?= $on ? ' checked' : '' ?>>
<label class="form-check-label" for="en-<?= e($id) ?>">Enabled</label></div>
<div class="d-flex flex-wrap gap-1">
<button class="btn btn-accent" name="action" value="save_link">Save</button>
<a class="btn btn-outline-secondary" href="?">Cancel</a>
<?php if ($id !== 'new'): ?><button class="btn btn-outline-danger ms-auto" name="action" value="delete_link" formnovalidate onclick="return confirm('Delete this link?')">Delete</button><?php endif; ?>
</div>
</form>
<?php else: ?>
<div class="d-flex gap-3">
<span class="icon" aria-hidden="true"><?= (e($l['icon'] ?? '') ?: '🔗') ?></span>
<div>
<h2 class="h5 card-title mb-1"><a class="text-decoration-none<?= $isAdmin ? '' : ' stretched-link' ?>" href="<?= e(safe_url($l['url'] ?? '')) ?>"><?= e($l['title'] ?? '') ?></a></h2>
<p class="card-text text-body-secondary mb-0"><?= e($l['description'] ?? '') ?></p>
<?php if (!$on): ?><span class="badge text-bg-secondary mt-2">Disabled</span><?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($isAdmin && $edit !== $id && $id !== ''): ?>
<div class="card-footer">
<form method="post" class="d-flex gap-1">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="id" value="<?= e($id) ?>">
<button class="btn btn-sm btn-outline-secondary" name="action" value="up" aria-label="Move up">↑</button>
<button class="btn btn-sm btn-outline-secondary" name="action" value="down" aria-label="Move down">↓</button>
<button class="btn btn-sm btn-outline-secondary" name="action" value="toggle"><?= $on ? 'Disable' : 'Enable' ?></button>
<a class="btn btn-sm btn-outline-secondary ms-auto" href="?edit=<?= urlencode($id) ?>">✎ Edit</a>
</form>
</div>
<?php endif; ?>
</div></div>
<?php endforeach; ?>
<?php if ($isAdmin && $edit !== 'new'): ?>
<div class="col"><a class="card h-100 text-decoration-none" href="?edit=new"><div class="card-body d-flex align-items-center justify-content-center">+ Add link</div></a></div>
<?php endif; ?>
</div>
<?php
// Optional sections. To add a feature, create <name>/<name>.php and add 'name/name.php' to this list.
// Each file renders its own HTML, can use $config, $isAdmin and $csrf, and shares portal.css.
foreach (['chat/chat.php', 'files/files.php'] as $part) {
if (!is_file(__DIR__ . '/' . $part)) continue;
echo '<section class="feature">';
include __DIR__ . '/' . $part;
echo '</section>';
}
?>
</main>
<script src="theme-controls.js"></script>
</body>
</html>

28
portal.css Executable file
View file

@ -0,0 +1,28 @@
/* Small custom styles. Included sections (files.php, chat.php, ...) share this file. */
html { --accent: #0d6efd; }
html[data-accent="green"] { --accent: #198754; }
html[data-accent="orange"] { --accent: #e8590c; }
html[data-accent="purple"] { --accent: #6f42c1; }
h1 { border-bottom: 3px solid var(--accent); display: inline-block; padding-bottom: .15rem; }
a { color: var(--accent); }
.btn-accent { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-accent:hover { filter: brightness(.9); color: #fff; }
.card:hover { border-color: var(--accent); }
.card-title a { color: inherit; }
.icon { font-size: 1.8rem; line-height: 1; }
.disabled-card { opacity: .6; }
.edit { text-decoration: none; font-size: 1rem; }
.swatch { width: 1.5rem; height: 1.5rem; border-radius: 50%; padding: 0; border: 2px solid var(--bs-border-color); }
.swatch[data-pick="blue"] { background: #0d6efd; }
.swatch[data-pick="green"] { background: #198754; }
.swatch[data-pick="orange"] { background: #e8590c; }
.swatch[data-pick="purple"] { background: #6f42c1; }
.feature {
border: 1px solid var(--bs-border-color);
border-top: 3px solid var(--accent);
border-radius: .5rem;
padding: 1rem;
margin-bottom: 1.5rem;
background: var(--bs-tertiary-bg);
}

21
theme-controls.js Executable file
View file

@ -0,0 +1,21 @@
// Wires up the appearance buttons and remembers the choice in localStorage (if available).
(function () {
var box = document.getElementById('appearance');
if (!box || !window.portalTheme) return;
var prefs = Object.assign({}, window.portalTheme.current());
box.addEventListener('click', function (e) {
var b = e.target.closest('button');
if (!b) return;
if (b.dataset.reset) prefs = {};
if (b.dataset.mode) prefs.mode = b.dataset.mode;
if (b.dataset.pick) prefs.accent = b.dataset.pick;
window.portalTheme.apply(prefs);
try {
if (b.dataset.reset) localStorage.removeItem(window.portalTheme.key);
else localStorage.setItem(window.portalTheme.key, JSON.stringify(prefs));
} catch (err) {}
});
box.hidden = false; // without JavaScript the control stays hidden
})();

21
theme.js Executable file
View file

@ -0,0 +1,21 @@
// Applies the theme: the visitor's saved choice if there is one, otherwise the site defaults.
(function () {
var root = document.documentElement;
var dark = window.matchMedia('(prefers-color-scheme: dark)');
var modes = ['light', 'dark', 'auto'];
var current = {};
function apply(prefs) {
current = prefs || {};
var mode = modes.indexOf(current.mode) >= 0 ? current.mode : root.dataset.defaultMode;
root.dataset.bsTheme = mode === 'auto' ? (dark.matches ? 'dark' : 'light') : mode;
root.dataset.accent = current.accent || root.dataset.defaultAccent;
}
var saved = {};
try { saved = JSON.parse(localStorage.getItem('portal-appearance')) || {}; } catch (e) {}
apply(saved);
dark.addEventListener('change', function () { apply(current); });
window.portalTheme = { key: 'portal-appearance', apply: apply, current: function () { return current; } };
})();