Compare commits
6 Commits
v1.2.3
...
7643600196
| Author | SHA1 | Date | |
|---|---|---|---|
| 7643600196 | |||
| b6375b4242 | |||
| 9393abc8df | |||
|
|
126d6b505a | ||
| c758c4d904 | |||
| ffe463e130 |
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
API_SECRET_KEY=
|
||||||
|
DISCORD_BOT_TOKEN=
|
||||||
|
DISCORD_CHANNEL_ID=
|
||||||
|
BACKUPS_REMOTE_HOST=
|
||||||
|
BACKUPS_REMOTE_ROOT=
|
||||||
|
BACKUPS_MAX_FILES=
|
||||||
|
DISK_COMMAND_REMOTE=
|
||||||
|
DISK_COMMAND_LOCAL=
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
## [1.2.4](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.3...v1.2.4) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bundle latest backup downloads ([ffe463e](https://gitea.malio.fr/MALIO-DEV/Supervisor/commit/ffe463e13034601843446514abbd7c69cbaee081))
|
||||||
|
|
||||||
## [1.2.3](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.2...v1.2.3) (2026-03-10)
|
## [1.2.3](https://gitea.malio.fr/MALIO-DEV/Supervisor/compare/v1.2.2...v1.2.3) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useApiAuthHeader } from "~/composables/useApiAuth"
|
||||||
|
|
||||||
type BackupScript = {
|
type BackupScript = {
|
||||||
key: string
|
key: string
|
||||||
@@ -118,6 +119,7 @@ const scripts = ref<BackupScript[]>([])
|
|||||||
const output = ref<string>("")
|
const output = ref<string>("")
|
||||||
const message = ref<string>("")
|
const message = ref<string>("")
|
||||||
const isError = ref(false)
|
const isError = ref(false)
|
||||||
|
const apiAuthHeader = useApiAuthHeader()
|
||||||
|
|
||||||
const statusClass = computed(() => (isError.value ? "status-error" : "status-success"))
|
const statusClass = computed(() => (isError.value ? "status-error" : "status-success"))
|
||||||
|
|
||||||
@@ -134,12 +136,14 @@ const loadScripts = async () => {
|
|||||||
downloadFolders: []
|
downloadFolders: []
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const data = await $fetch<BackupScriptListResponse>("/api/backup-script")
|
const data = await $fetch<BackupScriptListResponse>("/api/backup-script", {
|
||||||
|
headers: apiAuthHeader
|
||||||
|
})
|
||||||
scripts.value = data.scripts
|
scripts.value = data.scripts
|
||||||
} catch (error) {
|
} catch {
|
||||||
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: "",
|
||||||
@@ -162,7 +166,8 @@ const runScript = async (key: string) => {
|
|||||||
try {
|
try {
|
||||||
const data = await $fetch<BackupScriptRunResponse>("/api/backup-script", {
|
const data = await $fetch<BackupScriptRunResponse>("/api/backup-script", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { key }
|
body: { key },
|
||||||
|
headers: apiAuthHeader
|
||||||
})
|
})
|
||||||
message.value = `${data.label} execute avec succes`
|
message.value = `${data.label} execute avec succes`
|
||||||
output.value = data.output || "Aucune sortie retournee."
|
output.value = data.output || "Aucune sortie retournee."
|
||||||
@@ -173,8 +178,20 @@ const runScript = async (key: string) => {
|
|||||||
isError: false,
|
isError: false,
|
||||||
downloadFolders: data.downloadFolders || []
|
downloadFolders: data.downloadFolders || []
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
isError.value = true
|
isError.value = true
|
||||||
|
const statusMessage =
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"data" in error &&
|
||||||
|
typeof error.data === "object" &&
|
||||||
|
error.data !== null &&
|
||||||
|
"statusMessage" in error.data &&
|
||||||
|
typeof error.data.statusMessage === "string"
|
||||||
|
? error.data.statusMessage
|
||||||
|
: null
|
||||||
|
|
||||||
|
message.value = statusMessage || "Erreur lors de l'opération"
|
||||||
message.value = error?.data?.statusMessage || "Erreur execution script"
|
message.value = error?.data?.statusMessage || "Erreur execution script"
|
||||||
output.value = ""
|
output.value = ""
|
||||||
emit("result", {
|
emit("result", {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<h2 class="card-title">Speedtest</h2>
|
<h2 class="card-title">Speedtest</h2>
|
||||||
<button
|
<button
|
||||||
class="reload-btn"
|
class="reload-btn"
|
||||||
@click="runTests"
|
|
||||||
:disabled="isTesting"
|
:disabled="isTesting"
|
||||||
|
@click="runTests"
|
||||||
>
|
>
|
||||||
<IconifyIcon
|
<IconifyIcon
|
||||||
icon="mdi:reload"
|
icon="mdi:reload"
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
|
||||||
v-for="row in rows"
|
v-for="row in rows"
|
||||||
|
v-else
|
||||||
:key="`${row.label}-${row.url}`"
|
:key="`${row.label}-${row.url}`"
|
||||||
class="status-row"
|
class="status-row"
|
||||||
:class="row.status === 200 ? 'row-ok' : 'row-error'"
|
:class="row.status === 200 ? 'row-ok' : 'row-error'"
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
eslint.config.mjs
Normal file
3
eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import createConfigForNuxt from "@nuxt/eslint-config"
|
||||||
|
|
||||||
|
export default createConfigForNuxt()
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
:src="logoSrc"
|
:src="logoSrc"
|
||||||
alt="Logo Malio"
|
alt="Logo Malio"
|
||||||
class="logo"
|
class="logo"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-copy">
|
<div class="brand-copy">
|
||||||
<p class="brand-title">Supervisor</p>
|
<p class="brand-title">Supervisor</p>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
:src="logoSrc"
|
:src="logoSrc"
|
||||||
alt="Logo Malio"
|
alt="Logo Malio"
|
||||||
class="logo"
|
class="logo"
|
||||||
/>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-copy">
|
<div class="brand-copy">
|
||||||
<p class="brand-kicker">Control Center</p>
|
<p class="brand-kicker">Control Center</p>
|
||||||
|
|||||||
3138
package-lock.json
generated
3138
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -7,14 +7,14 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "^5.0.0",
|
||||||
"iconify": "^1.4.0",
|
"@nuxt/eslint": "^1.15.2",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1"
|
||||||
"vue": "^3.5.29",
|
|
||||||
"vue-router": "^4.6.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ const triggerDownload = (folder: string, file: string) => {
|
|||||||
link.remove()
|
link.remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerBatchDownload = (folders: string[]) => {
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = `/api/download-latest?folders=${encodeURIComponent(folders.join(","))}`
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -147,6 +156,11 @@ const handleScriptResult = async (payload: ScriptResult) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.downloadFolders.length > 1) {
|
||||||
|
triggerBatchDownload(payload.downloadFolders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for (const folder of payload.downloadFolders) {
|
for (const folder of payload.downloadFolders) {
|
||||||
try {
|
try {
|
||||||
await downloadLatestBackup(folder)
|
await downloadLatestBackup(folder)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid-middle">
|
<div class="grid-middle">
|
||||||
<Speedtest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
|
<SpeedTest class="animate-fade-in-up speedtest-card-mobile" style="animation-delay: 150ms" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({layout: false})
|
|
||||||
import {computed, onMounted, ref} from "vue"
|
import {computed, onMounted, ref} from "vue"
|
||||||
|
definePageMeta({layout: false})
|
||||||
|
|
||||||
type DiskSourceResult = {
|
type DiskSourceResult = {
|
||||||
key: string
|
key: string
|
||||||
@@ -74,7 +74,6 @@ type DiagramItem = {
|
|||||||
totalText: string
|
totalText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedBackup = ref<string | null>(null)
|
|
||||||
const rawResults = ref<DiskSourceResult[]>([])
|
const rawResults = ref<DiskSourceResult[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const chartRadius = 52
|
const chartRadius = 52
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { execFile } from "node:child_process"
|
import { execFile } from "node:child_process"
|
||||||
import folderMap from "../config/backup-folders.json"
|
import folderMap from "../config/backup-folders.json"
|
||||||
|
|
||||||
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b"
|
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST
|
||||||
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT || "/home/malio-b/backups"
|
const REMOTE_ROOT = process.env.BACKUPS_REMOTE_ROOT
|
||||||
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES || "200")
|
const MAX_FILES_PER_FOLDER = Number(process.env.BACKUPS_MAX_FILES)
|
||||||
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
const isSafeFolder = (value: string) => /^[a-zA-Z0-9._-]+$/.test(value)
|
||||||
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
||||||
const FOLDER_MAP = folderMap as Record<string, string>
|
const FOLDER_MAP = folderMap as Record<string, string>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function getCommand(source: DiskSource) {
|
|||||||
const legacyEnvKey =
|
const legacyEnvKey =
|
||||||
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
|
source.key === "remote" ? "DISK_REMOTE_COMMAND" : source.key === "local" ? "DISK_LOCAL_COMMAND" : ""
|
||||||
|
|
||||||
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || source.command
|
return process.env[envKey] || (legacyEnvKey ? process.env[legacyEnvKey] : undefined) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(command: string): Promise<string> {
|
function runCommand(command: string): Promise<string> {
|
||||||
@@ -31,7 +31,13 @@ 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 command = getCommand(source)
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
throw new Error(`Commande manquante pour ${source.key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await runCommand(command)
|
||||||
return {
|
return {
|
||||||
key: source.key,
|
key: source.key,
|
||||||
label: source.label,
|
label: source.label,
|
||||||
|
|||||||
139
server/api/download-latest.get.ts
Normal file
139
server/api/download-latest.get.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { execFile, spawn } from "node:child_process"
|
||||||
|
import folderMap from "../config/backup-folders.json"
|
||||||
|
|
||||||
|
const REMOTE_HOST = process.env.BACKUPS_REMOTE_HOST || "malio-b"
|
||||||
|
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 shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
||||||
|
|
||||||
|
function runSsh(command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
"ssh",
|
||||||
|
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", REMOTE_HOST, command],
|
||||||
|
{ maxBuffer: 10 * 1024 * 1024 },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(stderr || error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(stdout)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remoteDirExists(remoteDir: string): Promise<boolean> {
|
||||||
|
const output = await runSsh(`[ -d ${shellQuote(remoteDir)} ] && echo yes || echo no`)
|
||||||
|
return output.trim() === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFolderRemoteDir(folderName: string): Promise<string | null> {
|
||||||
|
const mapped = FOLDER_MAP[folderName]
|
||||||
|
if (mapped) {
|
||||||
|
return `${REMOTE_ROOT}/${mapped}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = `${REMOTE_ROOT}/${folderName}`
|
||||||
|
if (await remoteDirExists(direct)) {
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = `${REMOTE_ROOT}/bdd_recette/${folderName}`
|
||||||
|
if (await remoteDirExists(nested)) {
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLatestRemoteFile(remoteDir: string): Promise<string | null> {
|
||||||
|
const output = await runSsh(`cd ${shellQuote(remoteDir)} && ls -1A | sort -r | head -n 1`)
|
||||||
|
const fileName = output.trim()
|
||||||
|
return fileName || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContentDisposition(fileName: string) {
|
||||||
|
const asciiName = fileName.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
|
||||||
|
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { folders } = getQuery(event)
|
||||||
|
const folderParam = typeof folders === "string" ? folders : ""
|
||||||
|
const folderNames = folderParam
|
||||||
|
.split(",")
|
||||||
|
.map((folder) => folder.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (folderNames.length === 0) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderNames.some((folder) => !isSafeFolder(folder))) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Paramètre folders invalide" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFolders = [...new Set(folderNames)]
|
||||||
|
const archiveEntries: Array<{ remoteDir: string; fileName: string; archiveName: string }> = []
|
||||||
|
|
||||||
|
for (const folderName of uniqueFolders) {
|
||||||
|
const remoteDir = await resolveFolderRemoteDir(folderName)
|
||||||
|
if (!remoteDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = await getLatestRemoteFile(remoteDir)
|
||||||
|
if (!fileName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveEntries.push({
|
||||||
|
remoteDir,
|
||||||
|
fileName,
|
||||||
|
archiveName: `${folderName}/${fileName}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (archiveEntries.length === 0) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Aucun fichier a telecharger" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateLabel = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")
|
||||||
|
const archiveName = `backup-latest-${dateLabel}.tar.gz`
|
||||||
|
const tarArgs = archiveEntries.flatMap(({ remoteDir, fileName, archiveName: entryName }) => [
|
||||||
|
"--transform",
|
||||||
|
shellQuote(`s|^${fileName}$|${entryName}|`),
|
||||||
|
"-C",
|
||||||
|
shellQuote(remoteDir),
|
||||||
|
shellQuote(fileName)
|
||||||
|
])
|
||||||
|
const remoteCommand = `tar -czf - ${tarArgs.join(" ")}`
|
||||||
|
|
||||||
|
setHeader(event, "Content-Type", "application/gzip")
|
||||||
|
setHeader(event, "Content-Disposition", buildContentDisposition(archiveName))
|
||||||
|
|
||||||
|
const child = spawn("ssh", [
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=5",
|
||||||
|
REMOTE_HOST,
|
||||||
|
remoteCommand
|
||||||
|
])
|
||||||
|
|
||||||
|
let stderr = ""
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error(`Erreur archive SSH (${code}): ${stderr}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sendStream(event, child.stdout)
|
||||||
|
})
|
||||||
@@ -56,7 +56,7 @@ function buildContentDisposition(fileName: string) {
|
|||||||
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function speedtestStream(event: any) {
|
function speedtestStream(event: H3Event) {
|
||||||
const size = 128 * 1024 * 1024
|
const size = 128 * 1024 * 1024
|
||||||
let sent = 0
|
let sent = 0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user