|
export default { |
|
async fetch(request, env) { |
|
const url = new URL(request.url); |
|
const path = url.pathname; |
|
const cookie = request.headers.get('Cookie') || ''; |
|
const hasSecretCookie = cookie.includes(`secret=${env.SECRET_TOKEN}`); |
|
const hasOIDCCookie = cookie.includes('oidc_authenticated=true'); |
|
const isAuthenticated = hasSecretCookie || hasOIDCCookie; |
|
|
|
// --- OIDC Authentication Routes --- |
|
|
|
// OIDC Login initiation |
|
if (path === '/auth/oidc/login') { |
|
try { |
|
const oidcConfig = await getOIDCConfiguration(env.OIDC_PROVIDER_URL); |
|
|
|
// Generate state and nonce for security (Section 3.1.2.1) |
|
const state = await generateRandomString(32); |
|
const nonce = await generateRandomString(32); |
|
|
|
// Build Authorization Request per Section 3.1.2.1 |
|
const authUrl = new URL(oidcConfig.authorization_endpoint); |
|
authUrl.searchParams.set('response_type', 'code'); // Authorization Code Flow |
|
authUrl.searchParams.set('scope', 'openid email'); // REQUIRED openid scope + email |
|
authUrl.searchParams.set('client_id', env.OIDC_CLIENT_ID); |
|
authUrl.searchParams.set('redirect_uri', `${url.origin}/auth/oidc/callback`); |
|
authUrl.searchParams.set('state', state); |
|
authUrl.searchParams.set('nonce', nonce); |
|
|
|
// Store state and nonce in secure cookies for validation per Section 3.1.2.7 |
|
const headers = new Headers(); |
|
headers.set('Location', authUrl.toString()); |
|
headers.append('Set-Cookie', `oidc_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`); |
|
headers.append('Set-Cookie', `oidc_nonce=${nonce}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`); |
|
|
|
return new Response(null, { status: 302, headers }); |
|
} catch (error) { |
|
return new Response(`OIDC configuration error: ${error.message}`, { status: 500 }); |
|
} |
|
} |
|
|
|
// OIDC Callback handler |
|
if (path === '/auth/oidc/callback') { |
|
try { |
|
const code = url.searchParams.get('code'); |
|
const state = url.searchParams.get('state'); |
|
const error = url.searchParams.get('error'); |
|
|
|
// Error Response handling per Section 3.1.2.6 |
|
if (error) { |
|
const errorDescription = url.searchParams.get('error_description'); |
|
return new Response(`Authentication error: ${error}${errorDescription ? ' - ' + errorDescription : ''}`, { status: 400 }); |
|
} |
|
|
|
// Validate required parameters per Section 3.1.2.7 |
|
if (!code || !state) { |
|
return new Response('Missing authorization code or state parameter', { status: 400 }); |
|
} |
|
|
|
// Validate state parameter (CSRF protection) per Section 3.1.2.7 |
|
const storedState = getCookieValue(cookie, 'oidc_state'); |
|
if (!storedState || storedState !== state) { |
|
return new Response('Invalid state parameter - possible CSRF attack', { status: 400 }); |
|
} |
|
|
|
// Get OIDC configuration and exchange code for tokens per Section 3.1.3 |
|
const oidcConfig = await getOIDCConfiguration(env.OIDC_PROVIDER_URL); |
|
const tokenResponse = await exchangeCodeForTokens( |
|
code, |
|
`${url.origin}/auth/oidc/callback`, |
|
oidcConfig.token_endpoint, |
|
env.OIDC_CLIENT_ID, |
|
env.OIDC_CLIENT_SECRET |
|
); |
|
|
|
// Validate that we received an ID Token per Section 3.1.3.3 |
|
if (!tokenResponse.id_token) { |
|
return new Response('No ID Token received from token endpoint', { status: 400 }); |
|
} |
|
|
|
// Validate ID Token per Section 3.1.3.7 |
|
const storedNonce = getCookieValue(cookie, 'oidc_nonce'); |
|
const idTokenClaims = await validateIDToken( |
|
tokenResponse.id_token, |
|
oidcConfig.jwks_uri, |
|
env.OIDC_CLIENT_ID, |
|
env.OIDC_PROVIDER_URL, |
|
storedNonce |
|
); |
|
|
|
// Set authentication cookies and redirect per Section 3.1.2.5 |
|
const headers = new Headers(); |
|
headers.set('Location', url.origin); |
|
headers.append('Set-Cookie', 'oidc_authenticated=true; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400'); |
|
headers.append('Set-Cookie', `oidc_user=${encodeURIComponent(JSON.stringify({ |
|
sub: idTokenClaims.sub, |
|
email: idTokenClaims.email, |
|
email_verified: idTokenClaims.email_verified, |
|
iss: idTokenClaims.iss |
|
}))}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`); |
|
// Clear temporary state/nonce cookies |
|
headers.append('Set-Cookie', 'oidc_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'); |
|
headers.append('Set-Cookie', 'oidc_nonce=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'); |
|
|
|
return new Response(null, { status: 302, headers }); |
|
} catch (error) { |
|
return new Response(`Authentication failed: ${error.message}`, { status: 500 }); |
|
} |
|
} |
|
|
|
// OIDC Logout |
|
if (path === '/auth/logout') { |
|
const headers = new Headers(); |
|
headers.set('Location', url.origin); |
|
headers.append('Set-Cookie', 'oidc_authenticated=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0'); |
|
headers.append('Set-Cookie', 'oidc_user=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0'); |
|
headers.append('Set-Cookie', 'secret=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0'); |
|
|
|
return new Response(null, { status: 302, headers }); |
|
} |
|
|
|
// --- Secret Token Authentication Route (backward compatibility) --- |
|
if (path.startsWith('/secret/')) { |
|
const token = path.split('/')[2]; |
|
if (token === env.SECRET_TOKEN) { |
|
const headers = new Headers(); |
|
headers.append('Location', url.origin); |
|
headers.append('Set-Cookie', `secret=${env.SECRET_TOKEN}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`); |
|
|
|
return new Response(null, { |
|
status: 302, |
|
headers: headers, |
|
}); |
|
} |
|
return new Response('Invalid secret token.', { status: 403 }); |
|
} |
|
|
|
// --- API Routes (for authenticated users) --- |
|
if (path === '/api/links' && isAuthenticated) { |
|
// Get user identifier for logging purposes |
|
let userLogIdentifier = 'Secret Token User'; // Default for secret token or if cookie parsing fails |
|
if (hasOIDCCookie) { |
|
const userInfoCookie = getCookieValue(cookie, 'oidc_user'); |
|
if (userInfoCookie) { |
|
try { |
|
const user = JSON.parse(decodeURIComponent(userInfoCookie)); |
|
// Use email if available, otherwise fall back to the subject (sub) |
|
userLogIdentifier = `OIDC User (email: ${user.email || 'N/A'}, sub: ${user.sub})`; |
|
} catch (e) { |
|
userLogIdentifier = 'OIDC User (Error parsing cookie)'; |
|
} |
|
} else { |
|
userLogIdentifier = 'OIDC User (Cookie not found)'; |
|
} |
|
} |
|
|
|
// POST: Add or update a link |
|
if (request.method === 'POST') { |
|
try { |
|
const { slug, url: newUrl } = await request.json(); |
|
if (!slug || !newUrl) { |
|
return new Response(JSON.stringify({ error: 'Missing slug or url.' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); |
|
} |
|
const existingLink = await env.DB.prepare('SELECT url FROM links WHERE slug = ?').bind(slug).first(); |
|
const action = existingLink ? 'update' : 'add'; |
|
const logDetails = `User: ${userLogIdentifier} | URL set to: ${newUrl}`; |
|
|
|
await env.DB.batch([ |
|
env.DB.prepare('INSERT OR REPLACE INTO links (slug, url, created_at) VALUES (?, ?, datetime("now"))').bind(slug, newUrl), |
|
env.DB.prepare('INSERT INTO logs (action, slug, details, timestamp) VALUES (?, ?, ?, datetime("now"))').bind(action, slug, logDetails) |
|
]); |
|
return new Response(JSON.stringify({ message: `Link ${action}ed successfully.` }), { status: 200, headers: { 'Content-Type': 'application/json' } }); |
|
} catch (e) { |
|
return new Response(JSON.stringify({ error: `Error processing request: ${e.message}` }), { status: 500, headers: { 'Content-Type': 'application/json' } }); |
|
} |
|
} |
|
|
|
// GET: List all links |
|
if (request.method === 'GET') { |
|
const { results } = await env.DB.prepare('SELECT slug, url, created_at FROM links ORDER BY created_at DESC').all(); |
|
return new Response(JSON.stringify(results), { status: 200, headers: { 'Content-Type': 'application/json' } }); |
|
} |
|
|
|
// DELETE: Delete a link |
|
if (request.method === 'DELETE') { |
|
try { |
|
const { slug } = await request.json(); |
|
if (!slug) { |
|
return new Response(JSON.stringify({ error: 'Missing slug in request body.' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); |
|
} |
|
const link = await env.DB.prepare('SELECT url FROM links WHERE slug = ?').bind(slug).first(); |
|
if (!link) { |
|
return new Response(JSON.stringify({ error: 'Link not found.' }), { status: 404, headers: { 'Content-Type': 'application/json' } }); |
|
} |
|
const logDetails = `User: ${userLogIdentifier} | Deleted link with destination: ${link.url}`; |
|
|
|
await env.DB.batch([ |
|
env.DB.prepare('DELETE FROM links WHERE slug = ?').bind(slug), |
|
env.DB.prepare('INSERT INTO logs (action, slug, details, timestamp) VALUES (?, ?, ?, datetime("now"))').bind('delete', slug, logDetails) |
|
]); |
|
return new Response(JSON.stringify({ message: `Link /${slug} deleted successfully.` }), { status: 200, headers: { 'Content-Type': 'application/json' } }); |
|
} catch (e) { |
|
return new Response(JSON.stringify({ error: `Error processing request: ${e.message}` }), { status: 500, headers: { 'Content-Type': 'application/json' } }); |
|
} |
|
} |
|
|
|
return new Response('Method Not Allowed', { status: 405 }); |
|
} |
|
|
|
// --- Page Serving and Redirection --- |
|
if (path === '/') { |
|
if (isAuthenticated) { |
|
const userInfo = hasOIDCCookie ? getCookieValue(cookie, 'oidc_user') : null; |
|
return new Response(getAdminPanel(userInfo), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } }); |
|
} else { |
|
return new Response(getLoginPage(), { headers: { 'Content-Type': 'text/html;charset=UTF-8' } }); |
|
} |
|
} |
|
|
|
if (path.length > 1) { |
|
const slug = path.substring(1); |
|
const link = await env.DB.prepare('SELECT url FROM links WHERE slug = ?').bind(slug).first(); |
|
if (link) { |
|
return Response.redirect(link.url, 302); |
|
} else { |
|
return new Response('Short link not found.', { status: 404 }); |
|
} |
|
} |
|
|
|
return new Response('Not Found', { status: 404 }); |
|
}, |
|
}; |
|
|
|
// --- OIDC Helper Functions per Specification --- |
|
|
|
// Discovery per Section 15.3 and OpenID Connect Discovery 1.0 |
|
async function getOIDCConfiguration(providerUrl) { |
|
// Remove trailing slash and add well-known endpoint |
|
const baseUrl = providerUrl.replace(/\/$/, ''); |
|
const configUrl = `${baseUrl}/.well-known/openid-configuration`; |
|
|
|
const response = await fetch(configUrl); |
|
if (!response.ok) { |
|
throw new Error(`Failed to fetch OIDC configuration from ${configUrl}: ${response.status}`); |
|
} |
|
|
|
const config = await response.json(); |
|
|
|
// Validate required discovery document fields per OpenID Connect Discovery 1.0 |
|
if (!config.authorization_endpoint || !config.token_endpoint || !config.jwks_uri || !config.issuer) { |
|
throw new Error('Invalid OIDC configuration: missing required endpoints'); |
|
} |
|
|
|
return config; |
|
} |
|
|
|
// Token exchange per Section 3.1.3.1 (using client_secret_basic per Section 9) |
|
async function exchangeCodeForTokens(code, redirectUri, tokenEndpoint, clientId, clientSecret) { |
|
// Use HTTP Basic Authentication per Section 9 (client_secret_basic) |
|
const credentials = btoa(`${clientId}:${clientSecret}`); |
|
|
|
// Token Request per Section 3.1.3.1 |
|
const body = new URLSearchParams({ |
|
grant_type: 'authorization_code', |
|
code: code, |
|
redirect_uri: redirectUri, |
|
}); |
|
|
|
const response = await fetch(tokenEndpoint, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/x-www-form-urlencoded', |
|
'Authorization': `Basic ${credentials}`, |
|
}, |
|
body: body, |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorText = await response.text(); |
|
throw new Error(`Token exchange failed: ${errorText}`); |
|
} |
|
|
|
const tokenResponse = await response.json(); |
|
|
|
// Validate Token Response per Section 3.1.3.3 |
|
if (!tokenResponse.access_token || !tokenResponse.token_type || !tokenResponse.id_token) { |
|
throw new Error('Invalid token response: missing required tokens'); |
|
} |
|
|
|
if (tokenResponse.token_type.toLowerCase() !== 'bearer') { |
|
throw new Error(`Unexpected token_type: ${tokenResponse.token_type}`); |
|
} |
|
|
|
return tokenResponse; |
|
} |
|
|
|
// ID Token validation per Section 3.1.3.7 |
|
async function validateIDToken(idToken, jwksUri, clientId, issuer, nonce) { |
|
// Parse JWT structure |
|
const parts = idToken.split('.'); |
|
if (parts.length !== 3) { |
|
throw new Error('Invalid ID Token format'); |
|
} |
|
|
|
// Parse header to get key ID |
|
const [headerB64, payloadB64, signatureB64] = parts; |
|
const header = JSON.parse(base64urlDecode(headerB64, 'text')); |
|
|
|
// Fetch and validate JWKS |
|
const jwksResponse = await fetch(jwksUri); |
|
if (!jwksResponse.ok) { |
|
throw new Error(`Failed to fetch JWKS from ${jwksUri}`); |
|
} |
|
const jwks = await jwksResponse.json(); |
|
|
|
if (!jwks.keys || !Array.isArray(jwks.keys)) { |
|
throw new Error('Invalid JWKS format'); |
|
} |
|
|
|
// Find the correct key |
|
const key = jwks.keys.find(k => k.kid === header.kid && k.use === 'sig' && k.kty === 'RSA'); |
|
if (!key) { |
|
throw new Error(`Key not found in JWKS for kid: ${header.kid}`); |
|
} |
|
|
|
// Import the public key for verification |
|
const publicKey = await importJWK(key); |
|
|
|
// Verify the signature per JWS specification |
|
const encoder = new TextEncoder(); |
|
const data = encoder.encode(`${headerB64}.${payloadB64}`); |
|
const signature = base64urlDecode(signatureB64, 'bytes'); |
|
|
|
const isValid = await crypto.subtle.verify( |
|
{ name: 'RSASSA-PKCS1-v1_5' }, |
|
publicKey, |
|
signature, |
|
data |
|
); |
|
|
|
if (!isValid) { |
|
throw new Error('Invalid ID Token signature'); |
|
} |
|
|
|
// Parse and validate claims per Section 3.1.3.7 |
|
const payload = JSON.parse(base64urlDecode(payloadB64, 'text')); |
|
const now = Math.floor(Date.now() / 1000); |
|
|
|
// Required claims validation per Section 2 |
|
if (!payload.iss || !payload.sub || !payload.aud || !payload.exp || !payload.iat) { |
|
throw new Error('Missing required ID Token claims'); |
|
} |
|
|
|
// Expiration time validation |
|
if (payload.exp <= now) { |
|
throw new Error('ID Token has expired'); |
|
} |
|
|
|
// Not before time validation |
|
if (payload.nbf && payload.nbf > now) { |
|
throw new Error('ID Token not yet valid'); |
|
} |
|
|
|
// Audience validation (must contain our client_id) |
|
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; |
|
if (!audiences.includes(clientId)) { |
|
throw new Error('ID Token audience does not match client_id'); |
|
} |
|
|
|
// Issuer validation (must match discovery document) |
|
if (payload.iss !== issuer) { |
|
throw new Error(`ID Token issuer mismatch: expected ${issuer}, got ${payload.iss}`); |
|
} |
|
|
|
// Nonce validation (replay attack prevention) |
|
if (nonce) { |
|
if (!payload.nonce) { |
|
throw new Error('Expected nonce claim in ID Token'); |
|
} |
|
if (payload.nonce !== nonce) { |
|
throw new Error('ID Token nonce does not match request nonce'); |
|
} |
|
} |
|
|
|
// Issued at time validation (reasonable time window) |
|
const maxAge = 3600; // 1 hour max age for ID tokens |
|
if (payload.iat < (now - maxAge)) { |
|
throw new Error('ID Token issued too long ago'); |
|
} |
|
|
|
return payload; |
|
} |
|
|
|
// JWK import for RSA keys per JWK specification |
|
async function importJWK(jwk) { |
|
if (jwk.kty !== 'RSA') { |
|
throw new Error(`Unsupported key type: ${jwk.kty}`); |
|
} |
|
|
|
const keyData = { |
|
kty: jwk.kty, |
|
n: jwk.n, |
|
e: jwk.e, |
|
alg: jwk.alg || 'RS256', |
|
use: jwk.use || 'sig', |
|
}; |
|
|
|
return await crypto.subtle.importKey( |
|
'jwk', |
|
keyData, |
|
{ |
|
name: 'RSASSA-PKCS1-v1_5', |
|
hash: 'SHA-256', |
|
}, |
|
false, |
|
['verify'] |
|
); |
|
} |
|
|
|
// Base64URL decode utility |
|
function base64urlDecode(str, format = 'bytes') { |
|
// Add padding if needed |
|
str = str.replace(/-/g, '+').replace(/_/g, '/'); |
|
while (str.length % 4) { |
|
str += '='; |
|
} |
|
|
|
if (format === 'text') { |
|
return decodeURIComponent(escape(atob(str))); |
|
} |
|
|
|
const binary = atob(str); |
|
const bytes = new Uint8Array(binary.length); |
|
for (let i = 0; i < binary.length; i++) { |
|
bytes[i] = binary.charCodeAt(i); |
|
} |
|
return bytes; |
|
} |
|
|
|
// Cryptographically secure random string generation |
|
async function generateRandomString(length) { |
|
const array = new Uint8Array(length); |
|
crypto.getRandomValues(array); |
|
return Array.from(array, byte => ('0' + byte.toString(16)).slice(-2)).join('').slice(0, length); |
|
} |
|
|
|
// Cookie value extraction utility |
|
function getCookieValue(cookieString, name) { |
|
const match = cookieString.match(new RegExp('(^| )' + name + '=([^;]+)')); |
|
return match ? decodeURIComponent(match[2]) : null; |
|
} |
|
|
|
// --- UI Functions --- |
|
|
|
function getLoginPage() { |
|
return ` |
|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Login - Link Manager</title> |
|
<style> |
|
:root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; } |
|
body { max-width: 400px; margin: 4em auto; padding: 0 1em; background-color: #f9f9f9; } |
|
.login-container { background: #fff; padding: 2em; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); text-align: center; } |
|
h1 { color: #111; margin-bottom: 1.5em; } |
|
.login-btn { display: block; width: 100%; font-size: 1em; padding: 0.8em; margin-bottom: 1em; border: 1px solid #007bff; background-color: #007bff; color: white; border-radius: 4px; cursor: pointer; text-decoration: none; } |
|
.login-btn:hover { background-color: #0056b3; border-color: #0056b3; } |
|
.or-divider { margin: 1.5em 0; color: #6c757d; position: relative; } |
|
.or-divider:before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #dee2e6; } |
|
.or-divider span { background: #fff; padding: 0 1em; } |
|
.specs { font-size: 0.9em; color: #666; margin-top: 1.5em; text-align: left; } |
|
.specs hr { margin: 1em 0; } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="login-container"> |
|
<h1>Link Manager</h1> |
|
<p>Please authenticate to access the admin panel.</p> |
|
|
|
<a href="/auth/oidc/login" class="login-btn">Login with OpenID Connect</a> |
|
|
|
<div class="or-divider"><span>or</span></div> |
|
|
|
<p><small>If you have a secret token, you can access directly via:<br> |
|
<code>/secret/YOUR_TOKEN</code></small></p> |
|
|
|
<div class="specs"> |
|
<hr> |
|
<strong>OpenID Connect Implementation:</strong> |
|
<ul> |
|
<li>Flow: Authorization Code</li> |
|
<li>Scopes: openid email</li> |
|
<li>Discovery: /.well-known/openid-configuration</li> |
|
<li>Client Auth: client_secret_basic</li> |
|
<li>Security: CSRF protection, nonce validation</li> |
|
</ul> |
|
</div> |
|
</div> |
|
</body> |
|
</html> |
|
`; |
|
} |
|
|
|
function getAdminPanel(userInfo = null) { |
|
let user = null; |
|
if (userInfo) { |
|
try { |
|
user = JSON.parse(decodeURIComponent(userInfo)); |
|
} catch (e) { |
|
// Ignore parsing errors |
|
} |
|
} |
|
|
|
const userDisplay = user ? ` |
|
<div style="text-align: right; margin-bottom: 1em; padding: 0.5em; background: #e9ecef; border-radius: 4px;"> |
|
<strong>Authenticated via OpenID Connect</strong><br> |
|
<small>Subject: ${user.sub}</small><br> |
|
${user.email ? `<small>Email: ${user.email} ${user.email_verified ? '✓' : '⚠'}</small><br>` : ''} |
|
<small>Issuer: ${user.iss}</small> |
|
<a href="/auth/logout" style="margin-left: 1em; color: #dc3545; text-decoration: none;">[Logout]</a> |
|
</div> |
|
` : ''; |
|
|
|
return ` |
|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Link Manager</title> |
|
<style> |
|
:root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; } |
|
body { max-width: 800px; margin: 2em auto; padding: 0 1em; background-color: #f9f9f9; } |
|
h1, h2 { color: #111; } |
|
form { background: #fff; padding: 1.5em; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); margin-bottom: 2em; } |
|
input { width: calc(100% - 1.2em); padding: 0.6em; margin-bottom: 1em; border: 1px solid #ccc; border-radius: 4px; font-size: 1em; } |
|
button { font-size: 1em; padding: 0.7em 1.2em; border: none; background-color: #007bff; color: white; border-radius: 4px; cursor: pointer; } |
|
button:hover { background-color: #0056b3; } |
|
#message { margin-top: 1em; padding: 0.7em; border-radius: 4px; display: none; } |
|
#message.success { background-color: #e6ffed; color: #006622; } |
|
#message.error { background-color: #ffebe6; color: #800000; } |
|
table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.1); } |
|
th, td { padding: 0.8em; text-align: left; border-bottom: 1px solid #ddd; } |
|
th { background-color: #f2f2f2; } |
|
td a { color: #007bff; text-decoration: none; } |
|
td a:hover { text-decoration: underline; } |
|
.slug-col { word-break: break-all; } |
|
.delete-btn { font-size: 0.8em; padding: 0.4em 0.8em; background-color: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; } |
|
.delete-btn:hover { background-color: #c82333; } |
|
</style> |
|
</head> |
|
<body> |
|
${userDisplay} |
|
<h1>Link Manager</h1> |
|
<form id="linkForm"> |
|
<h2>Create or Update a Link</h2> |
|
<input type="text" id="slug" placeholder="Short slug (e.g., 'blog')" required> |
|
<input type="url" id="url" placeholder="Full destination URL" required> |
|
<button type="submit">Save Link</button> |
|
<div id="message"></div> |
|
</form> |
|
|
|
<h2>Existing Links</h2> |
|
<table id="linksTable"> |
|
<thead> |
|
<tr> |
|
<th>Slug</th> |
|
<th>Destination URL</th> |
|
<th>Created</th> |
|
<th>Action</th> |
|
</tr> |
|
</thead> |
|
<tbody></tbody> |
|
</table> |
|
|
|
<script> |
|
const form = document.getElementById('linkForm'); |
|
const messageEl = document.getElementById('message'); |
|
const linksTableBody = document.querySelector('#linksTable tbody'); |
|
|
|
async function loadLinks() { |
|
const response = await fetch('/api/links'); |
|
if (!response.ok) { |
|
linksTableBody.innerHTML = '<tr><td colspan="4">Error loading links.</td></tr>'; |
|
return; |
|
} |
|
const links = await response.json(); |
|
linksTableBody.innerHTML = links.map(link => \` |
|
<tr> |
|
<td class="slug-col">/\${link.slug}</td> |
|
<td><a href="\${link.url}" target="_blank">\${link.url}</a></td> |
|
<td>\${new Date(link.created_at + 'Z').toLocaleString()}</td> |
|
<td><button class="delete-btn" data-slug="\${link.slug}">Delete</button></td> |
|
</tr> |
|
\`).join(''); |
|
} |
|
|
|
form.addEventListener('submit', async (event) => { |
|
event.preventDefault(); |
|
const slug = document.getElementById('slug').value; |
|
const url = document.getElementById('url').value; |
|
const response = await fetch('/api/links', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ slug, url }), |
|
}); |
|
const result = await response.json(); |
|
messageEl.style.display = 'block'; |
|
if (response.ok) { |
|
messageEl.textContent = result.message; |
|
messageEl.className = 'success'; |
|
form.reset(); |
|
loadLinks(); |
|
} else { |
|
messageEl.textContent = 'Error: ' + result.error; |
|
messageEl.className = 'error'; |
|
} |
|
}); |
|
|
|
linksTableBody.addEventListener('click', async (event) => { |
|
if (event.target.classList.contains('delete-btn')) { |
|
const slug = event.target.dataset.slug; |
|
if (confirm(\`Are you sure you want to delete the link "/\${slug}"?\`)) { |
|
const response = await fetch('/api/links', { |
|
method: 'DELETE', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ slug }), |
|
}); |
|
const result = await response.json(); |
|
messageEl.style.display = 'block'; |
|
if (response.ok) { |
|
messageEl.textContent = result.message; |
|
messageEl.className = 'success'; |
|
loadLinks(); |
|
} else { |
|
messageEl.textContent = 'Error: ' + result.error; |
|
messageEl.className = 'error'; |
|
} |
|
} |
|
} |
|
}); |
|
|
|
document.addEventListener('DOMContentLoaded', loadLinks); |
|
</script> |
|
</body> |
|
</html> |
|
`; |
|
} |