hardening
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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([])
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.')
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -1,2 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
+20
-4
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -12,8 +16,20 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user