hardening

This commit is contained in:
2026-05-16 10:44:20 +00:00
parent 52e3d680f7
commit cc26ed9b2c
18 changed files with 315 additions and 169 deletions
+17 -11
View File
@@ -2,7 +2,14 @@
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import {
authFetch,
authFetchOrThrow,
ForbiddenError,
getApiBase,
getToken,
UnauthorizedError,
} from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminSetting = {
@@ -109,17 +116,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const loadSettings = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`)
const response = await authFetchOrThrow(`${baseUrl}/admin/settings`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Failed to load settings')
}
const data = await response.json()
@@ -199,6 +197,14 @@ export default function SettingsPage({ section }: SettingsPageProps) {
await loadArtworkPrefetchStatus()
}
} catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err)
setStatus('Could not load admin settings.')
} finally {
+12 -11
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
type Profile = {
username?: string
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
throw new Error('Could not load profile.')
}
const data = await response.json()
setProfile({ username: data?.username })
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
}
}
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
setSubmitting(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/feedback`, {
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -58,17 +60,16 @@ export default function FeedbackPage() {
}),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setMessage('')
setStatus('Thanks! Your message has been sent.')
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
setStatus('That did not send. Please try again.')
} finally {
+34
View File
@@ -23,3 +23,37 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
}
return fetch(input, { ...init, headers })
}
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized')
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends Error {
constructor() {
super('Forbidden')
this.name = 'ForbiddenError'
}
}
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init)
if (response.status === 401) {
clearToken()
throw new UnauthorizedError()
}
if (response.status === 403) {
throw new ForbiddenError()
}
return response
}
export const readResponseText = async (response: Response) => {
try {
return (await response.text()).trim()
} catch {
return ''
}
}
+36 -26
View File
@@ -2,7 +2,13 @@
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
import {
authFetchOrThrow,
ForbiddenError,
getApiBase,
getToken,
UnauthorizedError,
} from './lib/auth'
export default function HomePage() {
const router = useRouter()
@@ -52,13 +58,8 @@ export default function HomePage() {
setRecentError(null)
try {
const baseUrl = getApiBase()
const meResponse = await authFetch(`${baseUrl}/auth/me`)
const meResponse = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Auth failed: ${meResponse.status}`)
}
const me = await meResponse.json()
@@ -66,15 +67,10 @@ export default function HomePage() {
setRole(userRole)
setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6
const response = await authFetch(
const response = await authFetchOrThrow(
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Recent requests failed: ${response.status}`)
}
const data = await response.json()
@@ -99,6 +95,14 @@ export default function HomePage() {
)
}
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
if (error instanceof ForbiddenError) {
router.push('/')
return
}
console.error(error)
setRecentError('Recent requests are not available right now.')
} finally {
@@ -107,7 +111,7 @@ export default function HomePage() {
}
load()
}, [recentDays])
}, [recentDays, router])
useEffect(() => {
if (!authReady) {
@@ -118,18 +122,21 @@ export default function HomePage() {
setServicesError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
const response = await authFetchOrThrow(`${baseUrl}/status/services`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Service status failed: ${response.status}`)
}
const data = await response.json()
setServicesStatus(data)
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
if (error instanceof ForbiddenError) {
router.push('/')
return
}
console.error(error)
setServicesError('Service status is not available right now.')
} finally {
@@ -145,13 +152,8 @@ export default function HomePage() {
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
const response = await authFetchOrThrow(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Search failed: ${response.status}`)
}
const data = await response.json()
@@ -168,6 +170,14 @@ export default function HomePage() {
setSearchError(null)
}
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
if (error instanceof ForbiddenError) {
router.push('/')
return
}
console.error(error)
setSearchError('Search failed. Try a request ID instead.')
setSearchResults([])
+18 -5
View File
@@ -2,7 +2,13 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import {
authFetchOrThrow,
getApiBase,
getToken,
readResponseText,
UnauthorizedError,
} from '../lib/auth'
type ProfileInfo = {
username: string
@@ -26,9 +32,8 @@ export default function ProfilePage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
}
@@ -39,6 +44,10 @@ export default function ProfilePage() {
auth_provider: data?.auth_provider ?? 'local',
})
} catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(err)
setStatus('Could not load your profile.')
} finally {
@@ -57,7 +66,7 @@ export default function ProfilePage() {
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/password`, {
const response = await authFetchOrThrow(`${baseUrl}/auth/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -66,13 +75,17 @@ export default function ProfilePage() {
}),
})
if (!response.ok) {
const text = await response.text()
const text = await readResponseText(response)
throw new Error(text || 'Update failed')
}
setCurrentPassword('')
setNewPassword('')
setStatus('Password updated.')
} catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(err)
setStatus('Could not update password. Check your current password.')
}
+21 -15
View File
@@ -2,7 +2,15 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import {
authFetch,
authFetchOrThrow,
clearToken,
getApiBase,
getToken,
readResponseText,
UnauthorizedError,
} from '../../lib/auth'
type TimelineHop = {
service: string
@@ -502,16 +510,11 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
setModalMessage(null)
}
try {
const response = await authFetch(`${baseUrl}/requests/${snapshot.request_id}/${path}`, {
const response = await authFetchOrThrow(`${baseUrl}/requests/${snapshot.request_id}/${path}`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
const text = await readResponseText(response)
throw new Error(text || `Request failed: ${response.status}`)
}
const data = await response.json()
@@ -538,6 +541,10 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
setModalMessage(message)
}
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
const message = `${action.label} failed. Check the backend logs.`
setActionMessage(message)
@@ -582,7 +589,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
if (!ok) return
const baseUrl = getApiBase()
try {
const response = await authFetch(
const response = await authFetchOrThrow(
`${baseUrl}/requests/${snapshot.request_id}/actions/grab`,
{
method: 'POST',
@@ -595,17 +602,16 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
}
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
const text = await readResponseText(response)
throw new Error(text || `Request failed: ${response.status}`)
}
setActionMessage('Download sent to Sonarr/Radarr.')
setModalMessage('Download sent to Sonarr/Radarr.')
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
const message = 'Download failed. Check the logs.'
setActionMessage(message)
+6 -3
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<string | null>(null)
@@ -16,9 +16,8 @@ export default function HeaderIdentity() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
setIdentity(null)
return
}
@@ -27,6 +26,10 @@ export default function HeaderIdentity() {
setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`)
}
} catch (err) {
if (err instanceof UnauthorizedError) {
setIdentity(null)
return
}
console.error(err)
setIdentity(null)
}
+34 -13
View File
@@ -2,7 +2,13 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import {
authFetchOrThrow,
ForbiddenError,
getApiBase,
getToken,
UnauthorizedError,
} from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type AdminUser = {
@@ -29,17 +35,8 @@ export default function UsersPage() {
const loadUsers = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users`)
const response = await authFetchOrThrow(`${baseUrl}/admin/users`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Could not load users.')
}
const data = await response.json()
@@ -58,6 +55,14 @@ export default function UsersPage() {
}
setError(null)
} catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err)
setError('Could not load user list.')
} finally {
@@ -68,7 +73,7 @@ export default function UsersPage() {
const toggleUserBlock = async (username: string, blocked: boolean) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(
const response = await authFetchOrThrow(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
@@ -77,6 +82,14 @@ export default function UsersPage() {
}
await loadUsers()
} catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err)
setError('Could not update user access.')
}
@@ -85,7 +98,7 @@ export default function UsersPage() {
const updateUserRole = async (username: string, role: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(
const response = await authFetchOrThrow(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
{
method: 'POST',
@@ -98,6 +111,14 @@ export default function UsersPage() {
}
await loadUsers()
} catch (err) {
if (err instanceof UnauthorizedError) {
router.push('/login')
return
}
if (err instanceof ForbiddenError) {
router.push('/')
return
}
console.error(err)
setError('Could not update user role.')
}