From 1cc5d44e7645d82d5ed79962a9bf8b2ff18fc038 Mon Sep 17 00:00:00 2001 From: jonvdveen <196496658+jonvdveen@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:26:43 -0600 Subject: [PATCH 1/2] fix: update API key permissions for Immich v3 granular permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Immich v3 split album-membership rights out of album.update into albumAsset.create / albumAsset.delete, and assigning tags to assets requires tag.asset. The hardcoded WORKFLOW_PERMISSIONS list predates this, so auto-generated workflow keys 403 at runtime on Add to Album (and Add Tag) actions: Immich API error 403: {"message":"Missing required permission: albumAsset.create"} Also, validate-api-key.ts literal-matched required permissions against the key's permission list, so a key created with the "All" checkbox (stored as ["all"]) failed validation for every permission — and the settings page refuses to save a key that fails validation, making it impossible to work around with a full-permission key. - Add albumAsset.create, albumAsset.delete, tag.asset to WORKFLOW_PERMISSIONS (matching the endpoints actionExecutor.ts actually calls: PUT/DELETE /albums/:id/assets, PUT /tags/:id/assets) - Add albumAsset.create to IMPORT_PERMISSIONS for the same reason - Treat a granted "all" permission as satisfying all requirements in validate-api-key.ts Follow-up to #294. Co-Authored-By: Claude Fable 5 --- src/config/permissions.ts | 10 +++++++--- src/pages/api/settings/validate-api-key.ts | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/config/permissions.ts b/src/config/permissions.ts index 482f5f1..cdbdd66 100644 --- a/src/config/permissions.ts +++ b/src/config/permissions.ts @@ -8,7 +8,8 @@ export const IMPORT_PERMISSIONS: Permission[] = [ { name: "asset.upload", description: "Upload new assets" }, { name: "album.read", description: "Read album data" }, { name: "album.create", description: "Create new albums" }, - { name: "album.update", description: "Add assets to albums" }, + { name: "album.update", description: "Update album metadata" }, + { name: "albumAsset.create", description: "Add assets to albums" }, { name: "tag.create", description: "Tag imported assets" }, { name: "tag.asset", description: "Assign tags to assets" }, ]; @@ -18,8 +19,11 @@ export const WORKFLOW_PERMISSIONS: Permission[] = [ { name: "asset.update", description: "Favorite, archive, update metadata" }, { name: "album.read", description: "Read album data" }, { name: "album.create", description: "Create new albums" }, - { name: "album.update", description: "Add/remove assets from albums" }, - { name: "tag.create", description: "Create and assign tags" }, + { name: "album.update", description: "Update album metadata" }, + { name: "albumAsset.create", description: "Add assets to albums" }, + { name: "albumAsset.delete", description: "Remove assets from albums" }, + { name: "tag.create", description: "Create tags" }, + { name: "tag.asset", description: "Assign tags to assets" }, ]; export const getPermissionNames = (permissions: Permission[]): string[] => diff --git a/src/pages/api/settings/validate-api-key.ts b/src/pages/api/settings/validate-api-key.ts index 61b9491..98eea94 100644 --- a/src/pages/api/settings/validate-api-key.ts +++ b/src/pages/api/settings/validate-api-key.ts @@ -30,7 +30,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const keyData = await keyRes.json(); const granted: string[] = keyData.permissions ?? []; - const missing = REQUIRED_PERMISSIONS.filter((p) => !granted.includes(p)); + // A key created with the "All" checkbox reports its permissions as ["all"], + // which grants every permission without listing them individually. + const missing = granted.includes("all") + ? [] + : REQUIRED_PERMISSIONS.filter((p) => !granted.includes(p)); return res.status(200).json({ valid: missing.length === 0, From 1431b8523e2da767d8e224970d5cf79b0014c7b5 Mon Sep 17 00:00:00 2001 From: jonvdveen <196496658+jonvdveen@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:51:02 -0600 Subject: [PATCH 2/2] feat: five library-cleanup conditions for workflow IF/SWITCH nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds condition types common in smart-album systems of other photo managers (Apple Photos, Lightroom, Google Photos): - File Size: min/max in MB (fractional) over asset_exif.fileSizeInByte - File Name / Path: contains / not contains / starts with / ends with against originalFileName or originalPath (ILIKE, wildcards escaped so user text matches literally) - File Extension: is any of / is none of a comma-separated list, case-insensitive, matched after the last dot - Face Count: min/max visible, non-deleted detected faces — 'how many people', complementing the person condition's 'who' - Time of Day: from/to hour over localDateTime (capture wall-clock time, no timezone math); From > To wraps past midnight (22 to 5) Co-Authored-By: Claude Fable 5 --- .../workflows/config/ConditionEditor.tsx | 70 +++++++++++++++++++ .../workflows/nodes/conditionSummary.ts | 31 ++++++++ src/lib/workflow/conditionBuilder.ts | 57 +++++++++++++++ src/types/workflow.d.ts | 2 + 4 files changed, 160 insertions(+) diff --git a/src/components/workflows/config/ConditionEditor.tsx b/src/components/workflows/config/ConditionEditor.tsx index 084e63b..ea62426 100644 --- a/src/components/workflows/config/ConditionEditor.tsx +++ b/src/components/workflows/config/ConditionEditor.tsx @@ -29,6 +29,11 @@ const conditionTypeLabels: Record = { focal_length: "Focal Length", rating: "Rating", is_favorited: "Favorited", + file_size: "File Size", + filename: "File Name / Path", + file_extension: "File Extension", + face_count: "Face Count", + time_of_day: "Time of Day", not_in_album: "Not in Any Album", not_in_specific_album: "Not in Specific Album", }; @@ -202,6 +207,71 @@ function ConditionFields({ condition, onChange }: { condition: ICondition; onCha ); + case "file_size": + return ( +
+ onChange({ ...condition, min: e.target.value === "" ? undefined : parseFloat(e.target.value) })} /> + onChange({ ...condition, max: e.target.value === "" ? undefined : parseFloat(e.target.value) })} /> +
+ ); + case "filename": + return ( +
+
+ + +
+ onChange({ ...condition, text: e.target.value })} /> +
+ ); + case "file_extension": + return ( +
+ + onChange({ ...condition, extensions: e.target.value })} /> +
+ ); + case "face_count": + return ( +
+ onChange({ ...condition, min: e.target.value === "" ? undefined : parseInt(e.target.value) })} /> + onChange({ ...condition, max: e.target.value === "" ? undefined : parseInt(e.target.value) })} /> + faces +
+ ); + case "time_of_day": + return ( +
+
+ From + onChange({ ...condition, fromHour: e.target.value === "" ? undefined : parseInt(e.target.value) })} /> + to + onChange({ ...condition, toHour: e.target.value === "" ? undefined : parseInt(e.target.value) })} /> + h +
+

