@clipr/worker 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@clipr/worker",
3
+ "version": "0.0.5",
4
+ "description": "Cloudflare Worker for clipr URL redirects",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "dependencies": {
8
+ "hono": "^4.7.0",
9
+ "qrcode": "^1.5.4",
10
+ "@clipr/core": "0.0.6"
11
+ },
12
+ "devDependencies": {
13
+ "@cloudflare/workers-types": "^4.20250327.0",
14
+ "@types/node": "^25.5.0",
15
+ "@types/qrcode": "^1.5.5",
16
+ "vitest": "^4.1.2",
17
+ "wrangler": "^4.0.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=22"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/hammadxcm/clipr",
26
+ "directory": "packages/worker"
27
+ },
28
+ "scripts": {
29
+ "dev": "wrangler dev",
30
+ "build": "wrangler deploy --dry-run --outdir=dist",
31
+ "deploy": "wrangler deploy",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "typecheck": "tsc --noEmit",
35
+ "clean": "rm -rf dist"
36
+ }
37
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * PBKDF2 password hashing via Web Crypto API.
3
+ * Uses a random 16-byte salt with 100,000 iterations.
4
+ * Format: "hex_salt:hex_hash"
5
+ */
6
+
7
+ function toHex(bytes: Uint8Array): string {
8
+ return Array.from(bytes)
9
+ .map((b) => b.toString(16).padStart(2, '0'))
10
+ .join('');
11
+ }
12
+
13
+ function fromHex(hex: string): Uint8Array {
14
+ const bytes = new Uint8Array(hex.length / 2);
15
+ for (let i = 0; i < hex.length; i += 2) {
16
+ bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16);
17
+ }
18
+ return bytes;
19
+ }
20
+
21
+ const ITERATIONS = 100_000;
22
+ const HASH_ALGO = 'SHA-256';
23
+ const KEY_LENGTH = 256;
24
+
25
+ /**
26
+ * Hash a password with PBKDF2 (100k iterations).
27
+ * @returns "hex_salt:hex_hash"
28
+ */
29
+ export async function hashPassword(password: string): Promise<string> {
30
+ const salt = new Uint8Array(16);
31
+ crypto.getRandomValues(salt);
32
+
33
+ const encoder = new TextEncoder();
34
+ const keyMaterial = await crypto.subtle.importKey(
35
+ 'raw',
36
+ encoder.encode(password),
37
+ 'PBKDF2',
38
+ false,
39
+ ['deriveBits'],
40
+ );
41
+
42
+ const derivedBits = await crypto.subtle.deriveBits(
43
+ { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: HASH_ALGO },
44
+ keyMaterial,
45
+ KEY_LENGTH,
46
+ );
47
+
48
+ return `${toHex(salt)}:${toHex(new Uint8Array(derivedBits))}`;
49
+ }
50
+
51
+ /**
52
+ * Verify a password against a stored "hex_salt:hex_hash" string.
53
+ * Uses constant-time comparison to prevent timing attacks.
54
+ */
55
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
56
+ const [saltHex, expectedHashHex] = stored.split(':');
57
+ if (!saltHex || !expectedHashHex) return false;
58
+
59
+ const salt = fromHex(saltHex);
60
+ const encoder = new TextEncoder();
61
+ const keyMaterial = await crypto.subtle.importKey(
62
+ 'raw',
63
+ encoder.encode(password),
64
+ 'PBKDF2',
65
+ false,
66
+ ['deriveBits'],
67
+ );
68
+
69
+ const derivedBits = await crypto.subtle.deriveBits(
70
+ { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: HASH_ALGO },
71
+ keyMaterial,
72
+ KEY_LENGTH,
73
+ );
74
+
75
+ const actualBytes = new Uint8Array(derivedBits);
76
+ const expectedBytes = fromHex(expectedHashHex);
77
+
78
+ // Constant-time comparison
79
+ if (actualBytes.length !== expectedBytes.length) return false;
80
+ let diff = 0;
81
+ for (let i = 0; i < actualBytes.length; i++) {
82
+ diff |= actualBytes[i]! ^ expectedBytes[i]!;
83
+ }
84
+ return diff === 0;
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
3
+ import { authMiddleware } from './middleware/auth.js';
4
+ import { handleHealth } from './routes/health.js';
5
+ import { handleExport, handleImport } from './routes/import-export.js';
6
+ import {
7
+ handleDeleteLink,
8
+ handleGetLink,
9
+ handleListLinks,
10
+ handleUpdateLink,
11
+ } from './routes/links.js';
12
+ import { handlePasswordPage, handlePasswordVerify } from './routes/password.js';
13
+ import { handleQr } from './routes/qr.js';
14
+ import { handleRedirect } from './routes/redirect.js';
15
+ import { handleShorten } from './routes/shorten.js';
16
+ import { handleStats } from './routes/stats.js';
17
+ import type { Env } from './types.js';
18
+
19
+ const app = new Hono<{ Bindings: Env }>();
20
+
21
+ // CORS — restrict API access
22
+ app.use(
23
+ '/api/*',
24
+ cors({
25
+ origin: (origin) => origin || '*',
26
+ allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
27
+ allowHeaders: ['Authorization', 'Content-Type'],
28
+ maxAge: 86400,
29
+ }),
30
+ );
31
+
32
+ // Auth middleware — applied to all routes, skips public ones internally
33
+ app.use('*', authMiddleware);
34
+
35
+ // --- Public routes ---
36
+ app.get('/health', handleHealth);
37
+ app.get('/password/:code', handlePasswordPage);
38
+ app.post('/password/:code', handlePasswordVerify);
39
+
40
+ // --- API routes (protected by auth middleware) ---
41
+ app.post('/api/shorten', handleShorten);
42
+
43
+ app.get('/api/links', handleListLinks);
44
+ app.get('/api/links/:code', handleGetLink);
45
+ app.put('/api/links/:code', handleUpdateLink);
46
+ app.delete('/api/links/:code', handleDeleteLink);
47
+
48
+ app.get('/api/stats/:code', handleStats);
49
+ app.get('/api/qr/:code', handleQr);
50
+
51
+ app.post('/api/import', handleImport);
52
+ app.get('/api/export', handleExport);
53
+
54
+ // --- Catch-all redirect (public) ---
55
+ app.get('/:slug', handleRedirect);
56
+
57
+ app.notFound((c) => c.text('Not Found', 404));
58
+
59
+ export default app;
@@ -0,0 +1,56 @@
1
+ import { SlugConflictError } from '@clipr/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { KvBackend } from './kv-backend.js';
4
+ import { createMockKV } from './test-utils.js';
5
+
6
+ function makeEntry(slug: string, url = 'https://example.com') {
7
+ return { slug, url, createdAt: new Date().toISOString() };
8
+ }
9
+
10
+ describe('KvBackend', () => {
11
+ it('stores and retrieves entries', async () => {
12
+ const backend = new KvBackend(createMockKV());
13
+ await backend.set(makeEntry('abc', 'https://test.com'));
14
+ const entry = await backend.get('abc');
15
+ expect(entry?.url).toBe('https://test.com');
16
+ });
17
+
18
+ it('returns undefined for missing slugs', async () => {
19
+ const backend = new KvBackend(createMockKV());
20
+ expect(await backend.get('nope')).toBeUndefined();
21
+ });
22
+
23
+ it('throws SlugConflictError on duplicate', async () => {
24
+ const backend = new KvBackend(createMockKV());
25
+ await backend.set(makeEntry('dupe'));
26
+ await expect(backend.set(makeEntry('dupe'))).rejects.toThrow(SlugConflictError);
27
+ });
28
+
29
+ it('deletes entries', async () => {
30
+ const backend = new KvBackend(createMockKV());
31
+ await backend.set(makeEntry('del'));
32
+ expect(await backend.delete('del')).toBe(true);
33
+ expect(await backend.get('del')).toBeUndefined();
34
+ });
35
+
36
+ it('returns false when deleting non-existent slug', async () => {
37
+ const backend = new KvBackend(createMockKV());
38
+ expect(await backend.delete('ghost')).toBe(false);
39
+ });
40
+
41
+ it('checks existence', async () => {
42
+ const backend = new KvBackend(createMockKV());
43
+ await backend.set(makeEntry('exists'));
44
+ expect(await backend.has('exists')).toBe(true);
45
+ expect(await backend.has('nope')).toBe(false);
46
+ });
47
+
48
+ it('lists all entries', async () => {
49
+ const backend = new KvBackend(createMockKV());
50
+ await backend.set(makeEntry('aaa'));
51
+ await backend.set(makeEntry('bbb'));
52
+ const entries = await backend.list();
53
+ expect(entries).toHaveLength(2);
54
+ expect(entries.map((e) => e.slug).sort()).toEqual(['aaa', 'bbb']);
55
+ });
56
+ });
@@ -0,0 +1,70 @@
1
+ import type { Backend, UrlEntry } from '@clipr/core';
2
+ import { SlugConflictError } from '@clipr/core';
3
+
4
+ /**
5
+ * Backend implementation backed by Cloudflare KV.
6
+ *
7
+ * Each slug is stored as a separate KV key with the UrlEntry as JSON value.
8
+ * A special `_index` key holds the list of all slugs for the `list()` method.
9
+ */
10
+ export class KvBackend implements Backend {
11
+ constructor(private readonly kv: KVNamespace) {}
12
+
13
+ async get(slug: string): Promise<UrlEntry | undefined> {
14
+ const raw = await this.kv.get(slug, 'text');
15
+ if (!raw) return undefined;
16
+ return JSON.parse(raw) as UrlEntry;
17
+ }
18
+
19
+ async set(entry: UrlEntry): Promise<void> {
20
+ const existing = await this.kv.get(entry.slug, 'text');
21
+ if (existing) {
22
+ throw new SlugConflictError(entry.slug);
23
+ }
24
+ await this.kv.put(entry.slug, JSON.stringify(entry));
25
+ await this.addToIndex(entry.slug);
26
+ }
27
+
28
+ async delete(slug: string): Promise<boolean> {
29
+ const existing = await this.kv.get(slug, 'text');
30
+ if (!existing) return false;
31
+ await this.kv.delete(slug);
32
+ await this.removeFromIndex(slug);
33
+ return true;
34
+ }
35
+
36
+ async has(slug: string): Promise<boolean> {
37
+ const val = await this.kv.get(slug, 'text');
38
+ return val !== null;
39
+ }
40
+
41
+ async list(): Promise<UrlEntry[]> {
42
+ const slugs = await this.getIndex();
43
+ const entries: UrlEntry[] = [];
44
+ for (const slug of slugs) {
45
+ const entry = await this.get(slug);
46
+ if (entry) entries.push(entry);
47
+ }
48
+ return entries;
49
+ }
50
+
51
+ private async getIndex(): Promise<string[]> {
52
+ const raw = await this.kv.get('_index', 'text');
53
+ if (!raw) return [];
54
+ return JSON.parse(raw) as string[];
55
+ }
56
+
57
+ private async addToIndex(slug: string): Promise<void> {
58
+ const index = await this.getIndex();
59
+ if (!index.includes(slug)) {
60
+ index.push(slug);
61
+ await this.kv.put('_index', JSON.stringify(index));
62
+ }
63
+ }
64
+
65
+ private async removeFromIndex(slug: string): Promise<void> {
66
+ const index = await this.getIndex();
67
+ const filtered = index.filter((s) => s !== slug);
68
+ await this.kv.put('_index', JSON.stringify(filtered));
69
+ }
70
+ }
package/src/kv.ts ADDED
@@ -0,0 +1,164 @@
1
+ import type { LinkStats, ShortUrl } from '@clipr/core';
2
+
3
+ // KV key prefixes
4
+ const URL_PREFIX = 'url:';
5
+ const META_COUNTER = 'meta:counter';
6
+ const STATS_PREFIX = 'stats:';
7
+ const GEO_PREFIX = 'geo:';
8
+ const DEVICE_PREFIX = 'device:';
9
+ const REFERRER_PREFIX = 'referrer:';
10
+ const INDEX_KEY = '_url_index';
11
+
12
+ /** Get a ShortUrl by code. */
13
+ export async function getUrl(kv: KVNamespace, code: string): Promise<ShortUrl | null> {
14
+ const raw = await kv.get(`${URL_PREFIX}${code}`, 'text');
15
+ if (!raw) return null;
16
+ return JSON.parse(raw) as ShortUrl;
17
+ }
18
+
19
+ /** Store a ShortUrl by code and add to index. */
20
+ export async function putUrl(kv: KVNamespace, code: string, data: ShortUrl): Promise<void> {
21
+ await kv.put(`${URL_PREFIX}${code}`, JSON.stringify(data));
22
+ await addToIndex(kv, code);
23
+ }
24
+
25
+ /** Delete a ShortUrl by code and remove from index. */
26
+ export async function deleteUrl(kv: KVNamespace, code: string): Promise<boolean> {
27
+ const existing = await kv.get(`${URL_PREFIX}${code}`, 'text');
28
+ if (!existing) return false;
29
+ await kv.delete(`${URL_PREFIX}${code}`);
30
+ await removeFromIndex(kv, code);
31
+ return true;
32
+ }
33
+
34
+ /** List all stored ShortUrl entries. */
35
+ export async function listUrls(kv: KVNamespace): Promise<ShortUrl[]> {
36
+ const codes = await getIndex(kv);
37
+ const entries: ShortUrl[] = [];
38
+ for (const code of codes) {
39
+ const entry = await getUrl(kv, code);
40
+ if (entry) entries.push(entry);
41
+ }
42
+ return entries;
43
+ }
44
+
45
+ /** Get the current counter value. */
46
+ export async function getCounter(kv: KVNamespace): Promise<number> {
47
+ const raw = await kv.get(META_COUNTER, 'text');
48
+ if (!raw) return 0;
49
+ return parseInt(raw, 10);
50
+ }
51
+
52
+ /** Increment the counter and return the new value. */
53
+ export async function incrementCounter(kv: KVNamespace): Promise<number> {
54
+ const current = await getCounter(kv);
55
+ const next = current + 1;
56
+ await kv.put(META_COUNTER, String(next));
57
+ return next;
58
+ }
59
+
60
+ /**
61
+ * Increment a statistics counter for a link.
62
+ * @param type - 'total' | 'daily' | 'geo' | 'device' | 'referrer'
63
+ * @param key - The sub-key (e.g., date string, country code, device type, referrer domain).
64
+ * Ignored for 'total' type.
65
+ */
66
+ export async function incrementStat(
67
+ kv: KVNamespace,
68
+ code: string,
69
+ type: 'total' | 'daily' | 'geo' | 'device' | 'referrer',
70
+ key?: string,
71
+ ): Promise<void> {
72
+ let kvKey: string;
73
+ switch (type) {
74
+ case 'total':
75
+ kvKey = `${STATS_PREFIX}${code}:total`;
76
+ break;
77
+ case 'daily':
78
+ kvKey = `${STATS_PREFIX}${code}:daily:${key}`;
79
+ break;
80
+ case 'geo':
81
+ kvKey = `${GEO_PREFIX}${code}:${key}`;
82
+ break;
83
+ case 'device':
84
+ kvKey = `${DEVICE_PREFIX}${code}:${key}`;
85
+ break;
86
+ case 'referrer':
87
+ kvKey = `${REFERRER_PREFIX}${code}:${key}`;
88
+ break;
89
+ }
90
+ const raw = await kv.get(kvKey, 'text');
91
+ const current = raw ? parseInt(raw, 10) : 0;
92
+ await kv.put(kvKey, String(current + 1));
93
+ }
94
+
95
+ /** Assemble LinkStats for a given code from multiple KV keys. */
96
+ export async function getStats(kv: KVNamespace, code: string): Promise<LinkStats> {
97
+ const stats: LinkStats = {
98
+ total: 0,
99
+ daily: {},
100
+ geo: {},
101
+ device: {},
102
+ referrer: {},
103
+ };
104
+
105
+ // Total clicks
106
+ const totalRaw = await kv.get(`${STATS_PREFIX}${code}:total`, 'text');
107
+ if (totalRaw) stats.total = parseInt(totalRaw, 10);
108
+
109
+ // Daily stats — list keys with prefix
110
+ const dailyKeys = await kv.list({ prefix: `${STATS_PREFIX}${code}:daily:` });
111
+ for (const key of dailyKeys.keys) {
112
+ const dateStr = key.name.replace(`${STATS_PREFIX}${code}:daily:`, '');
113
+ const val = await kv.get(key.name, 'text');
114
+ if (val) stats.daily[dateStr] = parseInt(val, 10);
115
+ }
116
+
117
+ // Geo stats
118
+ const geoKeys = await kv.list({ prefix: `${GEO_PREFIX}${code}:` });
119
+ for (const key of geoKeys.keys) {
120
+ const country = key.name.replace(`${GEO_PREFIX}${code}:`, '');
121
+ const val = await kv.get(key.name, 'text');
122
+ if (val) stats.geo[country] = parseInt(val, 10);
123
+ }
124
+
125
+ // Device stats
126
+ const deviceKeys = await kv.list({ prefix: `${DEVICE_PREFIX}${code}:` });
127
+ for (const key of deviceKeys.keys) {
128
+ const device = key.name.replace(`${DEVICE_PREFIX}${code}:`, '');
129
+ const val = await kv.get(key.name, 'text');
130
+ if (val) stats.device[device] = parseInt(val, 10);
131
+ }
132
+
133
+ // Referrer stats
134
+ const referrerKeys = await kv.list({ prefix: `${REFERRER_PREFIX}${code}:` });
135
+ for (const key of referrerKeys.keys) {
136
+ const referrer = key.name.replace(`${REFERRER_PREFIX}${code}:`, '');
137
+ const val = await kv.get(key.name, 'text');
138
+ if (val) stats.referrer[referrer] = parseInt(val, 10);
139
+ }
140
+
141
+ return stats;
142
+ }
143
+
144
+ // --- Index helpers ---
145
+
146
+ async function getIndex(kv: KVNamespace): Promise<string[]> {
147
+ const raw = await kv.get(INDEX_KEY, 'text');
148
+ if (!raw) return [];
149
+ return JSON.parse(raw) as string[];
150
+ }
151
+
152
+ async function addToIndex(kv: KVNamespace, code: string): Promise<void> {
153
+ const index = await getIndex(kv);
154
+ if (!index.includes(code)) {
155
+ index.push(code);
156
+ await kv.put(INDEX_KEY, JSON.stringify(index));
157
+ }
158
+ }
159
+
160
+ async function removeFromIndex(kv: KVNamespace, code: string): Promise<void> {
161
+ const index = await getIndex(kv);
162
+ const filtered = index.filter((c) => c !== code);
163
+ await kv.put(INDEX_KEY, JSON.stringify(filtered));
164
+ }
@@ -0,0 +1,55 @@
1
+ import type { Context, Next } from 'hono';
2
+ import type { Env } from '../types.js';
3
+
4
+ /** Public route patterns that do not require authentication. */
5
+ const PUBLIC_PATTERNS = [
6
+ /^\/health$/,
7
+ /^\/password\/[^/]+$/,
8
+ /^\/[^/]+$/, // GET /:slug redirect
9
+ ];
10
+
11
+ /**
12
+ * Bearer token authentication middleware.
13
+ * Checks `Authorization: Bearer <token>` header against `env.API_TOKEN`.
14
+ * Skips auth for public routes (health, redirect, password).
15
+ */
16
+ export async function authMiddleware(
17
+ c: Context<{ Bindings: Env }>,
18
+ next: Next,
19
+ ): Promise<undefined | Response> {
20
+ const path = new URL(c.req.url).pathname;
21
+ const method = c.req.method;
22
+
23
+ // Skip auth for public routes
24
+ if (method === 'GET') {
25
+ for (const pattern of PUBLIC_PATTERNS) {
26
+ if (pattern.test(path)) {
27
+ await next();
28
+ return;
29
+ }
30
+ }
31
+ }
32
+
33
+ // POST /password/:code is also public
34
+ if (method === 'POST' && /^\/password\/[^/]+$/.test(path)) {
35
+ await next();
36
+ return;
37
+ }
38
+
39
+ const authHeader = c.req.header('Authorization');
40
+ if (!authHeader) {
41
+ return c.json({ error: 'Missing Authorization header' }, 401);
42
+ }
43
+
44
+ const parts = authHeader.split(' ');
45
+ if (parts.length !== 2 || parts[0] !== 'Bearer') {
46
+ return c.json({ error: 'Invalid Authorization format, expected: Bearer <token>' }, 401);
47
+ }
48
+
49
+ const token = parts[1];
50
+ if (token !== c.env.API_TOKEN) {
51
+ return c.json({ error: 'Invalid API token' }, 401);
52
+ }
53
+
54
+ await next();
55
+ }
@@ -0,0 +1,5 @@
1
+ import type { Context } from 'hono';
2
+
3
+ export function handleHealth(c: Context): Response {
4
+ return c.json({ status: 'ok', timestamp: new Date().toISOString() });
5
+ }
@@ -0,0 +1,49 @@
1
+ import type { ShortUrl } from '@clipr/core';
2
+ import type { Context } from 'hono';
3
+ import { listUrls, putUrl } from '../kv.js';
4
+ import type { Env } from '../types.js';
5
+
6
+ /** POST /api/import — Bulk import entries from a JSON array. */
7
+ export async function handleImport(c: Context<{ Bindings: Env }>): Promise<Response> {
8
+ let entries: ShortUrl[];
9
+ try {
10
+ entries = await c.req.json<ShortUrl[]>();
11
+ } catch {
12
+ return c.json({ error: 'Invalid JSON body, expected an array of ShortUrl entries' }, 400);
13
+ }
14
+
15
+ if (!Array.isArray(entries)) {
16
+ return c.json({ error: 'Request body must be a JSON array' }, 400);
17
+ }
18
+
19
+ let imported = 0;
20
+ const errors: string[] = [];
21
+
22
+ for (const entry of entries) {
23
+ if (!entry.slug || !entry.url) {
24
+ errors.push(`Skipped entry: missing slug or url`);
25
+ continue;
26
+ }
27
+ try {
28
+ await putUrl(c.env.URLS, entry.slug, {
29
+ ...entry,
30
+ createdAt: entry.createdAt || new Date().toISOString(),
31
+ });
32
+ imported++;
33
+ } catch (err) {
34
+ const msg = err instanceof Error ? err.message : String(err);
35
+ errors.push(`Failed to import "${entry.slug}": ${msg}`);
36
+ }
37
+ }
38
+
39
+ return c.json({ imported, total: entries.length, errors }, 200);
40
+ }
41
+
42
+ /** GET /api/export — Export all entries as a JSON array. */
43
+ export async function handleExport(c: Context<{ Bindings: Env }>): Promise<Response> {
44
+ const entries = await listUrls(c.env.URLS);
45
+
46
+ return c.json(entries, 200, {
47
+ 'Content-Disposition': 'attachment; filename="clipr-export.json"',
48
+ });
49
+ }
@@ -0,0 +1,98 @@
1
+ import type { ShortUrl } from '@clipr/core';
2
+ import type { Context } from 'hono';
3
+ import { deleteUrl, getUrl, listUrls, putUrl } from '../kv.js';
4
+ import type { Env } from '../types.js';
5
+
6
+ /** GET /api/links — List all links with optional filtering. */
7
+ export async function handleListLinks(c: Context<{ Bindings: Env }>): Promise<Response> {
8
+ const tag = c.req.query('tag');
9
+ const search = c.req.query('search');
10
+ const limitStr = c.req.query('limit');
11
+ const limit = limitStr ? parseInt(limitStr, 10) : undefined;
12
+
13
+ let entries = await listUrls(c.env.URLS);
14
+
15
+ // Filter by tag
16
+ if (tag) {
17
+ entries = entries.filter((e) => e.tags?.includes(tag));
18
+ }
19
+
20
+ // Search across slug, url, description
21
+ if (search) {
22
+ const q = search.toLowerCase();
23
+ entries = entries.filter(
24
+ (e) =>
25
+ e.slug.toLowerCase().includes(q) ||
26
+ e.url.toLowerCase().includes(q) ||
27
+ e.description?.toLowerCase().includes(q),
28
+ );
29
+ }
30
+
31
+ // Apply limit
32
+ if (limit && limit > 0) {
33
+ entries = entries.slice(0, limit);
34
+ }
35
+
36
+ return c.json(entries);
37
+ }
38
+
39
+ /** GET /api/links/:code — Get a single link entry. */
40
+ export async function handleGetLink(c: Context<{ Bindings: Env }>): Promise<Response> {
41
+ const code = c.req.param('code');
42
+ if (!code) {
43
+ return c.json({ error: 'Missing code parameter' }, 400);
44
+ }
45
+
46
+ const entry = await getUrl(c.env.URLS, code);
47
+ if (!entry) {
48
+ return c.json({ error: `Link "${code}" not found` }, 404);
49
+ }
50
+
51
+ return c.json(entry);
52
+ }
53
+
54
+ /** PUT /api/links/:code — Update an existing link (partial updates). */
55
+ export async function handleUpdateLink(c: Context<{ Bindings: Env }>): Promise<Response> {
56
+ const code = c.req.param('code');
57
+ if (!code) {
58
+ return c.json({ error: 'Missing code parameter' }, 400);
59
+ }
60
+
61
+ const existing = await getUrl(c.env.URLS, code);
62
+ if (!existing) {
63
+ return c.json({ error: `Link "${code}" not found` }, 404);
64
+ }
65
+
66
+ let updates: Partial<ShortUrl>;
67
+ try {
68
+ updates = await c.req.json<Partial<ShortUrl>>();
69
+ } catch {
70
+ return c.json({ error: 'Invalid JSON body' }, 400);
71
+ }
72
+
73
+ // Merge updates into existing entry (slug and createdAt are immutable)
74
+ const updated: ShortUrl = {
75
+ ...existing,
76
+ ...updates,
77
+ slug: existing.slug, // never change slug
78
+ createdAt: existing.createdAt, // never change creation time
79
+ };
80
+
81
+ await putUrl(c.env.URLS, code, updated);
82
+ return c.json(updated);
83
+ }
84
+
85
+ /** DELETE /api/links/:code — Delete a link. */
86
+ export async function handleDeleteLink(c: Context<{ Bindings: Env }>): Promise<Response> {
87
+ const code = c.req.param('code');
88
+ if (!code) {
89
+ return c.json({ error: 'Missing code parameter' }, 400);
90
+ }
91
+
92
+ const deleted = await deleteUrl(c.env.URLS, code);
93
+ if (!deleted) {
94
+ return c.json({ error: `Link "${code}" not found` }, 404);
95
+ }
96
+
97
+ return c.json({ ok: true, deleted: code });
98
+ }