fix/correctif-sec #12
@@ -22,6 +22,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="errorMessage" class="empty-state error-state">
|
||||||
|
<IconifyIcon icon="mdi:alert-circle-outline" class="text-3xl text-m-error/70" />
|
||||||
|
<p class="mt-2 font-mono text-xs text-m-error/80">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="backups.length === 0" class="empty-state">
|
<div v-else-if="backups.length === 0" class="empty-state">
|
||||||
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
|
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
|
||||||
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
||||||
@@ -55,6 +62,7 @@
|
|||||||
import {Icon as IconifyIcon} from "@iconify/vue"
|
import {Icon as IconifyIcon} from "@iconify/vue"
|
||||||
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
|
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
|
||||||
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
||||||
|
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
folder: string | null
|
folder: string | null
|
||||||
@@ -62,31 +70,42 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const backups = ref<string[]>([])
|
const backups = ref<string[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref("")
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (!props.folder) return "Fichiers"
|
if (!props.folder) return "Fichiers"
|
||||||
return `Backup — ${props.folder.toUpperCase()}`
|
return `Backup — ${props.folder.toUpperCase()}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const downloadBackup = (file: string) => {
|
const downloadBackup = async (file: string) => {
|
||||||
if (!props.folder) return
|
if (!props.folder) return
|
||||||
const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}`
|
const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}`
|
||||||
window.location.href = url
|
errorMessage.value = ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadApiFile(url, file)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur telechargement backup:", error)
|
||||||
|
errorMessage.value = "Erreur lors de l'opération"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.folder, async (folder) => {
|
watch(() => props.folder, async (folder) => {
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
backups.value = []
|
backups.value = []
|
||||||
|
errorMessage.value = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
errorMessage.value = ""
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
|
const data = await apiFetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
|
||||||
backups.value = data
|
backups.value = data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur récupération backups:", error)
|
console.error("Erreur récupération backups:", error)
|
||||||
backups.value = []
|
backups.value = []
|
||||||
|
errorMessage.value = "Erreur lors de l'opération"
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -120,6 +139,12 @@ watch(() => props.folder, async (folder) => {
|
|||||||
padding: 2.5rem 1rem;
|
padding: 2.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgb(var(--m-error) / 0.12);
|
||||||
|
background: rgb(var(--m-error) / 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.file-list {
|
.file-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { Icon as IconifyIcon } from "@iconify/vue"
|
import { Icon as IconifyIcon } from "@iconify/vue"
|
||||||
|
import { apiFetch } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
type BackupScript = {
|
type BackupScript = {
|
||||||
key: string
|
key: string
|
||||||
@@ -134,12 +135,12 @@ const loadScripts = async () => {
|
|||||||
downloadFolders: []
|
downloadFolders: []
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<BackupScriptListResponse>("/api/backup-script")
|
const data = await apiFetch<BackupScriptListResponse>("/api/backup-script")
|
||||||
scripts.value = data.scripts
|
scripts.value = data.scripts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
scripts.value = []
|
scripts.value = []
|
||||||
isError.value = true
|
isError.value = true
|
||||||
message.value = `Erreur chargement scripts: ${error instanceof Error ? error.message : String(error)}`
|
message.value = "Erreur lors de l'opération"
|
||||||
emit("result", {
|
emit("result", {
|
||||||
key: null,
|
key: null,
|
||||||
label: "",
|
label: "",
|
||||||
@@ -160,7 +161,7 @@ const runScript = async (key: string) => {
|
|||||||
isError.value = false
|
isError.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<BackupScriptRunResponse>("/api/backup-script", {
|
const data = await apiFetch<BackupScriptRunResponse>("/api/backup-script", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { key }
|
body: { key }
|
||||||
})
|
})
|
||||||
@@ -175,7 +176,7 @@ const runScript = async (key: string) => {
|
|||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
isError.value = true
|
isError.value = true
|
||||||
message.value = error?.data?.statusMessage || "Erreur execution script"
|
message.value = error?.data?.statusMessage || "Erreur lors de l'opération"
|
||||||
output.value = ""
|
output.value = ""
|
||||||
emit("result", {
|
emit("result", {
|
||||||
key,
|
key,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {Icon as IconifyIcon} from "@iconify/vue"
|
import {Icon as IconifyIcon} from "@iconify/vue"
|
||||||
const { data: messages } = await useFetch('/api/discord/messages')
|
import { apiFetch } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
|
const { data: messages, error } = await useFetch('/api/discord/messages', {
|
||||||
|
$fetch: apiFetch,
|
||||||
|
server: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -13,7 +18,14 @@ const { data: messages } = await useFetch('/api/discord/messages')
|
|||||||
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span>
|
<span class="font-mono text-[10px] text-m-muted tracking-widest uppercase">Messages</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!messages || messages.length === 0" class="empty-state">
|
<div v-if="error" class="empty-state error-state">
|
||||||
|
<IconifyIcon icon="mdi:alert-circle-outline" class="text-3xl text-m-error/70" />
|
||||||
|
<p class="mt-2 font-mono text-xs text-m-error/80">
|
||||||
|
Erreur lors de l'opération
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!messages || messages.length === 0" class="empty-state">
|
||||||
<IconifyIcon icon="mdi:chat-outline" class="text-3xl text-m-muted/40" />
|
<IconifyIcon icon="mdi:chat-outline" class="text-3xl text-m-muted/40" />
|
||||||
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
||||||
Aucun message
|
Aucun message
|
||||||
@@ -74,6 +86,12 @@ const { data: messages } = await useFetch('/api/discord/messages')
|
|||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgb(var(--m-error) / 0.12);
|
||||||
|
background: rgb(var(--m-error) / 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -36,17 +36,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="error-text" role="status" aria-live="polite">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref} from "vue"
|
import {computed, ref} from "vue"
|
||||||
import {Icon as IconifyIcon} from "@iconify/vue"
|
import {Icon as IconifyIcon} from "@iconify/vue"
|
||||||
|
import { apiRequest } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
const ping = ref<number | null>(null)
|
const ping = ref<number | null>(null)
|
||||||
const download = ref<number | null>(null)
|
const download = ref<number | null>(null)
|
||||||
const upload = ref<number | null>(null)
|
const upload = ref<number | null>(null)
|
||||||
const isTesting = ref(false)
|
const isTesting = ref(false)
|
||||||
|
const errorMessage = ref("")
|
||||||
|
|
||||||
const metrics = computed(() => [
|
const metrics = computed(() => [
|
||||||
{ label: "Download", icon: "mdi:arrow-down-bold", value: download.value, unit: "Mbps" },
|
{ label: "Download", icon: "mdi:arrow-down-bold", value: download.value, unit: "Mbps" },
|
||||||
@@ -56,7 +62,10 @@ const metrics = computed(() => [
|
|||||||
|
|
||||||
async function testDownload() {
|
async function testDownload() {
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
const res = await fetch('/api/download')
|
const res = await apiRequest('/api/download')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const end = performance.now()
|
const end = performance.now()
|
||||||
const size = blob.size
|
const size = blob.size
|
||||||
@@ -68,7 +77,10 @@ async function testUpload() {
|
|||||||
const size = 5 * 1024 * 1024
|
const size = 5 * 1024 * 1024
|
||||||
const data = new Uint8Array(size)
|
const data = new Uint8Array(size)
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
await fetch('/api/upload', { method: 'POST', body: data })
|
const response = await apiRequest('/api/upload', { method: 'POST', body: data })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
|||||||
const end = performance.now()
|
const end = performance.now()
|
||||||
const seconds = (end - start) / 1000
|
const seconds = (end - start) / 1000
|
||||||
upload.value = Math.round((size * 8) / seconds / 1000000)
|
upload.value = Math.round((size * 8) / seconds / 1000000)
|
||||||
@@ -76,7 +88,10 @@ async function testUpload() {
|
|||||||
|
|
||||||
async function testPing() {
|
async function testPing() {
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
await fetch('/api/ping')
|
const response = await fetch('/api/ping')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
const end = performance.now()
|
const end = performance.now()
|
||||||
ping.value = Math.round(end - start)
|
ping.value = Math.round(end - start)
|
||||||
}
|
}
|
||||||
@@ -86,11 +101,15 @@ async function runTests() {
|
|||||||
download.value = null
|
download.value = null
|
||||||
upload.value = null
|
upload.value = null
|
||||||
ping.value = null
|
ping.value = null
|
||||||
|
errorMessage.value = ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await testDownload()
|
await testDownload()
|
||||||
await testUpload()
|
await testUpload()
|
||||||
await testPing()
|
await testPing()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur speedtest:", error)
|
||||||
|
errorMessage.value = "Erreur lors de l'opération"
|
||||||
} finally {
|
} finally {
|
||||||
isTesting.value = false
|
isTesting.value = false
|
||||||
}
|
}
|
||||||
@@ -189,4 +208,15 @@ async function runTests() {
|
|||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: rgb(var(--m-muted));
|
color: rgb(var(--m-muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgb(var(--m-error) / 0.12);
|
||||||
|
background: rgb(var(--m-error) / 0.06);
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgb(var(--m-error));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
|
import CircleSkeleton from "~/components/skeleton/CircleSkeleton.vue"
|
||||||
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
import TextSkeleton from "~/components/skeleton/TextSkeleton.vue"
|
||||||
import {onBeforeUnmount, onMounted, ref} from "vue"
|
import {onBeforeUnmount, onMounted, ref} from "vue"
|
||||||
|
import { apiFetch } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
interface StatusRow {
|
interface StatusRow {
|
||||||
label: string
|
label: string
|
||||||
@@ -84,7 +85,7 @@ const checkStatus = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<StatusResponse>(props.endpoint)
|
const data = await apiFetch<StatusResponse>(props.endpoint)
|
||||||
rows.value = data.results
|
rows.value = data.results
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rows.value = [
|
rows.value = [
|
||||||
|
|||||||
75
composables/useApiAuth.ts
Normal file
75
composables/useApiAuth.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
function toHeadersObject(headers?: HeadersInit): Record<string, string> {
|
||||||
|
if (!headers) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers instanceof Headers) {
|
||||||
|
return Object.fromEntries(headers.entries())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
return Object.fromEntries(headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...headers }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDownloadFileName(contentDisposition: string | null, fallback: string) {
|
||||||
|
if (!contentDisposition) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
return decodeURIComponent(utf8Match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
const asciiMatch = contentDisposition.match(/filename="([^"]+)"/i)
|
||||||
|
if (asciiMatch?.[1]) {
|
||||||
|
return asciiMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withApiAuth(init: RequestInit = {}) {
|
||||||
|
// Les appels frontend reutilisent les cookies httpOnly poses cote serveur.
|
||||||
|
return {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...toHeadersObject(init.headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiFetch = $fetch.create({})
|
||||||
|
|
||||||
|
export function apiRequest(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||||
|
return fetch(input, withApiAuth(init))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadApiFile(url: string, fileNameFallback: string) {
|
||||||
|
// Les telechargements passent aussi par fetch pour pouvoir recuperer
|
||||||
|
// le contenu et le nom de fichier renvoye par l'API.
|
||||||
|
const response = await apiRequest(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
const fileName = getDownloadFileName(
|
||||||
|
response.headers.get("content-disposition"),
|
||||||
|
fileNameFallback
|
||||||
|
)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
|
||||||
|
link.href = objectUrl
|
||||||
|
link.download = fileName
|
||||||
|
link.style.display = "none"
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export default defineNuxtConfig({
|
|||||||
head: {
|
head: {
|
||||||
link: [
|
link: [
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
|
{ rel: "preconnect", href: "https://fonts.gstatic.com ", crossorigin: "" },
|
||||||
{
|
{
|
||||||
rel: "stylesheet",
|
rel: "stylesheet",
|
||||||
href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap"
|
href: "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&display=swap"
|
||||||
@@ -35,6 +35,7 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
|
apiSecretKey: process.env.API_SECRET_KEY,
|
||||||
public: {
|
public: {
|
||||||
appVersion: getRepoVersion()
|
appVersion: getRepoVersion()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import BackupRun from "~/components/BackupRun.vue"
|
import BackupRun from "~/components/BackupRun.vue"
|
||||||
|
import { apiFetch, downloadApiFile } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
definePageMeta({ layout: false })
|
definePageMeta({ layout: false })
|
||||||
|
|
||||||
@@ -119,33 +120,25 @@ const selectedBackup = ref<string | null>(null)
|
|||||||
const scriptResult = ref<ScriptResult>(emptyScriptResult())
|
const scriptResult = ref<ScriptResult>(emptyScriptResult())
|
||||||
|
|
||||||
const fetchLatestBackup = async (folder: string) => {
|
const fetchLatestBackup = async (folder: string) => {
|
||||||
const files = await $fetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
|
const files = await apiFetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`)
|
||||||
return files[0] || null
|
return files[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerDownload = (folder: string, file: string) => {
|
const triggerDownload = async (folder: string, file: string) => {
|
||||||
const link = document.createElement("a")
|
const url = `/api/download?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}`
|
||||||
link.href = `/api/download?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(file)}`
|
await downloadApiFile(url, file)
|
||||||
link.style.display = "none"
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerBatchDownload = (folders: string[]) => {
|
const triggerBatchDownload = async (folders: string[]) => {
|
||||||
const link = document.createElement("a")
|
const url = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}`
|
||||||
link.href = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}`
|
await downloadApiFile(url, "backup-latest.tar.gz")
|
||||||
link.style.display = "none"
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadLatestBackup = async (folder: string) => {
|
const downloadLatestBackup = async (folder: string) => {
|
||||||
const latestFile = await fetchLatestBackup(folder)
|
const latestFile = await fetchLatestBackup(folder)
|
||||||
|
|
||||||
if (latestFile) {
|
if (latestFile) {
|
||||||
triggerDownload(folder, latestFile)
|
await triggerDownload(folder, latestFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +150,7 @@ const handleScriptResult = async (payload: ScriptResult) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.downloadFolders.length > 1) {
|
if (payload.downloadFolders.length > 1) {
|
||||||
triggerBatchDownload(payload.downloadFolders)
|
await triggerBatchDownload(payload.downloadFolders)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({layout: false})
|
definePageMeta({layout: false})
|
||||||
import {computed, onMounted, ref} from "vue"
|
import {computed, onMounted, ref} from "vue"
|
||||||
|
import { apiFetch } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
type DiskSourceResult = {
|
type DiskSourceResult = {
|
||||||
key: string
|
key: string
|
||||||
@@ -151,16 +152,15 @@ const runScript = async () => {
|
|||||||
rawResults.value = []
|
rawResults.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await $fetch<DiskApiResponse>("/api/disk")
|
const output = await apiFetch<DiskApiResponse>("/api/disk")
|
||||||
rawResults.value = output.results
|
rawResults.value = output.results
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
rawResults.value = [
|
rawResults.value = [
|
||||||
{
|
{
|
||||||
key: "error",
|
key: "error",
|
||||||
label: "Source indisponible",
|
label: "Source indisponible",
|
||||||
ok: false,
|
ok: false,
|
||||||
output: message
|
output: "Erreur lors de l'opération"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { exec } from "node:child_process"
|
import { execFile } from "node:child_process"
|
||||||
import scripts from "../config/backup-script.json"
|
import scripts from "../config/backup-script.json"
|
||||||
|
|
||||||
type BackupScript = {
|
type BackupScript = {
|
||||||
@@ -6,11 +6,12 @@ type BackupScript = {
|
|||||||
label: string
|
label: string
|
||||||
downloadFolders?: string[]
|
downloadFolders?: string[]
|
||||||
command: string
|
command: string
|
||||||
|
args?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(command: string): Promise<string> {
|
function runCommand(command: string, args: string[] = []): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
exec(command, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => {
|
execFile(command, args, { timeout: 10 * 60 * 1000 }, (error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(stderr || error.message)
|
reject(stderr || error.message)
|
||||||
return
|
return
|
||||||
@@ -40,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await runCommand(script.command)
|
const output = await runCommand(script.command, script.args || [])
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
key: script.key,
|
key: script.key,
|
||||||
@@ -49,9 +50,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
output: output.trim()
|
output: output.trim()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Erreur execution script:", error)
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: `Erreur execution script: ${String(error)}`
|
statusMessage: "Erreur lors de l'opération"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ function isMissingPathError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toServerError(error: unknown) {
|
function toServerError(error: unknown) {
|
||||||
|
console.error("Erreur backups:", error)
|
||||||
|
|
||||||
return createError({
|
return createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: `Erreur SSH backups: ${String(error)}`
|
statusMessage: "Erreur lors de l'opération"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
const token = process.env.DISCORD_BOT_TOKEN
|
const token = process.env.DISCORD_BOT_TOKEN
|
||||||
const channel = process.env.DISCORD_CHANNEL_ID
|
const channel = process.env.DISCORD_CHANNEL_ID
|
||||||
|
|
||||||
|
if (!token || !channel) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: "Service indisponible"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const messages = await $fetch(
|
const messages = await $fetch(
|
||||||
`https://discord.com/api/v10/channels/${channel}/messages?limit=20`,
|
`https://discord.com/api/v10/channels/${channel}/messages?limit=20`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bot ${token}`
|
Authorization: `Bot ${token}`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur Discord messages:", error)
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Erreur lors de l'opération"
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { exec } from "child_process"
|
import { execFile } from "child_process"
|
||||||
import diskSources from "../config/disk-commands.json"
|
import diskSources from "../config/disk-commands.json"
|
||||||
|
|
||||||
type DiskSource = {
|
type DiskSource = {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
command: string
|
command: string
|
||||||
|
args?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommand(source: DiskSource) {
|
function getCommand(source: DiskSource) {
|
||||||
@@ -15,9 +16,9 @@ function getCommand(source: DiskSource) {
|
|||||||
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command
|
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(command: string): Promise<string> {
|
function runCommand(command: string, args: string[] = []): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
exec(command, (error, stdout, stderr) => {
|
execFile(command, args, (error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(stderr || error.message)
|
reject(stderr || error.message)
|
||||||
return
|
return
|
||||||
@@ -31,7 +32,7 @@ export default defineEventHandler(async () => {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
(diskSources as DiskSource[]).map(async (source) => {
|
(diskSources as DiskSource[]).map(async (source) => {
|
||||||
try {
|
try {
|
||||||
const output = await runCommand(getCommand(source))
|
const output = await runCommand(source.command, source.args || [])
|
||||||
return {
|
return {
|
||||||
key: source.key,
|
key: source.key,
|
||||||
label: source.label,
|
label: source.label,
|
||||||
@@ -39,11 +40,12 @@ export default defineEventHandler(async () => {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`Erreur disk source ${source.key}:`, error)
|
||||||
return {
|
return {
|
||||||
key: source.key,
|
key: source.key,
|
||||||
label: source.label,
|
label: source.label,
|
||||||
ok: false,
|
ok: false,
|
||||||
output: `Erreur: ${String(error)}`
|
output: "Erreur lors de l'opération"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
|||||||
const FOLDER_MAP = folderMap as Record<string, string>
|
const FOLDER_MAP = folderMap as Record<string, string>
|
||||||
|
|
||||||
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
||||||
const isSafeFile = (value: string) => /^[^/\\]+$/.test(value)
|
const isSafeFile = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
||||||
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
||||||
|
|
||||||
function runSsh(command: string): Promise<string> {
|
function runSsh(command: string): Promise<string> {
|
||||||
|
|||||||
@@ -4,19 +4,31 @@
|
|||||||
"label": "Backup BDD recette",
|
"label": "Backup BDD recette",
|
||||||
"icon": "mdi:database-export",
|
"icon": "mdi:database-export",
|
||||||
"downloadFolders": ["ferme", "inventory", "sirh", "user"],
|
"downloadFolders": ["ferme", "inventory", "sirh", "user"],
|
||||||
"command": "ssh ferme 'cd /home/malio/Malio-ops/RecetteScripts && bash backup-bdd-recette.sh && exit'"
|
"command": "ssh",
|
||||||
|
"args": [
|
||||||
|
"ferme",
|
||||||
|
"cd /home/malio/Malio-ops/RecetteScripts && bash backup-bdd-recette.sh"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "check-statut-recette",
|
"key": "check-statut-recette",
|
||||||
"label": "Check statut recette",
|
"label": "Check statut recette",
|
||||||
"icon": "mdi:server-network",
|
"icon": "mdi:server-network",
|
||||||
"command": "ssh ferme 'cd /home/malio/Malio-ops/RecetteScripts && bash check-statut-recette.sh && exit'"
|
"command": "ssh",
|
||||||
|
"args": [
|
||||||
|
"ferme",
|
||||||
|
"cd /home/malio/Malio-ops/RecetteScripts && bash check-statut-recette.sh"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "backup-vaultwarden",
|
"key": "backup-vaultwarden",
|
||||||
"label": "Backup vaultwarden",
|
"label": "Backup vaultwarden",
|
||||||
"icon": "mdi:data",
|
"icon": "mdi:data",
|
||||||
"downloadFolders": ["bitwarden"],
|
"downloadFolders": ["bitwarden"],
|
||||||
"command": "ssh bitwarden 'cd /home/matt/vaultwarden/Malio-ops/BackupVaultWarden && bash backup-vaultwarden.sh && exit'"
|
"command": "ssh",
|
||||||
|
"args": [
|
||||||
|
"bitwarden",
|
||||||
|
"cd /home/matt/vaultwarden/Malio-ops/BackupVaultWarden && bash backup-vaultwarden.sh"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2,11 +2,18 @@
|
|||||||
{
|
{
|
||||||
"key": "remote",
|
"key": "remote",
|
||||||
"label": "Serveur distant",
|
"label": "Serveur distant",
|
||||||
"command": "ssh malio-b 'cd /home/malio-b/Malio-ops/CheckStorage && bash check-storage.sh && exit'"
|
"command": "ssh",
|
||||||
|
"args": [
|
||||||
|
"malio-b",
|
||||||
|
"cd /home/malio-b/Malio-ops/CheckStorage && bash check-storage.sh"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "local",
|
"key": "local",
|
||||||
"label": "Machine locale",
|
"label": "Machine locale",
|
||||||
"command": "bash /home/kevin/check_storage.sh"
|
"command": "bash",
|
||||||
|
"args": [
|
||||||
|
"/home/kevin/check_storage.sh"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
25
server/middleware/auth-cookie.ts
Normal file
25
server/middleware/auth-cookie.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const path = event.path || event.node.req.url || ""
|
||||||
|
|
||||||
|
if (path.startsWith("/api/")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig(event)
|
||||||
|
const expectedToken = runtimeConfig.apiSecretKey
|
||||||
|
|
||||||
|
if (!expectedToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getCookie(event, "api_auth_token") === expectedToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(event, "api_auth_token", expectedToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/"
|
||||||
|
})
|
||||||
|
})
|
||||||
31
server/middleware/auth.ts
Normal file
31
server/middleware/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const path = event.path || event.node.req.url || ""
|
||||||
|
|
||||||
|
// Le middleware ne s'applique qu'aux routes API, sauf l'endpoint de ping
|
||||||
|
// qui reste public pour les tests de connectivite.
|
||||||
|
if (!path.startsWith("/api/") || path === "/api/ping") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig(event)
|
||||||
|
const authorization = getHeader(event, "authorization")
|
||||||
|
const cookieToken = getCookie(event, "api_auth_token")
|
||||||
|
const expectedToken = runtimeConfig.apiSecretKey
|
||||||
|
|
||||||
|
// Si aucun secret n'est configure cote serveur, on refuse la requete.
|
||||||
|
if (!expectedToken) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Unauthorized"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le secret peut venir soit d'un header serveur explicite,
|
||||||
|
// soit du cookie httpOnly pose pour l'application web.
|
||||||
|
if (authorization !== `Bearer ${expectedToken}` && cookieToken !== expectedToken) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Unauthorized"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user
Idem ici inclure en auto withApiAuth dans un fetch global