From 951bcace5da5ba411a3dfc3000ed562b269807db Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:41:17 +0000 Subject: [PATCH] feat: Add API Keys and Image Uploads support to Plummer dashboard/api Co-authored-by: kiyarose <75678535+kiyarose@users.noreply.github.com> --- scripts/swap-wrangler.mjs | 44 +- src/audit.js | 77 +- src/constants.js | 12 +- src/index.js | 46 +- src/kv.js | 366 +- src/pages/admin.js | 977 ++- src/pages/errors.js | 41 +- src/pages/folders.js | 31 +- src/pages/heartbeat.js | 4 +- src/pages/home.js | 11 +- src/pages/shared.js | 15 +- src/router.js | 1164 ++-- src/security.js | 84 +- src/transformers.js | 122 +- src/util.js | 29 +- worker-configuration.d.ts | 13526 ++++++++++++++++++++++++++++++++++++ 16 files changed, 15626 insertions(+), 923 deletions(-) create mode 100644 worker-configuration.d.ts 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 - ? `
${isTransformer ? renderPatternTokens(link.slug) : escHtml(link.slug)}${isTransformer ? ' Transformer' : ''}${isTransformer ? renderPatternTokens(link.slug) : escHtml(link.slug)}${typePill}${escHtml(linkHost)}${renderPatternTokens(link.guest)}`
- : `${escHtml(link.guest)}`}
- ${renderPatternTokens(link.guest)}` : (isImage ? 'Image Data' : `${escHtml(link.guest)}`)}' + escHtml(k.host) + '${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 = '' + escHtml(k.host) + '' + escHtml(k.host) + '' + escHtml(k.host) + '' + (isTransformer ? renderPatternTokens(slug) : esc(slug)) + '' + (isTransformer ? ' Transformer' : '') + '' + (isTransformer ? renderPatternTokens(slug) : esc(slug)) + '' + typePill + '' + esc(host) + '' + renderPatternTokens(guest) + '') : ('' + esc(guest) + '')) + '' + renderPatternTokens(guest) + '') : (isImage ? 'Image Data' : ('' + esc(guest) + ''))) + '${escHtml(link.slug)}