Compare commits

...

7 Commits

Author SHA1 Message Date
Claude cfb2ec0f59 feat(workflow): add webhook step to send asset information to an endpoint
Adds a 'webhook' core plugin method that POSTs the triggering asset's information to a configurable endpoint, with optional HTTP method and a secret header for receiver verification. Plugins are now permitted to make outbound HTTP requests.

https://claude.ai/code/session_01X6ZiTZMYfHtwJifZ6RuDnk
2026-06-02 20:14:35 +00:00
shenlong 9287fa08c6 fix!: unauthorized face creation (#28561)
* fix: unauthorized face creation

* review changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-02 22:44:11 +05:30
renovate[bot] 408e1180ca chore(deps): update machine-learning (#28239)
* chore(deps): update machine-learning

* fix typing

* fix deprecation log

* no control socket

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-06-02 16:44:50 +00:00
renovate[bot] 07f19d2caa chore(deps): update base-image to v202606021219 (#28771) 2026-06-02 18:31:52 +02:00
Tim Jones 368cb7a4ad feat: minimum face count per user (#27452)
* add user metadata table and use to filter persons in person.getAllForUser query

* update PersonRepository.getAllForUser query

* remove minFaces from PersonSearchOptions interface

* fix person.getAllForUser query

* update types and openapi specs

* add minFaces field to user settings page

* remove old arg from tests

* add e2e test to verify minimumFace user preference

* add i18n label and description for english

* update default min faces

* fetch minFaces ML default and use as per-user default in frontend

* update e2e tests

* fix bugs in people getAllForUser query

* update person getNumberOfPeople query to reflect correct number of people according to minFaces threshold

* updated mobile openapi specs?

* use subquery in coalesce instead of join

* remove out of scope query update
2026-06-02 18:05:55 +02:00
Timon 109e0a7ad0 fix(mobile): invisible ink splashes in asset sheet (#28756) 2026-06-02 10:37:20 -05:00
Timon 59750dad7d feat: places in context search (#28768) 2026-06-02 17:19:59 +02:00
35 changed files with 849 additions and 482 deletions
@@ -141,6 +141,7 @@ describe('/server', () => {
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
minFaces: 3,
});
});
});
+15
View File
@@ -230,6 +230,21 @@ describe('/users', () => {
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
});
it('should update minimum face count to display people', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ people: { minimumFaces: 3 } });
const { status, body } = await request(app)
.put('/users/me/preferences')
.send({ people: { minimumFaces: 2 } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ people: { minimumFaces: 2 } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ people: { minimumFaces: 2 } });
});
});
describe('GET /users/:id', () => {
+2
View File
@@ -1592,6 +1592,8 @@
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
"merge_people_successfully": "Merge people successfully",
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
"minFaces": "Minimum faces",
"minFaces_description": "The minimum number of recognized faces for a person to be displayed",
"minimize": "Minimize",
"minute": "Minute",
"minutes": "Minutes",
+4 -4
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
+1
View File
@@ -49,6 +49,7 @@ try:
str(settings.http_keepalive_timeout_s),
"--graceful-timeout",
"10",
"--no-control-socket",
],
) as cmd:
cmd.wait()
+2 -1
View File
@@ -12,7 +12,7 @@ from zipfile import BadZipFile
import orjson
from fastapi import Depends, FastAPI, File, Form, HTTPException
from fastapi.responses import ORJSONResponse, PlainTextResponse
from fastapi.responses import PlainTextResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from PIL.Image import Image
from pydantic import ValidationError
@@ -32,6 +32,7 @@ from .schemas import (
ModelIdentity,
ModelTask,
ModelType,
ORJSONResponse,
PipelineRequest,
T,
)
@@ -89,7 +89,9 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
pad_id: int = tokenizer.token_to_id(pad_token)
pad_id = tokenizer.token_to_id(pad_token)
if pad_id is None:
raise ValueError(f"Pad token '{pad_token}' not found in tokenizer vocab")
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
tokenizer.enable_truncation(max_length=context_length)
+7
View File
@@ -3,9 +3,16 @@ from typing import Any, Literal, Protocol, TypeGuard, TypeVar
import numpy as np
import numpy.typing as npt
import orjson
from fastapi.responses import JSONResponse
from typing_extensions import TypedDict
class ORJSONResponse(JSONResponse):
def render(self, content: Any) -> bytes:
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
class StrEnum(str, Enum):
value: str
+498 -450
View File
File diff suppressed because it is too large Load Diff
@@ -63,16 +63,19 @@ class SheetTile extends ConsumerWidget {
subtitleWidget = null;
}
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
return Material(
type: MaterialType.transparency,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
),
);
}
}
+22 -1
View File
@@ -14,32 +14,52 @@ class PeopleResponse {
/// Returns a new [PeopleResponse] instance.
PeopleResponse({
required this.enabled,
this.minimumFaces,
required this.sidebarWeb,
});
/// Whether people are enabled
bool enabled;
/// People face threshold
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? minimumFaces;
/// Whether people appear in web sidebar
bool sidebarWeb;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponse &&
other.enabled == enabled &&
other.minimumFaces == minimumFaces &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
(sidebarWeb.hashCode);
@override
String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]';
String toString() => 'PeopleResponse[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
if (this.minimumFaces != null) {
json[r'minimumFaces'] = this.minimumFaces;
} else {
// json[r'minimumFaces'] = null;
}
json[r'sidebarWeb'] = this.sidebarWeb;
return json;
}
@@ -54,6 +74,7 @@ class PeopleResponse {
return PeopleResponse(
enabled: mapValueOfType<bool>(json, r'enabled')!,
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
);
}
+22 -1
View File
@@ -14,6 +14,7 @@ class PeopleUpdate {
/// Returns a new [PeopleUpdate] instance.
PeopleUpdate({
this.enabled,
this.minimumFaces,
this.sidebarWeb,
});
@@ -26,6 +27,18 @@ class PeopleUpdate {
///
bool? enabled;
/// People face threshold
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? minimumFaces;
/// Whether people appear in web sidebar
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -38,16 +51,18 @@ class PeopleUpdate {
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate &&
other.enabled == enabled &&
other.minimumFaces == minimumFaces &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled == null ? 0 : enabled!.hashCode) +
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
@override
String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]';
String toString() => 'PeopleUpdate[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -56,6 +71,11 @@ class PeopleUpdate {
} else {
// json[r'enabled'] = null;
}
if (this.minimumFaces != null) {
json[r'minimumFaces'] = this.minimumFaces;
} else {
// json[r'minimumFaces'] = null;
}
if (this.sidebarWeb != null) {
json[r'sidebarWeb'] = this.sidebarWeb;
} else {
@@ -74,6 +94,7 @@ class PeopleUpdate {
return PeopleUpdate(
enabled: mapValueOfType<bool>(json, r'enabled'),
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
);
}
+13 -1
View File
@@ -20,6 +20,7 @@ class ServerConfigDto {
required this.maintenanceMode,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
required this.minFaces,
required this.oauthButtonText,
required this.publicUsers,
required this.trashDays,
@@ -47,6 +48,12 @@ class ServerConfigDto {
/// Map light style URL
String mapLightStyleUrl;
/// People min faces server default
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int minFaces;
/// OAuth button text
String oauthButtonText;
@@ -74,6 +81,7 @@ class ServerConfigDto {
other.maintenanceMode == maintenanceMode &&
other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl &&
other.minFaces == minFaces &&
other.oauthButtonText == oauthButtonText &&
other.publicUsers == publicUsers &&
other.trashDays == trashDays &&
@@ -89,13 +97,14 @@ class ServerConfigDto {
(maintenanceMode.hashCode) +
(mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) +
(minFaces.hashCode) +
(oauthButtonText.hashCode) +
(publicUsers.hashCode) +
(trashDays.hashCode) +
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, minFaces=$minFaces, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -106,6 +115,7 @@ class ServerConfigDto {
json[r'maintenanceMode'] = this.maintenanceMode;
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'minFaces'] = this.minFaces;
json[r'oauthButtonText'] = this.oauthButtonText;
json[r'publicUsers'] = this.publicUsers;
json[r'trashDays'] = this.trashDays;
@@ -129,6 +139,7 @@ class ServerConfigDto {
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
minFaces: mapValueOfType<int>(json, r'minFaces')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
trashDays: mapValueOfType<int>(json, r'trashDays')!,
@@ -187,6 +198,7 @@ class ServerConfigDto {
'maintenanceMode',
'mapDarkStyleUrl',
'mapLightStyleUrl',
'minFaces',
'oauthButtonText',
'publicUsers',
'trashDays',
+19
View File
@@ -19907,6 +19907,12 @@
"description": "Whether people are enabled",
"type": "boolean"
},
"minimumFaces": {
"description": "People face threshold",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
},
"sidebarWeb": {
"description": "Whether people appear in web sidebar",
"type": "boolean"
@@ -19968,6 +19974,12 @@
"description": "Whether people are enabled",
"type": "boolean"
},
"minimumFaces": {
"description": "People face threshold",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
},
"sidebarWeb": {
"description": "Whether people appear in web sidebar",
"type": "boolean"
@@ -21604,6 +21616,12 @@
"description": "Map light style URL",
"type": "string"
},
"minFaces": {
"description": "People min faces server default",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"oauthButtonText": {
"description": "OAuth button text",
"type": "string"
@@ -21633,6 +21651,7 @@
"maintenanceMode",
"mapDarkStyleUrl",
"mapLightStyleUrl",
"minFaces",
"oauthButtonText",
"publicUsers",
"trashDays",
+46
View File
@@ -55,6 +55,22 @@
}
],
"uiHints": ["SmartAlbum"]
},
{
"name": "asset-webhook",
"title": "Send a webhook",
"description": "Send the information of newly uploaded assets to an external endpoint",
"trigger": "AssetCreate",
"steps": [
{
"method": "immich-plugin-core#webhook",
"config": {
"url": "",
"method": "POST"
}
}
],
"uiHints": ["Webhook"]
}
],
"methods": [
@@ -238,6 +254,36 @@
"required": ["albumIds"]
}
},
{
"name": "webhook",
"title": "Send a webhook",
"description": "Send the asset information to an external endpoint",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "Webhook URL",
"description": "The endpoint that will receive the asset information"
},
"method": {
"type": "string",
"title": "HTTP method",
"enum": ["POST", "PUT", "PATCH"],
"default": "POST",
"description": "HTTP method used to send the request"
},
"secret": {
"type": "string",
"title": "Secret",
"description": "Optional value sent as the X-Immich-Webhook-Secret header so the receiver can verify the request"
}
},
"required": ["url"]
},
"uiHints": ["Webhook"]
},
{
"name": "noop1",
"title": "DEV: Nested properties",
+3
View File
@@ -22,4 +22,7 @@ declare module 'main' {
export function assetTimeline(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
// integrations
export function webhook(): I32;
}
+38
View File
@@ -102,6 +102,44 @@ export const assetTrash = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
type WebhookConfig = {
url: string;
method?: 'PATCH' | 'POST' | 'PUT';
secret?: string;
};
export const webhook = () => {
return wrapper<WorkflowType.AssetV1, WebhookConfig>(({ config, data, trigger, workflow }) => {
const { url, method = 'POST', secret } = config;
if (!url) {
console.warn('Webhook step skipped: no URL configured');
return {};
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'Immich',
};
if (secret) {
headers['X-Immich-Webhook-Secret'] = secret;
}
const body = JSON.stringify({
trigger,
workflowId: workflow.id,
asset: data.asset,
});
const response = Http.request({ url, method, headers }, body);
if (response.status >= 400) {
console.error(`Webhook request to ${url} failed with status ${response.status}`);
} else {
console.debug(`Webhook request to ${url} returned status ${response.status}`);
}
return {};
});
};
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
+6
View File
@@ -298,6 +298,8 @@ export type MemoriesResponse = {
export type PeopleResponse = {
/** Whether people are enabled */
enabled: boolean;
/** People face threshold */
minimumFaces?: number;
/** Whether people appear in web sidebar */
sidebarWeb: boolean;
};
@@ -375,6 +377,8 @@ export type MemoriesUpdate = {
export type PeopleUpdate = {
/** Whether people are enabled */
enabled?: boolean;
/** People face threshold */
minimumFaces?: number;
/** Whether people appear in web sidebar */
sidebarWeb?: boolean;
};
@@ -1963,6 +1967,8 @@ export type ServerConfigDto = {
mapDarkStyleUrl: string;
/** Map light style URL */
mapLightStyleUrl: string;
/** People min faces server default */
minFaces: number;
/** OAuth button text */
oauthButtonText: string;
/** Whether public user registration is enabled */
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202605121138@sha256:127cc323590e1d64d765016492e1de1e9355da8f658f078aef7be6bede0fdd0f AS builder
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202605121138@sha256:b346d42d86f42799fede6b6c9f6feed0018c5ee46e4ac31d1165f50737cee842
FROM ghcr.io/immich-app/base-server-prod:202606021219@sha256:6ef9ef5859492149af770a6c884b5e2ddbaeef99f8885ea5f2d9f73625a3d9ec
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202605121138@sha256:127cc323590e1d64d765016492e1de1e9355da8f658f078aef7be6bede0fdd0f AS dev
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS dev
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
+1
View File
@@ -124,6 +124,7 @@ const ServerConfigSchema = z
mapDarkStyleUrl: z.string().describe('Map dark style URL'),
mapLightStyleUrl: z.string().describe('Map light style URL'),
maintenanceMode: z.boolean().describe('Whether maintenance mode is active'),
minFaces: z.int().describe('People min faces server default'),
})
.meta({ id: 'ServerConfigDto' });
+2
View File
@@ -45,6 +45,7 @@ const PeopleUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether people are enabled'),
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
})
.optional()
.meta({ id: 'PeopleUpdate' });
@@ -138,6 +139,7 @@ const PeopleResponseSchema = z
.object({
enabled: z.boolean().describe('Whether people are enabled'),
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
})
.meta({ id: 'PeopleResponse' });
+12 -1
View File
@@ -42,7 +42,18 @@ group by
having
(
"person"."name" != $3
or count("asset_face"."assetId") >= $4
or count("asset_face"."assetId") >= COALESCE(
(
SELECT
value -> 'people' ->> 'minimumFaces'
FROM
user_metadata
WHERE
"userId" = $4
AND key = 'preferences'
),
'3'
)::int
)
order by
"person"."isHidden" asc,
+12 -3
View File
@@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum';
import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
@@ -13,7 +13,6 @@ import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions {
minimumFaceCount: number;
withHidden: boolean;
closestFaceAssetId?: string;
}
@@ -168,7 +167,17 @@ export class PersonRepository {
.having((eb) =>
eb.or([
eb('person.name', '!=', ''),
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1),
eb(
(innerEb) => innerEb.fn.count('asset_face.assetId'),
'>=',
sql<number>`COALESCE(
(SELECT value -> 'people' ->> 'minimumFaces'
FROM user_metadata
WHERE "userId" = ${userId}
AND key = ${sql.lit(UserMetadataKey.Preferences)}),
'3'
)::int `,
),
]),
)
.groupBy('person.id')
@@ -213,6 +213,8 @@ export class PluginRepository {
{
useWasi: true,
runInWorker,
// allow plugins (e.g. the webhook workflow step) to make outbound HTTP requests
allowedHosts: ['*'],
functions: {
'extism:host/user': functions ?? {},
},
@@ -0,0 +1,16 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Delete unauthorized cross-owner asset faces
await sql`
DELETE FROM asset_face
USING person, asset
WHERE asset_face."personId" = person.id
AND asset_face."assetId" = asset.id
AND person."ownerId" != asset."ownerId"
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the deleted rows were unauthorized cross-owner entries
}
+24 -2
View File
@@ -57,7 +57,6 @@ describe(PersonService.name, () => {
],
});
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: true,
});
});
@@ -84,7 +83,6 @@ describe(PersonService.name, () => {
],
});
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: false,
});
});
@@ -454,6 +452,30 @@ describe(PersonService.name, () => {
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
it('should reject creating a face on an asset the user does not own', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.person.createAssetFace).not.toHaveBeenCalled();
});
});
describe('createNewFeaturePhoto', () => {
+1 -3
View File
@@ -63,9 +63,7 @@ export class PersonService extends BaseService {
}
closestFaceAssetId = person.faceAssetId;
}
const { machineLearning } = await this.getConfig({ withCache: false });
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden,
closestFaceAssetId,
});
@@ -627,7 +625,7 @@ export class PersonService extends BaseService {
// TODO return a asset face response
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
await Promise.all([
this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);
@@ -168,6 +168,7 @@ describe(ServerService.name, () => {
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
minFaces: 3,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
+1
View File
@@ -128,6 +128,7 @@ export class ServerService extends BaseService {
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: false,
minFaces: config.machineLearning.facialRecognition.minFaces,
};
}
+1
View File
@@ -539,6 +539,7 @@ export type UserPreferences = {
people: {
enabled: boolean;
sidebarWeb: boolean;
minimumFaces: number;
};
ratings: {
enabled: boolean;
+1
View File
@@ -21,6 +21,7 @@ const getDefaultPreferences = (): UserPreferences => {
people: {
enabled: true,
sidebarWeb: false,
minimumFaces: 3,
},
sharedLinks: {
enabled: true,
@@ -1,6 +1,8 @@
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { AddressInfo } from 'node:net';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { AssetVisibility, LogLevel } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
@@ -332,4 +334,47 @@ describe('core plugin', () => {
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
});
describe('webhook', () => {
it('should send the asset information to the configured endpoint', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const received = new Promise<{ headers: NodeJS.Dict<string | string[]>; body: any }>((resolve, reject) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
res.writeHead(200);
res.end();
server.close();
resolve({ headers: req.headers, body: JSON.parse(Buffer.concat(chunks).toString()) });
});
});
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#webhook',
config: { url: `http://127.0.0.1:${port}/hook`, secret: 'super-secret' },
},
],
})
.then((workflow) => ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }))
.catch(reject);
});
});
const { headers, body } = await received;
expect(headers['x-immich-webhook-secret']).toBe('super-secret');
expect(body).toMatchObject({
trigger: WorkflowTrigger.AssetCreate,
asset: { id: asset.id, ownerId: user.id },
});
});
});
});
+6
View File
@@ -16,6 +16,7 @@ import {
mdiLink,
mdiLockOutline,
mdiMagnify,
mdiMapMarkerOutline,
mdiMapOutline,
mdiServer,
mdiStateMachine,
@@ -93,6 +94,11 @@ export const getPagesProvider = ($t: MessageFormatter) => {
onAction: () => goto(Route.people()),
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
},
{
title: $t('places'),
icon: mdiMapMarkerOutline,
onAction: () => goto(Route.places()),
},
{
title: $t('shared_links'),
icon: mdiLink,
@@ -1,4 +1,5 @@
<script lang="ts">
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
@@ -21,6 +22,7 @@
// People
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
// Ratings
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
@@ -43,7 +45,7 @@
albums: { defaultAssetOrder },
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar },
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
ratings: { enabled: ratingsEnabled },
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
@@ -117,6 +119,9 @@
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
<Switch bind:checked={peopleSidebar} />
</Field>
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
<NumberInput bind:value={peopleMinFaces} />
</Field>
{/if}
</div>
</SettingAccordion>