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 window.location.href = '/login'; 453 } 454 455 // Add auth header to all API requests 456 const originalFetch = window.fetch; 457 window.fetch = function (...args) { ··· 583 584 async function loadUrls() { 585 try { 586 const response = await fetch("/api/urls?limit=1000"); 587 const data = await response.json(); 588 allUrls = data.urls; ··· 594 } 595 } 596 597 async function searchServer(query) { 598 try { 599 const response = await fetch(`/api/urls?limit=1000&search=${encodeURIComponent(query)}`); ··· 643 container.style.height = '100%'; 644 645 // Create sticky header 646 const header = document.createElement('div'); 647 header.style.display = 'grid'; 648 - header.style.gridTemplateColumns = '15% 50% 15% 20%'; 649 header.style.position = 'sticky'; 650 header.style.top = '0'; 651 header.style.background = 'var(--input-bg)'; 652 header.style.zIndex = '10'; 653 header.style.borderBottom = '0.0625rem solid var(--border-color)'; 654 header.innerHTML = ` 655 <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 <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 <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> 659 `; 660 661 // Create scrollable content area ··· 693 694 const row = document.createElement('div'); 695 row.style.display = 'grid'; 696 - row.style.gridTemplateColumns = '15% 50% 15% 20%'; 697 row.style.position = 'absolute'; 698 row.style.top = `${i * ROW_HEIGHT}px`; 699 row.style.left = '0'; ··· 710 row.style.background = ''; 711 }); 712 713 row.innerHTML = ` 714 <div style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: var(--accent-bright); font-weight: 700; display: flex; align-items: center;"> 715 <a href="/${item.shortCode}" target="_blank" style="color: var(--accent-bright); text-decoration: none;">/${item.shortCode}</a> 716 </div> 717 <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 <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> 723 `; 724 725 scrollContent.appendChild(row);
··· 452 window.location.href = '/login'; 453 } 454 455 + // Track user role globally 456 + let userRole = null; 457 + 458 // Add auth header to all API requests 459 const originalFetch = window.fetch; 460 window.fetch = function (...args) { ··· 586 587 async function loadUrls() { 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 + 597 const response = await fetch("/api/urls?limit=1000"); 598 const data = await response.json(); 599 allUrls = data.urls; ··· 605 } 606 } 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 + 619 async function searchServer(query) { 620 try { 621 const response = await fetch(`/api/urls?limit=1000&search=${encodeURIComponent(query)}`); ··· 665 container.style.height = '100%'; 666 667 // Create sticky header 668 + const gridColumns = isViewOnly() ? '20% 60% 20%' : '15% 50% 15% 20%'; 669 const header = document.createElement('div'); 670 header.style.display = 'grid'; 671 + header.style.gridTemplateColumns = gridColumns; 672 header.style.position = 'sticky'; 673 header.style.top = '0'; 674 header.style.background = 'var(--input-bg)'; 675 header.style.zIndex = '10'; 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>`; 678 header.innerHTML = ` 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> 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> 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> 682 + ${actionsHeader} 683 `; 684 685 // Create scrollable content area ··· 717 718 const row = document.createElement('div'); 719 row.style.display = 'grid'; 720 + row.style.gridTemplateColumns = gridColumns; 721 row.style.position = 'absolute'; 722 row.style.top = `${i * ROW_HEIGHT}px`; 723 row.style.left = '0'; ··· 734 row.style.background = ''; 735 }); 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 + 744 row.innerHTML = ` 745 <div style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: var(--accent-bright); font-weight: 700; display: flex; align-items: center;"> 746 <a href="/${item.shortCode}" target="_blank" style="color: var(--accent-bright); text-decoration: none;">/${item.shortCode}</a> 747 </div> 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> 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> 750 + ${actionsColumn} 751 `; 752 753 scrollContent.appendChild(row);
+48 -3
src/index.ts
··· 114 115 const tokenData = await tokenResponse.json(); 116 117 - // Check if user has admin role 118 - if (tokenData.role !== 'admin') { 119 return Response.redirect(new URL('/login?error=unauthorized_role', request.url).toString(), 302); 120 } 121 ··· 156 }); 157 } 158 159 // Check auth for all other routes (except / which needs to load first) 160 if (url.pathname !== '/') { 161 const authHeader = request.headers.get('Authorization'); 162 if (!authHeader) { ··· 172 173 // Check if it's an API key 174 if (token === env.API_KEY) { 175 - // Valid API key, continue 176 } else { 177 // Check if it's a session token 178 const sessionData = await env.HOP.get(`session:${token}`); ··· 192 headers: { 'Content-Type': 'application/json' }, 193 }); 194 } 195 } 196 } else { 197 return new Response(JSON.stringify({ error: 'Unauthorized' }), { 198 status: 401, 199 headers: { 'Content-Type': 'application/json' }, 200 }); 201 }
··· 114 115 const tokenData = await tokenResponse.json(); 116 117 + // Check if user has admin or viewer role 118 + if (tokenData.role !== 'admin' && tokenData.role !== 'viewer') { 119 return Response.redirect(new URL('/login?error=unauthorized_role', request.url).toString(), 302); 120 } 121 ··· 156 }); 157 } 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 + 189 // Check auth for all other routes (except / which needs to load first) 190 + let userRole: string | null = null; 191 if (url.pathname !== '/') { 192 const authHeader = request.headers.get('Authorization'); 193 if (!authHeader) { ··· 203 204 // Check if it's an API key 205 if (token === env.API_KEY) { 206 + // Valid API key, treat as admin 207 + userRole = 'admin'; 208 } else { 209 // Check if it's a session token 210 const sessionData = await env.HOP.get(`session:${token}`); ··· 224 headers: { 'Content-Type': 'application/json' }, 225 }); 226 } 227 + userRole = session.role; 228 } 229 } else { 230 return new Response(JSON.stringify({ error: 'Unauthorized' }), { 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, 244 headers: { 'Content-Type': 'application/json' }, 245 }); 246 }