tangled
alpha
login
or
join now
dunkirk.sh
/
hop
6
fork
atom
blazing fast link redirects on cloudflare kv
hop.dunkirk.sh/u/tacy
6
fork
atom
overview
issues
pulls
pipelines
feat: add auth
dunkirk.sh
3 months ago
3373c34d
aa9f37e6
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+385
-21
4 changed files
expand all
collapse all
unified
split
src
index.html
index.ts
login.html
wrangler.toml
+44
-1
src/index.html
···
442
442
<section class="form-section">
443
443
<div class="shortcut">
444
444
<kbd>tab</kbd> navigate • <kbd>enter</kbd> submit •
445
445
-
<kbd>esc</kbd> clear • <kbd>cmd+k</kbd> focus
445
445
+
<kbd>esc</kbd> clear • <kbd>cmd+k</kbd> focus • <kbd>cmd+l</kbd> logout
446
446
</div>
447
447
<form id="shortenForm">
448
448
<div class="form-row">
···
474
474
</footer>
475
475
476
476
<script>
477
477
+
// Check for session token
478
478
+
const token = localStorage.getItem('hop_session');
479
479
+
if (!token) {
480
480
+
window.location.href = '/login';
481
481
+
}
482
482
+
483
483
+
// Add auth header to all API requests
484
484
+
const originalFetch = window.fetch;
485
485
+
window.fetch = function(...args) {
486
486
+
const [url, config] = args;
487
487
+
if (typeof url === 'string' && url.startsWith('/api/')) {
488
488
+
const token = localStorage.getItem('hop_session');
489
489
+
if (token) {
490
490
+
args[1] = {
491
491
+
...config,
492
492
+
headers: {
493
493
+
...(config?.headers || {}),
494
494
+
'Authorization': `Bearer ${token}`,
495
495
+
},
496
496
+
};
497
497
+
}
498
498
+
}
499
499
+
return originalFetch.apply(this, args).then(async (response) => {
500
500
+
if (response.status === 302 || response.status === 401) {
501
501
+
localStorage.removeItem('hop_session');
502
502
+
window.location.href = '/login';
503
503
+
}
504
504
+
return response;
505
505
+
});
506
506
+
};
507
507
+
477
508
const isReload =
478
509
performance.navigation.type === 1 ||
479
510
performance.getEntriesByType("navigation")[0]?.type === "reload";
···
516
547
slugInput.style.borderColor = "var(--border-color)";
517
548
urlInput.focus();
518
549
}
550
550
+
if (e.key === "l" && (e.metaKey || e.ctrlKey)) {
551
551
+
e.preventDefault();
552
552
+
logout();
553
553
+
}
519
554
if (
520
555
(e.key === "c" || e.key === "C") &&
521
556
document.getElementById("shortUrlInput")
···
526
561
}
527
562
}
528
563
});
564
564
+
565
565
+
async function logout() {
566
566
+
try {
567
567
+
await fetch('/api/logout', { method: 'POST' });
568
568
+
} catch (e) {}
569
569
+
localStorage.removeItem('hop_session');
570
570
+
window.location.href = '/login';
571
571
+
}
529
572
530
573
slugInput.addEventListener("input", (e) => {
531
574
clearTimeout(debounceTimer);
+127
-19
src/index.ts
···
1
1
import { nanoid } from 'nanoid';
2
2
import indexHTML from './index.html';
3
3
+
import loginHTML from './login.html';
3
4
4
5
export default {
5
6
async fetch(
···
9
10
): Promise<Response> {
10
11
const url = new URL(request.url);
11
12
13
13
+
// Public routes that don't require auth
14
14
+
if (url.pathname === '/login' && request.method === 'GET') {
15
15
+
return new Response(loginHTML, {
16
16
+
headers: { 'Content-Type': 'text/html' },
17
17
+
});
18
18
+
}
19
19
+
20
20
+
const isRedirect = url.pathname !== '/' && !url.pathname.startsWith('/api/');
21
21
+
if (isRedirect) {
22
22
+
const shortCode = url.pathname.slice(1);
23
23
+
const targetUrl = await env.HOP.get(shortCode);
24
24
+
25
25
+
if (targetUrl) {
26
26
+
return Response.redirect(targetUrl, 302);
27
27
+
}
28
28
+
29
29
+
return new Response('Short URL not found', {
30
30
+
status: 404,
31
31
+
headers: { 'Content-Type': 'text/plain' },
32
32
+
});
33
33
+
}
34
34
+
35
35
+
// Login endpoint
36
36
+
if (url.pathname === '/api/login' && request.method === 'POST') {
37
37
+
try {
38
38
+
const { password } = await request.json();
39
39
+
40
40
+
if (password !== env.AUTH_PASSWORD) {
41
41
+
return new Response(JSON.stringify({ error: 'Invalid password' }), {
42
42
+
status: 401,
43
43
+
headers: { 'Content-Type': 'application/json' },
44
44
+
});
45
45
+
}
46
46
+
47
47
+
// Generate session token
48
48
+
const token = await generateSessionToken();
49
49
+
const expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
50
50
+
51
51
+
// Store session in KV
52
52
+
await env.HOP.put(`session:${token}`, JSON.stringify({ expiresAt }), {
53
53
+
expirationTtl: 86400, // 24 hours
54
54
+
});
55
55
+
56
56
+
return new Response(JSON.stringify({ token }), {
57
57
+
headers: { 'Content-Type': 'application/json' },
58
58
+
});
59
59
+
} catch (error) {
60
60
+
return new Response(JSON.stringify({ error: 'Invalid request' }), {
61
61
+
status: 400,
62
62
+
headers: { 'Content-Type': 'application/json' },
63
63
+
});
64
64
+
}
65
65
+
}
66
66
+
67
67
+
// Logout endpoint
68
68
+
if (url.pathname === '/api/logout' && request.method === 'POST') {
69
69
+
const authHeader = request.headers.get('Authorization');
70
70
+
if (authHeader && authHeader.startsWith('Bearer ')) {
71
71
+
const token = authHeader.slice(7);
72
72
+
await env.HOP.delete(`session:${token}`);
73
73
+
}
74
74
+
return new Response(JSON.stringify({ success: true }), {
75
75
+
headers: { 'Content-Type': 'application/json' },
76
76
+
});
77
77
+
}
78
78
+
79
79
+
// Check auth for all other routes (except / which needs to load first)
80
80
+
if (url.pathname !== '/') {
81
81
+
const authHeader = request.headers.get('Authorization');
82
82
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
83
83
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
84
84
+
status: 401,
85
85
+
headers: { 'Content-Type': 'application/json' },
86
86
+
});
87
87
+
}
88
88
+
89
89
+
const token = authHeader.slice(7);
90
90
+
const sessionData = await env.HOP.get(`session:${token}`);
91
91
+
92
92
+
if (!sessionData) {
93
93
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
94
94
+
status: 401,
95
95
+
headers: { 'Content-Type': 'application/json' },
96
96
+
});
97
97
+
}
98
98
+
99
99
+
const session = JSON.parse(sessionData);
100
100
+
if (session.expiresAt < Date.now()) {
101
101
+
await env.HOP.delete(`session:${token}`);
102
102
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
103
103
+
status: 401,
104
104
+
headers: { 'Content-Type': 'application/json' },
105
105
+
});
106
106
+
}
107
107
+
}
108
108
+
12
109
if (url.pathname === '/' && request.method === 'GET') {
13
110
return new Response(indexHTML, {
14
111
headers: { 'Content-Type': 'text/html' },
···
28
125
29
126
const list = await env.HOP.list(listOptions);
30
127
128
128
+
// Clean up expired sessions in background
129
129
+
const now = Date.now();
130
130
+
const sessionKeys = list.keys.filter(key => key.name.startsWith('session:'));
131
131
+
for (const key of sessionKeys) {
132
132
+
const sessionData = await env.HOP.get(key.name);
133
133
+
if (sessionData) {
134
134
+
try {
135
135
+
const session = JSON.parse(sessionData);
136
136
+
if (session.expiresAt < now) {
137
137
+
ctx.waitUntil(env.HOP.delete(key.name));
138
138
+
}
139
139
+
} catch (e) {
140
140
+
// Invalid session data, delete it
141
141
+
ctx.waitUntil(env.HOP.delete(key.name));
142
142
+
}
143
143
+
}
144
144
+
}
145
145
+
31
146
let urls = await Promise.all(
32
32
-
list.keys.map(async (key) => ({
33
33
-
shortCode: key.name,
34
34
-
url: await env.HOP.get(key.name),
35
35
-
created: key.metadata?.created || Date.now(),
36
36
-
}))
147
147
+
list.keys
148
148
+
.filter(key => !key.name.startsWith('session:'))
149
149
+
.map(async (key) => ({
150
150
+
shortCode: key.name,
151
151
+
url: await env.HOP.get(key.name),
152
152
+
created: key.metadata?.created || Date.now(),
153
153
+
}))
37
154
);
38
155
39
156
// Filter by search term if provided
···
144
261
}
145
262
}
146
263
147
147
-
const shortCode = url.pathname.slice(1);
148
148
-
if (shortCode && !shortCode.startsWith('api/')) {
149
149
-
const targetUrl = await env.HOP.get(shortCode);
150
150
-
151
151
-
if (targetUrl) {
152
152
-
return Response.redirect(targetUrl, 302);
153
153
-
}
154
154
-
155
155
-
return new Response('Short URL not found', {
156
156
-
status: 404,
157
157
-
headers: { 'Content-Type': 'text/plain' },
158
158
-
});
159
159
-
}
160
160
-
161
264
return new Response('Not found', { status: 404 });
162
265
},
163
266
} satisfies ExportedHandler<Env>;
···
166
269
return nanoid(6);
167
270
}
168
271
272
272
+
async function generateSessionToken(): Promise<string> {
273
273
+
return nanoid(32);
274
274
+
}
275
275
+
169
276
interface Env {
170
277
HOP: KVNamespace;
278
278
+
AUTH_PASSWORD: string;
171
279
}
+210
src/login.html
···
1
1
+
<!doctype html>
2
2
+
<html lang="en">
3
3
+
4
4
+
<head>
5
5
+
<meta charset="UTF-8" />
6
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
+
<title>login // hop</title>
8
8
+
<link rel="icon"
9
9
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛎️</text></svg>" />
10
10
+
<style>
11
11
+
:root {
12
12
+
--bg-primary: oklch(23.17% 0.0113 115.0);
13
13
+
--text-primary: oklch(0.77 0.05 177.41);
14
14
+
--text-muted: oklch(61.43% 0.0603 149.4);
15
15
+
--accent-primary: oklch(82.83% 0.0539 158.7);
16
16
+
--accent-bright: oklch(0.81 0.05 164.12);
17
17
+
--input-bg: oklch(28.59% 0.0107 114.8);
18
18
+
--border-color: oklch(34.93% 0.0102 114.7);
19
19
+
--hover-bg: oklch(40.99% 0.0098 114.6);
20
20
+
--error-bg: oklch(58.72% 0.1531 28.0);
21
21
+
--error-text: oklch(73.36% 0.1635 27.9);
22
22
+
}
23
23
+
24
24
+
* {
25
25
+
margin: 0;
26
26
+
padding: 0;
27
27
+
box-sizing: border-box;
28
28
+
}
29
29
+
30
30
+
html {
31
31
+
background: var(--bg-primary);
32
32
+
}
33
33
+
34
34
+
body {
35
35
+
font-family: "Courier New", monospace;
36
36
+
background: var(--bg-primary);
37
37
+
color: var(--text-primary);
38
38
+
min-height: 100vh;
39
39
+
display: flex;
40
40
+
align-items: center;
41
41
+
justify-content: center;
42
42
+
padding: 1.25rem;
43
43
+
}
44
44
+
45
45
+
.login-container {
46
46
+
max-width: 25rem;
47
47
+
width: 100%;
48
48
+
}
49
49
+
50
50
+
h1 {
51
51
+
font-size: 2.5rem;
52
52
+
margin-bottom: 0.5rem;
53
53
+
font-weight: 700;
54
54
+
background: linear-gradient(135deg, var(--text-muted), var(--accent-primary), var(--accent-bright));
55
55
+
-webkit-background-clip: text;
56
56
+
-webkit-text-fill-color: transparent;
57
57
+
background-clip: text;
58
58
+
letter-spacing: -0.0625rem;
59
59
+
text-align: center;
60
60
+
}
61
61
+
62
62
+
.subtitle {
63
63
+
color: var(--text-muted);
64
64
+
margin-bottom: 2rem;
65
65
+
font-size: 0.8125rem;
66
66
+
font-family: monospace;
67
67
+
text-align: center;
68
68
+
}
69
69
+
70
70
+
form {
71
71
+
display: flex;
72
72
+
flex-direction: column;
73
73
+
gap: 0.75rem;
74
74
+
}
75
75
+
76
76
+
input {
77
77
+
width: 100%;
78
78
+
padding: 0.75rem 0.875rem;
79
79
+
background: var(--input-bg);
80
80
+
border: 0.0625rem solid var(--border-color);
81
81
+
border-radius: 0.25rem;
82
82
+
color: var(--text-primary);
83
83
+
font-size: 0.875rem;
84
84
+
font-family: "Courier New", monospace;
85
85
+
transition: border-color 0.15s;
86
86
+
}
87
87
+
88
88
+
input:focus {
89
89
+
outline: none;
90
90
+
border-color: var(--text-muted);
91
91
+
background: #2d2e28;
92
92
+
}
93
93
+
94
94
+
input::placeholder {
95
95
+
color: var(--hover-bg);
96
96
+
}
97
97
+
98
98
+
button {
99
99
+
width: 100%;
100
100
+
padding: 0.75rem;
101
101
+
background: var(--text-muted);
102
102
+
color: var(--bg-primary);
103
103
+
border: none;
104
104
+
border-radius: 0.25rem;
105
105
+
font-size: 0.875rem;
106
106
+
font-weight: 700;
107
107
+
cursor: pointer;
108
108
+
font-family: "Courier New", monospace;
109
109
+
transition: background 0.15s;
110
110
+
margin-top: 0.25rem;
111
111
+
}
112
112
+
113
113
+
button:hover {
114
114
+
background: #7a9e80;
115
115
+
}
116
116
+
117
117
+
button:active {
118
118
+
background: #5a7f61;
119
119
+
}
120
120
+
121
121
+
button:disabled {
122
122
+
opacity: 0.4;
123
123
+
cursor: not-allowed;
124
124
+
}
125
125
+
126
126
+
button:focus {
127
127
+
outline: 0.125rem solid var(--accent-primary);
128
128
+
outline-offset: 0.125rem;
129
129
+
}
130
130
+
131
131
+
.error {
132
132
+
color: var(--error-text);
133
133
+
font-size: 0.8125rem;
134
134
+
text-align: center;
135
135
+
margin-top: 0.75rem;
136
136
+
display: none;
137
137
+
}
138
138
+
139
139
+
.error.show {
140
140
+
display: block;
141
141
+
}
142
142
+
</style>
143
143
+
</head>
144
144
+
145
145
+
<body style="background-color: var(--bg-primary)">
146
146
+
<div class="login-container">
147
147
+
<h1>🛎️ hop</h1>
148
148
+
<p class="subtitle">// admin login</p>
149
149
+
<form id="loginForm">
150
150
+
<input type="password" id="password" placeholder="password" required autofocus />
151
151
+
<button type="submit">login</button>
152
152
+
</form>
153
153
+
<div id="error" class="error"></div>
154
154
+
</div>
155
155
+
156
156
+
<script>
157
157
+
const form = document.getElementById('loginForm');
158
158
+
const error = document.getElementById('error');
159
159
+
const passwordInput = document.getElementById('password');
160
160
+
161
161
+
document.addEventListener('keydown', (e) => {
162
162
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
163
163
+
e.preventDefault();
164
164
+
passwordInput.focus();
165
165
+
passwordInput.select();
166
166
+
}
167
167
+
});
168
168
+
169
169
+
form.addEventListener('submit', async (e) => {
170
170
+
e.preventDefault();
171
171
+
172
172
+
const password = passwordInput.value;
173
173
+
const submitBtn = form.querySelector('button');
174
174
+
175
175
+
submitBtn.disabled = true;
176
176
+
submitBtn.textContent = 'logging in...';
177
177
+
error.classList.remove('show');
178
178
+
179
179
+
try {
180
180
+
const response = await fetch('/api/login', {
181
181
+
method: 'POST',
182
182
+
headers: { 'Content-Type': 'application/json' },
183
183
+
body: JSON.stringify({ password }),
184
184
+
});
185
185
+
186
186
+
const data = await response.json();
187
187
+
188
188
+
if (!response.ok) {
189
189
+
throw new Error(data.error || 'Login failed');
190
190
+
}
191
191
+
192
192
+
// Store session token
193
193
+
localStorage.setItem('hop_session', data.token);
194
194
+
195
195
+
// Redirect to main app
196
196
+
window.location.href = '/';
197
197
+
} catch (err) {
198
198
+
error.textContent = err.message;
199
199
+
error.classList.add('show');
200
200
+
passwordInput.value = '';
201
201
+
passwordInput.focus();
202
202
+
} finally {
203
203
+
submitBtn.disabled = false;
204
204
+
submitBtn.textContent = 'login';
205
205
+
}
206
206
+
});
207
207
+
</script>
208
208
+
</body>
209
209
+
210
210
+
</html>
+4
-1
wrangler.toml
···
12
12
binding = "HOP"
13
13
id = "ae7cd39a622b466d876b8410d22d1397"
14
14
15
15
-
[rules]
15
15
+
[vars]
16
16
+
AUTH_PASSWORD = "changeme"
17
17
+
16
18
[[rules]]
17
19
type = "Text"
18
20
globs = ["**/*.html"]
21
21
+
fallthrough = false