fix(csrf): make errors more apparent (#4889)
This commit is contained in:
@@ -335,17 +335,23 @@ namespace confighttp {
|
|||||||
const auto token_it = csrf_tokens.find(client_id);
|
const auto token_it = csrf_tokens.find(client_id);
|
||||||
|
|
||||||
if (token_it == csrf_tokens.end()) {
|
if (token_it == csrf_tokens.end()) {
|
||||||
|
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||||
|
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF token validation failed: no token found for client"sv;
|
||||||
bad_request(response, request, "Invalid CSRF token");
|
bad_request(response, request, "Invalid CSRF token");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (const auto now = std::chrono::steady_clock::now(); token_it->second.expiration < now) {
|
if (const auto now = std::chrono::steady_clock::now(); token_it->second.expiration < now) {
|
||||||
csrf_tokens.erase(token_it);
|
csrf_tokens.erase(token_it);
|
||||||
|
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||||
|
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF token validation failed: token expired"sv;
|
||||||
bad_request(response, request, "CSRF token expired");
|
bad_request(response, request, "CSRF token expired");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token_it->second.token != provided_token) {
|
if (token_it->second.token != provided_token) {
|
||||||
|
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||||
|
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF token validation failed: token mismatch"sv;
|
||||||
bad_request(response, request, "Invalid CSRF token");
|
bad_request(response, request, "Invalid CSRF token");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -390,6 +396,7 @@ namespace confighttp {
|
|||||||
|
|
||||||
// A browser-like request arrived with an Origin/Referer that doesn't match an allowed origin.
|
// A browser-like request arrived with an Origin/Referer that doesn't match an allowed origin.
|
||||||
// Require a CSRF token.
|
// Require a CSRF token.
|
||||||
|
const std::string_view blocked_origin = (origin_it != request->header.end()) ? origin_it->second : referer_it->second;
|
||||||
// Extract token from X-CSRF-Token header
|
// Extract token from X-CSRF-Token header
|
||||||
const auto header_it = request->header.find("X-CSRF-Token");
|
const auto header_it = request->header.find("X-CSRF-Token");
|
||||||
if (header_it == request->header.end()) {
|
if (header_it == request->header.end()) {
|
||||||
@@ -397,6 +404,9 @@ namespace confighttp {
|
|||||||
auto query_params = request->parse_query_string();
|
auto query_params = request->parse_query_string();
|
||||||
const auto query_it = query_params.find("csrf_token");
|
const auto query_it = query_params.find("csrf_token");
|
||||||
if (query_it == query_params.end()) {
|
if (query_it == query_params.end()) {
|
||||||
|
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
|
||||||
|
BOOST_LOG(error) << "Web UI: ["sv << address << "] -- CSRF protection blocked request from origin: "sv << blocked_origin;
|
||||||
|
BOOST_LOG(error) << "Web UI: To allow this origin, add it to the 'csrf_allowed_origins' option in your Sunshine configuration"sv;
|
||||||
bad_request(response, request, "Missing CSRF token");
|
bad_request(response, request, "Missing CSRF token");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="navbar navbar-expand-lg navbar-sunshine">
|
<div>
|
||||||
<div class="container-fluid">
|
<nav class="navbar navbar-expand-lg navbar-sunshine">
|
||||||
<a class="navbar-brand" href="./" title="Sunshine">
|
<div class="container-fluid">
|
||||||
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
|
<a class="navbar-brand" href="./" title="Sunshine">
|
||||||
</a>
|
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
|
</a>
|
||||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
|
||||||
<span class="navbar-toggler-icon"></span>
|
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
</button>
|
<span class="navbar-toggler-icon"></span>
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
</button>
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
<li class="nav-item">
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
<a class="nav-link" href="./">
|
<li class="nav-item">
|
||||||
<Home :size="18" class="icon"></Home>
|
<a class="nav-link" href="./">
|
||||||
{{ $t('navbar.home') }}
|
<Home :size="18" class="icon"></Home>
|
||||||
</a>
|
{{ $t('navbar.home') }}
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link" href="./pin">
|
<li class="nav-item">
|
||||||
<Lock :size="18" class="icon"></Lock>
|
<a class="nav-link" href="./pin">
|
||||||
{{ $t('navbar.pin') }}
|
<Lock :size="18" class="icon"></Lock>
|
||||||
</a>
|
{{ $t('navbar.pin') }}
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link" href="./apps">
|
<li class="nav-item">
|
||||||
<Layers :size="18" class="icon"></Layers>
|
<a class="nav-link" href="./apps">
|
||||||
{{ $t('navbar.applications') }}
|
<Layers :size="18" class="icon"></Layers>
|
||||||
</a>
|
{{ $t('navbar.applications') }}
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link" href="./featured">
|
<li class="nav-item">
|
||||||
<Star :size="18" class="icon"></Star>
|
<a class="nav-link" href="./featured">
|
||||||
{{ $t('navbar.featured') }}
|
<Star :size="18" class="icon"></Star>
|
||||||
</a>
|
{{ $t('navbar.featured') }}
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link" href="./config">
|
<li class="nav-item">
|
||||||
<Settings :size="18" class="icon"></Settings>
|
<a class="nav-link" href="./config">
|
||||||
{{ $t('navbar.configuration') }}
|
<Settings :size="18" class="icon"></Settings>
|
||||||
</a>
|
{{ $t('navbar.configuration') }}
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link" href="./password">
|
<li class="nav-item">
|
||||||
<Shield :size="18" class="icon"></Shield>
|
<a class="nav-link" href="./password">
|
||||||
{{ $t('navbar.password') }}
|
<Shield :size="18" class="icon"></Shield>
|
||||||
</a>
|
{{ $t('navbar.password') }}
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<a class="nav-link" href="./troubleshooting">
|
<li class="nav-item">
|
||||||
<Info :size="18" class="icon"></Info>
|
<a class="nav-link" href="./troubleshooting">
|
||||||
{{ $t('navbar.troubleshoot') }}
|
<Info :size="18" class="icon"></Info>
|
||||||
</a>
|
{{ $t('navbar.troubleshoot') }}
|
||||||
</li>
|
</a>
|
||||||
<li class="nav-item">
|
</li>
|
||||||
<ThemeToggle/>
|
<li class="nav-item">
|
||||||
</li>
|
<ThemeToggle/>
|
||||||
</ul>
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
</nav>
|
<Notification></Notification>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Home, Lock, Layers, Star, Settings, Shield, Info } from 'lucide-vue-next'
|
import { Home, Lock, Layers, Star, Settings, Shield, Info } from 'lucide-vue-next'
|
||||||
import ThemeToggle from './ThemeToggle.vue'
|
import ThemeToggle from './ThemeToggle.vue'
|
||||||
|
import Notification from './Notification.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ThemeToggle,
|
ThemeToggle,
|
||||||
|
Notification,
|
||||||
Home,
|
Home,
|
||||||
Lock,
|
Lock,
|
||||||
Layers,
|
Layers,
|
||||||
|
|||||||
150
src_assets/common/assets/web/Notification.vue
Normal file
150
src_assets/common/assets/web/Notification.vue
Normal file
@@ -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 { initApp } from './init'
|
||||||
import Navbar from './Navbar.vue'
|
import Navbar from './Navbar.vue'
|
||||||
import Checkbox from './Checkbox.vue'
|
import Checkbox from './Checkbox.vue'
|
||||||
|
import { apiFetch } from './fetch_utils'
|
||||||
import { Modal } from 'bootstrap/dist/js/bootstrap'
|
import { Modal } from 'bootstrap/dist/js/bootstrap'
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -608,7 +609,7 @@
|
|||||||
"Are you sure to delete " + this.apps[id].name + "?"
|
"Are you sure to delete " + this.apps[id].name + "?"
|
||||||
);
|
);
|
||||||
if (resp) {
|
if (resp) {
|
||||||
fetch("./api/apps/" + id, {
|
apiFetch("./api/apps/" + id, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -705,7 +706,7 @@
|
|||||||
},
|
},
|
||||||
useCover(cover) {
|
useCover(cover) {
|
||||||
this.coverFinderBusy = true;
|
this.coverFinderBusy = true;
|
||||||
fetch("./api/covers/upload", {
|
apiFetch("./api/covers/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -808,7 +809,7 @@
|
|||||||
},
|
},
|
||||||
save() {
|
save() {
|
||||||
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
|
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
|
||||||
fetch("./api/apps", {
|
apiFetch("./api/apps", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
import { computed, createApp } from 'vue'
|
import { computed, createApp } from 'vue'
|
||||||
import { initApp } from './init'
|
import { initApp } from './init'
|
||||||
import Navbar from './Navbar.vue'
|
import Navbar from './Navbar.vue'
|
||||||
|
import { apiFetch } from './fetch_utils'
|
||||||
import General from './configs/tabs/General.vue'
|
import General from './configs/tabs/General.vue'
|
||||||
import Inputs from './configs/tabs/Inputs.vue'
|
import Inputs from './configs/tabs/Inputs.vue'
|
||||||
import Network from './configs/tabs/Network.vue'
|
import Network from './configs/tabs/Network.vue'
|
||||||
@@ -469,7 +470,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return fetch("./api/config", {
|
return apiFetch("./api/config", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -495,7 +496,7 @@
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.saved = this.restarted = false;
|
this.saved = this.restarted = false;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
fetch("./api/restart", {
|
apiFetch("./api/restart", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
|
|||||||
33
src_assets/common/assets/web/fetch_utils.js
Normal file
33
src_assets/common/assets/web/fetch_utils.js
Normal file
@@ -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 { createApp } from 'vue'
|
||||||
import { initApp } from './init'
|
import { initApp } from './init'
|
||||||
import Navbar from './Navbar.vue'
|
import Navbar from './Navbar.vue'
|
||||||
|
import { apiFetch } from './fetch_utils'
|
||||||
import { Save } from 'lucide-vue-next'
|
import { Save } from 'lucide-vue-next'
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
methods: {
|
methods: {
|
||||||
save() {
|
save() {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
fetch("./api/password", {
|
apiFetch("./api/password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { initApp } from './init'
|
import { initApp } from './init'
|
||||||
import Navbar from './Navbar.vue'
|
import Navbar from './Navbar.vue'
|
||||||
|
import { apiFetch } from './fetch_utils'
|
||||||
import {
|
import {
|
||||||
Forward,
|
Forward,
|
||||||
Hash,
|
Hash,
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
let name = document.querySelector("#name-input").value;
|
let name = document.querySelector("#name-input").value;
|
||||||
document.querySelector("#status").innerHTML = "";
|
document.querySelector("#status").innerHTML = "";
|
||||||
let b = JSON.stringify({pin: pin, name: name});
|
let b = JSON.stringify({pin: pin, name: name});
|
||||||
fetch("./api/pin", {
|
apiFetch("./api/pin", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"browse": "Browse",
|
"browse": "Browse",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
"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": "Disabled",
|
||||||
"disabled_def": "Disabled (default)",
|
"disabled_def": "Disabled (default)",
|
||||||
"disabled_def_cbox": "Default: unchecked",
|
"disabled_def_cbox": "Default: unchecked",
|
||||||
|
|||||||
@@ -193,6 +193,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { initApp } from './init'
|
import { initApp } from './init'
|
||||||
import Navbar from './Navbar.vue'
|
import Navbar from './Navbar.vue'
|
||||||
|
import { apiFetch } from './fetch_utils'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -410,7 +411,7 @@
|
|||||||
},
|
},
|
||||||
closeApp() {
|
closeApp() {
|
||||||
this.closeAppPressed = true;
|
this.closeAppPressed = true;
|
||||||
fetch("./api/apps/close", {
|
apiFetch("./api/apps/close", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -426,7 +427,7 @@
|
|||||||
},
|
},
|
||||||
unpairAll() {
|
unpairAll() {
|
||||||
this.unpairAllPressed = true;
|
this.unpairAllPressed = true;
|
||||||
fetch("./api/clients/unpair-all", {
|
apiFetch("./api/clients/unpair-all", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -443,7 +444,7 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
unpairSingle(uuid) {
|
unpairSingle(uuid) {
|
||||||
fetch("./api/clients/unpair", {
|
apiFetch("./api/clients/unpair", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -493,7 +494,7 @@
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.restartPressed = false;
|
this.restartPressed = false;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
fetch("./api/restart", {
|
apiFetch("./api/restart", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -502,7 +503,7 @@
|
|||||||
},
|
},
|
||||||
ddResetPersistence() {
|
ddResetPersistence() {
|
||||||
this.ddResetPressed = true;
|
this.ddResetPressed = true;
|
||||||
fetch("/api/reset-display-device-persistence", {
|
apiFetch("/api/reset-display-device-persistence", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -536,7 +537,7 @@
|
|||||||
this.vigemBusInstallPressed = true;
|
this.vigemBusInstallPressed = true;
|
||||||
this.vigemBusInstallStatus = null;
|
this.vigemBusInstallStatus = null;
|
||||||
this.vigemBusInstallError = null;
|
this.vigemBusInstallError = null;
|
||||||
fetch("/api/vigembus/install", {
|
apiFetch("/api/vigembus/install", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="app" v-cloak>
|
<body id="app" v-cloak>
|
||||||
|
<Notification></Notification>
|
||||||
<main role="main" style="max-width: 1200px; margin: 1em auto">
|
<main role="main" style="max-width: 1200px; margin: 1em auto">
|
||||||
<div class="d-flex gap-4">
|
<div class="d-flex gap-4">
|
||||||
<div class="card p-2">
|
<div class="card p-2">
|
||||||
@@ -55,10 +56,13 @@
|
|||||||
import { createApp } from "vue"
|
import { createApp } from "vue"
|
||||||
import ResourceCard from './ResourceCard.vue'
|
import ResourceCard from './ResourceCard.vue'
|
||||||
import { initApp } from './init'
|
import { initApp } from './init'
|
||||||
|
import { apiFetch } from './fetch_utils'
|
||||||
|
import Notification from './Notification.vue'
|
||||||
|
|
||||||
let app = createApp({
|
let app = createApp({
|
||||||
components: {
|
components: {
|
||||||
ResourceCard
|
ResourceCard,
|
||||||
|
Notification,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -76,7 +80,7 @@
|
|||||||
save() {
|
save() {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
fetch("./api/password", {
|
apiFetch("./api/password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|||||||
Reference in New Issue
Block a user