mirror of
https://github.com/LizardByte/Sunshine.git
synced 2026-08-07 10:20:46 +00:00
fix(csrf): make errors more apparent (#4889)
This commit is contained in:
@@ -1,73 +1,78 @@
|
||||
<template>
|
||||
<nav class="navbar navbar-expand-lg navbar-sunshine">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="./" title="Sunshine">
|
||||
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
|
||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./">
|
||||
<Home :size="18" class="icon"></Home>
|
||||
{{ $t('navbar.home') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./pin">
|
||||
<Lock :size="18" class="icon"></Lock>
|
||||
{{ $t('navbar.pin') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./apps">
|
||||
<Layers :size="18" class="icon"></Layers>
|
||||
{{ $t('navbar.applications') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./featured">
|
||||
<Star :size="18" class="icon"></Star>
|
||||
{{ $t('navbar.featured') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./config">
|
||||
<Settings :size="18" class="icon"></Settings>
|
||||
{{ $t('navbar.configuration') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./password">
|
||||
<Shield :size="18" class="icon"></Shield>
|
||||
{{ $t('navbar.password') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./troubleshooting">
|
||||
<Info :size="18" class="icon"></Info>
|
||||
{{ $t('navbar.troubleshoot') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<ThemeToggle/>
|
||||
</li>
|
||||
</ul>
|
||||
<div>
|
||||
<nav class="navbar navbar-expand-lg navbar-sunshine">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="./" title="Sunshine">
|
||||
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
|
||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./">
|
||||
<Home :size="18" class="icon"></Home>
|
||||
{{ $t('navbar.home') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./pin">
|
||||
<Lock :size="18" class="icon"></Lock>
|
||||
{{ $t('navbar.pin') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./apps">
|
||||
<Layers :size="18" class="icon"></Layers>
|
||||
{{ $t('navbar.applications') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./featured">
|
||||
<Star :size="18" class="icon"></Star>
|
||||
{{ $t('navbar.featured') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./config">
|
||||
<Settings :size="18" class="icon"></Settings>
|
||||
{{ $t('navbar.configuration') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./password">
|
||||
<Shield :size="18" class="icon"></Shield>
|
||||
{{ $t('navbar.password') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="./troubleshooting">
|
||||
<Info :size="18" class="icon"></Info>
|
||||
{{ $t('navbar.troubleshoot') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<ThemeToggle/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
<Notification></Notification>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Home, Lock, Layers, Star, Settings, Shield, Info } from 'lucide-vue-next'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import Notification from './Notification.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ThemeToggle,
|
||||
Notification,
|
||||
Home,
|
||||
Lock,
|
||||
Layers,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="notification-container" v-if="state.notifications.length > 0">
|
||||
<div
|
||||
v-for="n in state.notifications"
|
||||
:key="n.id"
|
||||
class="alert d-flex align-items-start gap-2 mb-2"
|
||||
:class="'alert-' + n.type"
|
||||
role="alert"
|
||||
>
|
||||
<component :is="iconFor(n.type)" :size="18" class="icon flex-shrink-0 mt-1"></component>
|
||||
<div class="flex-grow-1">
|
||||
<div v-if="n.titleKey || n.title">
|
||||
<strong>{{ n.titleKey ? $t(n.titleKey) : n.title }}</strong>
|
||||
</div>
|
||||
<span>{{ n.messageKey ? $t(n.messageKey) : n.message }}</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close" :aria-label="$t('_common.dismiss')" @click="dismiss(n.id)"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { reactive } from 'vue'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Info } from 'lucide-vue-next'
|
||||
|
||||
/**
|
||||
* Singleton reactive notification state shared across the app instance.
|
||||
* Using reactive() at module scope means all consumers — including plain JS
|
||||
* modules like fetch_utils.js — mutate the same reactive object, and any
|
||||
* mounted Notification component will update automatically.
|
||||
*/
|
||||
export const state = reactive({
|
||||
notifications: [],
|
||||
_nextId: 1,
|
||||
})
|
||||
|
||||
/**
|
||||
* Push a notification using raw strings.
|
||||
*
|
||||
* @param {'danger'|'warning'|'success'|'info'} type - Bootstrap color variant.
|
||||
* @param {string} message - The notification body text.
|
||||
* @param {string} [title] - Optional bold title prefix.
|
||||
*/
|
||||
function push(type, message, title) {
|
||||
state.notifications.push({ id: state._nextId++, type, message, title: title || null, messageKey: null, titleKey: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a notification using i18n translation keys.
|
||||
*
|
||||
* @param {'danger'|'warning'|'success'|'info'} type - Bootstrap color variant.
|
||||
* @param {string} messageKey - i18n key for the notification body.
|
||||
* @param {string} [titleKey] - Optional i18n key for the bold title prefix.
|
||||
*/
|
||||
function pushKey(type, messageKey, titleKey) {
|
||||
state.notifications.push({ id: state._nextId++, type, message: null, title: null, messageKey, titleKey: titleKey || null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Bootstrap color variant to its corresponding lucide-vue-next icon name.
|
||||
*
|
||||
* @param {'danger'|'warning'|'success'|'info'} type - Bootstrap color variant.
|
||||
* @returns {string} The lucide icon component name.
|
||||
*/
|
||||
function iconFor(type) {
|
||||
return { danger: 'AlertCircle', warning: 'AlertTriangle', success: 'CheckCircle', info: 'Info' }[type] || 'Info'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helpers for the four common variants using raw strings.
|
||||
*/
|
||||
export const notify = {
|
||||
/**
|
||||
* @param {string} message - The notification body text.
|
||||
* @param {string} [title] - Optional bold title shown above the message.
|
||||
*/
|
||||
error: (message, title) => push('danger', message, title),
|
||||
/**
|
||||
* @param {string} message - The notification body text.
|
||||
* @param {string} [title] - Optional bold title shown above the message.
|
||||
*/
|
||||
warning: (message, title) => push('warning', message, title),
|
||||
/**
|
||||
* @param {string} message - The notification body text.
|
||||
* @param {string} [title] - Optional bold title shown above the message.
|
||||
*/
|
||||
success: (message, title) => push('success', message, title),
|
||||
/**
|
||||
* @param {string} message - The notification body text.
|
||||
* @param {string} [title] - Optional bold title shown above the message.
|
||||
*/
|
||||
info: (message, title) => push('info', message, title),
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helpers for the four common variants using i18n keys.
|
||||
*/
|
||||
export const notifyKey = {
|
||||
/**
|
||||
* @param {string} messageKey - i18n key for the notification body text.
|
||||
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
|
||||
*/
|
||||
error: (messageKey, titleKey) => pushKey('danger', messageKey, titleKey),
|
||||
/**
|
||||
* @param {string} messageKey - i18n key for the notification body text.
|
||||
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
|
||||
*/
|
||||
warning: (messageKey, titleKey) => pushKey('warning', messageKey, titleKey),
|
||||
/**
|
||||
* @param {string} messageKey - i18n key for the notification body text.
|
||||
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
|
||||
*/
|
||||
success: (messageKey, titleKey) => pushKey('success', messageKey, titleKey),
|
||||
/**
|
||||
* @param {string} messageKey - i18n key for the notification body text.
|
||||
* @param {string} [titleKey] - Optional i18n key for the bold title shown above the message.
|
||||
*/
|
||||
info: (messageKey, titleKey) => pushKey('info', messageKey, titleKey),
|
||||
}
|
||||
|
||||
export default {
|
||||
components: { AlertCircle, AlertTriangle, CheckCircle, Info },
|
||||
|
||||
setup() {
|
||||
function dismiss(id) {
|
||||
const idx = state.notifications.findIndex(n => n.id === id)
|
||||
if (idx !== -1) state.notifications.splice(idx, 1)
|
||||
}
|
||||
|
||||
return { state, dismiss, iconFor }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 1090;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.notification-container {
|
||||
left: auto;
|
||||
width: 22rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -481,6 +481,7 @@
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import Checkbox from './Checkbox.vue'
|
||||
import { apiFetch } from './fetch_utils'
|
||||
import { Modal } from 'bootstrap/dist/js/bootstrap'
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -608,7 +609,7 @@
|
||||
"Are you sure to delete " + this.apps[id].name + "?"
|
||||
);
|
||||
if (resp) {
|
||||
fetch("./api/apps/" + id, {
|
||||
apiFetch("./api/apps/" + id, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -705,7 +706,7 @@
|
||||
},
|
||||
useCover(cover) {
|
||||
this.coverFinderBusy = true;
|
||||
fetch("./api/covers/upload", {
|
||||
apiFetch("./api/covers/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -808,7 +809,7 @@
|
||||
},
|
||||
save() {
|
||||
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
|
||||
fetch("./api/apps", {
|
||||
apiFetch("./api/apps", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
import { computed, createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import { apiFetch } from './fetch_utils'
|
||||
import General from './configs/tabs/General.vue'
|
||||
import Inputs from './configs/tabs/Inputs.vue'
|
||||
import Network from './configs/tabs/Network.vue'
|
||||
@@ -469,7 +470,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
return fetch("./api/config", {
|
||||
return apiFetch("./api/config", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -495,7 +496,7 @@
|
||||
setTimeout(() => {
|
||||
this.saved = this.restarted = false;
|
||||
}, 5000);
|
||||
fetch("./api/restart", {
|
||||
apiFetch("./api/restart", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { notifyKey } from './Notification.vue'
|
||||
|
||||
/**
|
||||
* The set of error messages that indicate a CSRF validation failure.
|
||||
*/
|
||||
const CSRF_ERRORS = new Set(['Missing CSRF token', 'Invalid CSRF token', 'CSRF token expired'])
|
||||
|
||||
/**
|
||||
* Wrapper around the native fetch that automatically detects CSRF errors
|
||||
* (HTTP 400 with a known CSRF error message) and displays a notification.
|
||||
*
|
||||
* @param {string} url - The URL to fetch.
|
||||
* @param {RequestInit} [options] - Standard fetch options.
|
||||
* @returns {Promise<Response>} The fetch Response.
|
||||
*/
|
||||
export async function apiFetch(url, options) {
|
||||
const response = await fetch(url, options)
|
||||
|
||||
if (response.status === 400) {
|
||||
let body = null
|
||||
try {
|
||||
body = await response.clone().json()
|
||||
} catch (e) {
|
||||
console.debug('apiFetch: response body is not JSON', e)
|
||||
}
|
||||
|
||||
if (body && CSRF_ERRORS.has(body.error)) {
|
||||
notifyKey.error('_common.csrf_error_desc', '_common.csrf_error')
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -64,6 +64,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import { apiFetch } from './fetch_utils'
|
||||
import { Save } from 'lucide-vue-next'
|
||||
|
||||
const app = createApp({
|
||||
@@ -87,7 +88,7 @@
|
||||
methods: {
|
||||
save() {
|
||||
this.error = null;
|
||||
fetch("./api/password", {
|
||||
apiFetch("./api/password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import { apiFetch } from './fetch_utils'
|
||||
import {
|
||||
Forward,
|
||||
Hash,
|
||||
@@ -60,7 +61,7 @@
|
||||
let name = document.querySelector("#name-input").value;
|
||||
document.querySelector("#status").innerHTML = "";
|
||||
let b = JSON.stringify({pin: pin, name: name});
|
||||
fetch("./api/pin", {
|
||||
apiFetch("./api/pin", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"browse": "Browse",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"csrf_error": "CSRF Protection Error",
|
||||
"csrf_error_desc": "The request was blocked by CSRF protection. If you are accessing Sunshine from a non-default URL or reverse proxy, add that origin to the 'csrf_allowed_origins' option in your configuration.",
|
||||
"disabled": "Disabled",
|
||||
"disabled_def": "Disabled (default)",
|
||||
"disabled_def_cbox": "Default: unchecked",
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import { apiFetch } from './fetch_utils'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -410,7 +411,7 @@
|
||||
},
|
||||
closeApp() {
|
||||
this.closeAppPressed = true;
|
||||
fetch("./api/apps/close", {
|
||||
apiFetch("./api/apps/close", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -426,7 +427,7 @@
|
||||
},
|
||||
unpairAll() {
|
||||
this.unpairAllPressed = true;
|
||||
fetch("./api/clients/unpair-all", {
|
||||
apiFetch("./api/clients/unpair-all", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -443,7 +444,7 @@
|
||||
});
|
||||
},
|
||||
unpairSingle(uuid) {
|
||||
fetch("./api/clients/unpair", {
|
||||
apiFetch("./api/clients/unpair", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -493,7 +494,7 @@
|
||||
setTimeout(() => {
|
||||
this.restartPressed = false;
|
||||
}, 5000);
|
||||
fetch("./api/restart", {
|
||||
apiFetch("./api/restart", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -502,7 +503,7 @@
|
||||
},
|
||||
ddResetPersistence() {
|
||||
this.ddResetPressed = true;
|
||||
fetch("/api/reset-display-device-persistence", {
|
||||
apiFetch("/api/reset-display-device-persistence", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -536,7 +537,7 @@
|
||||
this.vigemBusInstallPressed = true;
|
||||
this.vigemBusInstallStatus = null;
|
||||
this.vigemBusInstallError = null;
|
||||
fetch("/api/vigembus/install", {
|
||||
apiFetch("/api/vigembus/install", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</head>
|
||||
|
||||
<body id="app" v-cloak>
|
||||
<Notification></Notification>
|
||||
<main role="main" style="max-width: 1200px; margin: 1em auto">
|
||||
<div class="d-flex gap-4">
|
||||
<div class="card p-2">
|
||||
@@ -55,10 +56,13 @@
|
||||
import { createApp } from "vue"
|
||||
import ResourceCard from './ResourceCard.vue'
|
||||
import { initApp } from './init'
|
||||
import { apiFetch } from './fetch_utils'
|
||||
import Notification from './Notification.vue'
|
||||
|
||||
let app = createApp({
|
||||
components: {
|
||||
ResourceCard
|
||||
ResourceCard,
|
||||
Notification,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -76,7 +80,7 @@
|
||||
save() {
|
||||
this.error = null;
|
||||
this.loading = true;
|
||||
fetch("./api/password", {
|
||||
apiFetch("./api/password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
Reference in New Issue
Block a user