feat: security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish
- Add outbound URL validation (SSRF) for webhook/Discord/Slack/ntfy/Matrix dispatch - Template renderer: input/output caps and thread-based render timeout - Webhook log filter: strip Authorization/signature/token-like headers; atomic prune - Auth/JWT/backup/config tightening; misc frontend UX fixes
This commit is contained in:
+10
-1
@@ -12,7 +12,7 @@
|
||||
--color-background: #f8f9fb;
|
||||
--color-foreground: #1a1a2e;
|
||||
--color-muted: #eef0f4;
|
||||
--color-muted-foreground: #6b7280;
|
||||
--color-muted-foreground: #525866;
|
||||
--color-border: #e2e4ea;
|
||||
--color-primary: #0d9488;
|
||||
--color-primary-foreground: #ffffff;
|
||||
@@ -34,6 +34,15 @@
|
||||
--font-sans: 'DM Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Consolas', monospace;
|
||||
--radius: 0.625rem;
|
||||
/* Layered z-index scale — refer to these instead of ad-hoc numbers.
|
||||
Ordered: base < sticky < dropdown < overlay < modal < tooltip < toast */
|
||||
--z-base: 1;
|
||||
--z-sticky: 10;
|
||||
--z-dropdown: 30;
|
||||
--z-overlay: 40;
|
||||
--z-modal: 50;
|
||||
--z-tooltip: 60;
|
||||
--z-toast: 70;
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/** Normalize a caught error to a user-safe message. */
|
||||
export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
if (typeof err === 'string' && err) return err;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
|
||||
@@ -21,20 +21,22 @@
|
||||
</script>
|
||||
|
||||
<button type="button" bind:this={btnEl}
|
||||
class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-[9px] font-bold leading-none
|
||||
class="inline-flex items-center justify-center w-4 h-4 rounded-full text-[11px] font-bold leading-none
|
||||
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
|
||||
hover:bg-[var(--color-border)] hover:text-[var(--color-foreground)]
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]
|
||||
transition-colors cursor-help align-middle ml-2 flex-shrink-0"
|
||||
onmouseenter={show}
|
||||
onmouseleave={hide}
|
||||
onfocus={show}
|
||||
onblur={hide}
|
||||
aria-label={text}
|
||||
title={text}
|
||||
tabindex="0"
|
||||
>?</button>
|
||||
|
||||
{#if visible}
|
||||
<div role="tooltip" style="{tooltipStyle} background:var(--color-card); color:var(--color-foreground); border:1px solid var(--color-border); box-shadow:0 10px 30px rgba(0,0,0,0.3); padding:0.625rem 0.75rem; border-radius:0.5rem; font-size:0.75rem; white-space:normal; line-height:1.625; pointer-events:none;">
|
||||
<div role="tooltip" style="{tooltipStyle} background:var(--color-card); color:var(--color-foreground); border:1px solid var(--color-border); box-shadow:0 10px 30px rgba(0,0,0,0.3); padding:0.625rem 0.75rem; border-radius:0.5rem; font-size:0.8125rem; white-space:normal; line-height:1.625; pointer-events:none;">
|
||||
{text}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ConfigField {
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** Default value for this field. */
|
||||
defaultValue?: string | number;
|
||||
defaultValue?: string | number | boolean;
|
||||
}
|
||||
|
||||
// ── Event tracking (TrackingConfig form) ─────────────────────────────
|
||||
@@ -60,14 +60,14 @@ export interface EventTrackingField {
|
||||
export interface ExtraTrackingField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'grid-select';
|
||||
type: 'number' | 'grid-select' | 'toggle';
|
||||
/** Grid-select item source function name from grid-items.ts. */
|
||||
gridItems?: string;
|
||||
gridColumns?: number;
|
||||
hint?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: string | number;
|
||||
defaultValue?: string | number | boolean;
|
||||
}
|
||||
|
||||
/** A feature section like periodic summary, scheduled assets, memory mode. */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { createEntityCache } from './entity-cache.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type {
|
||||
ServiceProvider,
|
||||
NotificationTarget,
|
||||
@@ -57,19 +58,56 @@ export const commandTrackersCache = createEntityCache<CommandTracker>('/command-
|
||||
/** Actions — used by Actions page. */
|
||||
export const actionsCache = createEntityCache<Action>('/actions');
|
||||
|
||||
/** Provider capabilities — used by Template Configs, Command Configs. */
|
||||
export interface SlotDef {
|
||||
name: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandDef {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ActionTypeDef {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
notification_slots?: SlotDef[];
|
||||
command_slots?: SlotDef[];
|
||||
commands?: CommandDef[];
|
||||
action_types?: ActionTypeDef[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type CapabilitiesMap = Record<string, ProviderCapabilities>;
|
||||
|
||||
/** Provider capabilities — used by Template Configs, Command Configs.
|
||||
* Dedups concurrent fetches so two fast navigations do not double-hit the API. */
|
||||
export const capabilitiesCache = (() => {
|
||||
let data = $state<Record<string, any>>({});
|
||||
let data = $state<CapabilitiesMap>({});
|
||||
let fetchedAt = $state(0);
|
||||
const TTL = 60_000; // 1 minute
|
||||
let inflight: Promise<CapabilitiesMap> | null = null;
|
||||
const TTL = 60_000;
|
||||
return {
|
||||
get items() { return data; },
|
||||
async fetch(force = false): Promise<Record<string, any>> {
|
||||
async fetch(force = false): Promise<CapabilitiesMap> {
|
||||
if (!force && Object.keys(data).length > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||
const { api } = await import('$lib/api');
|
||||
data = await api('/providers/capabilities');
|
||||
fetchedAt = Date.now();
|
||||
return data;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
try {
|
||||
data = await api<CapabilitiesMap>('/providers/capabilities');
|
||||
fetchedAt = Date.now();
|
||||
return data;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -78,15 +116,23 @@ export const capabilitiesCache = (() => {
|
||||
export const supportedLocalesCache = (() => {
|
||||
let data = $state<string[]>(['en', 'ru']);
|
||||
let fetchedAt = $state(0);
|
||||
const TTL = 300_000; // 5 minutes
|
||||
let inflight: Promise<string[]> | null = null;
|
||||
const TTL = 300_000;
|
||||
return {
|
||||
get items() { return data; },
|
||||
async fetch(force = false): Promise<string[]> {
|
||||
if (!force && fetchedAt > 0 && Date.now() - fetchedAt < TTL) return data;
|
||||
const { api } = await import('$lib/api');
|
||||
data = await api('/settings/locales');
|
||||
fetchedAt = Date.now();
|
||||
return data;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
try {
|
||||
data = await api<string[]>('/settings/locales');
|
||||
fetchedAt = Date.now();
|
||||
return data;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
let editingEmail = $state<number | null>(null);
|
||||
let emailSubmitting = $state(false);
|
||||
let emailTesting = $state<Record<number, boolean>>({});
|
||||
let confirmDeleteEmail = $state<EmailBot | null>(null);
|
||||
let confirmDeleteEmail = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
let error = $state('');
|
||||
|
||||
const defaultEmailForm = () => ({
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
let editingMatrix = $state<number | null>(null);
|
||||
let matrixSubmitting = $state(false);
|
||||
let matrixTesting = $state<Record<number, boolean>>({});
|
||||
let confirmDeleteMatrix = $state<MatrixBot | null>(null);
|
||||
let confirmDeleteMatrix = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
let error = $state('');
|
||||
|
||||
const defaultMatrixForm = () => ({
|
||||
|
||||
@@ -375,12 +375,14 @@
|
||||
<div style={gridStyle}
|
||||
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
aria-label={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<EntitySelect
|
||||
items={LANG_ITEMS}
|
||||
value={chat.language_override || ''}
|
||||
@@ -388,7 +390,7 @@
|
||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
||||
<button
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={t('telegramBot.commandsToggle')}
|
||||
|
||||
@@ -57,15 +57,6 @@
|
||||
favorites: 'mdiStar', people: 'mdiAccountGroup',
|
||||
};
|
||||
|
||||
let allCapabilities = $derived(capabilitiesCache.items);
|
||||
let providerCommands = $derived<{key: string, icon: string}[]>(
|
||||
(allCapabilities[form.provider_type]?.commands || []).map((c: { name: string }) => ({
|
||||
key: c.name,
|
||||
icon: commandIcons[c.name] || 'mdiConsole',
|
||||
}))
|
||||
);
|
||||
let hasCommands = $derived(providerCommands.length > 0);
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
icon: '',
|
||||
@@ -78,6 +69,15 @@
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
let allCapabilities = $derived(capabilitiesCache.items);
|
||||
let providerCommands = $derived<{key: string, icon: string}[]>(
|
||||
(allCapabilities[form.provider_type]?.commands || []).map((c: { name: string }) => ({
|
||||
key: c.name,
|
||||
icon: commandIcons[c.name] || 'mdiConsole',
|
||||
}))
|
||||
);
|
||||
let hasCommands = $derived(providerCommands.length > 0);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
|
||||
@@ -90,6 +90,15 @@
|
||||
return 'empty';
|
||||
}
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: '',
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
slots: {} as Record<string, Record<string, string>>,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
// Provider capabilities
|
||||
let allCapabilities = $state<Record<string, any>>({});
|
||||
let providerTypes = $derived(Object.keys(allCapabilities));
|
||||
@@ -102,15 +111,6 @@
|
||||
: commandSlots
|
||||
);
|
||||
|
||||
const defaultForm = () => ({
|
||||
provider_type: '',
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
slots: {} as Record<string, Record<string, string>>,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
/** Get slot template for current locale, with fallback. */
|
||||
function getSlotValue(slotName: string): string {
|
||||
return form.slots[slotName]?.[activeLocale] || '';
|
||||
|
||||
@@ -35,9 +35,6 @@
|
||||
const providerItems = $derived(providers
|
||||
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
|
||||
.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
|
||||
const configItems = $derived(filteredConfigs()
|
||||
.filter((c: any) => !globalProviderFilter.providerType || c.provider_type === globalProviderFilter.providerType)
|
||||
.map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
|
||||
const botItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
@@ -64,12 +61,15 @@
|
||||
let form = $state(defaultForm());
|
||||
|
||||
// Filter command configs by selected provider's type
|
||||
let filteredConfigs = $derived(() => {
|
||||
let filteredConfigs = $derived.by(() => {
|
||||
if (!form.provider_id) return commandConfigs;
|
||||
const provider = providers.find(p => p.id === form.provider_id);
|
||||
if (!provider) return commandConfigs;
|
||||
return commandConfigs.filter(c => c.provider_type === provider.type);
|
||||
});
|
||||
const configItems = $derived(filteredConfigs
|
||||
.filter((c: any) => !globalProviderFilter.providerType || c.provider_type === globalProviderFilter.providerType)
|
||||
.map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
||||
<div class="flex items-center gap-2 mb-2 font-medium">
|
||||
{#if validationResult.valid}
|
||||
<MdiIcon name="mdiCheckCircle" size={14} class="text-green-600" />
|
||||
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
|
||||
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
||||
{:else}
|
||||
<MdiIcon name="mdiCloseCircle" size={14} />
|
||||
|
||||
Reference in New Issue
Block a user