mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
Fix audit findings and CI test failures
Backend fixes: - auth.rs: Replace .unwrap() with pattern matching and .expect() - middleware.rs: Replace .unwrap() with .expect() for static headers - pipeline.rs: Add error logging for analysis/planning failures - pipeline.rs: Log profile fetch errors instead of swallowing silently - wizard.rs: Set setup_required=false after setup completes (fixes CI) - tests.rs: Use tx_capacity for jobs channel to test lag behavior (fixes CI) Frontend fixes: - JobManager.tsx: Add exponential backoff for SSE reconnect (1s-30s + jitter) - SettingsPanel.tsx: Fix React hydration by moving URL parsing to useEffect - api.ts: Increase timeout from 15s to 30s for hardware detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -590,6 +590,7 @@ impl Pipeline {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
let reason = format!("analysis_failed|error={e}");
|
||||
let _ = self.db.add_log("error", Some(job_id), &reason).await;
|
||||
self.db.add_decision(job_id, "skip", &reason).await.ok();
|
||||
self.db
|
||||
.update_job_status(job_id, crate::db::JobState::Failed)
|
||||
@@ -602,11 +603,13 @@ impl Pipeline {
|
||||
let output_path = std::path::PathBuf::from(&job.output_path);
|
||||
|
||||
// Get profile for this job's input path (if any)
|
||||
let profile = self
|
||||
.db
|
||||
.get_profile_for_path(&job.input_path)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let profile = match self.db.get_profile_for_path(&job.input_path).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch profile for {}: {}", job.input_path, e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Run the planner
|
||||
let config_snapshot = Arc::new(self.config.read().await.clone());
|
||||
@@ -619,6 +622,7 @@ impl Pipeline {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let reason = format!("planning_failed|error={e}");
|
||||
let _ = self.db.add_log("error", Some(job_id), &reason).await;
|
||||
self.db.add_decision(job_id, "skip", &reason).await.ok();
|
||||
self.db
|
||||
.update_job_status(job_id, crate::db::JobState::Failed)
|
||||
|
||||
@@ -44,11 +44,11 @@ pub(crate) async fn login_handler(
|
||||
let parsed_hash = match &user_result {
|
||||
Some(u) => PasswordHash::new(&u.password_hash).unwrap_or_else(|_| {
|
||||
is_valid = false;
|
||||
PasswordHash::new(DUMMY_HASH).unwrap()
|
||||
PasswordHash::new(DUMMY_HASH).expect("DUMMY_HASH must be a valid argon2 hash")
|
||||
}),
|
||||
None => {
|
||||
is_valid = false;
|
||||
PasswordHash::new(DUMMY_HASH).unwrap()
|
||||
PasswordHash::new(DUMMY_HASH).expect("DUMMY_HASH must be a valid argon2 hash")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,11 +59,9 @@ pub(crate) async fn login_handler(
|
||||
is_valid = false;
|
||||
}
|
||||
|
||||
if !is_valid || user_result.is_none() {
|
||||
let Some(user) = user_result.filter(|_| is_valid) else {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
||||
}
|
||||
|
||||
let user = user_result.unwrap();
|
||||
};
|
||||
|
||||
// Create session
|
||||
let token: String = rand::rng()
|
||||
|
||||
@@ -29,17 +29,23 @@ pub(crate) async fn security_headers_middleware(request: Request, next: Next) ->
|
||||
let headers = response.headers_mut();
|
||||
|
||||
// Prevent clickjacking
|
||||
headers.insert(header::X_FRAME_OPTIONS, "DENY".parse().unwrap());
|
||||
headers.insert(
|
||||
header::X_FRAME_OPTIONS,
|
||||
"DENY".parse().expect("valid header value"),
|
||||
);
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
headers.insert(header::X_CONTENT_TYPE_OPTIONS, "nosniff".parse().unwrap());
|
||||
headers.insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
"nosniff".parse().expect("valid header value"),
|
||||
);
|
||||
|
||||
// XSS protection (legacy but still useful)
|
||||
headers.insert(
|
||||
"X-XSS-Protection"
|
||||
.parse::<axum::http::HeaderName>()
|
||||
.unwrap(),
|
||||
"1; mode=block".parse().unwrap(),
|
||||
.expect("valid header name"),
|
||||
"1; mode=block".parse().expect("valid header value"),
|
||||
);
|
||||
|
||||
// Content Security Policy - allows inline scripts/styles for the SPA
|
||||
@@ -48,21 +54,25 @@ pub(crate) async fn security_headers_middleware(request: Request, next: Next) ->
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
.expect("valid CSP header value"),
|
||||
);
|
||||
|
||||
// Referrer policy
|
||||
headers.insert(
|
||||
header::REFERRER_POLICY,
|
||||
"strict-origin-when-cross-origin".parse().unwrap(),
|
||||
"strict-origin-when-cross-origin"
|
||||
.parse()
|
||||
.expect("valid header value"),
|
||||
);
|
||||
|
||||
// Permissions policy (restrict browser features)
|
||||
headers.insert(
|
||||
"Permissions-Policy"
|
||||
.parse::<axum::http::HeaderName>()
|
||||
.unwrap(),
|
||||
"geolocation=(), microphone=(), camera=()".parse().unwrap(),
|
||||
.expect("valid header name"),
|
||||
"geolocation=(), microphone=(), camera=()"
|
||||
.parse()
|
||||
.expect("valid header value"),
|
||||
);
|
||||
|
||||
response
|
||||
|
||||
@@ -61,7 +61,8 @@ where
|
||||
let transcoder = Arc::new(Transcoder::new());
|
||||
|
||||
// Create event channels before Agent
|
||||
let (jobs_tx, _) = broadcast::channel(1000);
|
||||
// Use tx_capacity for jobs channel to allow testing lag behavior
|
||||
let (jobs_tx, _) = broadcast::channel(tx_capacity);
|
||||
let (config_tx, _) = broadcast::channel(50);
|
||||
let (system_tx, _) = broadcast::channel(100);
|
||||
let event_channels = Arc::new(crate::db::EventChannels {
|
||||
|
||||
@@ -282,6 +282,11 @@ pub(crate) async fn setup_complete_handler(
|
||||
replace_runtime_hardware(state.as_ref(), hardware_info, probe_log).await;
|
||||
refresh_file_watcher(&state).await;
|
||||
|
||||
// Mark setup as complete
|
||||
state
|
||||
.setup_required
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
// Start Scan (optional, but good for UX)
|
||||
// Use library_scanner so the UI can track progress via /api/scan/status
|
||||
let scanner = state.library_scanner.clone();
|
||||
|
||||
@@ -139,7 +139,7 @@ export function humanizeSkipReason(reason: string): SkipDetail {
|
||||
|
||||
default:
|
||||
return {
|
||||
summary: "Skipped",
|
||||
summary: "Decision recorded",
|
||||
detail: reason,
|
||||
action: null,
|
||||
measured,
|
||||
@@ -379,12 +379,28 @@ export default function JobManager() {
|
||||
let eventSource: EventSource | null = null;
|
||||
let cancelled = false;
|
||||
let reconnectTimeout: number | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
const getReconnectDelay = () => {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
|
||||
const baseDelay = 1000;
|
||||
const maxDelay = 30000;
|
||||
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), maxDelay);
|
||||
// Add jitter (±25%) to prevent thundering herd
|
||||
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
||||
return Math.round(delay + jitter);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
eventSource?.close();
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.onopen = () => {
|
||||
// Reset reconnect attempts on successful connection
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
eventSource.addEventListener("status", (e) => {
|
||||
try {
|
||||
const { job_id, status } = JSON.parse(e.data) as {
|
||||
@@ -425,7 +441,9 @@ export default function JobManager() {
|
||||
eventSource.onerror = () => {
|
||||
eventSource?.close();
|
||||
if (!cancelled) {
|
||||
reconnectTimeout = window.setTimeout(connect, 3000);
|
||||
reconnectAttempts++;
|
||||
const delay = getReconnectDelay();
|
||||
reconnectTimeout = window.setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1281,7 +1299,9 @@ export default function JobManager() {
|
||||
{focusedDecision && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-helios-ink">
|
||||
{focusedDecision.summary}
|
||||
{focusedJob.job.status === "completed"
|
||||
? "Transcoded"
|
||||
: focusedDecision.summary}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-helios-slate">
|
||||
{focusedDecision.detail}
|
||||
|
||||
@@ -25,12 +25,17 @@ const TABS = [
|
||||
];
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const [activeTab, setActiveTab] = useState(() => {
|
||||
if (typeof window === "undefined") return "watch";
|
||||
// Start with default tab to avoid hydration mismatch, then sync with URL in useEffect
|
||||
const [activeTab, setActiveTab] = useState("watch");
|
||||
|
||||
// Sync tab state from URL after hydration (client-side only)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requested = params.get("tab");
|
||||
return requested && TABS.some((tab) => tab.id === requested) ? requested : "watch";
|
||||
});
|
||||
if (requested && TABS.some((tab) => tab.id === requested)) {
|
||||
setActiveTab(requested);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activeIndex = TABS.findIndex(t => t.id === activeTab);
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = 15000;
|
||||
// 30s timeout: hardware detection and large scans can take time
|
||||
const timeoutMs = 30000;
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const abortHandler = () => controller.abort();
|
||||
|
||||
Reference in New Issue
Block a user