feat: comprehensive code review fixes + receivers-only architecture

Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore

Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout

Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
  exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
  add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test

Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links

i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
2026-03-22 02:19:31 +03:00
parent b525e3e7f4
commit 751097b347
43 changed files with 2584 additions and 1685 deletions
+98
View File
@@ -0,0 +1,98 @@
/**
* API client with JWT auth for the Notify Bridge backend.
*/
const API_BASE = '/api';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
export function clearTokens() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
export function isAuthenticated(): boolean {
return !!getToken();
}
let refreshPromise: Promise<boolean> | null = null;
async function refreshAccessToken(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = doRefreshAccessToken().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function doRefreshAccessToken(): Promise<boolean> {
if (typeof window === 'undefined') return false;
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (res.ok) {
const data = await res.json();
setTokens(data.access_token, data.refresh_token);
return true;
}
} catch {
// ignore
}
return false;
}
export async function api<T = any>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>)
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
// Try token refresh on 401
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (res.status === 204) return undefined as T;
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
@@ -0,0 +1,191 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { visible = false, children }: { visible: boolean; children: Snippet } = $props();
</script>
<div class="auth-page">
<!-- Animated gradient mesh background -->
<div class="auth-bg"></div>
<div class="auth-grid"></div>
<!-- Card -->
<div class="auth-card-wrapper" class:visible>
<div class="auth-card">
{@render children()}
</div>
</div>
</div>
<style>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--color-background);
}
.auth-bg {
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
animation: gradientShift 12s ease-in-out infinite;
background-size: 200% 200%;
}
.auth-grid {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.3;
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
background-size: 32px 32px;
}
.auth-card-wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 24rem;
padding: 1rem;
opacity: 0;
transform: translateY(16px) scale(0.98);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.auth-card-wrapper.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.auth-card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 1rem;
padding: 2rem;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
backdrop-filter: blur(8px);
}
:global([data-theme="dark"]) .auth-card {
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
:global(.auth-logo-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 1rem;
background: var(--color-primary);
color: var(--color-primary-foreground);
box-shadow: 0 0 24px var(--color-glow-strong);
}
:global(.auth-control-btn) {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.7rem;
font-weight: 500;
background: var(--color-muted);
color: var(--color-muted-foreground);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
:global(.auth-control-btn:hover) {
color: var(--color-foreground);
box-shadow: 0 0 8px var(--color-glow);
}
:global(.auth-label) {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--color-foreground);
}
:global(.auth-input) {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
font-size: 0.875rem;
background: var(--color-background);
color: var(--color-foreground);
transition: border-color 0.2s, box-shadow 0.2s;
}
:global(.auth-input:focus) {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
}
:global(.auth-error) {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.625rem;
font-size: 0.8rem;
margin-bottom: 1rem;
background: var(--color-error-bg);
color: var(--color-error-fg);
}
:global(.auth-submit) {
width: 100%;
padding: 0.625rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 600;
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
:global(.auth-submit:hover:not(:disabled)) {
box-shadow: 0 0 24px var(--color-glow-strong);
transform: translateY(-1px);
}
:global(.auth-submit:active:not(:disabled)) {
transform: translateY(0);
}
:global(.auth-submit:disabled) {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>
@@ -24,12 +24,12 @@
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
];
function filtered(): string[] {
const filtered = $derived.by(() => {
const allIcons = getAllMdiNames();
if (!search) return popular.filter(p => allIcons.includes(p));
const q = search.toLowerCase();
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
}
});
function toggleOpen() {
if (!open && buttonEl) {
@@ -81,7 +81,7 @@
<button type="button" onclick={() => select('')}
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
title="No icon"></button>
{#each filtered() as iconName}
{#each filtered as iconName}
<button type="button" onclick={() => select(iconName)}
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
title={iconName.replace('mdi', '')}>
@@ -51,7 +51,7 @@
mapFn: (e: any) => ({ detail: e.provider_type, icon: e.icon || 'mdiFileDocumentEdit' }) },
{ key: 'targets', label: 'nav.targets', icon: 'mdiTarget', href: '/targets',
mapFn: (e: any) => ({ detail: e.type, icon: e.icon || 'mdiTarget' }) },
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots',
{ key: 'telegram_bots', label: 'nav.telegram', icon: 'mdiSendCircle', href: '/bots?tab=telegram',
mapFn: (e: any) => ({ detail: `@${e.bot_username || ''}`, icon: e.icon || 'mdiRobot' }) },
{ key: 'email_bots', label: 'nav.email', icon: 'mdiEmailOutline', href: '/bots?tab=email',
mapFn: (e: any) => ({ detail: e.email || '', icon: e.icon || 'mdiEmailOutline' }) },
+10 -10
View File
@@ -79,24 +79,24 @@ export const sortFilterItems = (): GridItem[] => [
// --- Chat action (Telegram targets) ---
export const chatActionItems = (): GridItem[] => [
{ value: '', icon: 'mdiMinus', label: 'None' },
{ value: 'typing', icon: 'mdiKeyboard', label: 'Typing' },
{ value: 'upload_photo', icon: 'mdiImagePlus', label: 'Upload Photo' },
{ value: 'upload_video', icon: 'mdiVideoPlus', label: 'Upload Video' },
{ value: 'upload_document', icon: 'mdiFileUpload', label: 'Upload Doc' },
{ value: 'record_video', icon: 'mdiVideo', label: 'Record Video' },
{ value: 'record_voice', icon: 'mdiMicrophone', label: 'Record Voice' },
{ value: '', icon: 'mdiMinus', label: t('targets.chatActionNone') },
{ value: 'typing', icon: 'mdiKeyboard', label: t('targets.chatActionTyping') },
{ value: 'upload_photo', icon: 'mdiImagePlus', label: t('targets.chatActionUploadPhoto') },
{ value: 'upload_video', icon: 'mdiVideoPlus', label: t('targets.chatActionUploadVideo') },
{ value: 'upload_document', icon: 'mdiFileUpload', label: t('targets.chatActionUploadDoc') },
{ value: 'record_video', icon: 'mdiVideo', label: t('targets.chatActionRecordVideo') },
{ value: 'record_voice', icon: 'mdiMicrophone', label: t('targets.chatActionRecordVoice') },
];
// --- Preview target type ---
export const previewTargetTypeItems = (): GridItem[] => [
{ value: 'telegram', icon: 'mdiSend', label: 'Telegram' },
{ value: 'webhook', icon: 'mdiWebhook', label: 'Webhook' },
{ value: 'telegram', icon: 'mdiSend', label: t('targets.typeTelegram') },
{ value: 'webhook', icon: 'mdiWebhook', label: t('targets.typeWebhook') },
];
// --- Provider type ---
export const providerTypeItems = (): GridItem[] => [
{ value: 'immich', icon: 'mdiCamera', label: 'Immich' },
{ value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich') },
];
+23 -1
View File
@@ -105,6 +105,7 @@
"online": "Online",
"offline": "Offline",
"checking": "Checking...",
"typeImmich": "Immich",
"loadError": "Failed to load providers.",
"externalDomain": "External Domain",
"optional": "optional",
@@ -228,6 +229,14 @@
"sendLargeAsDocuments": "Send large photos as documents",
"chatAction": "Chat action",
"chatActionNone": "None (no action)",
"chatActionTyping": "Typing",
"chatActionUploadPhoto": "Upload Photo",
"chatActionUploadVideo": "Upload Video",
"chatActionUploadDoc": "Upload Doc",
"chatActionRecordVideo": "Record Video",
"chatActionRecordVoice": "Record Voice",
"typeTelegram": "Telegram",
"typeWebhook": "Webhook",
"overrideUsername": "Override bot username",
"ntfyServer": "ntfy Server URL",
"ntfyTopic": "Topic",
@@ -236,7 +245,16 @@
"selectEmailBot": "Select Email Bot",
"selectMatrixBot": "Select Matrix Bot",
"recipientEmail": "Recipient Email",
"matrixRoomId": "Room ID"
"matrixRoomId": "Room ID",
"receivers": "Receivers",
"noReceivers": "No receivers yet",
"addReceiver": "Add Receiver",
"receiverAdded": "Receiver added",
"receiverDeleted": "Receiver deleted",
"receiverUpdated": "Receiver updated",
"confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled",
"receiverDisabled": "Receiver disabled"
},
"users": {
"title": "Users",
@@ -702,6 +720,10 @@
"line": "line",
"add": "Add"
},
"error": {
"notFound": "Page not found",
"goHome": "Go home"
},
"searchPalette": {
"placeholder": "Search entities...",
"noResults": "No results found",
+23 -1
View File
@@ -105,6 +105,7 @@
"online": "В сети",
"offline": "Не в сети",
"checking": "Проверка...",
"typeImmich": "Immich",
"loadError": "Не удалось загрузить провайдеры.",
"externalDomain": "Внешний домен",
"optional": "необязательно",
@@ -228,6 +229,14 @@
"sendLargeAsDocuments": "Отправлять большие фото как документы",
"chatAction": "Действие в чате",
"chatActionNone": "Нет (без действия)",
"chatActionTyping": "Печатает",
"chatActionUploadPhoto": "Загрузка фото",
"chatActionUploadVideo": "Загрузка видео",
"chatActionUploadDoc": "Загрузка документа",
"chatActionRecordVideo": "Запись видео",
"chatActionRecordVoice": "Запись голоса",
"typeTelegram": "Telegram",
"typeWebhook": "Вебхук",
"overrideUsername": "Переопределить имя бота",
"ntfyServer": "URL сервера ntfy",
"ntfyTopic": "Тема",
@@ -236,7 +245,16 @@
"selectEmailBot": "Выберите Email бот",
"selectMatrixBot": "Выберите Matrix бот",
"recipientEmail": "Email получателя",
"matrixRoomId": "ID комнаты"
"matrixRoomId": "ID комнаты",
"receivers": "Получатели",
"noReceivers": "Нет получателей",
"addReceiver": "Добавить получателя",
"receiverAdded": "Получатель добавлен",
"receiverDeleted": "Получатель удалён",
"receiverUpdated": "Получатель обновлён",
"confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён"
},
"users": {
"title": "Пользователи",
@@ -702,6 +720,10 @@
"line": "строка",
"add": "Добавить"
},
"error": {
"notFound": "Страница не найдена",
"goHome": "На главную"
},
"searchPalette": {
"placeholder": "Поиск объектов...",
"noResults": "Ничего не найдено",
@@ -0,0 +1,85 @@
export type SnackType = 'success' | 'error' | 'info' | 'warning';
export interface Snack {
id: number;
type: SnackType;
message: string;
detail?: string;
timeout: number;
}
const DEFAULT_TIMEOUTS: Record<SnackType, number> = {
success: 3000,
info: 3000,
warning: 4000,
error: 5000,
};
const MAX_VISIBLE = 3;
let nextId = 1;
let snacks = $state<Snack[]>([]);
const timers = new Map<number, ReturnType<typeof setTimeout>>();
export function getSnacks(): Snack[] {
return snacks;
}
export function addSnack(
type: SnackType,
message: string,
options?: { detail?: string; timeout?: number },
): void {
const id = nextId++;
const timeout = options?.timeout ?? DEFAULT_TIMEOUTS[type];
const snack: Snack = { id, type, message, detail: options?.detail, timeout };
snacks = [snack, ...snacks];
// Enforce max visible — single batch removal
if (snacks.length > MAX_VISIBLE) {
const overflow = snacks.slice(MAX_VISIBLE);
for (const s of overflow) {
const timer = timers.get(s.id);
if (timer) {
clearTimeout(timer);
timers.delete(s.id);
}
}
snacks = snacks.slice(0, MAX_VISIBLE);
}
// Auto-dismiss
if (timeout > 0) {
timers.set(
id,
setTimeout(() => removeSnack(id), timeout),
);
}
}
export function removeSnack(id: number): void {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
timers.delete(id);
}
snacks = snacks.filter((s) => s.id !== id);
}
// Convenience functions
export function snackSuccess(message: string): void {
addSnack('success', message);
}
export function snackError(message: string, detail?: string): void {
addSnack('error', message, { detail });
}
export function snackInfo(message: string): void {
addSnack('info', message);
}
export function snackWarning(message: string): void {
addSnack('warning', message);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Theme management with Svelte 5 runes.
* Supports light, dark, and system preference.
*/
export type Theme = 'light' | 'dark' | 'system';
let theme = $state<Theme>('system');
let resolved = $state<'light' | 'dark'>('light');
let listenerRegistered = false;
export function getTheme() {
return {
get current() { return theme; },
get resolved() { return resolved; },
get isDark() { return resolved === 'dark'; },
};
}
export function setTheme(newTheme: Theme) {
theme = newTheme;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('theme', newTheme);
}
applyTheme();
}
export function initTheme() {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('theme') as Theme | null;
if (saved && ['light', 'dark', 'system'].includes(saved)) {
theme = saved;
}
}
applyTheme();
// Listen for system preference changes (register only once)
if (typeof window !== 'undefined' && !listenerRegistered) {
listenerRegistered = true;
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (theme === 'system') applyTheme();
});
}
}
function applyTheme() {
if (typeof document === 'undefined') return;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
resolved = theme;
}
document.documentElement.setAttribute('data-theme', resolved);
}
+13
View File
@@ -81,6 +81,17 @@ export interface Tracker {
created_at: string;
}
export interface TargetReceiver {
id: number;
target_id: number;
name: string;
config: Record<string, any>;
receiver_key: string;
enabled: boolean;
chat_name?: string;
created_at?: string;
}
export interface NotificationTarget {
id: number;
type: string;
@@ -88,6 +99,8 @@ export interface NotificationTarget {
icon: string;
config: Record<string, any>;
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
created_at: string;
}