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:
@@ -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>
|
||||
Reference in New Issue
Block a user