10d30fc956
Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.
## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession
## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch
## Database
- UNIQUE indexes on service_provider.webhook_token,
telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified
## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events
## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
oauth/client_secret/webhook_secret/csrf in both header filter and
template extras filter
## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
$effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
Telegram bot toggles
## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
(wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
test_planka_parser (6), test_immich_change_detector (6),
test_backup_roundtrip (1)
## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
failures), bridge_self_deferred_backlog (pending count crosses
threshold), bridge_self_target_failures (consecutive 5xx/network
failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
thresholds (logged only) — wire to your own Telegram/Email/Matrix to
get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
with backfill migration, frontend descriptor (excluded from "create
provider" wizard since auto-managed)
Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
receive failure alerts
Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
9.4 KiB
Svelte
226 lines
9.4 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { api, parseDate , errMsg} from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { getAuth } from '$lib/auth.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
|
import type { User } from '$lib/types';
|
|
|
|
const auth = getAuth();
|
|
let users = $state<User[]>([]);
|
|
let showForm = $state(false);
|
|
let form = $state({ username: '', password: '', role: 'user' });
|
|
let error = $state('');
|
|
let loaded = $state(false);
|
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
|
|
|
// Admin reset password
|
|
let resetUserId = $state<number | null>(null);
|
|
let resetUsername = $state('');
|
|
let resetPassword = $state('');
|
|
let resetMsg = $state('');
|
|
let resetSuccess = $state(false);
|
|
|
|
// Admin edit username/role
|
|
let editUserId = $state<number | null>(null);
|
|
let editUsername = $state('');
|
|
let editRole = $state('user');
|
|
let editMsg = $state('');
|
|
let editSuccess = $state(false);
|
|
|
|
onMount(load);
|
|
async function load() {
|
|
try { users = await api('/users'); }
|
|
catch (err: unknown) { error = errMsg(err, t('common.loadError')); snackError(error); }
|
|
finally { loaded = true; }
|
|
}
|
|
|
|
async function create(e: SubmitEvent) {
|
|
e.preventDefault(); error = '';
|
|
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
|
|
catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
|
}
|
|
function remove(id: number) {
|
|
confirmDelete = {
|
|
id,
|
|
onconfirm: async () => {
|
|
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
|
|
catch (err: unknown) { const __m = errMsg(err); error = __m; snackError(__m); }
|
|
finally { confirmDelete = null; }
|
|
}
|
|
};
|
|
}
|
|
function openResetPassword(user: any) {
|
|
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
|
}
|
|
function openEditUser(user: any) {
|
|
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
|
}
|
|
async function saveUserEdit(e: SubmitEvent) {
|
|
e.preventDefault(); editMsg = ''; editSuccess = false;
|
|
try {
|
|
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
|
editMsg = t('snack.userUpdated');
|
|
editSuccess = true;
|
|
snackSuccess(editMsg);
|
|
await load();
|
|
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
|
} catch (err: unknown) { const __m = errMsg(err); editMsg = __m; editSuccess = false; snackError(__m); }
|
|
}
|
|
async function resetUserPassword(e: SubmitEvent) {
|
|
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
|
try {
|
|
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
|
resetMsg = t('common.passwordChanged');
|
|
resetSuccess = true;
|
|
snackSuccess(t('snack.passwordChanged'));
|
|
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
|
} catch (err: unknown) { const __m = errMsg(err); resetMsg = __m; resetSuccess = false; snackError(__m); }
|
|
}
|
|
|
|
function userTiles(user: User): MetaTile[] {
|
|
const tiles: MetaTile[] = [];
|
|
const isAdmin = user.role === 'admin';
|
|
tiles.push({
|
|
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
|
|
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
|
|
tone: isAdmin ? 'orchid' : 'sky',
|
|
});
|
|
tiles.push({
|
|
icon: 'mdiCalendarOutline',
|
|
label: parseDate(user.created_at).toLocaleDateString(),
|
|
hint: t('users.joined'),
|
|
tone: 'lavender',
|
|
mono: true,
|
|
});
|
|
if (user.id === auth.user?.id) {
|
|
tiles.push({
|
|
icon: 'mdiAccountStar',
|
|
label: t('users.you', 'you'),
|
|
tone: 'mint',
|
|
});
|
|
}
|
|
return tiles;
|
|
}
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('users.title')}
|
|
emphasis={t('users.titleEmphasis')}
|
|
description={t('users.description')}
|
|
crumb={t('crumbs.systemAccess')}
|
|
count={users.length}
|
|
countLabel={t('users.countLabel')}
|
|
>
|
|
<Button size="sm" onclick={() => showForm = !showForm}>
|
|
{showForm ? t('users.cancel') : t('users.addUser')}
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}<Loading />{:else}
|
|
|
|
{#if showForm}
|
|
<Card class="mb-6">
|
|
{#if error}<ErrorBanner message={error} />{/if}
|
|
<form onsubmit={create} class="space-y-3">
|
|
<div>
|
|
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
|
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
|
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
|
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
|
<option value="user">{t('users.roleUser')}</option>
|
|
<option value="admin">{t('users.roleAdmin')}</option>
|
|
</select>
|
|
</div>
|
|
<Button type="submit">{t('users.create')}</Button>
|
|
</form>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if users.length === 0}
|
|
<Card>
|
|
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
|
</Card>
|
|
{:else}
|
|
<div class="list-stack stagger-children">
|
|
{#each users as user}
|
|
<Card hover>
|
|
<div class="list-row">
|
|
<div class="list-row__identity">
|
|
<p class="font-medium truncate">{user.username}</p>
|
|
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} В· {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
|
</div>
|
|
<MetaStrip tiles={userTiles(user)} />
|
|
<div class="list-row__actions">
|
|
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
|
{#if user.id !== auth.user?.id}
|
|
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
|
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
<!-- Admin reset password modal -->
|
|
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
|
<form onsubmit={resetUserPassword} class="space-y-3">
|
|
<div>
|
|
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
|
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
{#if resetMsg}
|
|
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
|
{/if}
|
|
<Button type="submit" class="w-full">
|
|
{t('common.save')}
|
|
</Button>
|
|
</form>
|
|
</Modal>
|
|
|
|
<!-- Admin edit username/role modal -->
|
|
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
|
<form onsubmit={saveUserEdit} class="space-y-3">
|
|
<div>
|
|
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
|
<input id="edit-username" bind:value={editUsername} required
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
|
<select id="edit-role" bind:value={editRole}
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
|
<option value="user">{t('users.roleUser')}</option>
|
|
<option value="admin">{t('users.roleAdmin')}</option>
|
|
</select>
|
|
</div>
|
|
{#if editMsg}
|
|
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
|
{/if}
|
|
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
|
</form>
|
|
</Modal>
|
|
|
|
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|