Skip to content
Merged
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
91 changes: 78 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ Built for [SillyLittleTech](https://sillylittle.tech) at `share.sillylittle.tech
| 📊 Click analytics | Per-link click counter in the admin dashboard |
| 🔒 Password protection | Require a password before redirecting |
| ⏰ Link expiry | Set an expiry date/time; expired links are cleaned up automatically |
| 🗂️ Folders | Optional folder pages (e.g. `/referrals/`) that list links |
| 🧾 Audit log | Tracks create/update/delete events with actor IP |
| ♻️ Safe deletes | Links are tombstoned and purged automatically after 3 days |
| 🌙 Dark / light mode | Follows system preference with a manual toggle |
| 🔑 Admin authentication | HTTP Basic Auth secured by a Wrangler secret |
| 🔑 Admin authentication | HTTP Basic Auth secured by a Wrangler secret (defense-in-depth even if you use Cloudflare Access) |

---

Expand All @@ -28,10 +31,13 @@ Visitor → share.sillylittle.tech/my-link
→ 302 redirect to destination URL
```

Links are stored in a Cloudflare KV namespace as JSON values under the key `link:{slug}`:
Links are stored in a Cloudflare KV namespace as JSON values under a host-scoped key:

- `link:{host}:{slug}` (e.g. `link:share.sillylittle.tech:my-link`)

```jsonc
{
"host": "share.sillylittle.tech",
"slug": "my-link",
"guest": "https://example.com",
"passwordHash": null, // SHA-256 of password, or null
Expand Down Expand Up @@ -74,32 +80,66 @@ id = "your-production-namespace-id"
preview_id = "your-preview-namespace-id"
```

### 4. Set the admin password secret
### Side note: TOML

You may have noticed `wrangler.toml` has two cousins, `wrangler.toml.cloud.bac` and `wrangler.toml.local.bac`, This is because when we add a custom domain for production in routes, WRANGLER is really eager to use it, even in dev.
Running `npm run toml:toggle` or use `dev` and `prod` to switch between the versions. Make sure you enforce parody!

### 4. Admin authentication

The admin dashboard is protected by HTTP Basic Auth. Set the password via Wrangler:
Production recommendation: protect the admin dashboard (`/admin`) at the edge (for
example, using Cloudflare Access). All `/api/*` routes always require HTTP Basic
Auth and therefore require `ADMIN_SECRET` to be set.

To enable local Basic Auth instead of an edge solution (for development or if you
don't have an edge auth configured), uncomment the local auth block in
[src/router.js](src/router.js#L752-L792) and set the secret:

```bash
npx wrangler secret put ADMIN_SECRET
# → Enter your chosen password when prompted
```

When visiting `/admin`, your browser will ask for a username and password.
Use **any username** and the password you just set.
Then enable the local flag (in `.dev.vars` or your environment):

```
ADMIN_SECRET=your-local-password
ENABLE_LOCAL_ADMIN_AUTH=true
```

When enabled, visiting `/admin` will prompt for Basic Auth (any username + the
password you set). By default the local auth block is commented out in the code
to avoid accidental exposure in production—uncomment it only if you intend to
use local Basic Auth.

### 5. (Optional) Configure a custom domain
### 5. Configure allowed hostnames (multi-domain/subdomain support)

Plummer supports serving and managing links across multiple configured hostnames (domains/subdomains).
Set `ALLOWED_HOSTS_JSON` in `wrangler.toml` as a JSON array of hostnames:

```toml
[vars]
ALLOWED_HOSTS_JSON = "[\"share.sillylittle.tech\",\"links.sillylittle.tech\",\"links.share.sillylittle.tech\"]"
```

The `/admin` UI will show these in a dropdown when creating links.

If you leave `ALLOWED_HOSTS_JSON` unset/empty, API writes are **restricted to the current request host** as a safer default.

### 6. (Optional) Configure a custom domain / route

To use a custom domain (e.g. `share.sillylittle.tech`), uncomment and update the
`[[routes]]` block in `wrangler.toml`:
`[[routes]]` block in `wrangler.toml` and set the correct `zone_id`:

```toml
[[routes]]
pattern = "share.sillylittle.tech/*"
zone_name = "sillylittle.tech"
pattern = "share.sillylittle.tech/*"
zone_id = "..."
```

The domain must be added to your Cloudflare account and DNS must point to Cloudflare.

### 6. Deploy
### 7. Deploy

```bash
npm run deploy
Expand All @@ -113,6 +153,14 @@ npm run dev
# or: npx wrangler dev
```

For local dev secrets, you can also use a `.dev.vars` file (Wrangler reads it automatically):

```bash
ADMIN_SECRET=your-local-password
ENABLE_DEBUG_ENDPOINTS=true
FORCE_DELETE_KEY=optional-testing-key
```

---

## GitHub Actions CI/CD
Expand All @@ -138,7 +186,12 @@ Add the following **repository secrets** in your GitHub repo settings
```
plummer/
├── src/
│ └── index.js # Cloudflare Worker (all routes + HTML templates inline)
│ ├── index.js # Worker entrypoint (fetch + scheduled purge)
│ ├── router.js # Routing for /admin, /api, redirects, folders, debug endpoints
│ ├── security.js # Basic auth + response security headers
│ ├── kv.js # KV storage helpers (host-scoped keys)
│ ├── audit.js # Audit event storage + listing
│ └── pages/ # HTML pages (home/admin/errors/folders)
├── .github/
│ └── workflows/
│ └── deploy.yml # GitHub Actions → Cloudflare Workers
Expand All @@ -157,7 +210,19 @@ All API routes require HTTP Basic Auth (same credentials as the admin dashboard)
|---|---|---|
| `GET` | `/api/links` | List all links (JSON array) |
| `POST` | `/api/links` | Create a new link (JSON body) |
| `DELETE` | `/api/links/:slug` | Delete a link |
| `PATCH` | `/api/links/:slug` | Update a link (destination, expiry, folder, password, status) |
| `POST` | `/api/links/:slug/rename` | Rename a link slug |
| `DELETE` | `/api/links/:slug?host=...` | Schedule deletion (3-day retention) |
| `GET` | `/api/folders?host=...` | List folders for a host |
| `POST` | `/api/folders` | Create folder |
| `PATCH` | `/api/folders/:slug` | Update folder (name, listingEnabled, password) |
| `DELETE` | `/api/folders/:slug?host=...` | Delete folder |
| `GET` | `/api/audit?limit=...` | List recent audit events |

### Debug endpoints (optional)

Debug endpoints are disabled by default. To enable them, set `ENABLE_DEBUG_ENDPOINTS=true`.
Some debug endpoints may additionally require `FORCE_DELETE_KEY`.

### Create link — request body

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types"
"cf-typegen": "wrangler types",
"toml:toggle": "node scripts/swap-wrangler.mjs",
"toml:dev": "node scripts/swap-wrangler.mjs dev",
"toml:prod": "node scripts/swap-wrangler.mjs prod"
},
"devDependencies": {
"wrangler": "^4.86.0"
Expand Down
35 changes: 35 additions & 0 deletions scripts/swap-wrangler.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const scriptDir = dirname(fileURLToPath(import.meta.url));
const root = dirname(scriptDir);
const activePath = resolve(root, 'wrangler.toml');
const devPath = resolve(root, 'wrangler.toml.local.bac');
const prodPath = resolve(root, 'wrangler.toml.cloud.bac');

const mode = (process.argv[2] || 'toggle').toLowerCase();

const active = readFileSync(activePath, 'utf8');
const dev = readFileSync(devPath, 'utf8');
const prod = readFileSync(prodPath, 'utf8');

let next;

if (mode === 'dev') {
next = dev;
} else if (mode === 'prod') {
next = prod;
} else if (active === dev) {
next = prod;
} else if (active === prod) {
next = dev;
} else {
console.error('wrangler.toml does not match either backup. Use `npm run toml:dev` or `npm run toml:prod`.');
process.exit(1);
}

writeFileSync(activePath, next);

const label = next === dev ? 'dev' : 'prod';
console.log(`Updated wrangler.toml -> ${label}`);
70 changes: 70 additions & 0 deletions src/audit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { normalizeHost } from './kv.js';

function padTs(ts) {
// 13-digit ms timestamp, lexicographically sortable
return String(ts).padStart(13, '0');
}

function auditKey(ts, id) {
return `audit:${padTs(ts)}:${id}`;
}

function randomId() {
// short, URL-safe (avoid Math.random predictability/collisions)
try {
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
} catch {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
}
}

export function getActor(request) {
const ip =
request.headers.get('cf-connecting-ip') ||
(request.headers.get('x-forwarded-for') || '').split(',')[0].trim() ||
null;
const ua = request.headers.get('user-agent') || null;
const ray = request.headers.get('cf-ray') || null;
return { ip, ua, ray };
}

export async function writeAudit(env, event) {
const ts = event.timestamp ?? Date.now();
const id = event.id ?? randomId();
const key = auditKey(ts, id);
const payload = { ...event, timestamp: ts, id };
if (payload.host) payload.host = normalizeHost(payload.host);
await env.LINKIVERSE.put(key, JSON.stringify(payload));
return { key, id, timestamp: ts };
}

export async function listAudit(env, { limit = 100 } = {}) {
const keysWindow = [];
const windowSize = Math.max(200, Math.min(1000, limit * 5));
let cursor;
do {
const page = await env.LINKIVERSE.list({ prefix: 'audit:', limit: 100, cursor });
for (const key of page.keys) {
keysWindow.push(key.name);
if (keysWindow.length > windowSize) keysWindow.shift();
}
cursor = page.list_complete ? undefined : page.cursor;
} while (cursor);

Comment thread
kiyarose marked this conversation as resolved.
const items = [];
for (const name of keysWindow) {
const raw = await env.LINKIVERSE.get(name);
if (!raw) continue;
try {
items.push(JSON.parse(raw));
} catch {
// ignore
}
}

items.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
return items.slice(0, limit);
}
Comment thread
kiyarose marked this conversation as resolved.

21 changes: 21 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Shared constants for the Plummer worker.

export const RESERVED = new Set([
'admin',
'api',
'favicon.ico',
'robots.txt',
'sitemap.xml',
]);

export const ADMIN_REALM = 'Plummer Admin';

// Visible build version displayed in the UI footer.
export const APP_VERSION = 'v2.1';

export const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
};

Loading