feat: harden notification stack and switch logging selectors to icon grid

Notifications:
- Add shared http_base, redact, and SSRF hardening modules
- Refactor dispatcher, queue, receiver and per-provider clients
  (telegram, discord, email, matrix, ntfy, slack, webhook) to use
  the shared base, with bounded queue and redacted error logs
- Tests for ssrf, redact, http_base, queue bounds, dispatcher
  aggregation, telegram media partition, email and matrix clients

Frontend:
- Settings: log level / log format selectors now use IconGridSelect
  with per-option icons and i18n descriptions
- Minor providers page and entity-cache store updates

Tooling:
- Document code-review-graph MCP usage in CLAUDE.md
- Ignore .code-review-graph/, register .mcp.json
This commit is contained in:
2026-05-07 13:53:26 +03:00
parent 5bd63a2191
commit 0eb899afb9
33 changed files with 2623 additions and 1033 deletions
+16
View File
@@ -73,6 +73,22 @@ export const localeItems = (): GridItem[] => [
{ value: 'ru', icon: 'mdiAlphabeticalVariant', label: 'Русский', desc: t('gridDesc.localeRu') },
];
// --- Log level ---
export const logLevelItems = (): GridItem[] => [
{ value: 'DEBUG', icon: 'mdiBugOutline', label: 'DEBUG', desc: t('gridDesc.logLevelDebug') },
{ value: 'INFO', icon: 'mdiInformationOutline', label: 'INFO', desc: t('gridDesc.logLevelInfo') },
{ value: 'WARNING', icon: 'mdiAlertOutline', label: 'WARNING', desc: t('gridDesc.logLevelWarning') },
{ value: 'ERROR', icon: 'mdiAlertOctagonOutline', label: 'ERROR', desc: t('gridDesc.logLevelError') },
];
// --- Log format ---
export const logFormatItems = (): GridItem[] => [
{ value: 'text', icon: 'mdiFormatText', label: 'text', desc: t('gridDesc.logFormatText') },
{ value: 'json', icon: 'mdiCodeJson', label: 'json', desc: t('gridDesc.logFormatJson') },
];
// --- Response mode ---
export const responseModeItems = (tFn: typeof t): GridItem[] => [
+8 -1
View File
@@ -192,7 +192,8 @@
"apiToken": "API Token",
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
"webhookUrl": "Webhook URL",
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings (relative to your bridge host).",
"webhookUrlHint": "Set this as the Target URL in Gitea webhook settings. The full URL is shown when an external base URL is configured in Settings; otherwise it is relative to your bridge host.",
"webhookUrlCopyTitle": "Click to copy",
"nutHost": "NUT Server Host",
"nutHostPlaceholder": "192.168.1.100 or ups.local",
"nutPort": "NUT Server Port",
@@ -1131,6 +1132,12 @@
"memorySourceNative": "Use Immich native memories API",
"localeEn": "English interface",
"localeRu": "Russian interface",
"logLevelDebug": "Verbose — show every step",
"logLevelInfo": "Default — high-level events",
"logLevelWarning": "Warnings and errors only",
"logLevelError": "Errors only — quietest",
"logFormatText": "Human-readable plain text",
"logFormatJson": "One JSON object per line",
"modeMedia": "Send actual photo/video files",
"modeText": "Send file names and links only",
"allEvents": "Show all event types",
+8 -1
View File
@@ -192,7 +192,8 @@
"apiToken": "API токен",
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
"webhookUrl": "URL вебхука",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea (относительно хоста bridge).",
"webhookUrlHint": "Укажите этот URL в настройках вебхука Gitea. Полный URL показывается, если в настройках задан внешний адрес; иначе путь указан относительно хоста bridge.",
"webhookUrlCopyTitle": "Нажмите, чтобы скопировать",
"nutHost": "Хост NUT-сервера",
"nutHostPlaceholder": "192.168.1.100 или ups.local",
"nutPort": "Порт NUT-сервера",
@@ -1131,6 +1132,12 @@
"memorySourceNative": "Использовать API воспоминаний Immich",
"localeEn": "Английский интерфейс",
"localeRu": "Русский интерфейс",
"logLevelDebug": "Подробный — каждый шаг",
"logLevelInfo": "По умолчанию — ключевые события",
"logLevelWarning": "Только предупреждения и ошибки",
"logLevelError": "Только ошибки — самый тихий",
"logFormatText": "Читаемый человеком текст",
"logFormatJson": "Один JSON-объект на строку",
"modeMedia": "Отправка файлов фото/видео",
"modeText": "Только имена файлов и ссылки",
"allEvents": "Показать все типы событий",
+28
View File
@@ -112,6 +112,34 @@ export const capabilitiesCache = (() => {
};
})();
/** Configured external base URL — used to render absolute webhook URLs.
* Available to all authenticated users. Empty string when unset. */
export const externalUrlCache = (() => {
let data = $state<string>('');
let fetchedAt = $state(0);
let inflight: Promise<string> | null = null;
const TTL = 300_000;
return {
get value() { return data; },
invalidate() { fetchedAt = 0; },
async fetch(force = false): Promise<string> {
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await api<{ external_url: string }>('/settings/external-url');
data = (res?.external_url || '').replace(/\/+$/, '');
fetchedAt = Date.now();
return data;
} finally {
inflight = null;
}
})();
return inflight;
},
};
})();
/** Supported template locales — fetched from app settings. */
export const supportedLocalesCache = (() => {
let data = $state<string[]>(['en', 'ru']);
+42 -4
View File
@@ -3,7 +3,7 @@
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import { providersCache, externalUrlCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -21,7 +21,7 @@
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import { onDestroy } from 'svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { getDescriptor, buildProviderFormDefaults } from '$lib/providers';
import Button from '$lib/components/Button.svelte';
@@ -45,6 +45,30 @@
let confirmDelete = $state<ServiceProvider | null>(null);
let descriptor = $derived(getDescriptor(form.type));
let externalUrl = $derived(externalUrlCache.value);
function buildWebhookUrl(pattern: string, token: string): string {
const path = pattern.replace('{token}', token ?? '');
return externalUrl ? `${externalUrl}${path}` : path;
}
function copyWebhookUrl(e: Event, url: string) {
e.preventDefault();
e.stopPropagation();
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
snackInfo(`${t('snack.copied')}: ${url}`);
}
// Auto-update name when provider type changes (unless user manually edited)
$effect(() => {
@@ -76,6 +100,7 @@
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
externalUrlCache.fetch().catch(() => { /* fall back to relative URLs */ });
});
onDestroy(() => topbarAction.clear());
async function load() {
@@ -246,9 +271,15 @@
</div>
{/each}
{#if descriptor?.webhookUrlPattern && editing}
{@const editingWebhookUrl = buildWebhookUrl(descriptor.webhookUrlPattern, providers.find(p => p.id === editing)?.webhook_token ?? '')}
<div class="bg-[var(--color-muted)] rounded-md p-3">
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
<button type="button"
onclick={(e) => copyWebhookUrl(e, editingWebhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="text-xs break-all text-left hover:text-[var(--color-primary)] cursor-pointer font-mono w-full">
<code class="bg-transparent">{editingWebhookUrl}</code>
</button>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
</div>
{/if}
@@ -295,7 +326,14 @@
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{provider.config.host}:{provider.config.port || 3493}</p>
{/if}
{#if provDesc?.webhookUrlPattern}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">{t('providers.webhookUrl')}: <span class="select-all">{provDesc.webhookUrlPattern.replace('{token}', provider.webhook_token)}</span></p>
{@const webhookUrl = buildWebhookUrl(provDesc.webhookUrlPattern, provider.webhook_token)}
<p class="text-xs text-[var(--color-muted-foreground)] font-mono mt-0.5">
{t('providers.webhookUrl')}:
<button type="button"
onclick={(e) => copyWebhookUrl(e, webhookUrl)}
title={t('providers.webhookUrlCopyTitle')}
class="hover:text-[var(--color-primary)] cursor-pointer break-all text-left">{webhookUrl}</button>
</p>
{/if}
</div>
</div>
+6 -12
View File
@@ -12,7 +12,10 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { logLevelItems, logFormatItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { externalUrlCache } from '$lib/stores/caches.svelte';
interface CacheBucketStats {
count: number;
@@ -76,6 +79,7 @@
saving = true; error = '';
try {
settings = await api('/settings', { method: 'PUT', body: JSON.stringify(settings) });
externalUrlCache.invalidate();
snackSuccess(t('settings.saved'));
} catch (err: any) { error = err.message; snackError(err.message); }
saving = false;
@@ -221,21 +225,11 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
<select bind:value={settings.log_level}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<IconGridSelect items={logLevelItems()} bind:value={settings.log_level} columns={2} />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
<select bind:value={settings.log_format}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="text">text</option>
<option value="json">json</option>
</select>
<IconGridSelect items={logFormatItems()} bind:value={settings.log_format} columns={2} />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>