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', - `
-
- ← Plummer -

Link Dashboard

-

Create, track, and manage your short links.

-
- -
-

Create Short Link

-
-
-
- - -

Letters, numbers, hyphens and underscores only.

-
-
- - -
-
-
-
- - -

Leave blank for a permanent link.

-
-
- - -

Visitors must enter this before being redirected.

-
-
- - -
-
- - -
- -
`, - - /* 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', + `
+
+ ← Plummer +

Link Dashboard

+

Create, track, and manage your short links.

+
+ +
+

Create Short Link

+
+
+
+ + +

Choose which configured domain/subdomain this link belongs to.

+
+
+ + +

Folders create reserved directory URLs (e.g. /referrals/).

+
+
+ + +

Letters, numbers, hyphens and underscores only.

+
+
+ + +
+
+
+
+ + +

Leave blank for a permanent link.

+
+
+ + +

Visitors must enter this before being redirected.

+
+
+ + +
+
+ + + +
+
+

Folders

+ +
+
+ + +

Folders are per-domain. The active folder filters the link table.

+
+ + +
+ +
+

Audit

+

Recent create/modify/delete events with actor IP.

+
+ + + + + + + + + + + + + +
TimeActionHostTargetIP
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', + `
+
+ +

Link Deleted

+

This short link has 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 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, + `
+
+ ← Home +

${safeName}

+

Folder listing on ${escHtml(host)}

+
+ +
+

Links

+
+ + + + + + + + + + + ${rows} + +
SlugDestinationStatusExpires
+
+
+
`, + ` + 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