Skip to content

Commit c8ad80c

Browse files
authored
Merge pull request #3 from SillyLittleTech/kr/v2
Enhance link management with folders, audits, and admin UI
2 parents 496b9d8 + d76d32b commit c8ad80c

19 files changed

Lines changed: 3138 additions & 1063 deletions

README.md

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

2023
---
2124

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

31-
Links are stored in a Cloudflare KV namespace as JSON values under the key `link:{slug}`:
34+
Links are stored in a Cloudflare KV namespace as JSON values under a host-scoped key:
35+
36+
- `link:{host}:{slug}` (e.g. `link:share.sillylittle.tech:my-link`)
3237

3338
```jsonc
3439
{
40+
"host": "share.sillylittle.tech",
3541
"slug": "my-link",
3642
"guest": "https://example.com",
3743
"passwordHash": null, // SHA-256 of password, or null
@@ -74,32 +80,66 @@ id = "your-production-namespace-id"
7480
preview_id = "your-preview-namespace-id"
7581
```
7682

77-
### 4. Set the admin password secret
83+
### Side note: TOML
84+
85+
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.
86+
Running `npm run toml:toggle` or use `dev` and `prod` to switch between the versions. Make sure you enforce parody!
87+
88+
### 4. Admin authentication
7889

79-
The admin dashboard is protected by HTTP Basic Auth. Set the password via Wrangler:
90+
Production recommendation: protect the admin dashboard (`/admin`) at the edge (for
91+
example, using Cloudflare Access). All `/api/*` routes always require HTTP Basic
92+
Auth and therefore require `ADMIN_SECRET` to be set.
93+
94+
To enable local Basic Auth instead of an edge solution (for development or if you
95+
don't have an edge auth configured), uncomment the local auth block in
96+
[src/router.js](src/router.js#L752-L792) and set the secret:
8097

8198
```bash
8299
npx wrangler secret put ADMIN_SECRET
83100
# → Enter your chosen password when prompted
84101
```
85102

86-
When visiting `/admin`, your browser will ask for a username and password.
87-
Use **any username** and the password you just set.
103+
Then enable the local flag (in `.dev.vars` or your environment):
104+
105+
```
106+
ADMIN_SECRET=your-local-password
107+
ENABLE_LOCAL_ADMIN_AUTH=true
108+
```
109+
110+
When enabled, visiting `/admin` will prompt for Basic Auth (any username + the
111+
password you set). By default the local auth block is commented out in the code
112+
to avoid accidental exposure in production—uncomment it only if you intend to
113+
use local Basic Auth.
88114

89-
### 5. (Optional) Configure a custom domain
115+
### 5. Configure allowed hostnames (multi-domain/subdomain support)
116+
117+
Plummer supports serving and managing links across multiple configured hostnames (domains/subdomains).
118+
Set `ALLOWED_HOSTS_JSON` in `wrangler.toml` as a JSON array of hostnames:
119+
120+
```toml
121+
[vars]
122+
ALLOWED_HOSTS_JSON = "[\"share.sillylittle.tech\",\"links.sillylittle.tech\",\"links.share.sillylittle.tech\"]"
123+
```
124+
125+
The `/admin` UI will show these in a dropdown when creating links.
126+
127+
If you leave `ALLOWED_HOSTS_JSON` unset/empty, API writes are **restricted to the current request host** as a safer default.
128+
129+
### 6. (Optional) Configure a custom domain / route
90130

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

94134
```toml
95135
[[routes]]
96-
pattern = "share.sillylittle.tech/*"
97-
zone_name = "sillylittle.tech"
136+
pattern = "share.sillylittle.tech/*"
137+
zone_id = "..."
98138
```
99139

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

102-
### 6. Deploy
142+
### 7. Deploy
103143

104144
```bash
105145
npm run deploy
@@ -113,6 +153,14 @@ npm run dev
113153
# or: npx wrangler dev
114154
```
115155

156+
For local dev secrets, you can also use a `.dev.vars` file (Wrangler reads it automatically):
157+
158+
```bash
159+
ADMIN_SECRET=your-local-password
160+
ENABLE_DEBUG_ENDPOINTS=true
161+
FORCE_DELETE_KEY=optional-testing-key
162+
```
163+
116164
---
117165

118166
## GitHub Actions CI/CD
@@ -138,7 +186,12 @@ Add the following **repository secrets** in your GitHub repo settings
138186
```
139187
plummer/
140188
├── src/
141-
│ └── index.js # Cloudflare Worker (all routes + HTML templates inline)
189+
│ ├── index.js # Worker entrypoint (fetch + scheduled purge)
190+
│ ├── router.js # Routing for /admin, /api, redirects, folders, debug endpoints
191+
│ ├── security.js # Basic auth + response security headers
192+
│ ├── kv.js # KV storage helpers (host-scoped keys)
193+
│ ├── audit.js # Audit event storage + listing
194+
│ └── pages/ # HTML pages (home/admin/errors/folders)
142195
├── .github/
143196
│ └── workflows/
144197
│ └── deploy.yml # GitHub Actions → Cloudflare Workers
@@ -157,7 +210,19 @@ All API routes require HTTP Basic Auth (same credentials as the admin dashboard)
157210
|---|---|---|
158211
| `GET` | `/api/links` | List all links (JSON array) |
159212
| `POST` | `/api/links` | Create a new link (JSON body) |
160-
| `DELETE` | `/api/links/:slug` | Delete a link |
213+
| `PATCH` | `/api/links/:slug` | Update a link (destination, expiry, folder, password, status) |
214+
| `POST` | `/api/links/:slug/rename` | Rename a link slug |
215+
| `DELETE` | `/api/links/:slug?host=...` | Schedule deletion (3-day retention) |
216+
| `GET` | `/api/folders?host=...` | List folders for a host |
217+
| `POST` | `/api/folders` | Create folder |
218+
| `PATCH` | `/api/folders/:slug` | Update folder (name, listingEnabled, password) |
219+
| `DELETE` | `/api/folders/:slug?host=...` | Delete folder |
220+
| `GET` | `/api/audit?limit=...` | List recent audit events |
221+
222+
### Debug endpoints (optional)
223+
224+
Debug endpoints are disabled by default. To enable them, set `ENABLE_DEBUG_ENDPOINTS=true`.
225+
Some debug endpoints may additionally require `FORCE_DELETE_KEY`.
161226

162227
### Create link — request body
163228

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
"scripts": {
77
"dev": "wrangler dev",
88
"deploy": "wrangler deploy",
9-
"cf-typegen": "wrangler types"
9+
"cf-typegen": "wrangler types",
10+
"toml:toggle": "node scripts/swap-wrangler.mjs",
11+
"toml:dev": "node scripts/swap-wrangler.mjs dev",
12+
"toml:prod": "node scripts/swap-wrangler.mjs prod"
1013
},
1114
"devDependencies": {
1215
"wrangler": "^4.86.0"

scripts/swap-wrangler.mjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { readFileSync, writeFileSync } from 'node:fs';
2+
import { fileURLToPath } from 'node:url';
3+
import { dirname, resolve } from 'node:path';
4+
5+
const scriptDir = dirname(fileURLToPath(import.meta.url));
6+
const root = dirname(scriptDir);
7+
const activePath = resolve(root, 'wrangler.toml');
8+
const devPath = resolve(root, 'wrangler.toml.local.bac');
9+
const prodPath = resolve(root, 'wrangler.toml.cloud.bac');
10+
11+
const mode = (process.argv[2] || 'toggle').toLowerCase();
12+
13+
const active = readFileSync(activePath, 'utf8');
14+
const dev = readFileSync(devPath, 'utf8');
15+
const prod = readFileSync(prodPath, 'utf8');
16+
17+
let next;
18+
19+
if (mode === 'dev') {
20+
next = dev;
21+
} else if (mode === 'prod') {
22+
next = prod;
23+
} else if (active === dev) {
24+
next = prod;
25+
} else if (active === prod) {
26+
next = dev;
27+
} else {
28+
console.error('wrangler.toml does not match either backup. Use `npm run toml:dev` or `npm run toml:prod`.');
29+
process.exit(1);
30+
}
31+
32+
writeFileSync(activePath, next);
33+
34+
const label = next === dev ? 'dev' : 'prod';
35+
console.log(`Updated wrangler.toml -> ${label}`);

src/audit.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { normalizeHost } from './kv.js';
2+
3+
function padTs(ts) {
4+
// 13-digit ms timestamp, lexicographically sortable
5+
return String(ts).padStart(13, '0');
6+
}
7+
8+
function auditKey(ts, id) {
9+
return `audit:${padTs(ts)}:${id}`;
10+
}
11+
12+
function randomId() {
13+
// short, URL-safe (avoid Math.random predictability/collisions)
14+
try {
15+
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
16+
} catch {
17+
const bytes = new Uint8Array(8);
18+
crypto.getRandomValues(bytes);
19+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
20+
}
21+
}
22+
23+
export function getActor(request) {
24+
const ip =
25+
request.headers.get('cf-connecting-ip') ||
26+
(request.headers.get('x-forwarded-for') || '').split(',')[0].trim() ||
27+
null;
28+
const ua = request.headers.get('user-agent') || null;
29+
const ray = request.headers.get('cf-ray') || null;
30+
return { ip, ua, ray };
31+
}
32+
33+
export async function writeAudit(env, event) {
34+
const ts = event.timestamp ?? Date.now();
35+
const id = event.id ?? randomId();
36+
const key = auditKey(ts, id);
37+
const payload = { ...event, timestamp: ts, id };
38+
if (payload.host) payload.host = normalizeHost(payload.host);
39+
await env.LINKIVERSE.put(key, JSON.stringify(payload));
40+
return { key, id, timestamp: ts };
41+
}
42+
43+
export async function listAudit(env, { limit = 100 } = {}) {
44+
const keysWindow = [];
45+
const windowSize = Math.max(200, Math.min(1000, limit * 5));
46+
let cursor;
47+
do {
48+
const page = await env.LINKIVERSE.list({ prefix: 'audit:', limit: 100, cursor });
49+
for (const key of page.keys) {
50+
keysWindow.push(key.name);
51+
if (keysWindow.length > windowSize) keysWindow.shift();
52+
}
53+
cursor = page.list_complete ? undefined : page.cursor;
54+
} while (cursor);
55+
56+
const items = [];
57+
for (const name of keysWindow) {
58+
const raw = await env.LINKIVERSE.get(name);
59+
if (!raw) continue;
60+
try {
61+
items.push(JSON.parse(raw));
62+
} catch {
63+
// ignore
64+
}
65+
}
66+
67+
items.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
68+
return items.slice(0, limit);
69+
}
70+

src/constants.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Shared constants for the Plummer worker.
2+
3+
export const RESERVED = new Set([
4+
'admin',
5+
'api',
6+
'favicon.ico',
7+
'robots.txt',
8+
'sitemap.xml',
9+
]);
10+
11+
export const ADMIN_REALM = 'Plummer Admin';
12+
13+
// Visible build version displayed in the UI footer.
14+
export const APP_VERSION = 'v2.1';
15+
16+
export const SECURITY_HEADERS = {
17+
'X-Content-Type-Options': 'nosniff',
18+
'X-Frame-Options': 'DENY',
19+
'Referrer-Policy': 'strict-origin-when-cross-origin',
20+
};
21+

0 commit comments

Comments
 (0)