Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions scripts/swap-wrangler.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
77 changes: 38 additions & 39 deletions src/audit.js
Original file line number Diff line number Diff line change
@@ -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)
}

12 changes: 6 additions & 6 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
46 changes: 25 additions & 21 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}))
}
},
};

}
}
Loading