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 view only mode
dunkirk.sh
2 months ago
2c1790fb
c727ddb8
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+83
-10
2 changed files
expand all
collapse all
unified
split
src
index.html
index.ts
+35
-7
src/index.html
···
452
452
window.location.href = '/login';
453
453
}
454
454
455
455
+
// Track user role globally
456
456
+
let userRole = null;
457
457
+
455
458
// Add auth header to all API requests
456
459
const originalFetch = window.fetch;
457
460
window.fetch = function (...args) {
···
583
586
584
587
async function loadUrls() {
585
588
try {
589
589
+
// Fetch user info to determine role
590
590
+
const meResponse = await fetch("/api/me");
591
591
+
if (meResponse.ok) {
592
592
+
const meData = await meResponse.json();
593
593
+
userRole = meData.role;
594
594
+
applyViewerMode();
595
595
+
}
596
596
+
586
597
const response = await fetch("/api/urls?limit=1000");
587
598
const data = await response.json();
588
599
allUrls = data.urls;
···
594
605
}
595
606
}
596
607
608
608
+
function applyViewerMode() {
609
609
+
if (userRole === 'viewer') {
610
610
+
// Hide the shorten form
611
611
+
document.querySelector('.form-section').style.display = 'none';
612
612
+
}
613
613
+
}
614
614
+
615
615
+
function isViewOnly() {
616
616
+
return userRole === 'viewer';
617
617
+
}
618
618
+
597
619
async function searchServer(query) {
598
620
try {
599
621
const response = await fetch(`/api/urls?limit=1000&search=${encodeURIComponent(query)}`);
···
643
665
container.style.height = '100%';
644
666
645
667
// Create sticky header
668
668
+
const gridColumns = isViewOnly() ? '20% 60% 20%' : '15% 50% 15% 20%';
646
669
const header = document.createElement('div');
647
670
header.style.display = 'grid';
648
648
-
header.style.gridTemplateColumns = '15% 50% 15% 20%';
671
671
+
header.style.gridTemplateColumns = gridColumns;
649
672
header.style.position = 'sticky';
650
673
header.style.top = '0';
651
674
header.style.background = 'var(--input-bg)';
652
675
header.style.zIndex = '10';
653
676
header.style.borderBottom = '0.0625rem solid var(--border-color)';
677
677
+
const actionsHeader = isViewOnly() ? '' : `<div style="padding: 0.75rem 0.875rem; text-align: right; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">actions</div>`;
654
678
header.innerHTML = `
655
679
<div style="padding: 0.75rem 0.875rem; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">short</div>
656
680
<div style="padding: 0.75rem 0.875rem; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">url</div>
657
681
<div style="padding: 0.75rem 0.875rem; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">created</div>
658
658
-
<div style="padding: 0.75rem 0.875rem; text-align: right; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">actions</div>
682
682
+
${actionsHeader}
659
683
`;
660
684
661
685
// Create scrollable content area
···
693
717
694
718
const row = document.createElement('div');
695
719
row.style.display = 'grid';
696
696
-
row.style.gridTemplateColumns = '15% 50% 15% 20%';
720
720
+
row.style.gridTemplateColumns = gridColumns;
697
721
row.style.position = 'absolute';
698
722
row.style.top = `${i * ROW_HEIGHT}px`;
699
723
row.style.left = '0';
···
710
734
row.style.background = '';
711
735
});
712
736
737
737
+
const actionsColumn = isViewOnly() ? '' : `
738
738
+
<div style="padding: 0.75rem 0.875rem; text-align: right; display: flex; align-items: center; justify-content: flex-end; gap: 0.25rem;">
739
739
+
<button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">✏️</button>
740
740
+
<button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">🗑️</button>
741
741
+
</div>
742
742
+
`;
743
743
+
713
744
row.innerHTML = `
714
745
<div style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: var(--accent-bright); font-weight: 700; display: flex; align-items: center;">
715
746
<a href="/${item.shortCode}" target="_blank" style="color: var(--accent-bright); text-decoration: none;">/${item.shortCode}</a>
716
747
</div>
717
748
<div class="url-cell" style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center;" title="${item.url}">${item.url}</div>
718
749
<div style="padding: 0.75rem 0.875rem; font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center;"><span data-created="${item.created}">${formatSmartTime(item.created)}</span></div>
719
719
-
<div style="padding: 0.75rem 0.875rem; text-align: right; display: flex; align-items: center; justify-content: flex-end; gap: 0.25rem;">
720
720
-
<button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">✏️</button>
721
721
-
<button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">🗑️</button>
722
722
-
</div>
750
750
+
${actionsColumn}
723
751
`;
724
752
725
753
scrollContent.appendChild(row);
+48
-3
src/index.ts
···
114
114
115
115
const tokenData = await tokenResponse.json();
116
116
117
117
-
// Check if user has admin role
118
118
-
if (tokenData.role !== 'admin') {
117
117
+
// Check if user has admin or viewer role
118
118
+
if (tokenData.role !== 'admin' && tokenData.role !== 'viewer') {
119
119
return Response.redirect(new URL('/login?error=unauthorized_role', request.url).toString(), 302);
120
120
}
121
121
···
156
156
});
157
157
}
158
158
159
159
+
// Get current user info endpoint
160
160
+
if (url.pathname === '/api/me' && request.method === 'GET') {
161
161
+
const authHeader = request.headers.get('Authorization');
162
162
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
163
163
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
164
164
+
status: 401,
165
165
+
headers: { 'Content-Type': 'application/json' },
166
166
+
});
167
167
+
}
168
168
+
169
169
+
const token = authHeader.slice(7);
170
170
+
const sessionData = await env.HOP.get(`session:${token}`);
171
171
+
172
172
+
if (!sessionData) {
173
173
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
174
174
+
status: 401,
175
175
+
headers: { 'Content-Type': 'application/json' },
176
176
+
});
177
177
+
}
178
178
+
179
179
+
const session = JSON.parse(sessionData);
180
180
+
return new Response(JSON.stringify({
181
181
+
role: session.role,
182
182
+
profile: session.profile,
183
183
+
me: session.me,
184
184
+
}), {
185
185
+
headers: { 'Content-Type': 'application/json' },
186
186
+
});
187
187
+
}
188
188
+
159
189
// Check auth for all other routes (except / which needs to load first)
190
190
+
let userRole: string | null = null;
160
191
if (url.pathname !== '/') {
161
192
const authHeader = request.headers.get('Authorization');
162
193
if (!authHeader) {
···
172
203
173
204
// Check if it's an API key
174
205
if (token === env.API_KEY) {
175
175
-
// Valid API key, continue
206
206
+
// Valid API key, treat as admin
207
207
+
userRole = 'admin';
176
208
} else {
177
209
// Check if it's a session token
178
210
const sessionData = await env.HOP.get(`session:${token}`);
···
192
224
headers: { 'Content-Type': 'application/json' },
193
225
});
194
226
}
227
227
+
userRole = session.role;
195
228
}
196
229
} else {
197
230
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
198
231
status: 401,
232
232
+
headers: { 'Content-Type': 'application/json' },
233
233
+
});
234
234
+
}
235
235
+
236
236
+
// Block write operations for viewers
237
237
+
const isWriteOperation =
238
238
+
(url.pathname === '/api/shorten' && request.method === 'POST') ||
239
239
+
(url.pathname.startsWith('/api/urls/') && (request.method === 'PUT' || request.method === 'DELETE'));
240
240
+
241
241
+
if (isWriteOperation && userRole === 'viewer') {
242
242
+
return new Response(JSON.stringify({ error: 'Forbidden: View-only access' }), {
243
243
+
status: 403,
199
244
headers: { 'Content-Type': 'application/json' },
200
245
});
201
246
}