e0bae394ee
Backend security: - Reject Gitea webhooks when webhook_secret is empty (was silently skipping) - Add slowapi rate limiting on login (5/min) and setup (3/min) endpoints - Add CORS middleware with configurable origins - Mask telegram_webhook_secret in settings API response - Protect system-owned command template configs from regular user modification - Increase minimum password length to 8 characters Backend performance: - Batch queries in _resolve_command_context (3 queries instead of 3N) - Concurrent album fetching with asyncio.gather in immich commands - Singleton Jinja2 SandboxedEnvironment (reuse instead of per-render creation) - TTLCache for rate limits (bounded memory, auto-eviction) - Optional aiohttp session reuse in send_reply/send_media_group Backend code quality: - Extract dispatch_helpers.py (shared link_data loading + event filtering) - Extract database/seeds.py from main.py (490 lines → dedicated module) - Split immich_handler.py (415 lines) into commands/immich/ subpackage - Replace bare except blocks with logged warnings - Add per-provider config validation (Pydantic models) - Truncate command input to 512 chars - Expose usage_* and desc_* slots in capabilities and variables API Frontend security: - CSS.escape() for user-controlled querySelector in highlight.ts - Client-side password min 8 chars validation on setup and password change Frontend code quality: - Replace any types with proper interfaces across top files - Decompose targets/+page.svelte into TargetForm + ReceiverSection - Fix $derived.by usage, $state mutation patterns - Add console.warn to empty catch blocks Frontend UX: - Auth redirect via goto() with "Redirecting..." state - Platform-aware Ctrl/Cmd K keyboard hint - Remove stat-card hover transform Frontend accessibility: - Modal: role=dialog, aria-modal, focus trap, restore focus - EntitySelect/IconGridSelect: listbox/option roles, aria-selected/disabled
173 lines
8.0 KiB
Svelte
173 lines
8.0 KiB
Svelte
<script lang="ts">
|
|
import { slide } from 'svelte/transition';
|
|
import { t } from '$lib/i18n';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
|
import Hint from '$lib/components/Hint.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
|
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
|
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
|
|
|
interface Props {
|
|
form: {
|
|
name: string;
|
|
icon: string;
|
|
bot_id: number;
|
|
bot_token: string;
|
|
max_media_to_send: number;
|
|
max_media_per_group: number;
|
|
media_delay: number;
|
|
max_asset_size: number;
|
|
disable_url_preview: boolean;
|
|
send_large_photos_as_documents: boolean;
|
|
ai_captions: boolean;
|
|
chat_action: string;
|
|
username: string;
|
|
server_url: string;
|
|
auth_token: string;
|
|
matrix_bot_id: number;
|
|
email_bot_id: number;
|
|
};
|
|
formType: string;
|
|
activeType: string | null;
|
|
typeGridItems: GridItem[];
|
|
telegramBotItems: EntityItem[];
|
|
emailBotItems: EntityItem[];
|
|
matrixBotItems: EntityItem[];
|
|
chatActionItems: GridItem[];
|
|
telegramBotCount: number;
|
|
emailBotCount: number;
|
|
matrixBotCount: number;
|
|
editing: number | null;
|
|
submitting: boolean;
|
|
error: string;
|
|
showTelegramSettings: boolean;
|
|
onsave: (e: SubmitEvent) => void;
|
|
ontoggleTelegramSettings: () => void;
|
|
}
|
|
|
|
let {
|
|
form = $bindable(),
|
|
formType = $bindable(),
|
|
activeType,
|
|
typeGridItems,
|
|
telegramBotItems,
|
|
emailBotItems,
|
|
matrixBotItems,
|
|
chatActionItems,
|
|
telegramBotCount,
|
|
emailBotCount,
|
|
matrixBotCount,
|
|
editing,
|
|
submitting,
|
|
error,
|
|
showTelegramSettings = $bindable(),
|
|
onsave,
|
|
ontoggleTelegramSettings,
|
|
}: Props = $props();
|
|
</script>
|
|
|
|
<div in:slide={{ duration: 200 }}>
|
|
<Card class="mb-6">
|
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
|
<form onsubmit={onsave} class="space-y-4">
|
|
{#if !activeType}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
|
|
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
|
<div class="flex gap-2">
|
|
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
|
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
</div>
|
|
{#if formType === 'telegram'}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
|
|
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
|
|
{#if telegramBotCount === 0}
|
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline">→</a></p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="border border-[var(--color-border)] rounded-md p-3">
|
|
<button type="button" onclick={ontoggleTelegramSettings}
|
|
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
|
|
{t('targets.telegramSettings')}
|
|
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
|
|
</button>
|
|
{#if showTelegramSettings}
|
|
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
|
|
<div>
|
|
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
|
|
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
|
|
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
|
|
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
|
|
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
|
|
<IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact />
|
|
</div>
|
|
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
|
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if formType === 'discord' || formType === 'slack'}
|
|
<div>
|
|
<label for="tgt-user" class="block text-sm font-medium mb-1">{t('targets.overrideUsername')}</label>
|
|
<input id="tgt-user" bind:value={form.username} placeholder="Notify Bridge"
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
{:else if formType === 'ntfy'}
|
|
<div>
|
|
<label for="tgt-ntfy-server" class="block text-sm font-medium mb-1">{t('targets.ntfyServer')}</label>
|
|
<input id="tgt-ntfy-server" bind:value={form.server_url} required placeholder="https://ntfy.sh"
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="tgt-ntfy-token" class="block text-sm font-medium mb-1">{t('targets.ntfyToken')}</label>
|
|
<input id="tgt-ntfy-token" bind:value={form.auth_token} placeholder={t('targets.ntfyTokenPlaceholder')}
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
{:else if formType === 'email'}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label>
|
|
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
|
|
{#if emailBotCount === 0}
|
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline">→</a></p>
|
|
{/if}
|
|
</div>
|
|
{:else if formType === 'matrix'}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label>
|
|
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
|
|
{#if matrixBotCount === 0}
|
|
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline">→</a></p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if formType === 'telegram'}
|
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
|
|
{/if}
|
|
|
|
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{submitting ? t('common.loading') : (editing ? t('common.save') : t('targets.create'))}</button>
|
|
</form>
|
|
</Card>
|
|
</div>
|