From a6676907b4541b36d4e9051a0b893bd7d51f7ef3 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 23 Nov 2023 21:55:36 +0100 Subject: [PATCH 01/60] chore(renovate): Group base image updates (#5284) --- renovate.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/renovate.json b/renovate.json index d8f272e68..3bdb1490d 100644 --- a/renovate.json +++ b/renovate.json @@ -35,6 +35,10 @@ { "matchFileNames": [".github/**"], "groupName": "github-actions" + }, + { + "groupName": "base-image", + "matchPackagePrefixes": ["ghcr.io/immich-app/base-server"] } ], "ignoreDeps": [ From 4987bbb71216021e32b62bff619f446be7b50ca2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 23 Nov 2023 18:55:25 -0600 Subject: [PATCH 02/60] chore(deps): update base-image to v20231123 (#5285) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index c780a116e..be353c9fc 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20231109 as dev +FROM ghcr.io/immich-app/base-server-dev:20231123 as dev WORKDIR /usr/src/app COPY server/package.json server/package-lock.json ./ @@ -23,7 +23,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20231109 +FROM ghcr.io/immich-app/base-server-prod:20231123 WORKDIR /usr/src/app ENV NODE_ENV=production From 309be88ccd7516f9b9de43593ddb5a29a96c2202 Mon Sep 17 00:00:00 2001 From: Emanuel Bennici Date: Fri, 24 Nov 2023 15:38:54 +0100 Subject: [PATCH 03/60] feat(server): load face entities faster (#5281) The query executed when loading the "People" page joins, among others, over "personId". The added indices improve the overall performance of those JOIN queries. Additionally, one ORDER BY clause is dropped since the resulting values will always be TRUE, and thus, sorting them does not change the result. --- server/src/infra/entities/asset-face.entity.ts | 3 ++- server/src/infra/entities/asset.entity.ts | 1 + .../1700752078178-AddAssetFaceIndicies.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 server/src/infra/migrations/1700752078178-AddAssetFaceIndicies.ts diff --git a/server/src/infra/entities/asset-face.entity.ts b/server/src/infra/entities/asset-face.entity.ts index 66f5c2fd1..c47074d2e 100644 --- a/server/src/infra/entities/asset-face.entity.ts +++ b/server/src/infra/entities/asset-face.entity.ts @@ -1,8 +1,9 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { AssetEntity } from './asset.entity'; import { PersonEntity } from './person.entity'; @Entity('asset_faces') +@Index(['personId', 'assetId']) export class AssetFaceEntity { @PrimaryGeneratedColumn('uuid') id!: string; diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 93050b23c..b1f254da4 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -33,6 +33,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; @Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_month', { synchronize: false }) @Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId']) +@Index(['stackParentId']) // For all assets, each originalpath must be unique per user and library export class AssetEntity { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/infra/migrations/1700752078178-AddAssetFaceIndicies.ts b/server/src/infra/migrations/1700752078178-AddAssetFaceIndicies.ts new file mode 100644 index 000000000..723b22b3d --- /dev/null +++ b/server/src/infra/migrations/1700752078178-AddAssetFaceIndicies.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetFaceIndicies1700752078178 implements MigrationInterface { + name = 'AddAssetFaceIndicies1700752078178' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX "IDX_bf339a24070dac7e71304ec530" ON "asset_faces" ("personId", "assetId") `); + await queryRunner.query(`CREATE INDEX "IDX_b463c8edb01364bf2beba08ef1" ON "assets" ("stackParentId") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_b463c8edb01364bf2beba08ef1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bf339a24070dac7e71304ec530"`); + } + +} From 8a8d3811b945235afd9dc8e75fbe2c91a2fc9321 Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Fri, 24 Nov 2023 16:29:49 -0500 Subject: [PATCH 04/60] fix(mobile): Add translatable strings for shared links info (#5292) Mark more strings as translatable, regarding shared link information and expiration. --- mobile/assets/i18n/en-US.json | 22 +++++++++++++ mobile/assets/i18n/es-US.json | 22 +++++++++++++ .../shared_link/ui/shared_link_item.dart | 31 +++++++++---------- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 387a6e370..8f8f3283a 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -390,6 +390,28 @@ "shared_link_edit_show_meta": "Show metadata", "shared_link_edit_submit_button": "Update link", "shared_link_empty": "You don't have any shared links", + "shared_link_error_server_url_fetch": "Cannot fetch the server url", + "shared_link_expired": "Expired", + "shared_link_expires_days": { + "one": "Expires in {} day", + "other": "Expires in {} days" + }, + "shared_link_expires_hours": { + "one": "Expires in {} hour", + "other": "Expires in {} hours" + }, + "shared_link_expires_minutes": { + "one": "Expires in {} minute", + "other": "Expires in {} minutes" + }, + "shared_link_expires_seconds": { + "one": "Expires in {} second", + "other": "Expires in {} seconds" + }, + "shared_link_expires_never": "Expires ∞", + "shared_link_info_chip_download": "Download", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "share_done": "Done", "share_invite": "Invite to album", diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index 24ea49a9d..1694f5a1f 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -390,6 +390,28 @@ "shared_link_edit_show_meta": "Mostrar metadatos", "shared_link_edit_submit_button": "Actualizar enlace", "shared_link_empty": "No tienes ningún enlace compartido", + "shared_link_error_server_url_fetch": "No se puede obtener la URL del servidor", + "shared_link_expired": "Expirado", + "shared_link_expires_days": { + "one": "Expira en {} día", + "other": "Expira en {} días" + }, + "shared_link_expires_hours": { + "one": "Expira en {} hora", + "other": "Expira en {} horas" + }, + "shared_link_expires_minutes": { + "one": "Expira en {} minuto", + "other": "Expira en {} minutos" + }, + "shared_link_expires_seconds": { + "one": "Expira en {} segundo", + "other": "Expira en {} segundos" + }, + "shared_link_expires_never": "Sin expiración", + "shared_link_info_chip_download": "Descargar", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_info_chip_upload": "Subir", "shared_link_manage_links": "Administrar enlaces compartidos", "share_done": "Hecho", "share_invite": "Invitar al álbum", diff --git a/mobile/lib/modules/shared_link/ui/shared_link_item.dart b/mobile/lib/modules/shared_link/ui/shared_link_item.dart index 85bfa4445..56e5d4af5 100644 --- a/mobile/lib/modules/shared_link/ui/shared_link_item.dart +++ b/mobile/lib/modules/shared_link/ui/shared_link_item.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -26,13 +27,13 @@ class SharedLinkItem extends ConsumerWidget { } Widget getExpiryDuration(bool isDarkMode) { - var expiresText = "Expires ∞"; + var expiresText = "shared_link_expires_never".tr(); if (sharedLink.expiresAt != null) { if (isExpired()) { return Text( - "Expired", + "shared_link_expired", style: TextStyle(color: Colors.red[300]), - ); + ).tr(); } final difference = sharedLink.expiresAt!.difference(DateTime.now()); debugPrint("Difference: $difference"); @@ -41,13 +42,13 @@ class SharedLinkItem extends ConsumerWidget { if (difference.inHours % 24 > 12) { dayDifference += 1; } - expiresText = "in $dayDifference days"; + expiresText = "shared_link_expires_days".plural(dayDifference); } else if (difference.inHours > 0) { - expiresText = "in ${difference.inHours} hours"; + expiresText = "shared_link_expires_hours".plural(difference.inHours); } else if (difference.inMinutes > 0) { - expiresText = "in ${difference.inMinutes} minutes"; + expiresText = "shared_link_expires_minutes".plural(difference.inMinutes); } else if (difference.inSeconds > 0) { - expiresText = "in ${difference.inSeconds} seconds"; + expiresText = "shared_link_expires_seconds".plural(difference.inSeconds); } } return Text( @@ -72,7 +73,7 @@ class SharedLinkItem extends ConsumerWidget { context: context, gravity: ToastGravity.BOTTOM, toastType: ToastType.error, - msg: 'Cannot fetch the server url', + msg: "shared_link_error_server_url_fetch".tr(), ); return; } @@ -83,11 +84,9 @@ class SharedLinkItem extends ConsumerWidget { ), ).then((_) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "Copied to clipboard", - ), - duration: Duration(seconds: 2), + SnackBar( + content: const Text("shared_link_clipboard_copied_massage").tr(), + duration: const Duration(seconds: 2), ), ); }); @@ -163,9 +162,9 @@ class SharedLinkItem extends ConsumerWidget { Widget buildBottomInfo() { return Row( children: [ - if (sharedLink.allowUpload) buildInfoChip("Upload"), - if (sharedLink.allowDownload) buildInfoChip("Download"), - if (sharedLink.showMetadata) buildInfoChip("EXIF"), + if (sharedLink.allowUpload) buildInfoChip("shared_link_info_chip_upload".tr()), + if (sharedLink.allowDownload) buildInfoChip("shared_link_info_chip_download".tr()), + if (sharedLink.showMetadata) buildInfoChip("shared_link_info_chip_metadata".tr()), ], ); } From 4684094b9bb5e40306efe4389611be976f6a96aa Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 24 Nov 2023 22:30:57 +0100 Subject: [PATCH 05/60] fix(web): Map clustering when zoomed in (#5299) * raise maxZoom to a value that cannot be reached * set max zoom for the entire map --- .../components/shared-components/map/map.svelte | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 3687d117e..87a1bd0a1 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -87,10 +87,19 @@ {#await style then style} - + event.detail.setMaxZoom(14)} + > {#if !simplified} - + @@ -110,7 +119,7 @@ }), }} id="geojson" - cluster={{ maxZoom: 14, radius: 500 }} + cluster={{ radius: 500 }} > Date: Fri, 24 Nov 2023 21:32:21 +0000 Subject: [PATCH 06/60] fix(mobile): update password change description text to use user name (#5105) Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/assets/i18n/ca.json | 2 +- mobile/assets/i18n/cs-CZ.json | 2 +- mobile/assets/i18n/da-DK.json | 2 +- mobile/assets/i18n/de-DE.json | 2 +- mobile/assets/i18n/en-US.json | 2 +- mobile/assets/i18n/es-ES.json | 2 +- mobile/assets/i18n/es-MX.json | 2 +- mobile/assets/i18n/es-PE.json | 2 +- mobile/assets/i18n/fi-FI.json | 2 +- mobile/assets/i18n/fr-CA.json | 2 +- mobile/assets/i18n/fr-FR.json | 2 +- mobile/assets/i18n/hi-IN.json | 2 +- mobile/assets/i18n/it-IT.json | 2 +- mobile/assets/i18n/ko-KR.json | 2 +- mobile/assets/i18n/lv-LV.json | 2 +- mobile/assets/i18n/mn.json | 2 +- mobile/assets/i18n/nb-NO.json | 2 +- mobile/assets/i18n/nl-NL.json | 2 +- mobile/assets/i18n/pl-PL.json | 2 +- mobile/assets/i18n/ru-RU.json | 2 +- mobile/assets/i18n/sk-SK.json | 2 +- mobile/assets/i18n/sr-Cyrl.json | 2 +- mobile/assets/i18n/sv-FI.json | 2 +- mobile/assets/i18n/sv-SE.json | 2 +- mobile/assets/i18n/th-TH.json | 2 +- mobile/assets/i18n/uk-UA.json | 2 +- mobile/assets/i18n/vi-VN.json | 2 +- mobile/assets/i18n/zh-CN.json | 2 +- mobile/assets/i18n/zh-Hans.json | 2 +- 29 files changed, 29 insertions(+), 29 deletions(-) diff --git a/mobile/assets/i18n/ca.json b/mobile/assets/i18n/ca.json index 6bdfb6255..51394751e 100644 --- a/mobile/assets/i18n/ca.json +++ b/mobile/assets/i18n/ca.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Configuració de la memòria cau", "change_password_form_confirm_password": "Confirma la contrasenya", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 2e8f00fe4..8290c8f8c 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Místní úložiště", "cache_settings_title": "Nastavení vyrovnávací paměti", "change_password_form_confirm_password": "Potvrďte heslo", - "change_password_form_description": "Dobrý den, {firstName} {lastName},\n\nje to buď poprvé, co se přihlašujete do systému, nebo byl vytvořen požadavek na změnu hesla. Níže zadejte nové heslo.", + "change_password_form_description": "Dobrý den, {name},\n\nje to buď poprvé, co se přihlašujete do systému, nebo byl vytvořen požadavek na změnu hesla. Níže zadejte nové heslo.", "change_password_form_new_password": "Nové heslo", "change_password_form_password_mismatch": "Hesla se neshodují", "change_password_form_reenter_new_password": "Znovu zadejte nové heslo", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index 45658bee1..fc12cc49c 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Cache-indstillinger", "change_password_form_confirm_password": "Bekræft kodeord", - "change_password_form_description": "Hej {firstName} {lastName},\n\nDette er enten første gang du logger ind eller også er der lavet en anmodning om at ændre dit kodeord. Indtast venligst et nyt kodeord nedenfor.", + "change_password_form_description": "Hej {name},\n\nDette er enten første gang du logger ind eller også er der lavet en anmodning om at ændre dit kodeord. Indtast venligst et nyt kodeord nedenfor.", "change_password_form_new_password": "Nyt kodeord", "change_password_form_password_mismatch": "Kodeord er ikke ens", "change_password_form_reenter_new_password": "Gentag nyt kodeord", diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index 0c871afc8..2af7aecc1 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Lokaler Speicher", "cache_settings_title": "Zwischenspeicher Einstellungen", "change_password_form_confirm_password": "Passwort bestätigen", - "change_password_form_description": "Hallo {firstName} {lastName}\n\nDas ist entweder das erste Mal dass du dich einloggst oder eine Anfrage zur Änderung deines Passwortes wurde gestellt. Bitte gebe das neue Passwort ein.", + "change_password_form_description": "Hallo {name}\n\nDas ist entweder das erste Mal dass du dich einloggst oder eine Anfrage zur Änderung deines Passwortes wurde gestellt. Bitte gebe das neue Passwort ein.", "change_password_form_new_password": "Neues Passwort", "change_password_form_password_mismatch": "Passwörter stimmen nicht überein", "change_password_form_reenter_new_password": "Passwort erneut eingeben", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 8f8f3283a..f41917e39 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -123,7 +123,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index f6b342f33..f32a8b1f8 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", "change_password_form_confirm_password": "Confirmar Contraseña", - "change_password_form_description": "Hola {firstName} {lastName},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", + "change_password_form_description": "Hola {name},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", "change_password_form_new_password": "Nueva Contraseña", "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index c1d68cf87..d140b60ee 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", "change_password_form_confirm_password": "Confirmar Contraseña", - "change_password_form_description": "Hola {firstName} {lastName},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", + "change_password_form_description": "Hola {name},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", "change_password_form_new_password": "Nueva Contraseña", "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index 388fe5ea1..0e03e2fd6 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", "change_password_form_confirm_password": "Confirmar Contraseña", - "change_password_form_description": "Hola {firstName} {lastName},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", + "change_password_form_description": "Hola {name},\n\nEsta es la primera vez que inicias sesión en el sistema o se ha solicitado cambiar tu contraseña. Por favor, introduce la nueva contraseña a continuación.", "change_password_form_new_password": "Nueva Contraseña", "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index 898b7fac4..d746f99c1 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Paikallinen tallennustila", "cache_settings_title": "Välimuistin asetukset", "change_password_form_confirm_password": "Vahvista salasana", - "change_password_form_description": "Hei {firstName} {lastName},\n\nTämä on joko ensimmäinen kirjautumisesi järjestelmään tai salasanan vaihtaminen vaihtaminen on pakotettu. Ole hyvä ja syötä uusi salasana alle.", + "change_password_form_description": "Hei {name},\n\nTämä on joko ensimmäinen kirjautumisesi järjestelmään tai salasanan vaihtaminen vaihtaminen on pakotettu. Ole hyvä ja syötä uusi salasana alle.", "change_password_form_new_password": "Uusi salasana", "change_password_form_password_mismatch": "Salasanat eivät täsmää", "change_password_form_reenter_new_password": "Uusi salasana uudelleen", diff --git a/mobile/assets/i18n/fr-CA.json b/mobile/assets/i18n/fr-CA.json index ee06e82ca..25e2615d9 100644 --- a/mobile/assets/i18n/fr-CA.json +++ b/mobile/assets/i18n/fr-CA.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Stockage local", "cache_settings_title": "Paramètres de mise en cache", "change_password_form_confirm_password": "Confirmez le mot de passe", - "change_password_form_description": "Bonjour {firstName} {lastName},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé de changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.", + "change_password_form_description": "Bonjour {name},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé de changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.", "change_password_form_new_password": "Nouveau mot de passe", "change_password_form_password_mismatch": "Les mots de passe ne correspondent pas", "change_password_form_reenter_new_password": "Saisissez à nouveau le nouveau mot de passe", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index 84c1ffd6a..22050072d 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Stockage local", "cache_settings_title": "Paramètres de mise en cache", "change_password_form_confirm_password": "Confirmez le mot de passe", - "change_password_form_description": "Bonjour {firstName} {lastName},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé à changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.", + "change_password_form_description": "Bonjour {name},\n\nC'est la première fois que vous vous connectez au système ou vous avez demandé à changer votre mot de passe. Veuillez saisir le nouveau mot de passe ci-dessous.", "change_password_form_new_password": "Nouveau mot de passe", "change_password_form_password_mismatch": "Les mots de passe ne correspondent pas", "change_password_form_reenter_new_password": "Saisissez à nouveau le nouveau mot de passe", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 7e3015c9f..a2ec8a49b 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index 77fed857b..8c5f10e4a 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Impostazioni della Cache", "change_password_form_confirm_password": "Conferma Password ", - "change_password_form_description": "Ciao {firstName} {lastName},\n\nQuesto è la prima volta che accedi al sistema oppure è stato fatto una richiesta di cambiare la password. Per favore inserisca la nuova password qui sotto", + "change_password_form_description": "Ciao {name},\n\nQuesto è la prima volta che accedi al sistema oppure è stato fatto una richiesta di cambiare la password. Per favore inserisca la nuova password qui sotto", "change_password_form_new_password": "Nuova Password", "change_password_form_password_mismatch": "Le password non coincidono", "change_password_form_reenter_new_password": "Inserisci ancora la nuova password ", diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index 58c738af6..4fa57d983 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "로컬 저장소", "cache_settings_title": "캐시 설정", "change_password_form_confirm_password": "비밀번호 확인", - "change_password_form_description": "{firstName} {lastName} 님, 안녕하세요.\n\n시스템에 처음 로그인했거나 비밀번호 변경 요청이 있었습니다. 아래에 새 비밀번호를 입력하세요.", + "change_password_form_description": "{name} 님, 안녕하세요.\n\n시스템에 처음 로그인했거나 비밀번호 변경 요청이 있었습니다. 아래에 새 비밀번호를 입력하세요.", "change_password_form_new_password": "새 비밀번호", "change_password_form_password_mismatch": "비밀번호가 일치하지 않습니다", "change_password_form_reenter_new_password": "새 비밀번호 재입력", diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index daf639d46..1d7c9f55a 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Kešdarbes iestatījumi", "change_password_form_confirm_password": "Apstiprināt Paroli", - "change_password_form_description": "Sveiki {FirstName} {LastName},\n\nŠī ir pirmā reize, kad pierakstāties sistēmā, vai arī ir iesniegts pieprasījums mainīt paroli. Lūdzu, zemāk ievadiet jauno paroli.", + "change_password_form_description": "Sveiki {name},\n\nŠī ir pirmā reize, kad pierakstāties sistēmā, vai arī ir iesniegts pieprasījums mainīt paroli. Lūdzu, zemāk ievadiet jauno paroli.", "change_password_form_new_password": "Jauna Parole", "change_password_form_password_mismatch": "Paroles nesakrīt", "change_password_form_reenter_new_password": "Atkārtoti ievadīt jaunu paroli", diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn.json index 60828faa2..87a943709 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index 0f919be54..cd2e9be27 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Lokal lagring", "cache_settings_title": "Bufringsinnstillinger", "change_password_form_confirm_password": "Bekreft passord", - "change_password_form_description": "Hei {firstName} {lastName}!\n\nDette er enten første gang du logger på systemet, eller det er sendt en forespørsel om å endre passordet ditt. Vennligst skriv inn det nye passordet nedenfor.", + "change_password_form_description": "Hei {name}!\n\nDette er enten første gang du logger på systemet, eller det er sendt en forespørsel om å endre passordet ditt. Vennligst skriv inn det nye passordet nedenfor.", "change_password_form_new_password": "Nytt passord", "change_password_form_password_mismatch": "Passordene stemmer ikke", "change_password_form_reenter_new_password": "Skriv nytt passord igjen", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index 842460652..a91a7e053 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Cache-instellingen", "change_password_form_confirm_password": "Bevestig wachtwoord", - "change_password_form_description": "Hallo {firstName} {lastName},\n\nDit is ofwel de eerste keer dat je inlogt, of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.", + "change_password_form_description": "Hallo {name},\n\nDit is ofwel de eerste keer dat je inlogt, of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.", "change_password_form_new_password": "Nieuw wachtwoord", "change_password_form_password_mismatch": "Wachtwoorden komen niet overeen", "change_password_form_reenter_new_password": "Vul het wachtwoord opnieuw in", diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index 53395ff79..0c7cebe24 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Lokalny magazyn", "cache_settings_title": "Ustawienia Buforowania", "change_password_form_confirm_password": "Potwierdź Hasło", - "change_password_form_description": "Cześć {firstName} {lastName},\n\nPierwszy raz logujesz się do systemu, albo złożono prośbę o zmianę hasła. Wpisz poniżej nowe hasło.", + "change_password_form_description": "Cześć {name},\n\nPierwszy raz logujesz się do systemu, albo złożono prośbę o zmianę hasła. Wpisz poniżej nowe hasło.", "change_password_form_new_password": "Nowe Hasło", "change_password_form_password_mismatch": "Hasła nie są zgodne", "change_password_form_reenter_new_password": "Wprowadź ponownie Nowe Hasło", diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index 62fdb4d70..2767a8200 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Локальное хранилище", "cache_settings_title": "Настройки кэширования", "change_password_form_confirm_password": "Подтвердите пароль", - "change_password_form_description": "Привет {firstName} {lastName},\n\nЭто либо ваш первый вход в систему, либо был сделан запрос на смену пароля. Пожалуйста, введите новый пароль ниже.", + "change_password_form_description": "Привет {name},\n\nЭто либо ваш первый вход в систему, либо был сделан запрос на смену пароля. Пожалуйста, введите новый пароль ниже.", "change_password_form_new_password": "Новый пароль", "change_password_form_password_mismatch": "Пароли не совпадают", "change_password_form_reenter_new_password": "Повторно введите новый пароль", diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index aa0f230dd..3e4921ffe 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Lokálne úložisko", "cache_settings_title": "Nastavenia vyrovnávacej pamäte", "change_password_form_confirm_password": "Potvrďte heslo", - "change_password_form_description": "Dobrý deň, {firstName} {lastName},\n\nBuď sa do systému prihlasujete prvýkrát, alebo bola podaná žiadosť o zmenu hesla. Prosím, zadajte nové heslo nižšie.", + "change_password_form_description": "Dobrý deň, {name},\n\nBuď sa do systému prihlasujete prvýkrát, alebo bola podaná žiadosť o zmenu hesla. Prosím, zadajte nové heslo nižšie.", "change_password_form_new_password": "Nové heslo", "change_password_form_password_mismatch": "Heslá sa nezhodujú", "change_password_form_reenter_new_password": "Znova zadajte nové heslo", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index 7e3015c9f..a2ec8a49b 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index 7e3015c9f..a2ec8a49b 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 31e15c643..c308160a9 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Cache Inställningar", "change_password_form_confirm_password": "Bekräfta lösenord", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "Nytt lösenord", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index ab2e93525..eb74cb43f 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "ตั้งค่าแคช", "change_password_form_confirm_password": "ยืนยันรหัสผ่าน", - "change_password_form_description": "สวัสดี {firstName} {lastName},\n\nครั้งนี้อาจจะเป็นครั้งแรกที่คุณเข้าสู่ระบบ หรือมีคำขอเพื่อที่จะเปลี่ยนรหัสผ่านของคุI กรุณาเพิ่มรหัสผ่านใหม่ข้างล่าง", + "change_password_form_description": "สวัสดี {name},\n\nครั้งนี้อาจจะเป็นครั้งแรกที่คุณเข้าสู่ระบบ หรือมีคำขอเพื่อที่จะเปลี่ยนรหัสผ่านของคุI กรุณาเพิ่มรหัสผ่านใหม่ข้างล่าง", "change_password_form_new_password": "รหัสผ่านใหม่", "change_password_form_password_mismatch": "รหัสผ่านไม่ตรงกัน", "change_password_form_reenter_new_password": "กรอกรหัสผ่านใหม่", diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index 3e44a505e..00031ded6 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Налаштування Кешування", "change_password_form_confirm_password": "Підтвердити пароль", - "change_password_form_description": "Привіт {firstName} {lastName},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.", + "change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.", "change_password_form_new_password": "Новий Пароль", "change_password_form_password_mismatch": "Паролі не співпадають", "change_password_form_reenter_new_password": "Повторіть Новий Пароль", diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 20edf605a..60edb10b4 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "Lưu trữ cục bộ", "cache_settings_title": "Caching Settings", "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 00b3501c4..28591e395 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "本地存储", "cache_settings_title": "缓存设置", "change_password_form_confirm_password": "确认密码", - "change_password_form_description": "{firstName} {lastName} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", + "change_password_form_description": "{name} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", "change_password_form_new_password": "新密码", "change_password_form_password_mismatch": "密码不匹配", "change_password_form_reenter_new_password": "重新输入新的密码", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index 3673afacb..cc8b89a15 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -119,7 +119,7 @@ "cache_settings_tile_title": "本地存储", "cache_settings_title": "缓存设置", "change_password_form_confirm_password": "确认密码", - "change_password_form_description": "{firstName} {lastName} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", + "change_password_form_description": "{name} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", "change_password_form_new_password": "新密码", "change_password_form_password_mismatch": "密码不匹配", "change_password_form_reenter_new_password": "重新输入新的密码", From 155ccbc8704e411a3ced662bd7033355f34c6006 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Nov 2023 09:17:53 -0600 Subject: [PATCH 07/60] chore(deps): update base-image to v20231125 (#5307) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index be353c9fc..be8a5ec99 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20231123 as dev +FROM ghcr.io/immich-app/base-server-dev:20231125 as dev WORKDIR /usr/src/app COPY server/package.json server/package-lock.json ./ @@ -23,7 +23,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20231123 +FROM ghcr.io/immich-app/base-server-prod:20231125 WORKDIR /usr/src/app ENV NODE_ENV=production From 0108211c0f25d008a8ab224dc3b33ffaeeac4125 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Sat, 25 Nov 2023 15:46:20 +0000 Subject: [PATCH 08/60] refactor: deprecate getUserAssetsByDeviceId (#5273) * refactor: deprecated getUserAssetsByDeviceId * prevent breaking changes * chore: add deprecation * prevent breaking changes * prevent breaking changes --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 102 +++++++++++++++++- mobile/ios/Podfile.lock | 2 +- .../backup/services/backup.service.dart | 3 + mobile/openapi/README.md | 3 +- mobile/openapi/doc/AssetApi.md | 66 +++++++++++- mobile/openapi/lib/api/asset_api.dart | 60 ++++++++++- mobile/openapi/test/asset_api_test.dart | 9 +- server/immich-openapi-specs.json | 49 ++++++++- server/src/domain/asset/asset.service.spec.ts | 14 +++ server/src/domain/asset/asset.service.ts | 4 + .../domain/repositories/asset.repository.ts | 1 + .../immich/api-v1/asset/asset.controller.ts | 5 +- .../immich/api-v1/asset/dto/device-id.dto.ts | 6 +- .../immich/controllers/asset.controller.ts | 9 ++ .../immich/controllers/search.controller.ts | 10 +- .../infra/repositories/asset.repository.ts | 21 ++++ .../repositories/asset.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 102 +++++++++++++++++- 18 files changed, 436 insertions(+), 31 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index c48c7a8f5..eaedabc7b 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -6808,6 +6808,48 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Get all asset of a device that are in the database, ID only. + * @param {string} deviceId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllUserAssetsByDeviceId: async (deviceId: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'deviceId' is not null or undefined + assertParamExists('getAllUserAssetsByDeviceId', 'deviceId', deviceId) + const localVarPath = `/asset/device/{deviceId}` + .replace(`{${"deviceId"}}`, encodeURIComponent(String(deviceId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -7477,9 +7519,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {string} deviceId * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ getUserAssetsByDeviceId: async (deviceId: string, options: AxiosRequestConfig = {}): Promise => { @@ -8311,6 +8355,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * Get all asset of a device that are in the database, ID only. + * @param {string} deviceId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAllUserAssetsByDeviceId(deviceId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllUserAssetsByDeviceId(deviceId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Get a single asset\'s information * @param {string} id @@ -8458,9 +8512,11 @@ export const AssetApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {string} deviceId * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async getUserAssetsByDeviceId(deviceId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { @@ -8686,6 +8742,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath)); }, + /** + * Get all asset of a device that are in the database, ID only. + * @param {AssetApiGetAllUserAssetsByDeviceIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllUserAssetsByDeviceId(requestParameters: AssetApiGetAllUserAssetsByDeviceIdRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getAllUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(axios, basePath)); + }, /** * Get a single asset\'s information * @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters. @@ -8792,9 +8857,11 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.withPartners, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {AssetApiGetUserAssetsByDeviceIdRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ getUserAssetsByDeviceId(requestParameters: AssetApiGetUserAssetsByDeviceIdRequest, options?: AxiosRequestConfig): AxiosPromise> { @@ -9030,6 +9097,20 @@ export interface AssetApiGetAllAssetsRequest { readonly ifNoneMatch?: string } +/** + * Request parameters for getAllUserAssetsByDeviceId operation in AssetApi. + * @export + * @interface AssetApiGetAllUserAssetsByDeviceIdRequest + */ +export interface AssetApiGetAllUserAssetsByDeviceIdRequest { + /** + * + * @type {string} + * @memberof AssetApiGetAllUserAssetsByDeviceId + */ + readonly deviceId: string +} + /** * Request parameters for getAssetById operation in AssetApi. * @export @@ -9974,6 +10055,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); } + /** + * Get all asset of a device that are in the database, ID only. + * @param {AssetApiGetAllUserAssetsByDeviceIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getAllUserAssetsByDeviceId(requestParameters: AssetApiGetAllUserAssetsByDeviceIdRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getAllUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(this.axios, this.basePath)); + } + /** * Get a single asset\'s information * @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters. @@ -10104,9 +10196,11 @@ export class AssetApi extends BaseAPI { } /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {AssetApiGetUserAssetsByDeviceIdRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof AssetApi */ diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index c6c23d942..75168ce1c 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -169,4 +169,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382 -COCOAPODS: 1.12.1 +COCOAPODS: 1.11.3 diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 15cc0c349..0e0a14a8c 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -42,6 +42,9 @@ class BackupService { try { return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId); + + // TODO! Start using this in 1.92.0 + // return await _apiService.assetApi.getAllUserAssetsByDeviceId(deviceId); } catch (e) { debugPrint('Error [getDeviceBackupAsset] ${e.toString()}'); return null; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1c307eaac..38aefc445 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -98,6 +98,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} | *AssetApi* | [**emptyTrash**](doc//AssetApi.md#emptytrash) | **POST** /asset/trash/empty | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | +*AssetApi* | [**getAllUserAssetsByDeviceId**](doc//AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | *AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | *AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics | @@ -110,7 +111,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random | *AssetApi* | [**getTimeBucket**](doc//AssetApi.md#gettimebucket) | **GET** /asset/time-bucket | *AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | -*AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | +*AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | Use /asset/device/:deviceId instead - Remove in 1.92 release *AssetApi* | [**restoreAssets**](doc//AssetApi.md#restoreassets) | **POST** /asset/restore | *AssetApi* | [**restoreTrash**](doc//AssetApi.md#restoretrash) | **POST** /asset/trash/restore | *AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 16f7ef94d..b479c08f3 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -16,6 +16,7 @@ Method | HTTP request | Description [**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | [**emptyTrash**](AssetApi.md#emptytrash) | **POST** /asset/trash/empty | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | +[**getAllUserAssetsByDeviceId**](AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | [**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | [**getAssetStatistics**](AssetApi.md#getassetstatistics) | **GET** /asset/statistics | @@ -28,7 +29,7 @@ Method | HTTP request | Description [**getRandom**](AssetApi.md#getrandom) | **GET** /asset/random | [**getTimeBucket**](AssetApi.md#gettimebucket) | **GET** /asset/time-bucket | [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | -[**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | +[**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | Use /asset/device/:deviceId instead - Remove in 1.92 release [**restoreAssets**](AssetApi.md#restoreassets) | **POST** /asset/restore | [**restoreTrash**](AssetApi.md#restoretrash) | **POST** /asset/trash/restore | [**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs | @@ -443,6 +444,63 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getAllUserAssetsByDeviceId** +> List getAllUserAssetsByDeviceId(deviceId) + + + +Get all asset of a device that are in the database, ID only. + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); +final deviceId = deviceId_example; // String | + +try { + final result = api_instance.getAllUserAssetsByDeviceId(deviceId); + print(result); +} catch (e) { + print('Exception when calling AssetApi->getAllUserAssetsByDeviceId: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **deviceId** | **String**| | + +### Return type + +**List** + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getAssetById** > AssetResponseDto getAssetById(id, key) @@ -1154,9 +1212,7 @@ Name | Type | Description | Notes # **getUserAssetsByDeviceId** > List getUserAssetsByDeviceId(deviceId) - - -Get all asset of a device that are in the database, ID only. +Use /asset/device/:deviceId instead - Remove in 1.92 release ### Example ```dart @@ -1177,7 +1233,7 @@ import 'package:openapi/api.dart'; //defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = AssetApi(); -final deviceId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final deviceId = deviceId_example; // String | try { final result = api_instance.getUserAssetsByDeviceId(deviceId); diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 366c83d57..45c1e1104 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -414,6 +414,62 @@ class AssetApi { return null; } + /// Get all asset of a device that are in the database, ID only. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] deviceId (required): + Future getAllUserAssetsByDeviceIdWithHttpInfo(String deviceId,) async { + // ignore: prefer_const_declarations + final path = r'/asset/device/{deviceId}' + .replaceAll('{deviceId}', deviceId); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Get all asset of a device that are in the database, ID only. + /// + /// Parameters: + /// + /// * [String] deviceId (required): + Future?> getAllUserAssetsByDeviceId(String deviceId,) async { + final response = await getAllUserAssetsByDeviceIdWithHttpInfo(deviceId,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + /// Get a single asset's information /// /// Note: This method returns the HTTP [Response]. @@ -1211,7 +1267,7 @@ class AssetApi { return null; } - /// Get all asset of a device that are in the database, ID only. + /// Use /asset/device/:deviceId instead - Remove in 1.92 release /// /// Note: This method returns the HTTP [Response]. /// @@ -1244,7 +1300,7 @@ class AssetApi { ); } - /// Get all asset of a device that are in the database, ID only. + /// Use /asset/device/:deviceId instead - Remove in 1.92 release /// /// Parameters: /// diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 50c35d289..c4f6c8511 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -58,6 +58,13 @@ void main() { // TODO }); + // Get all asset of a device that are in the database, ID only. + // + //Future> getAllUserAssetsByDeviceId(String deviceId) async + test('test getAllUserAssetsByDeviceId', () async { + // TODO + }); + // Get a single asset's information // //Future getAssetById(String id, { String key }) async @@ -120,7 +127,7 @@ void main() { // TODO }); - // Get all asset of a device that are in the database, ID only. + // Use /asset/device/:deviceId instead - Remove in 1.92 release // //Future> getUserAssetsByDeviceId(String deviceId) async test('test getUserAssetsByDeviceId', () async { diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 131766ba9..bd62f44f6 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1219,6 +1219,51 @@ ] } }, + "/asset/device/{deviceId}": { + "get": { + "description": "Get all asset of a device that are in the database, ID only.", + "operationId": "getAllUserAssetsByDeviceId", + "parameters": [ + { + "name": "deviceId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/download/archive": { "post": { "operationId": "downloadArchive", @@ -2281,7 +2326,7 @@ }, "/asset/{deviceId}": { "get": { - "description": "Get all asset of a device that are in the database, ID only.", + "deprecated": true, "operationId": "getUserAssetsByDeviceId", "parameters": [ { @@ -2289,7 +2334,6 @@ "required": true, "in": "path", "schema": { - "format": "uuid", "type": "string" } } @@ -2320,6 +2364,7 @@ "api_key": [] } ], + "summary": "Use /asset/device/:deviceId instead - Remove in 1.92 release", "tags": [ "Asset" ] diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 45687282f..f91c942f8 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1067,4 +1067,18 @@ describe(AssetService.name, () => { ); }); }); + + it('get assets by device id', async () => { + const assets = [assetStub.image, assetStub.image1]; + + assetMock.getAllByDeviceId.mockImplementation(() => + Promise.resolve(Array.from(assets.map((asset) => asset.deviceAssetId))), + ); + + const deviceId = 'device-id'; + const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); + + expect(result.length).toEqual(2); + expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index dbba670e7..86e480932 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -386,6 +386,10 @@ export class AssetService { return assets.map((a) => mapAsset(a)); } + async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) { + return this.assetRepository.getAllByDeviceId(authUser.id, deviceId); + } + async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id); diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 12ae49000..a42952958 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -162,6 +162,7 @@ export interface IAssetRepository { getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; + getAllByDeviceId(userId: string, deviceId: string): Promise; updateAll(ids: string[], options: Partial): Promise; save(asset: Pick & Partial): Promise; remove(asset: AssetEntity): Promise; diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index e7a04564c..ad17ccf31 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -14,7 +14,7 @@ import { UseInterceptors, ValidationPipe, } from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; import { AuthUser, Authenticated, SharedLinkRoute } from '../../app.guard'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; @@ -147,9 +147,10 @@ export class AssetController { } /** - * Get all asset of a device that are in the database, ID only. + * @deprecated Use /asset/device/:deviceId instead - Remove at 1.92 release */ @Get('/:deviceId') + @ApiOperation({ deprecated: true, summary: 'Use /asset/device/:deviceId instead - Remove in 1.92 release' }) getUserAssetsByDeviceId(@AuthUser() authUser: AuthUserDto, @Param() { deviceId }: DeviceIdDto) { return this.assetService.getUserAssetsByDeviceId(authUser, deviceId); } diff --git a/server/src/immich/api-v1/asset/dto/device-id.dto.ts b/server/src/immich/api-v1/asset/dto/device-id.dto.ts index ff2f4163b..cae5f60c8 100644 --- a/server/src/immich/api-v1/asset/dto/device-id.dto.ts +++ b/server/src/immich/api-v1/asset/dto/device-id.dto.ts @@ -1,9 +1,7 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsUUID } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; export class DeviceIdDto { @IsNotEmpty() - @IsUUID('4') - @ApiProperty({ format: 'uuid' }) + @IsString() deviceId!: string; } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 105760e50..3a652c2e5 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -38,6 +38,7 @@ import { StreamableFile, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { DeviceIdDto } from '../api-v1/asset/dto/device-id.dto'; import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard'; import { UseValidation, asStreamableFile } from '../app.utils'; import { Route } from '../interceptors'; @@ -100,6 +101,14 @@ export class AssetController { return this.service.downloadFile(authUser, id).then(asStreamableFile); } + /** + * Get all asset of a device that are in the database, ID only. + */ + @Get('/device/:deviceId') + getAllUserAssetsByDeviceId(@AuthUser() authUser: AuthUserDto, @Param() { deviceId }: DeviceIdDto) { + return this.service.getUserAssetsByDeviceId(authUser, deviceId); + } + @Get('statistics') getAssetStatistics(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetStatsDto): Promise { return this.service.getStatistics(authUser, dto); diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index ffa454cd5..b3de4b26c 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -19,11 +19,6 @@ import { UseValidation } from '../app.utils'; export class SearchController { constructor(private service: SearchService) {} - @Get('person') - searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise { - return this.service.searchPerson(authUser, dto); - } - @Get() search(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise { return this.service.search(authUser, dto); @@ -33,4 +28,9 @@ export class SearchController { getExploreData(@AuthUser() authUser: AuthUserDto): Promise { return this.service.getExploreData(authUser) as Promise; } + + @Get('person') + searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise { + return this.service.searchPerson(authUser, dto); + } } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index b6fa10c0d..59c29a9d2 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -326,6 +326,27 @@ export class AssetRepository implements IAssetRepository { }); } + /** + * Get assets by device's Id on the database + * @param ownerId + * @param deviceId + * + * @returns Promise - Array of assetIds belong to the device + */ + async getAllByDeviceId(ownerId: string, deviceId: string): Promise { + const items = await this.repository.find({ + select: { deviceAssetId: true }, + where: { + ownerId, + deviceId, + isVisible: true, + }, + withDeleted: true, + }); + + return items.map((asset) => asset.deviceAssetId); + } + getById(id: string): Promise { return this.repository.findOne({ where: { id }, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 566b7733a..88bbdabcf 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -18,6 +18,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getFirstAssetForAlbumId: jest.fn(), getLastUpdatedAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), + getAllByDeviceId: jest.fn(), updateAll: jest.fn(), getByLibraryId: jest.fn(), getByLibraryIdAndOriginalPath: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index c48c7a8f5..eaedabc7b 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -6808,6 +6808,48 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Get all asset of a device that are in the database, ID only. + * @param {string} deviceId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllUserAssetsByDeviceId: async (deviceId: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'deviceId' is not null or undefined + assertParamExists('getAllUserAssetsByDeviceId', 'deviceId', deviceId) + const localVarPath = `/asset/device/{deviceId}` + .replace(`{${"deviceId"}}`, encodeURIComponent(String(deviceId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -7477,9 +7519,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {string} deviceId * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ getUserAssetsByDeviceId: async (deviceId: string, options: AxiosRequestConfig = {}): Promise => { @@ -8311,6 +8355,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * Get all asset of a device that are in the database, ID only. + * @param {string} deviceId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAllUserAssetsByDeviceId(deviceId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllUserAssetsByDeviceId(deviceId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Get a single asset\'s information * @param {string} id @@ -8458,9 +8512,11 @@ export const AssetApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {string} deviceId * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ async getUserAssetsByDeviceId(deviceId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { @@ -8686,6 +8742,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath)); }, + /** + * Get all asset of a device that are in the database, ID only. + * @param {AssetApiGetAllUserAssetsByDeviceIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllUserAssetsByDeviceId(requestParameters: AssetApiGetAllUserAssetsByDeviceIdRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getAllUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(axios, basePath)); + }, /** * Get a single asset\'s information * @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters. @@ -8792,9 +8857,11 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.withPartners, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {AssetApiGetUserAssetsByDeviceIdRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ getUserAssetsByDeviceId(requestParameters: AssetApiGetUserAssetsByDeviceIdRequest, options?: AxiosRequestConfig): AxiosPromise> { @@ -9030,6 +9097,20 @@ export interface AssetApiGetAllAssetsRequest { readonly ifNoneMatch?: string } +/** + * Request parameters for getAllUserAssetsByDeviceId operation in AssetApi. + * @export + * @interface AssetApiGetAllUserAssetsByDeviceIdRequest + */ +export interface AssetApiGetAllUserAssetsByDeviceIdRequest { + /** + * + * @type {string} + * @memberof AssetApiGetAllUserAssetsByDeviceId + */ + readonly deviceId: string +} + /** * Request parameters for getAssetById operation in AssetApi. * @export @@ -9974,6 +10055,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getAllAssets(requestParameters.skip, requestParameters.take, requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); } + /** + * Get all asset of a device that are in the database, ID only. + * @param {AssetApiGetAllUserAssetsByDeviceIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getAllUserAssetsByDeviceId(requestParameters: AssetApiGetAllUserAssetsByDeviceIdRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getAllUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(this.axios, this.basePath)); + } + /** * Get a single asset\'s information * @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters. @@ -10104,9 +10196,11 @@ export class AssetApi extends BaseAPI { } /** - * Get all asset of a device that are in the database, ID only. + * + * @summary Use /asset/device/:deviceId instead - Remove in 1.92 release * @param {AssetApiGetUserAssetsByDeviceIdRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof AssetApi */ From 698226634e1e6e9a8d57a1520b3c7a3987fb441d Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 25 Nov 2023 18:53:30 +0000 Subject: [PATCH 09/60] feat: postgres reverse geocoding (#5301) * feat: add system metadata repository for storing key values for internal usage * feat: add database entities for geodata * feat: move reverse geocoding from local-reverse-geocoder to postgresql * infra: disable synchronization for geodata_places table until typeorm supports earth column * feat: remove cities override config as we will default all instances to cities500 now * test: e2e tests don't clear geodata tables on reset --- cli/src/api/open-api/api.ts | 24 -- mobile/openapi/.openapi-generator/FILES | 3 - mobile/openapi/README.md | 1 - mobile/openapi/doc/CitiesFile.md | 14 - .../doc/SystemConfigReverseGeocodingDto.md | 1 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api_client.dart | 2 - mobile/openapi/lib/api_helper.dart | 3 - mobile/openapi/lib/model/cities_file.dart | 91 ------ .../system_config_reverse_geocoding_dto.dart | 10 +- mobile/openapi/test/cities_file_test.dart | 21 -- ...tem_config_reverse_geocoding_dto_test.dart | 5 - server/Dockerfile | 2 +- server/immich-openapi-specs.json | 13 - server/package-lock.json | 264 ------------------ server/package.json | 3 +- server/{assets => resources}/style-dark.json | 0 server/{assets => resources}/style-light.json | 0 .../domain/metadata/metadata.service.spec.ts | 41 +-- .../src/domain/metadata/metadata.service.ts | 22 +- server/src/domain/repositories/index.ts | 1 + .../repositories/metadata.repository.ts | 6 +- .../system-metadata.repository.ts | 8 + .../system-config-reverse-geocoding.dto.ts | 8 +- .../system-config/system-config.core.ts | 2 - .../system-config.service.spec.ts | 2 - .../system-config/system-config.service.ts | 2 +- .../infra/entities/geodata-admin1.entity.ts | 10 + .../infra/entities/geodata-admin2.entity.ts | 10 + .../infra/entities/geodata-places.entity.ts | 59 ++++ server/src/infra/entities/index.ts | 12 + .../infra/entities/system-config.entity.ts | 9 - .../infra/entities/system-metadata.entity.ts | 18 ++ server/src/infra/infra.config.ts | 3 - server/src/infra/infra.module.ts | 3 + .../1700345818045-SystemMetadata.ts | 14 + .../infra/migrations/1700362016675-Geodata.ts | 29 ++ server/src/infra/repositories/index.ts | 1 + .../infra/repositories/metadata.repository.ts | 211 ++++++++++---- .../system-metadata.repository.ts | 20 ++ server/src/infra/utils/database-locks.ts | 3 + server/src/microservices/app.service.ts | 10 - .../repositories/metadata.repository.mock.ts | 1 - server/test/test-utils.ts | 4 +- web/src/api/open-api/api.ts | 24 -- .../settings/map-settings/map-settings.svelte | 22 +- 46 files changed, 368 insertions(+), 645 deletions(-) delete mode 100644 mobile/openapi/doc/CitiesFile.md delete mode 100644 mobile/openapi/lib/model/cities_file.dart delete mode 100644 mobile/openapi/test/cities_file_test.dart rename server/{assets => resources}/style-dark.json (100%) rename server/{assets => resources}/style-light.json (100%) create mode 100644 server/src/domain/repositories/system-metadata.repository.ts create mode 100644 server/src/infra/entities/geodata-admin1.entity.ts create mode 100644 server/src/infra/entities/geodata-admin2.entity.ts create mode 100644 server/src/infra/entities/geodata-places.entity.ts create mode 100644 server/src/infra/entities/system-metadata.entity.ts create mode 100644 server/src/infra/migrations/1700345818045-SystemMetadata.ts create mode 100644 server/src/infra/migrations/1700362016675-Geodata.ts create mode 100644 server/src/infra/repositories/system-metadata.repository.ts create mode 100644 server/src/infra/utils/database-locks.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index eaedabc7b..1f6669794 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1164,22 +1164,6 @@ export interface CheckExistingAssetsResponseDto { */ 'existingIds': Array; } -/** - * - * @export - * @enum {string} - */ - -export const CitiesFile = { - Cities15000: 'cities15000', - Cities5000: 'cities5000', - Cities1000: 'cities1000', - Cities500: 'cities500' -} as const; - -export type CitiesFile = typeof CitiesFile[keyof typeof CitiesFile]; - - /** * * @export @@ -3832,12 +3816,6 @@ export interface SystemConfigPasswordLoginDto { * @interface SystemConfigReverseGeocodingDto */ export interface SystemConfigReverseGeocodingDto { - /** - * - * @type {CitiesFile} - * @memberof SystemConfigReverseGeocodingDto - */ - 'citiesFileOverride': CitiesFile; /** * * @type {boolean} @@ -3845,8 +3823,6 @@ export interface SystemConfigReverseGeocodingDto { */ 'enabled': boolean; } - - /** * * @export diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 10f10fb01..f54b788a4 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -46,7 +46,6 @@ doc/CQMode.md doc/ChangePasswordDto.md doc/CheckExistingAssetsDto.md doc/CheckExistingAssetsResponseDto.md -doc/CitiesFile.md doc/ClassificationConfig.md doc/Colorspace.md doc/CreateAlbumDto.md @@ -231,7 +230,6 @@ lib/model/bulk_ids_dto.dart lib/model/change_password_dto.dart lib/model/check_existing_assets_dto.dart lib/model/check_existing_assets_response_dto.dart -lib/model/cities_file.dart lib/model/classification_config.dart lib/model/clip_config.dart lib/model/clip_mode.dart @@ -388,7 +386,6 @@ test/bulk_ids_dto_test.dart test/change_password_dto_test.dart test/check_existing_assets_dto_test.dart test/check_existing_assets_response_dto_test.dart -test/cities_file_test.dart test/classification_config_test.dart test/clip_config_test.dart test/clip_mode_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 38aefc445..903919c05 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -244,7 +244,6 @@ Class | Method | HTTP request | Description - [ChangePasswordDto](doc//ChangePasswordDto.md) - [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md) - [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md) - - [CitiesFile](doc//CitiesFile.md) - [ClassificationConfig](doc//ClassificationConfig.md) - [Colorspace](doc//Colorspace.md) - [CreateAlbumDto](doc//CreateAlbumDto.md) diff --git a/mobile/openapi/doc/CitiesFile.md b/mobile/openapi/doc/CitiesFile.md deleted file mode 100644 index 9acca959c..000000000 --- a/mobile/openapi/doc/CitiesFile.md +++ /dev/null @@ -1,14 +0,0 @@ -# openapi.model.CitiesFile - -## Load the model package -```dart -import 'package:openapi/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/mobile/openapi/doc/SystemConfigReverseGeocodingDto.md b/mobile/openapi/doc/SystemConfigReverseGeocodingDto.md index 36eab4747..9fca6c209 100644 --- a/mobile/openapi/doc/SystemConfigReverseGeocodingDto.md +++ b/mobile/openapi/doc/SystemConfigReverseGeocodingDto.md @@ -8,7 +8,6 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**citiesFileOverride** | [**CitiesFile**](CitiesFile.md) | | **enabled** | **bool** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 3052d5d8b..894162693 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -83,7 +83,6 @@ part 'model/cq_mode.dart'; part 'model/change_password_dto.dart'; part 'model/check_existing_assets_dto.dart'; part 'model/check_existing_assets_response_dto.dart'; -part 'model/cities_file.dart'; part 'model/classification_config.dart'; part 'model/colorspace.dart'; part 'model/create_album_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 77a999701..42a0e5cbb 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -255,8 +255,6 @@ class ApiClient { return CheckExistingAssetsDto.fromJson(value); case 'CheckExistingAssetsResponseDto': return CheckExistingAssetsResponseDto.fromJson(value); - case 'CitiesFile': - return CitiesFileTypeTransformer().decode(value); case 'ClassificationConfig': return ClassificationConfig.fromJson(value); case 'Colorspace': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index d3f7971e3..728a4ed83 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -73,9 +73,6 @@ String parameterToString(dynamic value) { if (value is CQMode) { return CQModeTypeTransformer().encode(value).toString(); } - if (value is CitiesFile) { - return CitiesFileTypeTransformer().encode(value).toString(); - } if (value is Colorspace) { return ColorspaceTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/cities_file.dart b/mobile/openapi/lib/model/cities_file.dart deleted file mode 100644 index 96f5d8e57..000000000 --- a/mobile/openapi/lib/model/cities_file.dart +++ /dev/null @@ -1,91 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class CitiesFile { - /// Instantiate a new enum with the provided [value]. - const CitiesFile._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const cities15000 = CitiesFile._(r'cities15000'); - static const cities5000 = CitiesFile._(r'cities5000'); - static const cities1000 = CitiesFile._(r'cities1000'); - static const cities500 = CitiesFile._(r'cities500'); - - /// List of all possible values in this [enum][CitiesFile]. - static const values = [ - cities15000, - cities5000, - cities1000, - cities500, - ]; - - static CitiesFile? fromJson(dynamic value) => CitiesFileTypeTransformer().decode(value); - - static List? listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = CitiesFile.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [CitiesFile] to String, -/// and [decode] dynamic data back to [CitiesFile]. -class CitiesFileTypeTransformer { - factory CitiesFileTypeTransformer() => _instance ??= const CitiesFileTypeTransformer._(); - - const CitiesFileTypeTransformer._(); - - String encode(CitiesFile data) => data.value; - - /// Decodes a [dynamic value][data] to a CitiesFile. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - CitiesFile? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'cities15000': return CitiesFile.cities15000; - case r'cities5000': return CitiesFile.cities5000; - case r'cities1000': return CitiesFile.cities1000; - case r'cities500': return CitiesFile.cities500; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [CitiesFileTypeTransformer] instance. - static CitiesFileTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart index 727e5534f..d995d9667 100644 --- a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart +++ b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart @@ -13,31 +13,25 @@ part of openapi.api; class SystemConfigReverseGeocodingDto { /// Returns a new [SystemConfigReverseGeocodingDto] instance. SystemConfigReverseGeocodingDto({ - required this.citiesFileOverride, required this.enabled, }); - CitiesFile citiesFileOverride; - bool enabled; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigReverseGeocodingDto && - other.citiesFileOverride == citiesFileOverride && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis - (citiesFileOverride.hashCode) + (enabled.hashCode); @override - String toString() => 'SystemConfigReverseGeocodingDto[citiesFileOverride=$citiesFileOverride, enabled=$enabled]'; + String toString() => 'SystemConfigReverseGeocodingDto[enabled=$enabled]'; Map toJson() { final json = {}; - json[r'citiesFileOverride'] = this.citiesFileOverride; json[r'enabled'] = this.enabled; return json; } @@ -50,7 +44,6 @@ class SystemConfigReverseGeocodingDto { final json = value.cast(); return SystemConfigReverseGeocodingDto( - citiesFileOverride: CitiesFile.fromJson(json[r'citiesFileOverride'])!, enabled: mapValueOfType(json, r'enabled')!, ); } @@ -99,7 +92,6 @@ class SystemConfigReverseGeocodingDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'citiesFileOverride', 'enabled', }; } diff --git a/mobile/openapi/test/cities_file_test.dart b/mobile/openapi/test/cities_file_test.dart deleted file mode 100644 index cfe63b754..000000000 --- a/mobile/openapi/test/cities_file_test.dart +++ /dev/null @@ -1,21 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -import 'package:openapi/api.dart'; -import 'package:test/test.dart'; - -// tests for CitiesFile -void main() { - - group('test CitiesFile', () { - - }); - -} diff --git a/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart b/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart index 12f7655ea..b4aa477df 100644 --- a/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart +++ b/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart @@ -16,11 +16,6 @@ void main() { // final instance = SystemConfigReverseGeocodingDto(); group('test SystemConfigReverseGeocodingDto', () { - // CitiesFile citiesFileOverride - test('to test the property `citiesFileOverride`', () async { - // TODO - }); - // bool enabled test('to test the property `enabled`', () async { // TODO diff --git a/server/Dockerfile b/server/Dockerfile index be8a5ec99..5f2b79634 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -31,7 +31,7 @@ COPY --from=prod /usr/src/app/node_modules ./node_modules COPY --from=prod /usr/src/app/dist ./dist COPY --from=prod /usr/src/app/bin ./bin COPY --from=web /usr/src/app/build ./www -COPY server/assets assets +COPY server/resources resources COPY server/package.json server/package-lock.json ./ COPY server/start*.sh ./ RUN npm link && npm cache clean --force diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index bd62f44f6..e3ed6402a 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -6989,15 +6989,6 @@ ], "type": "object" }, - "CitiesFile": { - "enum": [ - "cities15000", - "cities5000", - "cities1000", - "cities500" - ], - "type": "string" - }, "ClassificationConfig": { "properties": { "enabled": { @@ -9112,15 +9103,11 @@ }, "SystemConfigReverseGeocodingDto": { "properties": { - "citiesFileOverride": { - "$ref": "#/components/schemas/CitiesFile" - }, "enabled": { "type": "boolean" } }, "required": [ - "citiesFileOverride", "enabled" ], "type": "object" diff --git a/server/package-lock.json b/server/package-lock.json index 6ae9ae259..917e64374 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -38,7 +38,6 @@ "i18n-iso-countries": "^7.6.0", "ioredis": "^5.3.2", "joi": "^17.10.0", - "local-reverse-geocoder": "0.16.5", "lodash": "^4.17.21", "luxon": "^3.4.2", "mv": "^2.1.1", @@ -4132,18 +4131,6 @@ "node": ">=0.6" } }, - "node_modules/binary": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", - "dependencies": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - }, - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -4329,14 +4316,6 @@ "node": ">=4" } }, - "node_modules/buffers": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", - "engines": { - "node": ">=0.2.0" - } - }, "node_modules/buildcheck": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", @@ -4500,17 +4479,6 @@ } ] }, - "node_modules/chainsaw": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", - "dependencies": { - "traverse": ">=0.3.0 <0.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5161,19 +5129,6 @@ "node": ">= 8" } }, - "node_modules/csv-parse": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.0.tgz", - "integrity": "sha512-RxruSK3M4XgzcD7Trm2wEN+SJ26ChIb903+IWxNOcB5q4jT2Cs+hFr6QP39J05EohshRFEvyzEBoZ/466S2sbw==" - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "engines": { - "node": ">= 12" - } - }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -6300,28 +6255,6 @@ "bser": "2.1.1" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -6575,17 +6508,6 @@ "node": ">= 6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/formidable": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", @@ -8323,11 +8245,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/kdt": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz", - "integrity": "sha512-ueX0gyv7tw4zBq9cQjaCr9qIhGTo5XYHUf/8aUUMHwoyb81KeCZHkSOoUwHGg/mgabvhTKCYjDUuYEmdak6Xjg==" - }, "node_modules/keyv": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", @@ -8425,41 +8342,6 @@ "node": ">=6.11.5" } }, - "node_modules/local-reverse-geocoder": { - "version": "0.16.5", - "resolved": "https://registry.npmjs.org/local-reverse-geocoder/-/local-reverse-geocoder-0.16.5.tgz", - "integrity": "sha512-MgJsyR3s8eeMfRfMvikwIdOG/jh9s78zPPX9kfx1qk5fwQLJnry5Qx5jreclqDPEpjOpNKIqz4aG5BbWGAGLbw==", - "hasInstallScript": true, - "dependencies": { - "async": "^3.2.4", - "csv-parse": "^5.5.0", - "debug": "^4.3.4", - "kdt": "^0.1.0", - "node-fetch": "^3.3.2", - "unzip-stream": "^0.3.1" - }, - "engines": { - "node": ">=11.0.0", - "npm": ">=6.4.1" - } - }, - "node_modules/local-reverse-geocoder/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9077,24 +8959,6 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -11717,14 +11581,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "node_modules/traverse": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", - "engines": { - "node": "*" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -12314,15 +12170,6 @@ "node": ">=8" } }, - "node_modules/unzip-stream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.1.tgz", - "integrity": "sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==", - "dependencies": { - "binary": "^0.3.0", - "mkdirp": "^0.5.1" - } - }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -12480,14 +12327,6 @@ "defaults": "^1.0.3" } }, - "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -15937,15 +15776,6 @@ "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", "dev": true }, - "binary": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", - "requires": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - } - }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -16077,11 +15907,6 @@ "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" }, - "buffers": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==" - }, "buildcheck": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", @@ -16194,14 +16019,6 @@ "integrity": "sha512-UrtAXVcj1mvPBFQ4sKd38daP8dEcXXr5sQe6QNNinaPd0iA/cxg9/l3VrSdL73jgw5sKyuQ6jNgiKO12W3SsVA==", "dev": true }, - "chainsaw": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", - "requires": { - "traverse": ">=0.3.0 <0.4" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -16681,16 +16498,6 @@ "which": "^2.0.1" } }, - "csv-parse": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.0.tgz", - "integrity": "sha512-RxruSK3M4XgzcD7Trm2wEN+SJ26ChIb903+IWxNOcB5q4jT2Cs+hFr6QP39J05EohshRFEvyzEBoZ/466S2sbw==" - }, - "data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" - }, "date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -17523,15 +17330,6 @@ "bser": "2.1.1" } }, - "fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "requires": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - } - }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -17717,14 +17515,6 @@ "mime-types": "^2.1.12" } }, - "formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "requires": { - "fetch-blob": "^3.1.2" - } - }, "formidable": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", @@ -19005,11 +18795,6 @@ "universalify": "^2.0.0" } }, - "kdt": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz", - "integrity": "sha512-ueX0gyv7tw4zBq9cQjaCr9qIhGTo5XYHUf/8aUUMHwoyb81KeCZHkSOoUwHGg/mgabvhTKCYjDUuYEmdak6Xjg==" - }, "keyv": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", @@ -19094,31 +18879,6 @@ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "dev": true }, - "local-reverse-geocoder": { - "version": "0.16.5", - "resolved": "https://registry.npmjs.org/local-reverse-geocoder/-/local-reverse-geocoder-0.16.5.tgz", - "integrity": "sha512-MgJsyR3s8eeMfRfMvikwIdOG/jh9s78zPPX9kfx1qk5fwQLJnry5Qx5jreclqDPEpjOpNKIqz4aG5BbWGAGLbw==", - "requires": { - "async": "^3.2.4", - "csv-parse": "^5.5.0", - "debug": "^4.3.4", - "kdt": "^0.1.0", - "node-fetch": "^3.3.2", - "unzip-stream": "^0.3.1" - }, - "dependencies": { - "node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "requires": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - } - } - } - }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -19599,11 +19359,6 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, - "node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" - }, "node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -21569,11 +21324,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "traverse": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==" - }, "tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -21900,15 +21650,6 @@ "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", "dev": true }, - "unzip-stream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unzip-stream/-/unzip-stream-0.3.1.tgz", - "integrity": "sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==", - "requires": { - "binary": "^0.3.0", - "mkdirp": "^0.5.1" - } - }, "update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -22028,11 +21769,6 @@ "defaults": "^1.0.3" } }, - "web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" - }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/server/package.json b/server/package.json index 556740764..21e19ee59 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@babel/runtime": "^7.22.11", + "@immich/cli": "^2.0.3", "@nestjs/bullmq": "^10.0.1", "@nestjs/common": "^10.2.2", "@nestjs/config": "^3.0.0", @@ -65,10 +66,8 @@ "glob": "^10.3.3", "handlebars": "^4.7.8", "i18n-iso-countries": "^7.6.0", - "@immich/cli": "^2.0.3", "ioredis": "^5.3.2", "joi": "^17.10.0", - "local-reverse-geocoder": "0.16.5", "lodash": "^4.17.21", "luxon": "^3.4.2", "mv": "^2.1.1", diff --git a/server/assets/style-dark.json b/server/resources/style-dark.json similarity index 100% rename from server/assets/style-dark.json rename to server/resources/style-dark.json diff --git a/server/assets/style-light.json b/server/resources/style-light.json similarity index 100% rename from server/assets/style-light.json rename to server/resources/style-light.json diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index bb2d70622..7ce7db054 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -1,4 +1,4 @@ -import { AssetType, CitiesFile, ExifEntity, SystemConfigKey } from '@app/infra/entities'; +import { AssetType, ExifEntity, SystemConfigKey } from '@app/infra/entities'; import { assetStub, newAlbumRepositoryMock, @@ -15,7 +15,7 @@ import { randomBytes } from 'crypto'; import { Stats } from 'fs'; import { constants } from 'fs/promises'; import { when } from 'jest-when'; -import { JobName, QueueName } from '../job'; +import { JobName } from '../job'; import { IAlbumRepository, IAssetRepository, @@ -78,10 +78,7 @@ describe(MetadataService.name, () => { describe('init', () => { beforeEach(async () => { - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }, - { key: SystemConfigKey.REVERSE_GEOCODING_CITIES_FILE_OVERRIDE, value: CitiesFile.CITIES_500 }, - ]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); await sut.init(); }); @@ -90,42 +87,10 @@ describe(MetadataService.name, () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]); await sut.init(); - expect(metadataMock.deleteCache).not.toHaveBeenCalled(); expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(metadataMock.init).toHaveBeenCalledTimes(1); expect(jobMock.resume).toHaveBeenCalledTimes(1); }); - - it('should return if deleteCache is false and the cities precision has not changed', async () => { - await sut.init(); - - expect(metadataMock.deleteCache).not.toHaveBeenCalled(); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(metadataMock.init).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); - }); - - it('should re-init if deleteCache is false but the cities precision has changed', async () => { - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.REVERSE_GEOCODING_CITIES_FILE_OVERRIDE, value: CitiesFile.CITIES_1000 }, - ]); - - await sut.init(); - - expect(metadataMock.deleteCache).not.toHaveBeenCalled(); - expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); - expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_1000 }); - expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); - }); - - it('should re-init and delete cache if deleteCache is true', async () => { - await sut.init(true); - - expect(metadataMock.deleteCache).toHaveBeenCalled(); - expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); - expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_500 }); - expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); - }); }); describe('handleLivePhotoLinking', () => { diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index f600f75a9..b3a19dac2 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -97,31 +97,24 @@ export class MetadataService { this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); } - async init(deleteCache = false) { + async init() { if (!this.subscription) { this.subscription = this.configCore.config$.subscribe(() => this.init()); } const { reverseGeocoding } = await this.configCore.getConfig(); - const { citiesFileOverride } = reverseGeocoding; + const { enabled } = reverseGeocoding; - if (!reverseGeocoding.enabled) { + if (!enabled) { return; } try { - if (deleteCache) { - await this.repository.deleteCache(); - } else if (this.oldCities && this.oldCities === citiesFileOverride) { - return; - } - await this.jobRepository.pause(QueueName.METADATA_EXTRACTION); - await this.repository.init({ citiesFileOverride }); + await this.repository.init(); await this.jobRepository.resume(QueueName.METADATA_EXTRACTION); - this.logger.log(`Initialized local reverse geocoder with ${citiesFileOverride}`); - this.oldCities = citiesFileOverride; + this.logger.log(`Initialized local reverse geocoder`); } catch (error: Error | any) { this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack); } @@ -258,8 +251,9 @@ export class MetadataService { } try { - const { city, state, country } = await this.repository.reverseGeocode({ latitude, longitude }); - Object.assign(exifData, { city, state, country }); + const reverseGeocode = await this.repository.reverseGeocode({ latitude, longitude }); + if (!reverseGeocode) return; + Object.assign(exifData, reverseGeocode); } catch (error: Error | any) { this.logger.warn( `Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`, diff --git a/server/src/domain/repositories/index.ts b/server/src/domain/repositories/index.ts index ff098d8db..f812e6ee5 100644 --- a/server/src/domain/repositories/index.ts +++ b/server/src/domain/repositories/index.ts @@ -20,6 +20,7 @@ export * from './shared-link.repository'; export * from './smart-info.repository'; export * from './storage.repository'; export * from './system-config.repository'; +export * from './system-metadata.repository'; export * from './tag.repository'; export * from './user-token.repository'; export * from './user.repository'; diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts index 0c3b78462..c0a0fef46 100644 --- a/server/src/domain/repositories/metadata.repository.ts +++ b/server/src/domain/repositories/metadata.repository.ts @@ -1,5 +1,4 @@ import { Tags } from 'exiftool-vendored'; -import { InitOptions } from 'local-reverse-geocoder'; export const IMetadataRepository = 'IMetadataRepository'; @@ -31,9 +30,8 @@ export interface ImmichTags extends Omit { } export interface IMetadataRepository { - init(options: Partial): Promise; + init(): Promise; teardown(): Promise; - reverseGeocode(point: GeoPoint): Promise; - deleteCache(): Promise; + reverseGeocode(point: GeoPoint): Promise; getExifTags(path: string): Promise; } diff --git a/server/src/domain/repositories/system-metadata.repository.ts b/server/src/domain/repositories/system-metadata.repository.ts new file mode 100644 index 000000000..4d571953b --- /dev/null +++ b/server/src/domain/repositories/system-metadata.repository.ts @@ -0,0 +1,8 @@ +import { SystemMetadata } from '@app/infra/entities'; + +export const ISystemMetadataRepository = 'ISystemMetadataRepository'; + +export interface ISystemMetadataRepository { + get(key: T): Promise; + set(key: T, value: SystemMetadata[T]): Promise; +} diff --git a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts index be20a02c7..aa224ccc6 100644 --- a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts +++ b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts @@ -1,12 +1,6 @@ -import { CitiesFile } from '@app/infra/entities'; -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsEnum } from 'class-validator'; +import { IsBoolean } from 'class-validator'; export class SystemConfigReverseGeocodingDto { @IsBoolean() enabled!: boolean; - - @IsEnum(CitiesFile) - @ApiProperty({ enum: CitiesFile, enumName: 'CitiesFile' }) - citiesFileOverride!: CitiesFile; } diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index b3a030487..bfab4bb4f 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -1,6 +1,5 @@ import { AudioCodec, - CitiesFile, Colorspace, CQMode, SystemConfig, @@ -85,7 +84,6 @@ export const defaults = Object.freeze({ }, reverseGeocoding: { enabled: true, - citiesFileOverride: CitiesFile.CITIES_500, }, oauth: { enabled: false, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index cdeb552b0..6ff4ac5c4 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -1,6 +1,5 @@ import { AudioCodec, - CitiesFile, Colorspace, CQMode, SystemConfig, @@ -85,7 +84,6 @@ const updatedConfig = Object.freeze({ }, reverseGeocoding: { enabled: true, - citiesFileOverride: CitiesFile.CITIES_500, }, oauth: { autoLaunch: true, diff --git a/server/src/domain/system-config/system-config.service.ts b/server/src/domain/system-config/system-config.service.ts index 5e9743ba5..c81c462e8 100644 --- a/server/src/domain/system-config/system-config.service.ts +++ b/server/src/domain/system-config/system-config.service.ts @@ -79,7 +79,7 @@ export class SystemConfigService { return this.repository.fetchStyle(styleUrl); } - return JSON.parse(await this.repository.readFile(`./assets/style-${theme}.json`)); + return JSON.parse(await this.repository.readFile(`./resources/style-${theme}.json`)); } async getCustomCss(): Promise { diff --git a/server/src/infra/entities/geodata-admin1.entity.ts b/server/src/infra/entities/geodata-admin1.entity.ts new file mode 100644 index 000000000..36cf0a805 --- /dev/null +++ b/server/src/infra/entities/geodata-admin1.entity.ts @@ -0,0 +1,10 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('geodata_admin1') +export class GeodataAdmin1Entity { + @PrimaryColumn({ type: 'varchar' }) + key!: string; + + @Column({ type: 'varchar' }) + name!: string; +} diff --git a/server/src/infra/entities/geodata-admin2.entity.ts b/server/src/infra/entities/geodata-admin2.entity.ts new file mode 100644 index 000000000..bd03e8377 --- /dev/null +++ b/server/src/infra/entities/geodata-admin2.entity.ts @@ -0,0 +1,10 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('geodata_admin2') +export class GeodataAdmin2Entity { + @PrimaryColumn({ type: 'varchar' }) + key!: string; + + @Column({ type: 'varchar' }) + name!: string; +} diff --git a/server/src/infra/entities/geodata-places.entity.ts b/server/src/infra/entities/geodata-places.entity.ts new file mode 100644 index 000000000..244e4261b --- /dev/null +++ b/server/src/infra/entities/geodata-places.entity.ts @@ -0,0 +1,59 @@ +import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity'; +import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity'; +import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; + +@Entity('geodata_places', { synchronize: false }) +export class GeodataPlacesEntity { + @PrimaryColumn({ type: 'integer' }) + id!: number; + + @Column({ type: 'varchar', length: 200 }) + name!: string; + + @Column({ type: 'float' }) + longitude!: number; + + @Column({ type: 'float' }) + latitude!: number; + + // @Column({ + // generatedType: 'STORED', + // asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)', + // type: 'earth', + // }) + earthCoord!: unknown; + + @Column({ type: 'char', length: 2 }) + countryCode!: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + admin1Code!: string; + + @Column({ type: 'varchar', length: 80, nullable: true }) + admin2Code!: string; + + @Column({ + type: 'varchar', + generatedType: 'STORED', + asExpression: `"countryCode" || '.' || "admin1Code"`, + nullable: true, + }) + admin1Key!: string; + + @ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false }) + admin1!: GeodataAdmin1Entity; + + @Column({ + type: 'varchar', + generatedType: 'STORED', + asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`, + nullable: true, + }) + admin2Key!: string; + + @ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false }) + admin2!: GeodataAdmin2Entity; + + @Column({ type: 'date' }) + modificationDate!: Date; +} diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index e4b5c38b4..6c662a20a 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -1,3 +1,4 @@ +import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity'; import { ActivityEntity } from './activity.entity'; import { AlbumEntity } from './album.entity'; import { APIKeyEntity } from './api-key.entity'; @@ -6,6 +7,8 @@ import { AssetJobStatusEntity } from './asset-job-status.entity'; import { AssetEntity } from './asset.entity'; import { AuditEntity } from './audit.entity'; import { ExifEntity } from './exif.entity'; +import { GeodataAdmin1Entity } from './geodata-admin1.entity'; +import { GeodataPlacesEntity } from './geodata-places.entity'; import { LibraryEntity } from './library.entity'; import { MoveEntity } from './move.entity'; import { PartnerEntity } from './partner.entity'; @@ -13,6 +16,7 @@ import { PersonEntity } from './person.entity'; import { SharedLinkEntity } from './shared-link.entity'; import { SmartInfoEntity } from './smart-info.entity'; import { SystemConfigEntity } from './system-config.entity'; +import { SystemMetadataEntity } from './system-metadata.entity'; import { TagEntity } from './tag.entity'; import { UserTokenEntity } from './user-token.entity'; import { UserEntity } from './user.entity'; @@ -25,6 +29,9 @@ export * from './asset-job-status.entity'; export * from './asset.entity'; export * from './audit.entity'; export * from './exif.entity'; +export * from './geodata-admin1.entity'; +export * from './geodata-admin2.entity'; +export * from './geodata-places.entity'; export * from './library.entity'; export * from './move.entity'; export * from './partner.entity'; @@ -32,6 +39,7 @@ export * from './person.entity'; export * from './shared-link.entity'; export * from './smart-info.entity'; export * from './system-config.entity'; +export * from './system-metadata.entity'; export * from './tag.entity'; export * from './user-token.entity'; export * from './user.entity'; @@ -45,12 +53,16 @@ export const databaseEntities = [ AssetJobStatusEntity, AuditEntity, ExifEntity, + GeodataPlacesEntity, + GeodataAdmin1Entity, + GeodataAdmin2Entity, MoveEntity, PartnerEntity, PersonEntity, SharedLinkEntity, SmartInfoEntity, SystemConfigEntity, + SystemMetadataEntity, TagEntity, UserEntity, UserTokenEntity, diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 84e72e638..f6c14e1a7 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -66,7 +66,6 @@ export enum SystemConfigKey { MAP_DARK_STYLE = 'map.darkStyle', REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled', - REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride', NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled', @@ -145,13 +144,6 @@ export enum Colorspace { P3 = 'p3', } -export enum CitiesFile { - CITIES_15000 = 'cities15000', - CITIES_5000 = 'cities5000', - CITIES_1000 = 'cities1000', - CITIES_500 = 'cities500', -} - export interface SystemConfig { ffmpeg: { crf: number; @@ -200,7 +192,6 @@ export interface SystemConfig { }; reverseGeocoding: { enabled: boolean; - citiesFileOverride: CitiesFile; }; oauth: { enabled: boolean; diff --git a/server/src/infra/entities/system-metadata.entity.ts b/server/src/infra/entities/system-metadata.entity.ts new file mode 100644 index 000000000..623806db7 --- /dev/null +++ b/server/src/infra/entities/system-metadata.entity.ts @@ -0,0 +1,18 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity('system_metadata') +export class SystemMetadataEntity { + @PrimaryColumn() + key!: string; + + @Column({ type: 'jsonb', default: '{}', transformer: { to: JSON.stringify, from: JSON.parse } }) + value!: { [key: string]: unknown }; +} + +export enum SystemMetadataKey { + REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', +} + +export interface SystemMetadata extends Record { + [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; +} diff --git a/server/src/infra/infra.config.ts b/server/src/infra/infra.config.ts index 90477d8ca..7f2423032 100644 --- a/server/src/infra/infra.config.ts +++ b/server/src/infra/infra.config.ts @@ -74,6 +74,3 @@ function parseTypeSenseConfig(): ConfigurationOptions { } export const typesenseConfig: ConfigurationOptions = parseTypeSenseConfig(); - -export const REVERSE_GEOCODING_DUMP_DIRECTORY = - process.env.REVERSE_GEOCODING_DUMP_DIRECTORY || process.cwd() + '/.reverse-geocoding-dump/'; diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 276058c0b..e0d5711d6 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -21,6 +21,7 @@ import { ISmartInfoRepository, IStorageRepository, ISystemConfigRepository, + ISystemMetadataRepository, ITagRepository, IUserRepository, IUserTokenRepository, @@ -56,6 +57,7 @@ import { SharedLinkRepository, SmartInfoRepository, SystemConfigRepository, + SystemMetadataRepository, TagRepository, TypesenseRepository, UserRepository, @@ -84,6 +86,7 @@ const providers: Provider[] = [ { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, { provide: IStorageRepository, useClass: FilesystemProvider }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, + { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: IMediaRepository, useClass: MediaRepository }, { provide: IUserRepository, useClass: UserRepository }, diff --git a/server/src/infra/migrations/1700345818045-SystemMetadata.ts b/server/src/infra/migrations/1700345818045-SystemMetadata.ts new file mode 100644 index 000000000..0bd9162db --- /dev/null +++ b/server/src/infra/migrations/1700345818045-SystemMetadata.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class SystemMetadata1700345818045 implements MigrationInterface { + name = 'SystemMetadata1700345818045' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "system_metadata" ("key" character varying NOT NULL, "value" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_fa94f6857470fb5b81ec6084465" PRIMARY KEY ("key"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "system_metadata"`); + } + +} diff --git a/server/src/infra/migrations/1700362016675-Geodata.ts b/server/src/infra/migrations/1700362016675-Geodata.ts new file mode 100644 index 000000000..1ef562ff7 --- /dev/null +++ b/server/src/infra/migrations/1700362016675-Geodata.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Geodata1700362016675 implements MigrationInterface { + name = 'Geodata1700362016675' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS cube`) + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS earthdistance`) + await queryRunner.query(`CREATE TABLE "geodata_admin2" ("key" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key"))`); + await queryRunner.query(`CREATE TABLE "geodata_admin1" ("key" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key"))`); + await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["immich","public","geodata_places","GENERATED_COLUMN","admin1Key","\"countryCode\" || '.' || \"admin1Code\""]); + await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["immich","public","geodata_places","GENERATED_COLUMN","admin2Key","\"countryCode\" || '.' || \"admin1Code\" || '.' || \"admin2Code\""]); + await queryRunner.query(`CREATE TABLE "geodata_places" ("id" integer NOT NULL, "name" character varying(200) NOT NULL, "longitude" double precision NOT NULL, "latitude" double precision NOT NULL, "countryCode" character(2) NOT NULL, "admin1Code" character varying(20), "admin2Code" character varying(80), "admin1Key" character varying GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED, "admin2Key" character varying GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED, "modificationDate" date NOT NULL, CONSTRAINT "PK_c29918988912ef4036f3d7fbff4" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "geodata_places" ADD "earthCoord" earth GENERATED ALWAYS AS (ll_to_earth(latitude, longitude)) STORED`) + await queryRunner.query(`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" USING gist ("earthCoord");`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_geodata_gist_earthcoord"`); + await queryRunner.query(`DROP TABLE "geodata_places"`); + await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","admin2Key","immich","public","geodata_places"]); + await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","admin1Key","immich","public","geodata_places"]); + await queryRunner.query(`DROP TABLE "geodata_admin1"`); + await queryRunner.query(`DROP TABLE "geodata_admin2"`); + await queryRunner.query(`DROP EXTENSION cube`); + await queryRunner.query(`DROP EXTENSION earthdistance`); + } + +} diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index 81ea7dd81..0324fef43 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -19,6 +19,7 @@ export * from './server-info.repository'; export * from './shared-link.repository'; export * from './smart-info.repository'; export * from './system-config.repository'; +export * from './system-metadata.repository'; export * from './tag.repository'; export * from './typesense.repository'; export * from './user-token.repository'; diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index 63bc29dcb..8f8d068e5 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -1,77 +1,182 @@ -import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from '@app/domain'; -import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra'; -import { Injectable, Logger } from '@nestjs/common'; +import { + GeoPoint, + IMetadataRepository, + ImmichTags, + ISystemMetadataRepository, + ReverseGeocodeResult, +} from '@app/domain'; +import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities'; +import { DatabaseLock } from '@app/infra/utils/database-locks'; +import { Inject, Logger } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored'; -import { readdir, rm } from 'fs/promises'; +import { createReadStream, existsSync } from 'fs'; +import { readFile } from 'fs/promises'; import * as geotz from 'geo-tz'; import { getName } from 'i18n-iso-countries'; -import geocoder, { AddressObject, InitOptions } from 'local-reverse-geocoder'; -import path from 'path'; -import { promisify } from 'util'; +import * as readLine from 'readline'; +import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm'; -export interface AdminCode { - name: string; - asciiName: string; - geoNameId: string; -} +type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity; +type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity; -export type GeoData = AddressObject & { - admin1Code?: AdminCode | string; - admin2Code?: AdminCode | string; -}; +const CITIES_FILE = 'cities500.txt'; -const lookup = promisify(geocoder.lookUp).bind(geocoder); - -@Injectable() export class MetadataRepository implements IMetadataRepository { + constructor( + @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository, + @InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository, + @InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository, + @Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository, + @InjectDataSource() private dataSource: DataSource, + ) {} + private logger = new Logger(MetadataRepository.name); - async init(options: Partial): Promise { - return new Promise((resolve) => { - geocoder.init( - { - load: { - admin1: true, - admin2: true, - admin3And4: false, - alternateNames: false, - }, - countries: [], - dumpDirectory: REVERSE_GEOCODING_DUMP_DIRECTORY, - ...options, - }, - resolve, - ); + async init(): Promise { + this.logger.log('Initializing metadata repository'); + const geodataDate = await readFile('/usr/src/resources/geodata-date.txt', 'utf8'); + + await this.geodataPlacesRepository.query('SELECT pg_advisory_lock($1)', [DatabaseLock.GeodataImport]); + + const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + + if (geocodingMetadata?.lastUpdate === geodataDate) { + await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]); + return; + } + + this.logger.log('Importing geodata to database from file'); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + + try { + await queryRunner.startTransaction(); + + await this.loadCities500(queryRunner); + await this.loadAdmin1(queryRunner); + await this.loadAdmin2(queryRunner); + + await queryRunner.commitTransaction(); + } catch (e) { + this.logger.fatal('Error importing geodata', e); + await queryRunner.rollbackTransaction(); + throw e; + } finally { + await queryRunner.release(); + } + + await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, { + lastUpdate: geodataDate, + lastImportFileName: CITIES_FILE, }); + + await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]); + this.logger.log('Geodata import completed'); + } + + private async loadGeodataToTableFromFile( + queryRunner: QueryRunner, + lineToEntityMapper: (lineSplit: string[]) => T, + filePath: string, + entity: GeoEntityClass, + ) { + if (!existsSync(filePath)) { + this.logger.error(`Geodata file ${filePath} not found`); + throw new Error(`Geodata file ${filePath} not found`); + } + await queryRunner.manager.clear(entity); + + const input = createReadStream(filePath); + let buffer: DeepPartial[] = []; + const lineReader = readLine.createInterface({ input: input }); + + for await (const line of lineReader) { + const lineSplit = line.split('\t'); + buffer.push(lineToEntityMapper(lineSplit)); + if (buffer.length > 1000) { + await queryRunner.manager.save(buffer); + buffer = []; + } + } + await queryRunner.manager.save(buffer); + } + + private async loadCities500(queryRunner: QueryRunner) { + await this.loadGeodataToTableFromFile( + queryRunner, + (lineSplit: string[]) => + this.geodataPlacesRepository.create({ + id: parseInt(lineSplit[0]), + name: lineSplit[1], + latitude: parseFloat(lineSplit[4]), + longitude: parseFloat(lineSplit[5]), + countryCode: lineSplit[8], + admin1Code: lineSplit[10], + admin2Code: lineSplit[11], + modificationDate: lineSplit[18], + }), + `/usr/src/resources/${CITIES_FILE}`, + GeodataPlacesEntity, + ); + } + + private async loadAdmin1(queryRunner: QueryRunner) { + await this.loadGeodataToTableFromFile( + queryRunner, + (lineSplit: string[]) => + this.geodataAdmin1Repository.create({ + key: lineSplit[0], + name: lineSplit[1], + }), + '/usr/src/resources/admin1CodesASCII.txt', + GeodataAdmin1Entity, + ); + } + + private async loadAdmin2(queryRunner: QueryRunner) { + await this.loadGeodataToTableFromFile( + queryRunner, + (lineSplit: string[]) => + this.geodataAdmin2Repository.create({ + key: lineSplit[0], + name: lineSplit[1], + }), + '/usr/src/resources/admin2Codes.txt', + GeodataAdmin2Entity, + ); } async teardown() { await exiftool.end(); } - async deleteCache() { - const dumpDirectory = REVERSE_GEOCODING_DUMP_DIRECTORY; - if (dumpDirectory) { - // delete contents - const items = await readdir(dumpDirectory, { withFileTypes: true }); - const folders = items.filter((item) => item.isDirectory()); - for (const { name } of folders) { - await rm(path.join(dumpDirectory, name), { recursive: true, force: true }); - } - } - } - - async reverseGeocode(point: GeoPoint): Promise { + async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); - const [address] = await lookup([point], 1); - this.logger.verbose(`Raw: ${JSON.stringify(address, null, 2)}`); + const response = await this.geodataPlacesRepository + .createQueryBuilder('geoplaces') + .leftJoinAndSelect('geoplaces.admin1', 'admin1') + .leftJoinAndSelect('geoplaces.admin2', 'admin2') + .where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point) + .orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")') + .limit(1) + .getOne(); - const { countryCode, name: city, admin1Code, admin2Code } = address[0] as GeoData; + if (!response) { + this.logger.warn( + `Response from database for reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`, + ); + return null; + } + + this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); + + const { countryCode, name: city, admin1, admin2 } = response; const country = getName(countryCode, 'en') ?? null; - const stateParts = [(admin2Code as AdminCode)?.name, (admin1Code as AdminCode)?.name].filter((name) => !!name); + const stateParts = [admin2?.name, admin1?.name].filter((name) => !!name); const state = stateParts.length > 0 ? stateParts.join(', ') : null; - this.logger.debug(`Normalized: ${JSON.stringify({ country, state, city })}`); return { country, state, city }; } diff --git a/server/src/infra/repositories/system-metadata.repository.ts b/server/src/infra/repositories/system-metadata.repository.ts new file mode 100644 index 000000000..a4f3eeff0 --- /dev/null +++ b/server/src/infra/repositories/system-metadata.repository.ts @@ -0,0 +1,20 @@ +import { ISystemMetadataRepository } from '@app/domain/repositories/system-metadata.repository'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SystemMetadata, SystemMetadataEntity } from '../entities'; + +export class SystemMetadataRepository implements ISystemMetadataRepository { + constructor( + @InjectRepository(SystemMetadataEntity) + private repository: Repository, + ) {} + async get(key: T): Promise { + const metadata = await this.repository.findOne({ where: { key } }); + if (!metadata) return null; + return metadata.value as SystemMetadata[T]; + } + + async set(key: T, value: SystemMetadata[T]): Promise { + await this.repository.upsert({ key, value }, { conflictPaths: { key: true } }); + } +} diff --git a/server/src/infra/utils/database-locks.ts b/server/src/infra/utils/database-locks.ts new file mode 100644 index 000000000..756437743 --- /dev/null +++ b/server/src/infra/utils/database-locks.ts @@ -0,0 +1,3 @@ +export enum DatabaseLock { + GeodataImport = 100, +} diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 67d995e33..554519114 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -92,16 +92,6 @@ export class AppService { [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), }); - process.on('uncaughtException', async (error: Error | any) => { - const isCsvError = error.code === 'CSV_RECORD_INCONSISTENT_FIELDS_LENGTH'; - if (!isCsvError) { - throw error; - } - - this.logger.warn('Geocoding csv parse error, trying again without cache...'); - await this.metadataService.init(true); - }); - await this.metadataService.init(); await this.searchService.init(); } diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 76c6f777a..c602c54d5 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -2,7 +2,6 @@ import { IMetadataRepository } from '@app/domain'; export const newMetadataRepositoryMock = (): jest.Mocked => { return { - deleteCache: jest.fn(), getExifTags: jest.fn(), init: jest.fn(), teardown: jest.fn(), diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index 2cbd4f19a..dc7c1b698 100644 --- a/server/test/test-utils.ts +++ b/server/test/test-utils.ts @@ -25,7 +25,9 @@ export const db = { const tableNames = entities.length > 0 ? entities.map((entity) => em.getRepository(entity).metadata.tableName) - : dataSource.entityMetadatas.map((entity) => entity.tableName); + : dataSource.entityMetadatas + .map((entity) => entity.tableName) + .filter((tableName) => !tableName.startsWith('geodata')); let deleteUsers = false; for (const tableName of tableNames) { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index eaedabc7b..1f6669794 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1164,22 +1164,6 @@ export interface CheckExistingAssetsResponseDto { */ 'existingIds': Array; } -/** - * - * @export - * @enum {string} - */ - -export const CitiesFile = { - Cities15000: 'cities15000', - Cities5000: 'cities5000', - Cities1000: 'cities1000', - Cities500: 'cities500' -} as const; - -export type CitiesFile = typeof CitiesFile[keyof typeof CitiesFile]; - - /** * * @export @@ -3832,12 +3816,6 @@ export interface SystemConfigPasswordLoginDto { * @interface SystemConfigReverseGeocodingDto */ export interface SystemConfigReverseGeocodingDto { - /** - * - * @type {CitiesFile} - * @memberof SystemConfigReverseGeocodingDto - */ - 'citiesFileOverride': CitiesFile; /** * * @type {boolean} @@ -3845,8 +3823,6 @@ export interface SystemConfigReverseGeocodingDto { */ 'enabled': boolean; } - - /** * * @export diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte index 7093a0eeb..fe2f87969 100644 --- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte +++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte @@ -4,13 +4,12 @@ NotificationType, } from '$lib/components/shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; - import { api, CitiesFile, SystemConfigDto } from '@api'; + import { api, SystemConfigDto } from '@api'; import { cloneDeep, isEqual } from 'lodash-es'; import { fade } from 'svelte/transition'; import SettingAccordion from '../setting-accordion.svelte'; import SettingButtonsRow from '../setting-buttons-row.svelte'; import SettingSwitch from '../setting-switch.svelte'; - import SettingSelect from '../setting-select.svelte'; import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; export let config: SystemConfigDto; // this is the config that is being edited @@ -39,7 +38,6 @@ }, reverseGeocoding: { enabled: config.reverseGeocoding.enabled, - citiesFileOverride: config.reverseGeocoding.citiesFileOverride, }, }, }); @@ -131,24 +129,6 @@ subtitle="Enable reverse geocoding" bind:checked={config.reverseGeocoding.enabled} /> - -
- - From 6d1b325b342898716b618744842dd457ec90f31a Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Sat, 25 Nov 2023 17:56:23 -0500 Subject: [PATCH 10/60] chore(server): Check album permissions in bulk (#5290) * chore(server): Check album permissions in bulk Modify Access repository, to evaluate `album` permissions in bulk. Queries have been validated to match what they currently generate for single ids. Queries: * Owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "albums" "AlbumEntity" WHERE "AlbumEntity"."id" = $1 AND "AlbumEntity"."ownerId" = $2 AND "AlbumEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" WHERE "AlbumEntity"."id" IN ($1, $2) AND "AlbumEntity"."ownerId" = $3 AND "AlbumEntity"."deletedAt" IS NULL ``` * Shared link access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "shared_links" "SharedLinkEntity" WHERE "SharedLinkEntity"."id" = $1 AND "SharedLinkEntity"."albumId" = $2 ) LIMIT 1 -- After SELECT "SharedLinkEntity"."albumId" AS "SharedLinkEntity_albumId", "SharedLinkEntity"."id" AS "SharedLinkEntity_id" FROM "shared_links" "SharedLinkEntity" WHERE "SharedLinkEntity"."id" = $1 AND "SharedLinkEntity"."albumId" IN ($2, $3) ``` * Shared album access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE "AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $2 AND "AlbumEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE "AlbumEntity"."id" IN ($1, $2) AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3 AND "AlbumEntity"."deletedAt" IS NULL ``` * chore(server): Add set utils, avoid double queries for same ids * chore(server): Review feedback --- server/src/domain/access/access.core.ts | 78 +++++++++------- server/src/domain/activity/activity.spec.ts | 13 ++- server/src/domain/album/album.service.spec.ts | 91 +++++++++---------- server/src/domain/album/album.service.ts | 3 +- server/src/domain/asset/asset.service.spec.ts | 8 +- server/src/domain/domain.util.ts | 20 ++++ .../domain/repositories/access.repository.ts | 6 +- .../shared-link/shared-link.service.spec.ts | 8 +- .../infra/repositories/access.repository.ts | 72 ++++++++++----- .../repositories/access.repository.mock.ts | 6 +- 10 files changed, 179 insertions(+), 126 deletions(-) diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 30086f5d2..d829139a9 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -1,5 +1,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthUserDto } from '../auth'; +import { setDifference, setUnion } from '../domain.util'; import { IAccessRepository } from '../repositories'; export enum Permission { @@ -99,6 +100,24 @@ export class AccessCore { } private async checkAccessSharedLink(authUser: AuthUserDto, permission: Permission, ids: Set) { + const sharedLinkId = authUser.sharedLinkId; + if (!sharedLinkId) { + return new Set(); + } + + switch (permission) { + case Permission.ASSET_UPLOAD: + return authUser.isAllowUpload ? ids : new Set(); + + case Permission.ALBUM_READ: + return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids); + + case Permission.ALBUM_DOWNLOAD: + return !!authUser.isAllowDownload + ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) + : new Set(); + } + const allowedIds = new Set(); for (const id of ids) { const hasAccess = await this.hasSharedLinkAccess(authUser, permission, id); @@ -126,25 +145,42 @@ export class AccessCore { case Permission.ASSET_DOWNLOAD: return !!authUser.isAllowDownload && (await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id)); - case Permission.ASSET_UPLOAD: - return authUser.isAllowUpload; - case Permission.ASSET_SHARE: // TODO: fix this to not use authUser.id for shared link access control return this.repository.asset.hasOwnerAccess(authUser.id, id); - case Permission.ALBUM_READ: - return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); - - case Permission.ALBUM_DOWNLOAD: - return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); - default: return false; } } private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set) { + switch (permission) { + case Permission.ALBUM_READ: { + const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids); + const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_UPDATE: + return this.repository.album.checkOwnerAccess(authUser.id, ids); + + case Permission.ALBUM_DELETE: + return this.repository.album.checkOwnerAccess(authUser.id, ids); + + case Permission.ALBUM_SHARE: + return this.repository.album.checkOwnerAccess(authUser.id, ids); + + case Permission.ALBUM_DOWNLOAD: { + const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids); + const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_REMOVE_ASSET: + return this.repository.album.checkOwnerAccess(authUser.id, ids); + } + const allowedIds = new Set(); for (const id of ids) { const hasAccess = await this.hasOtherAccess(authUser, permission, id); @@ -204,33 +240,9 @@ export class AccessCore { (await this.repository.asset.hasPartnerAccess(authUser.id, id)) ); - case Permission.ALBUM_READ: - return ( - (await this.repository.album.hasOwnerAccess(authUser.id, id)) || - (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) - ); - - case Permission.ALBUM_UPDATE: - return this.repository.album.hasOwnerAccess(authUser.id, id); - - case Permission.ALBUM_DELETE: - return this.repository.album.hasOwnerAccess(authUser.id, id); - - case Permission.ALBUM_SHARE: - return this.repository.album.hasOwnerAccess(authUser.id, id); - - case Permission.ALBUM_DOWNLOAD: - return ( - (await this.repository.album.hasOwnerAccess(authUser.id, id)) || - (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) - ); - case Permission.ASSET_UPLOAD: return this.repository.library.hasOwnerAccess(authUser.id, id); - case Permission.ALBUM_REMOVE_ASSET: - return this.repository.album.hasOwnerAccess(authUser.id, id); - case Permission.ARCHIVE_READ: return authUser.id === id; diff --git a/server/src/domain/activity/activity.spec.ts b/server/src/domain/activity/activity.spec.ts index 496d8978b..659718bed 100644 --- a/server/src/domain/activity/activity.spec.ts +++ b/server/src/domain/activity/activity.spec.ts @@ -24,7 +24,7 @@ describe(ActivityService.name, () => { describe('getAll', () => { it('should get all', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); activityMock.search.mockResolvedValue([]); await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]); @@ -37,7 +37,7 @@ describe(ActivityService.name, () => { }); it('should filter by type=like', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); activityMock.search.mockResolvedValue([]); await expect( @@ -52,7 +52,7 @@ describe(ActivityService.name, () => { }); it('should filter by type=comment', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); activityMock.search.mockResolvedValue([]); await expect( @@ -70,7 +70,7 @@ describe(ActivityService.name, () => { describe('getStatistics', () => { it('should get the comment count', async () => { activityMock.getStatistics.mockResolvedValue(1); - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId])); await expect( sut.getStatistics(authStub.admin, { assetId: 'asset-id', @@ -82,7 +82,6 @@ describe(ActivityService.name, () => { describe('addComment', () => { it('should require access to the album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); await expect( sut.create(authStub.admin, { albumId: 'album-id', @@ -114,7 +113,7 @@ describe(ActivityService.name, () => { }); it('should fail because activity is disabled for the album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.activity.hasCreateAccess.mockResolvedValue(false); activityMock.create.mockResolvedValue(activityStub.oneComment); @@ -148,7 +147,7 @@ describe(ActivityService.name, () => { }); it('should skip if like exists', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); accessMock.activity.hasCreateAccess.mockResolvedValue(true); activityMock.search.mockResolvedValue([activityStub.liked]); diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index a93cb0ad1..414826cb2 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -204,7 +204,6 @@ describe(AlbumService.name, () => { }); it('should prevent updating a not owned album (shared with auth user)', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); await expect( sut.update(authStub.admin, albumStub.sharedWithAdmin.id, { albumName: 'new album name', @@ -213,7 +212,7 @@ describe(AlbumService.name, () => { }); it('should require a valid thumbnail asset id', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset); albumMock.hasAsset.mockResolvedValue(false); @@ -229,7 +228,7 @@ describe(AlbumService.name, () => { }); it('should allow the owner to update the album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4'])); albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset); @@ -252,7 +251,7 @@ describe(AlbumService.name, () => { describe('delete', () => { it('should throw an error for an album not found', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); albumMock.getById.mockResolvedValue(null); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( @@ -263,7 +262,6 @@ describe(AlbumService.name, () => { }); it('should not let a shared user delete the album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( @@ -274,7 +272,7 @@ describe(AlbumService.name, () => { }); it('should let the owner delete an album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id])); albumMock.getById.mockResolvedValue(albumStub.empty); await sut.delete(authStub.admin, albumStub.empty.id); @@ -286,7 +284,6 @@ describe(AlbumService.name, () => { describe('addUsers', () => { it('should throw an error if the auth user is not the owner', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); await expect( sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }), ).rejects.toBeInstanceOf(BadRequestException); @@ -294,7 +291,7 @@ describe(AlbumService.name, () => { }); it('should throw an error if the userId is already added', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }), @@ -303,7 +300,7 @@ describe(AlbumService.name, () => { }); it('should throw an error if the userId does not exist', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(null); await expect( @@ -313,7 +310,7 @@ describe(AlbumService.name, () => { }); it('should add valid shared users', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(userStub.user2); @@ -328,14 +325,14 @@ describe(AlbumService.name, () => { describe('removeUser', () => { it('should require a valid album id', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); albumMock.getById.mockResolvedValue(null); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); expect(albumMock.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id])); albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); await expect( @@ -352,7 +349,6 @@ describe(AlbumService.name, () => { }); it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple); await expect( @@ -360,7 +356,10 @@ describe(AlbumService.name, () => { ).rejects.toBeInstanceOf(BadRequestException); expect(albumMock.update).not.toHaveBeenCalled(); - expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, albumStub.sharedWithMultiple.id); + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + authStub.user1.id, + new Set([albumStub.sharedWithMultiple.id]), + ); }); it('should allow a shared user to remove themselves', async () => { @@ -413,51 +412,51 @@ describe(AlbumService.name, () => { describe('getAlbumInfo', () => { it('should get a shared album', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); await sut.get(authStub.admin, albumStub.oneAsset.id, {}); expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); - expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id); + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.id, + new Set([albumStub.oneAsset.id]), + ); }); it('should get a shared album via a shared link', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); + accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); await sut.get(authStub.adminSharedLink, 'album-123', {}); expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( + expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLinkId, - 'album-123', + new Set(['album-123']), ); }); it('should get a shared album via shared with user', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); - accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); await sut.get(authStub.user1, 'album-123', {}); expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); - expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123'); + expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123'])); }); it('should throw an error for no access', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); - accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); - await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); - expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-123'])); + expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-123'])); }); }); describe('addAssets', () => { it('should allow the owner to add assets', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -482,7 +481,7 @@ describe(AlbumService.name, () => { }); it('should not set the thumbnail if the album has one already', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -500,8 +499,7 @@ describe(AlbumService.name, () => { }); it('should allow a shared user to add assets', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); - accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -526,9 +524,7 @@ describe(AlbumService.name, () => { }); it('should allow a shared link user to add assets', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); - accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); - accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); + accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -551,14 +547,14 @@ describe(AlbumService.name, () => { assetIds: ['asset-1', 'asset-2', 'asset-3'], }); - expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( + expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLinkId, - 'album-123', + new Set(['album-123']), ); }); it('should allow adding assets shared via partner sharing', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.hasPartnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); @@ -577,7 +573,7 @@ describe(AlbumService.name, () => { }); it('should skip duplicate assets', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); @@ -590,7 +586,7 @@ describe(AlbumService.name, () => { }); it('should skip assets not shared with user', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.hasPartnerAccess.mockResolvedValue(false); albumMock.getById.mockResolvedValue(albumStub.oneAsset); @@ -605,33 +601,31 @@ describe(AlbumService.name, () => { }); it('should not allow unauthorized access to the album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); - accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); albumMock.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.hasOwnerAccess).toHaveBeenCalled(); - expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalled(); + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalled(); + expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalled(); }); it('should not allow unauthorized shared link access to the album', async () => { - accessMock.album.hasSharedLinkAccess.mockResolvedValue(false); albumMock.getById.mockResolvedValue(albumStub.oneAsset); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), ).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalled(); + expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalled(); }); }); describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123'])); + accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); @@ -644,7 +638,7 @@ describe(AlbumService.name, () => { }); it('should skip assets not in the album', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); albumMock.getAssetIds.mockResolvedValueOnce(new Set()); @@ -656,7 +650,7 @@ describe(AlbumService.name, () => { }); it('should skip assets without user permission to remove', async () => { - accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); @@ -672,7 +666,8 @@ describe(AlbumService.name, () => { }); it('should reset the thumbnail if it is removed', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123'])); + accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 986cdb891..0d92dae04 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { AccessCore, Permission } from '../access'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from '../asset'; import { AuthUserDto } from '../auth'; +import { setUnion } from '../domain.util'; import { JobName } from '../job'; import { AlbumInfoOptions, @@ -194,7 +195,7 @@ export class AlbumService { const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids); const canRemove = await this.access.checkAccess(authUser, Permission.ALBUM_REMOVE_ASSET, existingAssetIds); const canShare = await this.access.checkAccess(authUser, Permission.ASSET_SHARE, existingAssetIds); - const allowedAssetIds = new Set([...canRemove, ...canShare]); + const allowedAssetIds = setUnion(canRemove, canShare); const results: BulkIdResponseDto[] = []; for (const assetId of dto.ids) { diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index f91c942f8..0baddd953 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -347,14 +347,14 @@ describe(AssetService.name, () => { describe('getTimeBucket', () => { it('should return the assets for a album time bucket if user has album.read', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); await expect( sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-id'); + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-id'])); expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', @@ -546,7 +546,7 @@ describe(AssetService.name, () => { }); it('should return a list of archives (albumId)', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); assetMock.getByAlbumId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, @@ -554,7 +554,7 @@ describe(AssetService.name, () => { await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); - expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-1'); + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-1'])); expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); }); diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 53e674bf3..00ad27bc7 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -150,3 +150,23 @@ export function Optional({ nullable, ...validationOptions }: OptionalOptions = { return ValidateIf((obj: any, v: any) => v !== undefined, validationOptions); } + +// NOTE: The following Set utils have been added here, to easily determine where they are used. +// They should be replaced with native Set operations, when they are added to the language. +// Proposal reference: https://github.com/tc39/proposal-set-methods + +export const setUnion = (setA: Set, setB: Set): Set => { + const union = new Set(setA); + for (const elem of setB) { + union.add(elem); + } + return union; +}; + +export const setDifference = (setA: Set, setB: Set): Set => { + const difference = new Set(setA); + for (const elem of setB) { + difference.delete(elem); + } + return difference; +}; diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 9c009719d..e1ea27ce6 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -18,9 +18,9 @@ export interface IAccessRepository { }; album: { - hasOwnerAccess(userId: string, albumId: string): Promise; - hasSharedAlbumAccess(userId: string, albumId: string): Promise; - hasSharedLinkAccess(sharedLinkId: string, albumId: string): Promise; + checkOwnerAccess(userId: string, albumIds: Set): Promise>; + checkSharedAlbumAccess(userId: string, albumIds: Set): Promise>; + checkSharedLinkAccess(sharedLinkId: string, albumIds: Set): Promise>; }; library: { diff --git a/server/src/domain/shared-link/shared-link.service.spec.ts b/server/src/domain/shared-link/shared-link.service.spec.ts index 863e3a353..abf8128c4 100644 --- a/server/src/domain/shared-link/shared-link.service.spec.ts +++ b/server/src/domain/shared-link/shared-link.service.spec.ts @@ -97,7 +97,6 @@ describe(SharedLinkService.name, () => { }); it('should not allow non-owners to create album shared links', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(false); await expect( sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }), ).rejects.toBeInstanceOf(BadRequestException); @@ -117,12 +116,15 @@ describe(SharedLinkService.name, () => { }); it('should create an album shared link', async () => { - accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); shareMock.create.mockResolvedValue(sharedLinkStub.valid); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); - expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id); + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.id, + new Set([albumStub.oneAsset.id]), + ); expect(shareMock.create).toHaveBeenCalledWith({ type: SharedLinkType.ALBUM, userId: authStub.admin.id, diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index fa5862885..fb0b865fb 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -1,6 +1,6 @@ import { IAccessRepository } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { ActivityEntity, AlbumEntity, @@ -209,33 +209,57 @@ export class AccessRepository implements IAccessRepository { }; album = { - hasOwnerAccess: (userId: string, albumId: string): Promise => { - return this.albumRepository.exist({ - where: { - id: albumId, - ownerId: userId, - }, - }); - }, + checkOwnerAccess: async (userId: string, albumIds: Set): Promise> => { + if (albumIds.size === 0) { + return new Set(); + } - hasSharedAlbumAccess: (userId: string, albumId: string): Promise => { - return this.albumRepository.exist({ - where: { - id: albumId, - sharedUsers: { - id: userId, + return this.albumRepository + .find({ + select: { id: true }, + where: { + id: In([...albumIds]), + ownerId: userId, }, - }, - }); + }) + .then((albums) => new Set(albums.map((album) => album.id))); }, - hasSharedLinkAccess: (sharedLinkId: string, albumId: string): Promise => { - return this.sharedLinkRepository.exist({ - where: { - id: sharedLinkId, - albumId, - }, - }); + checkSharedAlbumAccess: async (userId: string, albumIds: Set): Promise> => { + if (albumIds.size === 0) { + return new Set(); + } + + return this.albumRepository + .find({ + select: { id: true }, + where: { + id: In([...albumIds]), + sharedUsers: { + id: userId, + }, + }, + }) + .then((albums) => new Set(albums.map((album) => album.id))); + }, + + checkSharedLinkAccess: async (sharedLinkId: string, albumIds: Set): Promise> => { + if (albumIds.size === 0) { + return new Set(); + } + + return this.sharedLinkRepository + .find({ + select: { albumId: true }, + where: { + id: sharedLinkId, + albumId: In([...albumIds]), + }, + }) + .then( + (sharedLinks) => + new Set(sharedLinks.flatMap((sharedLink) => (!!sharedLink.albumId ? [sharedLink.albumId] : []))), + ); }, }; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 8f1e9355d..eceb25812 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -30,9 +30,9 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => }, album: { - hasOwnerAccess: jest.fn(), - hasSharedAlbumAccess: jest.fn(), - hasSharedLinkAccess: jest.fn(), + checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), + checkSharedAlbumAccess: jest.fn().mockResolvedValue(new Set()), + checkSharedLinkAccess: jest.fn().mockResolvedValue(new Set()), }, authDevice: { From 69d096df17ccf07ad13b71a49a8932cf0c3ac2f8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Nov 2023 18:42:58 -0500 Subject: [PATCH 11/60] chore(deps): update server (#5257) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 94 +++++++++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 917e64374..c1c4bf476 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2729,12 +2729,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@testcontainers/postgresql": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.2.2.tgz", - "integrity": "sha512-G1xJKe8omeNzngK0dj4R2cSYxWyOUdTXD/oBA03AqIwdReq/gi4WjT6CJqGbkqQy9opXZV6ug3gHMja+wM5BCA==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.3.2.tgz", + "integrity": "sha512-PhbmhWe+M5RF9QZGOzg/Vc+Ve6KlT/j9OLv9G11xubvcdhbuAz9Am3u1GjsXS7C1Vt/rwWx+j0kg+FtYwbJQng==", "dev": true, "dependencies": { - "testcontainers": "^10.2.2" + "testcontainers": "^10.3.2" } }, "node_modules/@tsconfig/node10": { @@ -2908,6 +2908,26 @@ "cron": "*" } }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "3.3.23", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.23.tgz", + "integrity": "sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw==", + "dev": true, + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", @@ -3092,9 +3112,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.3.tgz", - "integrity": "sha512-nk5wXLAXGBKfrhLB0cyHGbSqopS+nz0BUgZkUQqSHSSgdee0kssp1IAqlQOu333bW+gMNs2QREx7iynm19Abxw==", + "version": "20.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", + "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", "dependencies": { "undici-types": "~5.26.4" } @@ -4327,9 +4347,9 @@ } }, "node_modules/bullmq": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.0.tgz", - "integrity": "sha512-2IjTzXfTkXQ+WNRy2/CVupnHJtqp6JpxacIvYbru2EvporUALnIcpiSpjJbk4V6kAbsYvrV2wRdUKllb+LfssQ==", + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.2.tgz", + "integrity": "sha512-lzK4F6H61oH5S3Mg4JP4rnSxpQx00Qq7KQKt1oWjcQarka7TdN50CDsZGXg9z6kzvu26Pd3aiwTxwr4YvcEFgw==", "dependencies": { "cron-parser": "^4.6.0", "glob": "^8.0.3", @@ -11261,12 +11281,13 @@ } }, "node_modules/testcontainers": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.2.2.tgz", - "integrity": "sha512-5GZ93rtoVXMx/s3xZjydftrKLnv1Yf+ETzGkXYRCm16LB60W48SGodxuiouYvNlVy0y0ogoQhdOw3DqsPActEA==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.3.2.tgz", + "integrity": "sha512-IsV6NgS5reHcVF1nJLCDJv1hM9gAWUhLwh9b3ybgzvM3X7T2dcmuLFKt1RAR8qN8k+44tW2Drj7idxW6oeGvvg==", "dev": true, "dependencies": { "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^3.3.21", "archiver": "^5.3.2", "async-lock": "^1.4.0", "byline": "^5.0.0", @@ -14603,12 +14624,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@testcontainers/postgresql": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.2.2.tgz", - "integrity": "sha512-G1xJKe8omeNzngK0dj4R2cSYxWyOUdTXD/oBA03AqIwdReq/gi4WjT6CJqGbkqQy9opXZV6ug3gHMja+wM5BCA==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.3.2.tgz", + "integrity": "sha512-PhbmhWe+M5RF9QZGOzg/Vc+Ve6KlT/j9OLv9G11xubvcdhbuAz9Am3u1GjsXS7C1Vt/rwWx+j0kg+FtYwbJQng==", "dev": true, "requires": { - "testcontainers": "^10.2.2" + "testcontainers": "^10.3.2" } }, "@tsconfig/node10": { @@ -14772,6 +14793,26 @@ "cron": "*" } }, + "@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "@types/dockerode": { + "version": "3.3.23", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.23.tgz", + "integrity": "sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw==", + "dev": true, + "requires": { + "@types/docker-modem": "*", + "@types/node": "*" + } + }, "@types/eslint": { "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", @@ -14956,9 +14997,9 @@ "dev": true }, "@types/node": { - "version": "20.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.3.tgz", - "integrity": "sha512-nk5wXLAXGBKfrhLB0cyHGbSqopS+nz0BUgZkUQqSHSSgdee0kssp1IAqlQOu333bW+gMNs2QREx7iynm19Abxw==", + "version": "20.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", + "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", "requires": { "undici-types": "~5.26.4" } @@ -15915,9 +15956,9 @@ "optional": true }, "bullmq": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.0.tgz", - "integrity": "sha512-2IjTzXfTkXQ+WNRy2/CVupnHJtqp6JpxacIvYbru2EvporUALnIcpiSpjJbk4V6kAbsYvrV2wRdUKllb+LfssQ==", + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.2.tgz", + "integrity": "sha512-lzK4F6H61oH5S3Mg4JP4rnSxpQx00Qq7KQKt1oWjcQarka7TdN50CDsZGXg9z6kzvu26Pd3aiwTxwr4YvcEFgw==", "requires": { "cron-parser": "^4.6.0", "glob": "^8.0.3", @@ -21056,12 +21097,13 @@ } }, "testcontainers": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.2.2.tgz", - "integrity": "sha512-5GZ93rtoVXMx/s3xZjydftrKLnv1Yf+ETzGkXYRCm16LB60W48SGodxuiouYvNlVy0y0ogoQhdOw3DqsPActEA==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.3.2.tgz", + "integrity": "sha512-IsV6NgS5reHcVF1nJLCDJv1hM9gAWUhLwh9b3ybgzvM3X7T2dcmuLFKt1RAR8qN8k+44tW2Drj7idxW6oeGvvg==", "dev": true, "requires": { "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^3.3.21", "archiver": "^5.3.2", "async-lock": "^1.4.0", "byline": "^5.0.0", From 1ffe862810edfaffc56374436cb2b2a2c9ba83b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Nov 2023 23:48:48 +0000 Subject: [PATCH 12/60] chore(deps): update server (#5311) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 2 +- server/package-lock.json | 14 +++++++------- server/package.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 5f2b79634..05332e3b0 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -13,7 +13,7 @@ RUN npm run build RUN npm prune --omit=dev --omit=optional # web build -FROM node:20.9-alpine3.18 as web +FROM node:20.10-alpine3.18 as web WORKDIR /usr/src/app COPY web/package.json web/package-lock.json ./ diff --git a/server/package-lock.json b/server/package-lock.json index c1c4bf476..117b811bc 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -70,7 +70,7 @@ "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", "@types/imagemin": "^8.0.1", - "@types/jest": "29.5.9", + "@types/jest": "29.5.10", "@types/jest-when": "^3.5.2", "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", @@ -3046,9 +3046,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.9", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.9.tgz", - "integrity": "sha512-zJeWhqBwVoPm83sP8h1/SVntwWTu5lZbKQGCvBjxQOyEWnKnsaomt2y7SlV4KfwlrHAHHAn00Sh4IAWaIsGOgQ==", + "version": "29.5.10", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.10.tgz", + "integrity": "sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -14931,9 +14931,9 @@ } }, "@types/jest": { - "version": "29.5.9", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.9.tgz", - "integrity": "sha512-zJeWhqBwVoPm83sP8h1/SVntwWTu5lZbKQGCvBjxQOyEWnKnsaomt2y7SlV4KfwlrHAHHAn00Sh4IAWaIsGOgQ==", + "version": "29.5.10", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.10.tgz", + "integrity": "sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==", "dev": true, "requires": { "expect": "^29.0.0", diff --git a/server/package.json b/server/package.json index 21e19ee59..c10d3bcd7 100644 --- a/server/package.json +++ b/server/package.json @@ -96,7 +96,7 @@ "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", "@types/imagemin": "^8.0.1", - "@types/jest": "29.5.9", + "@types/jest": "29.5.10", "@types/jest-when": "^3.5.2", "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", From ad06502539057720f5cb0506754fddf7d48e9673 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Nov 2023 23:54:34 +0000 Subject: [PATCH 13/60] chore(deps): update dependency @types/node to v20.10.0 (#5313) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 4547cfc3c..76217c4d8 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1604,9 +1604,9 @@ } }, "node_modules/@types/node": { - "version": "20.9.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.4.tgz", - "integrity": "sha512-wmyg8HUhcn6ACjsn8oKYjkN/zUzQeNtMy44weTJSM6p4MMzEOuKbA3OjJ267uPCOW7Xex9dyrNTful8XTQYoDA==", + "version": "20.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", + "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -7710,9 +7710,9 @@ } }, "@types/node": { - "version": "20.9.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.4.tgz", - "integrity": "sha512-wmyg8HUhcn6ACjsn8oKYjkN/zUzQeNtMy44weTJSM6p4MMzEOuKbA3OjJ267uPCOW7Xex9dyrNTful8XTQYoDA==", + "version": "20.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", + "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", "dev": true, "requires": { "undici-types": "~5.26.4" From e65d1d59308affabb0cc9852a628652212f20e48 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Sun, 26 Nov 2023 03:45:18 +0000 Subject: [PATCH 14/60] refactor: mobile - send livephoto as a separate request (#5275) * refactor: mobile - send livephoto as a separate request * fix: create new request for live asset --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../backup/services/backup.service.dart | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 0e0a14a8c..f4ca5932a 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -278,13 +278,6 @@ class BackupService { req.files.add(assetRawUploadData); - if (entity.isLivePhoto) { - var livePhotoRawUploadData = await _getLivePhotoFile(entity); - if (livePhotoRawUploadData != null) { - req.files.add(livePhotoRawUploadData); - } - } - setCurrentUploadAssetCb( CurrentUploadAsset( id: entity.id, @@ -299,6 +292,29 @@ class BackupService { var response = await httpClient.send(req, cancellationToken: cancelToken); + // Send live photo separately + if (entity.isLivePhoto) { + var livePhotoRawUploadData = await _getLivePhotoFile(entity); + if (livePhotoRawUploadData != null) { + var livePhotoReq = MultipartRequest( + req.method, + req.url, + onProgress: req.onProgress, + ) + ..headers.addAll(req.headers) + ..fields.addAll(req.fields); + + livePhotoReq.files.add(livePhotoRawUploadData); + // Send live photo only if the non-motion part is successful + if (response.statusCode == 200 || response.statusCode == 201) { + response = await httpClient.send( + livePhotoReq, + cancellationToken: cancelToken, + ); + } + } + } + if (response.statusCode == 200) { // asset is a duplicate (already exists on the server) duplicatedAssetIds.add(entity.id); @@ -356,7 +372,7 @@ class BackupService { var fileStream = motionFile.openRead(); String fileName = p.basename(motionFile.path); return http.MultipartFile( - "livePhotoData", + "assetData", fileStream, motionFile.lengthSync(), filename: fileName, From cf58649a991dd5743db2a24283fc1c48cf4cd7e5 Mon Sep 17 00:00:00 2001 From: Romon Wafa <33097252+romonwafa@users.noreply.github.com> Date: Sun, 26 Nov 2023 01:12:51 -0500 Subject: [PATCH 15/60] Change wording (#5312) --- mobile/assets/i18n/ca.json | 2 +- mobile/assets/i18n/da-DK.json | 2 +- mobile/assets/i18n/en-US.json | 2 +- mobile/assets/i18n/hi-IN.json | 2 +- mobile/assets/i18n/hu-HU.json | 2 +- mobile/assets/i18n/it-IT.json | 2 +- mobile/assets/i18n/lv-LV.json | 2 +- mobile/assets/i18n/mn.json | 2 +- mobile/assets/i18n/nl-NL.json | 2 +- mobile/assets/i18n/sr-Cyrl.json | 2 +- mobile/assets/i18n/sr-Latn.json | 2 +- mobile/assets/i18n/sv-FI.json | 2 +- mobile/assets/i18n/sv-SE.json | 2 +- mobile/assets/i18n/th-TH.json | 2 +- mobile/assets/i18n/uk-UA.json | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/mobile/assets/i18n/ca.json b/mobile/assets/i18n/ca.json index 51394751e..36aad1957 100644 --- a/mobile/assets/i18n/ca.json +++ b/mobile/assets/i18n/ca.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Afegeix usuaris", "all_people_page_title": "Persones", "all_videos_page_title": "Vídeos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No s'ha trobat res arxivat", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index fc12cc49c..b5b7c7e45 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Tilføj brugere", "all_people_page_title": "Personer", "all_videos_page_title": "Videoer", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "Ingen arkiverede elementer blev fundet", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index f41917e39..6d28890eb 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -28,7 +28,7 @@ "album_viewer_page_share_add_users": "Add users", "all_people_page_title": "People", "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No archived assets found", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index a2ec8a49b..9c42afb7d 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Add users", "all_people_page_title": "People", "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No archived assets found", diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index 0b9ed145f..60bf00d99 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Felhasználók hozzáadása", "all_people_page_title": "Emberek", "all_videos_page_title": "Videók", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "Nem található archivált média", diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index 8c5f10e4a..d8a372527 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Aggiungi utenti", "all_people_page_title": "Persone", "all_videos_page_title": "Video", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "Nessuna oggetto archiviato", diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index 1d7c9f55a..875044c29 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Pievienot lietotājus", "all_people_page_title": "Cilvēki", "all_videos_page_title": "Videoklipi", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "Nav atrasts neviens arhivēts aktīvs", diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn.json index 87a943709..dfbb54d89 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Add users", "all_people_page_title": "People", "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No archived assets found", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index a91a7e053..eece8bd40 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Gebruikers toevoegen", "all_people_page_title": "Personen", "all_videos_page_title": "Video's", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "Geen gearchiveerde items gevonden", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index a2ec8a49b..9c42afb7d 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Add users", "all_people_page_title": "People", "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No archived assets found", diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 2cdc37acf..9588a2ede 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Dodaj korisnike", "all_people_page_title": "People", "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No archived assets found", diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index a2ec8a49b..9c42afb7d 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Add users", "all_people_page_title": "People", "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No archived assets found", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index c308160a9..026219fe5 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Lägg till användare", "all_people_page_title": "People", "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "No archived assets found", diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index eb74cb43f..0cfad7eff 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "เพิ่มผู้ใช้งาน", "all_people_page_title": "ผู้คน", "all_videos_page_title": "วิดีโอ", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "ไม่พบทรัพยากรในที่เก็บถาวร", diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index 00031ded6..d3df92692 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -27,7 +27,7 @@ "album_viewer_page_share_add_users": "Додати користувачів", "all_people_page_title": "Люди", "all_videos_page_title": "Відео", - "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", "archive_page_no_archived_assets": "Немає архівних елементів", From f97dca77070352796a0d111cc17f3490bf6ba34e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 26 Nov 2023 00:14:06 -0600 Subject: [PATCH 16/60] chore(deps): update web (#5239) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/Dockerfile | 2 +- web/package-lock.json | 168 ++++++++++++++++++------------------------ web/package.json | 2 +- 3 files changed, 74 insertions(+), 98 deletions(-) diff --git a/web/Dockerfile b/web/Dockerfile index 544bce8fb..33555c87f 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.9-alpine3.18 +FROM node:20.10-alpine3.18 WORKDIR /usr/src/app COPY --chown=node:node package*.json ./ diff --git a/web/package-lock.json b/web/package-lock.json index 67359aef7..a6a984fa1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@egjs/svelte-view360": "^4.0.0-beta.7", "@mdi/js": "^7.3.67", - "@zoom-image/svelte": "^0.1.8", + "@zoom-image/svelte": "^0.2.0", "axios": "^0.27.2", "buffer": "^6.0.3", "copy-image-clipboard": "^2.1.2", @@ -1993,9 +1993,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz", - "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz", + "integrity": "sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3044,9 +3044,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "1.27.5", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.27.5.tgz", - "integrity": "sha512-+L1WPs/ZYNjXoBFoFARypD4aZOjkT51vFpRCtQI45+Fmmfi4Y0dH/8VFlmYD6VlGe89ViIPg7lgf/JpGQ2tr7A==", + "version": "1.27.6", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.27.6.tgz", + "integrity": "sha512-GsjTkMbKzXdbeRg0tk8S7HNShQ4879ftRr0ZHaZfjbig1xQwG57Bvcm9U9/mpLJtCapLbLWUnygKrgcLISLC8A==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -3538,9 +3538,9 @@ "dev": true }, "node_modules/@types/justified-layout": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@types/justified-layout/-/justified-layout-4.1.3.tgz", - "integrity": "sha512-Ph0kv9PuAIM+rQo8vyqool1ss1Kc894umFREsSM5hrTuPzCppnHBOvMyFtXozLyhelHBiN88QB7XBbYklHVz2g==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/justified-layout/-/justified-layout-4.1.4.tgz", + "integrity": "sha512-q2ybP0u0NVj87oMnGZOGxY2iUN8ddr48zPOBHBdbOLpsMTA/keGj+93ou+OMCnJk0xewzlNIaVEkxM6VBD3E2w==", "dev": true }, "node_modules/@types/lodash": { @@ -3550,18 +3550,18 @@ "dev": true }, "node_modules/@types/lodash-es": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.11.tgz", - "integrity": "sha512-eCw8FYAWHt2DDl77s+AMLLzPn310LKohruumpucZI4oOFJkIgnlaJcy23OKMJxx4r9PeTF13Gv6w+jqjWQaYUg==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "dev": true, "dependencies": { "@types/lodash": "*" } }, "node_modules/@types/luxon": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.4.tgz", - "integrity": "sha512-H9OXxv4EzJwE75aTPKpiGXJq+y4LFxjpsdgKwSmr503P5DkWc3AG7VAFYrFNVvqemT5DfgZJV9itYhqBHSGujA==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.5.tgz", + "integrity": "sha512-1cyf6Ge/94zlaWIZA2ei1pE6SZ8xpad2hXaYa5JEFiaUH0YS494CZwyi4MXNpXD9oEuv6ZH0Bmh0e7F9sPhmZA==", "dev": true }, "node_modules/@types/mapbox__point-geometry": { @@ -3936,9 +3936,9 @@ "dev": true }, "node_modules/@zoom-image/core": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.28.0.tgz", - "integrity": "sha512-+tqhet1Ev/1QcJtKSNYgYJLgPIyQ5BZjbcMZu6bW8o2/ak+UQ1L4UeVyc2Q0Ivro1ltLYBe5ywLRgwzqhyAgkQ==", + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.31.0.tgz", + "integrity": "sha512-lvFVfIe/CSASXVq1E2vWnt/inXqrBMgjW96lW/l1JdM9EaCj5yis6YXPL5z+Rz2WHmMg5bb7Ps6w1Gzs/bC8LQ==", "dependencies": { "@namnode/store": "^0.1.0" }, @@ -3948,11 +3948,11 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.1.21.tgz", - "integrity": "sha512-a7Spta7WD1e94InjnWxyqAURBnq24fkEDiLN6sz0BuedxDYaT+kmdhHueXsrNEl7z8RSwXsdC7xABAmV+pEb0w==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.1.tgz", + "integrity": "sha512-UGOFsXJN5Sk/uJxp7ZMajedXusmdmQ23nTNgphR4T9Q0Aef4qJJZI5dpGZtMCbGH2kdLbpIm30Sbht9kIe1L1Q==", "dependencies": { - "@zoom-image/core": "0.28.0" + "@zoom-image/core": "0.31.0" }, "funding": { "type": "github", @@ -4767,14 +4767,6 @@ "periscopic": "^3.1.0" } }, - "node_modules/code-red/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -5471,15 +5463,15 @@ } }, "node_modules/eslint": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz", - "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.54.0.tgz", + "integrity": "sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.53.0", + "@eslint/js": "8.54.0", "@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -5550,9 +5542,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.35.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.35.0.tgz", - "integrity": "sha512-3WDFxNrkXaMlpqoNo3M1ZOQuoFLMO9+bdnN6oVVXaydXC7nzCJuGy9a0zqoNDHMSRPYt0Rqo6hIdHMEaI5sQnw==", + "version": "2.35.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.35.1.tgz", + "integrity": "sha512-IF8TpLnROSGy98Z3NrsKXWDSCbNY2ReHDcrYTuXZMbfX7VmESISR78TWgO9zdg4Dht1X8coub5jKwHzP0ExRug==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -5927,6 +5919,14 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6934,6 +6934,14 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, + "node_modules/is-reference": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", + "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -9363,9 +9371,9 @@ } }, "node_modules/luxon": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", - "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", "engines": { "node": ">=12" } @@ -9449,9 +9457,9 @@ } }, "node_modules/maplibre-gl": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-3.6.0.tgz", - "integrity": "sha512-l+jBu+bMy96FOV4em7FgjMH77ewlOtLPXLAem/Q44y4+0vTGsJvPksJSoLoedmikcSff2QN20VZFo3+Zg0UJPQ==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-3.6.2.tgz", + "integrity": "sha512-krg2KFIdOpLPngONDhP6ixCoWl5kbdMINP0moMSJFVX7wX1Clm2M9hlNKXS8vBGlVWwR5R3ZfI6IPrYz7c+aCQ==", "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", @@ -9461,11 +9469,11 @@ "@mapbox/vector-tile": "^1.3.1", "@mapbox/whoots-js": "^3.1.0", "@maplibre/maplibre-gl-style-spec": "^19.3.3", - "@types/geojson": "^7946.0.12", - "@types/mapbox__point-geometry": "^0.1.3", - "@types/mapbox__vector-tile": "^1.3.3", - "@types/pbf": "^3.0.4", - "@types/supercluster": "^7.1.2", + "@types/geojson": "^7946.0.13", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", "earcut": "^2.2.4", "geojson-vt": "^3.2.1", "gl-matrix": "^3.4.3", @@ -9979,22 +9987,6 @@ "is-reference": "^3.0.0" } }, - "node_modules/periscopic/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/periscopic/node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -11192,9 +11184,9 @@ } }, "node_modules/svelte": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.3.tgz", - "integrity": "sha512-sqmG9KC6uUc7fb3ZuWoxXvqk6MI9Uu4ABA1M0fYDgTlFYu1k02xp96u6U9+yJZiVm84m9zge7rrA/BNZdFpOKw==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.7.tgz", + "integrity": "sha512-UExR1KS7raTdycsUrKLtStayu4hpdV3VZQgM0akX8XbXgLBlosdE/Sf3crOgyh9xIjqSYB3UEBuUlIQKRQX2hg==", "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -11215,9 +11207,9 @@ } }, "node_modules/svelte-check": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.0.tgz", - "integrity": "sha512-8VfqhfuRJ1sKW+o8isH2kPi0RhjXH1nNsIbCFGyoUHG+ZxVxHYRKcb+S8eaL/1tyj3VGvWYx3Y5+oCUsJgnzcw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.2.tgz", + "integrity": "sha512-E6iFh4aUCGJLRz6QZXH3gcN/VFfkzwtruWSRmlKrLWQTiO6VzLsivR6q02WYLGNAGecV3EocqZuCDrC2uttZ0g==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", @@ -11331,9 +11323,9 @@ } }, "node_modules/svelte-maplibre": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.7.0.tgz", - "integrity": "sha512-8Mm3MEr0mCq4en5ZmuemCxnv82ljd4mNzTt/pC+X3CTKEcfoVyJgr2PaDu8Znu3DOxUR378XBlG1z5Dw3amnvA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.7.3.tgz", + "integrity": "sha512-mF/wAHQKqrutC6NxnEBDWfszfcQiYusyyE5ulbRVuwWayC0ZTm9lkm376nmNfgruAJOe0QzPx4Mdxa7c2JlGLA==", "dependencies": { "d3-geo": "^3.1.0", "just-compare": "^2.3.0", @@ -11360,9 +11352,9 @@ } }, "node_modules/svelte-preprocess": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.0.tgz", - "integrity": "sha512-EkErPiDzHAc0k2MF5m6vBNmRUh338h2myhinUw/xaqsLs7/ZvsgREiLGj03VrSzbY/TB5ZXgBOsKraFee5yceA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.1.tgz", + "integrity": "sha512-p/Dp4hmrBW5mrCCq29lEMFpIJT2FZsRlouxEc5qpbOmXRbaFs7clLs8oKPwD3xCFyZfv1bIhvOzpQkhMEVQdMw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -11421,22 +11413,6 @@ } } }, - "node_modules/svelte/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/svelte/node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/svelte/node_modules/magic-string": { "version": "0.30.5", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", @@ -11750,9 +11726,9 @@ } }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", + "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/web/package.json b/web/package.json index 79fb3d1ee..418a7da60 100644 --- a/web/package.json +++ b/web/package.json @@ -61,7 +61,7 @@ "dependencies": { "@egjs/svelte-view360": "^4.0.0-beta.7", "@mdi/js": "^7.3.67", - "@zoom-image/svelte": "^0.1.8", + "@zoom-image/svelte": "^0.2.0", "axios": "^0.27.2", "buffer": "^6.0.3", "copy-image-clipboard": "^2.1.2", From c04340c63e23a8cfd32b2e75fd06b2a475940010 Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Sun, 26 Nov 2023 07:50:41 -0500 Subject: [PATCH 17/60] chore(server): Check more permissions in bulk (#5315) Modify Access repository, to evaluate `authDevice`, `library`, `partner`, `person`, and `timeline` permissions in bulk. Queries have been validated to match what they currently generate for single ids. As an extra performance improvement, we now use a custom QueryBuilder for the Partners queries, to avoid the eager relationships that add unneeded `LEFT JOIN` clauses. We only filter based on the ids present in the `partners` table, so those joins can be avoided. Queries: * `library` owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "libraries" "LibraryEntity" WHERE "LibraryEntity"."id" = $1 AND "LibraryEntity"."ownerId" = $2 AND "LibraryEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "LibraryEntity"."id" AS "LibraryEntity_id" FROM "libraries" "LibraryEntity" WHERE "LibraryEntity"."id" IN ($1, $2) AND "LibraryEntity"."ownerId" = $3 AND "LibraryEntity"."deletedAt" IS NULL ``` * `library` partner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "partners" "PartnerEntity" LEFT JOIN "users" "PartnerEntity__sharedBy" ON "PartnerEntity__sharedBy"."id"="PartnerEntity"."sharedById" AND "PartnerEntity__sharedBy"."deletedAt" IS NULL LEFT JOIN "users" "PartnerEntity__sharedWith" ON "PartnerEntity__sharedWith"."id"="PartnerEntity"."sharedWithId" AND "PartnerEntity__sharedWith"."deletedAt" IS NULL WHERE "PartnerEntity"."sharedWithId" = $1 AND "PartnerEntity"."sharedById" = $2 ) LIMIT 1 -- After SELECT "partner"."sharedById" AS "partner_sharedById", "partner"."sharedWithId" AS "partner_sharedWithId" FROM "partners" "partner" WHERE "partner"."sharedById" IN ($1, $2) AND "partner"."sharedWithId" = $3 ``` * `authDevice` owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "user_token" "UserTokenEntity" WHERE "UserTokenEntity"."userId" = $1 AND "UserTokenEntity"."id" = $2 ) LIMIT 1 -- After SELECT "UserTokenEntity"."id" AS "UserTokenEntity_id" FROM "user_token" "UserTokenEntity" WHERE "UserTokenEntity"."userId" = $1 AND "UserTokenEntity"."id" IN ($2, $3) ``` * `timeline` partner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "partners" "PartnerEntity" LEFT JOIN "users" "PartnerEntity__sharedBy" ON "PartnerEntity__sharedBy"."id"="PartnerEntity"."sharedById" AND "PartnerEntity__sharedBy"."deletedAt" IS NULL LEFT JOIN "users" "PartnerEntity__sharedWith" ON "PartnerEntity__sharedWith"."id"="PartnerEntity"."sharedWithId" AND "PartnerEntity__sharedWith"."deletedAt" IS NULL WHERE "PartnerEntity"."sharedWithId" = $1 AND "PartnerEntity"."sharedById" = $2 ) LIMIT 1 -- After SELECT "partner"."sharedById" AS "partner_sharedById", "partner"."sharedWithId" AS "partner_sharedWithId" FROM "partners" "partner" WHERE "partner"."sharedById" IN ($1, $2) AND "partner"."sharedWithId" = $3 ``` * `person` owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "person" "PersonEntity" WHERE "PersonEntity"."id" = $1 AND "PersonEntity"."ownerId" = $2 ) LIMIT 1 -- After SELECT "PersonEntity"."id" AS "PersonEntity_id" FROM "person" "PersonEntity" WHERE "PersonEntity"."id" IN ($1, $2) AND "PersonEntity"."ownerId" = $3 ``` * `partner` update access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "partners" "PartnerEntity" LEFT JOIN "users" "PartnerEntity__sharedBy" ON "PartnerEntity__sharedBy"."id"="PartnerEntity"."sharedById" AND "PartnerEntity__sharedBy"."deletedAt" IS NULL LEFT JOIN "users" "PartnerEntity__sharedWith" ON "PartnerEntity__sharedWith"."id"="PartnerEntity"."sharedWithId" AND "PartnerEntity__sharedWith"."deletedAt" IS NULL WHERE "PartnerEntity"."sharedWithId" = $1 AND "PartnerEntity"."sharedById" = $2 ) LIMIT 1 -- After SELECT "partner"."sharedById" AS "partner_sharedById", "partner"."sharedWithId" AS "partner_sharedWithId" FROM "partners" "partner" WHERE "partner"."sharedById" IN ($1, $2) AND "partner"."sharedWithId" = $3 ``` --- server/src/domain/access/access.core.ts | 81 ++++++------ server/src/domain/asset/asset.service.spec.ts | 4 +- server/src/domain/auth/auth.service.spec.ts | 4 +- .../domain/library/library.service.spec.ts | 2 +- .../src/domain/person/person.service.spec.ts | 98 +++++++------- .../domain/repositories/access.repository.ts | 12 +- .../immich/api-v1/asset/asset.service.spec.ts | 6 +- .../infra/repositories/access.repository.ts | 122 ++++++++++++------ .../repositories/access.repository.mock.ts | 12 +- 9 files changed, 190 insertions(+), 151 deletions(-) diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index d829139a9..c47b2acd2 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -179,6 +179,48 @@ export class AccessCore { case Permission.ALBUM_REMOVE_ASSET: return this.repository.album.checkOwnerAccess(authUser.id, ids); + + case Permission.ASSET_UPLOAD: + return this.repository.library.checkOwnerAccess(authUser.id, ids); + + case Permission.ARCHIVE_READ: + return ids.has(authUser.id) ? new Set([authUser.id]) : new Set(); + + case Permission.AUTH_DEVICE_DELETE: + return this.repository.authDevice.checkOwnerAccess(authUser.id, ids); + + case Permission.TIMELINE_READ: { + const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set(); + const isPartner = await this.repository.timeline.checkPartnerAccess(authUser.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.TIMELINE_DOWNLOAD: + return ids.has(authUser.id) ? new Set([authUser.id]) : new Set(); + + case Permission.LIBRARY_READ: { + const isOwner = await this.repository.library.checkOwnerAccess(authUser.id, ids); + const isPartner = await this.repository.library.checkPartnerAccess(authUser.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.LIBRARY_UPDATE: + return this.repository.library.checkOwnerAccess(authUser.id, ids); + + case Permission.LIBRARY_DELETE: + return this.repository.library.checkOwnerAccess(authUser.id, ids); + + case Permission.PERSON_READ: + return this.repository.person.checkOwnerAccess(authUser.id, ids); + + case Permission.PERSON_WRITE: + return this.repository.person.checkOwnerAccess(authUser.id, ids); + + case Permission.PERSON_MERGE: + return this.repository.person.checkOwnerAccess(authUser.id, ids); + + case Permission.PARTNER_UPDATE: + return this.repository.partner.checkUpdateAccess(authUser.id, ids); } const allowedIds = new Set(); @@ -240,45 +282,6 @@ export class AccessCore { (await this.repository.asset.hasPartnerAccess(authUser.id, id)) ); - case Permission.ASSET_UPLOAD: - return this.repository.library.hasOwnerAccess(authUser.id, id); - - case Permission.ARCHIVE_READ: - return authUser.id === id; - - case Permission.AUTH_DEVICE_DELETE: - return this.repository.authDevice.hasOwnerAccess(authUser.id, id); - - case Permission.TIMELINE_READ: - return authUser.id === id || (await this.repository.timeline.hasPartnerAccess(authUser.id, id)); - - case Permission.TIMELINE_DOWNLOAD: - return authUser.id === id; - - case Permission.LIBRARY_READ: - return ( - (await this.repository.library.hasOwnerAccess(authUser.id, id)) || - (await this.repository.library.hasPartnerAccess(authUser.id, id)) - ); - - case Permission.LIBRARY_UPDATE: - return this.repository.library.hasOwnerAccess(authUser.id, id); - - case Permission.LIBRARY_DELETE: - return this.repository.library.hasOwnerAccess(authUser.id, id); - - case Permission.PERSON_READ: - return this.repository.person.hasOwnerAccess(authUser.id, id); - - case Permission.PERSON_WRITE: - return this.repository.person.hasOwnerAccess(authUser.id, id); - - case Permission.PERSON_MERGE: - return this.repository.person.hasOwnerAccess(authUser.id, id); - - case Permission.PARTNER_UPDATE: - return this.repository.partner.hasUpdateAccess(authUser.id, id); - default: return false; } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 0baddd953..28a138254 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -559,7 +559,7 @@ describe(AssetService.name, () => { }); it('should return a list of archives (userId)', async () => { - accessMock.library.hasOwnerAccess.mockResolvedValue(true); + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id])); assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, @@ -575,7 +575,7 @@ describe(AssetService.name, () => { }); it('should split archives by size', async () => { - accessMock.library.hasOwnerAccess.mockResolvedValue(true); + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id])); assetMock.getByUserId.mockResolvedValue({ items: [ diff --git a/server/src/domain/auth/auth.service.spec.ts b/server/src/domain/auth/auth.service.spec.ts index a815e22d1..7ece7bed8 100644 --- a/server/src/domain/auth/auth.service.spec.ts +++ b/server/src/domain/auth/auth.service.spec.ts @@ -395,11 +395,11 @@ describe('AuthService', () => { describe('logoutDevice', () => { it('should logout the device', async () => { - accessMock.authDevice.hasOwnerAccess.mockResolvedValue(true); + accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); await sut.logoutDevice(authStub.user1, 'token-1'); - expect(accessMock.authDevice.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, 'token-1'); + expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['token-1'])); expect(userTokenMock.delete).toHaveBeenCalledWith('token-1'); }); }); diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 3d7d68736..c7e15e960 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -58,7 +58,7 @@ describe(LibraryService.name, () => { ctime: new Date('2023-01-01'), } as Stats); - accessMock.library.hasOwnerAccess.mockResolvedValue(true); + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id])); sut = new LibraryService( accessMock, diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 3a4ac6b6d..b210a9165 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -183,105 +183,101 @@ describe(PersonService.name, () => { describe('getById', () => { it('should require person.read permission', async () => { personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.hasOwnerAccess.mockResolvedValue(false); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should throw a bad request when person is not found', async () => { personMock.getById.mockResolvedValue(null); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should get a person by id', async () => { personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); expect(personMock.getById).toHaveBeenCalledWith('person-1'); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); describe('getThumbnail', () => { it('should require person.read permission', async () => { personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.hasOwnerAccess.mockResolvedValue(false); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { personMock.getById.mockResolvedValue(null); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should throw an error when person has no thumbnail', async () => { personMock.getById.mockResolvedValue(personStub.noThumbnail); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); expect(storageMock.createReadStream).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should serve the thumbnail', async () => { personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await sut.getThumbnail(authStub.admin, 'person-1'); expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg'); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); describe('getAssets', () => { it('should require person.read permission', async () => { personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); - accessMock.person.hasOwnerAccess.mockResolvedValue(false); await expect(sut.getAssets(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); expect(personMock.getAssets).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it("should return a person's assets", async () => { personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await sut.getAssets(authStub.admin, 'person-1'); expect(personMock.getAssets).toHaveBeenCalledWith('person-1'); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); describe('update', () => { it('should require person.write permission', async () => { personMock.getById.mockResolvedValue(personStub.noName); - accessMock.person.hasOwnerAccess.mockResolvedValue(false); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { personMock.getById.mockResolvedValue(null); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it("should update a person's name", async () => { personMock.getById.mockResolvedValue(personStub.noName); personMock.update.mockResolvedValue(personStub.withName); personMock.getAssets.mockResolvedValue([assetStub.image]); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); @@ -291,14 +287,14 @@ describe(PersonService.name, () => { name: JobName.SEARCH_INDEX_ASSET, data: { ids: [assetStub.image.id] }, }); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { personMock.getById.mockResolvedValue(personStub.noBirthDate); personMock.update.mockResolvedValue(personStub.withBirthDate); personMock.getAssets.mockResolvedValue([assetStub.image]); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ id: 'person-1', @@ -311,14 +307,14 @@ describe(PersonService.name, () => { expect(personMock.getById).toHaveBeenCalledWith('person-1'); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); expect(jobMock.queue).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { personMock.getById.mockResolvedValue(personStub.hidden); personMock.update.mockResolvedValue(personStub.withName); personMock.getAssets.mockResolvedValue([assetStub.image]); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); @@ -328,7 +324,7 @@ describe(PersonService.name, () => { name: JobName.SEARCH_INDEX_ASSET, data: { ids: [assetStub.image.id] }, }); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { @@ -336,7 +332,7 @@ describe(PersonService.name, () => { personMock.update.mockResolvedValue(personStub.withName); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect( sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), @@ -351,31 +347,31 @@ describe(PersonService.name, () => { }, ]); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } }); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { personMock.getById.mockResolvedValue(personStub.withName); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( BadRequestException, ); expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); describe('updateAll', () => { it('should throw an error when personId is invalid', async () => { personMock.getById.mockResolvedValue(null); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect( sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }), ).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]); expect(personMock.update).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); @@ -652,7 +648,6 @@ describe(PersonService.name, () => { personMock.getById.mockResolvedValueOnce(personStub.mergePerson); personMock.prepareReassignFaces.mockResolvedValue([]); personMock.delete.mockResolvedValue(personStub.mergePerson); - accessMock.person.hasOwnerAccess.mockResolvedValue(false); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, @@ -663,7 +658,7 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should merge two people', async () => { @@ -671,7 +666,8 @@ describe(PersonService.name, () => { personMock.getById.mockResolvedValueOnce(personStub.mergePerson); personMock.prepareReassignFaces.mockResolvedValue([]); personMock.delete.mockResolvedValue(personStub.mergePerson); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: true }, @@ -691,14 +687,15 @@ describe(PersonService.name, () => { name: JobName.PERSON_DELETE, data: { id: personStub.mergePerson.id }, }); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should delete conflicting faces before merging', async () => { personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getById.mockResolvedValue(personStub.mergePerson); personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: true }, @@ -713,25 +710,26 @@ describe(PersonService.name, () => { name: JobName.SEARCH_REMOVE_FACE, data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id }, }); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should throw an error when the primary person is not found', async () => { personMock.getById.mockResolvedValue(null); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should handle invalid merge ids', async () => { personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); personMock.getById.mockResolvedValueOnce(null); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, @@ -740,7 +738,7 @@ describe(PersonService.name, () => { expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should handle an error reassigning faces', async () => { @@ -748,14 +746,15 @@ describe(PersonService.name, () => { personMock.getById.mockResolvedValue(personStub.mergePerson); personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); personMock.reassignFaces.mockRejectedValue(new Error('update failed')); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, ]); expect(personMock.delete).not.toHaveBeenCalled(); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); @@ -763,16 +762,15 @@ describe(PersonService.name, () => { it('should get correct number of person', async () => { personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getStatistics.mockResolvedValue(statistics); - accessMock.person.hasOwnerAccess.mockResolvedValue(true); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); it('should require person.read permission', async () => { personMock.getById.mockResolvedValue(personStub.primaryPerson); - accessMock.person.hasOwnerAccess.mockResolvedValue(false); await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); }); diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index e1ea27ce6..7736fd890 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -14,7 +14,7 @@ export interface IAccessRepository { }; authDevice: { - hasOwnerAccess(userId: string, deviceId: string): Promise; + checkOwnerAccess(userId: string, deviceIds: Set): Promise>; }; album: { @@ -24,19 +24,19 @@ export interface IAccessRepository { }; library: { - hasOwnerAccess(userId: string, libraryId: string): Promise; - hasPartnerAccess(userId: string, partnerId: string): Promise; + checkOwnerAccess(userId: string, libraryIds: Set): Promise>; + checkPartnerAccess(userId: string, partnerIds: Set): Promise>; }; timeline: { - hasPartnerAccess(userId: string, partnerId: string): Promise; + checkPartnerAccess(userId: string, partnerIds: Set): Promise>; }; person: { - hasOwnerAccess(userId: string, personId: string): Promise; + checkOwnerAccess(userId: string, personIds: Set): Promise>; }; partner: { - hasUpdateAccess(userId: string, partnerId: string): Promise; + checkUpdateAccess(userId: string, partnerIds: Set): Promise>; }; } diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index cc2102766..11173b55f 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -130,7 +130,7 @@ describe('AssetService', () => { const dto = _getCreateAssetDto(); assetRepositoryMock.create.mockResolvedValue(assetEntity); - accessMock.library.hasOwnerAccess.mockResolvedValue(true); + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!])); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' }); @@ -150,7 +150,7 @@ describe('AssetService', () => { assetRepositoryMock.create.mockRejectedValue(error); assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]); - accessMock.library.hasOwnerAccess.mockResolvedValue(true); + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!])); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' }); @@ -167,7 +167,7 @@ describe('AssetService', () => { assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); - accessMock.library.hasOwnerAccess.mockResolvedValue(true); + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!])); await expect( sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion), diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index fb0b865fb..b23c559a6 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -62,33 +62,52 @@ export class AccessRepository implements IAccessRepository { }); }, }; + library = { - hasOwnerAccess: (userId: string, libraryId: string): Promise => { - return this.libraryRepository.exist({ - where: { - id: libraryId, - ownerId: userId, - }, - }); + checkOwnerAccess: async (userId: string, libraryIds: Set): Promise> => { + if (libraryIds.size === 0) { + return new Set(); + } + + return this.libraryRepository + .find({ + select: { id: true }, + where: { + id: In([...libraryIds]), + ownerId: userId, + }, + }) + .then((libraries) => new Set(libraries.map((library) => library.id))); }, - hasPartnerAccess: (userId: string, partnerId: string): Promise => { - return this.partnerRepository.exist({ - where: { - sharedWithId: userId, - sharedById: partnerId, - }, - }); + + checkPartnerAccess: async (userId: string, partnerIds: Set): Promise> => { + if (partnerIds.size === 0) { + return new Set(); + } + + return this.partnerRepository + .createQueryBuilder('partner') + .select('partner.sharedById') + .where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] }) + .andWhere('partner.sharedWithId = :userId', { userId }) + .getMany() + .then((partners) => new Set(partners.map((partner) => partner.sharedById))); }, }; timeline = { - hasPartnerAccess: (userId: string, partnerId: string): Promise => { - return this.partnerRepository.exist({ - where: { - sharedWithId: userId, - sharedById: partnerId, - }, - }); + checkPartnerAccess: async (userId: string, partnerIds: Set): Promise> => { + if (partnerIds.size === 0) { + return new Set(); + } + + return this.partnerRepository + .createQueryBuilder('partner') + .select('partner.sharedById') + .where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] }) + .andWhere('partner.sharedWithId = :userId', { userId }) + .getMany() + .then((partners) => new Set(partners.map((partner) => partner.sharedById))); }, }; @@ -198,13 +217,20 @@ export class AccessRepository implements IAccessRepository { }; authDevice = { - hasOwnerAccess: (userId: string, deviceId: string): Promise => { - return this.tokenRepository.exist({ - where: { - userId, - id: deviceId, - }, - }); + checkOwnerAccess: async (userId: string, deviceIds: Set): Promise> => { + if (deviceIds.size === 0) { + return new Set(); + } + + return this.tokenRepository + .find({ + select: { id: true }, + where: { + userId, + id: In([...deviceIds]), + }, + }) + .then((tokens) => new Set(tokens.map((token) => token.id))); }, }; @@ -264,24 +290,36 @@ export class AccessRepository implements IAccessRepository { }; person = { - hasOwnerAccess: (userId: string, personId: string): Promise => { - return this.personRepository.exist({ - where: { - id: personId, - ownerId: userId, - }, - }); + checkOwnerAccess: async (userId: string, personIds: Set): Promise> => { + if (personIds.size === 0) { + return new Set(); + } + + return this.personRepository + .find({ + select: { id: true }, + where: { + id: In([...personIds]), + ownerId: userId, + }, + }) + .then((persons) => new Set(persons.map((person) => person.id))); }, }; partner = { - hasUpdateAccess: (userId: string, partnerId: string): Promise => { - return this.partnerRepository.exist({ - where: { - sharedById: partnerId, - sharedWithId: userId, - }, - }); + checkUpdateAccess: async (userId: string, partnerIds: Set): Promise> => { + if (partnerIds.size === 0) { + return new Set(); + } + + return this.partnerRepository + .createQueryBuilder('partner') + .select('partner.sharedById') + .where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] }) + .andWhere('partner.sharedWithId = :userId', { userId }) + .getMany() + .then((partners) => new Set(partners.map((partner) => partner.sharedById))); }, }; } diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index eceb25812..f495d800e 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -36,24 +36,24 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => }, authDevice: { - hasOwnerAccess: jest.fn(), + checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), }, library: { - hasOwnerAccess: jest.fn(), - hasPartnerAccess: jest.fn(), + checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), + checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), }, timeline: { - hasPartnerAccess: jest.fn(), + checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), }, person: { - hasOwnerAccess: jest.fn(), + checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), }, partner: { - hasUpdateAccess: jest.fn(), + checkUpdateAccess: jest.fn().mockResolvedValue(new Set()), }, }; }; From 3aa2927dae3d32aa7be768c57723535511db9361 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Sun, 26 Nov 2023 16:23:43 +0100 Subject: [PATCH 18/60] fix(web): sorting options for albums (#5233) * fix: albums * pr feedback * fix: current behavior * rename * fix: album metadatas * fix: tests * fix: e2e test * simplify * fix: cover shared links * rename function * merge main * merge main --------- Co-authored-by: Alex Tran --- server/src/domain/album/album.service.spec.ts | 80 +++++++++-- server/src/domain/album/album.service.ts | 34 ++++- .../domain/repositories/album.repository.ts | 4 +- .../infra/repositories/album.repository.ts | 19 ++- server/test/e2e/album.e2e-spec.ts | 4 +- .../repositories/album.repository.mock.ts | 2 +- .../components/elements/table-header.svelte | 8 +- .../sharedlinks-page/shared-link-card.svelte | 27 ++-- web/src/routes/(user)/albums/+page.svelte | 126 +++++++++++++----- 9 files changed, 223 insertions(+), 81 deletions(-) diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 414826cb2..c13342897 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -58,9 +58,9 @@ describe(AlbumService.name, () => { describe('getAll', () => { it('gets list of albums for auth user', async () => { albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); - albumMock.getAssetCountForIds.mockResolvedValue([ - { albumId: albumStub.empty.id, assetCount: 0 }, - { albumId: albumStub.sharedWithUser.id, assetCount: 0 }, + albumMock.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); @@ -72,7 +72,14 @@ describe(AlbumService.name, () => { it('gets list of albums that have a specific asset', async () => { albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); @@ -83,7 +90,9 @@ describe(AlbumService.name, () => { it('gets list of albums that are shared', async () => { albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: true }); @@ -94,7 +103,9 @@ describe(AlbumService.name, () => { it('gets list of albums that are NOT shared', async () => { albumMock.getNotShared.mockResolvedValue([albumStub.empty]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: false }); @@ -106,7 +117,14 @@ describe(AlbumService.name, () => { it('counts assets correctly', async () => { albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]); - albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); @@ -118,8 +136,13 @@ describe(AlbumService.name, () => { it('updates the album thumbnail by listing all albums', async () => { albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]); - albumMock.getAssetCountForIds.mockResolvedValue([ - { albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 }, + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAssetInvalidThumbnail.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, ]); albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]); albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail); @@ -134,8 +157,13 @@ describe(AlbumService.name, () => { it('removes the thumbnail for an empty album', async () => { albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]); - albumMock.getAssetCountForIds.mockResolvedValue([ - { albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 }, + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.emptyWithInvalidThumbnail.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, ]); albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]); albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail); @@ -413,10 +441,18 @@ describe(AlbumService.name, () => { it('should get a shared album', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); await sut.get(authStub.admin, albumStub.oneAsset.id, {}); - expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true }); + expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: false }); expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.id, new Set([albumStub.oneAsset.id]), @@ -426,10 +462,18 @@ describe(AlbumService.name, () => { it('should get a shared album via a shared link', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123'])); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); await sut.get(authStub.adminSharedLink, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false }); expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLinkId, new Set(['album-123']), @@ -439,10 +483,18 @@ describe(AlbumService.name, () => { it('should get a shared album via shared with user', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); + albumMock.getMetadataForIds.mockResolvedValue([ + { + albumId: albumStub.oneAsset.id, + assetCount: 1, + startDate: new Date('1970-01-01'), + endDate: new Date('1970-01-01'), + }, + ]); await sut.get(authStub.user1, 'album-123', {}); - expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true }); + expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false }); expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123'])); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 0d92dae04..6d9fa4e70 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -6,6 +6,7 @@ import { AuthUserDto } from '../auth'; import { setUnion } from '../domain.util'; import { JobName } from '../job'; import { + AlbumAssetCount, AlbumInfoOptions, IAccessRepository, IAlbumRepository, @@ -69,11 +70,19 @@ export class AlbumService { // Get asset count for each album. Then map the result to an object: // { [albumId]: assetCount } - const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id)); - const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record, { albumId, assetCount }) => { - obj[albumId] = assetCount; - return obj; - }, {}); + const albumMetadataForIds = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); + const albumMetadataForIdsObj: Record = albumMetadataForIds.reduce( + (obj: Record, { albumId, assetCount, startDate, endDate }) => { + obj[albumId] = { + albumId, + assetCount, + startDate, + endDate, + }; + return obj; + }, + {}, + ); return Promise.all( albums.map(async (album) => { @@ -81,7 +90,9 @@ export class AlbumService { return { ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - assetCount: albumsAssetCountObj[album.id], + startDate: albumMetadataForIdsObj[album.id].startDate, + endDate: albumMetadataForIdsObj[album.id].endDate, + assetCount: albumMetadataForIdsObj[album.id].assetCount, lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, }; }), @@ -91,7 +102,16 @@ export class AlbumService { async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); await this.albumRepository.updateThumbnails(); - return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets); + const withAssets = dto.withoutAssets === undefined ? false : !dto.withoutAssets; + const album = await this.findOrFail(id, { withAssets }); + const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]); + + return { + ...mapAlbum(album, withAssets), + startDate: albumMetadataForIds.startDate, + endDate: albumMetadataForIds.endDate, + assetCount: albumMetadataForIds.assetCount, + }; } async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise { diff --git a/server/src/domain/repositories/album.repository.ts b/server/src/domain/repositories/album.repository.ts index d3ca62da1..10b789b4b 100644 --- a/server/src/domain/repositories/album.repository.ts +++ b/server/src/domain/repositories/album.repository.ts @@ -5,6 +5,8 @@ export const IAlbumRepository = 'IAlbumRepository'; export interface AlbumAssetCount { albumId: string; assetCount: number; + startDate: Date | undefined; + endDate: Date | undefined; } export interface AlbumInfoOptions { @@ -30,7 +32,7 @@ export interface IAlbumRepository { hasAsset(asset: AlbumAsset): Promise; removeAsset(assetId: string): Promise; removeAssets(assets: AlbumAssets): Promise; - getAssetCountForIds(ids: string[]): Promise; + getMetadataForIds(ids: string[]): Promise; getInvalidThumbnail(): Promise; getOwned(ownerId: string): Promise; getShared(ownerId: string): Promise; diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index 69df22685..e6c279726 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -59,25 +59,30 @@ export class AlbumRepository implements IAlbumRepository { }); } - async getAssetCountForIds(ids: string[]): Promise { + async getMetadataForIds(ids: string[]): Promise { // Guard against running invalid query when ids list is empty. if (!ids.length) { return []; } // Only possible with query builder because of GROUP BY. - const countByAlbums = await this.repository + const albumMetadatas = await this.repository .createQueryBuilder('album') .select('album.id') - .addSelect('COUNT(albums_assets.assetsId)', 'asset_count') - .leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id') + .addSelect('MIN(assets.fileCreatedAt)', 'start_date') + .addSelect('MAX(assets.fileCreatedAt)', 'end_date') + .addSelect('COUNT(album_assets.assetsId)', 'asset_count') + .leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id') + .leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId') .where('album.id IN (:...ids)', { ids }) .groupBy('album.id') .getRawMany(); - return countByAlbums.map((albumCount) => ({ - albumId: albumCount['album_id'], - assetCount: Number(albumCount['asset_count']), + return albumMetadatas.map((metadatas) => ({ + albumId: metadatas['album_id'], + assetCount: Number(metadatas['asset_count']), + startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined, + endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined, })); } diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index f5f6bb66e..775bb0b68 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -246,7 +246,7 @@ describe(`${AlbumController.name} (e2e)`, () => { it('should return album info for own album', async () => { const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}`) + .get(`/album/${user1Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -255,7 +255,7 @@ describe(`${AlbumController.name} (e2e)`, () => { it('should return album info for shared album', async () => { const { status, body } = await request(server) - .get(`/album/${user2Albums[0].id}`) + .get(`/album/${user2Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 7cd0a846b..36c3afb29 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -5,7 +5,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { getById: jest.fn(), getByIds: jest.fn(), getByAssetId: jest.fn(), - getAssetCountForIds: jest.fn(), + getMetadataForIds: jest.fn(), getInvalidThumbnail: jest.fn(), getOwned: jest.fn(), getShared: jest.fn(), diff --git a/web/src/lib/components/elements/table-header.svelte b/web/src/lib/components/elements/table-header.svelte index c89bff3db..0b68dd0e5 100644 --- a/web/src/lib/components/elements/table-header.svelte +++ b/web/src/lib/components/elements/table-header.svelte @@ -5,10 +5,10 @@ export let option: Sort; const handleSort = () => { - if (albumViewSettings === option.sortTitle) { + if (albumViewSettings === option.title) { option.sortDesc = !option.sortDesc; } else { - albumViewSettings = option.sortTitle; + albumViewSettings = option.title; } }; @@ -18,12 +18,12 @@ class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50" on:click={() => handleSort()} > - {#if albumViewSettings === option.sortTitle} + {#if albumViewSettings === option.title} {#if option.sortDesc} ↓ {:else} ↑ {/if} - {/if}{option.table} diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 621c98132..f4a21a540 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -7,13 +7,14 @@ import { createEventDispatcher } from 'svelte'; import { goto } from '$app/navigation'; import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiOpenInNew } from '@mdi/js'; + import noThumbnailUrl from '$lib/assets/no-thumbnail.png'; export let link: SharedLinkResponseDto; let expirationCountdown: luxon.DurationObjectUnits; const dispatch = createEventDispatcher(); - const getAssetInfo = async (): Promise => { + const getThumbnail = async (): Promise => { let assetId = ''; if (link.album?.albumThumbnailAssetId) { @@ -60,18 +61,28 @@ class="flex w-full gap-4 border-b border-gray-200 py-4 transition-all hover:border-immich-primary dark:border-gray-600 dark:text-immich-gray dark:hover:border-immich-dark-primary" >
- {#await getAssetInfo()} - - {:then asset} + {#if link?.album?.albumThumbnailAssetId || link.assets.length > 0} + {#await getThumbnail()} + + {:then asset} + {asset.id} + {/await} + {:else} {asset.id} - {/await} + {/if}
diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 4d17ed326..c7506a615 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -1,9 +1,6 @@ diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index 07c99ddb4..19448b944 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -7,12 +7,26 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number { if (assetCount < 6) { return Math.min(320, Math.floor(viewWidth / assetCount - assetCount)); - } else { - if (viewWidth > 600) return viewWidth / 7 - 7; - else if (viewWidth > 400) return viewWidth / 4 - 6; - else if (viewWidth > 300) return viewWidth / 2 - 6; - else if (viewWidth > 200) return viewWidth / 2 - 6; - else if (viewWidth > 100) return viewWidth / 1 - 6; + } + + if (viewWidth > 600) { + return viewWidth / 7 - 7; + } + + if (viewWidth > 400) { + return viewWidth / 4 - 6; + } + + if (viewWidth > 300) { + return viewWidth / 2 - 6; + } + + if (viewWidth > 200) { + return viewWidth / 2 - 6; + } + + if (viewWidth > 100) { + return viewWidth / 1 - 6; } return 300; From 5781ae9d82314e433b5fc17a5d6725ce2eaf5c7d Mon Sep 17 00:00:00 2001 From: Emanuel Bennici Date: Tue, 28 Nov 2023 21:23:27 +0100 Subject: [PATCH 29/60] feat(web): Lazy load thumbnails on the people page (#5356) * feat(web): Lazy load thumbnails on the people page Instead of loading all people thumbnails at once, only the first few should be loaded eagerly. This reduces the load on client and server side. * chore: change name --------- Co-authored-by: Alex Tran --- .../lib/components/assets/thumbnail/image-thumbnail.svelte | 2 ++ web/src/lib/components/faces-page/people-card.svelte | 2 ++ web/src/routes/(user)/people/+page.svelte | 6 ++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 2527e61d2..a6bdaf257 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -17,12 +17,14 @@ export let circle = false; export let hidden = false; export let border = false; + export let preload = true; let complete = false; export let eyeColor: 'black' | 'white' = 'white'; 0}
- {#each people as person (person.id)} + {#each people as person, idx (person.id)} {#if !person.isHidden} handleChangeName(person)} on:set-birth-date={() => handleSetBirthDate(person)} on:merge-faces={() => handleMergeFaces(person)} @@ -444,7 +445,7 @@ bind:showLoadingSpinner bind:toggleVisibility > - {#each people as person (person.id)} + {#each people as person, idx (person.id)} +
+ {:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly} +
+
+
+ +
+
+ +
+ {:else if asset.exifInfo?.dateTimeOriginal && asset.isReadOnly} + {@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, { + zone: asset.exifInfo.timeZone ?? undefined, + })} +
+
+
+ +
+ +
+

+ {assetDateTimeOriginal.toLocaleString( + { + month: 'short', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + )} +

+
+

+ {assetDateTimeOriginal.toLocaleString( + { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'longOffset', + }, + { locale: $locale }, + )} +

+
+
+
+
+ {/if} + + {#if isShowChangeDate} + {@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal + ? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, { + zone: asset.exifInfo.timeZone ?? undefined, + }) + : DateTime.now()} + handleConfirmChangeDate(date)} + on:cancel={() => (isShowChangeDate = false)} + /> + {/if} {#if asset.exifInfo?.fileSizeInByte}
@@ -292,24 +408,84 @@
{/if} - {#if asset.exifInfo?.city} -
-
+ {#if asset.exifInfo?.city && !asset.isReadOnly} +
(isShowChangeLocation = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} + tabindex="0" + role="button" + title="Edit location" + > +
+
+ +
+

{asset.exifInfo.city}

+ {#if asset.exifInfo?.state} +
+

{asset.exifInfo.state}

+
+ {/if} + {#if asset.exifInfo?.country} +
+

{asset.exifInfo.country}

+
+ {/if} +
+
-

{asset.exifInfo.city}

- {#if asset.exifInfo?.state} -
-

{asset.exifInfo.state}

-
- {/if} - {#if asset.exifInfo?.country} -
-

{asset.exifInfo.country}

-
- {/if} +
+ {:else if !asset.exifInfo?.city && !asset.isReadOnly} +
(isShowChangeLocation = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} + tabindex="0" + role="button" + title="Add location" + > +
+
+
+
+ +

Add a location

+
+
+ +
+
+ {:else if asset.exifInfo?.city && asset.isReadOnly} +
+
+
+ +
+

{asset.exifInfo.city}

+ {#if asset.exifInfo?.state} +
+

{asset.exifInfo.state}

+
+ {/if} + {#if asset.exifInfo?.country} +
+

{asset.exifInfo.country}

+
+ {/if} +
+
+
+ {/if} + {#if isShowChangeLocation} + handleConfirmChangeLocation(gps)} + on:cancel={() => (isShowChangeLocation = false)} + /> {/if}
diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte index d5fe8b29b..2cb22d41d 100644 --- a/web/src/lib/components/elements/buttons/link-button.svelte +++ b/web/src/lib/components/elements/buttons/link-button.svelte @@ -7,8 +7,9 @@ export let color: Color = 'transparent-gray'; export let disabled = false; + export let fullwidth = false; - diff --git a/web/src/lib/components/elements/dropdown.svelte b/web/src/lib/components/elements/dropdown.svelte index 65c551e43..6bf9b55d6 100644 --- a/web/src/lib/components/elements/dropdown.svelte +++ b/web/src/lib/components/elements/dropdown.svelte @@ -29,10 +29,13 @@ icon?: string; }; - let showMenu = false; + export let showMenu = false; + export let controlable = false; const handleClickOutside = () => { - showMenu = false; + if (!controlable) { + showMenu = false; + } }; const handleSelectOption = (option: T) => { @@ -60,7 +63,7 @@