feat: comprehensive code review fixes — security, performance, quality

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
This commit is contained in:
2026-03-23 01:59:51 +03:00
parent 31584c5d31
commit e0bae394ee
78 changed files with 2855 additions and 1658 deletions
@@ -0,0 +1,262 @@
<script lang="ts">
import { t } from '$lib/i18n';
import MdiIcon from './MdiIcon.svelte';
interface DayData {
date: string;
[eventType: string]: string | number;
}
let { days = [] }: { days: DayData[] } = $props();
const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const;
const COLORS: Record<string, string> = {
assets_added: '#059669',
assets_removed: '#ef4444',
collection_renamed: '#6366f1',
collection_deleted: '#dc2626',
sharing_changed: '#f59e0b',
};
const LABELS: Record<string, string> = {
assets_added: 'dashboard.filterAssetsAdded',
assets_removed: 'dashboard.filterAssetsRemoved',
collection_renamed: 'dashboard.filterRenamed',
collection_deleted: 'dashboard.filterDeleted',
sharing_changed: 'dashboard.filterSharingChanged',
};
let tooltip = $state<{ x: number; y: number; text: string } | null>(null);
const maxValue = $derived.by(() => {
let max = 0;
for (const day of days) {
let sum = 0;
for (const et of EVENT_TYPES) {
sum += (day[et] as number) || 0;
}
if (sum > max) max = sum;
}
return Math.max(max, 1);
});
const hasData = $derived(days.some(d => EVENT_TYPES.some(et => (d[et] as number) > 0)));
// Active event types (ones that actually have data)
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function showTooltip(e: MouseEvent, day: DayData) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const parts: string[] = [];
for (const et of EVENT_TYPES) {
const v = (day[et] as number) || 0;
if (v > 0) parts.push(`${t(LABELS[et])}: ${v} ${v === 1 ? t('dashboard.event') : t('dashboard.events')}`);
}
if (parts.length === 0) parts.push(`0 ${t('dashboard.events')}`);
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top,
text: `${formatDate(day.date)}\n${parts.join('\n')}`,
};
}
function hideTooltip() {
tooltip = null;
}
</script>
<div class="chart-wrapper">
<div class="chart-header">
<h4 class="chart-title">
<MdiIcon name="mdiChartBar" size={18} />
{t('dashboard.eventActivity')}
</h4>
<span class="chart-subtitle">{t('dashboard.last14days')}</span>
</div>
{#if !hasData}
<div class="chart-empty">
<MdiIcon name="mdiChartBoxOutline" size={32} />
<span>{t('dashboard.noChartData')}</span>
</div>
{:else}
<div class="chart-body">
<div class="chart-bars">
{#each days as day, i}
{@const total = EVENT_TYPES.reduce((s, et) => s + ((day[et] as number) || 0), 0)}
<div
class="bar-col"
role="img"
aria-label="{formatDate(day.date)}: {total} {t('dashboard.events')}"
onmouseenter={(e) => showTooltip(e, day)}
onmouseleave={hideTooltip}
>
<div class="bar-stack" style="--max: {maxValue}">
{#each EVENT_TYPES as et}
{@const v = (day[et] as number) || 0}
{#if v > 0}
<div
class="bar-segment"
style="height: {(v / maxValue) * 100}%; background: {COLORS[et]};"
></div>
{/if}
{/each}
</div>
<span class="bar-label">{i % 2 === 0 ? formatDate(day.date) : ''}</span>
</div>
{/each}
</div>
<!-- Legend -->
<div class="chart-legend">
{#each activeTypes as et}
<span class="legend-item">
<span class="legend-dot" style="background: {COLORS[et]};"></span>
{t(LABELS[et])}
</span>
{/each}
</div>
</div>
{/if}
</div>
{#if tooltip}
<div
class="chart-tooltip"
style="position: fixed; left: {tooltip.x}px; top: {tooltip.y}px; z-index: 9999; transform: translate(-50%, -100%) translateY(-8px);"
>
{#each tooltip.text.split('\n') as line}
<div>{line}</div>
{/each}
</div>
{/if}
<style>
.chart-wrapper {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
transition: border-color 0.2s;
}
.chart-wrapper:hover {
border-color: var(--color-primary);
box-shadow: 0 0 16px var(--color-glow);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.chart-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.chart-subtitle {
font-size: 0.75rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
}
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 0;
color: var(--color-muted-foreground);
opacity: 0.5;
font-size: 0.8rem;
}
.chart-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 120px;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
cursor: default;
}
.bar-stack {
flex: 1;
width: 100%;
display: flex;
flex-direction: column-reverse;
align-items: stretch;
justify-content: flex-start;
border-radius: 3px 3px 0 0;
overflow: hidden;
min-height: 0;
}
.bar-segment {
width: 100%;
min-height: 2px;
transition: height 0.5s ease, opacity 0.2s;
opacity: 0.85;
}
.bar-col:hover .bar-segment {
opacity: 1;
}
.bar-label {
font-size: 0.55rem;
color: var(--color-muted-foreground);
margin-top: 4px;
white-space: nowrap;
font-family: var(--font-mono);
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.65rem;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.chart-tooltip {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
font-family: var(--font-mono);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
pointer-events: none;
white-space: nowrap;
line-height: 1.5;
}
</style>