diff --git a/README.md b/README.md
index 729966c..5a231c1 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,11 @@ Built for [SillyLittleTech](https://sillylittle.tech) at `share.sillylittle.tech
| π Click analytics | Per-link click counter in the admin dashboard |
| π Password protection | Require a password before redirecting |
| β° Link expiry | Set an expiry date/time; expired links are cleaned up automatically |
+| ποΈ Folders | Optional folder pages (e.g. `/referrals/`) that list links |
+| π§Ύ Audit log | Tracks create/update/delete events with actor IP |
+| β»οΈ Safe deletes | Links are tombstoned and purged automatically after 3 days |
| π Dark / light mode | Follows system preference with a manual toggle |
-| π Admin authentication | HTTP Basic Auth secured by a Wrangler secret |
+| π Admin authentication | HTTP Basic Auth secured by a Wrangler secret (defense-in-depth even if you use Cloudflare Access) |
---
@@ -28,10 +31,13 @@ Visitor β share.sillylittle.tech/my-link
β 302 redirect to destination URL
```
-Links are stored in a Cloudflare KV namespace as JSON values under the key `link:{slug}`:
+Links are stored in a Cloudflare KV namespace as JSON values under a host-scoped key:
+
+- `link:{host}:{slug}` (e.g. `link:share.sillylittle.tech:my-link`)
```jsonc
{
+ "host": "share.sillylittle.tech",
"slug": "my-link",
"guest": "https://example.com",
"passwordHash": null, // SHA-256 of password, or null
@@ -74,32 +80,66 @@ id = "your-production-namespace-id"
preview_id = "your-preview-namespace-id"
```
-### 4. Set the admin password secret
+### Side note: TOML
+
+You may have noticed `wrangler.toml` has two cousins, `wrangler.toml.cloud.bac` and `wrangler.toml.local.bac`, This is because when we add a custom domain for production in routes, WRANGLER is really eager to use it, even in dev.
+Running `npm run toml:toggle` or use `dev` and `prod` to switch between the versions. Make sure you enforce parody!
+
+### 4. Admin authentication
-The admin dashboard is protected by HTTP Basic Auth. Set the password via Wrangler:
+Production recommendation: protect the admin dashboard (`/admin`) at the edge (for
+example, using Cloudflare Access). All `/api/*` routes always require HTTP Basic
+Auth and therefore require `ADMIN_SECRET` to be set.
+
+To enable local Basic Auth instead of an edge solution (for development or if you
+don't have an edge auth configured), uncomment the local auth block in
+[src/router.js](src/router.js#L752-L792) and set the secret:
```bash
npx wrangler secret put ADMIN_SECRET
# β Enter your chosen password when prompted
```
-When visiting `/admin`, your browser will ask for a username and password.
-Use **any username** and the password you just set.
+Then enable the local flag (in `.dev.vars` or your environment):
+
+```
+ADMIN_SECRET=your-local-password
+ENABLE_LOCAL_ADMIN_AUTH=true
+```
+
+When enabled, visiting `/admin` will prompt for Basic Auth (any username + the
+password you set). By default the local auth block is commented out in the code
+to avoid accidental exposure in productionβuncomment it only if you intend to
+use local Basic Auth.
-### 5. (Optional) Configure a custom domain
+### 5. Configure allowed hostnames (multi-domain/subdomain support)
+
+Plummer supports serving and managing links across multiple configured hostnames (domains/subdomains).
+Set `ALLOWED_HOSTS_JSON` in `wrangler.toml` as a JSON array of hostnames:
+
+```toml
+[vars]
+ALLOWED_HOSTS_JSON = "[\"share.sillylittle.tech\",\"links.sillylittle.tech\",\"links.share.sillylittle.tech\"]"
+```
+
+The `/admin` UI will show these in a dropdown when creating links.
+
+If you leave `ALLOWED_HOSTS_JSON` unset/empty, API writes are **restricted to the current request host** as a safer default.
+
+### 6. (Optional) Configure a custom domain / route
To use a custom domain (e.g. `share.sillylittle.tech`), uncomment and update the
-`[[routes]]` block in `wrangler.toml`:
+`[[routes]]` block in `wrangler.toml` and set the correct `zone_id`:
```toml
[[routes]]
-pattern = "share.sillylittle.tech/*"
-zone_name = "sillylittle.tech"
+pattern = "share.sillylittle.tech/*"
+zone_id = "..."
```
The domain must be added to your Cloudflare account and DNS must point to Cloudflare.
-### 6. Deploy
+### 7. Deploy
```bash
npm run deploy
@@ -113,6 +153,14 @@ npm run dev
# or: npx wrangler dev
```
+For local dev secrets, you can also use a `.dev.vars` file (Wrangler reads it automatically):
+
+```bash
+ADMIN_SECRET=your-local-password
+ENABLE_DEBUG_ENDPOINTS=true
+FORCE_DELETE_KEY=optional-testing-key
+```
+
---
## GitHub Actions CI/CD
@@ -138,7 +186,12 @@ Add the following **repository secrets** in your GitHub repo settings
```
plummer/
βββ src/
-β βββ index.js # Cloudflare Worker (all routes + HTML templates inline)
+β βββ index.js # Worker entrypoint (fetch + scheduled purge)
+β βββ router.js # Routing for /admin, /api, redirects, folders, debug endpoints
+β βββ security.js # Basic auth + response security headers
+β βββ kv.js # KV storage helpers (host-scoped keys)
+β βββ audit.js # Audit event storage + listing
+β βββ pages/ # HTML pages (home/admin/errors/folders)
βββ .github/
β βββ workflows/
β βββ deploy.yml # GitHub Actions β Cloudflare Workers
@@ -157,7 +210,19 @@ All API routes require HTTP Basic Auth (same credentials as the admin dashboard)
|---|---|---|
| `GET` | `/api/links` | List all links (JSON array) |
| `POST` | `/api/links` | Create a new link (JSON body) |
-| `DELETE` | `/api/links/:slug` | Delete a link |
+| `PATCH` | `/api/links/:slug` | Update a link (destination, expiry, folder, password, status) |
+| `POST` | `/api/links/:slug/rename` | Rename a link slug |
+| `DELETE` | `/api/links/:slug?host=...` | Schedule deletion (3-day retention) |
+| `GET` | `/api/folders?host=...` | List folders for a host |
+| `POST` | `/api/folders` | Create folder |
+| `PATCH` | `/api/folders/:slug` | Update folder (name, listingEnabled, password) |
+| `DELETE` | `/api/folders/:slug?host=...` | Delete folder |
+| `GET` | `/api/audit?limit=...` | List recent audit events |
+
+### Debug endpoints (optional)
+
+Debug endpoints are disabled by default. To enable them, set `ENABLE_DEBUG_ENDPOINTS=true`.
+Some debug endpoints may additionally require `FORCE_DELETE_KEY`.
### Create link β request body
diff --git a/package.json b/package.json
index 79e1e43..73b90fe 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,10 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
- "cf-typegen": "wrangler types"
+ "cf-typegen": "wrangler types",
+ "toml:toggle": "node scripts/swap-wrangler.mjs",
+ "toml:dev": "node scripts/swap-wrangler.mjs dev",
+ "toml:prod": "node scripts/swap-wrangler.mjs prod"
},
"devDependencies": {
"wrangler": "^4.86.0"
diff --git a/scripts/swap-wrangler.mjs b/scripts/swap-wrangler.mjs
new file mode 100644
index 0000000..a175f06
--- /dev/null
+++ b/scripts/swap-wrangler.mjs
@@ -0,0 +1,35 @@
+import { readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const root = dirname(scriptDir);
+const activePath = resolve(root, 'wrangler.toml');
+const devPath = resolve(root, 'wrangler.toml.local.bac');
+const prodPath = resolve(root, 'wrangler.toml.cloud.bac');
+
+const mode = (process.argv[2] || 'toggle').toLowerCase();
+
+const active = readFileSync(activePath, 'utf8');
+const dev = readFileSync(devPath, 'utf8');
+const prod = readFileSync(prodPath, 'utf8');
+
+let next;
+
+if (mode === 'dev') {
+ next = dev;
+} else if (mode === 'prod') {
+ next = prod;
+} else if (active === dev) {
+ next = prod;
+} else if (active === prod) {
+ next = dev;
+} else {
+ console.error('wrangler.toml does not match either backup. Use `npm run toml:dev` or `npm run toml:prod`.');
+ process.exit(1);
+}
+
+writeFileSync(activePath, next);
+
+const label = next === dev ? 'dev' : 'prod';
+console.log(`Updated wrangler.toml -> ${label}`);
diff --git a/src/audit.js b/src/audit.js
new file mode 100644
index 0000000..f5e2dfb
--- /dev/null
+++ b/src/audit.js
@@ -0,0 +1,70 @@
+import { normalizeHost } from './kv.js';
+
+function padTs(ts) {
+ // 13-digit ms timestamp, lexicographically sortable
+ return String(ts).padStart(13, '0');
+}
+
+function auditKey(ts, id) {
+ return `audit:${padTs(ts)}:${id}`;
+}
+
+function randomId() {
+ // short, URL-safe (avoid Math.random predictability/collisions)
+ try {
+ return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
+ } catch {
+ const bytes = new Uint8Array(8);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
+ }
+}
+
+export function getActor(request) {
+ const ip =
+ request.headers.get('cf-connecting-ip') ||
+ (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() ||
+ null;
+ const ua = request.headers.get('user-agent') || null;
+ const ray = request.headers.get('cf-ray') || null;
+ return { ip, ua, ray };
+}
+
+export async function writeAudit(env, event) {
+ const ts = event.timestamp ?? Date.now();
+ const id = event.id ?? randomId();
+ const key = auditKey(ts, id);
+ const payload = { ...event, timestamp: ts, id };
+ if (payload.host) payload.host = normalizeHost(payload.host);
+ await env.LINKIVERSE.put(key, JSON.stringify(payload));
+ return { key, id, timestamp: ts };
+}
+
+export async function listAudit(env, { limit = 100 } = {}) {
+ const keysWindow = [];
+ const windowSize = Math.max(200, Math.min(1000, limit * 5));
+ let cursor;
+ do {
+ const page = await env.LINKIVERSE.list({ prefix: 'audit:', limit: 100, cursor });
+ for (const key of page.keys) {
+ keysWindow.push(key.name);
+ if (keysWindow.length > windowSize) keysWindow.shift();
+ }
+ cursor = page.list_complete ? undefined : page.cursor;
+ } while (cursor);
+
+ const items = [];
+ for (const name of keysWindow) {
+ const raw = await env.LINKIVERSE.get(name);
+ if (!raw) continue;
+ try {
+ items.push(JSON.parse(raw));
+ } catch {
+ // ignore
+ }
+ }
+
+ items.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
+ return items.slice(0, limit);
+}
+
diff --git a/src/constants.js b/src/constants.js
new file mode 100644
index 0000000..0bbbda4
--- /dev/null
+++ b/src/constants.js
@@ -0,0 +1,21 @@
+// Shared constants for the Plummer worker.
+
+export const RESERVED = new Set([
+ 'admin',
+ 'api',
+ 'favicon.ico',
+ 'robots.txt',
+ 'sitemap.xml',
+]);
+
+export const ADMIN_REALM = 'Plummer Admin';
+
+// Visible build version displayed in the UI footer.
+export const APP_VERSION = 'v2.1';
+
+export const SECURITY_HEADERS = {
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
+};
+
diff --git a/src/index.js b/src/index.js
index 8c97108..aea9c6d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -16,1059 +16,37 @@
* }
*/
-// βββ Reserved slugs that cannot be used as short-link slugs βββββββββββββββββ
-const RESERVED = new Set([
- 'admin', 'api', 'favicon.ico', 'robots.txt', 'sitemap.xml',
-]);
+import { routeRequest } from './router.js';
+import { addSecurityHeaders } from './security.js';
+import { getAllLinks, deleteLink } from './kv.js';
+import { writeAudit } from './audit.js';
-const ADMIN_REALM = 'Plummer Admin';
-
-// βββ Shared CSS (based on SillyLittleTech lander / pasCurtain) ββββββββββββββ
-const SHARED_CSS = `
- :root {
- --bg-color: #ffffff;
- --text-color: #1a1a1a;
- --card-bg: #f5f5f5;
- --card-hover: #e8e8e8;
- --border-color: #e0e0e0;
- --shadow: rgba(0,0,0,0.1);
- --accent-color: #667eea;
- --danger-color: #ef4444;
- --success-color: #10b981;
- }
- [data-theme="dark"] {
- --bg-color: #1a1a1a;
- --text-color: #ffffff;
- --card-bg: #2d2d2d;
- --card-hover: #3a3a3a;
- --border-color: #404040;
- --shadow: rgba(0,0,0,0.3);
- --accent-color: #8b9dff;
- --danger-color: #f87171;
- --success-color: #34d399;
- }
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- body {
- font-family: "Lexend", -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, "Helvetica Neue", Arial, sans-serif;
- font-size: 16px;
- background: var(--bg-color);
- color: var(--text-color);
- transition: background-color 0.18s ease, color 0.18s ease;
- min-height: 100vh;
- line-height: 1.6;
- }
- a { color: var(--accent-color); text-decoration: none; }
- a:hover { text-decoration: underline; }
-
- /* Buttons */
- .btn {
- display: inline-flex; align-items: center; justify-content: center;
- gap: 6px;
- padding: 10px 18px;
- border: none; border-radius: 10px;
- font-family: inherit; font-size: 15px; font-weight: 700;
- cursor: pointer;
- transition: transform 0.14s ease, box-shadow 0.14s ease, opacity 0.12s ease;
- text-decoration: none;
- white-space: nowrap;
- }
- .btn:hover { text-decoration: none; }
- .btn-primary {
- background: linear-gradient(180deg, var(--accent-color),
- color-mix(in srgb, var(--accent-color) 85%, black 15%));
- color: #fff;
- box-shadow: 0 6px 24px rgba(102,126,234,0.2);
- }
- .btn-primary:hover {
- transform: translateY(-2px);
- box-shadow: 0 10px 32px rgba(102,126,234,0.25);
- }
- .btn-secondary {
- background: var(--card-bg);
- border: 1px solid var(--border-color);
- color: var(--text-color);
- }
- .btn-secondary:hover { background: var(--card-hover); transform: translateY(-2px); }
- .btn-danger { background: var(--danger-color); color: #fff; }
- .btn-danger:hover { opacity: 0.9; transform: translateY(-1px); }
- .btn-sm { padding: 6px 12px; font-size: 13px; border-radius: 8px; }
-
- /* Cards */
- .card {
- background: var(--card-bg);
- border: 1px solid var(--border-color);
- border-radius: 12px;
- padding: 24px;
- box-shadow: 0 4px 16px var(--shadow);
- }
-
- /* Form elements */
- .input, .select {
- width: 100%;
- padding: 10px 14px;
- border: 1px solid var(--border-color);
- border-radius: 8px;
- background: var(--bg-color);
- color: var(--text-color);
- font-family: inherit; font-size: 15px;
- transition: border-color 0.14s ease;
- }
- .input:focus, .select:focus {
- outline: none;
- border-color: var(--accent-color);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 20%, transparent);
- }
- label { display: block; font-weight: 600; margin-bottom: 6px; font-size: 14px; }
- .form-group { margin-bottom: 16px; }
- .hint { font-size: 12px; opacity: 0.65; margin-top: 4px; }
-
- /* Theme toggle */
- .theme-toggle {
- position: fixed; top: 20px; right: 20px;
- background: var(--card-bg);
- border: 2px solid var(--border-color); border-radius: 50%;
- width: 48px; height: 48px;
- display: flex; align-items: center; justify-content: center;
- cursor: pointer; z-index: 1000;
- transition: transform 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
- box-shadow: 0 6px 18px rgba(0,0,0,0.06);
- }
- .theme-toggle:hover { background: var(--card-hover); transform: rotate(12deg); }
- .theme-toggle svg {
- width: 22px; height: 22px;
- color: var(--text-color);
- fill: none; stroke: currentColor;
- stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
- }
- .sun-icon { display: none; }
- .moon-icon { display: block; }
- [data-theme="dark"] .sun-icon { display: block; }
- [data-theme="dark"] .moon-icon { display: none; }
-
- /* Alerts */
- .alert {
- padding: 12px 16px; border-radius: 8px; margin-bottom: 16px;
- font-weight: 600; font-size: 14px;
- }
- .alert-error {
- background: color-mix(in srgb, var(--danger-color) 10%, transparent);
- border: 1px solid var(--danger-color); color: var(--danger-color);
- }
- .alert-success {
- background: color-mix(in srgb, var(--success-color) 10%, transparent);
- border: 1px solid var(--success-color); color: var(--success-color);
- }
-
- /* Toast notification */
- .toast {
- position: fixed; bottom: 24px; right: 24px;
- background: var(--card-bg); border: 1px solid var(--border-color);
- border-radius: 10px; padding: 12px 20px;
- font-weight: 600; font-size: 14px;
- box-shadow: 0 8px 32px var(--shadow);
- transform: translateY(80px); opacity: 0;
- transition: all 0.3s ease; z-index: 9999;
- max-width: 320px;
- }
- .toast.show { transform: translateY(0); opacity: 1; }
- .toast.toast-error { border-color: var(--danger-color); }
- .toast.toast-ok { border-color: var(--success-color); }
-`;
-
-// βββ Theme toggle script βββββββββββββββββββββββββββββββββββββββββββββββββββββ
-const THEME_SCRIPT = `
-(function(){
- var html = document.documentElement;
- function getSaved(){ try{ return localStorage.getItem('theme'); }catch(e){ return null; } }
- function save(t){ try{ localStorage.setItem('theme',t); }catch(e){} }
- function apply(t){ if(t) html.dataset.theme = t; }
- function detect(){
- try{ return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
- catch(e){ return 'light'; }
- }
- function toggle(){
- var c = html.dataset.theme||'light';
- var n = c==='light' ? 'dark' : 'light';
- apply(n); save(n);
- }
- var saved = getSaved();
- var theme = saved || detect();
- apply(theme);
- if(!saved) save(theme);
- document.addEventListener('DOMContentLoaded', function(){
- var btn = document.getElementById('themeToggle');
- if(btn) btn.addEventListener('click', toggle);
- });
-})();
-`;
-
-const TOGGLE_BTN = ``;
-
-// βββ HTML page wrapper βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-function htmlPage(title, bodyContent, extraCss = '', extraScript = '') {
- return `
-
-
-
-
- ${title}
-
-
-
-
-
-
-
- ${TOGGLE_BTN}
- ${bodyContent}
-
-
-`;
-}
-
-// βββ Page: Homepage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-function homePage(origin) {
- return htmlPage(
- 'Plummer β Link Shortener',
- `
-
-
-
Plummer
-
A simple, fast link shortener β powered by
Cloudflare Workers & KV.
-
Manage Links β
-
-
-
-
-
β‘
-
Edge-Fast Redirects
-
Every redirect is served from Cloudflare's global edge network β sub-millisecond latency worldwide.
-
-
-
π
-
Click Analytics
-
Track how many times each short link has been visited, right from the admin dashboard.
-
-
-
π
-
Password Protection
-
Optionally require a password before visitors are redirected β perfect for private links.
-
-
-
β°
-
Link Expiry
-
Set links to expire automatically at a specific date and time. Expired links are cleaned up automatically.
-
-
-
-
-`,
- /* extra CSS */
- `
- body {
- display: flex; flex-direction: column;
- align-items: center; justify-content: center;
- padding: 40px 20px 80px;
- }
- .home-main { max-width: 900px; width: 100%; }
-
- /* Hero */
- .hero { text-align: center; padding: 60px 20px 48px; }
- .hero-icon {
- width: 84px; height: 84px;
- background: linear-gradient(135deg, var(--accent-color),
- color-mix(in srgb, var(--accent-color) 70%, #8b5cf6 30%));
- border-radius: 20px;
- display: inline-flex; align-items: center; justify-content: center;
- color: #fff; margin-bottom: 28px;
- box-shadow: 0 8px 32px color-mix(in srgb, var(--accent-color) 40%, transparent);
- }
- h1 { font-size: 3.5rem; font-weight: 800; letter-spacing: -2px; line-height: 1; }
- .tagline {
- font-size: 1.15rem; opacity: 0.75; margin-top: 16px;
- max-width: 420px; margin-left: auto; margin-right: auto;
- line-height: 1.7;
- }
- .hero-cta { margin-top: 28px; font-size: 1rem; padding: 12px 28px; border-radius: 12px; }
-
- /* Features */
- .features {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
- gap: 16px; margin-top: 16px;
- }
- .feature-card { text-align: center; }
- .feature-icon { font-size: 2rem; margin-bottom: 12px; }
- .feature-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
- .feature-card p { font-size: 0.875rem; opacity: 0.7; line-height: 1.55; }
-
- /* Footer */
- .home-footer {
- position: fixed; bottom: 0; left: 0; right: 0;
- text-align: center; padding: 10px 16px;
- background: var(--card-bg); border-top: 1px solid var(--border-color);
- font-size: 0.82rem; opacity: 0.8;
- }
-
- @media (max-width: 600px) {
- h1 { font-size: 2.4rem; }
- .features { grid-template-columns: 1fr 1fr; }
- }
- @media (max-width: 400px) {
- .features { grid-template-columns: 1fr; }
- }
- `,
- );
-}
-
-// βββ Page: Admin Dashboard βββββββββββββββββββββββββββββββββββββββββββββββββββ
-function adminPage(links, origin) {
- const rows = links.length === 0
- ? `| No links yet β create one above! |
`
- : links.map(link => {
- const expiry = link.expiresAt
- ? new Date(link.expiresAt).toLocaleString('en-GB', {
- dateStyle: 'short', timeStyle: 'short',
- })
- : 'β';
- const shortUrl = `${origin}/${link.slug}`;
- return `
- ${escHtml(link.slug)} |
-
- ${escHtml(link.guest)}
- |
- ${link.clicks ?? 0} |
- ${link.passwordHash ? 'π' : 'β'} |
- ${expiry} |
-
-
-
- |
-
`;
- }).join('');
-
- return htmlPage(
- 'Plummer β Admin',
- `
-
-
-
-
-
- All Links ${links.length}
-
-
-
-
- | Slug |
- Destination |
- Clicks |
- Pass |
- Expires |
- Actions |
-
-
-
- ${rows}
-
-
-
-
-
-
-`,
-
- /* extra CSS */
- `
- body { padding: 24px 16px 40px; }
- .admin-wrap { max-width: 1060px; margin: 0 auto; }
- .admin-header {
- margin-bottom: 24px; padding-bottom: 20px;
- border-bottom: 1px solid var(--border-color);
- }
- .admin-header h1 { font-size: 2rem; font-weight: 800; margin: 8px 0 4px; }
- .admin-header p { opacity: 0.7; font-size: 0.95rem; }
- .back-link { color: var(--accent-color); font-weight: 600; font-size: 0.875rem; }
- .back-link:hover { text-decoration: underline; }
-
- .create-card { margin-bottom: 20px; }
- .create-card h2, .links-card h2 {
- font-size: 1.1rem; font-weight: 700; margin-bottom: 18px;
- }
- .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
- .opt { font-weight: 400; opacity: 0.6; font-size: 12px; }
-
- .table-wrap { overflow-x: auto; }
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
- thead { background: var(--card-hover); }
- th {
- padding: 10px 14px; text-align: left;
- font-size: 11px; font-weight: 700; text-transform: uppercase;
- letter-spacing: 0.6px; white-space: nowrap;
- }
- td { padding: 12px 14px; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
- tr:last-child td { border-bottom: none; }
- tr:hover td { background: color-mix(in srgb, var(--card-hover) 50%, transparent); }
- .center { text-align: center; }
- .nowrap { white-space: nowrap; }
- .slug-code {
- font-family: ui-monospace, "Cascadia Code", monospace;
- background: var(--card-hover); padding: 2px 8px;
- border-radius: 5px; font-size: 13px;
- }
- .url-cell {
- max-width: 260px; overflow: hidden;
- text-overflow: ellipsis; white-space: nowrap;
- }
- .url-cell a { font-size: 13px; }
- .empty-row { text-align: center; padding: 40px; opacity: 0.55; font-style: italic; }
- .badge {
- background: var(--accent-color); color: #fff;
- border-radius: 999px; padding: 2px 9px;
- font-size: 11px; font-weight: 700; margin-left: 8px;
- vertical-align: middle;
- }
- #formError { margin-top: 12px; margin-bottom: 0; }
- .actions-cell { display: flex; gap: 6px; justify-content: center; }
- td:last-child { text-align: center; }
- td:last-child .btn { margin: 2px; }
-
- @media (max-width: 640px) {
- .form-row { grid-template-columns: 1fr; }
- body { padding: 16px 12px 40px; }
- }
- `,
-
- /* extra script */
- `
-const ORIGIN = ${JSON.stringify(origin)};
-
-function showToast(msg, isError) {
- const t = document.getElementById('toast');
- t.textContent = msg;
- t.className = 'toast show ' + (isError ? 'toast-error' : 'toast-ok');
- clearTimeout(t._timer);
- t._timer = setTimeout(() => { t.classList.remove('show'); }, 2800);
-}
-
-async function deleteLink(slug) {
- if (!confirm('Delete /' + slug + '? This cannot be undone.')) return;
- const r = await fetch('/api/links/' + encodeURIComponent(slug), { method: 'DELETE' });
- if (r.ok) {
- showToast('Link /' + slug + ' deleted.');
- const row = document.querySelector('[data-slug="' + slug + '"]');
- if (row) row.remove();
- const badge = document.getElementById('linkCount');
- if (badge) badge.textContent = parseInt(badge.textContent || '0', 10) - 1;
- const tbody = document.getElementById('linksBody');
- if (tbody && tbody.rows.length === 0) {
- tbody.innerHTML = '| No links yet β create one above! |
';
- }
- } else {
- const d = await r.json().catch(() => ({}));
- showToast('Error: ' + (d.error || 'Unknown error'), true);
- }
-}
-
-function copyLink(url) {
- navigator.clipboard.writeText(url)
- .then(() => showToast('Copied: ' + url))
- .catch(() => showToast('Could not copy to clipboard.', true));
-}
-
-document.getElementById('createForm').addEventListener('submit', async function(e) {
- e.preventDefault();
- const errEl = document.getElementById('formError');
- errEl.style.display = 'none';
- const fd = new FormData(e.target);
- const slug = fd.get('slug').trim();
- const guest = fd.get('guest').trim();
- const expiresAtRaw = fd.get('expiresAt');
- const password = fd.get('password');
-
- const body = {
- slug,
- guest,
- expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
- password: password || null,
- };
-
- const btn = document.getElementById('createBtn');
- btn.disabled = true;
- btn.textContent = 'Creatingβ¦';
-
- const r = await fetch('/api/links', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- const d = await r.json().catch(() => ({}));
- btn.disabled = false;
- btn.innerHTML = ' Create Link';
-
- if (!r.ok) {
- errEl.textContent = d.error || 'Failed to create link.';
- errEl.style.display = '';
- return;
- }
-
- showToast('Created: ' + ORIGIN + '/' + slug);
- e.target.reset();
-
- // Add row to table
- const tbody = document.getElementById('linksBody');
- // Remove empty-row if present
- const emptyRow = tbody.querySelector('.empty-row');
- if (emptyRow) emptyRow.closest('tr').remove();
-
- const expiryText = body.expiresAt
- ? new Date(body.expiresAt).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'short' })
- : 'β';
- const shortUrl = ORIGIN + '/' + slug;
- const tr = document.createElement('tr');
- tr.dataset.slug = slug;
-
- // Use JSON.stringify to get a safe JS literal, then HTML-encode the quotes
- // for the onclick attribute value (browser decodes HTML entities before eval).
- function jsAttr(val) {
- return JSON.stringify(val).replace(/&/g,'&').replace(/"/g,'"');
- }
- function esc(s) {
- return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
- }
-
- tr.innerHTML =
- '' + esc(slug) + ' | ' +
- '' + esc(guest) + ' | ' +
- '0 | ' +
- '' + (body.password ? 'π' : 'β') + ' | ' +
- '' + esc(expiryText) + ' | ' +
- '' +
- ' ' +
- '' +
- ' | ';
- tbody.insertBefore(tr, tbody.firstChild);
-
- const badge = document.getElementById('linkCount');
- if (badge) badge.textContent = parseInt(badge.textContent || '0', 10) + 1;
-});
- `,
- );
-}
-
-// βββ Page: Password prompt βββββββββββββββββββββββββββββββββββββββββββββββββββ
-function passwordPage(slug, badPassword) {
- return htmlPage(
- 'Protected Link β Plummer',
- `
-
-
π
-
Protected Link
-
This link requires a password. Enter it below to continue.
- ${badPassword ? '
Incorrect password β please try again.
' : ''}
-
-
-
`,
- `
- body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
- .pw-wrap { width:100%; max-width:380px; }
- .pw-card { text-align:center; }
- .pw-icon { font-size:3rem; margin-bottom:18px; }
- h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
- p { opacity:0.72; margin-bottom:20px; font-size:0.95rem; }
- .alert { text-align:left; }
- `,
- );
-}
-
-// βββ Page: Link expired ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-function expiredPage() {
- return htmlPage(
- 'Link Expired β Plummer',
- `
-
-
β°
-
Link Expired
-
This short link has expired and is no longer active.
-
β Back to Home
-
-
`,
- `
- body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
- .center-card { text-align:center; max-width:380px; }
- .page-icon { font-size:3rem; margin-bottom:18px; }
- h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
- p { opacity:0.72; margin-bottom:4px; font-size:0.95rem; }
- `,
- );
-}
-
-// βββ Page: Not found βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-function notFoundPage() {
- return htmlPage(
- 'Not Found β Plummer',
- `
-
-
π
-
Link Not Found
-
This short link doesn't exist, or may have been deleted.
-
β Back to Home
-
-
`,
- `
- body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
- .center-card { text-align:center; max-width:380px; }
- .page-icon { font-size:3rem; margin-bottom:18px; }
- h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
- p { opacity:0.72; margin-bottom:4px; font-size:0.95rem; }
- `,
- );
-}
-
-// βββ Page: Misconfigured (no ADMIN_SECRET set) βββββββββββββββββββββββββββββββ
-function misconfiguredPage() {
- return htmlPage(
- 'Setup Required β Plummer',
- `
-
-
βοΈ
-
Setup Required
-
The ADMIN_SECRET environment variable is not configured.
-
- Run the following command to set it, then re-deploy:
-
-
npx wrangler secret put ADMIN_SECRET
-
- View Setup Guide β
-
-
-
`,
- `
- body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
- .center-card { text-align:center; max-width:440px; }
- .page-icon { font-size:3rem; margin-bottom:18px; }
- h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
- p { opacity:0.75; margin-bottom:4px; font-size:0.95rem; }
- p code {
- font-family: ui-monospace, monospace;
- background: var(--card-hover); padding: 1px 6px; border-radius: 4px; font-size:0.9rem;
- }
- .setup-code {
- margin-top:14px; padding:12px 16px;
- background:var(--card-hover); border:1px solid var(--border-color);
- border-radius:8px; font-family:ui-monospace,monospace; font-size:13px;
- text-align:left; overflow-x:auto;
- }
- `,
- );
-}
-
-// βββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-/** Escape characters that are special in HTML attribute values and text nodes. */
-function escHtml(str) {
- return String(str)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-/** Compute a hex SHA-256 digest of a string using Web Crypto. */
-async function sha256(text) {
- const buf = await crypto.subtle.digest(
- 'SHA-256',
- new TextEncoder().encode(text),
- );
- return Array.from(new Uint8Array(buf))
- .map(b => b.toString(16).padStart(2, '0'))
- .join('');
-}
-
-/**
- * Timing-safe equality check for two hex digests of equal length.
- * Prevents timing attacks when comparing password hashes.
- */
-function safeEqual(a, b) {
- if (a.length !== b.length) return false;
- let diff = 0;
- for (let i = 0; i < a.length; i++) {
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
- }
- return diff === 0;
-}
-
-/** Validate that a submitted URL is http or https. */
-function isValidUrl(raw) {
- try {
- const u = new URL(raw);
- return u.protocol === 'http:' || u.protocol === 'https:';
- } catch {
- return false;
- }
-}
-
-/** Check HTTP Basic Auth against env.ADMIN_SECRET. Returns true if valid. */
-async function checkAdminAuth(request, env) {
- if (!env.ADMIN_SECRET) return false;
- const auth = request.headers.get('Authorization') ?? '';
- if (!auth.startsWith('Basic ')) return false;
- let decoded;
- try {
- decoded = atob(auth.slice(6));
- } catch {
- return false;
- }
- const colon = decoded.indexOf(':');
- if (colon === -1) return false;
- const password = decoded.slice(colon + 1);
- // Compare hashes to get a fixed-length comparison (mitigates timing leaks)
- const [submittedHash, secretHash] = await Promise.all([
- sha256(password),
- sha256(env.ADMIN_SECRET),
- ]);
- return safeEqual(submittedHash, secretHash);
-}
-
-/** Returns a 401 response that prompts Basic Auth in the browser. */
-function unauthorizedResponse() {
- return new Response('Unauthorized', {
- status: 401,
- headers: {
- 'WWW-Authenticate': `Basic realm="${ADMIN_REALM}", charset="UTF-8"`,
- 'Content-Type': 'text/plain',
- },
- });
-}
-
-// βββ KV helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-async function getLink(env, slug) {
- const raw = await env.LINKIVERSE.get(`link:${slug}`);
- if (!raw) return null;
- try { return JSON.parse(raw); } catch { return null; }
-}
-
-async function putLink(env, link) {
- await env.LINKIVERSE.put(`link:${link.slug}`, JSON.stringify(link));
-}
-
-async function deleteLink(env, slug) {
- await env.LINKIVERSE.delete(`link:${slug}`);
-}
-
-/** Fetch all stored links, handling KV list pagination. */
-async function getAllLinks(env) {
- const links = [];
- let cursor;
- do {
- const page = await env.LINKIVERSE.list({ prefix: 'link:', limit: 100, cursor });
- for (const key of page.keys) {
- const raw = await env.LINKIVERSE.get(key.name);
- if (raw) {
- try { links.push(JSON.parse(raw)); } catch { /* skip corrupt entries */ }
- }
- }
- cursor = page.list_complete ? undefined : page.cursor;
- } while (cursor);
- links.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
- return links;
-}
-
-// βββ Route handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-async function handleHomePage(origin) {
- return new Response(homePage(origin), {
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
-}
-
-async function handleAdminPage(request, env, origin) {
- // if (!env.ADMIN_SECRET) {
- // return new Response(misconfiguredPage(), {
- // status: 503,
- // headers: { 'Content-Type': 'text/html; charset=utf-8' },
- // });
- // }
- // if (!await checkAdminAuth(request, env)) return unauthorizedResponse();
-
- const links = await getAllLinks(env);
- return new Response(adminPage(links, origin), {
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
-}
-
-async function handleAPI(request, env, pathname) {
- // if (!env.ADMIN_SECRET) {
- // return Response.json({ error: 'ADMIN_SECRET is not configured' }, { status: 503 });
- // }
- // if (!await checkAdminAuth(request, env)) {
- // return new Response('Unauthorized', {
- // status: 401,
- // headers: { 'WWW-Authenticate': `Basic realm="${ADMIN_REALM}"` },
- // });
- // }
-
- // GET /api/links
- if (pathname === '/api/links' && request.method === 'GET') {
- const links = await getAllLinks(env);
- return Response.json(links);
- }
-
- // POST /api/links
- if (pathname === '/api/links' && request.method === 'POST') {
- let body;
- try { body = await request.json(); } catch {
- return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
- }
-
- const { slug, guest, expiresAt, password } = body ?? {};
-
- if (!slug || typeof slug !== 'string') {
- return Response.json({ error: 'slug is required' }, { status: 400 });
- }
- if (!/^[a-zA-Z0-9_-]{1,64}$/.test(slug)) {
- return Response.json(
- { error: 'Slug may only contain letters, numbers, hyphens and underscores (max 64 chars)' },
- { status: 400 },
- );
- }
- if (RESERVED.has(slug.toLowerCase())) {
- return Response.json({ error: `"${slug}" is a reserved slug` }, { status: 400 });
- }
- if (!guest || !isValidUrl(guest)) {
- return Response.json({ error: 'A valid destination URL is required' }, { status: 400 });
- }
- if (expiresAt !== undefined && expiresAt !== null) {
- if (typeof expiresAt !== 'number' || expiresAt < Date.now()) {
- return Response.json({ error: 'expiresAt must be a future Unix timestamp (ms)' }, { status: 400 });
- }
- }
-
- const existing = await getLink(env, slug);
- if (existing) {
- return Response.json({ error: `Slug "${slug}" is already in use` }, { status: 409 });
- }
-
- const passwordHash = password ? await sha256(password) : null;
-
- const link = {
- slug,
- guest,
- passwordHash,
- expiresAt: expiresAt ?? null,
- clicks: 0,
- createdAt: Date.now(),
- };
- await putLink(env, link);
- return Response.json({ slug, message: 'Created' }, { status: 201 });
- }
-
- // DELETE /api/links/:slug
- const deleteMatch = pathname.match(/^\/api\/links\/([^/]+)$/);
- if (deleteMatch && request.method === 'DELETE') {
- const slug = decodeURIComponent(deleteMatch[1]);
- const existing = await getLink(env, slug);
- if (!existing) return Response.json({ error: 'Link not found' }, { status: 404 });
- await deleteLink(env, slug);
- return Response.json({ message: 'Deleted' });
- }
-
- return Response.json({ error: 'API route not found' }, { status: 404 });
-}
-
-async function handleRedirect(request, env, slug) {
- const link = await getLink(env, slug);
- if (!link) {
- return new Response(notFoundPage(), {
- status: 404,
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
- }
-
- // Check link expiry
- if (link.expiresAt && Date.now() > link.expiresAt) {
- // Clean up the expired link from KV
- await deleteLink(env, slug);
- return new Response(expiredPage(), {
- status: 410,
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
- }
-
- // Handle password-protected links
- if (link.passwordHash) {
- if (request.method === 'POST') {
- let formData;
- try { formData = await request.formData(); } catch {
- return new Response(passwordPage(slug, false), {
- status: 200,
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
- }
- const submitted = formData.get('password') ?? '';
- const submittedHash = await sha256(submitted);
- if (!safeEqual(submittedHash, link.passwordHash)) {
- return new Response(passwordPage(slug, true), {
- status: 403,
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
- }
- // Correct password β fall through to redirect
- } else {
- return new Response(passwordPage(slug, false), {
- status: 200,
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
- }
- }
-
- // Increment click counter (best-effort; do not block the redirect)
- const updated = { ...link, clicks: (link.clicks ?? 0) + 1 };
- // Use waitUntil if available to avoid delaying the response
- env.ctx?.waitUntil(putLink(env, updated));
-
- return Response.redirect(link.guest, 302);
-}
-
-// βββ Security headers added to every response ββββββββββββββββββββββββββββββββ
-const SECURITY_HEADERS = {
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'DENY',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
-};
-
-function addSecurityHeaders(response) {
- const r = new Response(response.body, response);
- for (const [k, v] of Object.entries(SECURITY_HEADERS)) r.headers.set(k, v);
- return r;
-}
-
-// βββ Main fetch handler ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export default {
async fetch(request, env, ctx) {
// Attach ctx so handlers can use waitUntil
env.ctx = ctx;
+ const response = await routeRequest(request, env);
+ return addSecurityHeaders(response);
+ },
- const url = new URL(request.url);
- const { pathname } = url;
- const origin = url.origin;
-
- let response;
-
- // Homepage
- if (pathname === '/' && request.method === 'GET') {
- response = await handleHomePage(origin);
- }
-
- // Admin dashboard
- else if ((pathname === '/admin' || pathname === '/admin/') && request.method === 'GET') {
- response = await handleAdminPage(request, env, origin);
- }
-
- // REST API
- else if (pathname.startsWith('/api/')) {
- response = await handleAPI(request, env, pathname);
- }
-
- // Short-link redirect β slug must be alphanumeric/-/_
- else {
- const slugMatch = pathname.match(/^\/([a-zA-Z0-9_-]+)\/?$/);
- if (slugMatch && (request.method === 'GET' || request.method === 'POST')) {
- response = await handleRedirect(request, env, slugMatch[1]);
- } else {
- response = new Response(notFoundPage(), {
- status: 404,
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
- }
+ async scheduled(_event, env, ctx) {
+ // Purge tombstoned links after purgeAfter timestamp.
+ const links = await getAllLinks(env);
+ const now = Date.now();
+ for (const link of links) {
+ if (link?.status !== 'deleted') continue;
+ if (!link?.purgeAfter || typeof link.purgeAfter !== 'number') continue;
+ if (link.purgeAfter > now) continue;
+ if (!link.host || !link.slug) continue;
+ ctx.waitUntil(deleteLink(env, link.host, link.slug));
+ ctx.waitUntil(writeAudit(env, {
+ action: 'link.purge',
+ host: link.host,
+ slug: link.slug,
+ before: link,
+ actor: { ip: null, ua: null, ray: null },
+ }));
}
-
- return addSecurityHeaders(response);
},
};
+
diff --git a/src/kv.js b/src/kv.js
new file mode 100644
index 0000000..b040b09
--- /dev/null
+++ b/src/kv.js
@@ -0,0 +1,176 @@
+async function parseJSON(raw) {
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+export function normalizeHost(rawHost) {
+ // IMPORTANT: keep ports. For local dev we need exact Host header matching (e.g. localhost:8787).
+ return String(rawHost ?? '').trim().toLowerCase();
+}
+
+function linkKey(host, slug) {
+ return `link:${host}:${slug}`;
+}
+
+function legacyLinkKey(slug) {
+ return `link:${slug}`;
+}
+
+function folderKey(host, folderSlug) {
+ return `folder:${host}:${folderSlug}`;
+}
+
+function folderPrefix(host) {
+ return `folder:${host}:`;
+}
+
+export async function getFolder(env, host, folderSlug) {
+ const h = normalizeHost(host);
+ const direct = await parseJSON(await env.LINKIVERSE.get(folderKey(h, folderSlug)));
+ if (direct) return direct;
+
+ // Compatibility: if host includes a port, try the no-port key and migrate.
+ const noPort = h.replace(/:\d+$/, '');
+ if (noPort && noPort !== h) {
+ const legacy = await parseJSON(await env.LINKIVERSE.get(folderKey(noPort, folderSlug)));
+ if (!legacy) return null;
+ const migrated = { ...legacy, host: h };
+ await env.LINKIVERSE.put(folderKey(h, folderSlug), JSON.stringify(migrated));
+ await env.LINKIVERSE.delete(folderKey(noPort, folderSlug));
+ return migrated;
+ }
+
+ return null;
+}
+
+export async function putFolder(env, host, folder) {
+ const h = normalizeHost(host);
+ const f = { ...folder, host: h };
+ await env.LINKIVERSE.put(folderKey(h, f.slug), JSON.stringify(f));
+}
+
+export async function deleteFolder(env, host, folderSlug) {
+ const h = normalizeHost(host);
+ await env.LINKIVERSE.delete(folderKey(h, folderSlug));
+}
+
+export async function listFolders(env, host) {
+ const h = normalizeHost(host);
+ const folders = [];
+ let cursor;
+ do {
+ const page = await env.LINKIVERSE.list({ prefix: folderPrefix(h), limit: 100, cursor });
+ for (const key of page.keys) {
+ const parsed = await parseJSON(await env.LINKIVERSE.get(key.name));
+ if (parsed) folders.push(parsed);
+ }
+ cursor = page.list_complete ? undefined : page.cursor;
+ } while (cursor);
+ folders.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
+ return folders;
+}
+
+export async function listLinksByFolder(env, host, folderSlug) {
+ const h = normalizeHost(host);
+ // NOTE: This currently scans all links and filters in memory.
+ // If folder listings become high-traffic, consider maintaining a folder index/prefix to avoid full scans.
+ const all = await getAllLinks(env);
+ return all.filter((l) => normalizeHost(l.host) === h && (l.folderSlug ?? null) === folderSlug);
+}
+
+/**
+ * Get a link by host+slug.
+ * Falls back to legacy key format (`link:{slug}`) and migrates it to host format.
+ */
+export async function getLink(env, host, slug) {
+ const h = normalizeHost(host);
+ const raw = await env.LINKIVERSE.get(linkKey(h, slug));
+ const parsed = await parseJSON(raw);
+ if (parsed) return parsed;
+
+ // Compatibility: if host includes a port, try the no-port key and migrate.
+ const noPort = h.replace(/:\d+$/, '');
+ if (noPort && noPort !== h) {
+ const legacyPortRaw = await env.LINKIVERSE.get(linkKey(noPort, slug));
+ const legacyPortParsed = await parseJSON(legacyPortRaw);
+ if (legacyPortParsed) {
+ const migrated = { ...legacyPortParsed, host: h };
+ await env.LINKIVERSE.put(linkKey(h, slug), JSON.stringify(migrated));
+ await env.LINKIVERSE.delete(linkKey(noPort, slug));
+ return migrated;
+ }
+ }
+
+ // Legacy fallback: if found, migrate to host-scoped key.
+ const legacyRaw = await env.LINKIVERSE.get(legacyLinkKey(slug));
+ const legacyParsed = await parseJSON(legacyRaw);
+ if (legacyParsed) {
+ const migrated = { ...legacyParsed, host: h };
+ await env.LINKIVERSE.put(linkKey(h, slug), JSON.stringify(migrated));
+ // Remove legacy key to avoid duplicates in listings once migrated.
+ await env.LINKIVERSE.delete(legacyLinkKey(slug));
+ return migrated;
+ }
+
+ // Last-resort compatibility: scan existing link records to find a matching {host,slug}.
+ // This handles older key formats and edge-case host encodings in local dev.
+ let cursor;
+ do {
+ const page = await env.LINKIVERSE.list({ prefix: 'link:', limit: 100, cursor });
+ for (const key of page.keys) {
+ const candidate = await parseJSON(await env.LINKIVERSE.get(key.name));
+ if (!candidate) continue;
+ if (String(candidate.slug) !== String(slug)) continue;
+ if (normalizeHost(candidate.host) !== h) continue;
+
+ // Migrate to canonical key and delete the old one.
+ const migrated = { ...candidate, host: h };
+ await env.LINKIVERSE.put(linkKey(h, slug), JSON.stringify(migrated));
+ if (key.name !== linkKey(h, slug)) await env.LINKIVERSE.delete(key.name);
+ return migrated;
+ }
+ cursor = page.list_complete ? undefined : page.cursor;
+ } while (cursor);
+
+ return null;
+}
+
+export async function putLink(env, host, link) {
+ const h = normalizeHost(host);
+ const withHost = { ...link, host: h };
+ await env.LINKIVERSE.put(linkKey(h, withHost.slug), JSON.stringify(withHost));
+}
+
+export async function deleteLink(env, host, slug) {
+ const h = normalizeHost(host);
+ await env.LINKIVERSE.delete(linkKey(h, slug));
+}
+
+/** Fetch all stored links (all hosts), handling KV list pagination. */
+export async function getAllLinks(env) {
+ const links = [];
+ let cursor;
+ do {
+ const page = await env.LINKIVERSE.list({ prefix: 'link:', limit: 100, cursor });
+ for (const key of page.keys) {
+ const raw = await env.LINKIVERSE.get(key.name);
+ const parsed = await parseJSON(raw);
+ if (!parsed) continue;
+
+ // Infer host from key if needed (new keys are link:{host}:{slug})
+ if (!parsed.host) {
+ const parts = key.name.split(':');
+ if (parts.length === 3) parsed.host = parts[1];
+ }
+ links.push(parsed);
+ }
+ cursor = page.list_complete ? undefined : page.cursor;
+ } while (cursor);
+ links.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
+ return links;
+}
+
diff --git a/src/pages/admin.js b/src/pages/admin.js
new file mode 100644
index 0000000..5601b73
--- /dev/null
+++ b/src/pages/admin.js
@@ -0,0 +1,1030 @@
+import { htmlPage } from './shared.js';
+import { escHtml } from '../util.js';
+
+export function adminPage(links, origin, allowedHosts = []) {
+ const hosts = Array.isArray(allowedHosts) && allowedHosts.length > 0
+ ? allowedHosts
+ : [new URL(origin).host];
+
+ const rows = links.length === 0
+ ? `| No links yet β create one above! |
`
+ : links.map((link) => {
+ const expiry = link.expiresAt
+ ? new Date(link.expiresAt).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'short' })
+ : 'β';
+ const currentOrigin = new URL(origin);
+ const currentHost = currentOrigin.host;
+ const linkHost = link.host ? String(link.host) : currentHost;
+ const shortUrl = (linkHost === currentHost)
+ ? `${origin}/${link.slug}`
+ : `https://${linkHost}/${link.slug}`;
+ const guestJs = escHtml(JSON.stringify(link.guest ?? ''));
+ const expiresAtJs = escHtml(JSON.stringify(link.expiresAt ?? null));
+ const status = link.status || 'active';
+ const statusBadge =
+ status === 'active' ? 'Active' :
+ status === 'inactive' ? 'Inactive' :
+ 'Deleted';
+
+ const folderSlugAttr = escHtml(link.folderSlug ?? '');
+ return `
+ ${escHtml(link.slug)} |
+ ${escHtml(linkHost)} |
+ ${statusBadge} |
+
+ ${escHtml(link.guest)}
+ |
+ ${link.clicks ?? 0} |
+ ${link.passwordHash ? 'π' : 'β'} |
+ ${expiry} |
+
+
+
+
+
+ |
+
`;
+ }).join('');
+
+ return htmlPage(
+ 'Plummer β Admin',
+ `
+
+
+
+
+
+ All Links ${links.length}
+
+
+ Deleted links purge automatically after 3 days.
+
+
+
+
+
+ | Slug |
+ Domain |
+ Status |
+ Destination |
+ Clicks |
+ Pass |
+ Expires |
+ Actions |
+
+
+
+ ${rows}
+
+
+
+
+
+
+
+
Folders
+
+
+
+
+
+
+
+
+ Audit
+ Recent create/modify/delete events with actor IP.
+
+
+
+
+ | Time |
+ Action |
+ Host |
+ Target |
+ IP |
+
+
+
+ | Loading audit⦠|
+
+
+
+
+
+
+
+
+
+
+`,
+
+ /* extra CSS */
+ `
+ body { padding: 24px 16px 40px; }
+ .admin-wrap { max-width: 1060px; margin: 0 auto; }
+ .admin-header {
+ margin-bottom: 24px; padding-bottom: 20px;
+ border-bottom: 1px solid var(--border-color);
+ }
+ .admin-header h1 { font-size: 2rem; font-weight: 800; margin: 8px 0 4px; }
+ .admin-header p { opacity: 0.7; font-size: 0.95rem; }
+ .back-link { color: var(--accent-color); font-weight: 600; font-size: 0.875rem; }
+ .back-link:hover { text-decoration: underline; }
+
+ .create-card { margin-bottom: 20px; }
+ .create-card h2, .links-card h2 {
+ font-size: 1.1rem; font-weight: 700; margin-bottom: 18px;
+ }
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+ .opt { font-weight: 400; opacity: 0.6; font-size: 12px; }
+
+ .table-wrap { overflow-x: auto; }
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
+ thead { background: var(--card-hover); }
+ th {
+ padding: 10px 14px; text-align: left;
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
+ letter-spacing: 0.6px; white-space: nowrap;
+ }
+ td { padding: 12px 14px; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
+ tr:last-child td { border-bottom: none; }
+ tr:hover td { background: color-mix(in srgb, var(--card-hover) 50%, transparent); }
+ .center { text-align: center; }
+ .nowrap { white-space: nowrap; }
+ .slug-code {
+ font-family: ui-monospace, "Cascadia Code", monospace;
+ background: var(--card-hover); padding: 2px 8px;
+ border-radius: 5px; font-size: 13px;
+ }
+ .host-code {
+ font-family: ui-monospace, "Cascadia Code", monospace;
+ background: var(--card-hover); padding: 2px 8px;
+ border-radius: 5px; font-size: 12px;
+ opacity: 0.9;
+ }
+ .url-cell {
+ max-width: 260px; overflow: hidden;
+ text-overflow: ellipsis; white-space: nowrap;
+ }
+ .url-cell a { font-size: 13px; }
+ .empty-row { text-align: center; padding: 40px; opacity: 0.55; font-style: italic; }
+ .badge {
+ background: var(--accent-color); color: #fff;
+ border-radius: 999px; padding: 2px 9px;
+ font-size: 11px; font-weight: 700; margin-left: 8px;
+ vertical-align: middle;
+ }
+ #formError { margin-top: 12px; margin-bottom: 0; }
+ .actions-cell { display: flex; gap: 6px; justify-content: center; }
+ td:last-child { text-align: center; }
+ td:last-child .btn { margin: 2px; }
+
+ .folders-card { margin-bottom: 20px; }
+ .folder-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
+ .folder-list { display:flex; flex-direction:column; gap:6px; margin-top: 10px; }
+ .folder-item {
+ display:flex; align-items:center; gap:10px;
+ width: 100%;
+ text-align:left;
+ padding: 10px 10px;
+ border-radius: 10px;
+ border: 1px solid var(--border-color);
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font-weight: 700;
+ font-size: 13px;
+ opacity: 0.92;
+ }
+ .folder-item:hover { background: color-mix(in srgb, var(--card-hover) 70%, transparent); }
+ .folder-item.is-active {
+ background: color-mix(in srgb, var(--accent-color) 12%, transparent);
+ border-color: color-mix(in srgb, var(--accent-color) 40%, var(--border-color));
+ }
+ .folder-dot { width: 10px; height: 10px; border-radius: 999px; background: var(--accent-color); opacity: 0.9; }
+ .folder-name { flex: 1; overflow:hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .folder-meta { font-weight: 600; opacity: 0.65; font-size: 12px; }
+ .folder-divider { height: 1px; background: var(--border-color); opacity: 0.7; margin: 6px 0; }
+ .folder-empty { padding: 12px 10px; opacity: 0.65; font-style: italic; }
+
+ .pill {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.2px;
+ border: 1px solid var(--border-color);
+ }
+ .pill-ok { background: color-mix(in srgb, var(--success-color) 18%, transparent); color: var(--success-color); }
+ .pill-warn { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #f59e0b; }
+ .pill-danger { background: color-mix(in srgb, var(--danger-color) 16%, transparent); color: var(--danger-color); }
+
+ /* Edit modal */
+ .modal-backdrop {
+ position: fixed; inset: 0;
+ background: rgba(0,0,0,0.45);
+ display: none;
+ align-items: center; justify-content: center;
+ padding: 16px;
+ z-index: 9998;
+ }
+ .modal-backdrop.show { display: flex; }
+ .modal {
+ width: 100%;
+ max-width: 640px;
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 14px;
+ box-shadow: 0 24px 70px rgba(0,0,0,0.25);
+ overflow: hidden;
+ }
+ .modal-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 16px;
+ background: var(--card-bg);
+ border-bottom: 1px solid var(--border-color);
+ }
+ .modal-header h3 { font-size: 1rem; margin: 0; }
+ .modal-body { padding: 16px; }
+ .modal-actions { display: flex; gap: 10px; justify-content: flex-end; padding-top: 8px; }
+ .btn-link { background: transparent; border: 1px solid var(--border-color); }
+
+ @media (max-width: 640px) {
+ .form-row { grid-template-columns: 1fr; }
+ body { padding: 16px 12px 40px; }
+ }
+ `,
+
+ /* extra script */
+ `
+const ORIGIN = ${JSON.stringify(origin)};
+const HOSTS = ${JSON.stringify(hosts)};
+
+function jsAttr(val) {
+ return JSON.stringify(val).replace(/&/g,'&').replace(/"/g,'"');
+}
+
+function showToast(msg, isError) {
+ const t = document.getElementById('toast');
+ t.textContent = msg;
+ t.className = 'toast show ' + (isError ? 'toast-error' : 'toast-ok');
+ clearTimeout(t._timer);
+ t._timer = setTimeout(() => { t.classList.remove('show'); }, 2800);
+}
+
+// (deleteLink removed in favor of softDeleteLink/permanentDeleteLink)
+
+async function setStatus(host, slug, status) {
+ const r = await fetch('/api/links/' + encodeURIComponent(slug), {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ host, status }),
+ });
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(d.error || 'Status update failed');
+ return d;
+}
+
+function setRowStatus(host, slug, status) {
+ const row = document.querySelector('[data-slug="' + slug + '"][data-host="' + host + '"]');
+ if (!row) return;
+ row.dataset.status = status;
+ const statusCell = row.querySelector('.status-cell');
+ if (!statusCell) return;
+ statusCell.innerHTML =
+ status === 'active' ? 'Active' :
+ status === 'inactive' ? 'Inactive' :
+ 'Deleted';
+
+ // Ensure delete button visibility matches status rules
+ const delBtn = row.querySelector('[data-action=\"schedule-delete\"]');
+ if (delBtn) delBtn.style.display = status === 'inactive' ? '' : 'none';
+}
+
+let SHOW_DELETED = false;
+let ACTIVE_FOLDER = '';
+let ACTIVE_FOLDER_NAME = 'Default';
+let ACTIVE_HOST = '';
+let LAST_FOLDERS = [];
+
+function applyRowVisibility() {
+ const hostSel = document.getElementById('host');
+ if (hostSel) ACTIVE_HOST = hostSel.value;
+
+ const rows = document.querySelectorAll('#linksBody tr[data-status]');
+ for (const row of rows) {
+ const s = row.dataset.status || 'active';
+ const host = row.dataset.host || '';
+ const folder = row.dataset.folderSlug || '';
+
+ const hostOk = !ACTIVE_HOST || host === ACTIVE_HOST;
+ const folderOk = folder === (ACTIVE_FOLDER || '');
+ const deletedOk = !(s === 'deleted' && !SHOW_DELETED);
+ row.style.display = hostOk && folderOk && deletedOk ? '' : 'none';
+ }
+}
+
+async function toggleInactive(host, slug) {
+ const row = document.querySelector('[data-slug="' + slug + '"][data-host="' + host + '"]');
+ const current = row ? (row.dataset.status || 'active') : 'active';
+ const next = current === 'inactive' ? 'active' : 'inactive';
+ try {
+ await setStatus(host, slug, next);
+ setRowStatus(host, slug, next);
+ showToast((next === 'active' ? 'Activated: ' : 'Inactivated: ') + host + '/' + slug);
+ } catch (e) {
+ showToast('Error: ' + (e.message || 'Unknown error'), true);
+ }
+}
+
+async function scheduleDeleteLink(host, slug) {
+ if (!confirm('Schedule deletion for ' + host + '/' + slug + '? It will be removed automatically after 3 days.')) return;
+ const r = await fetch('/api/links/' + encodeURIComponent(slug) + '?host=' + encodeURIComponent(host), {
+ method: 'DELETE',
+ });
+ if (r.ok) {
+ showToast('Deletion scheduled: ' + host + '/' + slug);
+ setRowStatus(host, slug, 'deleted');
+ applyRowVisibility();
+ } else {
+ const d = await r.json().catch(() => ({}));
+ showToast('Error: ' + (d.error || 'Unknown error'), true);
+ }
+}
+
+function copyLink(url) {
+ navigator.clipboard.writeText(url)
+ .then(() => showToast('Copied: ' + url))
+ .catch(() => showToast('Could not copy to clipboard.', true));
+}
+
+/** Rebuild action buttons so slugs in onclick match after rename. */
+function buildLinkRowActionsHtml(host, slug, guest, expiresAtMs, folderSlug, status) {
+ const co = new URL(ORIGIN);
+ const shortUrl = (host === co.host) ? (ORIGIN + '/' + slug) : ('https://' + host + '/' + slug);
+ const delStyle = (status === 'inactive') ? '' : 'display:none;';
+ return (
+ ' ' +
+ ' ' +
+ ' ' +
+ ''
+ );
+}
+
+async function fetchFolders(host) {
+ const r = await fetch('/api/folders?host=' + encodeURIComponent(host));
+ if (!r.ok) return [];
+ return await r.json().catch(() => ([]));
+}
+
+function renderFolderOptions(selectEl, folders) {
+ if (!selectEl) return;
+ selectEl.textContent = '';
+ const empty = document.createElement('option');
+ empty.value = '';
+ empty.textContent = 'β None β';
+ selectEl.appendChild(empty);
+ (folders || []).forEach((f) => {
+ const opt = document.createElement('option');
+ opt.value = String(f.slug ?? '');
+ opt.textContent = String((f.name || f.slug) ?? '');
+ selectEl.appendChild(opt);
+ });
+}
+
+function renderFolderSidebar(folders) {
+ const list = document.getElementById('folderList');
+ if (!list) return;
+
+ function esc(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/\"/g,'"').replace(/'/g,''');
+ }
+
+ const items = (folders || []).map((f) => {
+ const name = f.name || f.slug;
+ const metaBits = [];
+ if (f.listingEnabled === false) metaBits.push('listing off');
+ if (f.passwordHash) metaBits.push('π');
+ const meta = metaBits.length ? ('' + esc(metaBits.join(' β’ ')) + '') : '';
+ const activeClass = (String(f.slug) === String(ACTIVE_FOLDER)) ? ' is-active' : '';
+ return (
+ ''
+ );
+ }).join('');
+
+ const defaultActive = (ACTIVE_FOLDER || '') === '' ? ' is-active' : '';
+ const empty = (folders && folders.length) ? '' : 'No folders yet.
';
+
+ list.innerHTML =
+ '' +
+ '' +
+ (items || empty);
+
+ list.querySelectorAll('.folder-item').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const slug = btn.getAttribute('data-folder') || '';
+ const name = btn.getAttribute('data-folder-name') || (slug ? slug : 'Default');
+ setActiveFolder(slug, name);
+ });
+ });
+}
+
+async function refreshFoldersForHost(host) {
+ const folders = await fetchFolders(host);
+ LAST_FOLDERS = folders;
+ renderFolderOptions(document.getElementById('folderSlug'), folders);
+ renderFolderOptions(document.getElementById('editFolderSlug'), folders);
+ restoreActiveFolderForHost(host);
+ renderFolderSidebar(folders);
+}
+
+async function deleteFolder(host, slug) {
+ if (!confirm('Delete folder ' + host + '/' + slug + '?')) return;
+ const r = await fetch('/api/folders/' + encodeURIComponent(slug) + '?host=' + encodeURIComponent(host), { method: 'DELETE' });
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok) return showToast('Error: ' + (d.error || 'Unknown error'), true);
+ showToast('Folder deleted: ' + slug);
+ await refreshFoldersForHost(host);
+}
+
+function restoreActiveFolderForHost(host) {
+ const key = 'plummer.activeFolder:' + host;
+ const saved = localStorage.getItem(key);
+ if (saved === null) return;
+ try {
+ const parsed = JSON.parse(saved);
+ setActiveFolder(parsed.slug || '', parsed.name || (parsed.slug ? parsed.slug : 'Default'), { persist: false });
+ } catch {
+ // ignore
+ }
+}
+
+function setActiveFolder(folderSlug, folderName, opts) {
+ ACTIVE_FOLDER = folderSlug || '';
+ ACTIVE_FOLDER_NAME = folderName || (ACTIVE_FOLDER ? ACTIVE_FOLDER : 'Default');
+ const host = document.getElementById('host')?.value || '';
+ const persist = !(opts && opts.persist === false);
+ if (persist && host) {
+ localStorage.setItem('plummer.activeFolder:' + host, JSON.stringify({ slug: ACTIVE_FOLDER, name: ACTIVE_FOLDER_NAME }));
+ }
+ const sel = document.getElementById('folderSlug');
+ if (sel) sel.value = ACTIVE_FOLDER;
+ renderFolderSidebar(LAST_FOLDERS);
+ applyRowVisibility();
+}
+
+async function fetchAudit(limit) {
+ const r = await fetch('/api/audit?limit=' + encodeURIComponent(limit || 100));
+ if (!r.ok) return [];
+ return await r.json().catch(() => ([]));
+}
+
+function renderAuditTable(items) {
+ const tbody = document.getElementById('auditBody');
+ if (!tbody) return;
+ if (!items || items.length === 0) {
+ tbody.innerHTML = '| No audit events yet. |
';
+ return;
+ }
+
+ function esc(s) {
+ return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/\"/g,'"').replace(/'/g,''');
+ }
+
+ tbody.innerHTML = items.map((e) => {
+ const ts = e.timestamp ? new Date(e.timestamp).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'medium' }) : 'β';
+ const action = e.action || 'β';
+ const host = e.host || 'β';
+ const target = e.slug ? ('/' + e.slug) : (e.folderSlug ? ('folder:' + e.folderSlug) : 'β');
+ const ip = e.actor && e.actor.ip ? e.actor.ip : 'β';
+ return '' +
+ '| ' + esc(ts) + ' | ' +
+ '' + esc(action) + ' | ' +
+ '' + esc(host) + ' | ' +
+ '' + esc(target) + ' | ' +
+ '' + esc(ip) + ' | ' +
+ '
';
+ }).join('');
+}
+
+async function refreshAudit() {
+ const items = await fetchAudit(150);
+ renderAuditTable(items);
+}
+
+document.getElementById('host').addEventListener('change', async (e) => {
+ const host = e.target.value;
+ // keep folder host selector in sync by default
+ const fh = document.getElementById('folderHost');
+ if (fh && fh.value !== host && HOSTS.includes(host)) fh.value = host;
+ await refreshFoldersForHost(host);
+});
+
+document.getElementById('folderHost').addEventListener('change', async (e) => {
+ await refreshFoldersForHost(e.target.value);
+});
+
+function openFolderModal() {
+ const backdrop = document.getElementById('folderBackdrop');
+ if (!backdrop) return;
+ const host = document.getElementById('folderHost')?.value || document.getElementById('host')?.value || '';
+ const hostCreate = document.getElementById('folderHostCreate');
+ if (hostCreate && host) hostCreate.value = host;
+ backdrop.style.display = '';
+ setTimeout(() => document.getElementById('folderSlugCreate')?.focus(), 0);
+}
+
+function closeFolderModal() {
+ const backdrop = document.getElementById('folderBackdrop');
+ if (!backdrop) return;
+ backdrop.style.display = 'none';
+ const errEl = document.getElementById('folderFormError');
+ if (errEl) errEl.style.display = 'none';
+}
+
+document.getElementById('newFolderBtn')?.addEventListener('click', openFolderModal);
+document.getElementById('folderCancelBtn')?.addEventListener('click', closeFolderModal);
+document.getElementById('folderCancelBtn2')?.addEventListener('click', closeFolderModal);
+
+document.getElementById('folderModalForm').addEventListener('submit', async function(e) {
+ e.preventDefault();
+ const errEl = document.getElementById('folderFormError');
+ errEl.style.display = 'none';
+ const host = document.getElementById('folderHostCreate').value.trim();
+ const slug = document.getElementById('folderSlugCreate').value.trim();
+ const name = document.getElementById('folderNameCreate').value.trim();
+ const password = document.getElementById('folderPasswordCreate').value;
+ const listingEnabled = document.getElementById('folderListingEnabledCreate').checked;
+
+ const r = await fetch('/api/folders', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ host, slug, name, password: password || null, listingEnabled }),
+ });
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok) {
+ errEl.textContent = d.error || 'Failed to create folder.';
+ errEl.style.display = '';
+ return;
+ }
+ showToast('Folder created: ' + slug);
+ document.getElementById('folderSlugCreate').value = '';
+ document.getElementById('folderNameCreate').value = '';
+ document.getElementById('folderPasswordCreate').value = '';
+ document.getElementById('folderListingEnabledCreate').checked = true;
+ await refreshFoldersForHost(host);
+ closeFolderModal();
+});
+
+// --- Edit modal helpers ---
+function isoToDatetimeLocal(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ const pad = (n) => String(n).padStart(2, '0');
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
+}
+
+function msToDatetimeLocal(ms) {
+ if (!ms) return '';
+ const d = new Date(ms);
+ const pad = (n) => String(n).padStart(2, '0');
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
+}
+
+function closeEditModal() {
+ const backdrop = document.getElementById('editBackdrop');
+ backdrop.classList.remove('show');
+}
+
+function openEditModal(host, slug, guest, expiresAt, folderSlug) {
+ const backdrop = document.getElementById('editBackdrop');
+ backdrop.classList.add('show');
+ document.getElementById('editHost').value = host;
+ document.getElementById('editOldSlug').value = slug;
+ document.getElementById('editSlug').value = slug;
+ document.getElementById('editGuest').value = guest || '';
+ document.getElementById('editExpiresAt').value = msToDatetimeLocal(expiresAt);
+ document.getElementById('editFolderSlug').value = folderSlug || '';
+ document.getElementById('editPassword').value = '';
+ document.getElementById('editClearPassword').checked = false;
+ const err = document.getElementById('editError');
+ err.style.display = 'none';
+ err.textContent = '';
+}
+
+document.getElementById('showDeletedToggle').addEventListener('change', (e) => {
+ SHOW_DELETED = !!e.target.checked;
+ applyRowVisibility();
+});
+
+// Initial hide of deleted rows
+document.addEventListener('DOMContentLoaded', () => {
+ refreshFoldersForHost(document.getElementById('host').value);
+ refreshAudit();
+ applyRowVisibility();
+});
+
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') closeEditModal();
+});
+
+document.getElementById('editBackdrop').addEventListener('click', (e) => {
+ if (e.target.id === 'editBackdrop') closeEditModal();
+});
+
+document.getElementById('editCancelBtn').addEventListener('click', (e) => {
+ e.preventDefault();
+ closeEditModal();
+});
+
+document.getElementById('editCancelBtn2').addEventListener('click', (e) => {
+ e.preventDefault();
+ closeEditModal();
+});
+
+document.getElementById('editForm').addEventListener('submit', async function(e) {
+ e.preventDefault();
+ const errEl = document.getElementById('editError');
+ errEl.style.display = 'none';
+
+ const host = document.getElementById('editHost').value.trim();
+ const oldSlug = document.getElementById('editOldSlug').value.trim();
+ const newSlug = document.getElementById('editSlug').value.trim();
+ const guest = document.getElementById('editGuest').value.trim();
+ const expiresAtRaw = document.getElementById('editExpiresAt').value;
+ const folderSlug = document.getElementById('editFolderSlug').value.trim();
+ const password = document.getElementById('editPassword').value;
+ const clearPassword = document.getElementById('editClearPassword').checked;
+
+ const saveBtn = document.getElementById('editSaveBtn');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Savingβ¦';
+
+ try {
+ let currentSlug = oldSlug;
+
+ if (newSlug && newSlug !== oldSlug) {
+ const rr = await fetch('/api/links/' + encodeURIComponent(oldSlug) + '/rename', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ host, newSlug }),
+ });
+ const rd = await rr.json().catch(() => ({}));
+ if (!rr.ok) throw new Error(rd.error || 'Rename failed');
+ currentSlug = rd.slug || newSlug;
+
+ // Update table row dataset/slug display
+ const row = document.querySelector('[data-slug="' + oldSlug + '"][data-host="' + host + '"]');
+ if (row) {
+ row.dataset.slug = currentSlug;
+ const code = row.querySelector('.slug-code');
+ if (code) code.textContent = currentSlug;
+ }
+ document.getElementById('editOldSlug').value = currentSlug;
+ }
+
+ const patchBody = {
+ host,
+ guest,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ };
+ patchBody.folderSlug = folderSlug || null;
+ if (clearPassword) patchBody.password = null;
+ else if (password) patchBody.password = password;
+
+ const pr = await fetch('/api/links/' + encodeURIComponent(currentSlug), {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patchBody),
+ });
+ const pd = await pr.json().catch(() => ({}));
+ if (!pr.ok) throw new Error(pd.error || 'Update failed');
+
+ // Update destination cell + expiry cell + pass cell
+ const row = document.querySelector('[data-slug="' + currentSlug + '"][data-host="' + host + '"]');
+ if (row) {
+ const destCell = row.querySelector('.url-cell a');
+ if (destCell) {
+ destCell.href = guest;
+ destCell.title = guest;
+ destCell.textContent = guest;
+ }
+ const expiryCell = row.querySelector('td.center.nowrap');
+ if (expiryCell) {
+ expiryCell.textContent = expiresAtRaw
+ ? new Date(new Date(expiresAtRaw).getTime()).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'short' })
+ : 'β';
+ }
+ const passCell = row.querySelector('.pass-cell');
+ if (passCell) {
+ // If user entered a password, show lock. If cleared, show dash. Otherwise unchanged.
+ if (clearPassword) passCell.textContent = 'β';
+ else if (password) passCell.textContent = 'π';
+ }
+ const actionsCell = row.querySelector('td:last-child');
+ if (actionsCell) {
+ const st = row.dataset.status || 'active';
+ const expMs = expiresAtRaw ? new Date(expiresAtRaw).getTime() : null;
+ actionsCell.innerHTML = buildLinkRowActionsHtml(host, currentSlug, guest, expMs, folderSlug || null, st);
+ }
+ }
+
+ showToast('Updated: ' + (host === new URL(ORIGIN).host ? (ORIGIN + '/' + currentSlug) : ('https://' + host + '/' + currentSlug)));
+ closeEditModal();
+ } catch (err) {
+ errEl.textContent = err.message || 'Failed to update link.';
+ errEl.style.display = '';
+ } finally {
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save changes';
+ }
+});
+
+document.getElementById('createForm').addEventListener('submit', async function(e) {
+ e.preventDefault();
+ const errEl = document.getElementById('formError');
+ errEl.style.display = 'none';
+ const fd = new FormData(e.target);
+ const host = fd.get('host').trim();
+ const slug = fd.get('slug').trim();
+ const guest = fd.get('guest').trim();
+ const folderSlug = (fd.get('folderSlug') || '').trim();
+ const expiresAtRaw = fd.get('expiresAt');
+ const password = fd.get('password');
+
+ const body = {
+ host,
+ slug,
+ guest,
+ folderSlug: folderSlug || null,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ password: password || null,
+ };
+
+ const btn = document.getElementById('createBtn');
+ btn.disabled = true;
+ btn.textContent = 'Creatingβ¦';
+
+ const r = await fetch('/api/links', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const d = await r.json().catch(() => ({}));
+ btn.disabled = false;
+ btn.innerHTML = ' Create Link';
+
+ if (!r.ok) {
+ errEl.textContent = d.error || 'Failed to create link.';
+ errEl.style.display = '';
+ return;
+ }
+
+ showToast('Created: ' + (host === new URL(ORIGIN).host ? (ORIGIN + '/' + slug) : ('https://' + host + '/' + slug)));
+ e.target.reset();
+
+ // Add row to table
+ const tbody = document.getElementById('linksBody');
+ // Remove empty-row if present
+ const emptyRow = tbody.querySelector('.empty-row');
+ if (emptyRow) emptyRow.closest('tr').remove();
+
+ const expiryText = body.expiresAt
+ ? new Date(body.expiresAt).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'short' })
+ : 'β';
+ const shortUrl = (host === new URL(ORIGIN).host) ? (ORIGIN + '/' + slug) : ('https://' + host + '/' + slug);
+ const tr = document.createElement('tr');
+ tr.dataset.slug = slug;
+ tr.dataset.host = host;
+ tr.dataset.status = 'active';
+ tr.dataset.folderSlug = folderSlug || '';
+
+ function esc(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+ }
+
+ tr.innerHTML =
+ '' + esc(slug) + ' | ' +
+ '' + esc(host) + ' | ' +
+ 'Active | ' +
+ '' + esc(guest) + ' | ' +
+ '0 | ' +
+ '' + (body.password ? 'π' : 'β') + ' | ' +
+ '' + esc(expiryText) + ' | ' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '' +
+ ' | ';
+ tbody.insertBefore(tr, tbody.firstChild);
+
+ const badge = document.getElementById('linkCount');
+ if (badge) badge.textContent = parseInt(badge.textContent || '0', 10) + 1;
+ applyRowVisibility();
+});
+ `,
+ );
+}
+
diff --git a/src/pages/errors.js b/src/pages/errors.js
new file mode 100644
index 0000000..1a20a7d
--- /dev/null
+++ b/src/pages/errors.js
@@ -0,0 +1,158 @@
+import { htmlPage } from './shared.js';
+import { escHtml } from '../util.js';
+
+export function passwordPage(slug, badPassword) {
+ return htmlPage(
+ 'Protected Link β Plummer',
+ `
+
+
π
+
Protected Link
+
This link requires a password. Enter it below to continue.
+ ${badPassword ? '
Incorrect password β please try again.
' : ''}
+
+
+
`,
+ `
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
+ .pw-wrap { width:100%; max-width:380px; }
+ .pw-card { text-align:center; }
+ .pw-icon { font-size:3rem; margin-bottom:18px; }
+ h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
+ p { opacity:0.72; margin-bottom:20px; font-size:0.95rem; }
+ .alert { text-align:left; }
+ `,
+ );
+}
+
+export function expiredPage() {
+ return htmlPage(
+ 'Link Expired β Plummer',
+ `
+
+
β°
+
Link Expired
+
This short link has expired and is no longer active.
+
β Back to Home
+
+
`,
+ `
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
+ .center-card { text-align:center; max-width:380px; }
+ .page-icon { font-size:3rem; margin-bottom:18px; }
+ h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
+ p { opacity:0.72; margin-bottom:4px; font-size:0.95rem; }
+ `,
+ );
+}
+
+export function inactivePage() {
+ return htmlPage(
+ 'Link Inactive β Plummer',
+ `
+
+
βΈοΈ
+
Link Inactive
+
This short link is currently inactive.
+
β Back to Home
+
+
`,
+ `
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
+ .center-card { text-align:center; max-width:380px; }
+ .page-icon { font-size:3rem; margin-bottom:18px; }
+ h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
+ p { opacity:0.72; margin-bottom:4px; font-size:0.95rem; }
+ `,
+ );
+}
+
+export function deletedPage() {
+ return htmlPage(
+ 'Link Deleted β Plummer',
+ ``,
+ `
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
+ .center-card { text-align:center; max-width:380px; }
+ .page-icon { font-size:3rem; margin-bottom:18px; }
+ h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
+ p { opacity:0.72; margin-bottom:4px; font-size:0.95rem; }
+ `,
+ );
+}
+
+export function notFoundPage() {
+ return htmlPage(
+ 'Not Found β Plummer',
+ `
+
+
π
+
Link Not Found
+
This short link doesn't exist, or may have been deleted.
+
β Back to Home
+
+
`,
+ `
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
+ .center-card { text-align:center; max-width:380px; }
+ .page-icon { font-size:3rem; margin-bottom:18px; }
+ h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
+ p { opacity:0.72; margin-bottom:4px; font-size:0.95rem; }
+ `,
+ );
+}
+
+export function misconfiguredPage() {
+ return htmlPage(
+ 'Setup Required β Plummer',
+ `
+
+
βοΈ
+
Setup Required
+
The ADMIN_SECRET environment variable is not configured.
+
+ Run the following command to set it, then re-deploy:
+
+
npx wrangler secret put ADMIN_SECRET
+
+ View Setup Guide β
+
+
+
`,
+ `
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:20px; }
+ .center-card { text-align:center; max-width:440px; }
+ .page-icon { font-size:3rem; margin-bottom:18px; }
+ h1 { font-size:1.5rem; font-weight:800; margin-bottom:10px; }
+ p { opacity:0.75; margin-bottom:4px; font-size:0.95rem; }
+ p code {
+ font-family: ui-monospace, monospace;
+ background: var(--card-hover); padding: 1px 6px; border-radius: 4px; font-size:0.9rem;
+ }
+ .setup-code {
+ margin-top:14px; padding:12px 16px;
+ background:var(--card-hover); border:1px solid var(--border-color);
+ border-radius:8px; font-family:ui-monospace,monospace; font-size:13px;
+ text-align:left; overflow-x:auto;
+ }
+ `,
+ );
+}
+
diff --git a/src/pages/folders.js b/src/pages/folders.js
new file mode 100644
index 0000000..0385c80
--- /dev/null
+++ b/src/pages/folders.js
@@ -0,0 +1,83 @@
+import { htmlPage } from './shared.js';
+import { escHtml } from '../util.js';
+
+export function folderListingPage({ origin, host, folder, links }) {
+ const title = folder?.name ? `${folder.name} β Plummer` : 'Folder β Plummer';
+ const safeName = folder?.name ? escHtml(folder.name) : escHtml(folder?.slug ?? 'Folder');
+ const base = new URL(origin);
+ base.host = host;
+
+ const rows = links.length === 0
+ ? `| No links in this folder yet. |
`
+ : links.map((link) => {
+ const shortUrl = `${base.origin}/${link.slug}`;
+ const expiry = link.expiresAt
+ ? new Date(link.expiresAt).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'short' })
+ : 'β';
+ const status = link.status || 'active';
+ return `
+ ${escHtml(link.slug)} |
+ ${escHtml(link.guest)} |
+ ${escHtml(status)} |
+ ${escHtml(expiry)} |
+
`;
+ }).join('');
+
+ return htmlPage(
+ title,
+ `
+
+
+
+ Links
+
+
+
+
+ | Slug |
+ Destination |
+ Status |
+ Expires |
+
+
+
+ ${rows}
+
+
+
+
+
`,
+ `
+ body { padding: 24px 16px 40px; }
+ .wrap { max-width: 960px; margin: 0 auto; }
+ .header { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--border-color); }
+ .header h1 { font-size: 1.8rem; font-weight: 900; margin: 8px 0 4px; }
+ .sub { opacity: 0.75; font-size: 0.95rem; }
+ .sub code { font-family: ui-monospace, monospace; background: var(--card-hover); padding: 1px 6px; border-radius: 4px; }
+ .back-link { color: var(--accent-color); font-weight: 700; font-size: 0.9rem; }
+ .back-link:hover { text-decoration: underline; }
+
+ h2 { font-size: 1.05rem; font-weight: 800; margin-bottom: 12px; }
+ .table-wrap { overflow-x: auto; }
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
+ thead { background: var(--card-hover); }
+ th {
+ padding: 10px 14px; text-align: left;
+ font-size: 11px; font-weight: 800; text-transform: uppercase;
+ letter-spacing: 0.6px; white-space: nowrap;
+ }
+ td { padding: 12px 14px; border-bottom: 1px solid var(--border-color); vertical-align: middle; }
+ tr:last-child td { border-bottom: none; }
+ .center { text-align: center; }
+ .nowrap { white-space: nowrap; }
+ .url-cell { max-width: 460px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .empty-row { text-align: center; padding: 28px; opacity: 0.6; font-style: italic; }
+ code { font-family: ui-monospace, "Cascadia Code", monospace; }
+ `,
+ );
+}
+
diff --git a/src/pages/heartbeat.js b/src/pages/heartbeat.js
new file mode 100644
index 0000000..fde110c
--- /dev/null
+++ b/src/pages/heartbeat.js
@@ -0,0 +1,116 @@
+export function heartbeatPage() {
+ return `
+
+
+
+
+ Plummer β Heartbeat
+
+
+
+
+
β
+
Service Up
+
Operational
+
+
Diagnostic Information
+
+ Timestamp
+ ${new Date().toISOString()}
+
+
+ Service
+ Plummer
+
+
+ Status
+ Healthy
+
+
+
+
+
+`;
+}
diff --git a/src/pages/home.js b/src/pages/home.js
new file mode 100644
index 0000000..49ca3b5
--- /dev/null
+++ b/src/pages/home.js
@@ -0,0 +1,109 @@
+import { APP_VERSION } from '../constants.js';
+import { htmlPage } from './shared.js';
+
+export function homePage(origin) {
+ return htmlPage(
+ 'Plummer β Link Shortener',
+ `
+
+
+
Plummer
+
A simple, fast link shortener β powered by
Cloudflare Workers & KV.
+
Manage Links β
+
+
+
+
+
β‘
+
Edge-Fast Redirects
+
Every redirect is served from Cloudflare's global edge network β sub-millisecond latency worldwide.
+
+
+
π
+
Click Analytics
+
Track how many times each short link has been visited, right from the admin dashboard.
+
+
+
π
+
Password Protection
+
Optionally require a password before visitors are redirected β perfect for private links.
+
+
+
β°
+
Link Expiry
+
Set links to expire automatically at a specific date and time. Expired links are cleaned up automatically.
+
+
+
+
+`,
+ /* extra CSS */
+ `
+ body {
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ padding: 40px 20px 80px;
+ }
+ .home-main { max-width: 900px; width: 100%; }
+
+ /* Hero */
+ .hero { text-align: center; padding: 60px 20px 48px; }
+ .hero-icon {
+ width: 84px; height: 84px;
+ background: linear-gradient(135deg, var(--accent-color),
+ color-mix(in srgb, var(--accent-color) 70%, #8b5cf6 30%));
+ border-radius: 20px;
+ display: inline-flex; align-items: center; justify-content: center;
+ color: #fff; margin-bottom: 28px;
+ box-shadow: 0 8px 32px color-mix(in srgb, var(--accent-color) 40%, transparent);
+ }
+ h1 { font-size: 3.5rem; font-weight: 800; letter-spacing: -2px; line-height: 1; }
+ .tagline {
+ font-size: 1.15rem; opacity: 0.75; margin-top: 16px;
+ max-width: 420px; margin-left: auto; margin-right: auto;
+ line-height: 1.7;
+ }
+ .hero-cta { margin-top: 28px; font-size: 1rem; padding: 12px 28px; border-radius: 12px; }
+
+ /* Features */
+ .features {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 16px; margin-top: 16px;
+ }
+ .feature-card { text-align: center; }
+ .feature-icon { font-size: 2rem; margin-bottom: 12px; }
+ .feature-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
+ .feature-card p { font-size: 0.875rem; opacity: 0.7; line-height: 1.55; }
+
+ /* Footer */
+ .home-footer {
+ position: fixed; bottom: 0; left: 0; right: 0;
+ text-align: center; padding: 10px 16px;
+ background: var(--card-bg); border-top: 1px solid var(--border-color);
+ font-size: 0.82rem; opacity: 0.8;
+ }
+ .app-version { font-weight: 700; opacity: 0.75; }
+
+ @media (max-width: 600px) {
+ h1 { font-size: 2.4rem; }
+ .features { grid-template-columns: 1fr 1fr; }
+ }
+ @media (max-width: 400px) {
+ .features { grid-template-columns: 1fr; }
+ }
+ `,
+ );
+}
+
diff --git a/src/pages/shared.js b/src/pages/shared.js
new file mode 100644
index 0000000..2f1f8ce
--- /dev/null
+++ b/src/pages/shared.js
@@ -0,0 +1,221 @@
+// Shared HTML/CSS primitives used by all pages.
+import { escHtml } from '../util.js';
+
+// βββ Shared CSS (based on SillyLittleTech lander / pasCurtain) ββββββββββββββ
+const SHARED_CSS = `
+ :root {
+ --bg-color: #ffffff;
+ --text-color: #1a1a1a;
+ --card-bg: #f5f5f5;
+ --card-hover: #e8e8e8;
+ --border-color: #e0e0e0;
+ --shadow: rgba(0,0,0,0.1);
+ --accent-color: #667eea;
+ --danger-color: #ef4444;
+ --success-color: #10b981;
+ }
+ [data-theme="dark"] {
+ --bg-color: #1a1a1a;
+ --text-color: #ffffff;
+ --card-bg: #2d2d2d;
+ --card-hover: #3a3a3a;
+ --border-color: #404040;
+ --shadow: rgba(0,0,0,0.3);
+ --accent-color: #8b9dff;
+ --danger-color: #f87171;
+ --success-color: #34d399;
+ }
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ body {
+ font-family: "Lexend", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Helvetica Neue", Arial, sans-serif;
+ font-size: 16px;
+ background: var(--bg-color);
+ color: var(--text-color);
+ transition: background-color 0.18s ease, color 0.18s ease;
+ min-height: 100vh;
+ line-height: 1.6;
+ }
+ a { color: var(--accent-color); text-decoration: none; }
+ a:hover { text-decoration: underline; }
+
+ /* Buttons */
+ .btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ gap: 6px;
+ padding: 10px 18px;
+ border: none; border-radius: 10px;
+ font-family: inherit; font-size: 15px; font-weight: 700;
+ cursor: pointer;
+ transition: transform 0.14s ease, box-shadow 0.14s ease, opacity 0.12s ease;
+ text-decoration: none;
+ white-space: nowrap;
+ }
+ .btn:hover { text-decoration: none; }
+ .btn-primary {
+ background: linear-gradient(180deg, var(--accent-color),
+ color-mix(in srgb, var(--accent-color) 85%, black 15%));
+ color: #fff;
+ box-shadow: 0 6px 24px rgba(102,126,234,0.2);
+ }
+ .btn-primary:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 32px rgba(102,126,234,0.25);
+ }
+ .btn-secondary {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ color: var(--text-color);
+ }
+ .btn-secondary:hover { background: var(--card-hover); transform: translateY(-2px); }
+ .btn-danger { background: var(--danger-color); color: #fff; }
+ .btn-danger:hover { opacity: 0.9; transform: translateY(-1px); }
+ .btn-sm { padding: 6px 12px; font-size: 13px; border-radius: 8px; }
+
+ /* Cards */
+ .card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 24px;
+ box-shadow: 0 4px 16px var(--shadow);
+ }
+
+ /* Form elements */
+ .input, .select {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-color);
+ color: var(--text-color);
+ font-family: inherit; font-size: 15px;
+ transition: border-color 0.14s ease;
+ }
+ .input:focus, .select:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 20%, transparent);
+ }
+ label { display: block; font-weight: 600; margin-bottom: 6px; font-size: 14px; }
+ .form-group { margin-bottom: 16px; }
+ .hint { font-size: 12px; opacity: 0.65; margin-top: 4px; }
+
+ /* Theme toggle */
+ .theme-toggle {
+ position: fixed; top: 20px; right: 20px;
+ background: var(--card-bg);
+ border: 2px solid var(--border-color); border-radius: 50%;
+ width: 48px; height: 48px;
+ display: flex; align-items: center; justify-content: center;
+ cursor: pointer; z-index: 1000;
+ transition: transform 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
+ box-shadow: 0 6px 18px rgba(0,0,0,0.06);
+ }
+ .theme-toggle:hover { background: var(--card-hover); transform: rotate(12deg); }
+ .theme-toggle svg {
+ width: 22px; height: 22px;
+ color: var(--text-color);
+ fill: none; stroke: currentColor;
+ stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
+ }
+ .sun-icon { display: none; }
+ .moon-icon { display: block; }
+ [data-theme="dark"] .sun-icon { display: block; }
+ [data-theme="dark"] .moon-icon { display: none; }
+
+ /* Alerts */
+ .alert {
+ padding: 12px 16px; border-radius: 8px; margin-bottom: 16px;
+ font-weight: 600; font-size: 14px;
+ }
+ .alert-error {
+ background: color-mix(in srgb, var(--danger-color) 10%, transparent);
+ border: 1px solid var(--danger-color); color: var(--danger-color);
+ }
+ .alert-success {
+ background: color-mix(in srgb, var(--success-color) 10%, transparent);
+ border: 1px solid var(--success-color); color: var(--success-color);
+ }
+
+ /* Toast notification */
+ .toast {
+ position: fixed; bottom: 24px; right: 24px;
+ background: var(--card-bg); border: 1px solid var(--border-color);
+ border-radius: 10px; padding: 12px 20px;
+ font-weight: 600; font-size: 14px;
+ box-shadow: 0 8px 32px var(--shadow);
+ transform: translateY(80px); opacity: 0;
+ transition: all 0.3s ease; z-index: 9999;
+ max-width: 320px;
+ }
+ .toast.show { transform: translateY(0); opacity: 1; }
+ .toast.toast-error { border-color: var(--danger-color); }
+ .toast.toast-ok { border-color: var(--success-color); }
+`;
+
+// βββ Theme toggle script βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+const THEME_SCRIPT = `
+(function(){
+ var html = document.documentElement;
+ function getSaved(){ try{ return localStorage.getItem('theme'); }catch(e){ return null; } }
+ function save(t){ try{ localStorage.setItem('theme',t); }catch(e){} }
+ function apply(t){ if(t) html.dataset.theme = t; }
+ function detect(){
+ try{ return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
+ catch(e){ return 'light'; }
+ }
+ function toggle(){
+ var c = html.dataset.theme||'light';
+ var n = c==='light' ? 'dark' : 'light';
+ apply(n); save(n);
+ }
+ var saved = getSaved();
+ var theme = saved || detect();
+ apply(theme);
+ if(!saved) save(theme);
+ document.addEventListener('DOMContentLoaded', function(){
+ var btn = document.getElementById('themeToggle');
+ if(btn) btn.addEventListener('click', toggle);
+ });
+})();
+`;
+
+const TOGGLE_BTN = ``;
+
+// βββ HTML page wrapper βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+export function htmlPage(title, bodyContent, extraCss = '', extraScript = '') {
+ const safeTitle = escHtml(title ?? '');
+ return `
+
+
+
+
+ ${safeTitle}
+
+
+
+
+
+
+
+ ${TOGGLE_BTN}
+ ${bodyContent}
+
+
+`;
+}
+
diff --git a/src/router.js b/src/router.js
new file mode 100644
index 0000000..6b38c9e
--- /dev/null
+++ b/src/router.js
@@ -0,0 +1,834 @@
+import { RESERVED } from './constants.js';
+import {
+ deleteFolder,
+ deleteLink,
+ getAllLinks,
+ getFolder,
+ getLink,
+ listFolders,
+ listLinksByFolder,
+ normalizeHost,
+ putFolder,
+ putLink,
+} from './kv.js';
+import { adminPage } from './pages/admin.js';
+import { homePage } from './pages/home.js';
+import { deletedPage, expiredPage, inactivePage, misconfiguredPage, notFoundPage, passwordPage } from './pages/errors.js';
+import { folderListingPage } from './pages/folders.js';
+import { heartbeatPage } from './pages/heartbeat.js';
+import { checkAdminAuth, unauthorizedResponse } from './security.js';
+import { isValidUrl, safeEqual, sha256 } from './util.js';
+import { getActor, listAudit, writeAudit } from './audit.js';
+
+function getAllowedHosts(env) {
+ const raw = env.ALLOWED_HOSTS_JSON;
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.map((h) => normalizeHost(h)).filter(Boolean);
+ } catch {
+ return [];
+ }
+}
+
+async function handleHomePage(origin) {
+ return new Response(homePage(origin), {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+}
+
+async function handleAdminPage(_request, env, origin) {
+ const links = await getAllLinks(env);
+ const allowedHosts = getAllowedHosts(env);
+ return new Response(adminPage(links, origin, allowedHosts), {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+}
+
+async function handleAPI(request, env, pathname) {
+ const allowedHosts = getAllowedHosts(env);
+ const actor = getActor(request);
+ const requestHost = normalizeHost(request.headers.get('host') ?? new URL(request.url).host);
+ const debugEnabled = env.ENABLE_DEBUG_ENDPOINTS === true || env.ENABLE_DEBUG_ENDPOINTS === 'true';
+
+ async function requireDebugAccess() {
+ if (!debugEnabled) return { ok: false, response: Response.json({ error: 'Not Found' }, { status: 404 }) };
+ // Defense-in-depth: require Basic Auth even if routing/auth changes later.
+ const ok = await checkAdminAuth(request, env);
+ if (!ok) return { ok: false, response: unauthorizedResponse() };
+ return { ok: true };
+ }
+
+ function assertHostAllowed(host) {
+ if (!host) return { ok: false, response: Response.json({ error: 'host is required' }, { status: 400 }) };
+ if (allowedHosts.length > 0 && !allowedHosts.includes(host)) {
+ return {
+ ok: false,
+ response: Response.json({ error: `"${host}" is not an allowed host` }, { status: 400 }),
+ };
+ }
+ // Safer default: if no allowlist is configured, only allow the current request host.
+ // This prevents creating/updating records under arbitrary host-scoped keys.
+ if (allowedHosts.length === 0 && host !== requestHost) {
+ return {
+ ok: false,
+ response: Response.json(
+ { error: `Host "${host}" is not allowed (no ALLOWED_HOSTS_JSON configured; expected "${requestHost}")` },
+ { status: 400 },
+ ),
+ };
+ }
+ return { ok: true };
+ }
+
+ // GET /api/debug/link?host=...&slug=...
+ // Temporary debugging helper for KV key issues in dev.
+ if (pathname === '/api/debug/link' && request.method === 'GET') {
+ const access = await requireDebugAccess();
+ if (!access.ok) return access.response;
+ const url = new URL(request.url);
+ const host = normalizeHost(url.searchParams.get('host'));
+ const slug = url.searchParams.get('slug');
+ if (!host || !slug) return Response.json({ error: 'host and slug are required' }, { status: 400 });
+
+ const keysToCheck = [
+ `link:${host}:${slug}`,
+ `link:${host.replace(/:\\d+$/, '')}:${slug}`,
+ `link:${slug}`,
+ ];
+
+ const results = {};
+ for (const k of keysToCheck) {
+ const v = await env.LINKIVERSE.get(k);
+ results[k] = v ? { found: true, sample: v.slice(0, 200) } : { found: false };
+ }
+
+ // Also list a few keys containing the slug suffix.
+ const matches = [];
+ let cursor;
+ do {
+ const page = await env.LINKIVERSE.list({ prefix: 'link:', limit: 100, cursor });
+ for (const key of page.keys) {
+ if (key.name.endsWith(`:${slug}`) || key.name === `link:${slug}`) matches.push(key.name);
+ if (matches.length >= 20) break;
+ }
+ if (matches.length >= 20) break;
+ cursor = page.list_complete ? undefined : page.cursor;
+ } while (cursor);
+
+ return Response.json({ host, slug, keysToCheck, results, matches });
+ }
+
+ // GET /api/debug/getlink?host=...&slug=...
+ // Returns the result of getLink() vs a direct KV get for the canonical key.
+ if (pathname === '/api/debug/getlink' && request.method === 'GET') {
+ const access = await requireDebugAccess();
+ if (!access.ok) return access.response;
+ const url = new URL(request.url);
+ const host = normalizeHost(url.searchParams.get('host'));
+ const slug = url.searchParams.get('slug');
+ if (!host || !slug) return Response.json({ error: 'host and slug are required' }, { status: 400 });
+
+ const viaHelper = await getLink(env, host, slug);
+ const canonicalKey = `link:${host}:${slug}`;
+ const raw = await env.LINKIVERSE.get(canonicalKey);
+ let parsedRaw = null;
+ try { parsedRaw = raw ? JSON.parse(raw) : null; } catch { parsedRaw = null; }
+
+ return Response.json({
+ host,
+ slug,
+ canonicalKey,
+ getLinkFound: Boolean(viaHelper),
+ getLinkSample: viaHelper ? JSON.stringify(viaHelper).slice(0, 200) : null,
+ directFound: Boolean(raw),
+ directSample: raw ? raw.slice(0, 200) : null,
+ directParsedSample: parsedRaw ? JSON.stringify(parsedRaw).slice(0, 200) : null,
+ });
+ }
+
+ // GET /api/debug/redirect-lookup?slug=...
+ // Uses the incoming request's host/url like handleRedirect does, then runs getLink().
+ if (pathname === '/api/debug/redirect-lookup' && request.method === 'GET') {
+ const access = await requireDebugAccess();
+ if (!access.ok) return access.response;
+ const url = new URL(request.url);
+ const slug = url.searchParams.get('slug');
+ if (!slug) return Response.json({ error: 'slug is required' }, { status: 400 });
+ const hostHeader = request.headers.get('host');
+ const computedHost = normalizeHost(hostHeader ?? url.host);
+ const found = await getLink(env, computedHost, slug);
+ return Response.json({
+ slug,
+ hostHeader,
+ urlHost: url.host,
+ computedHost,
+ canonicalKey: `link:${computedHost}:${slug}`,
+ found: Boolean(found),
+ sample: found ? JSON.stringify(found).slice(0, 200) : null,
+ });
+ }
+
+ // POST /api/debug/force-delete?host=...&slug=...
+ // Testing-only endpoint: immediately removes KV entries for a link.
+ // Gated behind FORCE_DELETE_KEY (set locally via .dev.vars and in prod via wrangler secret).
+ // Also requires ENABLE_DEBUG_ENDPOINTS=true to avoid accidental exposure in production.
+ //
+ // Provide key via header `x-force-delete-key` OR query param `key` (header recommended).
+ if (pathname === '/api/debug/force-delete' && request.method === 'POST') {
+ // Require FORCE_DELETE_KEY to be configured for this operation to exist.
+ if (!env.FORCE_DELETE_KEY) {
+ return Response.json({ error: 'FORCE_DELETE_KEY is not configured' }, { status: 404 });
+ }
+
+ const url = new URL(request.url);
+ const host = normalizeHost(url.searchParams.get('host'));
+ const slug = url.searchParams.get('slug');
+ if (!host || !slug) return Response.json({ error: 'host and slug are required' }, { status: 400 });
+
+ // Allow either a matching FORCE_DELETE_KEY (header `x-force-delete-key` OR query `key`)
+ // OR valid Basic Auth via `requireDebugAccess()`. This avoids applying blanket
+ // Basic Auth rules to all `/api/*` routes while still protecting this destructive
+ // endpoint.
+ const provided = request.headers.get('x-force-delete-key') ?? url.searchParams.get('key') ?? '';
+ const [providedHash, expectedHash] = await Promise.all([sha256(provided), sha256(env.FORCE_DELETE_KEY)]);
+ if (!provided || !safeEqual(providedHash, expectedHash)) {
+ // Fallback to Basic Auth for debugging access.
+ const access = await requireDebugAccess();
+ if (!access.ok) return access.response;
+ }
+
+ const before = await getLink(env, host, slug);
+
+ // Delete canonical key + a couple compatibility keys (host without port, and legacy link:{slug})
+ await deleteLink(env, host, slug);
+ const noPort = host.replace(/:\\d+$/, '');
+ if (noPort && noPort !== host) {
+ await env.LINKIVERSE.delete(`link:${noPort}:${slug}`);
+ }
+ await env.LINKIVERSE.delete(`link:${slug}`);
+
+ await writeAudit(env, {
+ action: 'link.forceDelete',
+ host,
+ slug,
+ before,
+ actor,
+ });
+
+ return Response.json({ ok: true, host, slug, existed: Boolean(before) });
+ }
+
+ // GET /api/links
+ if (pathname === '/api/links' && request.method === 'GET') {
+ const links = await getAllLinks(env);
+ return Response.json(links);
+ }
+
+ // GET /api/audit
+ if (pathname === '/api/audit' && request.method === 'GET') {
+ const url = new URL(request.url);
+ const limit = Math.min(300, Math.max(1, Number(url.searchParams.get('limit') || 100)));
+ return Response.json(await listAudit(env, { limit }));
+ }
+
+ // GET /api/folders?host=...
+ if (pathname === '/api/folders' && request.method === 'GET') {
+ const url = new URL(request.url);
+ const host = normalizeHost(url.searchParams.get('host'));
+ const hostCheck = assertHostAllowed(host);
+ if (!hostCheck.ok) return hostCheck.response;
+ return Response.json(await listFolders(env, host));
+ }
+
+ // POST /api/folders
+ if (pathname === '/api/folders' && request.method === 'POST') {
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ const host = normalizeHost(body?.host);
+ const hostCheck = assertHostAllowed(host);
+ if (!hostCheck.ok) return hostCheck.response;
+
+ const slug = body?.slug;
+ const name = body?.name;
+ const listingEnabled = body?.listingEnabled !== false;
+ const password = body?.password ?? null;
+
+ if (!slug || typeof slug !== 'string') return Response.json({ error: 'slug is required' }, { status: 400 });
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(slug)) {
+ return Response.json({ error: 'Folder slug may only contain letters, numbers, hyphens and underscores (max 64 chars)' }, { status: 400 });
+ }
+ if (RESERVED.has(slug.toLowerCase())) return Response.json({ error: `"${slug}" is reserved` }, { status: 400 });
+
+ // Prevent folder slug colliding with an existing link on that host.
+ const linkCollision = await getLink(env, host, slug);
+ if (linkCollision) return Response.json({ error: `Folder slug "${slug}" collides with an existing link` }, { status: 409 });
+
+ const existingFolder = await getFolder(env, host, slug);
+ if (existingFolder) return Response.json({ error: `Folder "${slug}" already exists` }, { status: 409 });
+
+ const folder = {
+ slug,
+ host,
+ name: typeof name === 'string' && name.trim() ? name.trim() : slug,
+ listingEnabled: !!listingEnabled,
+ passwordHash: password ? await sha256(password) : null,
+ createdAt: Date.now(),
+ };
+
+ await putFolder(env, host, folder);
+ env.ctx?.waitUntil(writeAudit(env, {
+ action: 'folder.create',
+ host,
+ folderSlug: slug,
+ after: folder,
+ actor,
+ }));
+ return Response.json({ slug, host, message: 'Created' }, { status: 201 });
+ }
+
+ // PATCH /api/folders/:slug
+ const patchFolderMatch = pathname.match(/^\/api\/folders\/([^/]+)$/);
+ if (patchFolderMatch && request.method === 'PATCH') {
+ const folderSlug = decodeURIComponent(patchFolderMatch[1]);
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ const host = normalizeHost(body?.host);
+ const hostCheck = assertHostAllowed(host);
+ if (!hostCheck.ok) return hostCheck.response;
+
+ const existing = await getFolder(env, host, folderSlug);
+ if (!existing) return Response.json({ error: 'Folder not found' }, { status: 404 });
+
+ const next = { ...existing };
+ if (body?.name !== undefined) {
+ if (typeof body.name !== 'string' || !body.name.trim()) return Response.json({ error: 'name must be a non-empty string' }, { status: 400 });
+ next.name = body.name.trim();
+ }
+ if (body?.listingEnabled !== undefined) next.listingEnabled = !!body.listingEnabled;
+ if (body?.password !== undefined) {
+ if (body.password === null || body.password === '') next.passwordHash = null;
+ else if (typeof body.password === 'string') next.passwordHash = await sha256(body.password);
+ else return Response.json({ error: 'password must be a string or null' }, { status: 400 });
+ }
+
+ await putFolder(env, host, next);
+ env.ctx?.waitUntil(writeAudit(env, {
+ action: 'folder.update',
+ host,
+ folderSlug,
+ before: existing,
+ after: next,
+ actor,
+ }));
+ return Response.json({ slug: folderSlug, host, message: 'Updated' });
+ }
+
+ // DELETE /api/folders/:slug?host=...
+ const deleteFolderMatch = pathname.match(/^\/api\/folders\/([^/]+)$/);
+ if (deleteFolderMatch && request.method === 'DELETE') {
+ const folderSlug = decodeURIComponent(deleteFolderMatch[1]);
+ const url = new URL(request.url);
+ const host = normalizeHost(url.searchParams.get('host'));
+ const hostCheck = assertHostAllowed(host);
+ if (!hostCheck.ok) return hostCheck.response;
+
+ const existing = await getFolder(env, host, folderSlug);
+ if (!existing) return Response.json({ error: 'Folder not found' }, { status: 404 });
+
+ await deleteFolder(env, host, folderSlug);
+ env.ctx?.waitUntil(writeAudit(env, {
+ action: 'folder.delete',
+ host,
+ folderSlug,
+ before: existing,
+ actor,
+ }));
+ return Response.json({ message: 'Deleted' });
+ }
+
+ // POST /api/links
+ if (pathname === '/api/links' && request.method === 'POST') {
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ const { slug, guest, expiresAt, password, host, folderSlug } = body ?? {};
+ const normalizedHost = normalizeHost(host);
+ const hostCheck = assertHostAllowed(normalizedHost);
+ if (!hostCheck.ok) return hostCheck.response;
+
+ if (!slug || typeof slug !== 'string') {
+ return Response.json({ error: 'slug is required' }, { status: 400 });
+ }
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(slug)) {
+ return Response.json(
+ { error: 'Slug may only contain letters, numbers, hyphens and underscores (max 64 chars)' },
+ { status: 400 },
+ );
+ }
+ if (RESERVED.has(slug.toLowerCase())) {
+ return Response.json({ error: `"${slug}" is a reserved slug` }, { status: 400 });
+ }
+ if (!guest || !isValidUrl(guest)) {
+ return Response.json({ error: 'A valid destination URL is required' }, { status: 400 });
+ }
+ if (expiresAt !== undefined && expiresAt !== null) {
+ if (typeof expiresAt !== 'number' || expiresAt < Date.now()) {
+ return Response.json({ error: 'expiresAt must be a future Unix timestamp (ms)' }, { status: 400 });
+ }
+ }
+
+ if (folderSlug !== undefined && folderSlug !== null) {
+ if (typeof folderSlug !== 'string') return Response.json({ error: 'folderSlug must be a string or null' }, { status: 400 });
+ if (folderSlug && !(await getFolder(env, normalizedHost, folderSlug))) {
+ return Response.json({ error: `Folder "${folderSlug}" not found` }, { status: 400 });
+ }
+ }
+
+ const existing = await getLink(env, normalizedHost, slug);
+ if (existing) {
+ return Response.json(
+ { error: `Slug "${slug}" is already in use on ${normalizedHost}` },
+ { status: 409 },
+ );
+ }
+
+ const passwordHash = password ? await sha256(password) : null;
+
+ const link = {
+ slug,
+ host: normalizedHost,
+ guest,
+ passwordHash,
+ expiresAt: expiresAt ?? null,
+ folderSlug: folderSlug || null,
+ clicks: 0,
+ createdAt: Date.now(),
+ status: 'active',
+ inactiveAt: null,
+ deletedAt: null,
+ };
+ await putLink(env, normalizedHost, link);
+ env.ctx?.waitUntil(writeAudit(env, {
+ action: 'link.create',
+ host: normalizedHost,
+ slug,
+ after: link,
+ actor,
+ }));
+ return Response.json({ slug, host: normalizedHost, message: 'Created' }, { status: 201 });
+ }
+
+ // PATCH /api/links/:slug
+ const patchMatch = pathname.match(/^\/api\/links\/([^/]+)$/);
+ if (patchMatch && request.method === 'PATCH') {
+ const slug = decodeURIComponent(patchMatch[1]);
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ const normalizedHost = normalizeHost(body?.host);
+ const hostCheck = assertHostAllowed(normalizedHost);
+ if (!hostCheck.ok) return hostCheck.response;
+
+ const existing = await getLink(env, normalizedHost, slug);
+ if (!existing) return Response.json({ error: 'Link not found' }, { status: 404 });
+
+ const next = { ...existing };
+
+ if (body?.status !== undefined) {
+ const s = body.status;
+ if (s !== 'active' && s !== 'inactive' && s !== 'deleted') {
+ return Response.json({ error: 'status must be active, inactive, or deleted' }, { status: 400 });
+ }
+ next.status = s;
+ const purgeMs = 3 * 24 * 60 * 60 * 1000;
+ if (s === 'active') {
+ next.inactiveAt = null;
+ next.deletedAt = null;
+ next.purgeAfter = null;
+ } else if (s === 'inactive') {
+ next.inactiveAt = next.inactiveAt ?? Date.now();
+ next.deletedAt = null;
+ next.purgeAfter = null;
+ } else if (s === 'deleted') {
+ next.deletedAt = next.deletedAt ?? Date.now();
+ next.purgeAfter = Date.now() + purgeMs;
+ }
+ }
+
+ if (body?.folderSlug !== undefined) {
+ if (body.folderSlug === null || body.folderSlug === '') {
+ next.folderSlug = null;
+ } else if (typeof body.folderSlug === 'string') {
+ const f = await getFolder(env, normalizedHost, body.folderSlug);
+ if (!f) return Response.json({ error: `Folder "${body.folderSlug}" not found` }, { status: 400 });
+ next.folderSlug = body.folderSlug;
+ } else {
+ return Response.json({ error: 'folderSlug must be a string or null' }, { status: 400 });
+ }
+ }
+
+ if (body?.guest !== undefined) {
+ if (!body.guest || typeof body.guest !== 'string' || !isValidUrl(body.guest)) {
+ return Response.json({ error: 'A valid destination URL is required' }, { status: 400 });
+ }
+ next.guest = body.guest;
+ }
+
+ if (body?.expiresAt !== undefined) {
+ if (body.expiresAt === null) {
+ next.expiresAt = null;
+ } else if (typeof body.expiresAt === 'number' && body.expiresAt >= Date.now()) {
+ next.expiresAt = body.expiresAt;
+ } else {
+ return Response.json({ error: 'expiresAt must be null or a future Unix timestamp (ms)' }, { status: 400 });
+ }
+ }
+
+ if (body?.password !== undefined) {
+ if (body.password === null || body.password === '') {
+ next.passwordHash = null;
+ } else if (typeof body.password === 'string') {
+ next.passwordHash = await sha256(body.password);
+ } else {
+ return Response.json({ error: 'password must be a string or null' }, { status: 400 });
+ }
+ }
+
+ await putLink(env, normalizedHost, next);
+ env.ctx?.waitUntil(writeAudit(env, {
+ action: 'link.update',
+ host: normalizedHost,
+ slug,
+ before: existing,
+ after: next,
+ actor,
+ }));
+ return Response.json({ slug: next.slug, host: normalizedHost, message: 'Updated' });
+ }
+
+ // POST /api/links/:slug/rename
+ const renameMatch = pathname.match(/^\/api\/links\/([^/]+)\/rename$/);
+ if (renameMatch && request.method === 'POST') {
+ const oldSlug = decodeURIComponent(renameMatch[1]);
+ let body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ const normalizedHost = normalizeHost(body?.host);
+ const hostCheck = assertHostAllowed(normalizedHost);
+ if (!hostCheck.ok) return hostCheck.response;
+
+ const newSlug = body?.newSlug;
+ if (!newSlug || typeof newSlug !== 'string') {
+ return Response.json({ error: 'newSlug is required' }, { status: 400 });
+ }
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(newSlug)) {
+ return Response.json(
+ { error: 'Slug may only contain letters, numbers, hyphens and underscores (max 64 chars)' },
+ { status: 400 },
+ );
+ }
+ if (RESERVED.has(newSlug.toLowerCase())) {
+ return Response.json({ error: `"${newSlug}" is a reserved slug` }, { status: 400 });
+ }
+
+ const existing = await getLink(env, normalizedHost, oldSlug);
+ if (!existing) return Response.json({ error: 'Link not found' }, { status: 404 });
+
+ const collision = await getLink(env, normalizedHost, newSlug);
+ if (collision) return Response.json({ error: `Slug "${newSlug}" is already in use` }, { status: 409 });
+
+ const renamed = { ...existing, slug: newSlug };
+ await putLink(env, normalizedHost, renamed);
+ await deleteLink(env, normalizedHost, oldSlug);
+
+ env.ctx?.waitUntil(writeAudit(env, {
+ action: 'link.rename',
+ host: normalizedHost,
+ slug: oldSlug,
+ newSlug,
+ before: existing,
+ after: renamed,
+ actor,
+ }));
+
+ return Response.json({ slug: newSlug, host: normalizedHost, message: 'Renamed' });
+ }
+
+ // DELETE /api/links/:slug
+ const deleteMatch = pathname.match(/^\/api\/links\/([^/]+)$/);
+ if (deleteMatch && request.method === 'DELETE') {
+ const slug = decodeURIComponent(deleteMatch[1]);
+ const url = new URL(request.url);
+ const host = normalizeHost(url.searchParams.get('host'));
+ const hostCheck = assertHostAllowed(host);
+ if (!hostCheck.ok) return hostCheck.response;
+
+ const existing = await getLink(env, host, slug);
+ if (!existing) return Response.json({ error: 'Link not found' }, { status: 404 });
+ if ((existing.status ?? 'active') !== 'inactive') {
+ return Response.json({ error: 'Link must be inactive before deletion can be scheduled' }, { status: 400 });
+ }
+
+ // Always schedule purge for 3 days; no manual hard-delete.
+ const next = {
+ ...existing,
+ status: 'deleted',
+ deletedAt: existing.deletedAt ?? Date.now(),
+ purgeAfter: Date.now() + 3 * 24 * 60 * 60 * 1000,
+ };
+ await putLink(env, host, next);
+ env.ctx?.waitUntil(writeAudit(env, {
+ action: 'link.delete.scheduled',
+ host,
+ slug,
+ before: existing,
+ after: next,
+ actor,
+ }));
+ return Response.json({ message: 'Deletion scheduled (3-day retention)' });
+ }
+
+ return Response.json({ error: 'API route not found' }, { status: 404 });
+}
+
+async function handleRedirect(request, env, slug) {
+ const url = new URL(request.url);
+ // In Workers/Wrangler dev, Host may be absent; fall back to the URL host.
+ const hostHeader = request.headers.get('host');
+ const host = normalizeHost(hostHeader ?? url.host);
+
+ const maybeFolder = await getFolder(env, host, slug);
+ if (maybeFolder && maybeFolder.listingEnabled !== false) {
+ if (maybeFolder.passwordHash) {
+ if (request.method === 'POST') {
+ let formData;
+ try {
+ formData = await request.formData();
+ } catch {
+ return new Response(passwordPage(slug, false), {
+ status: 200,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ const submitted = formData.get('password') ?? '';
+ const submittedHash = await sha256(submitted);
+ if (!safeEqual(submittedHash, maybeFolder.passwordHash)) {
+ return new Response(passwordPage(slug, true), {
+ status: 403,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ } else {
+ return new Response(passwordPage(slug, false), {
+ status: 200,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ }
+
+ const links = (await listLinksByFolder(env, host, maybeFolder.slug))
+ .filter((l) => (l.status ?? 'active') === 'active');
+
+ return new Response(folderListingPage({ origin: url.origin, host, folder: maybeFolder, links }), {
+ status: 200,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+
+ let link = await getLink(env, host, slug);
+ let resolvedHost = host;
+ if (!link) {
+ // Extra defensive lookup: in local dev we sometimes observe key mismatches despite the expected key existing.
+ // Try a few host candidates directly against KV before giving up.
+ const hostCandidates = [
+ host,
+ normalizeHost(url.host),
+ normalizeHost(hostHeader ?? ''),
+ ].filter(Boolean);
+ for (const h of hostCandidates) {
+ const raw = await env.LINKIVERSE.get(`link:${h}:${slug}`);
+ if (!raw) continue;
+ try {
+ link = JSON.parse(raw);
+ } catch {
+ link = null;
+ }
+ if (link) {
+ // Migrate to canonical key for future reads
+ await putLink(env, h, link);
+ resolvedHost = h;
+ break;
+ }
+ }
+ }
+ if (!link) {
+ const headers = { 'Content-Type': 'text/html; charset=utf-8' };
+ if ((request.headers.get('x-plummer-debug') ?? '') === '1') {
+ headers['X-Plummer-Debug-HostHeader'] = hostHeader ?? '';
+ headers['X-Plummer-Debug-UrlHost'] = url.host;
+ headers['X-Plummer-Debug-NormalizedHost'] = host;
+ headers['X-Plummer-Debug-Key'] = `link:${host}:${slug}`;
+ }
+ return new Response(notFoundPage(), {
+ status: 404,
+ headers,
+ });
+ }
+
+ if (link.status === 'inactive') {
+ return new Response(inactivePage(), {
+ status: 404,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ if (link.status === 'deleted') {
+ return new Response(deletedPage(), {
+ status: 404,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+
+ // Check link expiry
+ if (link.expiresAt && Date.now() > link.expiresAt) {
+ // Clean up the expired link from KV
+ await deleteLink(env, resolvedHost, slug);
+ return new Response(expiredPage(), {
+ status: 410,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+
+ // Handle password-protected links
+ if (link.passwordHash) {
+ if (request.method === 'POST') {
+ let formData;
+ try {
+ formData = await request.formData();
+ } catch {
+ return new Response(passwordPage(slug, false), {
+ status: 200,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ const submitted = formData.get('password') ?? '';
+ const submittedHash = await sha256(submitted);
+ if (!safeEqual(submittedHash, link.passwordHash)) {
+ return new Response(passwordPage(slug, true), {
+ status: 403,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ // Correct password β fall through to redirect
+ } else {
+ return new Response(passwordPage(slug, false), {
+ status: 200,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ }
+
+ // Increment click counter (best-effort; do not block the redirect)
+ const updated = { ...link, clicks: (link.clicks ?? 0) + 1 };
+ // Use waitUntil if available to avoid delaying the response
+ env.ctx?.waitUntil(putLink(env, resolvedHost, updated));
+
+ return Response.redirect(link.guest, 302);
+}
+
+export async function routeRequest(request, env) {
+ const url = new URL(request.url);
+ const { pathname } = url;
+ const origin = url.origin;
+
+ // Homepage
+ if (pathname === '/' && request.method === 'GET') {
+ return handleHomePage(origin);
+ }
+
+ // Note: local Basic Auth should NOT be applied to all `/api/*` routes by
+ // default. Debug-only endpoints inside `handleAPI()` use `requireDebugAccess()`
+ // which enforces Basic Auth when necessary. In production, protect `/admin`
+ // at the edge (Cloudflare Access) and leave API access to that protection.
+
+ if ((pathname === '/admin' || pathname === '/admin/') && request.method === 'GET') {
+ // By default, `/admin` should be protected at the edge (e.g. Cloudflare Access)
+ // in production. To avoid accidental exposure, API routes continue to require
+ // Basic Auth via `ADMIN_SECRET`.
+ //
+ // If you want to enable local Basic Auth instead of an edge-auth solution,
+ // uncomment the block below and set `ADMIN_SECRET` (Wrangler secret) and
+ // `ENABLE_LOCAL_ADMIN_AUTH=true` in your environment or .dev.vars file.
+ /*
+ const localAdminEnabled = env.ENABLE_LOCAL_ADMIN_AUTH === true || env.ENABLE_LOCAL_ADMIN_AUTH === 'true';
+ if (localAdminEnabled) {
+ if (!env.ADMIN_SECRET) {
+ return new Response(misconfiguredPage(), {
+ status: 503,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+ const ok = await checkAdminAuth(request, env);
+ if (!ok) return unauthorizedResponse();
+ }
+ */
+ }
+
+ // Admin dashboard
+ if ((pathname === '/admin' || pathname === '/admin/') && request.method === 'GET') {
+ return handleAdminPage(request, env, origin);
+ }
+
+ // Heartbeat check endpoint
+ if ((pathname === '/heartbeat/check' || pathname === '/heartbeat/check/') && request.method === 'GET') {
+ return new Response(heartbeatPage(), {
+ status: 200,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+
+ // Heartbeat redirect endpoint (redirects to /heartbeat/check/)
+ if ((pathname === '/heartbeat' || pathname === '/heartbeat/') && request.method === 'GET') {
+ return Response.redirect(`${origin}/heartbeat/check/`, 301);
+ }
+
+ // REST API
+ if (pathname.startsWith('/api/')) {
+ return handleAPI(request, env, pathname);
+ }
+
+ // Short-link redirect β slug must be alphanumeric/-/_
+ const slugMatch = pathname.match(/^\/([a-zA-Z0-9_-]+)\/?$/);
+ if (slugMatch && (request.method === 'GET' || request.method === 'POST')) {
+ return handleRedirect(request, env, slugMatch[1]);
+ }
+
+ return new Response(notFoundPage(), {
+ status: 404,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+}
+
diff --git a/src/security.js b/src/security.js
new file mode 100644
index 0000000..eda4c04
--- /dev/null
+++ b/src/security.js
@@ -0,0 +1,42 @@
+import { ADMIN_REALM, SECURITY_HEADERS } from './constants.js';
+import { safeEqual, sha256 } from './util.js';
+
+/** Check HTTP Basic Auth against env.ADMIN_SECRET. Returns true if valid. */
+export async function checkAdminAuth(request, env) {
+ if (!env.ADMIN_SECRET) return false;
+ const auth = request.headers.get('Authorization') ?? '';
+ if (!auth.startsWith('Basic ')) return false;
+ let decoded;
+ try {
+ decoded = atob(auth.slice(6));
+ } catch {
+ return false;
+ }
+ const colon = decoded.indexOf(':');
+ if (colon === -1) return false;
+ const password = decoded.slice(colon + 1);
+ // Compare hashes to get a fixed-length comparison (mitigates timing leaks)
+ const [submittedHash, secretHash] = await Promise.all([
+ sha256(password),
+ sha256(env.ADMIN_SECRET),
+ ]);
+ return safeEqual(submittedHash, secretHash);
+}
+
+/** Returns a 401 response that prompts Basic Auth in the browser. */
+export function unauthorizedResponse() {
+ return new Response('Unauthorized', {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': `Basic realm="${ADMIN_REALM}", charset="UTF-8"`,
+ 'Content-Type': 'text/plain',
+ },
+ });
+}
+
+export function addSecurityHeaders(response) {
+ const r = new Response(response.body, response);
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) r.headers.set(k, v);
+ return r;
+}
+
diff --git a/src/util.js b/src/util.js
new file mode 100644
index 0000000..e9fc3e7
--- /dev/null
+++ b/src/util.js
@@ -0,0 +1,41 @@
+/** Escape characters that are special in HTML attribute values and text nodes. */
+export function escHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/** Compute a hex SHA-256 digest of a string using Web Crypto. */
+export async function sha256(text) {
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
+ return Array.from(new Uint8Array(buf))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+}
+
+/**
+ * Timing-safe equality check for two hex digests of equal length.
+ * Prevents timing attacks when comparing password hashes.
+ */
+export function safeEqual(a, b) {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) {
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ }
+ return diff === 0;
+}
+
+/** Validate that a submitted URL is http or https. */
+export function isValidUrl(raw) {
+ try {
+ const u = new URL(raw);
+ return u.protocol === 'http:' || u.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
diff --git a/wrangler.toml b/wrangler.toml
index a9649d6..fb0f89e 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -1,3 +1,6 @@
+# βββ CLOUD TEMPLATE USE IN PROD βββββββββββββββββββββββββββββββββββββββββββββββ
+
+
name = "plummer"
main = "src/index.js"
compatibility_date = "2025-04-01"
@@ -10,7 +13,9 @@ id = "30fa1d3f69d8412bb79886b862851801"
preview_id = "00410bf2f1054283819de99e7958be89"
# βββ Custom Domain (optional) ββββββββββββββββββββββββββββββββββββββββββββββββ
-# Uncomment and update to route a custom domain through this worker.
+# NOTE: keep this commented for local dev (`wrangler dev`) to avoid confusing host/origin behavior.
+# Uncomment and update to route a custom domain through this worker in production.
+#
[[routes]]
pattern = "share.sillylittle.tech/*"
zone_id = "7cb3a67ff9a720d9d07336094c5fd193"
@@ -24,4 +29,12 @@ zone_id = "7cb3a67ff9a720d9d07336094c5fd193"
[observability]
[observability.logs]
enabled = true
-invocation_logs = true
\ No newline at end of file
+invocation_logs = true
+
+[vars]
+# JSON array of hostnames allowed for link creation (and shown in the /admin dropdown).
+# Example: ["share.sillylittle.tech","links.sillylittle.tech","links.share.sillylittle.tech"]
+ALLOWED_HOSTS_JSON = "[\"share.sillylittle.tech\"]"
+
+[triggers]
+crons = ["0 * * * *"]
\ No newline at end of file
diff --git a/wrangler.toml.cloud.bac b/wrangler.toml.cloud.bac
new file mode 100644
index 0000000..fb0f89e
--- /dev/null
+++ b/wrangler.toml.cloud.bac
@@ -0,0 +1,40 @@
+# βββ CLOUD TEMPLATE USE IN PROD βββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+name = "plummer"
+main = "src/index.js"
+compatibility_date = "2025-04-01"
+
+# βββ KV Namespace ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+[[kv_namespaces]]
+binding = "LINKIVERSE"
+id = "30fa1d3f69d8412bb79886b862851801"
+# Uncomment and fill in a preview namespace ID for `wrangler dev`:
+preview_id = "00410bf2f1054283819de99e7958be89"
+
+# βββ Custom Domain (optional) ββββββββββββββββββββββββββββββββββββββββββββββββ
+# NOTE: keep this commented for local dev (`wrangler dev`) to avoid confusing host/origin behavior.
+# Uncomment and update to route a custom domain through this worker in production.
+#
+[[routes]]
+pattern = "share.sillylittle.tech/*"
+zone_id = "7cb3a67ff9a720d9d07336094c5fd193"
+
+
+# βββ Secrets (set via CLI, NOT in this file) βββββββββββββββββββββββββββββββββ
+# npx wrangler secret put ADMIN_SECRET
+# This is the password used to access the /admin dashboard.
+
+
+[observability]
+[observability.logs]
+enabled = true
+invocation_logs = true
+
+[vars]
+# JSON array of hostnames allowed for link creation (and shown in the /admin dropdown).
+# Example: ["share.sillylittle.tech","links.sillylittle.tech","links.share.sillylittle.tech"]
+ALLOWED_HOSTS_JSON = "[\"share.sillylittle.tech\"]"
+
+[triggers]
+crons = ["0 * * * *"]
\ No newline at end of file
diff --git a/wrangler.toml.local.bac b/wrangler.toml.local.bac
new file mode 100644
index 0000000..b7a3396
--- /dev/null
+++ b/wrangler.toml.local.bac
@@ -0,0 +1,40 @@
+# βββ LOCAL TEMPLATE USE IN DEV βββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+name = "plummer"
+main = "src/index.js"
+compatibility_date = "2025-04-01"
+
+# βββ KV Namespace ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+[[kv_namespaces]]
+binding = "LINKIVERSE"
+id = "30fa1d3f69d8412bb79886b862851801"
+# Uncomment and fill in a preview namespace ID for `wrangler dev`:
+preview_id = "00410bf2f1054283819de99e7958be89"
+
+# βββ Custom Domain (optional) ββββββββββββββββββββββββββββββββββββββββββββββββ
+# NOTE: keep this commented for local dev (`wrangler dev`) to avoid confusing host/origin behavior.
+# Uncomment and update to route a custom domain through this worker in production.
+#
+# [[routes]]
+# pattern = "share.sillylittle.tech/*"
+# zone_id = "7cb3a67ff9a720d9d07336094c5fd193"
+
+
+# βββ Secrets (set via CLI, NOT in this file) βββββββββββββββββββββββββββββββββ
+# npx wrangler secret put ADMIN_SECRET
+# This is the password used to access the /admin dashboard.
+
+
+[observability]
+[observability.logs]
+enabled = true
+invocation_logs = true
+
+[vars]
+# JSON array of hostnames allowed for link creation (and shown in the /admin dropdown).
+# Example: ["share.sillylittle.tech","links.sillylittle.tech","links.share.sillylittle.tech"]
+ALLOWED_HOSTS_JSON = "[\"localhost:8787\"]"
+
+[triggers]
+crons = ["0 * * * *"]
\ No newline at end of file