Hours 0–23; From > To wraps past midnight (e.g. 22 to 5).

+
+ ); case "person": return (
diff --git a/src/components/workflows/nodes/conditionSummary.ts b/src/components/workflows/nodes/conditionSummary.ts index 5cc441c..819bc40 100644 --- a/src/components/workflows/nodes/conditionSummary.ts +++ b/src/components/workflows/nodes/conditionSummary.ts @@ -18,6 +18,11 @@ const conditionTypeLabels: Record = { focal_length: "Focal Length", rating: "Rating", is_favorited: "Favorited", + file_size: "File Size", + filename: "File Name / Path", + file_extension: "Extension", + face_count: "Faces", + time_of_day: "Time of Day", not_in_album: "Not in Any Album", not_in_specific_album: "Not in Specific Album", }; @@ -97,6 +102,32 @@ export function formatConditionSummary(c: ICondition): string { } case "is_favorited": return c.value === false ? "Not Favorited" : "Favorited"; + case "file_size": { + if (c.min != null && c.max != null) return `${label}: ${c.min}–${c.max}MB`; + if (c.min != null) return `${label}: ≥${c.min}MB`; + if (c.max != null) return `${label}: ≤${c.max}MB`; + return label; + } + case "filename": { + if (!c.text) return label; + const field = c.field === "path" ? "Path" : "Name"; + const matchNames: Record = { contains: "contains", not_contains: "doesn't contain", starts_with: "starts with", ends_with: "ends with" }; + return `${field} ${matchNames[c.match] || "contains"}: ${c.text}`; + } + case "file_extension": { + if (!c.extensions) return label; + return `${label} ${c.match === "not_in" ? "none of" : "any of"}: ${c.extensions}`; + } + case "face_count": { + if (c.min != null && c.max != null) return c.min === c.max ? `${label}: ${c.min}` : `${label}: ${c.min}–${c.max}`; + if (c.min != null) return `${label}: ≥${c.min}`; + if (c.max != null) return `${label}: ≤${c.max}`; + return label; + } + case "time_of_day": { + if (c.fromHour == null || c.toHour == null) return label; + return `${label}: ${c.fromHour}:00–${c.toHour}:59`; + } case "not_in_album": return label; case "not_in_specific_album": diff --git a/src/lib/workflow/conditionBuilder.ts b/src/lib/workflow/conditionBuilder.ts index e695515..e00ad4d 100644 --- a/src/lib/workflow/conditionBuilder.ts +++ b/src/lib/workflow/conditionBuilder.ts @@ -92,6 +92,63 @@ function buildSingleCondition(c: ICondition): SQL | undefined { return parts.length > 0 ? and(...parts)! : undefined; } + case "file_size": { + // min/max in megabytes (fractional allowed) + const parts: SQL[] = []; + if (c.min !== undefined && c.min !== null) parts.push(sql`${exif.fileSizeInByte} >= ${Math.round(c.min * 1048576)}`); + if (c.max !== undefined && c.max !== null) parts.push(sql`${exif.fileSizeInByte} <= ${Math.round(c.max * 1048576)}`); + return parts.length > 0 ? and(...parts)! : undefined; + } + + case "filename": { + if (!c.text) return undefined; + const column = c.field === "path" ? assets.originalPath : assets.originalFileName; + // Escape LIKE wildcards so user text is matched literally + const escaped = c.text.replace(/([\\%_])/g, "\\$1"); + const match = c.match || "contains"; + const pattern = + match === "starts_with" ? `${escaped}%` + : match === "ends_with" ? `%${escaped}` + : `%${escaped}%`; + return match === "not_contains" + ? sql`${column} NOT ILIKE ${pattern}` + : sql`${column} ILIKE ${pattern}`; + } + + case "file_extension": { + const raw: string = c.extensions || ""; + const exts = raw + .split(",") + .map((e: string) => e.trim().replace(/^\./, "").toLowerCase()) + .filter(Boolean); + if (exts.length === 0) return undefined; + // Extension = text after the last dot; files without one never match either mode + const extExpr = sql`lower(substring(${assets.originalFileName} from '\\.([^.]+)$'))`; + const list = exts.map((e: string) => `'${e.replace(/'/g, "''")}'`).join(","); + return c.match === "not_in" + ? sql`${extExpr} NOT IN (${sql.raw(list)})` + : sql`${extExpr} IN (${sql.raw(list)})`; + } + + case "face_count": { + const faceCount = sql`(SELECT count(*) FROM "asset_face" af WHERE af."assetId" = ${assets.id} AND af."deletedAt" IS NULL AND af."isVisible" = true)`; + const parts: SQL[] = []; + if (c.min !== undefined && c.min !== null) parts.push(sql`${faceCount} >= ${c.min}`); + if (c.max !== undefined && c.max !== null) parts.push(sql`${faceCount} <= ${c.max}`); + return parts.length > 0 ? and(...parts)! : undefined; + } + + case "time_of_day": { + if (c.fromHour == null || c.toHour == null) return undefined; + // localDateTime holds the capture wall-clock time, so no timezone math needed + const hour = sql`EXTRACT(HOUR FROM ${assets.localDateTime})`; + if (c.fromHour <= c.toHour) { + return sql`${hour} BETWEEN ${c.fromHour} AND ${c.toHour}`; + } + // From > To wraps past midnight, e.g. 22–5 = evening through early morning + return sql`(${hour} >= ${c.fromHour} OR ${hour} <= ${c.toHour})`; + } + case "person": { const ids: string[] = c.personIds || (c.personId ? [c.personId] : []); if (ids.length === 0) return undefined; diff --git a/src/types/workflow.d.ts b/src/types/workflow.d.ts index fd1367b..ccd7ca3 100644 --- a/src/types/workflow.d.ts +++ b/src/types/workflow.d.ts @@ -55,6 +55,8 @@ export type ConditionType = | "camera_make" | "camera_model" | "lens" | "asset_type" | "iso_range" | "focal_length" | "rating" | "is_favorited" + | "file_size" | "filename" | "file_extension" + | "face_count" | "time_of_day" | "not_in_album" | "not_in_specific_album"; export interface ICondition {