From 6eadca330b94e30be40edac0eddad2b6cca1270f Mon Sep 17 00:00:00 2001 From: Aamir Azad <82281117+aamirazad@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:17:56 -0500 Subject: [PATCH 01/58] docs: change github sponsor link to organization (#5267) --- docs/docs/overview/support-the-project.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/overview/support-the-project.md b/docs/docs/overview/support-the-project.md index 8819cdafd8..7bd473eb1e 100644 --- a/docs/docs/overview/support-the-project.md +++ b/docs/docs/overview/support-the-project.md @@ -12,8 +12,8 @@ If you feel like this is the right cause and the app is something you see yourse ## Donation -- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502) -- One-time donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) +- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/immich-app) +- One-time donation via [GitHub Sponsors](https://github.com/sponsors/immich-app?frequency=one-time) - [Librepay](https://liberapay.com/alex.tran1502/) - [buymeacoffee](https://www.buymeacoffee.com/altran1502) - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX From 6e10d15f2c350dc59e5436fc9a37bba337e771dc Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 22 Nov 2023 19:42:17 -0500 Subject: [PATCH 02/58] pin python (#5272) --- machine-learning/poetry.lock | 126 +------------------------------- machine-learning/pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 124 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 8edb14818f..e9ec397ff3 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aiocache" @@ -585,68 +585,6 @@ files = [ test = ["PyYAML", "mock", "pytest"] yaml = ["PyYAML"] -[[package]] -name = "contourpy" -version = "1.1.0" -description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false -python-versions = ">=3.8" -files = [ - {file = "contourpy-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:89f06eff3ce2f4b3eb24c1055a26981bffe4e7264acd86f15b97e40530b794bc"}, - {file = "contourpy-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dffcc2ddec1782dd2f2ce1ef16f070861af4fb78c69862ce0aab801495dda6a3"}, - {file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ae46595e22f93592d39a7eac3d638cda552c3e1160255258b695f7b58e5655"}, - {file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17cfaf5ec9862bc93af1ec1f302457371c34e688fbd381f4035a06cd47324f48"}, - {file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18a64814ae7bce73925131381603fff0116e2df25230dfc80d6d690aa6e20b37"}, - {file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c81f22b4f572f8a2110b0b741bb64e5a6427e0a198b2cdc1fbaf85f352a3aa"}, - {file = "contourpy-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:53cc3a40635abedbec7f1bde60f8c189c49e84ac180c665f2cd7c162cc454baa"}, - {file = "contourpy-1.1.0-cp310-cp310-win32.whl", hash = "sha256:9b2dd2ca3ac561aceef4c7c13ba654aaa404cf885b187427760d7f7d4c57cff8"}, - {file = "contourpy-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:1f795597073b09d631782e7245016a4323cf1cf0b4e06eef7ea6627e06a37ff2"}, - {file = "contourpy-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0b7b04ed0961647691cfe5d82115dd072af7ce8846d31a5fac6c142dcce8b882"}, - {file = "contourpy-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27bc79200c742f9746d7dd51a734ee326a292d77e7d94c8af6e08d1e6c15d545"}, - {file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052cc634bf903c604ef1a00a5aa093c54f81a2612faedaa43295809ffdde885e"}, - {file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9382a1c0bc46230fb881c36229bfa23d8c303b889b788b939365578d762b5c18"}, - {file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5cec36c5090e75a9ac9dbd0ff4a8cf7cecd60f1b6dc23a374c7d980a1cd710e"}, - {file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f0cbd657e9bde94cd0e33aa7df94fb73c1ab7799378d3b3f902eb8eb2e04a3a"}, - {file = "contourpy-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:181cbace49874f4358e2929aaf7ba84006acb76694102e88dd15af861996c16e"}, - {file = "contourpy-1.1.0-cp311-cp311-win32.whl", hash = "sha256:edb989d31065b1acef3828a3688f88b2abb799a7db891c9e282df5ec7e46221b"}, - {file = "contourpy-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb3b7d9e6243bfa1efb93ccfe64ec610d85cfe5aec2c25f97fbbd2e58b531256"}, - {file = "contourpy-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bcb41692aa09aeb19c7c213411854402f29f6613845ad2453d30bf421fe68fed"}, - {file = "contourpy-1.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5d123a5bc63cd34c27ff9c7ac1cd978909e9c71da12e05be0231c608048bb2ae"}, - {file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62013a2cf68abc80dadfd2307299bfa8f5aa0dcaec5b2954caeb5fa094171103"}, - {file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b6616375d7de55797d7a66ee7d087efe27f03d336c27cf1f32c02b8c1a5ac70"}, - {file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:317267d915490d1e84577924bd61ba71bf8681a30e0d6c545f577363157e5e94"}, - {file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d551f3a442655f3dcc1285723f9acd646ca5858834efeab4598d706206b09c9f"}, - {file = "contourpy-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7a117ce7df5a938fe035cad481b0189049e8d92433b4b33aa7fc609344aafa1"}, - {file = "contourpy-1.1.0-cp38-cp38-win32.whl", hash = "sha256:108dfb5b3e731046a96c60bdc46a1a0ebee0760418951abecbe0fc07b5b93b27"}, - {file = "contourpy-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4f26b25b4f86087e7d75e63212756c38546e70f2a92d2be44f80114826e1cd4"}, - {file = "contourpy-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc00bb4225d57bff7ebb634646c0ee2a1298402ec10a5fe7af79df9a51c1bfd9"}, - {file = "contourpy-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:189ceb1525eb0655ab8487a9a9c41f42a73ba52d6789754788d1883fb06b2d8a"}, - {file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f2931ed4741f98f74b410b16e5213f71dcccee67518970c42f64153ea9313b9"}, - {file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f511c05fab7f12e0b1b7730ebdc2ec8deedcfb505bc27eb570ff47c51a8f15"}, - {file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:143dde50520a9f90e4a2703f367cf8ec96a73042b72e68fcd184e1279962eb6f"}, - {file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e94bef2580e25b5fdb183bf98a2faa2adc5b638736b2c0a4da98691da641316a"}, - {file = "contourpy-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ed614aea8462735e7d70141374bd7650afd1c3f3cb0c2dbbcbe44e14331bf002"}, - {file = "contourpy-1.1.0-cp39-cp39-win32.whl", hash = "sha256:71551f9520f008b2950bef5f16b0e3587506ef4f23c734b71ffb7b89f8721999"}, - {file = "contourpy-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:438ba416d02f82b692e371858143970ed2eb6337d9cdbbede0d8ad9f3d7dd17d"}, - {file = "contourpy-1.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a698c6a7a432789e587168573a864a7ea374c6be8d4f31f9d87c001d5a843493"}, - {file = "contourpy-1.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b0ac8a12880412da3551a8cb5a187d3298a72802b45a3bd1805e204ad8439"}, - {file = "contourpy-1.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:a67259c2b493b00e5a4d0f7bfae51fb4b3371395e47d079a4446e9b0f4d70e76"}, - {file = "contourpy-1.1.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2b836d22bd2c7bb2700348e4521b25e077255ebb6ab68e351ab5aa91ca27e027"}, - {file = "contourpy-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084eaa568400cfaf7179b847ac871582199b1b44d5699198e9602ecbbb5f6104"}, - {file = "contourpy-1.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:911ff4fd53e26b019f898f32db0d4956c9d227d51338fb3b03ec72ff0084ee5f"}, - {file = "contourpy-1.1.0.tar.gz", hash = "sha256:e53046c3863828d21d531cc3b53786e6580eb1ba02477e8681009b6aa0870b21"}, -] - -[package.dependencies] -numpy = ">=1.16" - -[package.extras] -bokeh = ["bokeh", "selenium"] -docs = ["furo", "sphinx-copybutton"] -mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.2.0)", "types-Pillow"] -test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] -test-no-images = ["pytest", "pytest-cov", "wurlitzer"] - [[package]] name = "contourpy" version = "1.1.1" @@ -2578,64 +2516,6 @@ files = [ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] -[[package]] -name = "pandas" -version = "2.1.0" -description = "Powerful data structures for data analysis, time series, and statistics" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pandas-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:40dd20439ff94f1b2ed55b393ecee9cb6f3b08104c2c40b0cb7186a2f0046242"}, - {file = "pandas-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d4f38e4fedeba580285eaac7ede4f686c6701a9e618d8a857b138a126d067f2f"}, - {file = "pandas-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e6a0fe052cf27ceb29be9429428b4918f3740e37ff185658f40d8702f0b3e09"}, - {file = "pandas-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d81e1813191070440d4c7a413cb673052b3b4a984ffd86b8dd468c45742d3cc"}, - {file = "pandas-2.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb20252720b1cc1b7d0b2879ffc7e0542dd568f24d7c4b2347cb035206936421"}, - {file = "pandas-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:38f74ef7ebc0ffb43b3d633e23d74882bce7e27bfa09607f3c5d3e03ffd9a4a5"}, - {file = "pandas-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cda72cc8c4761c8f1d97b169661f23a86b16fdb240bdc341173aee17e4d6cedd"}, - {file = "pandas-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d97daeac0db8c993420b10da4f5f5b39b01fc9ca689a17844e07c0a35ac96b4b"}, - {file = "pandas-2.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8c58b1113892e0c8078f006a167cc210a92bdae23322bb4614f2f0b7a4b510f"}, - {file = "pandas-2.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:629124923bcf798965b054a540f9ccdfd60f71361255c81fa1ecd94a904b9dd3"}, - {file = "pandas-2.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:70cf866af3ab346a10debba8ea78077cf3a8cd14bd5e4bed3d41555a3280041c"}, - {file = "pandas-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:d53c8c1001f6a192ff1de1efe03b31a423d0eee2e9e855e69d004308e046e694"}, - {file = "pandas-2.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:86f100b3876b8c6d1a2c66207288ead435dc71041ee4aea789e55ef0e06408cb"}, - {file = "pandas-2.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28f330845ad21c11db51e02d8d69acc9035edfd1116926ff7245c7215db57957"}, - {file = "pandas-2.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9a6ccf0963db88f9b12df6720e55f337447aea217f426a22d71f4213a3099a6"}, - {file = "pandas-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d99e678180bc59b0c9443314297bddce4ad35727a1a2656dbe585fd78710b3b9"}, - {file = "pandas-2.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b31da36d376d50a1a492efb18097b9101bdbd8b3fbb3f49006e02d4495d4c644"}, - {file = "pandas-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0164b85937707ec7f70b34a6c3a578dbf0f50787f910f21ca3b26a7fd3363437"}, - {file = "pandas-2.1.0.tar.gz", hash = "sha256:62c24c7fc59e42b775ce0679cfa7b14a5f9bfb7643cfbe708c960699e05fb918"}, -] - -[package.dependencies] -numpy = {version = ">=1.23.2", markers = "python_version >= \"3.11\""} -python-dateutil = ">=2.8.2" -pytz = ">=2020.1" -tzdata = ">=2022.1" - -[package.extras] -all = ["PyQt5 (>=5.15.6)", "SQLAlchemy (>=1.4.36)", "beautifulsoup4 (>=4.11.1)", "bottleneck (>=1.3.4)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=0.8.1)", "fsspec (>=2022.05.0)", "gcsfs (>=2022.05.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.8.0)", "matplotlib (>=3.6.1)", "numba (>=0.55.2)", "numexpr (>=2.8.0)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pandas-gbq (>=0.17.5)", "psycopg2 (>=2.9.3)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.5)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "pyxlsb (>=1.0.9)", "qtpy (>=2.2.0)", "s3fs (>=2022.05.0)", "scipy (>=1.8.1)", "tables (>=3.7.0)", "tabulate (>=0.8.10)", "xarray (>=2022.03.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)", "zstandard (>=0.17.0)"] -aws = ["s3fs (>=2022.05.0)"] -clipboard = ["PyQt5 (>=5.15.6)", "qtpy (>=2.2.0)"] -compression = ["zstandard (>=0.17.0)"] -computation = ["scipy (>=1.8.1)", "xarray (>=2022.03.0)"] -consortium-standard = ["dataframe-api-compat (>=0.1.7)"] -excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pyxlsb (>=1.0.9)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)"] -feather = ["pyarrow (>=7.0.0)"] -fss = ["fsspec (>=2022.05.0)"] -gcp = ["gcsfs (>=2022.05.0)", "pandas-gbq (>=0.17.5)"] -hdf5 = ["tables (>=3.7.0)"] -html = ["beautifulsoup4 (>=4.11.1)", "html5lib (>=1.1)", "lxml (>=4.8.0)"] -mysql = ["SQLAlchemy (>=1.4.36)", "pymysql (>=1.0.2)"] -output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.8.10)"] -parquet = ["pyarrow (>=7.0.0)"] -performance = ["bottleneck (>=1.3.4)", "numba (>=0.55.2)", "numexpr (>=2.8.0)"] -plot = ["matplotlib (>=3.6.1)"] -postgresql = ["SQLAlchemy (>=1.4.36)", "psycopg2 (>=2.9.3)"] -spss = ["pyreadstat (>=1.1.5)"] -sql-other = ["SQLAlchemy (>=1.4.36)"] -test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)"] -xml = ["lxml (>=4.8.0)"] - [[package]] name = "pandas" version = "2.1.2" @@ -4771,5 +4651,5 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" -python-versions = "^3.11" -content-hash = "bba5f87aa67bc1d2283a9f4b471ef78e572337f22413870d324e908014410d53" +python-versions = "~3.11" +content-hash = "a4c9b3550bb2a67a54b9ab70e700b24fb9eb0b652e90d7dd8ec92abd121ca6e3" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 8b321a6b1a..2779be554f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" packages = [{include = "app"}] [tool.poetry.dependencies] -python = "^3.11" +python = "~3.11" torch = [ {markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.1.0", source = "pypi"}, {markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.1.0", source = "pytorch-cpu"} From 030cd8c4c4d74124ddc5c4157d4bf44ff82f22dc Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Wed, 22 Nov 2023 23:04:52 -0500 Subject: [PATCH 03/58] chore(server): Prepare access interfaces for bulk permission checks (#5223) * chore(server): Prepare access interfaces for bulk permission checks This change adds the `AccessCore.getAllowedIds` method, to evaluate permissions in bulk, along with some other `getAllowedIds*` private methods. The added methods still calculate permissions by id, and are not optimized to reduce the amount of queries and execution time, which will be implemented in separate pull requests. Services that were evaluating permissions in a loop have been refactored to make use of the bulk approach. * chore(server): Apply review suggestions * chore(server): Make multiple-permission check more readable --- server/src/domain/access/access.core.ts | 68 ++++++++++++------- server/src/domain/album/album.service.ts | 12 ++-- server/src/domain/person/person.service.ts | 7 +- .../domain/shared-link/shared-link.service.ts | 8 ++- 4 files changed, 61 insertions(+), 34 deletions(-) diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 16527de989..30086f5d26 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -68,40 +68,48 @@ export class AccessCore { return authUser; } + /** + * Check if user has access to all ids, for the given permission. + * Throws error if user does not have access to any of the ids. + */ async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) { - const hasAccess = await this.hasPermission(authUser, permission, ids); - if (!hasAccess) { + ids = Array.isArray(ids) ? ids : [ids]; + const allowedIds = await this.checkAccess(authUser, permission, ids); + if (new Set(ids).size !== allowedIds.size) { throw new BadRequestException(`Not found or no ${permission} access`); } } - async hasAny(authUser: AuthUserDto, permissions: Array<{ permission: Permission; id: string }>) { - for (const { permission, id } of permissions) { - const hasAccess = await this.hasPermission(authUser, permission, id); - if (hasAccess) { - return true; - } + /** + * Return ids that user has access to, for the given permission. + * Check is done for each id, and only allowed ids are returned. + * + * @returns Set + */ + async checkAccess(authUser: AuthUserDto, permission: Permission, ids: Set | string[]) { + const idSet = Array.isArray(ids) ? new Set(ids) : ids; + if (idSet.size === 0) { + return new Set(); } - return false; - } - - async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) { - ids = Array.isArray(ids) ? ids : [ids]; const isSharedLink = authUser.isPublicUser ?? false; - - for (const id of ids) { - const hasAccess = isSharedLink - ? await this.hasSharedLinkAccess(authUser, permission, id) - : await this.hasOtherAccess(authUser, permission, id); - if (!hasAccess) { - return false; - } - } - - return true; + return isSharedLink + ? await this.checkAccessSharedLink(authUser, permission, idSet) + : await this.checkAccessOther(authUser, permission, idSet); } + private async checkAccessSharedLink(authUser: AuthUserDto, permission: Permission, ids: Set) { + const allowedIds = new Set(); + for (const id of ids) { + const hasAccess = await this.hasSharedLinkAccess(authUser, permission, id); + if (hasAccess) { + allowedIds.add(id); + } + } + return allowedIds; + } + + // TODO: Migrate logic to checkAccessSharedLink to evaluate permissions in bulk. private async hasSharedLinkAccess(authUser: AuthUserDto, permission: Permission, id: string) { const sharedLinkId = authUser.sharedLinkId; if (!sharedLinkId) { @@ -136,6 +144,18 @@ export class AccessCore { } } + private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set) { + const allowedIds = new Set(); + for (const id of ids) { + const hasAccess = await this.hasOtherAccess(authUser, permission, id); + if (hasAccess) { + allowedIds.add(id); + } + } + return allowedIds; + } + + // TODO: Migrate logic to checkAccessOther to evaluate permissions in bulk. private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) { switch (permission) { // uses album id diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 0fb0391ef4..986cdb8911 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -153,6 +153,8 @@ export class AlbumService { await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids); + const notPresentAssetIds = dto.ids.filter((id) => !existingAssetIds.has(id)); + const allowedAssetIds = await this.access.checkAccess(authUser, Permission.ASSET_SHARE, notPresentAssetIds); const results: BulkIdResponseDto[] = []; for (const assetId of dto.ids) { @@ -162,7 +164,7 @@ export class AlbumService { continue; } - const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId); + const hasAccess = allowedAssetIds.has(assetId); if (!hasAccess) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; @@ -190,6 +192,9 @@ export class AlbumService { await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); 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 results: BulkIdResponseDto[] = []; for (const assetId of dto.ids) { @@ -199,10 +204,7 @@ export class AlbumService { continue; } - const hasAccess = await this.access.hasAny(authUser, [ - { permission: Permission.ALBUM_REMOVE_ASSET, id: assetId }, - { permission: Permission.ASSET_SHARE, id: assetId }, - ]); + const hasAccess = allowedAssetIds.has(assetId); if (!hasAccess) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 49329a452a..3452807f66 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -375,10 +375,11 @@ export class PersonService { const results: BulkIdResponseDto[] = []; - for (const mergeId of mergeIds) { - const hasPermission = await this.access.hasPermission(authUser, Permission.PERSON_MERGE, mergeId); + const allowedIds = await this.access.checkAccess(authUser, Permission.PERSON_MERGE, mergeIds); - if (!hasPermission) { + for (const mergeId of mergeIds) { + const hasAccess = allowedIds.has(mergeId); + if (!hasAccess) { results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; } diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index d3fd89661b..bd9aaea6a3 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -119,15 +119,19 @@ export class SharedLinkService { throw new BadRequestException('Invalid shared link type'); } + const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id)); + const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); + const allowedAssetIds = await this.access.checkAccess(authUser, Permission.ASSET_SHARE, notPresentAssetIds); + const results: AssetIdsResponseDto[] = []; for (const assetId of dto.assetIds) { - const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId); + const hasAsset = existingAssetIds.has(assetId); if (hasAsset) { results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE }); continue; } - const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId); + const hasAccess = allowedAssetIds.has(assetId); if (!hasAccess) { results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION }); continue; From df9ec9327d7ba4dde4adf52e43dcf313ecef73b4 Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Thu, 23 Nov 2023 15:33:26 +0100 Subject: [PATCH 04/58] chore(web): adjust album thumbnail size (#5277) --- web/src/routes/(user)/albums/+page.svelte | 2 +- web/src/routes/(user)/search/+page.svelte | 2 +- web/src/routes/(user)/sharing/+page.svelte | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 3db0851689..4d17ed326b 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -251,7 +251,7 @@ {#if $albums.length !== 0} {#if $albumViewSettings.view === AlbumViewMode.Cover} -
+
{#each $albums as album (album.id)}
ALBUMS
-
+
{#each albums as album (album.id)} diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index 38b4a723aa..51c0baf151 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -93,7 +93,7 @@
-
+
{#each data.sharedAlbums as album (album.id)} From a6676907b4541b36d4e9051a0b893bd7d51f7ef3 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 23 Nov 2023 21:55:36 +0100 Subject: [PATCH 05/58] chore(renovate): Group base image updates (#5284) --- renovate.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/renovate.json b/renovate.json index d8f272e687..3bdb1490d2 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 06/58] 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 c780a116e0..be353c9fc7 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 07/58] 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 66f5c2fd14..c47074d2ea 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 93050b23cc..b1f254da42 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 0000000000..723b22b3d1 --- /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 08/58] 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 387a6e370b..8f8f3283ad 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 24ea49a9df..1694f5a1fb 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 85bfa4445f..56e5d4af5d 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 09/58] 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 3687d117e1..87a1bd0a1a 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 10/58] 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 6bdfb6255f..51394751e7 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 2e8f00fe48..8290c8f8ca 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 45658bee19..fc12cc49c1 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 0c871afc84..2af7aecc16 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 8f8f3283ad..f41917e397 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 f6b342f33f..f32a8b1f8e 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 c1d68cf87d..d140b60ee2 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 388fe5ea1c..0e03e2fd6d 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 898b7fac4c..d746f99c1d 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 ee06e82ca7..25e2615d9c 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 84c1ffd6af..22050072d9 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 7e3015c9fb..a2ec8a49bb 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 77fed857b7..8c5f10e4a1 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 58c738af6e..4fa57d9831 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 daf639d46e..1d7c9f55a0 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 60828faa2c..87a9437099 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 0f919be543..cd2e9be27c 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 842460652d..a91a7e053d 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 53395ff799..0c7cebe246 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 62fdb4d70e..2767a82003 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 aa0f230ddf..3e4921ffe3 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 7e3015c9fb..a2ec8a49bb 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 7e3015c9fb..a2ec8a49bb 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 31e15c6435..c308160a9f 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 ab2e93525e..eb74cb43f3 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 3e44a505e0..00031ded6c 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 20edf605af..60edb10b48 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 00b3501c43..28591e3959 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 3673afacb5..cc8b89a152 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 11/58] 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 be353c9fc7..be8a5ec999 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 12/58] 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 c48c7a8f57..eaedabc7bd 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 c6c23d942a..75168ce1c9 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 15cc0c3493..0e0a14a8c7 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 1c307eaac4..38aefc4452 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 16f7ef94d7..b479c08f34 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 366c83d57e..45c1e11043 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 50c35d2890..c4f6c85112 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 131766ba96..bd62f44f61 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 45687282f8..f91c942f8b 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 dbba670e73..86e4809325 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 12ae49000e..a42952958c 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 e7a04564c0..ad17ccf319 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 ff2f4163b5..cae5f60c8b 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 105760e502..3a652c2e50 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 ffa454cd50..b3de4b26c5 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 b6fa10c0d7..59c29a9d22 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 566b7733a1..88bbdabcfd 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 c48c7a8f57..eaedabc7bd 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 13/58] 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 eaedabc7bd..1f6669794f 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 10f10fb01a..f54b788a4e 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 38aefc4452..903919c050 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 9acca959c7..0000000000 --- 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 36eab47477..9fca6c2094 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 3052d5d8b2..8941626933 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 77a9997010..42a0e5cbb3 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 d3f7971e3e..728a4ed833 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 96f5d8e573..0000000000 --- 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 727e5534fd..d995d96673 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 cfe63b7548..0000000000 --- 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 12f7655ead..b4aa477df3 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 be8a5ec999..5f2b796340 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 bd62f44f61..e3ed6402aa 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 6ae9ae2596..917e643740 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 556740764d..21e19ee59c 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 bb2d706224..7ce7db054a 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 f600f75a9b..b3a19dac20 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 ff098d8dbb..f812e6ee59 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 0c3b78462b..c0a0fef46a 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 0000000000..4d571953bc --- /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 be20a02c79..aa224ccc6c 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 b3a030487a..bfab4bb4fc 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 cdeb552b09..6ff4ac5c45 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 5e9743ba5a..c81c462e89 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 0000000000..36cf0a805e --- /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 0000000000..bd03e83776 --- /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 0000000000..244e4261b0 --- /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 e4b5c38b4d..6c662a20ad 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 84e72e6380..f6c14e1a7d 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 0000000000..623806db79 --- /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 90477d8ca3..7f24230326 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 276058c0b3..e0d5711d63 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 0000000000..0bd9162db7 --- /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 0000000000..1ef562ff7e --- /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 81ea7dd81f..0324fef43c 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 63bc29dcba..8f8d068e56 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 0000000000..a4f3eeff02 --- /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 0000000000..756437743b --- /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 67d995e331..554519114e 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 76c6f777a5..c602c54d56 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 2cbd4f19a6..dc7c1b6988 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 eaedabc7bd..1f6669794f 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 7093a0eeb0..fe2f879690 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 14/58] 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 30086f5d26..d829139a95 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 496d8978b7..659718bede 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 a93cb0ad17..414826cb2c 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 986cdb8911..0d92dae04d 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 f91c942f8b..0baddd953e 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 53e674bf30..00ad27bc77 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 9c009719d3..e1ea27ce6d 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 863e3a3534..abf8128c48 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 fa58628851..fb0b865fb6 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 8f1e9355d8..eceb25812e 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 15/58] 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 917e643740..c1c4bf4769 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 16/58] 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 5f2b796340..05332e3b0c 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 c1c4bf4769..117b811bcf 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 21e19ee59c..c10d3bcd7a 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 17/58] 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 4547cfc3c1..76217c4d8a 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 18/58] 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 0e0a14a8c7..f4ca5932a1 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 19/58] 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 51394751e7..36aad19570 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 fc12cc49c1..b5b7c7e45d 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 f41917e397..6d28890ebc 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 a2ec8a49bb..9c42afb7d1 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 0b9ed145f0..60bf00d994 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 8c5f10e4a1..d8a3725275 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 1d7c9f55a0..875044c299 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 87a9437099..dfbb54d89a 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 a91a7e053d..eece8bd402 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 a2ec8a49bb..9c42afb7d1 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 2cdc37acf8..9588a2ede7 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 a2ec8a49bb..9c42afb7d1 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 c308160a9f..026219fe5e 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 eb74cb43f3..0cfad7effa 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 00031ded6c..d3df92692d 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 20/58] 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 544bce8fb5..33555c87fe 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 67359aef7c..a6a984fa1f 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 79fb3d1eeb..418a7da60b 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 21/58] 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 d829139a95..c47b2acd24 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 0baddd953e..28a138254c 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 a815e22d1f..7ece7bed85 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 3d7d68736f..c7e15e9600 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 3a4ac6b6db..b210a9165e 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 e1ea27ce6d..7736fd890f 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 cc2102766c..11173b55fa 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 fb0b865fb6..b23c559a61 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 eceb25812e..f495d800e0 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 22/58] 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 414826cb2c..c133428979 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 0d92dae04d..6d9fa4e707 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 d3ca62da12..10b789b4b3 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 69df226859..e6c2797261 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 f5f6bb66ec..775bb0b687 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 7cd0a846b3..36c3afb297 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 c89bff3db2..0b68dd0e52 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 621c981326..f4a21a5408 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 4d17ed326b..c7506a6158 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 07c99ddb44..19448b944f 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 33/58] 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 2527e61d25..a6bdaf257b 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)}