[feat](trx-frontend-http): add recorder file management and pagination
Add download/remove buttons per file, filename filter, sort dropdown, and paginated file list. Restore header REC toggle button. Add GET /api/recorder/download/{filename} and DELETE /api/recorder/files/{filename} endpoints with path traversal protection.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -254,6 +254,7 @@ function applyAuthRestrictions() {
|
|||||||
"settings-clear-ft2-history",
|
"settings-clear-ft2-history",
|
||||||
"settings-clear-wspr-history",
|
"settings-clear-wspr-history",
|
||||||
"settings-clear-sat-history",
|
"settings-clear-sat-history",
|
||||||
|
"header-rec-btn",
|
||||||
"recorder-start-btn",
|
"recorder-start-btn",
|
||||||
"recorder-stop-btn"
|
"recorder-stop-btn"
|
||||||
];
|
];
|
||||||
@@ -8862,11 +8863,12 @@ if (headerAudioToggle) {
|
|||||||
headerAudioToggle.addEventListener("click", startRxAudio);
|
headerAudioToggle.addEventListener("click", startRxAudio);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Recorder page ──────────────────────────────────────────────────────────
|
// ── Recorder ───────────────────────────────────────────────────────────────
|
||||||
let recorderActive = false;
|
let recorderActive = false;
|
||||||
const recorderStartBtn = document.getElementById("recorder-start-btn");
|
const recorderStartBtn = document.getElementById("recorder-start-btn");
|
||||||
const recorderStopBtn = document.getElementById("recorder-stop-btn");
|
const recorderStopBtn = document.getElementById("recorder-stop-btn");
|
||||||
const recorderStatusInd = document.getElementById("recorder-status-indicator");
|
const recorderStatusInd = document.getElementById("recorder-status-indicator");
|
||||||
|
const headerRecBtn = document.getElementById("header-rec-btn");
|
||||||
|
|
||||||
function syncRecorderUi() {
|
function syncRecorderUi() {
|
||||||
if (recorderStartBtn) recorderStartBtn.disabled = recorderActive;
|
if (recorderStartBtn) recorderStartBtn.disabled = recorderActive;
|
||||||
@@ -8875,27 +8877,27 @@ function syncRecorderUi() {
|
|||||||
recorderStatusInd.textContent = recorderActive ? "Recording" : "";
|
recorderStatusInd.textContent = recorderActive ? "Recording" : "";
|
||||||
recorderStatusInd.classList.toggle("rec-active", recorderActive);
|
recorderStatusInd.classList.toggle("rec-active", recorderActive);
|
||||||
}
|
}
|
||||||
// Sync the tab icon indicator.
|
if (headerRecBtn) headerRecBtn.classList.toggle("rec-active", recorderActive);
|
||||||
const tabBtn = document.querySelector('.tab[data-tab="recorder"]');
|
const tabBtn = document.querySelector('.tab[data-tab="recorder"]');
|
||||||
if (tabBtn) tabBtn.classList.toggle("rec-active", recorderActive);
|
if (tabBtn) tabBtn.classList.toggle("rec-active", recorderActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recorderStartBtn) {
|
if (recorderStartBtn) {
|
||||||
recorderStartBtn.addEventListener("click", async () => {
|
recorderStartBtn.addEventListener("click", async () => {
|
||||||
try {
|
try { await postPath("/api/recorder/start"); } catch (e) { console.error("Recorder start failed", e); }
|
||||||
await postPath("/api/recorder/start");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Recorder start failed", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (recorderStopBtn) {
|
if (recorderStopBtn) {
|
||||||
recorderStopBtn.addEventListener("click", async () => {
|
recorderStopBtn.addEventListener("click", async () => {
|
||||||
try {
|
try { await postPath("/api/recorder/stop"); } catch (e) { console.error("Recorder stop failed", e); }
|
||||||
await postPath("/api/recorder/stop");
|
});
|
||||||
} catch (e) {
|
|
||||||
console.error("Recorder stop failed", e);
|
|
||||||
}
|
}
|
||||||
|
if (headerRecBtn) {
|
||||||
|
headerRecBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
if (recorderActive) { await postPath("/api/recorder/stop"); }
|
||||||
|
else { await postPath("/api/recorder/start"); }
|
||||||
|
} catch (e) { console.error("Recorder toggle failed", e); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8904,6 +8906,10 @@ window._syncRecorderState = function (enabled) {
|
|||||||
syncRecorderUi();
|
syncRecorderUi();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let _recorderFiles = [];
|
||||||
|
let _recFilesPage = 0;
|
||||||
|
const REC_PAGE_SIZE = 15;
|
||||||
|
|
||||||
async function refreshRecorderStatus() {
|
async function refreshRecorderStatus() {
|
||||||
try {
|
try {
|
||||||
const [statusResp, filesResp] = await Promise.all([
|
const [statusResp, filesResp] = await Promise.all([
|
||||||
@@ -8915,8 +8921,8 @@ async function refreshRecorderStatus() {
|
|||||||
renderRecorderActive(active);
|
renderRecorderActive(active);
|
||||||
}
|
}
|
||||||
if (filesResp.ok) {
|
if (filesResp.ok) {
|
||||||
const files = await filesResp.json();
|
_recorderFiles = await filesResp.json();
|
||||||
renderRecorderFiles(files);
|
renderRecorderFiles();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Recorder status fetch failed", e);
|
console.error("Recorder status fetch failed", e);
|
||||||
@@ -8940,21 +8946,108 @@ function renderRecorderActive(list) {
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRecorderFiles(list) {
|
function recorderFormatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
|
return (bytes / 1048576).toFixed(1) + " MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recFilterAndSort() {
|
||||||
|
const filterEl = document.getElementById("recorder-filter");
|
||||||
|
const sortEl = document.getElementById("recorder-sort");
|
||||||
|
const filter = (filterEl ? filterEl.value : "").toLowerCase();
|
||||||
|
const sortMode = sortEl ? sortEl.value : "name-desc";
|
||||||
|
|
||||||
|
let filtered = _recorderFiles;
|
||||||
|
if (filter) {
|
||||||
|
filtered = filtered.filter(function (f) {
|
||||||
|
return f.name.toLowerCase().includes(filter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = filtered.slice();
|
||||||
|
switch (sortMode) {
|
||||||
|
case "name-asc": sorted.sort(function (a, b) { return a.name.localeCompare(b.name); }); break;
|
||||||
|
case "name-desc": sorted.sort(function (a, b) { return b.name.localeCompare(a.name); }); break;
|
||||||
|
case "size-asc": sorted.sort(function (a, b) { return a.size - b.size; }); break;
|
||||||
|
case "size-desc": sorted.sort(function (a, b) { return b.size - a.size; }); break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecorderFiles() {
|
||||||
const el = document.getElementById("recorder-files-list");
|
const el = document.getElementById("recorder-files-list");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!list.length) {
|
|
||||||
el.innerHTML = '<p class="recorder-empty">No recorded files.</p>';
|
const sorted = recFilterAndSort();
|
||||||
|
const total = sorted.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / REC_PAGE_SIZE));
|
||||||
|
if (_recFilesPage >= totalPages) _recFilesPage = totalPages - 1;
|
||||||
|
if (_recFilesPage < 0) _recFilesPage = 0;
|
||||||
|
const start = _recFilesPage * REC_PAGE_SIZE;
|
||||||
|
const page = sorted.slice(start, start + REC_PAGE_SIZE);
|
||||||
|
|
||||||
|
const summaryEl = document.getElementById("rec-page-summary");
|
||||||
|
const indicatorEl = document.getElementById("rec-page-indicator");
|
||||||
|
const prevBtn = document.getElementById("rec-page-prev");
|
||||||
|
const nextBtn = document.getElementById("rec-page-next");
|
||||||
|
|
||||||
|
if (summaryEl) {
|
||||||
|
summaryEl.textContent = total ? "Showing " + (start + 1) + "-" + Math.min(start + REC_PAGE_SIZE, total) + " of " + total : "Showing 0-0 of 0";
|
||||||
|
}
|
||||||
|
if (indicatorEl) indicatorEl.textContent = "Page " + (_recFilesPage + 1) + " of " + totalPages;
|
||||||
|
if (prevBtn) prevBtn.disabled = _recFilesPage <= 0;
|
||||||
|
if (nextBtn) nextBtn.disabled = _recFilesPage >= totalPages - 1;
|
||||||
|
|
||||||
|
const filterEl = document.getElementById("recorder-filter");
|
||||||
|
const filter = filterEl ? filterEl.value : "";
|
||||||
|
|
||||||
|
if (!page.length) {
|
||||||
|
el.innerHTML = '<p class="recorder-empty">' + (filter ? "No files match filter." : "No recorded files.") + "</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '<table class="recorder-table"><thead><tr><th>File</th><th>Size</th></tr></thead><tbody>';
|
|
||||||
for (const f of list) {
|
let html = '<table class="recorder-table"><thead><tr><th>File</th><th>Size</th><th></th></tr></thead><tbody>';
|
||||||
const size = f.size < 1048576 ? (f.size / 1024).toFixed(1) + " KB" : (f.size / 1048576).toFixed(1) + " MB";
|
for (const f of page) {
|
||||||
html += `<tr><td>${escapeMapHtml(f.name)}</td><td>${size}</td></tr>`;
|
const safeName = escapeMapHtml(f.name);
|
||||||
|
const encodedName = encodeURIComponent(f.name);
|
||||||
|
html += "<tr>"
|
||||||
|
+ "<td>" + safeName + "</td>"
|
||||||
|
+ "<td>" + recorderFormatSize(f.size) + "</td>"
|
||||||
|
+ '<td><div class="rec-file-actions">'
|
||||||
|
+ '<a class="rec-file-btn" href="/api/recorder/download/' + encodedName + '" download="' + safeName + '">Download</a>'
|
||||||
|
+ '<button class="rec-file-btn rec-delete-btn" data-name="' + safeName + '" type="button">Remove</button>'
|
||||||
|
+ "</div></td></tr>";
|
||||||
}
|
}
|
||||||
html += "</tbody></table>";
|
html += "</tbody></table>";
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", async function () {
|
||||||
|
const name = btn.dataset.name;
|
||||||
|
if (!confirm("Delete recording " + name + "?")) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
_recorderFiles = _recorderFiles.filter(function (f) { return f.name !== name; });
|
||||||
|
renderRecorderFiles();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Delete failed", e);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const filterEl = document.getElementById("recorder-filter");
|
||||||
|
const sortEl = document.getElementById("recorder-sort");
|
||||||
|
if (filterEl) filterEl.addEventListener("input", function () { _recFilesPage = 0; renderRecorderFiles(); });
|
||||||
|
if (sortEl) sortEl.addEventListener("change", function () { _recFilesPage = 0; renderRecorderFiles(); });
|
||||||
|
const prevBtn = document.getElementById("rec-page-prev");
|
||||||
|
const nextBtn = document.getElementById("rec-page-next");
|
||||||
|
if (prevBtn) prevBtn.addEventListener("click", function () { _recFilesPage--; renderRecorderFiles(); });
|
||||||
|
if (nextBtn) nextBtn.addEventListener("click", function () { _recFilesPage++; renderRecorderFiles(); });
|
||||||
|
})();
|
||||||
|
|
||||||
const rxVolPct = document.getElementById("rx-vol-pct");
|
const rxVolPct = document.getElementById("rx-vol-pct");
|
||||||
const txVolPct = document.getElementById("tx-vol-pct");
|
const txVolPct = document.getElementById("tx-vol-pct");
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
<button id="header-audio-toggle" class="header-bar-btn header-audio-btn" aria-label="Toggle audio playback" title="Toggle audio playback">
|
<button id="header-audio-toggle" class="header-bar-btn header-audio-btn" aria-label="Toggle audio playback" title="Toggle audio playback">
|
||||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 3v10l8-5z"/></svg>
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 3v10l8-5z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
|
||||||
<div class="header-rig-switch">
|
<div class="header-rig-switch">
|
||||||
<select id="header-rig-switch-select" aria-label="Select active rig"></select>
|
<select id="header-rig-switch-select" aria-label="Select active rig"></select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1076,9 +1077,26 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="recorder-section">
|
<section class="recorder-section">
|
||||||
<h3 class="recorder-section-heading">Recorded files</h3>
|
<h3 class="recorder-section-heading">Recorded files</h3>
|
||||||
|
<div class="recorder-filter-bar">
|
||||||
|
<input id="recorder-filter" type="text" class="status-input recorder-filter-input" placeholder="Filter by filename..." />
|
||||||
|
<select id="recorder-sort" class="status-input recorder-sort-select">
|
||||||
|
<option value="name-desc">Newest first</option>
|
||||||
|
<option value="name-asc">Oldest first</option>
|
||||||
|
<option value="size-desc">Largest first</option>
|
||||||
|
<option value="size-asc">Smallest first</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div id="recorder-files-list" class="recorder-list">
|
<div id="recorder-files-list" class="recorder-list">
|
||||||
<p class="recorder-empty">No recorded files.</p>
|
<p class="recorder-empty">No recorded files.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="recorder-page-bar">
|
||||||
|
<div id="rec-page-summary" class="bm-page-summary">Showing 0-0 of 0</div>
|
||||||
|
<div class="bm-page-controls">
|
||||||
|
<button id="rec-page-prev" type="button" disabled>Previous</button>
|
||||||
|
<span id="rec-page-indicator" class="bm-page-indicator">Page 1 of 1</span>
|
||||||
|
<button id="rec-page-next" type="button" disabled>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-settings" class="tab-panel" style="display:none;">
|
<div id="tab-settings" class="tab-panel" style="display:none;">
|
||||||
|
|||||||
@@ -1118,6 +1118,7 @@ small { color: var(--text-muted); }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.header-bar-btn.header-audio-btn {
|
.header-bar-btn.header-audio-btn {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
@@ -1142,6 +1143,26 @@ small { color: var(--text-muted); }
|
|||||||
color: #00d17f;
|
color: #00d17f;
|
||||||
border-color: #00d17f;
|
border-color: #00d17f;
|
||||||
}
|
}
|
||||||
|
.header-bar-btn.header-rec-btn {
|
||||||
|
height: 2rem;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-rec-btn.rec-active {
|
||||||
|
color: #ff3b30;
|
||||||
|
border-color: #ff3b30;
|
||||||
|
background: rgba(255, 59, 48, 0.12);
|
||||||
|
animation: rec-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
/* ── Recorder page ──────────────────────────────────────────────────────── */
|
/* ── Recorder page ──────────────────────────────────────────────────────── */
|
||||||
.recorder-controls-bar {
|
.recorder-controls-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1177,6 +1198,14 @@ small { color: var(--text-muted); }
|
|||||||
50% { opacity: 0.6; }
|
50% { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
.tab.rec-active .tab-icon { color: #ff3b30; }
|
.tab.rec-active .tab-icon { color: #ff3b30; }
|
||||||
|
.recorder-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.recorder-filter-input { flex: 1; min-width: 0; max-width: 22rem; }
|
||||||
|
.recorder-sort-select { width: auto; min-width: 10rem; }
|
||||||
.recorder-section { margin-bottom: 1.5rem; }
|
.recorder-section { margin-bottom: 1.5rem; }
|
||||||
.recorder-section-heading {
|
.recorder-section-heading {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -1204,6 +1233,28 @@ small { color: var(--text-muted); }
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
.recorder-table .rec-file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.recorder-table .rec-file-btn {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.recorder-table .rec-file-btn:hover { border-color: var(--accent-green); color: var(--accent-green); }
|
||||||
|
.recorder-table .rec-file-btn.rec-delete-btn:hover { border-color: #ff3b30; color: #ff3b30; }
|
||||||
|
.recorder-page-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 0.9rem 0.2rem 0;
|
||||||
|
}
|
||||||
.header-rig-switch {
|
.header-rig-switch {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -612,6 +612,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(recorder::recorder_stop)
|
.service(recorder::recorder_stop)
|
||||||
.service(recorder::recorder_status)
|
.service(recorder::recorder_status)
|
||||||
.service(recorder::recorder_files)
|
.service(recorder::recorder_files)
|
||||||
|
.service(recorder::recorder_download)
|
||||||
|
.service(recorder::recorder_delete)
|
||||||
// Static assets
|
// Static assets
|
||||||
.service(assets::index)
|
.service(assets::index)
|
||||||
.service(assets::map_index)
|
.service(assets::map_index)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use actix_web::{get, post, web, Error, HttpResponse};
|
use actix_web::http::header;
|
||||||
|
use actix_web::{delete, get, post, web, Error, HttpResponse};
|
||||||
|
use bytes::Bytes;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
use trx_core::{RigCommand, RigState};
|
use trx_core::{RigCommand, RigState};
|
||||||
@@ -133,6 +135,43 @@ pub async fn recorder_files(
|
|||||||
Ok(HttpResponse::Ok().json(files))
|
Ok(HttpResponse::Ok().json(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download a recorded file.
|
||||||
|
#[get("/api/recorder/download/{filename}")]
|
||||||
|
pub async fn recorder_download(
|
||||||
|
path: web::Path<String>,
|
||||||
|
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let filename = path.into_inner();
|
||||||
|
let file_path = recorder_mgr
|
||||||
|
.file_path(&filename)
|
||||||
|
.map_err(actix_web::error::ErrorNotFound)?;
|
||||||
|
|
||||||
|
let data = tokio::fs::read(&file_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("read error: {e}")))?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "audio/ogg"))
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{filename}\""),
|
||||||
|
))
|
||||||
|
.body(Bytes::from(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a recorded file.
|
||||||
|
#[delete("/api/recorder/files/{filename}")]
|
||||||
|
pub async fn recorder_delete(
|
||||||
|
path: web::Path<String>,
|
||||||
|
recorder_mgr: web::Data<Arc<RecorderManager>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let filename = path.into_inner();
|
||||||
|
match recorder_mgr.delete_file(&filename) {
|
||||||
|
Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": filename }))),
|
||||||
|
Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -411,6 +411,35 @@ impl RecorderManager {
|
|||||||
files
|
files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve and validate a filename, returning the full path.
|
||||||
|
///
|
||||||
|
/// Rejects path traversal attempts and files outside the output directory.
|
||||||
|
fn validate_filename(&self, filename: &str) -> Result<PathBuf, String> {
|
||||||
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
|
return Err("invalid filename".into());
|
||||||
|
}
|
||||||
|
if !filename.ends_with(".ogg") {
|
||||||
|
return Err("only .ogg files are accessible".into());
|
||||||
|
}
|
||||||
|
let dir = self.config.resolve_output_dir();
|
||||||
|
let path = dir.join(filename);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!("file not found: {filename}"));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the full path to a recorded file for download.
|
||||||
|
pub fn file_path(&self, filename: &str) -> Result<PathBuf, String> {
|
||||||
|
self.validate_filename(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a recorded file.
|
||||||
|
pub fn delete_file(&self, filename: &str) -> Result<(), String> {
|
||||||
|
let path = self.validate_filename(filename)?;
|
||||||
|
std::fs::remove_file(&path).map_err(|e| format!("failed to delete: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a recording is active for the given key.
|
/// Check if a recording is active for the given key.
|
||||||
pub fn is_recording(&self, rig_id: &str, vchan_id: Option<&str>) -> bool {
|
pub fn is_recording(&self, rig_id: &str, vchan_id: Option<&str>) -> bool {
|
||||||
let key = Self::make_key(rig_id, vchan_id);
|
let key = Self::make_key(rig_id, vchan_id);
|
||||||
|
|||||||
Reference in New Issue
Block a user