diff --git a/scripts/swap-wrangler.mjs b/scripts/swap-wrangler.mjs
index a175f06..65c9840 100644
--- a/scripts/swap-wrangler.mjs
+++ b/scripts/swap-wrangler.mjs
@@ -1,35 +1,35 @@
-import { readFileSync, writeFileSync } from 'node:fs';
-import { fileURLToPath } from 'node:url';
-import { dirname, resolve } from 'node:path';
+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 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 mode = (process.argv[2] || 'toggle').toLowerCase()
-const active = readFileSync(activePath, 'utf8');
-const dev = readFileSync(devPath, 'utf8');
-const prod = readFileSync(prodPath, 'utf8');
+const active = readFileSync(activePath, 'utf8')
+const dev = readFileSync(devPath, 'utf8')
+const prod = readFileSync(prodPath, 'utf8')
-let next;
+let next
if (mode === 'dev') {
- next = dev;
+ next = dev
} else if (mode === 'prod') {
- next = prod;
+ next = prod
} else if (active === dev) {
- next = prod;
+ next = prod
} else if (active === prod) {
- next = dev;
+ 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);
+ 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);
+writeFileSync(activePath, next)
-const label = next === dev ? 'dev' : 'prod';
-console.log(`Updated wrangler.toml -> ${label}`);
+const label = next === dev ? 'dev' : 'prod'
+console.log(`Updated wrangler.toml -> ${label}`)
diff --git a/src/audit.js b/src/audit.js
index f5e2dfb..5c41add 100644
--- a/src/audit.js
+++ b/src/audit.js
@@ -1,70 +1,69 @@
-import { normalizeHost } from './kv.js';
+import { normalizeHost } from './kv.js'
-function padTs(ts) {
+function padTs (ts) {
// 13-digit ms timestamp, lexicographically sortable
- return String(ts).padStart(13, '0');
+ return String(ts).padStart(13, '0')
}
-function auditKey(ts, id) {
- return `audit:${padTs(ts)}:${id}`;
+function auditKey (ts, id) {
+ return `audit:${padTs(ts)}:${id}`
}
-function randomId() {
+function randomId () {
// short, URL-safe (avoid Math.random predictability/collisions)
try {
- return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
+ 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('');
+ 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) {
+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 };
+ 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 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;
+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 });
+ 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();
+ keysWindow.push(key.name)
+ if (keysWindow.length > windowSize) keysWindow.shift()
}
- cursor = page.list_complete ? undefined : page.cursor;
- } while (cursor);
+ cursor = page.list_complete ? undefined : page.cursor
+ } while (cursor)
- const items = [];
+ const items = []
for (const name of keysWindow) {
- const raw = await env.LINKIVERSE.get(name);
- if (!raw) continue;
+ const raw = await env.LINKIVERSE.get(name)
+ if (!raw) continue
try {
- items.push(JSON.parse(raw));
+ items.push(JSON.parse(raw))
} catch {
// ignore
}
}
- items.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
- return items.slice(0, limit);
+ 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
index a08ff4c..6ef359c 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -5,16 +5,16 @@ export const RESERVED = new Set([
'api',
'favicon.ico',
'robots.txt',
- 'sitemap.xml',
-]);
+ 'sitemap.xml'
+])
-export const ADMIN_REALM = 'Plummer Admin';
+export const ADMIN_REALM = 'Plummer Admin'
// Visible build version displayed in the UI footer.
-export const APP_VERSION = 'v2.6.0';
+export const APP_VERSION = 'v2.6.0'
export const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
-};
+ 'Referrer-Policy': 'strict-origin-when-cross-origin'
+}
diff --git a/src/index.js b/src/index.js
index aea9c6d..5b52e63 100644
--- a/src/index.js
+++ b/src/index.js
@@ -16,37 +16,41 @@
* }
*/
-import { routeRequest } from './router.js';
-import { addSecurityHeaders } from './security.js';
-import { getAllLinks, deleteLink } from './kv.js';
-import { writeAudit } from './audit.js';
+import { routeRequest } from './router.js'
+import { addSecurityHeaders } from './security.js'
+import { getAllLinks, deleteLink, deleteImageData } from './kv.js'
+import { writeAudit } from './audit.js'
export default {
- async fetch(request, env, ctx) {
+ async fetch (request, env, ctx) {
// Attach ctx so handlers can use waitUntil
- env.ctx = ctx;
- const response = await routeRequest(request, env);
- return addSecurityHeaders(response);
+ env.ctx = ctx
+ const response = await routeRequest(request, env)
+ return addSecurityHeaders(response)
},
- async scheduled(_event, env, ctx) {
+ async scheduled (_event, env, ctx) {
// Purge tombstoned links after purgeAfter timestamp.
- const links = await getAllLinks(env);
- const now = Date.now();
+ 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));
+ 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))
+ if (link.type === 'image') {
+ ctx.waitUntil(deleteImageData(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 },
- }));
+ actor: { ip: null, ua: null, ray: null }
+ }))
}
- },
-};
-
+ }
+}
diff --git a/src/kv.js b/src/kv.js
index b14fbbe..ab343a3 100644
--- a/src/kv.js
+++ b/src/kv.js
@@ -1,240 +1,322 @@
-async function parseJSON(raw) {
- if (!raw) return null;
+async function parseJSON (raw) {
+ if (!raw) return null
try {
- return JSON.parse(raw);
+ return JSON.parse(raw)
} catch {
- return null;
+ return null
}
}
-export function normalizeHost(rawHost) {
+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();
+ return String(rawHost ?? '').trim().toLowerCase()
}
-function linkKey(host, slug) {
- return `link:${host}:${slug}`;
+function linkKey (host, slug) {
+ return `link:${host}:${slug}`
}
-function legacyLinkKey(slug) {
- return `link:${slug}`;
+function legacyLinkKey (slug) {
+ return `link:${slug}`
}
-function folderKey(host, folderSlug) {
- return `folder:${host}:${folderSlug}`;
+function folderKey (host, folderSlug) {
+ return `folder:${host}:${folderSlug}`
}
-function folderPrefix(host) {
- return `folder:${host}:`;
+function folderPrefix (host) {
+ return `folder:${host}:`
}
-function transformerKey(host, id) {
- return `transformer:${host}:${id}`;
+function transformerKey (host, id) {
+ return `transformer:${host}:${id}`
}
-function transformerPrefix(host) {
- return `transformer:${host}:`;
+function transformerPrefix (host) {
+ return `transformer:${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;
+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+$/, '');
+ 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;
+ 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;
+ 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 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 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;
+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 });
+ 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);
+ 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;
+ 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);
+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);
+ 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;
+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+$/, '');
+ const noPort = h.replace(/:\d+$/, '')
if (noPort && noPort !== h) {
- const legacyPortRaw = await env.LINKIVERSE.get(linkKey(noPort, slug));
- const legacyPortParsed = await parseJSON(legacyPortRaw);
+ 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;
+ 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);
+ 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));
+ 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;
+ 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;
+ let cursor
do {
- const page = await env.LINKIVERSE.list({ prefix: 'link:', limit: 100, cursor });
+ 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;
+ 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;
+ 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);
+ cursor = page.list_complete ? undefined : page.cursor
+ } while (cursor)
- return null;
+ 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 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));
+export async function deleteLink (env, host, slug) {
+ const h = normalizeHost(host)
+ await env.LINKIVERSE.delete(linkKey(h, slug))
}
-export async function getTransformer(env, host, id) {
- const h = normalizeHost(host);
- return await parseJSON(await env.LINKIVERSE.get(transformerKey(h, id)));
+export async function getTransformer (env, host, id) {
+ const h = normalizeHost(host)
+ return await parseJSON(await env.LINKIVERSE.get(transformerKey(h, id)))
}
-export async function putTransformer(env, host, transformer) {
- const h = normalizeHost(host);
- const withHost = { ...transformer, host: h };
- await env.LINKIVERSE.put(transformerKey(h, withHost.id), JSON.stringify(withHost));
+export async function putTransformer (env, host, transformer) {
+ const h = normalizeHost(host)
+ const withHost = { ...transformer, host: h }
+ await env.LINKIVERSE.put(transformerKey(h, withHost.id), JSON.stringify(withHost))
}
-export async function deleteTransformer(env, host, id) {
- const h = normalizeHost(host);
- await env.LINKIVERSE.delete(transformerKey(h, id));
+export async function deleteTransformer (env, host, id) {
+ const h = normalizeHost(host)
+ await env.LINKIVERSE.delete(transformerKey(h, id))
}
-export async function listTransformers(env, host) {
- const h = normalizeHost(host);
- const transformers = [];
- let cursor;
+export async function listTransformers (env, host) {
+ const h = normalizeHost(host)
+ const transformers = []
+ let cursor
do {
- const page = await env.LINKIVERSE.list({ prefix: transformerPrefix(h), limit: 100, cursor });
+ const page = await env.LINKIVERSE.list({ prefix: transformerPrefix(h), limit: 100, cursor })
for (const key of page.keys) {
- const parsed = await parseJSON(await env.LINKIVERSE.get(key.name));
- if (parsed) transformers.push(parsed);
+ const parsed = await parseJSON(await env.LINKIVERSE.get(key.name))
+ if (parsed) transformers.push(parsed)
}
- cursor = page.list_complete ? undefined : page.cursor;
- } while (cursor);
+ cursor = page.list_complete ? undefined : page.cursor
+ } while (cursor)
transformers.sort((a, b) => {
- const ap = Number(a.priority ?? 100);
- const bp = Number(b.priority ?? 100);
- if (ap !== bp) return ap - bp;
- return (a.createdAt ?? 0) - (b.createdAt ?? 0);
- });
- return transformers;
+ const ap = Number(a.priority ?? 100)
+ const bp = Number(b.priority ?? 100)
+ if (ap !== bp) return ap - bp
+ return (a.createdAt ?? 0) - (b.createdAt ?? 0)
+ })
+ return transformers
}
-export async function getAllTransformers(env) {
- const transformers = [];
- let cursor;
+export async function getAllTransformers (env) {
+ const transformers = []
+ let cursor
do {
- const page = await env.LINKIVERSE.list({ prefix: 'transformer:', limit: 100, cursor });
+ const page = await env.LINKIVERSE.list({ prefix: 'transformer:', limit: 100, cursor })
for (const key of page.keys) {
- const parsed = await parseJSON(await env.LINKIVERSE.get(key.name));
- if (!parsed) continue;
+ const parsed = await parseJSON(await env.LINKIVERSE.get(key.name))
+ if (!parsed) continue
if (!parsed.host) {
- const parts = key.name.split(':');
- if (parts.length >= 3) parsed.host = parts[1];
+ const parts = key.name.split(':')
+ if (parts.length >= 3) parsed.host = parts[1]
}
- transformers.push(parsed);
+ transformers.push(parsed)
}
- cursor = page.list_complete ? undefined : page.cursor;
- } while (cursor);
- transformers.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
- return transformers;
+ cursor = page.list_complete ? undefined : page.cursor
+ } while (cursor)
+ transformers.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
+ return transformers
}
/** Fetch all stored links (all hosts), handling KV list pagination. */
-export async function getAllLinks(env) {
- const links = [];
- let cursor;
+export async function getAllLinks (env) {
+ const links = []
+ let cursor
do {
- const page = await env.LINKIVERSE.list({ prefix: 'link:', limit: 100, cursor });
+ 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;
+ 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];
+ const parts = key.name.split(':')
+ if (parts.length === 3) parsed.host = parts[1]
}
- links.push(parsed);
+ links.push(parsed)
}
- cursor = page.list_complete ? undefined : page.cursor;
- } while (cursor);
- links.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
- return links;
+ cursor = page.list_complete ? undefined : page.cursor
+ } while (cursor)
+ links.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
+ return links
+}
+
+// API Keys Schema
+function apiKeyKey (host, id) {
+ return `apikey:${host}:${id}`
+}
+
+function apiKeyPrefix (host) {
+ return `apikey:${host}:`
+}
+
+export async function getApiKey (env, host, id) {
+ const h = normalizeHost(host)
+ return await parseJSON(await env.LINKIVERSE.get(apiKeyKey(h, id)))
+}
+
+export async function putApiKey (env, host, key) {
+ const h = normalizeHost(host)
+ const withHost = { ...key, host: h }
+ await env.LINKIVERSE.put(apiKeyKey(h, withHost.id), JSON.stringify(withHost))
+}
+
+export async function deleteApiKey (env, host, id) {
+ const h = normalizeHost(host)
+ await env.LINKIVERSE.delete(apiKeyKey(h, id))
+}
+
+export async function listApiKeys (env, host) {
+ const h = normalizeHost(host)
+ const keys = []
+ let cursor
+ do {
+ const page = await env.LINKIVERSE.list({ prefix: apiKeyPrefix(h), limit: 100, cursor })
+ for (const key of page.keys) {
+ const parsed = await parseJSON(await env.LINKIVERSE.get(key.name))
+ if (parsed) keys.push(parsed)
+ }
+ cursor = page.list_complete ? undefined : page.cursor
+ } while (cursor)
+ keys.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
+ return keys
+}
+
+export async function getAllApiKeys (env) {
+ const keys = []
+ let cursor
+ do {
+ const page = await env.LINKIVERSE.list({ prefix: 'apikey:', limit: 100, cursor })
+ for (const key of page.keys) {
+ const parsed = await parseJSON(await env.LINKIVERSE.get(key.name))
+ if (!parsed) continue
+ if (!parsed.host) {
+ const parts = key.name.split(':')
+ if (parts.length >= 3) parsed.host = parts[1]
+ }
+ keys.push(parsed)
+ }
+ cursor = page.list_complete ? undefined : page.cursor
+ } while (cursor)
+ keys.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
+ return keys
+}
+
+// Image Storage Schema
+function imageKey (host, slug) {
+ return `image:${host}:${slug}`
+}
+
+export async function getImageData (env, host, slug) {
+ const h = normalizeHost(host)
+ const data = await env.LINKIVERSE.get(imageKey(h, slug), { type: 'arrayBuffer' })
+ return data
+}
+
+export async function putImageData (env, host, slug, buffer) {
+ const h = normalizeHost(host)
+ await env.LINKIVERSE.put(imageKey(h, slug), buffer)
+}
+
+export async function deleteImageData (env, host, slug) {
+ const h = normalizeHost(host)
+ await env.LINKIVERSE.delete(imageKey(h, slug))
}
diff --git a/src/pages/admin.js b/src/pages/admin.js
index fd092f3..6fa0d0c 100644
--- a/src/pages/admin.js
+++ b/src/pages/admin.js
@@ -1,75 +1,105 @@
-import { htmlPage } from './shared.js';
-import { escHtml } from '../util.js';
+import { htmlPage } from './shared.js'
+import { escHtml } from '../util.js'
-export function adminPage(links, origin, allowedHosts = []) {
+export function adminPage (links, origin, allowedHosts = []) {
const hosts = Array.isArray(allowedHosts) && allowedHosts.length > 0
? allowedHosts
- : [new URL(origin).host];
+ : [new URL(origin).host]
const renderPatternTokens = (value) => escHtml(value).replace(
/\$\{([1-3])\}/g,
- '${$1}',
- );
+ '${$1}'
+ )
const rows = links.length === 0
- ? `
| No links yet — create one above! |
`
+ ? '| No links yet — create one above! |
'
: links.map((link) => {
- const isTransformer = link.type === 'transformer';
+ const isTransformer = link.type === 'transformer'
+
+ const isImage = link.type === 'image'
+ const typePill = isTransformer
+ ? ' Transformer'
+ : (isImage ? ' Image' : '')
+
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 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';
+ : `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 ?? '');
- const typeAttr = isTransformer ? 'transformer' : 'link';
- const idAttr = escHtml(isTransformer ? link.id : link.slug);
+ status === 'active'
+ ? 'Active'
+ : status === 'inactive'
+ ? 'Inactive'
+ : 'Deleted'
+
+ const folderSlugAttr = escHtml(link.folderSlug ?? '')
+ const typeAttr = isTransformer ? 'transformer' : 'link'
+ const idAttr = escHtml(isTransformer ? link.id : link.slug)
return `
- ${isTransformer ? renderPatternTokens(link.slug) : escHtml(link.slug)}${isTransformer ? ' Transformer' : ''} |
+ ${isTransformer ? renderPatternTokens(link.slug) : escHtml(link.slug)}${typePill} |
${escHtml(linkHost)} |
${statusBadge} |
-
- ${isTransformer
- ? `${renderPatternTokens(link.guest)}`
- : `${escHtml(link.guest)}`}
- |
+ ${isTransformer ? `${renderPatternTokens(link.guest)}` : (isImage ? 'Image Data' : `${escHtml(link.guest)}`)} |
${link.clicks ?? 0} |
${isTransformer ? '—' : (link.passwordHash ? '🔒' : '—')} |
${expiry} |
+ style="display:none;">📋 Copy
+ style="display:none;">✏️ Edit
-
+ style="display:none;">⏸️
+ ${buildLinkRowActionsHtml(linkHost, link.slug, link.guest, link.expiresAt, link.folderSlug, status, link.type, isTransformer ? link.id : link.slug)}
|
-
`;
- }).join('');
+ `
+ }).join('')
return htmlPage(
'Plummer — Admin',
`
+
+
+
@@ -719,7 +760,7 @@ function copyLink(url) {
}
/** Rebuild action buttons so slugs in onclick match after rename. */
-function buildLinkRowActionsHtml(host, slug, guest, expiresAtMs, folderSlug, status, type, id) {
+function buildLinkRowActionsHtml(linkHost, link.slug, link.guest, link.expiresAt, link.folderSlug, status, link.type, isTransformer ? link.id : link.slug)(host, slug, guest, expiresAtMs, folderSlug, status, type, id) {
const isTransformer = type === 'transformer';
const actionId = id || slug;
const co = new URL(ORIGIN);
@@ -1080,6 +1121,692 @@ document.getElementById('guest')?.addEventListener('input', updateTransformerMod
// Initial hide of deleted rows
document.addEventListener('DOMContentLoaded', () => {
+
+let apiKeysLoaded = false;
+
+document.querySelectorAll('.tab-link').forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ document.querySelectorAll('.tab-link').forEach(l => l.classList.remove('active'));
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+
+ link.classList.add('active');
+ document.getElementById(link.dataset.target).classList.add('active');
+
+ if (link.dataset.target === 'tab-apikeys' && !apiKeysLoaded) {
+ apiKeysLoaded = true;
+ loadApiKeys();
+ }
+ });
+});
+
+document.getElementById('refreshApiKeysBtn')?.addEventListener('click', loadApiKeys);
+
+async function loadApiKeys() {
+ const tbody = document.getElementById('apiKeysBody');
+ if (!tbody) return;
+ tbody.innerHTML = '
| Loading... |
';
+
+ try {
+ const host = document.getElementById('apiKeyHost').value;
+ const res = await fetch('/api/keys?host=' + encodeURIComponent(host));
+ if (!res.ok) throw new Error('Failed to fetch keys');
+ const keys = await res.json();
+
+ if (keys.length === 0) {
+ tbody.innerHTML = '
| No API keys found. |
';
+ return;
+ }
+
+ tbody.innerHTML = keys.map(k => {
+ const expStr = k.expiresAt ? new Date(k.expiresAt).toLocaleString() : 'Never';
+ const perms = (k.permissions || []).join(', ');
+
+ return '
' +
+ '| ' + escHtml(k.name) + ' | ' +
+ '' + escHtml(k.host) + ' | ' +
+ '' + escHtml(perms) + ' | ' +
+ '' + escHtml(expStr) + ' | ' +
+ '' +
+ '' +
+ ' | ' +
+ '
';
+ }).join('');
+
+ } catch (err) {
+ tbody.innerHTML = '
| Error loading keys: ' + escHtml(err.message) + ' |
';
+ }
+}
+
+window.revokeApiKey = async function(id, host) {
+ if (!confirm('Are you sure you want to revoke this API key? This cannot be undone.')) return;
+ try {
+ const res = await fetch('/api/keys/' + encodeURIComponent(id) + '?host=' + encodeURIComponent(host), { method: 'DELETE' });
+ if (!res.ok) throw new Error('Failed to revoke key');
+ showToast('API Key revoked');
+ loadApiKeys();
+ } catch(err) {
+ alert('Error: ' + err.message);
+ }
+};
+
+document.getElementById('createApiKeyForm')?.addEventListener('submit', async function(e) {
+ e.preventDefault();
+ const errEl = document.getElementById('apiKeyFormError');
+ const successEl = document.getElementById('apiKeySuccess');
+ const btn = document.getElementById('createApiKeyBtn');
+ errEl.style.display = 'none';
+ successEl.style.display = 'none';
+
+ const fd = new FormData(e.target);
+ const host = fd.get('host');
+ const name = fd.get('name');
+ const expiresAtRaw = fd.get('expiresAt');
+ const permissions = Array.from(fd.getAll('permissions'));
+
+ if (permissions.length === 0) {
+ errEl.textContent = 'Please select at least one permission.';
+ errEl.style.display = '';
+ return;
+ }
+
+ const body = {
+ host,
+ name,
+ permissions,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ };
+
+ btn.disabled = true;
+ btn.textContent = 'Generating...';
+
+ try {
+ const res = await fetch('/api/keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || 'Failed to create API key');
+
+ document.getElementById('apiKeyDisplay').value = data.token;
+ successEl.style.display = 'flex';
+ e.target.reset();
+
+ // Default checked state for permissions since reset clears them
+ e.target.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
+ // Restore host
+ document.getElementById('apiKeyHost').value = host;
+
+ loadApiKeys();
+
+ } catch(err) {
+ errEl.textContent = err.message;
+ errEl.style.display = '';
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Generate Key';
+ }
+});
+
+document.getElementById('apiKeyHost')?.addEventListener('change', loadApiKeys);
+
+
+function updateTransformerMode() {
+ const isT = document.getElementById('isTransformer')?.checked;
+ const isI = document.getElementById('isImage')?.checked;
+
+ const guestEl = document.getElementById('guest');
+ const slugHint = document.getElementById('slugHint');
+ const guestHint = document.getElementById('guestHint');
+
+ if (isI) {
+ if (document.getElementById('isTransformer')) {
+ document.getElementById('isTransformer').checked = false;
+ document.getElementById('isTransformer').disabled = true;
+ }
+
+ if (document.getElementById('imageUploadGrid')) document.getElementById('imageUploadGrid').style.display = 'grid';
+ if (guestEl) {
+ guestEl.parentElement.style.display = 'none';
+ guestEl.removeAttribute('required');
+ }
+
+ if (document.getElementById('folderSlug')) document.getElementById('folderSlug').disabled = false;
+ if (document.getElementById('expiresAt')) document.getElementById('expiresAt').disabled = false;
+ if (document.getElementById('password')) document.getElementById('password').disabled = false;
+
+ } else if (isT) {
+ if (document.getElementById('isImage')) {
+ document.getElementById('isImage').checked = false;
+ document.getElementById('isImage').disabled = true;
+ }
+
+ if (document.getElementById('imageUploadGrid')) document.getElementById('imageUploadGrid').style.display = 'none';
+ if (guestEl) {
+ guestEl.parentElement.style.display = 'block';
+ guestEl.setAttribute('required', 'true');
+ guestEl.type = 'text';
+ guestEl.placeholder = 'https://example.com/${1}';
+ }
+ if (slugHint) slugHint.innerHTML = 'Use
${1},
${2},
${3} to capture path segments.';
+ if (guestHint) {
+ guestHint.style.display = 'block';
+ guestHint.innerHTML = 'Substitute
${1} into the destination URL.';
+ }
+
+ if (document.getElementById('folderSlug')) {
+ document.getElementById('folderSlug').value = '';
+ document.getElementById('folderSlug').disabled = true;
+ }
+ if (document.getElementById('expiresAt')) {
+ document.getElementById('expiresAt').value = '';
+ document.getElementById('expiresAt').disabled = true;
+ }
+ if (document.getElementById('password')) {
+ document.getElementById('password').value = '';
+ document.getElementById('password').disabled = true;
+ }
+ } else {
+ if (document.getElementById('isImage')) document.getElementById('isImage').disabled = false;
+ if (document.getElementById('isTransformer')) document.getElementById('isTransformer').disabled = false;
+
+ if (document.getElementById('imageUploadGrid')) document.getElementById('imageUploadGrid').style.display = 'none';
+ if (guestEl) {
+ guestEl.parentElement.style.display = 'block';
+ guestEl.setAttribute('required', 'true');
+ guestEl.type = 'url';
+ guestEl.placeholder = 'https://example.com';
+ }
+ if (slugHint) slugHint.innerHTML = 'Letters, numbers, hyphens and underscores only.';
+ if (guestHint) guestHint.style.display = 'none';
+
+ if (document.getElementById('folderSlug')) document.getElementById('folderSlug').disabled = false;
+ if (document.getElementById('expiresAt')) document.getElementById('expiresAt').disabled = false;
+ if (document.getElementById('password')) document.getElementById('password').disabled = false;
+ }
+}
+
+// Override original form submission with FormData
+document.getElementById('createForm')?.addEventListener('submit', async function(e) {
+ e.preventDefault();
+
+document.getElementById('createForm')?.addEventListener('submit', async function(e) {
+ // Override inside DOMContentLoaded to win the listener battle.
+ e.stopImmediatePropagation();
+ 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 folderSlug = (fd.get('folderSlug') || '').trim();
+ const expiresAtRaw = fd.get('expiresAt');
+ const password = fd.get('password');
+ const isTransformer = document.getElementById('isTransformer')?.checked || false;
+ const isImage = document.getElementById('isImage')?.checked || false;
+ let guest = fd.get('guest');
+ if (guest) guest = guest.trim();
+
+ let reqOpts = {};
+
+ if (isImage) {
+ const file = fd.get('image');
+ if (!file || !file.size) {
+ errEl.textContent = 'Please select an image file.';
+ errEl.style.display = '';
+ return;
+ }
+ reqOpts = {
+ method: 'POST',
+ body: fd
+ };
+ fd.append('type', 'image');
+ if (expiresAtRaw) fd.set('expiresAt', new Date(expiresAtRaw).getTime());
+ else fd.delete('expiresAt');
+ } else {
+ const body = {
+ host,
+ slug,
+ guest,
+ folderSlug: folderSlug || null,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ password: password || null,
+ };
+ if (isTransformer) {
+ body.type = 'transformer';
+ body.isTransformer = true;
+ body.folderSlug = null;
+ body.expiresAt = null;
+ body.password = null;
+ }
+
+ reqOpts = {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ };
+ }
+
+ const btn = document.getElementById('createBtn');
+ btn.disabled = true;
+ btn.textContent = isTransformer ? 'Creating transformer…' : (isImage ? 'Uploading image…' : 'Creating…');
+
+ const r = await fetch('/api/links', reqOpts);
+ 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;
+ }
+
+ const createdId = d.id || slug;
+ showToast(isTransformer
+ ? ('Transformer created: ' + host + '/' + slug)
+ : (isImage ? 'Image uploaded: ' + host + '/' + slug : 'Created: ' + (host === new URL(ORIGIN).host ? (ORIGIN + '/' + slug) : ('https://' + host + '/' + slug))));
+ e.target.reset();
+ updateTransformerMode();
+ setTimeout(() => location.reload(), 1500); // Reload to fetch fresh table data easily
+}, true); // Use capture to intercept the original listener
+
+});
+
+
+
+let apiKeysLoaded = false;
+
+document.querySelectorAll('.tab-link').forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ document.querySelectorAll('.tab-link').forEach(l => l.classList.remove('active'));
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+
+ link.classList.add('active');
+ document.getElementById(link.dataset.target).classList.add('active');
+
+ if (link.dataset.target === 'tab-apikeys' && !apiKeysLoaded) {
+ apiKeysLoaded = true;
+ loadApiKeys();
+ }
+ });
+});
+
+document.getElementById('refreshApiKeysBtn')?.addEventListener('click', loadApiKeys);
+
+async function loadApiKeys() {
+ const tbody = document.getElementById('apiKeysBody');
+ if (!tbody) return;
+ tbody.innerHTML = '
| Loading... |
';
+
+ try {
+ const host = document.getElementById('apiKeyHost').value;
+ const res = await fetch('/api/keys?host=' + encodeURIComponent(host));
+ if (!res.ok) throw new Error('Failed to fetch keys');
+ const keys = await res.json();
+
+ if (keys.length === 0) {
+ tbody.innerHTML = '
| No API keys found. |
';
+ return;
+ }
+
+ tbody.innerHTML = keys.map(k => {
+ const expStr = k.expiresAt ? new Date(k.expiresAt).toLocaleString() : 'Never';
+ const perms = (k.permissions || []).join(', ');
+
+ return '
' +
+ '| ' + escHtml(k.name) + ' | ' +
+ '' + escHtml(k.host) + ' | ' +
+ '' + escHtml(perms) + ' | ' +
+ '' + escHtml(expStr) + ' | ' +
+ '' +
+ '' +
+ ' | ' +
+ '
';
+ }).join('');
+
+ } catch (err) {
+ tbody.innerHTML = '
| Error loading keys: ' + escHtml(err.message) + ' |
';
+ }
+}
+
+window.revokeApiKey = async function(id, host) {
+ if (!confirm('Are you sure you want to revoke this API key? This cannot be undone.')) return;
+ try {
+ const res = await fetch('/api/keys/' + encodeURIComponent(id) + '?host=' + encodeURIComponent(host), { method: 'DELETE' });
+ if (!res.ok) throw new Error('Failed to revoke key');
+ showToast('API Key revoked');
+ loadApiKeys();
+ } catch(err) {
+ alert('Error: ' + err.message);
+ }
+};
+
+document.getElementById('createApiKeyForm')?.addEventListener('submit', async function(e) {
+ e.preventDefault();
+ const errEl = document.getElementById('apiKeyFormError');
+ const successEl = document.getElementById('apiKeySuccess');
+ const btn = document.getElementById('createApiKeyBtn');
+ errEl.style.display = 'none';
+ successEl.style.display = 'none';
+
+ const fd = new FormData(e.target);
+ const host = fd.get('host');
+ const name = fd.get('name');
+ const expiresAtRaw = fd.get('expiresAt');
+ const permissions = Array.from(fd.getAll('permissions'));
+
+ if (permissions.length === 0) {
+ errEl.textContent = 'Please select at least one permission.';
+ errEl.style.display = '';
+ return;
+ }
+
+ const body = {
+ host,
+ name,
+ permissions,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ };
+
+ btn.disabled = true;
+ btn.textContent = 'Generating...';
+
+ try {
+ const res = await fetch('/api/keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || 'Failed to create API key');
+
+ document.getElementById('apiKeyDisplay').value = data.token;
+ successEl.style.display = 'flex';
+ e.target.reset();
+
+ // Default checked state for permissions since reset clears them
+ e.target.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
+ // Restore host
+ document.getElementById('apiKeyHost').value = host;
+
+ loadApiKeys();
+
+ } catch(err) {
+ errEl.textContent = err.message;
+ errEl.style.display = '';
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Generate Key';
+ }
+});
+
+document.getElementById('apiKeyHost')?.addEventListener('change', loadApiKeys);
+
+
+
+let apiKeysLoaded = false;
+
+document.querySelectorAll('.tab-link').forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ document.querySelectorAll('.tab-link').forEach(l => l.classList.remove('active'));
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+
+ link.classList.add('active');
+ document.getElementById(link.dataset.target).classList.add('active');
+
+ if (link.dataset.target === 'tab-apikeys' && !apiKeysLoaded) {
+ apiKeysLoaded = true;
+ loadApiKeys();
+ }
+ });
+});
+
+document.getElementById('refreshApiKeysBtn')?.addEventListener('click', loadApiKeys);
+
+async function loadApiKeys() {
+ const tbody = document.getElementById('apiKeysBody');
+ if (!tbody) return;
+ tbody.innerHTML = '
| Loading... |
';
+
+ try {
+ const host = document.getElementById('apiKeyHost').value;
+ const res = await fetch('/api/keys?host=' + encodeURIComponent(host));
+ if (!res.ok) throw new Error('Failed to fetch keys');
+ const keys = await res.json();
+
+ if (keys.length === 0) {
+ tbody.innerHTML = '
| No API keys found. |
';
+ return;
+ }
+
+ tbody.innerHTML = keys.map(k => {
+ const expStr = k.expiresAt ? new Date(k.expiresAt).toLocaleString() : 'Never';
+ const perms = (k.permissions || []).join(', ');
+
+ return '
' +
+ '| ' + escHtml(k.name) + ' | ' +
+ '' + escHtml(k.host) + ' | ' +
+ '' + escHtml(perms) + ' | ' +
+ '' + escHtml(expStr) + ' | ' +
+ '' +
+ '' +
+ ' | ' +
+ '
';
+ }).join('');
+
+ } catch (err) {
+ tbody.innerHTML = '
| Error loading keys: ' + escHtml(err.message) + ' |
';
+ }
+}
+
+window.revokeApiKey = async function(id, host) {
+ if (!confirm('Are you sure you want to revoke this API key? This cannot be undone.')) return;
+ try {
+ const res = await fetch('/api/keys/' + encodeURIComponent(id) + '?host=' + encodeURIComponent(host), { method: 'DELETE' });
+ if (!res.ok) throw new Error('Failed to revoke key');
+ showToast('API Key revoked');
+ loadApiKeys();
+ } catch(err) {
+ alert('Error: ' + err.message);
+ }
+};
+
+document.getElementById('createApiKeyForm')?.addEventListener('submit', async function(e) {
+ e.preventDefault();
+ const errEl = document.getElementById('apiKeyFormError');
+ const successEl = document.getElementById('apiKeySuccess');
+ const btn = document.getElementById('createApiKeyBtn');
+ errEl.style.display = 'none';
+ successEl.style.display = 'none';
+
+ const fd = new FormData(e.target);
+ const host = fd.get('host');
+ const name = fd.get('name');
+ const expiresAtRaw = fd.get('expiresAt');
+ const permissions = Array.from(fd.getAll('permissions'));
+
+ if (permissions.length === 0) {
+ errEl.textContent = 'Please select at least one permission.';
+ errEl.style.display = '';
+ return;
+ }
+
+ const body = {
+ host,
+ name,
+ permissions,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ };
+
+ btn.disabled = true;
+ btn.textContent = 'Generating...';
+
+ try {
+ const res = await fetch('/api/keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || 'Failed to create API key');
+
+ document.getElementById('apiKeyDisplay').value = data.token;
+ successEl.style.display = 'flex';
+ e.target.reset();
+
+ // Default checked state for permissions since reset clears them
+ e.target.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
+ // Restore host
+ document.getElementById('apiKeyHost').value = host;
+
+ loadApiKeys();
+
+ } catch(err) {
+ errEl.textContent = err.message;
+ errEl.style.display = '';
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Generate Key';
+ }
+});
+
+document.getElementById('apiKeyHost')?.addEventListener('change', loadApiKeys);
+
+
+
+let apiKeysLoaded = false;
+
+document.querySelectorAll('.tab-link').forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ document.querySelectorAll('.tab-link').forEach(l => l.classList.remove('active'));
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+
+ link.classList.add('active');
+ document.getElementById(link.dataset.target).classList.add('active');
+
+ if (link.dataset.target === 'tab-apikeys' && !apiKeysLoaded) {
+ apiKeysLoaded = true;
+ loadApiKeys();
+ }
+ });
+});
+
+document.getElementById('refreshApiKeysBtn')?.addEventListener('click', loadApiKeys);
+
+async function loadApiKeys() {
+ const tbody = document.getElementById('apiKeysBody');
+ tbody.innerHTML = '
| Loading... |
';
+
+ try {
+ const host = document.getElementById('apiKeyHost').value;
+ const res = await fetch('/api/keys?host=' + encodeURIComponent(host));
+ if (!res.ok) throw new Error('Failed to fetch keys');
+ const keys = await res.json();
+
+ if (keys.length === 0) {
+ tbody.innerHTML = '
| No API keys found. |
';
+ return;
+ }
+
+ tbody.innerHTML = keys.map(k => {
+ const expStr = k.expiresAt ? new Date(k.expiresAt).toLocaleString() : 'Never';
+ const perms = (k.permissions || []).join(', ');
+
+ return '
' +
+ '| ' + escHtml(k.name) + ' | ' +
+ '' + escHtml(k.host) + ' | ' +
+ '' + escHtml(perms) + ' | ' +
+ '' + escHtml(expStr) + ' | ' +
+ '' +
+ '' +
+ ' | ' +
+ '
';
+ }).join('');
+
+ } catch (err) {
+ tbody.innerHTML = '
| Error loading keys: ' + escHtml(err.message) + ' |
';
+ }
+}
+
+window.revokeApiKey = async function(id, host) {
+ if (!confirm('Are you sure you want to revoke this API key? This cannot be undone.')) return;
+ try {
+ const res = await fetch('/api/keys/' + encodeURIComponent(id) + '?host=' + encodeURIComponent(host), { method: 'DELETE' });
+ if (!res.ok) throw new Error('Failed to revoke key');
+ showToast('API Key revoked');
+ loadApiKeys();
+ } catch(err) {
+ alert('Error: ' + err.message);
+ }
+};
+
+document.getElementById('createApiKeyForm')?.addEventListener('submit', async function(e) {
+ e.preventDefault();
+ const errEl = document.getElementById('apiKeyFormError');
+ const successEl = document.getElementById('apiKeySuccess');
+ const btn = document.getElementById('createApiKeyBtn');
+ errEl.style.display = 'none';
+ successEl.style.display = 'none';
+
+ const fd = new FormData(e.target);
+ const host = fd.get('host');
+ const name = fd.get('name');
+ const expiresAtRaw = fd.get('expiresAt');
+ const permissions = Array.from(fd.getAll('permissions'));
+
+ if (permissions.length === 0) {
+ errEl.textContent = 'Please select at least one permission.';
+ errEl.style.display = '';
+ return;
+ }
+
+ const body = {
+ host,
+ name,
+ permissions,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ };
+
+ btn.disabled = true;
+ btn.textContent = 'Generating...';
+
+ try {
+ const res = await fetch('/api/keys', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || 'Failed to create API key');
+
+ document.getElementById('apiKeyDisplay').value = data.token;
+ successEl.style.display = 'flex';
+ e.target.reset();
+
+ // Default checked state for permissions since reset clears them
+ e.target.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
+ // Restore host
+ document.getElementById('apiKeyHost').value = host;
+
+ loadApiKeys();
+
+ } catch(err) {
+ errEl.textContent = err.message;
+ errEl.style.display = '';
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Generate Key';
+ }
+});
+
+document.getElementById('apiKeyHost')?.addEventListener('change', loadApiKeys);
+
+
refreshFoldersForHost(document.getElementById('host').value);
refreshAudit();
updateTransformerMode();
@@ -1104,7 +1831,8 @@ document.getElementById('editCancelBtn2').addEventListener('click', (e) => {
closeEditModal();
});
-document.getElementById('editForm').addEventListener('submit', async function(e) {
+
+document.getElementById('editForm')?.addEventListener('submit', async function(e) {
e.preventDefault();
const errEl = document.getElementById('editError');
errEl.style.display = 'none';
@@ -1133,6 +1861,57 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
});
const pd = await pr.json().catch(() => ({}));
if (!pr.ok) throw new Error(pd.error || 'Update failed');
+ showToast('Updated transformer: ' + host + '/' + newSlug);
+ setTimeout(() => location.reload(), 500);
+ return;
+ }
+
+ 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;
+ }
+
+ const patchBody = {
+ host,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ };
+ if (editType !== 'image') {
+ patchBody.guest = guest;
+ }
+
+ 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');
+
+ showToast('Updated: ' + currentSlug);
+ setTimeout(() => location.reload(), 500);
+ } catch (err) {
+ errEl.textContent = err.message || 'Failed to update link.';
+ errEl.style.display = '';
+ } finally {
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save changes';
+ }
+});
+
+ const pd = await pr.json().catch(() => ({}));
+ if (!pr.ok) throw new Error(pd.error || 'Update failed');
const row = document.querySelector('[data-id="' + editId + '"][data-host="' + host + '"][data-type="transformer"]');
if (row) {
@@ -1178,11 +1957,15 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
document.getElementById('editOldSlug').value = currentSlug;
}
+
const patchBody = {
host,
- guest,
expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
};
+ if (editType !== 'image') {
+ patchBody.guest = guest;
+ }
+
patchBody.folderSlug = folderSlug || null;
if (clearPassword) patchBody.password = null;
else if (password) patchBody.password = password;
@@ -1198,12 +1981,17 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
// 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) {
+ const urlCell = row.querySelector('.url-cell');
+ if (editType === 'image') {
+ if (urlCell) urlCell.innerHTML = '
Image Data';
+ } else if (destCell) {
destCell.href = guest;
destCell.title = guest;
destCell.textContent = guest;
}
+
const expiryCell = row.querySelector('td.center.nowrap');
if (expiryCell) {
expiryCell.textContent = expiresAtRaw
@@ -1235,6 +2023,7 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
}
});
+
document.getElementById('createForm').addEventListener('submit', async function(e) {
e.preventDefault();
const errEl = document.getElementById('formError');
@@ -1242,37 +2031,60 @@ document.getElementById('createForm').addEventListener('submit', async function(
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 isTransformer = document.getElementById('isTransformer')?.checked || false;
+ const isImage = document.getElementById('isImage')?.checked || false;
+ let guest = fd.get('guest');
+ if (guest) guest = guest.trim();
+
+ let reqOpts = {};
+
+ if (isImage) {
+ const file = fd.get('image');
+ if (!file || !file.size) {
+ errEl.textContent = 'Please select an image file.';
+ errEl.style.display = '';
+ return;
+ }
+ // We send FormData directly for image uploads
+ reqOpts = {
+ method: 'POST',
+ body: fd
+ };
+ fd.append('type', 'image');
+ if (expiresAtRaw) fd.set('expiresAt', new Date(expiresAtRaw).getTime());
+ else fd.delete('expiresAt');
+ } else {
+ const body = {
+ host,
+ slug,
+ guest,
+ folderSlug: folderSlug || null,
+ expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
+ password: password || null,
+ };
+ if (isTransformer) {
+ body.type = 'transformer';
+ body.isTransformer = true;
+ body.folderSlug = null;
+ body.expiresAt = null;
+ body.password = null;
+ }
- const body = {
- host,
- slug,
- guest,
- folderSlug: folderSlug || null,
- expiresAt: expiresAtRaw ? new Date(expiresAtRaw).getTime() : null,
- password: password || null,
- };
- if (isTransformer) {
- body.type = 'transformer';
- body.isTransformer = true;
- body.folderSlug = null;
- body.expiresAt = null;
- body.password = null;
+ reqOpts = {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ };
}
const btn = document.getElementById('createBtn');
btn.disabled = true;
- btn.textContent = isTransformer ? 'Creating transformer…' : 'Creating…';
+ btn.textContent = isTransformer ? 'Creating transformer…' : (isImage ? 'Uploading image…' : 'Creating…');
- const r = await fetch('/api/links', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
+ const r = await fetch('/api/links', reqOpts);
const d = await r.json().catch(() => ({}));
btn.disabled = false;
btn.innerHTML = '
Create Link';
@@ -1286,7 +2098,7 @@ document.getElementById('createForm').addEventListener('submit', async function(
const createdId = d.id || slug;
showToast(isTransformer
? ('Transformer created: ' + host + '/' + slug)
- : ('Created: ' + (host === new URL(ORIGIN).host ? (ORIGIN + '/' + slug) : ('https://' + host + '/' + slug))));
+ : (isImage ? 'Image uploaded: ' + host + '/' + slug : 'Created: ' + (host === new URL(ORIGIN).host ? (ORIGIN + '/' + slug) : ('https://' + host + '/' + slug))));
e.target.reset();
updateTransformerMode();
@@ -1296,8 +2108,8 @@ document.getElementById('createForm').addEventListener('submit', async function(
const emptyRow = tbody.querySelector('tr:not(#filterEmptyRow) .empty-row');
if (emptyRow) emptyRow.closest('tr').remove();
- const expiryText = body.expiresAt
- ? new Date(body.expiresAt).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'short' })
+ const expiryText = expiresAtRaw
+ ? new Date(expiresAtRaw).toLocaleString('en-GB', { dateStyle: 'short', timeStyle: 'short' })
: '—';
const shortUrl = (host === new URL(ORIGIN).host) ? (ORIGIN + '/' + slug) : ('https://' + host + '/' + slug);
const tr = document.createElement('tr');
@@ -1305,23 +2117,27 @@ document.getElementById('createForm').addEventListener('submit', async function(
tr.dataset.host = host;
tr.dataset.status = 'active';
tr.dataset.folderSlug = isTransformer ? '' : (folderSlug || '');
- tr.dataset.type = isTransformer ? 'transformer' : 'link';
+ tr.dataset.type = isTransformer ? 'transformer' : (isImage ? 'image' : 'link');
tr.dataset.id = createdId;
function esc(s) {
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
}
+ let typePill = '';
+ if (isTransformer) typePill = '
Transformer';
+ if (isImage) typePill = '
Image';
+
tr.innerHTML =
- '
' + (isTransformer ? renderPatternTokens(slug) : esc(slug)) + '' + (isTransformer ? ' Transformer' : '') + ' | ' +
+ '
' + (isTransformer ? renderPatternTokens(slug) : esc(slug)) + '' + typePill + ' | ' +
'
' + esc(host) + ' | ' +
'
Active | ' +
- '
' + (isTransformer ? ('' + renderPatternTokens(guest) + '') : ('' + esc(guest) + '')) + ' | ' +
+ '
' + (isTransformer ? ('' + renderPatternTokens(guest) + '') : (isImage ? 'Image Data' : ('' + esc(guest) + ''))) + ' | ' +
'
0 | ' +
- '
' + (!isTransformer && body.password ? '🔒' : '—') + ' | ' +
+ '
' + (!isTransformer && password ? '🔒' : '—') + ' | ' +
'
' + (isTransformer ? '—' : esc(expiryText)) + ' | ' +
'
' +
- buildLinkRowActionsHtml(host, slug, guest, isTransformer ? null : body.expiresAt, isTransformer ? null : (folderSlug || null), 'active', isTransformer ? 'transformer' : 'link', createdId) +
+ buildLinkRowActionsHtml(host, slug, guest || '', isTransformer || isImage ? null : new Date(expiresAtRaw).getTime(), isTransformer ? null : (folderSlug || null), 'active', isTransformer ? 'transformer' : (isImage ? 'image' : 'link'), createdId) +
' | ';
tbody.insertBefore(tr, tbody.firstChild);
@@ -1329,6 +2145,7 @@ document.getElementById('createForm').addEventListener('submit', async function(
if (badge) badge.textContent = parseInt(badge.textContent || '0', 10) + 1;
applyRowVisibility();
});
- `,
- );
+
+ `
+ )
}
diff --git a/src/pages/errors.js b/src/pages/errors.js
index 1a20a7d..a72a273 100644
--- a/src/pages/errors.js
+++ b/src/pages/errors.js
@@ -1,7 +1,7 @@
-import { htmlPage } from './shared.js';
-import { escHtml } from '../util.js';
+import { htmlPage } from './shared.js'
+import { escHtml } from '../util.js'
-export function passwordPage(slug, badPassword) {
+export function passwordPage (slug, badPassword) {
return htmlPage(
'Protected Link — Plummer',
`
@@ -30,11 +30,11 @@ export function passwordPage(slug, badPassword) {
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() {
+export function expiredPage () {
return htmlPage(
'Link Expired — Plummer',
`
@@ -51,11 +51,11 @@ export function expiredPage() {
.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() {
+export function inactivePage () {
return htmlPage(
'Link Inactive — Plummer',
`
@@ -72,11 +72,11 @@ export function inactivePage() {
.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() {
+export function deletedPage () {
return htmlPage(
'Link Deleted — Plummer',
`
@@ -93,11 +93,11 @@ export function deletedPage() {
.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() {
+export function notFoundPage () {
return htmlPage(
'Not Found — Plummer',
`
@@ -114,11 +114,11 @@ export function notFoundPage() {
.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() {
+export function misconfiguredPage () {
return htmlPage(
'Setup Required — Plummer',
`
@@ -152,7 +152,6 @@ export function misconfiguredPage() {
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
index 0385c80..a8086bf 100644
--- a/src/pages/folders.js
+++ b/src/pages/folders.js
@@ -1,27 +1,27 @@
-import { htmlPage } from './shared.js';
-import { escHtml } from '../util.js';
+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;
+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. |
`
+ ? '
| No links in this folder yet. |
'
: links.map((link) => {
- const shortUrl = `${base.origin}/${link.slug}`;
+ 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';
+ : '—'
+ const status = link.status || 'active'
return `
${escHtml(link.slug)} |
${escHtml(link.guest)} |
${escHtml(status)} |
${escHtml(expiry)} |
-
`;
- }).join('');
+ `
+ }).join('')
return htmlPage(
title,
@@ -77,7 +77,6 @@ export function folderListingPage({ origin, host, folder, links }) {
.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
index fde110c..c785186 100644
--- a/src/pages/heartbeat.js
+++ b/src/pages/heartbeat.js
@@ -1,4 +1,4 @@
-export function heartbeatPage() {
+export function heartbeatPage () {
return `
@@ -112,5 +112,5 @@ export function heartbeatPage() {
-`;
+