@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.
@@ -0,0 +1,117 @@
1
+ import { appendUtm, hasUtm } from '@clipr/core';
2
+ import type { Context } from 'hono';
3
+ import { verifyPassword } from '../crypto.js';
4
+ import { getUrl } from '../kv.js';
5
+ import type { Env } from '../types.js';
6
+
7
+ /** Escape HTML special characters to prevent XSS. */
8
+ function escapeHtml(text: string): string {
9
+ return text
10
+ .replace(/&/g, '&')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#039;');
15
+ }
16
+
17
+ /** Render a simple HTML password form. */
18
+ function renderPasswordPage(code: string, error?: string): string {
19
+ return `<!DOCTYPE html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="utf-8" />
23
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
24
+ <title>Password Required - clipr</title>
25
+ <style>
26
+ * { box-sizing: border-box; margin: 0; padding: 0; }
27
+ body {
28
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
29
+ display: flex; justify-content: center; align-items: center;
30
+ min-height: 100vh; background: #f5f5f5; color: #333;
31
+ }
32
+ .card {
33
+ background: #fff; border-radius: 8px; padding: 2rem;
34
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1); max-width: 400px; width: 100%;
35
+ }
36
+ h1 { font-size: 1.25rem; margin-bottom: 1rem; }
37
+ .error { color: #dc3545; font-size: 0.875rem; margin-bottom: 1rem; }
38
+ label { display: block; font-size: 0.875rem; margin-bottom: 0.5rem; font-weight: 500; }
39
+ input[type="password"] {
40
+ width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd;
41
+ border-radius: 4px; font-size: 1rem; margin-bottom: 1rem;
42
+ }
43
+ button {
44
+ width: 100%; padding: 0.625rem; background: #111; color: #fff;
45
+ border: none; border-radius: 4px; font-size: 1rem; cursor: pointer;
46
+ }
47
+ button:hover { background: #333; }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div class="card">
52
+ <h1>This link is password-protected</h1>
53
+ ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
54
+ <form method="POST" action="/password/${escapeHtml(code)}">
55
+ <label for="password">Password</label>
56
+ <input type="password" id="password" name="password" required autofocus />
57
+ <button type="submit">Continue</button>
58
+ </form>
59
+ </div>
60
+ </body>
61
+ </html>`;
62
+ }
63
+
64
+ /** GET /password/:code — Render password form. */
65
+ export async function handlePasswordPage(c: Context<{ Bindings: Env }>): Promise<Response> {
66
+ const code = c.req.param('code');
67
+ if (!code) {
68
+ return c.text('Not Found', 404);
69
+ }
70
+
71
+ const entry = await getUrl(c.env.URLS, code);
72
+ if (!entry) {
73
+ return c.text('Not Found', 404);
74
+ }
75
+
76
+ if (!entry.passwordHash) {
77
+ // Not password-protected, redirect directly
78
+ const target = hasUtm(entry.utm) ? appendUtm(entry.url, entry.utm) : entry.url;
79
+ return c.redirect(target, 301);
80
+ }
81
+
82
+ return c.html(renderPasswordPage(code));
83
+ }
84
+
85
+ /** POST /password/:code — Verify password and redirect or show error. */
86
+ export async function handlePasswordVerify(c: Context<{ Bindings: Env }>): Promise<Response> {
87
+ const code = c.req.param('code');
88
+ if (!code) {
89
+ return c.text('Not Found', 404);
90
+ }
91
+
92
+ const entry = await getUrl(c.env.URLS, code);
93
+ if (!entry) {
94
+ return c.text('Not Found', 404);
95
+ }
96
+
97
+ if (!entry.passwordHash) {
98
+ const target = hasUtm(entry.utm) ? appendUtm(entry.url, entry.utm) : entry.url;
99
+ return c.redirect(target, 301);
100
+ }
101
+
102
+ // Parse form body
103
+ const body = await c.req.parseBody();
104
+ const password = body.password;
105
+ if (typeof password !== 'string' || !password) {
106
+ return c.html(renderPasswordPage(code, 'Password is required.'), 400);
107
+ }
108
+
109
+ const valid = await verifyPassword(password, entry.passwordHash);
110
+ if (!valid) {
111
+ return c.html(renderPasswordPage(code, 'Incorrect password. Please try again.'), 403);
112
+ }
113
+
114
+ // Password correct — redirect to the target
115
+ const target = hasUtm(entry.utm) ? appendUtm(entry.url, entry.utm) : entry.url;
116
+ return c.redirect(target, 301);
117
+ }
@@ -0,0 +1,39 @@
1
+ import type { Context } from 'hono';
2
+ import { getUrl } from '../kv.js';
3
+ import type { Env } from '../types.js';
4
+ import { generateQr, type QrFormat } from '../utils/qr.js';
5
+
6
+ /** GET /api/qr/:code — Generate a QR code for the short URL. */
7
+ export async function handleQr(c: Context<{ Bindings: Env }>): Promise<Response> {
8
+ const code = c.req.param('code');
9
+ if (!code) {
10
+ return c.json({ error: 'Missing code parameter' }, 400);
11
+ }
12
+
13
+ // Verify the link exists
14
+ const entry = await getUrl(c.env.URLS, code);
15
+ if (!entry) {
16
+ return c.json({ error: `Link "${code}" not found` }, 404);
17
+ }
18
+
19
+ const format = (c.req.query('format') || 'svg') as QrFormat;
20
+ if (format !== 'svg' && format !== 'png') {
21
+ return c.json({ error: 'Invalid format, must be "svg" or "png"' }, 400);
22
+ }
23
+
24
+ const sizeStr = c.req.query('size');
25
+ const size = sizeStr ? parseInt(sizeStr, 10) : 256;
26
+ if (Number.isNaN(size) || size < 32 || size > 2048) {
27
+ return c.json({ error: 'Invalid size, must be between 32 and 2048' }, 400);
28
+ }
29
+
30
+ const shortUrl = `${c.env.BASE_URL}/${code}`;
31
+ const { data, contentType } = await generateQr(shortUrl, format, size);
32
+
33
+ return new Response(data as BodyInit, {
34
+ headers: {
35
+ 'Content-Type': contentType,
36
+ 'Cache-Control': 'public, max-age=3600',
37
+ },
38
+ });
39
+ }
@@ -0,0 +1,127 @@
1
+ import type { UrlEntry } from '@clipr/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import app from '../index.js';
4
+ import { createMockKV } from '../test-utils.js';
5
+
6
+ async function seedKV(kv: KVNamespace, entry: UrlEntry): Promise<void> {
7
+ // Use url: prefix to match kv.ts getUrl()
8
+ await kv.put(`url:${entry.slug}`, JSON.stringify(entry));
9
+ // Update the url index
10
+ const raw = await kv.get('_url_index', 'text');
11
+ const index: string[] = raw ? JSON.parse(raw) : [];
12
+ index.push(entry.slug);
13
+ await kv.put('_url_index', JSON.stringify(index));
14
+ }
15
+
16
+ function makeRequest(path: string, kv: KVNamespace): Promise<Response> {
17
+ return app.request(path, {}, { URLS: kv, API_TOKEN: 'test-token', BASE_URL: 'https://clpr.sh' });
18
+ }
19
+
20
+ describe('redirect route', () => {
21
+ it('redirects to the target URL (301)', async () => {
22
+ const kv = createMockKV();
23
+ await seedKV(kv, {
24
+ slug: 'test',
25
+ url: 'https://example.com',
26
+ createdAt: new Date().toISOString(),
27
+ });
28
+
29
+ const res = await makeRequest('/test', kv);
30
+ expect(res.status).toBe(301);
31
+ expect(res.headers.get('Location')).toBe('https://example.com');
32
+ expect(res.headers.get('Cache-Control')).toBe('public, max-age=300');
33
+ });
34
+
35
+ it('appends UTM params on redirect', async () => {
36
+ const kv = createMockKV();
37
+ await seedKV(kv, {
38
+ slug: 'utm',
39
+ url: 'https://example.com',
40
+ createdAt: new Date().toISOString(),
41
+ utm: { utm_source: 'twitter', utm_medium: 'social' },
42
+ });
43
+
44
+ const res = await makeRequest('/utm', kv);
45
+ expect(res.status).toBe(301);
46
+ const location = res.headers.get('Location')!;
47
+ const url = new URL(location);
48
+ expect(url.searchParams.get('utm_source')).toBe('twitter');
49
+ expect(url.searchParams.get('utm_medium')).toBe('social');
50
+ });
51
+
52
+ it('returns 404 for missing slugs', async () => {
53
+ const kv = createMockKV();
54
+ const res = await makeRequest('/missing', kv);
55
+ expect(res.status).toBe(404);
56
+ });
57
+
58
+ it('returns 410 for expired links', async () => {
59
+ const kv = createMockKV();
60
+ await seedKV(kv, {
61
+ slug: 'old',
62
+ url: 'https://example.com',
63
+ createdAt: new Date().toISOString(),
64
+ expiresAt: '2020-01-01T00:00:00Z',
65
+ });
66
+
67
+ const res = await makeRequest('/old', kv);
68
+ expect(res.status).toBe(410);
69
+ });
70
+
71
+ it('redirects non-expired links with expiresAt', async () => {
72
+ const kv = createMockKV();
73
+ const future = new Date(Date.now() + 86400000).toISOString();
74
+ await seedKV(kv, {
75
+ slug: 'future',
76
+ url: 'https://example.com',
77
+ createdAt: new Date().toISOString(),
78
+ expiresAt: future,
79
+ });
80
+
81
+ const res = await makeRequest('/future', kv);
82
+ expect(res.status).toBe(301);
83
+ });
84
+ });
85
+
86
+ describe('health route', () => {
87
+ it('returns 200 with status ok', async () => {
88
+ const kv = createMockKV();
89
+ const res = await makeRequest('/health', kv);
90
+ expect(res.status).toBe(200);
91
+ const body = await res.json();
92
+ expect(body.status).toBe('ok');
93
+ });
94
+ });
95
+
96
+ describe('404 handler', () => {
97
+ it('returns 401 for deep paths without auth', async () => {
98
+ const kv = createMockKV();
99
+ const res = await makeRequest('/some/deep/path', kv);
100
+ // Deep paths are not public routes, so auth middleware blocks them
101
+ expect(res.status).toBe(401);
102
+ });
103
+
104
+ it('returns 404 for non-existent single-segment slugs', async () => {
105
+ const kv = createMockKV();
106
+ const res = await makeRequest('/nonexistent', kv);
107
+ expect(res.status).toBe(404);
108
+ });
109
+ });
110
+
111
+ describe('API auth', () => {
112
+ it('returns 401 for API routes without token', async () => {
113
+ const kv = createMockKV();
114
+ const res = app.request('/api/links', {}, { URLS: kv, API_TOKEN: 'secret', BASE_URL: '' });
115
+ expect((await res).status).toBe(401);
116
+ });
117
+
118
+ it('allows API routes with valid token', async () => {
119
+ const kv = createMockKV();
120
+ const res = await app.request(
121
+ '/api/links',
122
+ { headers: { Authorization: 'Bearer secret' } },
123
+ { URLS: kv, API_TOKEN: 'secret', BASE_URL: '' },
124
+ );
125
+ expect(res.status).toBe(200);
126
+ });
127
+ });
@@ -0,0 +1,87 @@
1
+ import { appendUtm, hasUtm } from '@clipr/core';
2
+ import type { Context } from 'hono';
3
+ import { getUrl, incrementStat } from '../kv.js';
4
+ import type { Env } from '../types.js';
5
+
6
+ /** Check if a URL entry has expired. */
7
+ function isExpired(expiresAt: string | undefined): boolean {
8
+ if (!expiresAt) return false;
9
+ return new Date(expiresAt).getTime() < Date.now();
10
+ }
11
+
12
+ /** Extract a simple device type from User-Agent. */
13
+ function parseDevice(ua: string | undefined): string {
14
+ if (!ua) return 'unknown';
15
+ const lower = ua.toLowerCase();
16
+ if (lower.includes('mobile') || lower.includes('android') || lower.includes('iphone'))
17
+ return 'mobile';
18
+ if (lower.includes('tablet') || lower.includes('ipad')) return 'tablet';
19
+ return 'desktop';
20
+ }
21
+
22
+ /** Extract referrer domain from Referer header. */
23
+ function parseReferrer(referer: string | undefined): string {
24
+ if (!referer) return 'direct';
25
+ try {
26
+ return new URL(referer).hostname;
27
+ } catch {
28
+ return 'unknown';
29
+ }
30
+ }
31
+
32
+ export async function handleRedirect(c: Context<{ Bindings: Env }>): Promise<Response> {
33
+ const slug = c.req.param('slug');
34
+ if (!slug) {
35
+ return c.text('Not Found', 404);
36
+ }
37
+
38
+ const entry = await getUrl(c.env.URLS, slug);
39
+
40
+ if (!entry) {
41
+ return c.text('Not Found', 404);
42
+ }
43
+
44
+ if (isExpired(entry.expiresAt)) {
45
+ return c.text('This link has expired', 410);
46
+ }
47
+
48
+ // Password-protected links redirect to the password page
49
+ if (entry.passwordHash) {
50
+ return c.redirect(`/password/${slug}`, 302);
51
+ }
52
+
53
+ const target = hasUtm(entry.utm) ? appendUtm(entry.url, entry.utm) : entry.url;
54
+
55
+ // Non-blocking analytics via waitUntil
56
+ let ctx: { waitUntil: (p: Promise<unknown>) => void } | undefined;
57
+ try {
58
+ ctx = c.executionCtx;
59
+ } catch {
60
+ // executionCtx not available in test environment
61
+ }
62
+ if (ctx && 'waitUntil' in ctx) {
63
+ const kv = c.env.URLS;
64
+ const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
65
+ const country = c.req.header('cf-ipcountry') || 'unknown';
66
+ const device = parseDevice(c.req.header('user-agent'));
67
+ const referrer = parseReferrer(c.req.header('referer'));
68
+
69
+ ctx.waitUntil(
70
+ Promise.all([
71
+ incrementStat(kv, slug, 'total'),
72
+ incrementStat(kv, slug, 'daily', date),
73
+ incrementStat(kv, slug, 'geo', country),
74
+ incrementStat(kv, slug, 'device', device),
75
+ incrementStat(kv, slug, 'referrer', referrer),
76
+ ]),
77
+ );
78
+ }
79
+
80
+ return new Response(null, {
81
+ status: 301,
82
+ headers: {
83
+ Location: target,
84
+ 'Cache-Control': 'public, max-age=300',
85
+ },
86
+ });
87
+ }
@@ -0,0 +1,85 @@
1
+ import type { ShortUrl, UtmParams } from '@clipr/core';
2
+ import { generateSlug, validateSlug, validateUrl } from '@clipr/core';
3
+ import type { Context } from 'hono';
4
+ import { hashPassword } from '../crypto.js';
5
+ import { getUrl, incrementCounter, putUrl } from '../kv.js';
6
+ import type { Env } from '../types.js';
7
+
8
+ interface ShortenBody {
9
+ url: string;
10
+ slug?: string;
11
+ tags?: string[];
12
+ expiresAt?: string;
13
+ password?: string;
14
+ description?: string;
15
+ utm?: UtmParams;
16
+ ogTitle?: string;
17
+ ogDescription?: string;
18
+ ogImage?: string;
19
+ }
20
+
21
+ /** POST /api/shorten — Create a new short URL. */
22
+ export async function handleShorten(c: Context<{ Bindings: Env }>): Promise<Response> {
23
+ let body: ShortenBody;
24
+ try {
25
+ body = await c.req.json<ShortenBody>();
26
+ } catch {
27
+ return c.json({ error: 'Invalid JSON body' }, 400);
28
+ }
29
+
30
+ if (!body.url) {
31
+ return c.json({ error: 'Missing required field: url' }, 400);
32
+ }
33
+
34
+ // Validate URL
35
+ const urlResult = validateUrl(body.url);
36
+ if (!urlResult.valid) {
37
+ return c.json({ error: `Invalid URL: ${urlResult.reason}` }, 400);
38
+ }
39
+
40
+ // Determine slug
41
+ let slug: string;
42
+ if (body.slug) {
43
+ const slugResult = validateSlug(body.slug);
44
+ if (!slugResult.valid) {
45
+ return c.json({ error: `Invalid slug: ${slugResult.reason}` }, 400);
46
+ }
47
+ slug = body.slug;
48
+
49
+ // Check for conflicts
50
+ const existing = await getUrl(c.env.URLS, slug);
51
+ if (existing) {
52
+ return c.json({ error: `Slug "${slug}" already exists` }, 409);
53
+ }
54
+ } else {
55
+ // Auto-generate slug from counter
56
+ const counter = await incrementCounter(c.env.URLS);
57
+ slug = generateSlug(counter);
58
+ }
59
+
60
+ // Hash password if provided
61
+ let passwordHash: string | undefined;
62
+ if (body.password) {
63
+ passwordHash = await hashPassword(body.password);
64
+ }
65
+
66
+ const now = new Date().toISOString();
67
+ const entry: ShortUrl = {
68
+ slug,
69
+ url: body.url,
70
+ createdAt: now,
71
+ ...(body.expiresAt && { expiresAt: body.expiresAt }),
72
+ ...(body.description && { description: body.description }),
73
+ ...(body.tags && { tags: body.tags }),
74
+ ...(body.utm && { utm: body.utm }),
75
+ ...(passwordHash && { passwordHash }),
76
+ ...(body.ogTitle && { ogTitle: body.ogTitle }),
77
+ ...(body.ogDescription && { ogDescription: body.ogDescription }),
78
+ ...(body.ogImage && { ogImage: body.ogImage }),
79
+ };
80
+
81
+ await putUrl(c.env.URLS, slug, entry);
82
+
83
+ const shortUrl = `${c.env.BASE_URL}/${slug}`;
84
+ return c.json({ slug, shortUrl, url: body.url }, 201);
85
+ }
@@ -0,0 +1,20 @@
1
+ import type { Context } from 'hono';
2
+ import { getStats, getUrl } from '../kv.js';
3
+ import type { Env } from '../types.js';
4
+
5
+ /** GET /api/stats/:code — Returns LinkStats JSON for a link. */
6
+ export async function handleStats(c: Context<{ Bindings: Env }>): Promise<Response> {
7
+ const code = c.req.param('code');
8
+ if (!code) {
9
+ return c.json({ error: 'Missing code parameter' }, 400);
10
+ }
11
+
12
+ // Verify the link exists
13
+ const entry = await getUrl(c.env.URLS, code);
14
+ if (!entry) {
15
+ return c.json({ error: `Link "${code}" not found` }, 404);
16
+ }
17
+
18
+ const stats = await getStats(c.env.URLS, code);
19
+ return c.json({ code, ...stats });
20
+ }
@@ -0,0 +1,29 @@
1
+ /** In-memory KVNamespace mock for testing. */
2
+ export function createMockKV(): KVNamespace {
3
+ const store = new Map<string, string>();
4
+
5
+ return {
6
+ get(key: string, _opts?: unknown): Promise<string | null> {
7
+ return Promise.resolve(store.get(key) ?? null);
8
+ },
9
+ put(key: string, value: string): Promise<void> {
10
+ store.set(key, value);
11
+ return Promise.resolve();
12
+ },
13
+ delete(key: string): Promise<void> {
14
+ store.delete(key);
15
+ return Promise.resolve();
16
+ },
17
+ list(): Promise<KVNamespaceListResult<unknown, string>> {
18
+ const keys = [...store.keys()].map((name) => ({ name }));
19
+ return Promise.resolve({
20
+ keys,
21
+ list_complete: true,
22
+ cacheStatus: null,
23
+ } as KVNamespaceListResult<unknown, string>);
24
+ },
25
+ getWithMetadata(): Promise<KVNamespaceGetWithMetadataResult<string, unknown>> {
26
+ return Promise.resolve({ value: null, metadata: null, cacheStatus: null });
27
+ },
28
+ } as unknown as KVNamespace;
29
+ }
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Cloudflare Worker environment bindings. */
2
+ export interface Env {
3
+ URLS: KVNamespace;
4
+ API_TOKEN: string;
5
+ BASE_URL: string;
6
+ }
@@ -0,0 +1,35 @@
1
+ import QRCode from 'qrcode';
2
+
3
+ export type QrFormat = 'svg' | 'png';
4
+
5
+ /**
6
+ * Generate a QR code for the given text.
7
+ * @param text - The content to encode (typically a URL).
8
+ * @param format - Output format: 'svg' or 'png'.
9
+ * @param size - Width/height in pixels (for PNG) or module count hint.
10
+ * @returns The QR code as a string (SVG) or Buffer (PNG).
11
+ */
12
+ export async function generateQr(
13
+ text: string,
14
+ format: QrFormat = 'svg',
15
+ size: number = 256,
16
+ ): Promise<{ data: string | Buffer; contentType: string }> {
17
+ if (format === 'svg') {
18
+ const svg = await QRCode.toString(text, {
19
+ type: 'svg',
20
+ width: size,
21
+ margin: 2,
22
+ });
23
+ return { data: svg, contentType: 'image/svg+xml' };
24
+ }
25
+
26
+ // PNG format
27
+ const dataUrl = await QRCode.toDataURL(text, {
28
+ width: size,
29
+ margin: 2,
30
+ });
31
+ // Strip the data:image/png;base64, prefix and decode
32
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
33
+ const buffer = Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0));
34
+ return { data: buffer as unknown as Buffer, contentType: 'image/png' };
35
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tooling/typescript/tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["@cloudflare/workers-types"]
7
+ },
8
+ "include": ["src"]
9
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts'],
6
+ },
7
+ });
package/wrangler.toml ADDED
@@ -0,0 +1,8 @@
1
+ name = "clipr"
2
+ main = "src/index.ts"
3
+ compatibility_date = "2025-03-30"
4
+ compatibility_flags = ["nodejs_compat"]
5
+
6
+ [[kv_namespaces]]
7
+ binding = "URLS"
8
+ id = "1ed1615bdd234d23887b3c58b42b3dc4"