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
157 lines
7.1 KiB
Svelte
157 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
import { api } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import type { MatrixBot } from '$lib/types';
|
|
|
|
let { onreload }: { onreload: () => Promise<void> } = $props();
|
|
|
|
let matrixBots = $derived(matrixBotsCache.items);
|
|
let showMatrixForm = $state(false);
|
|
let editingMatrix = $state<number | null>(null);
|
|
let matrixSubmitting = $state(false);
|
|
let matrixTesting = $state<Record<number, boolean>>({});
|
|
let confirmDeleteMatrix = $state<MatrixBot | null>(null);
|
|
let error = $state('');
|
|
|
|
const defaultMatrixForm = () => ({
|
|
name: '', icon: '', homeserver_url: '', access_token: '', display_name: '',
|
|
});
|
|
let matrixForm = $state(defaultMatrixForm());
|
|
|
|
function openNewMatrix() { matrixForm = defaultMatrixForm(); editingMatrix = null; showMatrixForm = true; }
|
|
function editMatrixBot(bot: MatrixBot) {
|
|
matrixForm = {
|
|
name: bot.name, icon: bot.icon || '',
|
|
homeserver_url: bot.homeserver_url, access_token: '',
|
|
display_name: bot.display_name || '',
|
|
};
|
|
editingMatrix = bot.id; showMatrixForm = true;
|
|
}
|
|
|
|
async function saveMatrixBot(e: SubmitEvent) {
|
|
e.preventDefault(); error = ''; matrixSubmitting = true;
|
|
try {
|
|
const body = { ...matrixForm };
|
|
if (editingMatrix && !body.access_token) delete (body as any).access_token;
|
|
if (editingMatrix) {
|
|
await api(`/matrix-bots/${editingMatrix}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
snackSuccess(t('snack.matrixBotUpdated'));
|
|
} else {
|
|
await api('/matrix-bots', { method: 'POST', body: JSON.stringify(body) });
|
|
snackSuccess(t('snack.matrixBotCreated'));
|
|
}
|
|
matrixForm = defaultMatrixForm(); showMatrixForm = false; editingMatrix = null; await onreload();
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
finally { matrixSubmitting = false; }
|
|
}
|
|
|
|
function removeMatrix(id: number) {
|
|
confirmDeleteMatrix = {
|
|
id,
|
|
onconfirm: async () => {
|
|
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
|
catch (err: any) { error = err.message; snackError(err.message); }
|
|
finally { confirmDeleteMatrix = null; }
|
|
}
|
|
};
|
|
}
|
|
|
|
async function testMatrixBot(botId: number) {
|
|
matrixTesting = { ...matrixTesting, [botId]: true };
|
|
try {
|
|
const res = await api(`/matrix-bots/${botId}/test`, { method: 'POST' });
|
|
if (res.success) snackSuccess(t('snack.matrixBotTestOk'));
|
|
else snackError(res.error || 'Failed');
|
|
} catch (err: any) { snackError(err.message); }
|
|
matrixTesting = { ...matrixTesting, [botId]: false };
|
|
}
|
|
</script>
|
|
|
|
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
|
|
<button onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}
|
|
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
|
{showMatrixForm ? t('common.cancel') : t('matrixBot.addBot')}
|
|
</button>
|
|
</PageHeader>
|
|
|
|
{#if showMatrixForm}
|
|
<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={saveMatrixBot} class="space-y-3">
|
|
<div>
|
|
<label for="mbot-name" class="block text-sm font-medium mb-1">{t('matrixBot.name')}</label>
|
|
<div class="flex gap-2">
|
|
<IconPicker value={matrixForm.icon} onselect={(v: string) => matrixForm.icon = v} />
|
|
<input id="mbot-name" bind:value={matrixForm.name} required placeholder={t('matrixBot.namePlaceholder')}
|
|
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label for="mbot-hs" class="block text-sm font-medium mb-1">{t('matrixBot.homeserverUrl')}</label>
|
|
<input id="mbot-hs" bind:value={matrixForm.homeserver_url} required placeholder="https://matrix.org"
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="mbot-token" class="block text-sm font-medium mb-1">{t('matrixBot.accessToken')}</label>
|
|
<input id="mbot-token" bind:value={matrixForm.access_token} type="password"
|
|
required={!editingMatrix}
|
|
placeholder={editingMatrix ? t('matrixBot.tokenUnchanged') : t('matrixBot.tokenPlaceholder')}
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
|
</div>
|
|
<div>
|
|
<label for="mbot-display" class="block text-sm font-medium mb-1">{t('matrixBot.displayName')}</label>
|
|
<input id="mbot-display" bind:value={matrixForm.display_name} placeholder="Notify Bridge"
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<button type="submit" disabled={matrixSubmitting}
|
|
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">
|
|
{matrixSubmitting ? t('common.loading') : (editingMatrix ? t('common.save') : t('matrixBot.addBot'))}
|
|
</button>
|
|
</form>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if matrixBots.length === 0 && !showMatrixForm}
|
|
<Card>
|
|
<EmptyState icon="mdiMatrix" message={t('matrixBot.noBots')} />
|
|
</Card>
|
|
{:else}
|
|
<div class="space-y-3 stagger-children">
|
|
{#each matrixBots as bot}
|
|
<Card hover entityId={bot.id}>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiMatrix'} size={20} /></span>
|
|
<p class="font-medium">{bot.name}</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
|
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.homeserver_url}</span>
|
|
{#if bot.display_name}
|
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.display_name}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<IconButton icon="mdiConnection" title={t('matrixBot.testConnection')} onclick={() => testMatrixBot(bot.id)} disabled={matrixTesting[bot.id]} />
|
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editMatrixBot(bot)} />
|
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeMatrix(bot.id)} variant="danger" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
|
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|