dbf45ec31d
Build and Push Docker Image / build (push) Successful in 1m4s
Docs Check / Markdown lint (push) Successful in 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m22s
CI / Security audit (push) Successful in 2m44s
CI / Swagger Validation & Coverage (push) Successful in 2m59s
Docs Check / Mermaid diagram parse check (push) Successful in 3m11s
CI / Tests & coverage (push) Successful in 3m27s
- Add requireAuth to GET /api/webhook/config to enforce authentication - Add SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET validation to POST /api/ombi/webhook/enable and /test - Return 400 with descriptive errors when webhook config is missing on Ombi routes - Clean up test environment in webhook.test.js afterEach - Add regression tests for all new validation logic - Update CHANGELOG.md with security fixes
352 lines
11 KiB
JavaScript
352 lines
11 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
|
|
import { state } from './state.js';
|
|
|
|
export async function checkAuthentication() {
|
|
try {
|
|
// Fetch both auth state and a fresh CSRF token in parallel
|
|
const [meRes, csrfRes] = await Promise.all([
|
|
fetch('/api/auth/me'),
|
|
fetch('/api/auth/csrf')
|
|
]);
|
|
const data = await meRes.json();
|
|
const csrfData = await csrfRes.json();
|
|
if (csrfData.csrfToken) state.csrfToken = csrfData.csrfToken;
|
|
|
|
if (data.authenticated) {
|
|
state.currentUser = data.user;
|
|
state.isAdmin = !!data.user.isAdmin;
|
|
return { authenticated: true, user: data.user };
|
|
} else {
|
|
return { authenticated: false };
|
|
}
|
|
} catch (err) {
|
|
console.error('Authentication check failed:', err);
|
|
return { authenticated: false };
|
|
}
|
|
}
|
|
|
|
export async function handleLogin(username, password, rememberMe) {
|
|
try {
|
|
const response = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ username, password, rememberMe })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
state.currentUser = data.user;
|
|
state.isAdmin = !!data.user.isAdmin;
|
|
// Store CSRF token returned by login for use in subsequent requests
|
|
if (data.csrfToken) state.csrfToken = data.csrfToken;
|
|
return { success: true, user: data.user };
|
|
} else {
|
|
return { success: false, error: data.error || 'Login failed' };
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
return { success: false, error: 'Login failed. Please try again.' };
|
|
}
|
|
}
|
|
|
|
export async function handleLogout() {
|
|
try {
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
headers: state.csrfToken ? { 'X-CSRF-Token': state.csrfToken } : {}
|
|
});
|
|
state.currentUser = null;
|
|
state.csrfToken = null;
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Logout failed:', err);
|
|
return { success: false };
|
|
}
|
|
}
|
|
|
|
export async function loadHistory(forceRefresh = false) {
|
|
try {
|
|
const params = new URLSearchParams({ days: state.historyDays });
|
|
if (state.showAll) params.set('showAll', 'true');
|
|
if (forceRefresh) params.set('_t', Date.now());
|
|
const res = await fetch(`/api/history/recent?${params}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
return { success: true, history: data.history || [] };
|
|
} catch (err) {
|
|
console.error('[History] Load error:', err);
|
|
return { success: false, error: 'Failed to load history.' };
|
|
}
|
|
}
|
|
|
|
export async function handleBlocklistSearch(download) {
|
|
try {
|
|
const res = await fetch('/api/dashboard/blocklist-search', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': state.csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
arrQueueId: download.arrQueueId,
|
|
arrType: download.arrType,
|
|
arrInstanceUrl: download.arrInstanceUrl,
|
|
arrInstanceKey: download.arrInstanceKey,
|
|
arrContentId: download.arrContentId,
|
|
arrContentType: download.arrContentType
|
|
})
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error || `HTTP ${res.status}`);
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('[Blocklist] Error:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function loadAppVersion() {
|
|
try {
|
|
const res = await fetch('/health');
|
|
const data = await res.json();
|
|
return data.version || null;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function fetchWebhookMetrics() {
|
|
try {
|
|
const res = await fetch('/api/dashboard/webhook-metrics');
|
|
if (!res.ok) return null;
|
|
return await res.json();
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function fetchWebhookStatus() {
|
|
try {
|
|
// Fetch metrics in parallel
|
|
const metricsPromise = fetchWebhookMetrics();
|
|
|
|
// Fetch webhook configuration status (checks SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET)
|
|
let webhookConfigValid = false;
|
|
try {
|
|
const configRes = await fetch('/api/webhook/config');
|
|
if (configRes.ok) {
|
|
const configData = await configRes.json();
|
|
webhookConfigValid = configData.valid || false;
|
|
}
|
|
} catch (err) {
|
|
// Config endpoint not available, assume invalid
|
|
}
|
|
|
|
// Fetch Sonarr notifications
|
|
let sonarrEnabled = false;
|
|
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
|
try {
|
|
const sonarrRes = await fetch('/api/sonarr/notifications');
|
|
if (sonarrRes.ok) {
|
|
const sonarrData = await sonarrRes.json();
|
|
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
|
sonarrEnabled = webhookConfigValid && !!sonarrSofarr;
|
|
if (sonarrSofarr) {
|
|
sonarrTriggers = {
|
|
onGrab: sonarrSofarr.onGrab,
|
|
onDownload: sonarrSofarr.onDownload,
|
|
onImport: sonarrSofarr.onImport,
|
|
onUpgrade: sonarrSofarr.onUpgrade
|
|
};
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Sonarr not configured
|
|
}
|
|
|
|
// Fetch Radarr notifications
|
|
let radarrEnabled = false;
|
|
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
|
try {
|
|
const radarrRes = await fetch('/api/radarr/notifications');
|
|
if (radarrRes.ok) {
|
|
const radarrData = await radarrRes.json();
|
|
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
|
radarrEnabled = webhookConfigValid && !!radarrSofarr;
|
|
if (radarrSofarr) {
|
|
radarrTriggers = {
|
|
onGrab: radarrSofarr.onGrab,
|
|
onDownload: radarrSofarr.onDownload,
|
|
onImport: radarrSofarr.onImport,
|
|
onUpgrade: radarrSofarr.onUpgrade
|
|
};
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Radarr not configured
|
|
}
|
|
|
|
// Fetch Ombi webhook status
|
|
let ombiEnabled = false;
|
|
let ombiTriggers = { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
|
|
let ombiStats = null;
|
|
try {
|
|
const ombiRes = await fetch('/api/ombi/webhook/status');
|
|
if (ombiRes.ok) {
|
|
const ombiData = await ombiRes.json();
|
|
ombiEnabled = ombiData.enabled || false;
|
|
ombiTriggers = ombiData.triggers || { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
|
|
ombiStats = ombiData.stats || null;
|
|
}
|
|
} catch (err) {
|
|
// Ombi not configured
|
|
}
|
|
|
|
state.webhookMetrics = await metricsPromise;
|
|
|
|
// Find instance stats
|
|
const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : [];
|
|
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
|
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
|
|
|
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
|
|
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
|
|
state.ombiWebhook = { enabled: ombiEnabled, triggers: ombiTriggers, stats: ombiStats };
|
|
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to fetch webhook status:', err);
|
|
return { success: false };
|
|
}
|
|
}
|
|
|
|
export async function enableSonarrWebhook() {
|
|
try {
|
|
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
|
});
|
|
if (!res.ok) throw new Error('Failed to enable');
|
|
await fetchWebhookStatus();
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to enable Sonarr webhook:', err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
export async function enableRadarrWebhook() {
|
|
try {
|
|
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
|
});
|
|
if (!res.ok) throw new Error('Failed to enable');
|
|
await fetchWebhookStatus();
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to enable Radarr webhook:', err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
export async function testSonarrWebhook() {
|
|
try {
|
|
const sonarrRes = await fetch('/api/sonarr/notifications');
|
|
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
|
|
const sonarrData = await sonarrRes.json();
|
|
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
|
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
|
|
|
|
const res = await fetch('/api/sonarr/notifications/test', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': state.csrfToken || ''
|
|
},
|
|
body: JSON.stringify(sonarrSofarr)
|
|
});
|
|
if (!res.ok) throw new Error('Test failed');
|
|
await fetchWebhookStatus();
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to test Sonarr webhook:', err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
export async function testRadarrWebhook() {
|
|
try {
|
|
const radarrRes = await fetch('/api/radarr/notifications');
|
|
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
|
|
const radarrData = await radarrRes.json();
|
|
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
|
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
|
|
|
|
const res = await fetch('/api/radarr/notifications/test', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': state.csrfToken || ''
|
|
},
|
|
body: JSON.stringify(radarrSofarr)
|
|
});
|
|
if (!res.ok) throw new Error('Test failed');
|
|
await fetchWebhookStatus();
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to test Radarr webhook:', err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
export async function enableOmbiWebhook() {
|
|
try {
|
|
const res = await fetch('/api/ombi/webhook/enable', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
|
});
|
|
if (!res.ok) throw new Error('Failed to enable');
|
|
await fetchWebhookStatus();
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to enable Ombi webhook:', err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
export async function testOmbiWebhook() {
|
|
try {
|
|
const res = await fetch('/api/ombi/webhook/test', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
|
});
|
|
if (!res.ok) throw new Error('Test failed');
|
|
await fetchWebhookStatus();
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error('Failed to test Ombi webhook:', err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
export async function refreshStatusPanel() {
|
|
try {
|
|
const res = await fetch('/api/status');
|
|
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
|
|
const data = await res.json();
|
|
return { success: true, data };
|
|
} catch (err) {
|
|
console.error('[Status] Error fetching status:', err);
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|