2d59a5b994
Apply six isolated, low-risk fixes surfaced by the parallel production-readiness review (backend, frontend, security, perf, UI/UX, bugs+features). Backend - Mask access_token in provider GET responses and drop it on edit when carrying the *** placeholder — fixes plaintext leak of HA long-lived tokens (security H-1). Centralized via PROVIDER_SECRET_FIELDS so all call sites stay in sync (C-5). - Hold HA status-change tasks in a module-level set with a done_callback — asyncio.create_task only keeps weak refs and the task could be GC'd before its row was written (C-1). - Roll back the request session in the Telegram-webhook catch-all so a handler exception cannot leak uncommitted writes into the next request (C-2). - Bail before reading the 1 MiB webhook body when the Gitea provider has no secret configured or the request has no signature header. For the generic webhook with bearer_token auth, verify the Authorization header before the body read. Closes the pre-auth resource-exhaustion amplifier (C-3). Frontend - Add supportsAutoOrganize capability to ProviderDescriptor and consume it from RuleEditor instead of `provider.type !== 'immich'`, bringing the last action-rule editor under CLAUDE.md rule 8 (no provider-type hardcoding in components). - Snackbar: add role="region" + per-toast role/aria-live/aria-atomic so screen readers announce success/error toasts. - Sidebar nav: add aria-current="page" on the active link so the active state has an accessible name. - New snackbar.region key in en + ru (locale parity preserved). Out of scope for this commit (tracked in .claude/reviews/README.md ship-blocker list): secret encryption at rest, JWT cookie move, Alembic adoption, webhook idempotency, deferred-dispatch crash window, persisted Telegram update watermark, bridge_self counter lock — each needs more than a mechanical edit.
175 lines
8.9 KiB
TypeScript
175 lines
8.9 KiB
TypeScript
import type { ProviderDescriptor } from './types';
|
|
|
|
/**
|
|
* Today's date in ISO (YYYY-MM-DD) — used as the default for
|
|
* `periodic_start_date` so new configs anchor to "today" rather than a
|
|
* hardcoded date that gets further into the past on every release.
|
|
*/
|
|
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
|
|
|
export const immichDescriptor: ProviderDescriptor = {
|
|
type: 'immich',
|
|
defaultName: 'Immich',
|
|
icon: 'mdiImageMultiple',
|
|
hasUrl: true,
|
|
urlPlaceholder: undefined, // uses generic i18n placeholder
|
|
supportsAutoOrganize: true,
|
|
|
|
configFields: [
|
|
{
|
|
key: 'api_key', configKey: 'api_key',
|
|
label: 'providers.apiKey', editLabel: 'providers.apiKeyKeep',
|
|
type: 'password', required: 'create-only',
|
|
},
|
|
{
|
|
key: 'external_domain', configKey: 'external_domain',
|
|
label: 'providers.externalDomain',
|
|
type: 'text', optional: true, placeholder: 'https://photos.example.com',
|
|
},
|
|
],
|
|
|
|
buildConfig(form, editing) {
|
|
const config: Record<string, any> = { url: form.url };
|
|
if (form.api_key) config.api_key = form.api_key;
|
|
if (form.external_domain) config.external_domain = form.external_domain;
|
|
if (!editing) config.api_key = form.api_key;
|
|
return { config };
|
|
},
|
|
|
|
hasConfigChanged(form, existing) {
|
|
return form.url !== (existing.url || '') ||
|
|
!!form.api_key ||
|
|
form.external_domain !== (existing.external_domain || '');
|
|
},
|
|
|
|
eventFields: [
|
|
{ key: 'track_assets_added', label: 'trackingConfig.assetsAdded', default: true },
|
|
{ key: 'track_assets_removed', label: 'trackingConfig.assetsRemoved', default: false },
|
|
{ key: 'track_collection_renamed', label: 'trackingConfig.albumRenamed', default: true },
|
|
{ key: 'track_collection_deleted', label: 'trackingConfig.albumDeleted', default: true },
|
|
{ key: 'track_sharing_changed', label: 'trackingConfig.sharingChanged', default: false },
|
|
{ key: 'track_images', label: 'trackingConfig.trackImages', default: true },
|
|
{ key: 'track_videos', label: 'trackingConfig.trackVideos', default: true },
|
|
{ key: 'notify_favorites_only', label: 'trackingConfig.favoritesOnly', default: false, hint: 'hints.favoritesOnly' },
|
|
{ key: 'include_tags', label: 'trackingConfig.includePeople', default: true },
|
|
{ key: 'include_asset_details', label: 'trackingConfig.includeDetails', default: false },
|
|
],
|
|
|
|
extraTrackingFields: [
|
|
{ key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 10, hint: 'hints.maxAssets' },
|
|
{ key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' },
|
|
{ key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' },
|
|
],
|
|
|
|
featureSections: [
|
|
{
|
|
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
|
|
enabledField: 'periodic_enabled', enabledDefault: false,
|
|
fields: [
|
|
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
|
|
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
|
|
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
|
],
|
|
},
|
|
{
|
|
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
|
enabledField: 'scheduled_enabled', enabledDefault: false,
|
|
fields: [
|
|
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
|
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
|
|
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
|
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
|
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
|
{ key: 'scheduled_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
|
|
],
|
|
},
|
|
{
|
|
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
|
enabledField: 'memory_enabled', enabledDefault: false,
|
|
fields: [
|
|
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
|
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
|
|
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
|
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
|
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
|
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
|
|
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums', hint: 'hints.memorySource' },
|
|
],
|
|
},
|
|
{
|
|
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
|
|
enabledField: 'quiet_hours_enabled', enabledDefault: false,
|
|
fields: [
|
|
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' },
|
|
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
|
|
],
|
|
},
|
|
],
|
|
|
|
collectionMeta: {
|
|
label: 'notificationTracker.albums',
|
|
icon: 'mdiImageMultiple',
|
|
placeholder: 'notificationTracker.selectAlbums',
|
|
countLabel: 'notificationTracker.albums_count',
|
|
desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`,
|
|
},
|
|
|
|
// Periodic summaries / scheduled picks / memories / quiet hours all live on
|
|
// the linked tracking & template configs — surface that connection on the
|
|
// tracker form so users don't need to read docs to find them.
|
|
featureDiscoveryHint: {
|
|
messageKey: 'notificationTracker.featureDiscovery',
|
|
ctas: [
|
|
{ href: '/tracking-configs?edit={tracking_config_id}', labelKey: 'notificationTracker.openTrackingConfig', icon: 'mdiArrowRight' },
|
|
{ href: '/template-configs?edit={template_config_id}', labelKey: 'notificationTracker.openTemplateConfig', icon: 'mdiArrowRight' },
|
|
],
|
|
},
|
|
|
|
async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) {
|
|
const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id));
|
|
if (newIds.length === 0) return { proceed: true };
|
|
|
|
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
|
const warnings: { id: string; name: string; issue: string }[] = [];
|
|
|
|
// Run shared-link checks in parallel with a concurrency cap so a large
|
|
// album set doesn't stall the save button for seconds. Cap of 6 keeps
|
|
// the save dialog responsive for users with 50+ albums while staying
|
|
// well under typical Immich per-IP rate limits.
|
|
const CONCURRENCY = 6;
|
|
async function checkOne(albumId: string): Promise<void> {
|
|
try {
|
|
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
|
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
|
|
if (!validLink) {
|
|
const album = collections.find(c => c.id === albumId);
|
|
const problematic = links.find((l) => l.is_expired || l.has_password);
|
|
warnings.push({
|
|
id: albumId,
|
|
name: album?.albumName || album?.name || albumId,
|
|
issue: problematic
|
|
? (problematic.is_expired ? 'expired' : 'password-protected')
|
|
: 'missing',
|
|
});
|
|
}
|
|
} catch { /* shared-link check failed, proceed */ }
|
|
}
|
|
|
|
const queue = [...newIds];
|
|
const workers: Promise<void>[] = [];
|
|
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
|
|
workers.push((async () => {
|
|
while (queue.length > 0) {
|
|
const next = queue.shift();
|
|
if (next === undefined) return;
|
|
await checkOne(next);
|
|
}
|
|
})());
|
|
}
|
|
await Promise.all(workers);
|
|
|
|
if (warnings.length > 0) return { warnings, proceed: false };
|
|
return { proceed: true };
|
|
},
|
|
};
|