blazing fast link redirects on cloudflare kv hop.dunkirk.sh/u/tacy

feat: add view only mode

dunkirk.sh 2c1790fb c727ddb8

verified
+83 -10
+35 -7
src/index.html
··· 452 452 window.location.href = '/login'; 453 453 } 454 454 455 + // Track user role globally 456 + let userRole = null; 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 + // Fetch user info to determine role 590 + const meResponse = await fetch("/api/me"); 591 + if (meResponse.ok) { 592 + const meData = await meResponse.json(); 593 + userRole = meData.role; 594 + applyViewerMode(); 595 + } 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 + function applyViewerMode() { 609 + if (userRole === 'viewer') { 610 + // Hide the shorten form 611 + document.querySelector('.form-section').style.display = 'none'; 612 + } 613 + } 614 + 615 + function isViewOnly() { 616 + return userRole === 'viewer'; 617 + } 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 + const gridColumns = isViewOnly() ? '20% 60% 20%' : '15% 50% 15% 20%'; 646 669 const header = document.createElement('div'); 647 670 header.style.display = 'grid'; 648 - header.style.gridTemplateColumns = '15% 50% 15% 20%'; 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 + 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 - <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 + ${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 - row.style.gridTemplateColumns = '15% 50% 15% 20%'; 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 + const actionsColumn = isViewOnly() ? '' : ` 738 + <div style="padding: 0.75rem 0.875rem; text-align: right; display: flex; align-items: center; justify-content: flex-end; gap: 0.25rem;"> 739 + <button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">✏️</button> 740 + <button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">🗑️</button> 741 + </div> 742 + `; 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 - <div style="padding: 0.75rem 0.875rem; text-align: right; display: flex; align-items: center; justify-content: flex-end; gap: 0.25rem;"> 720 - <button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">✏️</button> 721 - <button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">🗑️</button> 722 - </div> 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 - // Check if user has admin role 118 - if (tokenData.role !== 'admin') { 117 + // Check if user has admin or viewer role 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 + // Get current user info endpoint 160 + if (url.pathname === '/api/me' && request.method === 'GET') { 161 + const authHeader = request.headers.get('Authorization'); 162 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 163 + return new Response(JSON.stringify({ error: 'Unauthorized' }), { 164 + status: 401, 165 + headers: { 'Content-Type': 'application/json' }, 166 + }); 167 + } 168 + 169 + const token = authHeader.slice(7); 170 + const sessionData = await env.HOP.get(`session:${token}`); 171 + 172 + if (!sessionData) { 173 + return new Response(JSON.stringify({ error: 'Unauthorized' }), { 174 + status: 401, 175 + headers: { 'Content-Type': 'application/json' }, 176 + }); 177 + } 178 + 179 + const session = JSON.parse(sessionData); 180 + return new Response(JSON.stringify({ 181 + role: session.role, 182 + profile: session.profile, 183 + me: session.me, 184 + }), { 185 + headers: { 'Content-Type': 'application/json' }, 186 + }); 187 + } 188 + 159 189 // Check auth for all other routes (except / which needs to load first) 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 - // Valid API key, continue 206 + // Valid API key, treat as admin 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 + userRole = session.role; 195 228 } 196 229 } else { 197 230 return new Response(JSON.stringify({ error: 'Unauthorized' }), { 198 231 status: 401, 232 + headers: { 'Content-Type': 'application/json' }, 233 + }); 234 + } 235 + 236 + // Block write operations for viewers 237 + const isWriteOperation = 238 + (url.pathname === '/api/shorten' && request.method === 'POST') || 239 + (url.pathname.startsWith('/api/urls/') && (request.method === 'PUT' || request.method === 'DELETE')); 240 + 241 + if (isWriteOperation && userRole === 'viewer') { 242 + return new Response(JSON.stringify({ error: 'Forbidden: View-only access' }), { 243 + status: 403, 199 244 headers: { 'Content-Type': 'application/json' }, 200 245 }); 201 246 }