diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 58482bd..7ed8483 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,160 +1,178 @@ -import type { Handle } from '@sveltejs/kit'; -import { redirect } from '@sveltejs/kit'; -import * as auth from '$lib/server/auth.js'; -import { building } from '$app/environment'; -import { startWorker } from '$lib/server/worker'; -import { setupTranscriptionWorker } from '$lib/server/queue'; - -const PUBLIC_PATHS = ['/login', '/api/setup', '/api/complete-setup', '/api/auth', '/api/verify', '/api/check-config']; -const ALLOWED_ORIGINS = [ - 'capacitor://localhost', - 'http://localhost:5173', - 'http://localhost', - 'http://localhost:*', - 'http://127.0.0.1:5173', - 'http://127.0.0.1', - 'capacitor://127.0.0.1', - 'http://your-frontend-domain.com' -]; - -if (!building) { - // startWorker().catch(console.error); - await setupTranscriptionWorker().catch(console.error); - console.log("STARTED WORKER -->") -} - -const handleAuth: Handle = async ({ event, resolve }) => { - console.log('[Hooks] Incoming request:', { - path: event.url.pathname, - method: event.request.method, - origin: event.request.headers.get('origin'), - host: event.request.headers.get('host'), - referer: event.request.headers.get('referer') - }); - - // Skip auth check during build time - if (building) { - console.log('[Hooks] Skipping auth check during build time'); - return resolve(event); - } - - const requestOrigin = event.request.headers.get('origin'); - console.log('[Hooks] Request origin:', requestOrigin); - - // Handle CORS - let corsHeaders = {}; - if (requestOrigin) { - const isAllowed = ALLOWED_ORIGINS.some(allowed => { - if (allowed.includes('*')) { - const pattern = new RegExp('^' + allowed.replace('*', '.*') + '$'); - return pattern.test(requestOrigin); - } - return allowed === requestOrigin; - }); - - if (isAllowed) { - corsHeaders = { - 'Access-Control-Allow-Origin': requestOrigin, - 'Access-Control-Allow-Methods': 'GET, PATCH, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With', - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin' - }; - } - } - - if (event.request.method === 'OPTIONS') { - return new Response(null, { - status: 204, - headers: { - ...corsHeaders, - 'Access-Control-Max-Age': '3600', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With' - } - }); - } - - const path = event.url.pathname; - const isPublicPath = PUBLIC_PATHS.some((p) => path.startsWith(p)); - - // Check authentication in this order: Bearer token, URL token (for EventSource), Cookie - let authenticated = false; - - // 1. Check Bearer token - const authHeader = event.request.headers.get('authorization'); - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.slice(7); - try { - const { session, user } = await auth.validateSessionToken(token); - if (session && user) { - event.locals.user = user; - event.locals.session = session; - authenticated = true; - } - } catch (error) { - console.error('[Hooks] Bearer token validation error:', error); - } - } - - // 2. Check URL token (for EventSource) - if (!authenticated) { - const urlToken = event.url.searchParams.get('token'); - if (urlToken) { - console.log('[Hooks] Found URL token, validating...'); - try { - const { session, user } = await auth.validateSessionToken(urlToken); - if (session && user) { - event.locals.user = user; - event.locals.session = session; - authenticated = true; - console.log('[Hooks] URL token validated successfully'); - } - } catch (error) { - console.error('[Hooks] URL token validation error:', error); - } - } - } - - // 3. Check cookie - if (!authenticated) { - const sessionToken = event.cookies.get(auth.sessionCookieName); - if (sessionToken) { - try { - const { session, user } = await auth.validateSessionToken(sessionToken); - if (session && user) { - event.locals.user = user; - event.locals.session = session; - auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); - authenticated = true; - } else if (!isPublicPath) { - auth.deleteSessionTokenCookie(event); - } - } catch (error) { - console.error('[Hooks] Cookie validation error:', error); - } - } - } - - // Handle unauthenticated requests to protected paths - if (!authenticated && !isPublicPath) { - console.log('[Hooks] Unauthenticated request to protected path:', path); - if (path.startsWith('/api/')) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json' - } - }); - } - throw redirect(303, '/login'); - } - - const response = await resolve(event); - Object.entries(corsHeaders).forEach(([key, value]) => { - response.headers.set(key, value); - }); - return response; -}; - -export const handle: Handle = handleAuth; +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import * as auth from '$lib/server/auth.js'; +import { building } from '$app/environment'; +import { startWorker } from '$lib/server/worker'; +import { setupTranscriptionWorker } from '$lib/server/queue'; + +const PUBLIC_PATHS = ['/login', '/api/setup', '/api/complete-setup', '/api/auth', '/api/verify', '/api/check-config']; +const ALLOWED_ORIGINS = [ + 'capacitor://localhost', + 'http://localhost:5173', + 'http://localhost', + 'http://localhost:*', + 'http://127.0.0.1:5173', + 'http://127.0.0.1', + 'capacitor://127.0.0.1', + 'http://your-frontend-domain.com' +]; + +// Static paths that should always show a 401 error for API, not redirect +const API_PATHS = ['/api/']; + +if (!building) { + // startWorker().catch(console.error); + await setupTranscriptionWorker().catch(console.error); + console.log("STARTED WORKER -->") +} + +const handleAuth: Handle = async ({ event, resolve }) => { + console.log('[Hooks] Incoming request:', { + path: event.url.pathname, + method: event.request.method, + origin: event.request.headers.get('origin'), + host: event.request.headers.get('host'), + referer: event.request.headers.get('referer') + }); + + // Skip auth check during build time + if (building) { + console.log('[Hooks] Skipping auth check during build time'); + return resolve(event); + } + + const requestOrigin = event.request.headers.get('origin'); + console.log('[Hooks] Request origin:', requestOrigin); + + // Handle CORS + let corsHeaders = {}; + if (requestOrigin) { + const isAllowed = ALLOWED_ORIGINS.some(allowed => { + if (allowed.includes('*')) { + const pattern = new RegExp('^' + allowed.replace('*', '.*') + '$'); + return pattern.test(requestOrigin); + } + return allowed === requestOrigin; + }); + + if (isAllowed) { + corsHeaders = { + 'Access-Control-Allow-Origin': requestOrigin, + 'Access-Control-Allow-Methods': 'GET, PATCH, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With', + 'Access-Control-Allow-Credentials': 'true', + 'Vary': 'Origin' + }; + } + } + + if (event.request.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + ...corsHeaders, + 'Access-Control-Max-Age': '3600', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With' + } + }); + } + + const path = event.url.pathname; + const isPublicPath = PUBLIC_PATHS.some((p) => path.startsWith(p)); + const isApiPath = API_PATHS.some((p) => path.startsWith(p)); + + // Check authentication in this order: Bearer token, URL token (for EventSource), Cookie + let authenticated = false; + + // 1. Check Bearer token + const authHeader = event.request.headers.get('authorization'); + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + try { + const { session, user } = await auth.validateSessionToken(token); + if (session && user) { + event.locals.user = user; + event.locals.session = session; + authenticated = true; + } + } catch (error) { + console.error('[Hooks] Bearer token validation error:', error); + } + } + + // 2. Check URL token (for EventSource) + if (!authenticated) { + const urlToken = event.url.searchParams.get('token'); + if (urlToken) { + console.log('[Hooks] Found URL token, validating...'); + try { + const { session, user } = await auth.validateSessionToken(urlToken); + if (session && user) { + event.locals.user = user; + event.locals.session = session; + authenticated = true; + console.log('[Hooks] URL token validated successfully'); + } + } catch (error) { + console.error('[Hooks] URL token validation error:', error); + } + } + } + + // 3. Check cookie + if (!authenticated) { + const sessionToken = event.cookies.get(auth.sessionCookieName); + if (sessionToken) { + try { + const { session, user } = await auth.validateSessionToken(sessionToken); + if (session && user) { + event.locals.user = user; + event.locals.session = session; + auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); + authenticated = true; + } else if (!isPublicPath) { + auth.deleteSessionTokenCookie(event); + } + } catch (error) { + console.error('[Hooks] Cookie validation error:', error); + // Explicitly clear cookie on validation error + auth.deleteSessionTokenCookie(event); + } + } + } + + // Handle unauthenticated requests to protected paths + if (!authenticated && !isPublicPath) { + console.log('[Hooks] Unauthenticated request to protected path:', path); + + // API requests return 401 Unauthorized + if (isApiPath || path.startsWith('/api/')) { + return new Response(JSON.stringify({ + error: 'Unauthorized', + code: 'AUTH_REQUIRED', + message: 'Authentication required' + }), { + status: 401, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json' + } + }); + } + + // Non-API requests redirect to login + throw redirect(303, '/login'); + } + + // Continue processing the request + const response = await resolve(event); + + // Add CORS headers to all responses + Object.entries(corsHeaders).forEach(([key, value]) => { + response.headers.set(key, value); + }); + + return response; +}; + +export const handle: Handle = handleAuth; \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 0733ed7..2b12be5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,42 +1,139 @@ -// src/lib/api.ts -import { Preferences } from '@capacitor/preferences'; -import { authToken, serverUrl } from '$lib/stores/config'; -import { get } from 'svelte/store'; - -export async function apiFetch(endpoint: string, options: RequestInit = {}) { - const baseUrl = get(serverUrl); - const token = get(authToken); - - const url = baseUrl ? `${baseUrl}${endpoint}` : endpoint; - const headers = { - ...options.headers, - 'Authorization': token ? `Bearer ${token}` : '', - }; - return fetch(url, { - ...options, - headers - }); -} - -// Get the stored server URL -export async function getServerUrl() { - const { value } = await Preferences.get({ key: 'server_url' }); - if (!value) { - return ''; - } else { - return value; - } -} - -export async function createEventSource(path: string): Promise { - const baseUrl = await getServerUrl(); - const token = get(authToken); - - // Ensure path starts with '/' - const cleanPath = path.startsWith('/') ? path : '/' + path; - - // Create URL with auth token - const url = `${baseUrl}${cleanPath}?token=${token}`; - - return new EventSource(url); -} +// src/lib/api.ts +import { Preferences } from '@capacitor/preferences'; +import { authToken, serverUrl, isAuthenticated } from '$lib/stores/config'; +import { get } from 'svelte/store'; +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; + +const AUTH_ENDPOINTS = ['/api/auth', '/login']; + +// Error indicating authorization failure that needs handling +export class AuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthError'; + } +} + +export async function apiFetch(endpoint: string, options: RequestInit = {}) { + const baseUrl = get(serverUrl); + const token = get(authToken); + + // Do not add auth headers for auth-related endpoints + const isAuthEndpoint = AUTH_ENDPOINTS.some(authPath => endpoint.includes(authPath)); + + const url = baseUrl ? `${baseUrl}${endpoint}` : endpoint; + const headers = { + ...options.headers, + 'Authorization': !isAuthEndpoint && token ? `Bearer ${token}` : '', + }; + + try { + const response = await fetch(url, { + ...options, + headers + }); + + // Handle authentication errors + if (response.status === 401 && !isAuthEndpoint) { + // Clear authentication state + if (browser) { + // Clear in-memory token + authToken.set(''); + isAuthenticated.set(false); + + // Clear from local storage + localStorage.removeItem('sessionToken'); + localStorage.removeItem('sessionExpires'); + + // Navigate to login + goto('/login'); + } + + throw new AuthError('Authentication failed. Please log in again.'); + } + + return response; + } catch (error) { + // Rethrow AuthError instances + if (error instanceof AuthError) { + throw error; + } + + // Handle network errors + if (error instanceof Error && error.message.includes('fetch')) { + console.error('Network error:', error); + // Only redirect on client + if (browser && !isAuthEndpoint) { + goto('/login'); + } + throw new AuthError('Network error. Please check your connection and try again.'); + } + + // Rethrow other errors + throw error; + } +} + +// Get the stored server URL +export async function getServerUrl() { + const { value } = await Preferences.get({ key: 'server_url' }); + if (!value) { + return ''; + } else { + return value; + } +} + +export async function createEventSource(path: string): Promise { + const baseUrl = await getServerUrl(); + const token = get(authToken); + + // Ensure path starts with '/' + const cleanPath = path.startsWith('/') ? path : '/' + path; + + // Create URL with auth token + const url = `${baseUrl}${cleanPath}?token=${token}`; + + return new EventSource(url); +} + +// Check if the token is about to expire and refresh it if needed +export async function checkAndRefreshToken(): Promise { + if (!browser) return false; + + const expiresAtStr = localStorage.getItem('sessionExpires'); + if (!expiresAtStr) return false; + + const expiresAt = new Date(expiresAtStr).getTime(); + const now = Date.now(); + + // If token expires in less than 1 day (86400000 ms), refresh it + const shouldRefresh = expiresAt - now < 86400000 && expiresAt > now; + + if (shouldRefresh) { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${get(authToken)}` + } + }); + + if (response.ok) { + const data = await response.json(); + if (data.token) { + // Update token in localStorage and stores + localStorage.setItem('sessionToken', data.token); + localStorage.setItem('sessionExpires', data.expiresAt); + authToken.set(data.token); + return true; + } + } + } catch (error) { + console.error('Failed to refresh token:', error); + } + } + + return false; +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ed2a809..94763ab 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,37 +1,91 @@ - - - -{#if !check?.isConfigured} - -{:else} - {@render children()} -{/if} + + + +{#if !check?.isConfigured} + +{:else} + {@render children()} +{/if} \ No newline at end of file diff --git a/src/routes/api/auth/refresh/+server.ts b/src/routes/api/auth/refresh/+server.ts new file mode 100644 index 0000000..2ae6d54 --- /dev/null +++ b/src/routes/api/auth/refresh/+server.ts @@ -0,0 +1,50 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as auth from '$lib/server/auth'; + +export const POST: RequestHandler = async ({ request, locals }) => { + try { + // Get bearer token from the request + const authHeader = request.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response(JSON.stringify({ error: 'Invalid authentication header' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + const token = authHeader.slice(7); + + // Validate the token and get user information + const { session, user } = await auth.validateSessionToken(token); + + if (!session || !user) { + return new Response(JSON.stringify({ error: 'Invalid token' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Generate a new token + const newToken = auth.generateSessionToken(); + + // Create a new session with this token + const newSession = await auth.createSession(newToken, user.id); + + // Return the new token and expiration time + return json({ + token: newToken, + expiresAt: newSession.expiresAt.toISOString(), + user: { + username: user.username, + isAdmin: user.isAdmin + } + }); + } catch (error) { + console.error('Token refresh error:', error); + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; \ No newline at end of file diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 1700147..07e2b33 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,27 +1,45 @@ -// routes/login/+page.server.ts -import type { Actions } from './$types'; -import * as auth from '$lib/server/auth'; -import { fail, redirect } from '@sveltejs/kit'; - -export const prerender = false; - -export const actions: Actions = { - default: async (event) => { - const data = await event.request.formData(); - const username = data.get('username'); - const password = data.get('password'); - - if (!username || !password) { - return fail(400, { success: false }); - } - - try { - const { token, session, user } = await auth.login(username.toString(), password.toString()); - auth.setSessionTokenCookie(event, token, session.expiresAt); - return { success: true, token, expiresAt: session.expiresAt }; - } catch (error) { - console.error('Login error:', error); - return fail(400, { success: false }); - } - } -}; +// routes/login/+page.server.ts +import type { Actions } from './$types'; +import * as auth from '$lib/server/auth'; +import { fail, redirect } from '@sveltejs/kit'; + +export const prerender = false; + +export const actions: Actions = { + default: async (event) => { + const data = await event.request.formData(); + const username = data.get('username'); + const password = data.get('password'); + + if (!username || !password) { + return fail(400, { + success: false, + message: 'Username and password are required' + }); + } + + try { + const { token, session, user } = await auth.login(username.toString(), password.toString()); + + // Set the session cookie + auth.setSessionTokenCookie(event, token, session.expiresAt); + + // Return token and expiration info for client-side storage + return { + success: true, + token, + expiresAt: session.expiresAt.toISOString(), + user: { + username: user.username, + isAdmin: user.isAdmin + } + }; + } catch (error) { + console.error('Login error:', error); + return fail(400, { + success: false, + message: error instanceof Error ? error.message : 'Authentication failed' + }); + } + } +}; \ No newline at end of file diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index de7dfc8..7755d9d 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,61 +1,102 @@ - - -
- - - Admin Login - Enter your credentials to access the dashboard - - - {#if error} - - {error} - - {/if} -
-
- - -
-
- - -
- -
-
-
-
+ + +
+ + + Admin Login + Enter your credentials to access the dashboard + + + {#if error} + + {error} + + {/if} +
+
+ + +
+
+ + +
+ +
+
+
+
\ No newline at end of file