fix : securite regex et message erreur et endpoint
This commit is contained in:
@@ -22,6 +22,13 @@
|
||||
</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">
|
||||
<IconifyIcon icon="mdi:file-hidden" class="text-3xl text-m-muted/40" />
|
||||
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
||||
@@ -63,6 +70,7 @@ const props = defineProps<{
|
||||
|
||||
const backups = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref("")
|
||||
const apiAuthHeader = useApiAuthHeader()
|
||||
const title = computed(() => {
|
||||
if (!props.folder) return "Fichiers"
|
||||
@@ -72,25 +80,36 @@ const title = computed(() => {
|
||||
const downloadBackup = async (file: string) => {
|
||||
if (!props.folder) return
|
||||
const url = `/api/download?folder=${encodeURIComponent(props.folder)}&file=${encodeURIComponent(file)}`
|
||||
await downloadApiFile(url, file)
|
||||
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) => {
|
||||
if (!folder) {
|
||||
loading.value = false
|
||||
backups.value = []
|
||||
errorMessage.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
try {
|
||||
const data = await $fetch<string[]>(`/api/backups?folder=${encodeURIComponent(folder)}`, {
|
||||
headers: apiAuthHeader
|
||||
headers: apiAuthHeader,
|
||||
server: false
|
||||
})
|
||||
backups.value = data
|
||||
} catch (error) {
|
||||
console.error("Erreur récupération backups:", error)
|
||||
backups.value = []
|
||||
errorMessage.value = "Erreur lors de l'opération"
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -124,6 +143,12 @@ watch(() => props.folder, async (folder) => {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -143,7 +143,7 @@ const loadScripts = async () => {
|
||||
} catch (error) {
|
||||
scripts.value = []
|
||||
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", {
|
||||
key: null,
|
||||
label: "",
|
||||
@@ -180,7 +180,7 @@ const runScript = async (key: string) => {
|
||||
})
|
||||
} catch (error: any) {
|
||||
isError.value = true
|
||||
message.value = error?.data?.statusMessage || "Erreur execution script"
|
||||
message.value = error?.data?.statusMessage || "Erreur lors de l'opération"
|
||||
output.value = ""
|
||||
emit("result", {
|
||||
key,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import {Icon as IconifyIcon} from "@iconify/vue"
|
||||
import { useApiAuthHeader } from "~/composables/useApiAuth"
|
||||
|
||||
const { data: messages } = await useFetch('/api/discord/messages', {
|
||||
headers: useApiAuthHeader()
|
||||
const { data: messages, error } = await useFetch('/api/discord/messages', {
|
||||
headers: useApiAuthHeader(),
|
||||
server: false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -17,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>
|
||||
</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" />
|
||||
<p class="mt-2 font-mono text-xs text-m-muted/50">
|
||||
Aucun message
|
||||
@@ -78,6 +86,12 @@ const { data: messages } = await useFetch('/api/discord/messages', {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error-text" role="status" aria-live="polite">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +52,7 @@ const ping = ref<number | null>(null)
|
||||
const download = ref<number | null>(null)
|
||||
const upload = ref<number | null>(null)
|
||||
const isTesting = ref(false)
|
||||
const errorMessage = ref("")
|
||||
const apiAuthHeader = useApiAuthHeader()
|
||||
|
||||
const metrics = computed(() => [
|
||||
@@ -61,6 +66,9 @@ async function testDownload() {
|
||||
const res = await fetch('/api/download', {
|
||||
headers: apiAuthHeader
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const end = performance.now()
|
||||
const size = blob.size
|
||||
@@ -72,7 +80,10 @@ async function testUpload() {
|
||||
const size = 5 * 1024 * 1024
|
||||
const data = new Uint8Array(size)
|
||||
const start = performance.now()
|
||||
await fetch('/api/upload', withApiAuth({ method: 'POST', body: data }))
|
||||
const response = await fetch('/api/upload', withApiAuth({ method: 'POST', body: data }))
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const end = performance.now()
|
||||
const seconds = (end - start) / 1000
|
||||
upload.value = Math.round((size * 8) / seconds / 1000000)
|
||||
@@ -80,7 +91,10 @@ async function testUpload() {
|
||||
|
||||
async function testPing() {
|
||||
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()
|
||||
ping.value = Math.round(end - start)
|
||||
}
|
||||
@@ -90,11 +104,15 @@ async function runTests() {
|
||||
download.value = null
|
||||
upload.value = null
|
||||
ping.value = null
|
||||
errorMessage.value = ""
|
||||
|
||||
try {
|
||||
await testDownload()
|
||||
await testUpload()
|
||||
await testPing()
|
||||
} catch (error) {
|
||||
console.error("Erreur speedtest:", error)
|
||||
errorMessage.value = "Erreur lors de l'opération"
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
@@ -193,4 +211,15 @@ async function runTests() {
|
||||
letter-spacing: 0.1em;
|
||||
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>
|
||||
|
||||
86
composables/useApiAuth.ts
Normal file
86
composables/useApiAuth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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 useApiAuthHeader() {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const token = runtimeConfig.public.apiSecretKey
|
||||
|
||||
if (!token) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// Tous les appels frontend vers /api/* reutilisent ce header commun.
|
||||
return {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadApiFile(url: string, fileNameFallback: string) {
|
||||
// Les telechargements passent aussi par fetch pour pouvoir envoyer
|
||||
// le header Authorization, contrairement a un simple <a href>.
|
||||
const response = await fetch(url, {
|
||||
headers: useApiAuthHeader()
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function withApiAuth(init: RequestInit = {}) {
|
||||
// Fusionne le header d'auth avec d'eventuels headers deja fournis.
|
||||
return {
|
||||
...init,
|
||||
headers: {
|
||||
...useApiAuthHeader(),
|
||||
...toHeadersObject(init.headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default defineNuxtConfig({
|
||||
head: {
|
||||
link: [
|
||||
{ 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",
|
||||
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"
|
||||
|
||||
@@ -158,13 +158,12 @@ const runScript = async () => {
|
||||
})
|
||||
rawResults.value = output.results
|
||||
} catch (error) {
|
||||
const message = `Erreur: ${error instanceof Error ? error.message : String(error)}`
|
||||
rawResults.value = [
|
||||
{
|
||||
key: "error",
|
||||
label: "Source indisponible",
|
||||
ok: false,
|
||||
output: message
|
||||
output: "Erreur lors de l'opération"
|
||||
}
|
||||
]
|
||||
} finally {
|
||||
|
||||
@@ -50,9 +50,11 @@ export default defineEventHandler(async (event) => {
|
||||
output: output.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur execution script:", error)
|
||||
|
||||
throw createError({
|
||||
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) {
|
||||
console.error("Erreur backups:", error)
|
||||
|
||||
return createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Erreur SSH backups: ${String(error)}`
|
||||
statusMessage: "Erreur lors de l'opération"
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
export default defineEventHandler(async () => {
|
||||
const token = process.env.DISCORD_BOT_TOKEN
|
||||
const channel = process.env.DISCORD_CHANNEL_ID
|
||||
const token = process.env.DISCORD_BOT_TOKEN
|
||||
const channel = process.env.DISCORD_CHANNEL_ID
|
||||
|
||||
if (!token || !channel) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: "Service indisponible"
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await $fetch(
|
||||
`https://discord.com/api/v10/channels/${channel}/messages?limit=20`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`
|
||||
}
|
||||
`https://discord.com/api/v10/channels/${channel}/messages?limit=20`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return messages
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Erreur Discord messages:", error)
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Erreur lors de l'opération"
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,11 +40,12 @@ export default defineEventHandler(async () => {
|
||||
output
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Erreur disk source ${source.key}:`, error)
|
||||
return {
|
||||
key: source.key,
|
||||
label: source.label,
|
||||
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 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, `'\\''`)}'`
|
||||
|
||||
function runSsh(command: string): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user