Coves frontend - a photon fork
at main 180 lines 6.1 kB view raw
1import { redirect, type Handle, type HandleServerError } from '@sveltejs/kit' 2import { dev } from '$app/environment' 3import { env } from '$env/dynamic/public' 4import { 5 parseApiMeResponse, 6 asInstanceURL, 7 asSealedToken, 8} from '$lib/server/session' 9 10function getInstanceUrl(): string { 11 return env.PUBLIC_INTERNAL_INSTANCE || env.PUBLIC_INSTANCE_URL || '' 12} 13 14/** 15 * Returns the canonical hostname (with port) from PUBLIC_INSTANCE_URL, if configured. 16 * 17 * In development, the ATProto OAuth spec (RFC 8252) requires the callback redirect_uri 18 * to use 127.0.0.1 rather than "localhost". The Go backend sets APPVIEW_PUBLIC_URL to 19 * http://127.0.0.1:8080, so the coves_session cookie is set on the 127.0.0.1 domain. 20 * If a user navigates to localhost:8080 instead, the cookie is invisible and the user 21 * appears unauthenticated. This function extracts the canonical host so we can redirect 22 * mismatched hostnames to the correct origin. 23 */ 24function getCanonicalHost(): string | null { 25 const publicUrl = env.PUBLIC_INSTANCE_URL 26 if (!publicUrl) return null 27 try { 28 return new URL(publicUrl).host 29 } catch { 30 return null 31 } 32} 33 34/** 35 * Checks whether an error is a network-level failure (DNS, TLS, connection refused, etc.). 36 * Inspects the error message for known network-related keywords rather than matching on 37 * error type alone, to avoid misclassifying programming bugs as transient network errors. 38 */ 39function isNetworkError(error: unknown): boolean { 40 if (error instanceof Error) { 41 const msg = error.message.toLowerCase() 42 return ( 43 msg.includes('fetch failed') || 44 msg.includes('network') || 45 msg.includes('econnrefused') || 46 msg.includes('enotfound') || 47 msg.includes('etimedout') || 48 msg.includes('tls') || 49 msg.includes('ssl') || 50 msg.includes('dns') 51 ) 52 } 53 return false 54} 55 56export const handle: Handle = async ({ event, resolve }) => { 57 // DEV MODE: Normalize hostname to match the OAuth callback domain. 58 // The ATProto PDS requires 127.0.0.1 in redirect_uri (per RFC 8252), so the 59 // coves_session cookie is set on 127.0.0.1. If the user accesses the app via 60 // "localhost" instead, the cookie is invisible and auth silently fails. 61 // Redirect to the canonical host from PUBLIC_INSTANCE_URL to ensure consistency. 62 if (dev) { 63 const canonicalHost = getCanonicalHost() 64 if (canonicalHost && event.url.host !== canonicalHost) { 65 const canonicalUrl = new URL(event.url) 66 const canonical = new URL(env.PUBLIC_INSTANCE_URL!) 67 canonicalUrl.hostname = canonical.hostname 68 canonicalUrl.port = canonical.port 69 canonicalUrl.protocol = canonical.protocol 70 redirect(302, canonicalUrl.toString()) 71 } 72 } 73 74 event.locals.auth = { authenticated: false } 75 76 const covesSession = event.cookies.get('coves_session') 77 if (!covesSession) { 78 return resolve(event) 79 } 80 81 const instanceUrl = getInstanceUrl() 82 if (!instanceUrl) { 83 throw new Error( 84 '[hooks] No instance URL configured. Set PUBLIC_INTERNAL_INSTANCE or PUBLIC_INSTANCE_URL.', 85 ) 86 } 87 88 // Validate configuration eagerly — these throw on invalid input and must 89 // NOT be caught so that misconfiguration surfaces immediately on the first request. 90 const instance = asInstanceURL(instanceUrl) 91 const sealedToken = asSealedToken(covesSession) 92 93 // TODO: Consider caching /api/me responses or skipping validation for proxy 94 // requests to reduce latency. Currently /api/me is called on every request. 95 try { 96 const response = await fetch(`${instance}/api/me`, { 97 headers: { 98 Cookie: `coves_session=${covesSession}`, 99 }, 100 }) 101 102 if (!response.ok) { 103 if (response.status === 401) { 104 // Session expired or revoked — clear the stale cookie so we don't 105 // make a wasted /api/me round-trip on every subsequent request. 106 event.cookies.delete('coves_session', { path: '/' }) 107 // Flag so the layout can show "Your session has expired" to the user 108 event.locals.sessionExpired = true 109 } else { 110 console.warn( 111 `[hooks] /api/me returned ${response.status} - treating as unauthenticated`, 112 ) 113 } 114 return resolve(event) 115 } 116 117 const data: unknown = await response.json() 118 const account = parseApiMeResponse(data, instance, sealedToken) 119 120 if (!account) { 121 console.warn( 122 '[hooks] /api/me response failed validation - treating as unauthenticated', 123 ) 124 event.locals.authError = 'validation_error' 125 return resolve(event) 126 } 127 128 event.locals.auth = { 129 authenticated: true, 130 account, 131 authToken: sealedToken, 132 } 133 } catch (error) { 134 // Distinguish network/infrastructure errors from validation errors. 135 // Network errors (DNS, TLS, timeouts, connection refused) are likely 136 // temporary — preserve the cookie so the user can retry. 137 if (isNetworkError(error)) { 138 console.warn( 139 '[hooks] Network error calling /api/me - backend may be unreachable:', 140 error, 141 ) 142 event.locals.authError = 'network_error' 143 } else if (error instanceof SyntaxError) { 144 // JSON parse error from response.json() — the server returned 145 // non-JSON content (e.g. HTML error page, empty body) 146 console.warn( 147 '[hooks] /api/me returned invalid JSON - treating as unauthenticated:', 148 error, 149 ) 150 event.locals.authError = 'validation_error' 151 } else { 152 console.warn( 153 '[hooks] Unexpected error calling /api/me - treating as unauthenticated:', 154 error, 155 ) 156 event.locals.authError = 'network_error' 157 } 158 } 159 160 return resolve(event) 161} 162 163export const handleError: HandleServerError = async ({ 164 error, 165 event, 166 status, 167 message, 168}) => { 169 if (status == 404) { 170 return { message: 'Not found' } 171 } 172 173 console.error(`An error was captured:`) 174 console.error(error) 175 console.error(`Event:`, event) 176 console.error(`Status:`, status) 177 console.error(`Message:`, message) 178 179 return { message: 'An unexpected error occurred' } 180}