diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6b17774ed67e..d704aa629527d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,23 +10,6 @@ concurrency: cancel-in-progress: true jobs: - server-e2e-api: - name: Server (e2e-api) - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./server - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run npm install - run: npm ci - - - name: Run e2e tests - run: npm run e2e:api - server-e2e-jobs: name: Server (e2e-jobs) runs-on: ubuntu-latest @@ -213,6 +196,10 @@ jobs: run: npm run format if: ${{ !cancelled() }} + - name: Run tsc + run: npm run check + if: ${{ !cancelled() }} + - name: Install Playwright Browsers run: npx playwright install --with-deps chromium if: ${{ !cancelled() }} diff --git a/Makefile b/Makefile index b455e2656b64e..55875e732b277 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,6 @@ pull-stage: server-e2e-jobs: docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build -server-e2e-api: - npm run e2e:api --prefix server - .PHONY: e2e e2e: docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans diff --git a/README.md b/README.md index ca8b1e39708a0..f4adc1af0095f 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@

- +

-

Immich - High performance self-hosted photo and video backup solution

+

High performance self-hosted photo and video backup solution


diff --git a/README_ca_ES.md b/README_ca_ES.md index ebf1514cd524a..cf7bcb4f52fcd 100644 --- a/README_ca_ES.md +++ b/README_ca_ES.md @@ -9,7 +9,7 @@

- +

Immich - Solució de còpia de seguretat d'alta rendiment per a fotos i vídeos auto-allotjada


diff --git a/README_de_DE.md b/README_de_DE.md index 64f8b75a9f289..322a58b96bc06 100644 --- a/README_de_DE.md +++ b/README_de_DE.md @@ -9,7 +9,7 @@

- +

Immich - Hoch performante, selbst gehostete Backup-Lösung für Fotos und Videos


diff --git a/README_es_ES.md b/README_es_ES.md index 6ab6617a6bc12..047c472608fb8 100644 --- a/README_es_ES.md +++ b/README_es_ES.md @@ -9,7 +9,7 @@

- +

Immich: Una solución Self-Hosted de copia de seguridad de fotos y videos de alto rendimiento


diff --git a/README_fr_FR.md b/README_fr_FR.md index 66c403c81c762..3b4c6d4287a8b 100644 --- a/README_fr_FR.md +++ b/README_fr_FR.md @@ -9,7 +9,7 @@

- +

Immich - Solution de sauvegarde performante et auto-hébergée des photos et des vidéos


diff --git a/README_it_IT.md b/README_it_IT.md index b588e1951fac9..7e8863a93905c 100644 --- a/README_it_IT.md +++ b/README_it_IT.md @@ -9,7 +9,7 @@

- +

Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video


diff --git a/README_ja_JP.md b/README_ja_JP.md index 999237a2e2a77..90143025af1c6 100644 --- a/README_ja_JP.md +++ b/README_ja_JP.md @@ -9,7 +9,7 @@

- +

Immich - 高性能なセルフホスト 写真/ビデオバックアップソリューション


diff --git a/README_ko_KR.md b/README_ko_KR.md index db3bb7a07bdda..3a1599597c008 100644 --- a/README_ko_KR.md +++ b/README_ko_KR.md @@ -9,7 +9,7 @@

- +

Immich - 고성능 자체 호스팅 사진 및 동영상 백업 솔루션


diff --git a/README_nl_NL.md b/README_nl_NL.md index 7d853c2199ab8..1eeb41e5a5a65 100644 --- a/README_nl_NL.md +++ b/README_nl_NL.md @@ -9,7 +9,7 @@

- +

Immich - Hoogwaardige, self-hosted back-up oplossing voor foto's en video's


diff --git a/README_ru_RU.md b/README_ru_RU.md index 690870596f9da..f6d50b1ce91d7 100644 --- a/README_ru_RU.md +++ b/README_ru_RU.md @@ -9,7 +9,7 @@

- +

Immich - Высокопроизводительное решение для автономоного создания фото и видео архивов


diff --git a/README_tr_TR.md b/README_tr_TR.md index f04293f88d8de..6e5c43ee6ea60 100644 --- a/README_tr_TR.md +++ b/README_tr_TR.md @@ -9,7 +9,7 @@

- +

Immich - Yüksek performanslı, kendine ait barındırılan fotoğraf ve video yedekleme çözümü


diff --git a/README_zh_CN.md b/README_zh_CN.md index 0b9bd07061e4d..874b18f09ed4d 100644 --- a/README_zh_CN.md +++ b/README_zh_CN.md @@ -9,7 +9,7 @@

- +

Immich - 高性能的自托管照片和视频备份方案

diff --git a/cli/package-lock.json b/cli/package-lock.json index 9574fa26f6a05..38fe3c6f17d8d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -46,7 +46,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.2", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/design/admin-interface.png b/design/admin-interface.png deleted file mode 100644 index 62719facdf14d..0000000000000 Binary files a/design/admin-interface.png and /dev/null differ diff --git a/design/admin-registration-form.png b/design/admin-registration-form.png deleted file mode 100644 index 1ded0cbd2aa5d..0000000000000 Binary files a/design/admin-registration-form.png and /dev/null differ diff --git a/design/appicon.png b/design/appicon.png deleted file mode 100644 index a48b5af354034..0000000000000 Binary files a/design/appicon.png and /dev/null differ diff --git a/design/backup-screen.png b/design/backup-screen.png deleted file mode 100644 index d70669a9d654a..0000000000000 Binary files a/design/backup-screen.png and /dev/null differ diff --git a/design/bitcoin.png b/design/bitcoin.png deleted file mode 100644 index 63084db447a48..0000000000000 Binary files a/design/bitcoin.png and /dev/null differ diff --git a/design/cardano.png b/design/cardano.png deleted file mode 100644 index b7e59a90bdae7..0000000000000 Binary files a/design/cardano.png and /dev/null differ diff --git a/design/feature-panel.png b/design/feature-panel.png deleted file mode 100644 index cab58441c54e8..0000000000000 Binary files a/design/feature-panel.png and /dev/null differ diff --git a/design/google-play-qr-code.png b/design/google-play-qr-code.png deleted file mode 100644 index 7c8e944b949fc..0000000000000 Binary files a/design/google-play-qr-code.png and /dev/null differ diff --git a/design/home-screen.jpeg b/design/home-screen.jpeg deleted file mode 100644 index 7871ed6f9ad10..0000000000000 Binary files a/design/home-screen.jpeg and /dev/null differ diff --git a/design/immich-logo-inline-dark.png b/design/immich-logo-inline-dark.png new file mode 100644 index 0000000000000..cc6cb23b62363 Binary files /dev/null and b/design/immich-logo-inline-dark.png differ diff --git a/design/immich-logo-inline-dark.svg b/design/immich-logo-inline-dark.svg new file mode 100644 index 0000000000000..024337c2e34ef --- /dev/null +++ b/design/immich-logo-inline-dark.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/design/immich-logo-inline-light.png b/design/immich-logo-inline-light.png new file mode 100644 index 0000000000000..b910b3790425d Binary files /dev/null and b/design/immich-logo-inline-light.png differ diff --git a/design/immich-logo-inline-light.svg b/design/immich-logo-inline-light.svg new file mode 100644 index 0000000000000..216466f58d847 --- /dev/null +++ b/design/immich-logo-inline-light.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/design/immich-logo-no-outline.png b/design/immich-logo-no-outline.png deleted file mode 100644 index d4e7085f125b6..0000000000000 Binary files a/design/immich-logo-no-outline.png and /dev/null differ diff --git a/design/immich-logo-stacked-dark.png b/design/immich-logo-stacked-dark.png new file mode 100644 index 0000000000000..6b357f1d5eab8 Binary files /dev/null and b/design/immich-logo-stacked-dark.png differ diff --git a/design/immich-logo-stacked-dark.svg b/design/immich-logo-stacked-dark.svg new file mode 100644 index 0000000000000..7f8381869a767 --- /dev/null +++ b/design/immich-logo-stacked-dark.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/design/immich-logo-stacked-light.png b/design/immich-logo-stacked-light.png new file mode 100644 index 0000000000000..3df5e04e18a1e Binary files /dev/null and b/design/immich-logo-stacked-light.png differ diff --git a/design/immich-logo-stacked-light.svg b/design/immich-logo-stacked-light.svg new file mode 100644 index 0000000000000..8c4505d97ed11 --- /dev/null +++ b/design/immich-logo-stacked-light.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/design/immich-logo.png b/design/immich-logo.png new file mode 100644 index 0000000000000..297f8a926c3e2 Binary files /dev/null and b/design/immich-logo.png differ diff --git a/design/immich-logo.svg b/design/immich-logo.svg index e7edba069b995..376fa6f3e837e 100644 --- a/design/immich-logo.svg +++ b/design/immich-logo.svg @@ -1,98 +1,29 @@ - - + + - - - - - - - - - - - - - - - - + + + + + + + diff --git a/design/immich-text-dark.png b/design/immich-text-dark.png new file mode 100644 index 0000000000000..215687af8f9aa Binary files /dev/null and b/design/immich-text-dark.png differ diff --git a/design/immich-text-right.png b/design/immich-text-right.png new file mode 100644 index 0000000000000..478158d39c354 Binary files /dev/null and b/design/immich-text-right.png differ diff --git a/design/ios-qr-code.png b/design/ios-qr-code.png deleted file mode 100644 index 3dfa11fd7b54b..0000000000000 Binary files a/design/ios-qr-code.png and /dev/null differ diff --git a/design/login-screen.jpeg b/design/login-screen.jpeg deleted file mode 100644 index 9a756a5062934..0000000000000 Binary files a/design/login-screen.jpeg and /dev/null differ diff --git a/design/login-screen.png b/design/login-screen.png deleted file mode 100644 index a3687fb12aa9d..0000000000000 Binary files a/design/login-screen.png and /dev/null differ diff --git a/design/nsc3.png b/design/nsc3.png deleted file mode 100644 index a8dd15eaededf..0000000000000 Binary files a/design/nsc3.png and /dev/null differ diff --git a/design/nsc4.jpeg b/design/nsc4.jpeg deleted file mode 100644 index 3228b348c06c1..0000000000000 Binary files a/design/nsc4.jpeg and /dev/null differ diff --git a/design/nsc6.png b/design/nsc6.png deleted file mode 100644 index b4baf2630d02e..0000000000000 Binary files a/design/nsc6.png and /dev/null differ diff --git a/design/search-screen.jpeg b/design/search-screen.jpeg deleted file mode 100644 index e43fb897eabd3..0000000000000 Binary files a/design/search-screen.jpeg and /dev/null differ diff --git a/design/selective-backup-screen.png b/design/selective-backup-screen.png deleted file mode 100644 index 7b3d1ed0477a2..0000000000000 Binary files a/design/selective-backup-screen.png and /dev/null differ diff --git a/design/shared-albums.png b/design/shared-albums.png deleted file mode 100644 index a0b139b843acf..0000000000000 Binary files a/design/shared-albums.png and /dev/null differ diff --git a/design/web-admin.jpeg b/design/web-admin.jpeg deleted file mode 100644 index d5d819ae31145..0000000000000 Binary files a/design/web-admin.jpeg and /dev/null differ diff --git a/design/web-detail.jpeg b/design/web-detail.jpeg deleted file mode 100644 index e6c7c6d2e8266..0000000000000 Binary files a/design/web-detail.jpeg and /dev/null differ diff --git a/design/web-home.jpeg b/design/web-home.jpeg deleted file mode 100644 index f9d16c175fd26..0000000000000 Binary files a/design/web-home.jpeg and /dev/null differ diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index 2f6ae3ebdec9f..ef9c0a5bb1301 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -1,9 +1,9 @@ version: "3.8" -# Configurations for hardware-accelerated transcoding +# Configurations for hardware-accelerated transcoding # If using Unraid or another platform that doesn't allow multiple Compose files, -# you can inline the config for a backend by copying its contents +# you can inline the config for a backend by copying its contents # into the immich-microservices service in the docker-compose.yml file. # See https://immich.app/docs/features/hardware-transcoding for more info on using hardware transcoding. @@ -38,6 +38,10 @@ services: - /dev/dri:/dev/dri - /dev/dma_heap:/dev/dma_heap - /dev/mpp_service:/dev/mpp_service + #- /dev/mali0:/dev/mali0 # only required to enable OpenCL-accelerated HDR -> SDR tonemapping + volumes: + #- /etc/OpenCL:/etc/OpenCL:ro # only required to enable OpenCL-accelerated HDR -> SDR tonemapping + #- /usr/lib/aarch64-linux-gnu/libmali.so.1:/usr/lib/aarch64-linux-gnu/libmali.so.1:ro # only required to enable OpenCL-accelerated HDR -> SDR tonemapping vaapi: devices: diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index db3d1ba7d6da9..420cd2a43bae3 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -42,6 +42,18 @@ You do not need to redo any transcoding jobs after enabling hardware acceleratio - If you have an 11th gen CPU or older, then you may need to follow [these][jellyfin-lp] instructions as Low-Power mode is required - Additionally, if the server specifically has an 11th gen CPU and is running kernel 5.15 (shipped with Ubuntu 22.04 LTS), then you will need to upgrade this kernel (from [Jellyfin docs][jellyfin-kernel-bug]) +#### RKMPP + +For RKMPP to work: + +- You must have a supported Rockchip ARM SoC. +- Only RK3588 supports hardware tonemapping, other SoCs use slower software tonemapping while still using hardware encoding. +- Tonemapping requires `/usr/lib/aarch64-linux-gnu/libmali.so.1` to be present on your host system. Install [`libmali-valhall-g610-g6p0-gbm`][libmali-rockchip] and modify the [`hwaccel.transcoding.yml`][hw-file] file: + - under `rkmpp` uncomment the 3 lines required for OpenCL tonemapping by removing the `#` symbol at the beginning of each line + - `- /dev/mali0:/dev/mali0` + - `- /etc/OpenCL:/etc/OpenCL:ro` + - `- /usr/lib/aarch64-linux-gnu/libmali.so.1:/usr/lib/aarch64-linux-gnu/libmali.so.1:ro` + ## Setup #### Basic Setup @@ -106,3 +118,4 @@ Once this is done, you can continue to step 3 of "Basic Setup". [nvcr]: https://github.com/NVIDIA/nvidia-container-runtime/ [jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux [jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations +[libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases diff --git a/docs/docs/guides/api-album-sync.md b/docs/docs/guides/api-album-sync.md new file mode 100644 index 0000000000000..c03915e2cce24 --- /dev/null +++ b/docs/docs/guides/api-album-sync.md @@ -0,0 +1,130 @@ +# API Album Sync (Python Script) + +This is an example of a python script for syncing an album to a local folder. This was used for a digital photoframe so the displayed photos could be managed from the immich web or app UI. + +The script is copied below in it's current form. A repository is hosted [here](https://git.orenit.solutions/open/immichalbumpull). + +:::danger +This guide uses a generated API key. This key gives the same access to your immich instance as the user it is attached to, so be careful how the config file is stored and transferred. +::: + +### Prerequisites + +- Python 3.7+ +- [requests library](https://pypi.org/project/requests/) + +### Installing + +Copy the contents of 'pull.py' (shown below) to your chosen location or clone the repository: + +```bash +git clone https://git.orenit.solutions/open/immichalbumpull +``` + +Edit or create the 'config.ini' file in the same directory as the script with the necessary details: + +```ini title='config.ini' +[immich] +# URL of target immich instance +url = https://photo.example.com +# API key from Account Settings -> API Keys +apikey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Full local path to target directory +destination = /home/photo/photos +# immich album name +album = Photoframe +``` + +### Usage + +Run the script directly: + +```bash +./pull.py +``` + +Or from cron (every 5 minutes): + +```bash +*/5 * * * * /usr/bin/python /home/user/immichalbumpull/pull.py +``` + +### Python Script + +```python title='pull.py' +#!/usr/bin/env python + +import requests +import configparser +import os +import shutil + +# Read config file +config = configparser.ConfigParser() +config.read('config.ini') + +url = config['immich']['url'] +apikey = config['immich']['apikey'] +photodir = config['immich']['destination'] +albumname = config['immich']['album'] + +headers = { + 'Accept': 'application/json', + 'x-api-key': apikey +} + +# Set up the directory for the downloaded images +os.makedirs(photodir, exist_ok=True) + +# Get the list of albums from the API +response = requests.get(url + "/api/album", headers=headers) + +# Parse the JSON response +data = response.json() + +# Find the chosen album id +for item in data: + if item['albumName'] == albumname: + albumid = item['id'] + +# Get the list of photos from the API using the albumid +response = requests.get(url + "/api/album/" + albumid, headers=headers) + +# Parse the JSON response and extract the URLs of the images +data = response.json() +image_urls = data['assets'] + +# Download each image from the URL and save it to the directory +headers = { + 'Accept': 'application/octet-stream', + 'x-api-key': apikey +} + +photolist = [] + +for id in image_urls: + # Query asset info endpoint for correct extension + assetinfourl = url + "/api/asset/" + str(id['id']) + response = requests.get(assetinfourl, headers=headers) + assetinfo = response.json() + ext = os.path.splitext(assetinfo['originalFileName']) + + asseturl = url + "/api/download/asset/" + str(id['id']) + response = requests.post(asseturl, headers=headers, stream=True) + + # Build current photo list for deletions below + photo = os.path.basename(asseturl) + ext[1] + photolist.append(photo) + + photofullpath = photodir + '/' + os.path.basename(asseturl) + ext[1] + # Only download file if it doesn't already exist + if not os.path.exists(photofullpath): + with open(photofullpath, 'wb') as f: + for chunk in response.iter_content(1024): + f.write(chunk) + +# Delete old photos removed from album +for filename in os.listdir(photodir): + if filename not in photolist: + os.unlink(os.path.join(photodir, filename)) +``` diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index b1d4b67b2e046..ba60e8b118cb3 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -14,6 +14,12 @@ Edit `docker-compose.yml` to add two new mount points under `volumes:` - ${EXTERNAL_PATH}:/usr/src/app/external ``` +``` + immich-microservices: + volumes: + - ${EXTERNAL_PATH}:/usr/src/app/external +``` + Be sure to add exactly the same line to both `immich-server:` and `immich-microservices:`. Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer: diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 338d6d2a14e94..f3d06eacc671f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.0.0", + "version": "1.98.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.0.0", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -79,7 +79,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.2", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { @@ -1274,16 +1274,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.0.tgz", - "integrity": "sha512-j6vT/kCulhG5wBmGtstKeiVr1rdXE4nk+DT1k6trYkwlrvW9eOF5ZbgKnd/YR6PcM4uTEXa0h6Fcvf6X7Dxl0w==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz", + "integrity": "sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.1.0", - "@typescript-eslint/type-utils": "7.1.0", - "@typescript-eslint/utils": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/type-utils": "7.1.1", + "@typescript-eslint/utils": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1309,15 +1309,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.0.tgz", - "integrity": "sha512-V1EknKUubZ1gWFjiOZhDSNToOjs63/9O0puCgGS8aDOgpZY326fzFu15QAUjwaXzRZjf/qdsdBrckYdv9YxB8w==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.1.tgz", + "integrity": "sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.1.0", - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/typescript-estree": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", "debug": "^4.3.4" }, "engines": { @@ -1337,13 +1337,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.0.tgz", - "integrity": "sha512-6TmN4OJiohHfoOdGZ3huuLhpiUgOGTpgXNUPJgeZOZR3DnIpdSgtt83RS35OYNNXxM4TScVlpVKC9jyQSETR1A==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", + "integrity": "sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0" + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1354,13 +1354,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.0.tgz", - "integrity": "sha512-UZIhv8G+5b5skkcuhgvxYWHjk7FW7/JP5lPASMEUoliAPwIH/rxoUSQPia2cuOj9AmDZmwUl1usKm85t5VUMew==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.1.tgz", + "integrity": "sha512-5r4RKze6XHEEhlZnJtR3GYeCh1IueUHdbrukV2KSlLXaTjuSfeVF8mZUVPLovidCuZfbVjfhi4c0DNSa/Rdg5g==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.1.0", - "@typescript-eslint/utils": "7.1.0", + "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/utils": "7.1.1", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1381,9 +1381,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.0.tgz", - "integrity": "sha512-qTWjWieJ1tRJkxgZYXx6WUYtWlBc48YRxgY2JN1aGeVpkhmnopq+SUC8UEVGNXIvWH7XyuTjwALfG6bFEgCkQA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz", + "integrity": "sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1394,13 +1394,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.0.tgz", - "integrity": "sha512-k7MyrbD6E463CBbSpcOnwa8oXRdHzH1WiVzOipK3L5KSML92ZKgUBrTlehdi7PEIMT8k0bQixHUGXggPAlKnOQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.1.tgz", + "integrity": "sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1446,17 +1446,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.0.tgz", - "integrity": "sha512-WUFba6PZC5OCGEmbweGpnNJytJiLG7ZvDBJJoUcX4qZYf1mGZ97mO2Mps6O2efxJcJdRNpqweCistDbZMwIVHw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.1.0", - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/typescript-estree": "7.1.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", "semver": "^7.5.4" }, "engines": { @@ -1471,12 +1471,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.0.tgz", - "integrity": "sha512-FhUqNWluiGNzlvnDZiXad4mZRhtghdoKW6e98GoEOYSu5cND+E39rG5KwJMUzeENwm1ztYBRqof8wMLP+wNPIA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.1.tgz", + "integrity": "sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.0", + "@typescript-eslint/types": "7.1.1", "eslint-visitor-keys": "^3.4.1" }, "engines": { diff --git a/e2e/package.json b/e2e/package.json index 14685df51bd5b..99d3a91cdb648 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.0.0", + "version": "1.98.2", "description": "", "main": "index.js", "type": "module", @@ -12,7 +12,8 @@ "format": "prettier --check .", "format:fix": "prettier --write .", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix" + "lint:fix": "npm run lint -- --fix", + "check": "tsc --noEmit" }, "keywords": [], "author": "", diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 4faa5eac3d8e0..2310b4718cfc3 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -92,7 +92,7 @@ describe('/album', () => { }), ]); - await deleteUser({ id: user3.userId }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUser({ id: user3.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); describe('GET /album', () => { diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index f1bb355315a6e..a13bb58eb174c 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -2,20 +2,45 @@ import { AssetFileUploadResponseDto, AssetResponseDto, AssetTypeEnum, + LibraryResponseDto, LoginResponseDto, SharedLinkType, + TimeBucketSize, + getAllLibraries, + getAssetInfo, + updateAssets, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; +import { randomBytes } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; +import { makeRandomImage } from 'src/generators'; import { errorDto } from 'src/responses'; -import { app, tempDir, testAssetDir, utils } from 'src/utils'; +import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const makeUploadDto = (options?: { omit: string }): Record => { + const dto: Record = { + deviceAssetId: 'example-image', + deviceId: 'TEST', + fileCreatedAt: new Date().toISOString(), + fileModifiedAt: new Date().toISOString(), + isFavorite: 'testing', + duration: '0:00:00.000000', + }; + + const omit = options?.omit; + if (omit) { + delete dto[omit]; + } + + return dto; +}; + const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; @@ -35,34 +60,43 @@ const yesterday = today.minus({ days: 1 }); describe('/asset', () => { let admin: LoginResponseDto; + let websocket: Socket; + let user1: LoginResponseDto; let user2: LoginResponseDto; - let userStats: LoginResponseDto; + let timeBucketUser: LoginResponseDto; + let quotaUser: LoginResponseDto; + let statsUser: LoginResponseDto; + let stackUser: LoginResponseDto; + let user1Assets: AssetFileUploadResponseDto[]; let user2Assets: AssetFileUploadResponseDto[]; - let assetLocation: AssetFileUploadResponseDto; - let ws: Socket; + let stackAssets: AssetFileUploadResponseDto[]; + let locationAsset: AssetFileUploadResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [ws, user1, user2, userStats] = await Promise.all([ + [websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ utils.connectWebsocket(admin.accessToken), - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - utils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.create('1')), + utils.userSetup(admin.accessToken, createUserDto.create('2')), + utils.userSetup(admin.accessToken, createUserDto.create('stats')), + utils.userSetup(admin.accessToken, createUserDto.userQuota), + utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), + utils.userSetup(admin.accessToken, createUserDto.create('stack')), ]); // asset location - assetLocation = await utils.createAsset(admin.accessToken, { + locationAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'thompson-springs.jpg', bytes: await readFile(locationAssetFilepath), }, }); - await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id }); user1Assets = await Promise.all([ utils.createAsset(user1.accessToken), @@ -80,22 +114,43 @@ describe('/asset', () => { user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]); + await Promise.all([ + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + ]); + for (const asset of [...user1Assets, ...user2Assets]) { expect(asset.duplicate).toBe(false); } await Promise.all([ // stats - utils.createAsset(userStats.accessToken), - utils.createAsset(userStats.accessToken, { isFavorite: true }), - utils.createAsset(userStats.accessToken, { isArchived: true }), - utils.createAsset(userStats.accessToken, { + utils.createAsset(statsUser.accessToken), + utils.createAsset(statsUser.accessToken, { isFavorite: true }), + utils.createAsset(statsUser.accessToken, { isArchived: true }), + utils.createAsset(statsUser.accessToken, { isArchived: true, isFavorite: true, assetData: { filename: 'example.mp4' }, }), ]); + // stacks + stackAssets = await Promise.all([ + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + ]); + + await updateAssets( + { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + { headers: asBearerAuth(stackUser.accessToken) }, + ); + const person1 = await utils.createPerson(user1.accessToken, { name: 'Test Person', }); @@ -106,7 +161,7 @@ describe('/asset', () => { }, 30_000); afterAll(() => { - utils.disconnectWebsocket(ws); + utils.disconnectWebsocket(websocket); }); describe('GET /asset/:id', () => { @@ -193,7 +248,7 @@ describe('/asset', () => { it('should return stats of all assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`); + .set('Authorization', `Bearer ${statsUser.accessToken}`); expect(body).toEqual({ images: 3, videos: 1, total: 4 }); expect(status).toBe(200); @@ -202,7 +257,7 @@ describe('/asset', () => { it('should return stats of all favored assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: true }); expect(status).toBe(200); @@ -212,7 +267,7 @@ describe('/asset', () => { it('should return stats of all archived assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isArchived: true }); expect(status).toBe(200); @@ -222,7 +277,7 @@ describe('/asset', () => { it('should return stats of all favored and archived assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: true, isArchived: true }); expect(status).toBe(200); @@ -232,7 +287,7 @@ describe('/asset', () => { it('should return stats of all assets neither favored nor archived', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: false, isArchived: false }); expect(status).toBe(200); @@ -488,6 +543,35 @@ describe('/asset', () => { }); describe('POST /asset/upload', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/asset/upload`); + expect(body).toEqual(errorDto.unauthorized); + expect(status).toBe(401); + }); + + const invalid = [ + { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } }, + { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, + { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, + { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, + { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, + { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, + { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, + { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, + ]; + + for (const { should, dto } of invalid) { + it(`should ${should}`, async () => { + const { status, body } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${user1.accessToken}`) + .attach('assetData', makeRandomImage(), 'example.png') + .field(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + const tests = [ { input: 'formats/jpg/el_torcal_rocks.jpg', @@ -601,7 +685,7 @@ describe('/asset', () => { ]; for (const { input, expected } of tests) { - it(`should generate a thumbnail for ${input}`, async () => { + it(`should upload and generate a thumbnail for ${input}`, async () => { const filepath = join(testAssetDir, input); const { id, duplicate } = await utils.createAsset(admin.accessToken, { assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, @@ -609,7 +693,7 @@ describe('/asset', () => { expect(duplicate).toBe(false); - await utils.waitForWebsocketEvent({ event: 'upload', assetId: id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); const asset = await utils.getAssetInfo(admin.accessToken, id); @@ -631,6 +715,57 @@ describe('/asset', () => { expect(duplicate).toBe(true); }); + it("should not upload to another user's library", async () => { + const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); + const library = libraries.find((library) => library.ownerId === user1.userId) as LibraryResponseDto; + + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${admin.accessToken}`) + .field('libraryId', library.id) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .field('duration', '0:00:00.000000') + .attach('assetData', makeRandomImage(), 'example.png'); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no asset.upload access')); + }); + + it('should update the used quota', async () => { + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .attach('assetData', makeRandomImage(), 'example.jpg'); + + expect(body).toEqual({ id: expect.any(String), duplicate: false }); + expect(status).toBe(201); + + const { body: user } = await request(app).get('/user/me').set('Authorization', `Bearer ${quotaUser.accessToken}`); + + expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); + }); + + it('should not upload an asset if it would exceed the quota', async () => { + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .attach('assetData', randomBytes(2014), 'example.jpg'); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!')); + }); + // These hashes were created by copying the image files to a Samsung phone, // exporting the video from Samsung's stock Gallery app, and hashing them locally. // This ensures that immich+exiftool are extracting the videos the same way Samsung does. @@ -660,7 +795,7 @@ describe('/asset', () => { }, }); - await utils.waitForWebsocketEvent({ event: 'upload', assetId: response.id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id }); expect(response.duplicate).toBe(false); @@ -675,7 +810,7 @@ describe('/asset', () => { describe('GET /asset/thumbnail/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`); + const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -683,12 +818,12 @@ describe('/asset', () => { it('should not include gps data for webp thumbnails', async () => { const { status, body, type } = await request(app) - .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`) + .get(`/asset/thumbnail/${locationAsset.id}?format=WEBP`) .set('Authorization', `Bearer ${admin.accessToken}`); await utils.waitForWebsocketEvent({ - event: 'upload', - assetId: assetLocation.id, + event: 'assetUpload', + id: locationAsset.id, }); expect(status).toBe(200); @@ -702,7 +837,7 @@ describe('/asset', () => { it('should not include gps data for jpeg thumbnails', async () => { const { status, body, type } = await request(app) - .get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`) + .get(`/asset/thumbnail/${locationAsset.id}?format=JPEG`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -717,7 +852,7 @@ describe('/asset', () => { describe('GET /asset/file/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`); + const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -725,14 +860,14 @@ describe('/asset', () => { it('should download the original', async () => { const { status, body, type } = await request(app) - .get(`/asset/file/${assetLocation.id}`) + .get(`/asset/file/${locationAsset.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toBeDefined(); expect(type).toBe('image/jpeg'); - const asset = await utils.getAssetInfo(admin.accessToken, assetLocation.id); + const asset = await utils.getAssetInfo(admin.accessToken, locationAsset.id); const original = await readFile(locationAssetFilepath); const originalChecksum = utils.sha1(original); @@ -742,4 +877,376 @@ describe('/asset', () => { expect(downloadChecksum).toBe(asset.checksum); }); }); + + describe('GET /asset/map-marker', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/map-marker'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + // TODO archive one of these assets + it('should get map markers for all non-archived assets', async () => { + const { status, body } = await request(app) + .get('/asset/map-marker') + .query({ isArchived: false }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(2); + expect(body).toEqual([ + { + city: 'Palisade', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(39.115), + lon: expect.closeTo(-108.400_968), + state: 'Mesa County, Colorado', + }, + { + city: 'Ralston', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(41.2203), + lon: expect.closeTo(-96.071_625), + state: 'Douglas County, Nebraska', + }, + ]); + }); + + // TODO archive one of these assets + it('should get all map markers', async () => { + const { status, body } = await request(app) + .get('/asset/map-marker') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([ + { + city: 'Palisade', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(39.115), + lon: expect.closeTo(-108.400_968), + state: 'Mesa County, Colorado', + }, + { + city: 'Ralston', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(41.2203), + lon: expect.closeTo(-96.071_625), + state: 'Douglas County, Nebraska', + }, + ]); + }); + }); + + describe('GET /asset/time-buckets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get time buckets by month', async () => { + const { status, body } = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]), + ); + }); + + it('should not allow access for unrelated shared links', async () => { + const sharedLink = await utils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Individual, + assetIds: user1Assets.map(({ id }) => id), + }); + + const { status, body } = await request(app) + .get('/asset/time-buckets') + .query({ key: sharedLink.key, size: TimeBucketSize.Month }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should get time buckets by day', async () => { + const { status, body } = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Day }); + + expect(status).toBe(200); + expect(body).toEqual([ + { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]); + }); + }); + + describe('GET /asset/time-bucket', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/time-bucket').query({ + size: TimeBucketSize.Month, + timeBucket: '1900-01-01T00:00:00.000Z', + }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should handle 5 digit years', async () => { + const { status, body } = await request(app) + .get('/asset/time-bucket') + .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + + // TODO enable date string validation while still accepting 5 digit years + // it('should fail if time bucket is invalid', async () => { + // const { status, body } = await request(app) + // .get('/asset/time-bucket') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest); + // }); + + it('should return time bucket', async () => { + const { status, body } = await request(app) + .get('/asset/time-bucket') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + + it('should return error if time bucket is requested with partners asset and archived', async () => { + const req1 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${user1.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and favorite', async () => { + const req1 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and trash', async () => { + const req = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${user1.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); + + expect(req.status).toBe(400); + expect(req.body).toEqual(errorDto.badRequest()); + }); + }); + + describe('GET /asset', () => { + it('should return stack data', async () => { + const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`); + + const stack = body.find((asset: AssetResponseDto) => asset.id === stackAssets[0].id); + + expect(status).toBe(200); + expect(stack).toEqual( + expect.objectContaining({ + stackCount: 3, + stack: + // Response includes children at the root level + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[1].id }), + expect.objectContaining({ id: stackAssets[2].id }), + ]), + }), + ); + }); + }); + + describe('PUT /asset', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put('/asset'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid parent id', async () => { + const { status, body } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); + }); + + it('should require access to the parent', async () => { + const { status, body } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should add stack children', async () => { + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); + }); + + it('should remove stack children', async () => { + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ removeParent: true, ids: [stackAssets[1].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[2].id }), + expect.objectContaining({ id: stackAssets[3].id }), + ]), + ); + }); + + it('should remove all stack children', async () => { + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).toBeUndefined(); + }); + + it('should merge stack children', async () => { + // create stack after previous test removed stack children + await updateAssets( + { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + { headers: asBearerAuth(stackUser.accessToken) }, + ); + + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[0].id }), + expect.objectContaining({ id: stackAssets[1].id }), + expect.objectContaining({ id: stackAssets[2].id }), + ]), + ); + }); + }); + + describe('PUT /asset/stack/parent', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put('/asset/stack/parent'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should make old parent child of new parent', async () => { + const { status } = await request(app) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); + + expect(status).toBe(200); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + + // new parent + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[1].id }), + expect.objectContaining({ id: stackAssets[2].id }), + expect.objectContaining({ id: stackAssets[3].id }), + ]), + ); + }); + }); }); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index de7d9ef4c5a22..9c554abc56b48 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -1,61 +1,108 @@ -import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; +import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets } from '@immich/sdk'; +import { DateTime } from 'luxon'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { app, testAssetDir, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const albums = { total: 0, count: 0, items: [], facets: [] }; +const today = DateTime.now(); describe('/search', () => { let admin: LoginResponseDto; + let websocket: Socket; + let assetFalcon: AssetFileUploadResponseDto; let assetDenali: AssetFileUploadResponseDto; - let websocket: Socket; + let assetCyclamen: AssetFileUploadResponseDto; + let assetNotocactus: AssetFileUploadResponseDto; + let assetSilver: AssetFileUploadResponseDto; + // let assetDensity: AssetFileUploadResponseDto; + // let assetPhiladelphia: AssetFileUploadResponseDto; + // let assetOrychophragmus: AssetFileUploadResponseDto; + // let assetRidge: AssetFileUploadResponseDto; + // let assetPolemonium: AssetFileUploadResponseDto; + // let assetWood: AssetFileUploadResponseDto; + let assetHeic: AssetFileUploadResponseDto; + let assetRocks: AssetFileUploadResponseDto; + let assetOneJpg6: AssetFileUploadResponseDto; + let assetOneHeic6: AssetFileUploadResponseDto; + let assetOneJpg5: AssetFileUploadResponseDto; + let assetGlarus: AssetFileUploadResponseDto; + let assetSprings: AssetFileUploadResponseDto; + let assetLast: AssetFileUploadResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); websocket = await utils.connectWebsocket(admin.accessToken); - const files: string[] = [ - '/albums/nature/prairie_falcon.jpg', - '/formats/webp/denali.webp', - '/formats/raw/Nikon/D700/philadelphia.nef', - '/albums/nature/orychophragmus_violaceus.jpg', - '/albums/nature/notocactus_minimus.jpg', - '/albums/nature/silver_fir.jpg', - '/albums/nature/tanners_ridge.jpg', - '/albums/nature/cyclamen_persicum.jpg', - '/albums/nature/polemonium_reptans.jpg', - '/albums/nature/wood_anemones.jpg', - '/formats/heic/IMG_2682.heic', - '/formats/jpg/el_torcal_rocks.jpg', - '/formats/png/density_plot.png', - '/formats/motionphoto/Samsung One UI 6.jpg', - '/formats/motionphoto/Samsung One UI 6.heic', - '/formats/motionphoto/Samsung One UI 5.jpg', - '/formats/raw/Nikon/D80/glarus.nef', - '/metadata/gps-position/thompson-springs.jpg', + const files = [ + { filename: '/albums/nature/prairie_falcon.jpg' }, + { filename: '/formats/webp/denali.webp' }, + { filename: '/albums/nature/cyclamen_persicum.jpg', dto: { isFavorite: true } }, + { filename: '/albums/nature/notocactus_minimus.jpg' }, + { filename: '/albums/nature/silver_fir.jpg' }, + { filename: '/formats/heic/IMG_2682.heic' }, + { filename: '/formats/jpg/el_torcal_rocks.jpg' }, + { filename: '/formats/motionphoto/Samsung One UI 6.jpg' }, + { filename: '/formats/motionphoto/Samsung One UI 6.heic' }, + { filename: '/formats/motionphoto/Samsung One UI 5.jpg' }, + { filename: '/formats/raw/Nikon/D80/glarus.nef', dto: { isReadOnly: true } }, + { filename: '/metadata/gps-position/thompson-springs.jpg', dto: { isArchived: true } }, + + // used for search suggestions + { filename: '/formats/png/density_plot.png' }, + { filename: '/formats/raw/Nikon/D700/philadelphia.nef' }, + { filename: '/albums/nature/orychophragmus_violaceus.jpg' }, + { filename: '/albums/nature/tanners_ridge.jpg' }, + { filename: '/albums/nature/polemonium_reptans.jpg' }, + + // last asset + { filename: '/albums/nature/wood_anemones.jpg' }, ]; const assets: AssetFileUploadResponseDto[] = []; - for (const filename of files) { + for (const { filename, dto } of files) { const bytes = await readFile(join(testAssetDir, filename)); assets.push( await utils.createAsset(admin.accessToken, { deviceAssetId: `test-${filename}`, assetData: { bytes, filename }, + ...dto, }), ); } for (const asset of assets) { - await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); } - [assetFalcon, assetDenali] = assets; + [ + assetFalcon, + assetDenali, + assetCyclamen, + assetNotocactus, + assetSilver, + assetHeic, + assetRocks, + assetOneJpg6, + assetOneHeic6, + assetOneJpg5, + assetGlarus, + assetSprings, + // assetDensity, + // assetPhiladelphia, + // assetOrychophragmus, + // assetRidge, + // assetPolemonium, + // assetWood, + ] = assets; + + assetLast = assets.at(-1) as AssetFileUploadResponseDto; + + await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); }); afterAll(async () => { @@ -69,44 +116,224 @@ describe('/search', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should search by camera make', async () => { - const { status, body } = await request(app) - .post('/search/metadata') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ make: 'Canon' }); - expect(status).toBe(200); - expect(body).toEqual({ - albums, - assets: { - count: 2, - items: expect.arrayContaining([ - expect.objectContaining({ id: assetDenali.id }), - expect.objectContaining({ id: assetFalcon.id }), - ]), - facets: [], - nextPage: null, - total: 2, - }, - }); - }); + const badTests = [ + { + should: 'should reject page as a string', + dto: { page: 'abc' }, + expected: ['page must not be less than 1', 'page must be an integer number'], + }, + { + should: 'should reject page as a decimal', + dto: { page: 1.5 }, + expected: ['page must be an integer number'], + }, + { + should: 'should reject page as a negative number', + dto: { page: -10 }, + expected: ['page must not be less than 1'], + }, + { + should: 'should reject page as 0', + dto: { page: 0 }, + expected: ['page must not be less than 1'], + }, + { + should: 'should reject size as a string', + dto: { size: 'abc' }, + expected: [ + 'size must not be greater than 1000', + 'size must not be less than 1', + 'size must be an integer number', + ], + }, + { + should: 'should reject an invalid size', + dto: { size: -1.5 }, + expected: ['size must not be less than 1', 'size must be an integer number'], + }, + ...[ + 'isArchived', + 'isFavorite', + 'isReadOnly', + 'isExternal', + 'isEncoded', + 'isMotion', + 'isOffline', + 'isVisible', + ].map((value) => ({ + should: `should reject ${value} not a boolean`, + dto: { [value]: 'immich' }, + expected: [`${value} must be a boolean value`], + })), + ]; - it('should search by camera model', async () => { - const { status, body } = await request(app) - .post('/search/metadata') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ model: 'Canon EOS 7D' }); - expect(status).toBe(200); - expect(body).toEqual({ - albums, - assets: { - count: 1, - items: [expect.objectContaining({ id: assetDenali.id })], - facets: [], - nextPage: null, - total: 1, - }, + for (const { should, dto, expected } of badTests) { + it(should, async () => { + const { status, body } = await request(app) + .post('/search/metadata') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expected)); }); - }); + } + + const searchTests = [ + { + should: 'should get my assets', + deferred: () => ({ dto: { size: 1 }, assets: [assetLast] }), + }, + { + should: 'should sort my assets in reverse', + deferred: () => ({ dto: { order: 'asc', size: 2 }, assets: [assetCyclamen, assetNotocactus] }), + }, + { + should: 'should support pagination', + deferred: () => ({ dto: { order: 'asc', size: 1, page: 2 }, assets: [assetNotocactus] }), + }, + { + should: 'should search by checksum (base64)', + deferred: () => ({ dto: { checksum: '9IXBDMjj9OrQb+1YMHprZJgZ/UQ=' }, assets: [assetCyclamen] }), + }, + { + should: 'should search by checksum (hex)', + deferred: () => ({ dto: { checksum: 'f485c10cc8e3f4ead06fed58307a6b649819fd44' }, assets: [assetCyclamen] }), + }, + { should: 'should search by id', deferred: () => ({ dto: { id: assetCyclamen.id }, assets: [assetCyclamen] }) }, + { + should: 'should search by isFavorite (true)', + deferred: () => ({ dto: { isFavorite: true }, assets: [assetCyclamen] }), + }, + { + should: 'should search by isFavorite (false)', + deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }), + }, + { + should: 'should search by isArchived (true)', + deferred: () => ({ dto: { isArchived: true }, assets: [assetSprings] }), + }, + { + should: 'should search by isArchived (false)', + deferred: () => ({ dto: { size: 1, isArchived: false }, assets: [assetLast] }), + }, + { + should: 'should search by isReadOnly (true)', + deferred: () => ({ dto: { isReadOnly: true }, assets: [assetGlarus] }), + }, + { + should: 'should search by isReadOnly (false)', + deferred: () => ({ dto: { size: 1, isReadOnly: false }, assets: [assetLast] }), + }, + { + should: 'should search by type (image)', + deferred: () => ({ dto: { size: 1, type: 'IMAGE' }, assets: [assetLast] }), + }, + { + should: 'should search by type (video)', + deferred: () => ({ + dto: { type: 'VIDEO' }, + assets: [ + // the three live motion photos + { id: expect.any(String) }, + { id: expect.any(String) }, + { id: expect.any(String) }, + ], + }), + }, + { + should: 'should search by trashedBefore', + deferred: () => ({ dto: { trashedBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }), + }, + { + should: 'should search by trashedBefore (no results)', + deferred: () => ({ dto: { trashedBefore: today.minus({ days: 1 }).toJSDate() }, assets: [] }), + }, + { + should: 'should search by trashedAfter', + deferred: () => ({ dto: { trashedAfter: today.minus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }), + }, + { + should: 'should search by trashedAfter (no results)', + deferred: () => ({ dto: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }), + }, + { + should: 'should search by takenBefore', + deferred: () => ({ dto: { size: 1, takenBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetLast] }), + }, + { + should: 'should search by takenBefore (no results)', + deferred: () => ({ dto: { takenBefore: DateTime.fromObject({ year: 1234 }).toJSDate() }, assets: [] }), + }, + { + should: 'should search by takenAfter', + deferred: () => ({ + dto: { size: 1, takenAfter: DateTime.fromObject({ year: 1234 }).toJSDate() }, + assets: [assetLast], + }), + }, + { + should: 'should search by takenAfter (no results)', + deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }), + }, + // { + // should: 'should search by originalPath', + // deferred: () => ({ + // dto: { originalPath: asset1.originalPath }, + // assets: [asset1], + // }), + // }, + { + should: 'should search by originalFilename', + deferred: () => ({ + dto: { originalFileName: 'rocks' }, + assets: [assetRocks], + }), + }, + { + should: 'should search by originalFilename with spaces', + deferred: () => ({ + dto: { originalFileName: 'Samsung One', type: 'IMAGE' }, + assets: [assetOneJpg5, assetOneJpg6, assetOneHeic6], + }), + }, + { + should: 'should search by city', + deferred: () => ({ dto: { city: 'Ralston' }, assets: [assetHeic] }), + }, + { + should: 'should search by state', + deferred: () => ({ dto: { state: 'Douglas County, Nebraska' }, assets: [assetHeic] }), + }, + { + should: 'should search by country', + deferred: () => ({ dto: { country: 'United States of America' }, assets: [assetHeic] }), + }, + { + should: 'should search by make', + deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }), + }, + { + should: 'should search by model', + deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }), + }, + ]; + + for (const { should, deferred } of searchTests) { + it(should, async () => { + const { assets, dto } = deferred(); + const { status, body } = await request(app) + .post('/search/metadata') + .send(dto) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body.assets).toBeDefined(); + expect(Array.isArray(body.assets.items)).toBe(true); + for (const [i, asset] of assets.entries()) { + expect(body.assets.items[i]).toEqual(expect.objectContaining({ id: asset.id })); + } + expect(body.assets.items).toHaveLength(assets.length); + }); + } }); describe('POST /search/smart', () => { diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 8b854eda00f36..3c3fd79269059 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -86,7 +86,7 @@ describe('/shared-link', () => { }), ]); - await deleteUser({ id: user2.userId }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUser({ id: user2.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); describe('GET /shared-link', () => { diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 3e6c2f1fc6ef6..dc2cadc498f96 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -38,7 +38,7 @@ describe('/trash', () => { const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - await utils.waitForWebsocketEvent({ event: 'delete', assetId }); + await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) }); expect(after.length).toBe(0); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index d448a605cdcde..911f25381a8df 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,27 +1,37 @@ import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk'; +import { Socket } from 'socket.io-client'; import { createUserDto, userDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('/user', () => { + let websocket: Socket; -describe('/server-info', () => { let admin: LoginResponseDto; let deletedUser: LoginResponseDto; let userToDelete: LoginResponseDto; + let userToHardDelete: LoginResponseDto; let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [deletedUser, nonAdmin, userToDelete] = await Promise.all([ + [websocket, deletedUser, nonAdmin, userToDelete, userToHardDelete] = await Promise.all([ + utils.connectWebsocket(admin.accessToken), utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user4), ]); - await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUser({ id: deletedUser.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); + }); + + afterAll(() => { + utils.disconnectWebsocket(websocket); }); describe('GET /user', () => { @@ -34,13 +44,14 @@ describe('/server-info', () => { it('should get users', async () => { const { status, body } = await request(app).get('/user').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(5); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'user1@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }), expect.objectContaining({ email: 'user3@immich.cloud' }), + expect.objectContaining({ email: 'user4@immich.cloud' }), ]), ); }); @@ -51,12 +62,13 @@ describe('/server-info', () => { .query({ isAll: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(3); + expect(body).toHaveLength(4); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }), expect.objectContaining({ email: 'user3@immich.cloud' }), + expect.objectContaining({ email: 'user4@immich.cloud' }), ]), ); }); @@ -68,13 +80,14 @@ describe('/server-info', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(5); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'user1@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }), expect.objectContaining({ email: 'user3@immich.cloud' }), + expect.objectContaining({ email: 'user4@immich.cloud' }), ]), ); }); @@ -138,13 +151,13 @@ describe('/server-info', () => { .post(`/user`) .send({ isAdmin: true, - email: 'user4@immich.cloud', + email: 'user5@immich.cloud', password: 'password123', name: 'Immich', }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toMatchObject({ - email: 'user4@immich.cloud', + email: 'user5@immich.cloud', isAdmin: false, shouldChangePassword: true, }); @@ -188,6 +201,22 @@ describe('/server-info', () => { deletedAt: expect.any(String), }); }); + + it('should hard delete user', async () => { + const { status, body } = await request(app) + .delete(`/user/${userToHardDelete.userId}`) + .send({ force: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ + id: userToHardDelete.userId, + updatedAt: expect.any(String), + deletedAt: expect.any(String), + }); + + await utils.waitForWebsocketEvent({ event: 'userDelete', id: userToHardDelete.userId, timeout: 5000 }); + }); }); describe('PUT /user', () => { diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 6a1a1b39681d1..031985c5fb257 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -21,6 +21,13 @@ export const signupDto = { }; export const createUserDto = { + create(key: string) { + return { + email: `${key}@immich.cloud`, + name: `Generated User ${key}`, + password: `password-${key}`, + }; + }, user1: { email: 'user1@immich.cloud', name: 'User 1', @@ -36,6 +43,17 @@ export const createUserDto = { name: 'User 3', password: 'password123', }, + user4: { + email: 'user4@immich.cloud', + name: 'User 4', + password: 'password123', + }, + userQuota: { + email: 'user-quota@immich.cloud', + name: 'User Quota', + password: 'password-quota', + quotaSizeInBytes: 512, + }, }; export const userDto = { diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 76e289ade25db..37892be0c86a6 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -76,6 +76,7 @@ export const signupResponseDto = { memoriesEnabled: true, quotaUsageInBytes: 0, quotaSizeInBytes: null, + status: 'active', }, }; diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index d62497b8e4e70..dde9ed22ce809 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -36,8 +36,8 @@ import { makeRandomImage } from 'src/generators'; import request from 'supertest'; type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; -type EventType = 'upload' | 'delete'; -type WaitOptions = { event: EventType; assetId: string; timeout?: number }; +type EventType = 'assetUpload' | 'assetDelete' | 'userDelete'; +type WaitOptions = { event: EventType; id: string; timeout?: number }; type AdminSetupOptions = { onboarding?: boolean }; type AssetData = { bytes?: Buffer; filename: string }; @@ -78,20 +78,21 @@ export const immichCli = async (args: string[]) => { let client: pg.Client | null = null; const events: Record> = { - upload: new Set(), - delete: new Set(), + assetUpload: new Set(), + assetDelete: new Set(), + userDelete: new Set(), }; const callbacks: Record void> = {}; const execPromise = promisify(exec); -const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => { - events[event].add(assetId); - const callback = callbacks[assetId]; +const onEvent = ({ event, id }: { event: EventType; id: string }) => { + events[event].add(id); + const callback = callbacks[id]; if (callback) { callback(); - delete callbacks[assetId]; + delete callbacks[id]; } }; @@ -104,6 +105,8 @@ export const utils = { } tables = tables || [ + // TODO e2e test for deleting a stack, since it is quite complex + 'asset_stack', 'libraries', 'shared_links', 'person', @@ -117,9 +120,17 @@ export const utils = { 'system_metadata', ]; - for (const table of tables) { - await client.query(`DELETE FROM ${table} CASCADE;`); + const sql: string[] = []; + + if (tables.includes('asset_stack')) { + sql.push('UPDATE "assets" SET "stackId" = NULL;'); } + + for (const table of tables) { + sql.push(`DELETE FROM ${table} CASCADE;`); + } + + await client.query(sql.join('\n')); } catch (error) { console.error('Failed to reset database', error); throw error; @@ -156,8 +167,9 @@ export const utils = { return new Promise((resolve) => { websocket .on('connect', () => resolve(websocket)) - .on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'upload', assetId: data.id })) - .on('on_asset_delete', (assetId: string) => onEvent({ event: 'delete', assetId })) + .on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id })) + .on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId })) + .on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId })) .connect(); }); }, @@ -172,17 +184,17 @@ export const utils = { } }, - waitForWebsocketEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise => { - console.log(`Waiting for ${event} [${assetId}]`); + waitForWebsocketEvent: async ({ event, id, timeout: ms }: WaitOptions): Promise => { + console.log(`Waiting for ${event} [${id}]`); const set = events[event]; - if (set.has(assetId)) { + if (set.has(id)) { return; } return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000); - callbacks[assetId] = () => { + callbacks[id] = () => { clearTimeout(timeout); resolve(); }; diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index fc881d29ae204..327f4fd35e561 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.98.0" +version = "1.98.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 45d32ebfcbf49..14f0b3a8174c4 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -65,6 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then make open-api npm --prefix open-api/typescript-sdk version "$SERVER_PUMP" npm --prefix web version "$SERVER_PUMP" + npm --prefix e2e version "$SERVER_PUMP" npm --prefix web i --package-lock-only npm --prefix cli i --package-lock-only npm --prefix e2e i --package-lock-only diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index a656dbc4cdbff..a11d602676b2c 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 126, - "android.injected.version.name" => "1.98.0", + "android.injected.version.code" => 128, + "android.injected.version.name" => "1.98.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 575edaa4e4902..0dcf80996487d 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6081988b7aaf6..a9ac5b33817e9 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -180,4 +180,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 457160ad355c1..dcfe3dc85ed1f 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -379,7 +379,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 141; + CURRENT_PROJECT_VERSION = 144; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -515,7 +515,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 141; + CURRENT_PROJECT_VERSION = 144; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 141; + CURRENT_PROJECT_VERSION = 144; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 6d745e984c30d..310133e0bbbbb 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,11 +55,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.97.0 + 1.98.2 CFBundleSignature ???? CFBundleVersion - 141 + 144 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 328fe1536ec18..00cbeb8ac0e87 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.98.0" + version_number: "1.98.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 129dddb341555..f1e83434b4e18 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + diff --git a/mobile/lib/modules/archive/providers/archive_asset_provider.dart b/mobile/lib/modules/archive/providers/archive_asset_provider.dart index 579ace7c50951..5441293f7798f 100644 --- a/mobile/lib/modules/archive/providers/archive_asset_provider.dart +++ b/mobile/lib/modules/archive/providers/archive_asset_provider.dart @@ -17,6 +17,6 @@ final archiveProvider = StreamProvider((ref) { .filter() .isArchivedEqualTo(true) .isTrashedEqualTo(false) - .sortByFileCreatedAt(); + .sortByFileCreatedAtDesc(); return renderListGenerator(query, ref); }); diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index bdd8e1d4bcff2..ddebdaf77df08 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -58,6 +58,7 @@ doc/CreateTagDto.md doc/CreateUserDto.md doc/CuratedLocationsResponseDto.md doc/CuratedObjectsResponseDto.md +doc/DeleteUserDto.md doc/DownloadApi.md doc/DownloadArchiveInfo.md doc/DownloadInfoDto.md @@ -184,6 +185,7 @@ doc/UserApi.md doc/UserAvatarColor.md doc/UserDto.md doc/UserResponseDto.md +doc/UserStatus.md doc/ValidateAccessTokenResponseDto.md doc/ValidateLibraryDto.md doc/ValidateLibraryImportPathResponseDto.md @@ -268,6 +270,7 @@ lib/model/create_tag_dto.dart lib/model/create_user_dto.dart lib/model/curated_locations_response_dto.dart lib/model/curated_objects_response_dto.dart +lib/model/delete_user_dto.dart lib/model/download_archive_info.dart lib/model/download_info_dto.dart lib/model/download_response_dto.dart @@ -380,6 +383,7 @@ lib/model/usage_by_user_dto.dart lib/model/user_avatar_color.dart lib/model/user_dto.dart lib/model/user_response_dto.dart +lib/model/user_status.dart lib/model/validate_access_token_response_dto.dart lib/model/validate_library_dto.dart lib/model/validate_library_import_path_response_dto.dart @@ -441,6 +445,7 @@ test/create_tag_dto_test.dart test/create_user_dto_test.dart test/curated_locations_response_dto_test.dart test/curated_objects_response_dto_test.dart +test/delete_user_dto_test.dart test/download_api_test.dart test/download_archive_info_test.dart test/download_info_dto_test.dart @@ -567,6 +572,7 @@ test/user_api_test.dart test/user_avatar_color_test.dart test/user_dto_test.dart test/user_response_dto_test.dart +test/user_status_test.dart test/validate_access_token_response_dto_test.dart test/validate_library_dto_test.dart test/validate_library_import_path_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 536c671b8df40..d8ff4d30fed86 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.98.0 +- API version: 1.98.2 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements @@ -264,6 +264,7 @@ Class | Method | HTTP request | Description - [CreateUserDto](doc//CreateUserDto.md) - [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md) - [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md) + - [DeleteUserDto](doc//DeleteUserDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponseDto](doc//DownloadResponseDto.md) @@ -376,6 +377,7 @@ Class | Method | HTTP request | Description - [UserAvatarColor](doc//UserAvatarColor.md) - [UserDto](doc//UserDto.md) - [UserResponseDto](doc//UserResponseDto.md) + - [UserStatus](doc//UserStatus.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) - [ValidateLibraryDto](doc//ValidateLibraryDto.md) - [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md) diff --git a/mobile/openapi/doc/DeleteUserDto.md b/mobile/openapi/doc/DeleteUserDto.md new file mode 100644 index 0000000000000..50894b61675aa --- /dev/null +++ b/mobile/openapi/doc/DeleteUserDto.md @@ -0,0 +1,15 @@ +# openapi.model.DeleteUserDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**force** | **bool** | | [optional] + +[[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/PartnerResponseDto.md b/mobile/openapi/doc/PartnerResponseDto.md index ce45b32594696..5d0c4ddf37774 100644 --- a/mobile/openapi/doc/PartnerResponseDto.md +++ b/mobile/openapi/doc/PartnerResponseDto.md @@ -22,6 +22,7 @@ Name | Type | Description | Notes **quotaSizeInBytes** | **int** | | **quotaUsageInBytes** | **int** | | **shouldChangePassword** | **bool** | | +**status** | [**UserStatus**](UserStatus.md) | | **storageLabel** | **String** | | **updatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/UserApi.md b/mobile/openapi/doc/UserApi.md index 62f3148061829..61df5d4de06fc 100644 --- a/mobile/openapi/doc/UserApi.md +++ b/mobile/openapi/doc/UserApi.md @@ -182,7 +182,7 @@ void (empty response body) [[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) # **deleteUser** -> UserResponseDto deleteUser(id) +> UserResponseDto deleteUser(id, deleteUserDto) @@ -206,9 +206,10 @@ import 'package:openapi/api.dart'; final api_instance = UserApi(); final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final deleteUserDto = DeleteUserDto(); // DeleteUserDto | try { - final result = api_instance.deleteUser(id); + final result = api_instance.deleteUser(id, deleteUserDto); print(result); } catch (e) { print('Exception when calling UserApi->deleteUser: $e\n'); @@ -220,6 +221,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **id** | **String**| | + **deleteUserDto** | [**DeleteUserDto**](DeleteUserDto.md)| | ### Return type @@ -231,7 +233,7 @@ Name | Type | Description | Notes ### HTTP request headers - - **Content-Type**: Not defined + - **Content-Type**: application/json - **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) diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index 700a5b849efd1..69d85fbbd4b10 100644 --- a/mobile/openapi/doc/UserResponseDto.md +++ b/mobile/openapi/doc/UserResponseDto.md @@ -21,6 +21,7 @@ Name | Type | Description | Notes **quotaSizeInBytes** | **int** | | **quotaUsageInBytes** | **int** | | **shouldChangePassword** | **bool** | | +**status** | [**UserStatus**](UserStatus.md) | | **storageLabel** | **String** | | **updatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/UserStatus.md b/mobile/openapi/doc/UserStatus.md new file mode 100644 index 0000000000000..02abb4eff9385 --- /dev/null +++ b/mobile/openapi/doc/UserStatus.md @@ -0,0 +1,14 @@ +# openapi.model.UserStatus + +## 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/lib/api.dart b/mobile/openapi/lib/api.dart index 0a093e45365b2..5b49d8d67f950 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -99,6 +99,7 @@ part 'model/create_tag_dto.dart'; part 'model/create_user_dto.dart'; part 'model/curated_locations_response_dto.dart'; part 'model/curated_objects_response_dto.dart'; +part 'model/delete_user_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response_dto.dart'; @@ -211,6 +212,7 @@ part 'model/usage_by_user_dto.dart'; part 'model/user_avatar_color.dart'; part 'model/user_dto.dart'; part 'model/user_response_dto.dart'; +part 'model/user_status.dart'; part 'model/validate_access_token_response_dto.dart'; part 'model/validate_library_dto.dart'; part 'model/validate_library_import_path_response_dto.dart'; diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index f92b8fe9f5b80..241c1698c346b 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -157,19 +157,21 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future deleteUserWithHttpInfo(String id,) async { + /// + /// * [DeleteUserDto] deleteUserDto (required): + Future deleteUserWithHttpInfo(String id, DeleteUserDto deleteUserDto,) async { // ignore: prefer_const_declarations final path = r'/user/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody; + Object? postBody = deleteUserDto; final queryParams = []; final headerParams = {}; final formParams = {}; - const contentTypes = []; + const contentTypes = ['application/json']; return apiClient.invokeAPI( @@ -186,8 +188,10 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future deleteUser(String id,) async { - final response = await deleteUserWithHttpInfo(id,); + /// + /// * [DeleteUserDto] deleteUserDto (required): + Future deleteUser(String id, DeleteUserDto deleteUserDto,) async { + final response = await deleteUserWithHttpInfo(id, deleteUserDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5e5f702996d9a..312153788c5a0 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -280,6 +280,8 @@ class ApiClient { return CuratedLocationsResponseDto.fromJson(value); case 'CuratedObjectsResponseDto': return CuratedObjectsResponseDto.fromJson(value); + case 'DeleteUserDto': + return DeleteUserDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -504,6 +506,8 @@ class ApiClient { return UserDto.fromJson(value); case 'UserResponseDto': return UserResponseDto.fromJson(value); + case 'UserStatus': + return UserStatusTypeTransformer().decode(value); case 'ValidateAccessTokenResponseDto': return ValidateAccessTokenResponseDto.fromJson(value); case 'ValidateLibraryDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index f37ba588a3128..d186845d94dc3 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -136,6 +136,9 @@ String parameterToString(dynamic value) { if (value is UserAvatarColor) { return UserAvatarColorTypeTransformer().encode(value).toString(); } + if (value is UserStatus) { + return UserStatusTypeTransformer().encode(value).toString(); + } if (value is VideoCodec) { return VideoCodecTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/delete_user_dto.dart b/mobile/openapi/lib/model/delete_user_dto.dart new file mode 100644 index 0000000000000..d62f40b1ee607 --- /dev/null +++ b/mobile/openapi/lib/model/delete_user_dto.dart @@ -0,0 +1,107 @@ +// +// 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 DeleteUserDto { + /// Returns a new [DeleteUserDto] instance. + DeleteUserDto({ + this.force, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? force; + + @override + bool operator ==(Object other) => identical(this, other) || other is DeleteUserDto && + other.force == force; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (force == null ? 0 : force!.hashCode); + + @override + String toString() => 'DeleteUserDto[force=$force]'; + + Map toJson() { + final json = {}; + if (this.force != null) { + json[r'force'] = this.force; + } else { + // json[r'force'] = null; + } + return json; + } + + /// Returns a new [DeleteUserDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DeleteUserDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return DeleteUserDto( + force: mapValueOfType(json, r'force'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DeleteUserDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DeleteUserDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DeleteUserDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DeleteUserDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 008e0c4f2673a..37602d04b72a2 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -27,6 +27,7 @@ class PartnerResponseDto { required this.quotaSizeInBytes, required this.quotaUsageInBytes, required this.shouldChangePassword, + required this.status, required this.storageLabel, required this.updatedAt, }); @@ -71,6 +72,8 @@ class PartnerResponseDto { bool shouldChangePassword; + UserStatus status; + String? storageLabel; DateTime updatedAt; @@ -91,6 +94,7 @@ class PartnerResponseDto { other.quotaSizeInBytes == quotaSizeInBytes && other.quotaUsageInBytes == quotaUsageInBytes && other.shouldChangePassword == shouldChangePassword && + other.status == status && other.storageLabel == storageLabel && other.updatedAt == updatedAt; @@ -111,11 +115,12 @@ class PartnerResponseDto { (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + (shouldChangePassword.hashCode) + + (status.hashCode) + (storageLabel == null ? 0 : storageLabel!.hashCode) + (updatedAt.hashCode); @override - String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -153,6 +158,7 @@ class PartnerResponseDto { // json[r'quotaUsageInBytes'] = null; } json[r'shouldChangePassword'] = this.shouldChangePassword; + json[r'status'] = this.status; if (this.storageLabel != null) { json[r'storageLabel'] = this.storageLabel; } else { @@ -184,6 +190,7 @@ class PartnerResponseDto { quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, + status: UserStatus.fromJson(json[r'status'])!, storageLabel: mapValueOfType(json, r'storageLabel'), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); @@ -245,6 +252,7 @@ class PartnerResponseDto { 'quotaSizeInBytes', 'quotaUsageInBytes', 'shouldChangePassword', + 'status', 'storageLabel', 'updatedAt', }; diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index d4e0bf07dd3b2..df68128e714ba 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -26,6 +26,7 @@ class UserResponseDto { required this.quotaSizeInBytes, required this.quotaUsageInBytes, required this.shouldChangePassword, + required this.status, required this.storageLabel, required this.updatedAt, }); @@ -62,6 +63,8 @@ class UserResponseDto { bool shouldChangePassword; + UserStatus status; + String? storageLabel; DateTime updatedAt; @@ -81,6 +84,7 @@ class UserResponseDto { other.quotaSizeInBytes == quotaSizeInBytes && other.quotaUsageInBytes == quotaUsageInBytes && other.shouldChangePassword == shouldChangePassword && + other.status == status && other.storageLabel == storageLabel && other.updatedAt == updatedAt; @@ -100,11 +104,12 @@ class UserResponseDto { (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + (shouldChangePassword.hashCode) + + (status.hashCode) + (storageLabel == null ? 0 : storageLabel!.hashCode) + (updatedAt.hashCode); @override - String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -137,6 +142,7 @@ class UserResponseDto { // json[r'quotaUsageInBytes'] = null; } json[r'shouldChangePassword'] = this.shouldChangePassword; + json[r'status'] = this.status; if (this.storageLabel != null) { json[r'storageLabel'] = this.storageLabel; } else { @@ -167,6 +173,7 @@ class UserResponseDto { quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, + status: UserStatus.fromJson(json[r'status'])!, storageLabel: mapValueOfType(json, r'storageLabel'), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); @@ -228,6 +235,7 @@ class UserResponseDto { 'quotaSizeInBytes', 'quotaUsageInBytes', 'shouldChangePassword', + 'status', 'storageLabel', 'updatedAt', }; diff --git a/mobile/openapi/lib/model/user_status.dart b/mobile/openapi/lib/model/user_status.dart new file mode 100644 index 0000000000000..cbbe1b56d9840 --- /dev/null +++ b/mobile/openapi/lib/model/user_status.dart @@ -0,0 +1,88 @@ +// +// 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 UserStatus { + /// Instantiate a new enum with the provided [value]. + const UserStatus._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const active = UserStatus._(r'active'); + static const removing = UserStatus._(r'removing'); + static const deleted = UserStatus._(r'deleted'); + + /// List of all possible values in this [enum][UserStatus]. + static const values = [ + active, + removing, + deleted, + ]; + + static UserStatus? fromJson(dynamic value) => UserStatusTypeTransformer().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 = UserStatus.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [UserStatus] to String, +/// and [decode] dynamic data back to [UserStatus]. +class UserStatusTypeTransformer { + factory UserStatusTypeTransformer() => _instance ??= const UserStatusTypeTransformer._(); + + const UserStatusTypeTransformer._(); + + String encode(UserStatus data) => data.value; + + /// Decodes a [dynamic value][data] to a UserStatus. + /// + /// 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. + UserStatus? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'active': return UserStatus.active; + case r'removing': return UserStatus.removing; + case r'deleted': return UserStatus.deleted; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [UserStatusTypeTransformer] instance. + static UserStatusTypeTransformer? _instance; +} + diff --git a/mobile/openapi/test/delete_user_dto_test.dart b/mobile/openapi/test/delete_user_dto_test.dart new file mode 100644 index 0000000000000..475681d420ae1 --- /dev/null +++ b/mobile/openapi/test/delete_user_dto_test.dart @@ -0,0 +1,27 @@ +// +// 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 DeleteUserDto +void main() { + // final instance = DeleteUserDto(); + + group('test DeleteUserDto', () { + // bool force + test('to test the property `force`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/partner_response_dto_test.dart b/mobile/openapi/test/partner_response_dto_test.dart index 7fce31d5ebd46..2eef7f0c83497 100644 --- a/mobile/openapi/test/partner_response_dto_test.dart +++ b/mobile/openapi/test/partner_response_dto_test.dart @@ -86,6 +86,11 @@ void main() { // TODO }); + // UserStatus status + test('to test the property `status`', () async { + // TODO + }); + // String storageLabel test('to test the property `storageLabel`', () async { // TODO diff --git a/mobile/openapi/test/user_api_test.dart b/mobile/openapi/test/user_api_test.dart index b0a3ba85f1fb6..61df36243dad0 100644 --- a/mobile/openapi/test/user_api_test.dart +++ b/mobile/openapi/test/user_api_test.dart @@ -32,7 +32,7 @@ void main() { // TODO }); - //Future deleteUser(String id) async + //Future deleteUser(String id, DeleteUserDto deleteUserDto) async test('test deleteUser', () async { // TODO }); diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index d0fdf97e12954..71fa57f488280 100644 --- a/mobile/openapi/test/user_response_dto_test.dart +++ b/mobile/openapi/test/user_response_dto_test.dart @@ -81,6 +81,11 @@ void main() { // TODO }); + // UserStatus status + test('to test the property `status`', () async { + // TODO + }); + // String storageLabel test('to test the property `storageLabel`', () async { // TODO diff --git a/mobile/openapi/test/user_status_test.dart b/mobile/openapi/test/user_status_test.dart new file mode 100644 index 0000000000000..88abba0459225 --- /dev/null +++ b/mobile/openapi/test/user_status_test.dart @@ -0,0 +1,21 @@ +// +// 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 UserStatus +void main() { + + group('test UserStatus', () { + + }); + +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d0ab2a8ac8b00..85f240ae9510e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.98.0+126 +version: 1.98.2+128 isar_version: &isar_version 3.1.0+1 environment: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8819825b9175d..2540baf7755e5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2358,6 +2358,7 @@ "required": false, "in": "query", "schema": { + "format": "uuid", "type": "array", "items": { "type": "string" @@ -4630,7 +4631,7 @@ "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -4768,7 +4769,7 @@ "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -6402,6 +6403,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteUserDto" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -6476,7 +6487,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.98.0", + "version": "1.98.2", "contact": {} }, "tags": [], @@ -7750,6 +7761,14 @@ ], "type": "object" }, + "DeleteUserDto": { + "properties": { + "force": { + "type": "boolean" + } + }, + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -8463,6 +8482,7 @@ }, "personIds": { "items": { + "format": "uuid", "type": "string" }, "type": "array" @@ -8616,6 +8636,9 @@ "shouldChangePassword": { "type": "boolean" }, + "status": { + "$ref": "#/components/schemas/UserStatus" + }, "storageLabel": { "nullable": true, "type": "string" @@ -8638,6 +8661,7 @@ "quotaSizeInBytes", "quotaUsageInBytes", "shouldChangePassword", + "status", "storageLabel", "updatedAt" ], @@ -9589,6 +9613,7 @@ }, "personIds": { "items": { + "format": "uuid", "type": "string" }, "type": "array" @@ -10561,6 +10586,9 @@ "shouldChangePassword": { "type": "boolean" }, + "status": { + "$ref": "#/components/schemas/UserStatus" + }, "storageLabel": { "nullable": true, "type": "string" @@ -10583,11 +10611,20 @@ "quotaSizeInBytes", "quotaUsageInBytes", "shouldChangePassword", + "status", "storageLabel", "updatedAt" ], "type": "object" }, + "UserStatus": { + "enum": [ + "active", + "removing", + "deleted" + ], + "type": "string" + }, "ValidateAccessTokenResponseDto": { "properties": { "authStatus": { diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index ff333b7f77eee..0c223083533e3 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 2bd1cfddd3cd8..ec5e78facb636 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.2", "description": "", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e9ce467127c56..acf540aff1df5 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.98.0 + * 1.98.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -75,6 +75,7 @@ export type UserResponseDto = { quotaSizeInBytes: number | null; quotaUsageInBytes: number | null; shouldChangePassword: boolean; + status: UserStatus; storageLabel: string | null; updatedAt: string; }; @@ -518,6 +519,7 @@ export type PartnerResponseDto = { quotaSizeInBytes: number | null; quotaUsageInBytes: number | null; shouldChangePassword: boolean; + status: UserStatus; storageLabel: string | null; updatedAt: string; }; @@ -994,6 +996,9 @@ export type CreateProfileImageResponseDto = { profileImagePath: string; userId: string; }; +export type DeleteUserDto = { + force?: boolean; +}; export function getActivities({ albumId, assetId, level, $type, userId }: { albumId: string; assetId?: string; @@ -2205,7 +2210,7 @@ export function searchMetadata({ metadataSearchDto }: { metadataSearchDto: MetadataSearchDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; + status: 200; data: SearchResponseDto; }>("/search/metadata", oazapfts.json({ ...opts, @@ -2243,7 +2248,7 @@ export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; + status: 200; data: SearchResponseDto; }>("/search/smart", oazapfts.json({ ...opts, @@ -2678,16 +2683,18 @@ export function getProfileImage({ id }: { ...opts })); } -export function deleteUser({ id }: { +export function deleteUser({ id, deleteUserDto }: { id: string; + deleteUserDto: DeleteUserDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto; - }>(`/user/${encodeURIComponent(id)}`, { + }>(`/user/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, - method: "DELETE" - })); + method: "DELETE", + body: deleteUserDto + }))); } export function restoreUser({ id }: { id: string; @@ -2724,6 +2731,11 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum UserStatus { + Active = "active", + Removing = "removing", + Deleted = "deleted" +} export enum TagTypeEnum { Object = "OBJECT", Face = "FACE", diff --git a/server/e2e/api/jest-e2e.json b/server/e2e/api/jest-e2e.json deleted file mode 100644 index 9fd67774f3e40..0000000000000 --- a/server/e2e/api/jest-e2e.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "modulePaths": [""], - "rootDir": "../..", - "globalSetup": "/e2e/api/setup.ts", - "testEnvironment": "node", - "testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"], - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "/src/**/*.(t|j)s", - "!/src/**/*.spec.(t|s)s", - "!/src/infra/migrations/**" - ], - "coverageDirectory": "./coverage", - "moduleNameMapper": { - "^@test(|/.*)$": "/test/$1", - "^@app/immich(|/.*)$": "/src/immich/$1", - "^@app/infra(|/.*)$": "/src/infra/$1", - "^@app/domain(|/.*)$": "/src/domain/$1" - } -} diff --git a/server/e2e/api/setup.ts b/server/e2e/api/setup.ts deleted file mode 100644 index 88f2f598bdbb0..0000000000000 --- a/server/e2e/api/setup.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import path from 'node:path'; - -export default async () => { - let IMMICH_TEST_ASSET_PATH: string = ''; - - if (process.env.IMMICH_TEST_ASSET_PATH === undefined) { - IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`); - process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH; - } else { - IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; - } - - const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0') - .withDatabase('immich') - .withUsername('postgres') - .withPassword('postgres') - .withReuse() - .withCommand(['-c', 'fsync=off', '-c', 'shared_preload_libraries=vectors.so']) - .start(); - - process.env.DB_URL = pg.getConnectionUri(); - process.env.NODE_ENV = 'development'; - process.env.TZ = 'Z'; - - if (process.env.LOG_LEVEL === undefined) { - process.env.LOG_LEVEL = 'fatal'; - } -}; diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts deleted file mode 100644 index 6badd4c67460e..0000000000000 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ /dev/null @@ -1,1151 +0,0 @@ -import { - AssetResponseDto, - IAssetRepository, - IPersonRepository, - LibraryResponseDto, - LoginResponseDto, - TimeBucketSize, - WithoutProperty, - mapAsset, - usePagination, -} from '@app/domain'; -import { AssetController } from '@app/immich'; -import { AssetEntity, AssetStackEntity, AssetType, SharedLinkType } from '@app/infra/entities'; -import { AssetRepository } from '@app/infra/repositories'; -import { INestApplication } from '@nestjs/common'; -import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { assetApi } from 'e2e/client/asset-api'; -import { randomBytes } from 'node:crypto'; -import request from 'supertest'; -import { api } from '../../client'; -import { generateAsset, testApp, today, yesterday } from '../utils'; - -const makeUploadDto = (options?: { omit: string }): Record => { - const dto: Record = { - deviceAssetId: 'example-image', - deviceId: 'TEST', - fileCreatedAt: new Date().toISOString(), - fileModifiedAt: new Date().toISOString(), - isFavorite: 'testing', - duration: '0:00:00.000000', - }; - - const omit = options?.omit; - if (omit) { - delete dto[omit]; - } - - return dto; -}; - -describe(`${AssetController.name} (e2e)`, () => { - let app: INestApplication; - let server: any; - let assetRepository: IAssetRepository; - let admin: LoginResponseDto; - let user1: LoginResponseDto; - let user2: LoginResponseDto; - let userWithQuota: LoginResponseDto; - let libraries: LibraryResponseDto[]; - let asset1: AssetResponseDto; - let asset2: AssetResponseDto; - let asset3: AssetResponseDto; - let asset4: AssetResponseDto; - let asset5: AssetResponseDto; - let asset6: AssetResponseDto; - - const createAsset = async ( - loginResponse: LoginResponseDto, - fileCreatedAt: Date, - other: Partial = {}, - ) => { - const asset = await assetRepository.create( - generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }), - ); - - return mapAsset(asset); - }; - - beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); - assetRepository = app.get(IAssetRepository); - - await testApp.reset(); - - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - - await Promise.all([ - api.userApi.create(server, admin.accessToken, userDto.user1), - api.userApi.create(server, admin.accessToken, userDto.user2), - api.userApi.create(server, admin.accessToken, userDto.userWithQuota), - ]); - - [user1, user2, userWithQuota] = await Promise.all([ - api.authApi.login(server, userDto.user1), - api.authApi.login(server, userDto.user2), - api.authApi.login(server, userDto.userWithQuota), - ]); - - libraries = await api.libraryApi.getAll(server, admin.accessToken); - }); - - beforeEach(async () => { - await testApp.reset({ entities: [AssetEntity, AssetStackEntity] }); - - [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([ - createAsset(user1, new Date('1970-01-01')), - createAsset(user1, new Date('1970-02-10')), - createAsset(user1, new Date('1970-02-11'), { - isFavorite: true, - isExternal: true, - isReadOnly: true, - type: AssetType.VIDEO, - fileCreatedAt: yesterday.toJSDate(), - fileModifiedAt: yesterday.toJSDate(), - createdAt: yesterday.toJSDate(), - updatedAt: yesterday.toJSDate(), - localDateTime: yesterday.toJSDate(), - encodedVideoPath: '/path/to/encoded-video.mp4', - webpPath: '/path/to/thumb.webp', - resizePath: '/path/to/thumb.jpg', - }), - createAsset(user2, new Date('1970-01-01')), - createAsset(user1, new Date('1970-01-01'), { - deletedAt: yesterday.toJSDate(), - }), - createAsset(user1, new Date('1970-02-11'), { - isArchived: true, - }), - ]); - - await assetRepository.upsertExif({ - assetId: asset3.id, - latitude: 90, - longitude: 90, - city: 'Immich', - state: 'Nebraska', - country: 'United States', - make: 'Cannon', - model: 'EOS Rebel T7', - lensModel: 'Fancy lens', - }); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - describe('GET /assets', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/assets'); - expect(body).toEqual(errorStub.unauthorized); - expect(status).toBe(401); - }); - - const badTests = [ - // - { - should: 'should reject page as a string', - query: { page: 'abc' }, - expected: ['page must not be less than 1', 'page must be an integer number'], - }, - { - should: 'should reject page as a decimal', - query: { page: 1.5 }, - expected: ['page must be an integer number'], - }, - { - should: 'should reject page as a negative number', - query: { page: -10 }, - expected: ['page must not be less than 1'], - }, - { - should: 'should reject page as 0', - query: { page: 0 }, - expected: ['page must not be less than 1'], - }, - { - should: 'should reject size as a string', - query: { size: 'abc' }, - expected: [ - 'size must not be greater than 1000', - 'size must not be less than 1', - 'size must be an integer number', - ], - }, - { - should: 'should reject an invalid size', - query: { size: -1.5 }, - expected: ['size must not be less than 1', 'size must be an integer number'], - }, - ...[ - 'isArchived', - 'isFavorite', - 'isReadOnly', - 'isExternal', - 'isEncoded', - 'isMotion', - 'isOffline', - 'isVisible', - ].map((value) => ({ - should: `should reject ${value} not a boolean`, - query: { [value]: 'immich' }, - expected: [`${value} must be a boolean value`], - })), - ]; - - for (const { should, query, expected } of badTests) { - it(should, async () => { - const { status, body } = await request(server) - .get('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query(query); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(expected)); - }); - } - - const searchTests = [ - { - should: 'should only return my own assets', - deferred: () => ({ - query: {}, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should sort my assets in reverse', - deferred: () => ({ - query: { order: 'asc' }, - assets: [asset1, asset2, asset3], - }), - }, - { - should: 'should support custom page sizes', - deferred: () => ({ - query: { size: 1 }, - assets: [asset3], - }), - }, - { - should: 'should support pagination', - deferred: () => ({ - query: { size: 1, page: 2 }, - assets: [asset2], - }), - }, - { - should: 'should search by checksum (base64)', - deferred: () => ({ - query: { checksum: asset1.checksum }, - assets: [asset1], - }), - }, - { - should: 'should search by checksum (hex)', - deferred: () => ({ - query: { checksum: Buffer.from(asset1.checksum, 'base64').toString('hex') }, - assets: [asset1], - }), - }, - { - should: 'should search by id', - deferred: () => ({ - query: { id: asset1.id }, - assets: [asset1], - }), - }, - { - should: 'should search by isFavorite (true)', - deferred: () => ({ - query: { isFavorite: true }, - assets: [asset3], - }), - }, - { - should: 'should search by isFavorite (false)', - deferred: () => ({ - query: { isFavorite: false }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by isArchived (true)', - deferred: () => ({ - query: { isArchived: true }, - assets: [asset6], - }), - }, - { - should: 'should search by isArchived (false)', - deferred: () => ({ - query: { isArchived: false }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by isReadOnly (true)', - deferred: () => ({ - query: { isReadOnly: true }, - assets: [asset3], - }), - }, - { - should: 'should search by isReadOnly (false)', - deferred: () => ({ - query: { isReadOnly: false }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by type (image)', - deferred: () => ({ - query: { type: 'IMAGE' }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by type (video)', - deferred: () => ({ - query: { type: 'VIDEO' }, - assets: [asset3], - }), - }, - { - should: 'should search by withArchived (true)', - deferred: () => ({ - query: { withArchived: true }, - assets: [asset3, asset6, asset2, asset1], - }), - }, - { - should: 'should search by withArchived (false)', - deferred: () => ({ - query: { withArchived: false }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by createdBefore', - deferred: () => ({ - query: { createdBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by createdBefore (no results)', - deferred: () => ({ - query: { createdBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by createdAfter', - deferred: () => ({ - query: { createdAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by createdAfter (no results)', - deferred: () => ({ - query: { createdAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by updatedBefore', - deferred: () => ({ - query: { updatedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by updatedBefore (no results)', - deferred: () => ({ - query: { updatedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by updatedAfter', - deferred: () => ({ - query: { updatedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by updatedAfter (no results)', - deferred: () => ({ - query: { updatedAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by trashedBefore', - deferred: () => ({ - query: { trashedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset5], - }), - }, - { - should: 'should search by trashedBefore (no results)', - deferred: () => ({ - query: { trashedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by trashedAfter', - deferred: () => ({ - query: { trashedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset5], - }), - }, - { - should: 'should search by trashedAfter (no results)', - deferred: () => ({ - query: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by takenBefore', - deferred: () => ({ - query: { takenBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by takenBefore (no results)', - deferred: () => ({ - query: { takenBefore: yesterday.minus({ years: 100 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by takenAfter', - deferred: () => ({ - query: { takenAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by takenAfter (no results)', - deferred: () => ({ - query: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by originalPath', - deferred: () => ({ - query: { originalPath: asset1.originalPath }, - assets: [asset1], - }), - }, - { - should: 'should search by originalFilename', - deferred: () => ({ - query: { originalFileName: asset1.originalFileName }, - assets: [asset1], - }), - }, - { - should: 'should search by encodedVideoPath', - deferred: () => ({ - query: { encodedVideoPath: '/path/to/encoded-video.mp4' }, - assets: [asset3], - }), - }, - { - should: 'should search by resizePath', - deferred: () => ({ - query: { resizePath: '/path/to/thumb.jpg' }, - assets: [asset3], - }), - }, - { - should: 'should search by webpPath', - deferred: () => ({ - query: { webpPath: '/path/to/thumb.webp' }, - assets: [asset3], - }), - }, - { - should: 'should search by city', - deferred: () => ({ - query: { city: 'Immich' }, - assets: [asset3], - }), - }, - { - should: 'should search by state', - deferred: () => ({ - query: { state: 'Nebraska' }, - assets: [asset3], - }), - }, - { - should: 'should search by country', - deferred: () => ({ - query: { country: 'United States' }, - assets: [asset3], - }), - }, - { - should: 'should search by make', - deferred: () => ({ - query: { make: 'Cannon' }, - assets: [asset3], - }), - }, - { - should: 'should search by country', - deferred: () => ({ - query: { model: 'EOS Rebel T7' }, - assets: [asset3], - }), - }, - { - should: 'should search by lensModel', - deferred: () => ({ - query: { lensModel: 'Fancy lens' }, - assets: [asset3], - }), - }, - ]; - - for (const { should, deferred } of searchTests) { - it(should, async () => { - const { assets, query } = deferred(); - const { status, body } = await request(server) - .get('/assets') - .query(query) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body.length).toBe(assets.length); - for (const [i, asset] of assets.entries()) { - expect(body[i]).toEqual(expect.objectContaining({ id: asset.id })); - } - }); - } - - it('should return stack data', async () => { - const parentId = asset1.id; - const childIds = [asset2.id, asset3.id]; - await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: parentId, ids: childIds }); - - const body = await assetApi.getAllAssets(server, user1.accessToken); - // Response includes parent with stack children count - const parentDto = body.find((a) => a.id == parentId); - expect(parentDto?.stackCount).toEqual(3); - - // Response includes children at the root level - expect.arrayContaining([expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })]); - }); - }); - - describe('POST /asset/upload', () => { - it('should require authentication', async () => { - const { status, body } = await request(server) - .post(`/asset/upload`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', false) - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.jpg'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - const invalid = [ - { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } }, - { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, - { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, - { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, - { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, - { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, - { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, - { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, - ]; - - for (const { should, dto } of invalid) { - it(`should ${should}`, async () => { - const { status, body } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .attach('assetData', randomBytes(32), 'example.jpg') - .field(dto); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - }); - } - - it('should upload a new asset', async () => { - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.jpg'); - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const asset = await api.assetApi.get(server, user1.accessToken, body.id); - expect(asset).toMatchObject({ id: body.id, isFavorite: true }); - }); - - it('should have correct original file name and extension (simple)', async () => { - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.jpg'); - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const asset = await api.assetApi.get(server, user1.accessToken, body.id); - expect(asset).toMatchObject({ id: body.id, originalFileName: 'example.jpg' }); - }); - - it('should have correct original file name and extension (complex)', async () => { - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.complex.ext.jpg'); - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const asset = await api.assetApi.get(server, user1.accessToken, body.id); - expect(asset).toMatchObject({ id: body.id, originalFileName: 'example.complex.ext.jpg' }); - }); - - it('should not upload the same asset twice', async () => { - const content = randomBytes(32); - await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', false) - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(200); - expect(body.duplicate).toBe(true); - }); - - it("should not upload to another user's library", async () => { - const content = randomBytes(32); - const [library] = await api.libraryApi.getAll(server, admin.accessToken); - await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); - - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('libraryId', library.id) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', false) - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Not found or no asset.upload access')); - }); - - it('should update the used quota', async () => { - const content = randomBytes(32); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${userWithQuota.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const { body: user } = await request(server) - .get('/user/me') - .set('Authorization', `Bearer ${userWithQuota.accessToken}`); - - expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 32 })); - }); - - it('should not upload an asset if it would exceed the quota', async () => { - const content = randomBytes(420); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${userWithQuota.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Quota has been exceeded!')); - }); - }); - - describe('GET /asset/time-buckets', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH }); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should get time buckets by month', async () => { - const { status, body } = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 1, timeBucket: '2023-11-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - { count: 2, timeBucket: '1970-02-01T00:00:00.000Z' }, - ]), - ); - }); - - it('should not allow access for unrelated shared links', async () => { - const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.INDIVIDUAL, - assetIds: [asset1.id, asset2.id], - }); - - const { status, body } = await request(server) - .get('/asset/time-buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.MONTH }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should get time buckets by day', async () => { - const { status, body } = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.DAY }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 1, timeBucket: asset1.fileCreatedAt.toISOString() }, - { count: 1, timeBucket: asset2.fileCreatedAt.toISOString() }, - { count: 1, timeBucket: asset3.fileCreatedAt.toISOString() }, - ]), - ); - }); - }); - - describe('GET /asset/time-bucket', () => { - let timeBucket: string; - beforeEach(async () => { - const { body, status } = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH }); - - expect(status).toBe(200); - timeBucket = body[1].timeBucket; - }); - - it('should require authentication', async () => { - const { status, body } = await request(server) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.MONTH, timeBucket }); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should handle 5 digit years', async () => { - const { status, body } = await request(server) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.MONTH, timeBucket: '+012345-01-01T00:00:00.000Z' }) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - // it('should fail if time bucket is invalid', async () => { - // const { status, body } = await request(server) - // .get('/asset/time-bucket') - // .set('Authorization', `Bearer ${user1.accessToken}`) - // .query({ size: TimeBucketSize.MONTH, timeBucket: 'foo' }); - - // expect(status).toBe(400); - // expect(body).toEqual(errorStub.badRequest); - // }); - - it('should return time bucket', async () => { - const { status, body } = await request(server) - .get('/asset/time-bucket') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, timeBucket }); - - expect(status).toBe(200); - expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset2.id })])); - }); - - it('should return error if time bucket is requested with partners asset and archived', async () => { - const req1 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isArchived: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorStub.badRequest()); - - const req2 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isArchived: undefined }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorStub.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and favorite', async () => { - const req1 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isFavorite: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorStub.badRequest()); - - const req2 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isFavorite: false }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorStub.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and trash', async () => { - const req = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isTrashed: true }); - - expect(req.status).toBe(400); - expect(req.body).toEqual(errorStub.badRequest()); - }); - }); - - describe('GET /asset/map-marker', () => { - beforeEach(async () => { - await Promise.all([ - assetRepository.save({ id: asset1.id, isArchived: true }), - assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 }), - assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 }), - ]); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).get('/asset/map-marker'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should get map markers for all non-archived assets', async () => { - const { status, body } = await request(server) - .get('/asset/map-marker') - .query({ isArchived: false }) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - - it('should get all map markers', async () => { - const { status, body } = await request(server) - .get('/asset/map-marker') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ isArchived: false }); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - }); - - describe('PUT /asset', () => { - beforeEach(async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).put('/asset'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should require a valid parent id', async () => { - const { status, body } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID'])); - }); - - it('should require access to the parent', async () => { - const { status, body } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset4.id, ids: [asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should add stack children', async () => { - const [parent, child] = await Promise.all([ - createAsset(user1, new Date('1970-01-01')), - createAsset(user1, new Date('1970-01-01')), - ]); - - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: parent.id, ids: [child.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, parent.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })])); - }); - - it('should remove stack children', async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ removeParent: true, ids: [asset2.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); - }); - - it('should remove all stack children', async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ removeParent: true, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); - expect(asset.stack).toBeUndefined(); - }); - - it('should merge stack children', async () => { - const newParent = await createAsset(user1, new Date('1970-01-01')); - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: newParent.id, ids: [asset1.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, newParent.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset1.id }), - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - }); - - describe('PUT /asset/stack/parent', () => { - beforeEach(async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).put('/asset/stack/parent'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - }); - - it('should require access', async () => { - const { status, body } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset4.id, newParentId: asset1.id }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should make old parent child of new parent', async () => { - const { status } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset1.id, newParentId: asset2.id }); - - expect(status).toBe(200); - - const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })])); - }); - - it('should make all childrens of old parent, a child of new parent', async () => { - const { status } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset1.id, newParentId: asset2.id }); - - expect(status).toBe(200); - - const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); - }); - }); - - const getAssetIdsWithoutFaces = async () => { - const assetPagination = usePagination(10, (pagination) => - assetRepository.getWithout(pagination, WithoutProperty.FACES), - ); - let assets: AssetEntity[] = []; - for await (const assetsPage of assetPagination) { - assets = [...assets, ...assetsPage]; - } - return assets.map((a) => a.id); - }; - - describe(AssetRepository.name, () => { - describe('getWithout', () => { - describe('WithoutProperty.FACES', () => { - beforeEach(async () => { - await assetRepository.save({ id: asset1.id, resizePath: '/path/to/resize' }); - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - }); - - describe('with recognized faces', () => { - beforeEach(async () => { - const personRepository = app.get(IPersonRepository); - const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); - await personRepository.createFaces([ - { - assetId: asset1.id, - personId: person.id, - embedding: Array.from({ length: 512 }, Math.random), - }, - ]); - }); - - it('should not return asset with facesRecognizedAt unset', async () => { - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - - it('should not return asset with facesRecognizedAt set', async () => { - await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - }); - - describe('without recognized faces', () => { - it('should return asset with facesRecognizedAt unset', async () => { - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - }); - - it('should not return asset with facesRecognizedAt set', async () => { - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - }); - }); - }); - }); -}); diff --git a/server/e2e/api/utils.ts b/server/e2e/api/utils.ts deleted file mode 100644 index c03c4ada5525b..0000000000000 --- a/server/e2e/api/utils.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { AssetCreate, IJobRepository, IMetadataRepository, LibraryResponseDto } from '@app/domain'; -import { AppModule } from '@app/immich'; -import { InfraModule, InfraTestModule, dataSource } from '@app/infra'; -import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { DateTime } from 'luxon'; -import { randomBytes } from 'node:crypto'; -import { EntityTarget, ObjectLiteral } from 'typeorm'; -import { AppService } from '../../src/microservices/app.service'; -import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test'; - -export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); -export const yesterday = today.minus({ days: 1 }); - -export interface ResetOptions { - entities?: EntityTarget[]; -} -export const db = { - reset: async (options?: ResetOptions) => { - if (!dataSource.isInitialized) { - await dataSource.initialize(); - } - await dataSource.transaction(async (em) => { - const entities = options?.entities || []; - const tableNames = - entities.length > 0 - ? entities.map((entity) => em.getRepository(entity).metadata.tableName) - : dataSource.entityMetadatas - .map((entity) => entity.tableName) - .filter((tableName) => !tableName.startsWith('geodata')); - - if (tableNames.includes('asset_stack')) { - await em.query(`DELETE FROM "asset_stack" CASCADE;`); - } - let deleteUsers = false; - for (const tableName of tableNames) { - if (tableName === 'users') { - deleteUsers = true; - continue; - } - await em.query(`DELETE FROM ${tableName} CASCADE;`); - } - if (deleteUsers) { - await em.query(`DELETE FROM "users" CASCADE;`); - } - }); - }, - disconnect: async () => { - if (dataSource.isInitialized) { - await dataSource.destroy(); - } - }, -}; - -let app: INestApplication; - -export const testApp = { - create: async (): Promise => { - const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] }) - .overrideModule(InfraModule) - .useModule(InfraTestModule) - .overrideProvider(IJobRepository) - .useValue(newJobRepositoryMock()) - .overrideProvider(IMetadataRepository) - .useValue(newMetadataRepositoryMock()) - .compile(); - - app = await moduleFixture.createNestApplication().init(); - await app.get(AppService).init(); - - return app; - }, - reset: async (options?: ResetOptions) => { - await db.reset(options); - }, - teardown: async () => { - if (app) { - await app.get(AppService).teardown(); - await app.close(); - } - await db.disconnect(); - }, -}; - -function randomDate(start: Date, end: Date): Date { - return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); -} - -let assetCount = 0; -export function generateAsset( - userId: string, - libraries: LibraryResponseDto[], - other: Partial = {}, -): AssetCreate { - const id = assetCount++; - const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other; - - return { - createdAt: today.toJSDate(), - updatedAt: today.toJSDate(), - ownerId: userId, - checksum: randomBytes(20), - originalPath: `/tests/test_${id}`, - deviceAssetId: `test_${id}`, - deviceId: 'e2e-test', - libraryId: ( - libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto - ).id, - isVisible: true, - fileCreatedAt, - fileModifiedAt: new Date(), - localDateTime: fileCreatedAt, - type: AssetType.IMAGE, - originalFileName: `test_${id}`, - ...other, - }; -} diff --git a/server/e2e/client/asset-api.ts b/server/e2e/client/asset-api.ts index 8d2a1b79bcc67..63d4395866470 100644 --- a/server/e2e/client/asset-api.ts +++ b/server/e2e/client/asset-api.ts @@ -1,77 +1,10 @@ import { AssetResponseDto } from '@app/domain'; -import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto'; -import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; -import { randomBytes } from 'node:crypto'; import request from 'supertest'; -type UploadDto = Partial & { content?: Buffer; filename?: string }; - -const asset = { - deviceAssetId: 'test-1', - deviceId: 'test', - fileCreatedAt: new Date(), - fileModifiedAt: new Date(), -}; - export const assetApi = { - create: async ( - server: any, - accessToken: string, - dto?: Omit, - ): Promise => { - dto = dto || asset; - const { status, body } = await request(server) - .post(`/asset/upload`) - .field('deviceAssetId', dto.deviceAssetId) - .field('deviceId', dto.deviceId) - .field('fileCreatedAt', dto.fileCreatedAt.toISOString()) - .field('fileModifiedAt', dto.fileModifiedAt.toISOString()) - .attach('assetData', randomBytes(32), 'example.jpg') - .set('Authorization', `Bearer ${accessToken}`); - - expect([200, 201].includes(status)).toBe(true); - - return body as AssetResponseDto; - }, - get: async (server: any, accessToken: string, id: string): Promise => { - const { body, status } = await request(server).get(`/asset/${id}`).set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body as AssetResponseDto; - }, getAllAssets: async (server: any, accessToken: string) => { const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(200); return body as AssetResponseDto[]; }, - upload: async (server: any, accessToken: string, deviceAssetId: string, dto: UploadDto = {}) => { - const { content, filename, isFavorite = false, isArchived = false } = dto; - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${accessToken}`) - .field('deviceAssetId', deviceAssetId) - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', isFavorite) - .field('isArchived', isArchived) - .field('duration', '0:00:00.000000') - .attach('assetData', content || randomBytes(32), filename || 'example.jpg'); - - expect(status).toBe(201); - return body as AssetFileUploadResponseDto; - }, - getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => { - const { body, status } = await request(server) - .get(`/asset/thumbnail/${assetId}`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, - getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => { - const { body, status } = await request(server) - .get(`/asset/thumbnail/${assetId}?format=JPEG`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, }; diff --git a/server/e2e/client/auth-api.ts b/server/e2e/client/auth-api.ts index f0206d3376671..e89e6d057609f 100644 --- a/server/e2e/client/auth-api.ts +++ b/server/e2e/client/auth-api.ts @@ -1,4 +1,4 @@ -import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain'; +import { LoginResponseDto, UserResponseDto } from '@app/domain'; import { adminSignupStub, loginResponseStub, loginStub } from '@test'; import request from 'supertest'; @@ -17,14 +17,6 @@ export const authApi = { expect(body).toMatchObject({ accessToken: expect.any(String) }); expect(status).toBe(201); - return body as LoginResponseDto; - }, - login: async (server: any, dto: LoginCredentialDto) => { - const { status, body } = await request(server).post('/auth/login').send(dto); - - expect(status).toEqual(201); - expect(body).toMatchObject({ accessToken: expect.any(String) }); - return body as LoginResponseDto; }, }; diff --git a/server/e2e/client/index.ts b/server/e2e/client/index.ts index b0464a34d8e94..b4aa2a141bfbc 100644 --- a/server/e2e/client/index.ts +++ b/server/e2e/client/index.ts @@ -1,15 +1,9 @@ import { assetApi } from './asset-api'; import { authApi } from './auth-api'; import { libraryApi } from './library-api'; -import { sharedLinkApi } from './shared-link-api'; -import { trashApi } from './trash-api'; -import { userApi } from './user-api'; export const api = { authApi, assetApi, libraryApi, - sharedLinkApi, - trashApi, - userApi, }; diff --git a/server/e2e/client/library-api.ts b/server/e2e/client/library-api.ts index e0b1331267703..070683eb01a07 100644 --- a/server/e2e/client/library-api.ts +++ b/server/e2e/client/library-api.ts @@ -1,12 +1,4 @@ -import { - CreateLibraryDto, - LibraryResponseDto, - LibraryStatsResponseDto, - ScanLibraryDto, - UpdateLibraryDto, - ValidateLibraryDto, - ValidateLibraryResponseDto, -} from '@app/domain'; +import { CreateLibraryDto, LibraryResponseDto, ScanLibraryDto } from '@app/domain'; import request from 'supertest'; export const libraryApi = { @@ -38,34 +30,4 @@ export const libraryApi = { .send(dto); expect(status).toBe(204); }, - removeOfflineFiles: async (server: any, accessToken: string, id: string) => { - const { status } = await request(server) - .post(`/library/${id}/removeOffline`) - .set('Authorization', `Bearer ${accessToken}`) - .send(); - expect(status).toBe(204); - }, - getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise => { - const { body, status } = await request(server) - .get(`/library/${id}/statistics`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, - update: async (server: any, accessToken: string, id: string, data: UpdateLibraryDto) => { - const { body, status } = await request(server) - .put(`/library/${id}`) - .set('Authorization', `Bearer ${accessToken}`) - .send(data); - expect(status).toBe(200); - return body as LibraryResponseDto; - }, - validate: async (server: any, accessToken: string, id: string, data: ValidateLibraryDto) => { - const { body, status } = await request(server) - .post(`/library/${id}/validate`) - .set('Authorization', `Bearer ${accessToken}`) - .send(data); - expect(status).toBe(200); - return body as ValidateLibraryResponseDto; - }, }; diff --git a/server/e2e/client/shared-link-api.ts b/server/e2e/client/shared-link-api.ts deleted file mode 100644 index c34093b0ac769..0000000000000 --- a/server/e2e/client/shared-link-api.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { SharedLinkCreateDto, SharedLinkResponseDto } from '@app/domain'; -import request from 'supertest'; - -export const sharedLinkApi = { - create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => { - const { status, body } = await request(server) - .post('/shared-link') - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - expect(status).toBe(201); - return body as SharedLinkResponseDto; - }, -}; diff --git a/server/e2e/client/trash-api.ts b/server/e2e/client/trash-api.ts deleted file mode 100644 index a381253f50be2..0000000000000 --- a/server/e2e/client/trash-api.ts +++ /dev/null @@ -1,13 +0,0 @@ -import request from 'supertest'; -import type { App } from 'supertest/types'; - -export const trashApi = { - async empty(server: App, accessToken: string) { - const { status } = await request(server).post('/trash/empty').set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(204); - }, - async restore(server: App, accessToken: string) { - const { status } = await request(server).post('/trash/restore').set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(204); - }, -}; diff --git a/server/e2e/client/user-api.ts b/server/e2e/client/user-api.ts deleted file mode 100644 index c538db3a8fae7..0000000000000 --- a/server/e2e/client/user-api.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain'; -import request from 'supertest'; - -export const userApi = { - create: async (server: any, accessToken: string, dto: CreateUserDto) => { - const { status, body } = await request(server) - .post('/user') - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - - expect(status).toBe(201); - expect(body).toMatchObject({ - id: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - email: dto.email, - }); - - return body as UserResponseDto; - }, - update: async (server: any, accessToken: string, dto: UpdateUserDto) => { - const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto); - - expect(status).toBe(200); - expect(body).toMatchObject({ id: dto.id }); - - return body as UserResponseDto; - }, - delete: async (server: any, accessToken: string, id: string) => { - const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ id, deletedAt: expect.any(String) }); - - return body as UserResponseDto; - }, -}; diff --git a/server/package-lock.json b/server/package-lock.json index 54dd09902591e..2468c0c97d7c0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.98.0", + "version": "1.98.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.98.0", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", @@ -33,6 +33,7 @@ "cookie-parser": "^1.4.6", "exiftool-vendored": "~24.5.0", "exiftool-vendored.pl": "12.76", + "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", "glob": "^10.3.3", @@ -51,6 +52,7 @@ "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "sharp": "^0.33.0", + "sirv": "^2.0.4", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", "ua-parser-js": "^1.0.35" @@ -2656,7 +2658,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2669,7 +2670,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "engines": { "node": ">= 8" } @@ -2678,7 +2678,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -2730,6 +2729,11 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -6339,7 +6343,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -6372,7 +6375,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -8659,7 +8661,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "engines": { "node": ">= 8" } @@ -8676,7 +8677,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -8689,7 +8689,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -8831,6 +8830,14 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -9951,7 +9958,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -10391,7 +10397,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -10427,7 +10432,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -10821,6 +10825,19 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11748,6 +11765,14 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14419,7 +14444,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "requires": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -14428,14 +14452,12 @@ "@nodelib/fs.stat": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" }, "@nodelib/fs.walk": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "requires": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -14468,6 +14490,11 @@ "integrity": "sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ==", "dev": true }, + "@polka/url": { + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" + }, "@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -17281,7 +17308,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -17311,7 +17337,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, "requires": { "reusify": "^1.0.4" } @@ -19033,8 +19058,7 @@ "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" }, "methods": { "version": "1.1.2", @@ -19045,7 +19069,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "requires": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -19054,8 +19077,7 @@ "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" } } }, @@ -19156,6 +19178,11 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, + "mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -19990,8 +20017,7 @@ "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, "queue-tick": { "version": "1.0.1", @@ -20322,8 +20348,7 @@ "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" }, "rimraf": { "version": "5.0.5", @@ -20343,7 +20368,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "requires": { "queue-microtask": "^1.2.2" } @@ -20648,6 +20672,16 @@ } } }, + "sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "requires": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -21386,6 +21420,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/server/package.json b/server/package.json index da9cb0e442344..70d647989c535 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.98.0", + "version": "1.98.2", "description": "", "author": "", "private": true, @@ -23,7 +23,6 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "e2e:jobs": "jest --config e2e/jobs/jest-e2e.json --runInBand", - "e2e:api": "jest --config e2e/api/jest-e2e.json --runInBand", "typeorm": "typeorm", "typeorm:migrations:create": "typeorm migration:create", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js", @@ -58,6 +57,7 @@ "cookie-parser": "^1.4.6", "exiftool-vendored": "~24.5.0", "exiftool-vendored.pl": "12.76", + "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", "glob": "^10.3.3", @@ -76,6 +76,7 @@ "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "sharp": "^0.33.0", + "sirv": "^2.0.4", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", "ua-parser-js": "^1.0.35" diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index 0dc9c54140bac..b723474dd3d94 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -82,8 +82,7 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf8')); export const serverVersion = Version.fromString(version); export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; - -export const WEB_ROOT_PATH = join(process.env.IMMICH_WEB_ROOT || '/usr/src/app/www', 'index.html'); +export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; const GEODATA_ROOT_PATH = process.env.IMMICH_REVERSE_GEOCODING_ROOT || '/usr/src/resources'; diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 1010185b2ca54..5d5333f3ab87d 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -280,6 +280,11 @@ export class JobService { } break; } + + case JobName.USER_DELETION: { + this.communicationRepository.broadcast(ClientEvent.USER_DELETE, item.data.id); + break; + } } } } diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index a44624c43abb7..03042cf55a245 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -156,8 +156,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); - assetMock.getPathsNotInLibrary.mockResolvedValue(['/data/user1/photo.jpg']); - assetMock.getByLibraryId.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -183,7 +182,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); - assetMock.getByLibraryId.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -233,7 +232,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); storageMock.crawl.mockResolvedValue([]); - assetMock.getByLibraryId.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -242,6 +241,48 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }); }); + + it('should set missing assets offline', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ + items: [assetStub.image], + hasNextPage: false, + }); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true }); + expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false }); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + }); + + it('should set crawled assets that were previously offline back online', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue([assetStub.offline.originalPath]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ + items: [assetStub.offline], + hasNextPage: false, + }); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.offline.id], { isOffline: false }); + expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true }); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + }); }); describe('handleAssetRefresh', () => { diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index c74e97ea36db6..25894c9b5a963 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -640,27 +640,56 @@ export class LibraryService extends EventEmitter { .filter((validation) => validation.isValid) .map((validation) => validation.importPath); - const rawPaths = await this.storageRepository.crawl({ + let rawPaths = await this.storageRepository.crawl({ pathsToCrawl: validImportPaths, exclusionPatterns: library.exclusionPatterns, }); - const crawledAssetPaths = rawPaths.map((filePath) => path.normalize(filePath)); + const crawledAssetPaths = new Set(rawPaths); - this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`); + const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles; + let pathsToScan: string[] = shouldScanAll ? rawPaths : []; + rawPaths = []; - await this.assetRepository.updateOfflineLibraryAssets(library.id, crawledAssetPaths); + this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`); - if (crawledAssetPaths.length > 0) { - let filteredPaths: string[] = []; - if (job.refreshAllFiles || job.refreshModifiedFiles) { - filteredPaths = crawledAssetPaths; - } else { - filteredPaths = await this.assetRepository.getPathsNotInLibrary(library.id, crawledAssetPaths); + const assetIdsToMarkOffline = []; + const assetIdsToMarkOnline = []; + const pagination = usePagination(5000, (pagination) => + this.assetRepository.getLibraryAssetPaths(pagination, library.id), + ); - this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`); + for await (const page of pagination) { + for (const asset of page) { + const isOffline = !crawledAssetPaths.has(asset.originalPath); + if (isOffline && !asset.isOffline) { + assetIdsToMarkOffline.push(asset.id); + } + + if (!isOffline && asset.isOffline) { + assetIdsToMarkOnline.push(asset.id); + } + + crawledAssetPaths.delete(asset.originalPath); } + } - await this.scanAssets(job.id, filteredPaths, library.ownerId, job.refreshAllFiles ?? false); + if (assetIdsToMarkOffline.length > 0) { + this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`); + await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true }); + } + + if (assetIdsToMarkOnline.length > 0) { + this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`); + await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false }); + } + + if (!shouldScanAll) { + pathsToScan = [...crawledAssetPaths]; + this.logger.debug(`Will import ${pathsToScan.length} new asset(s)`); + } + + if (pathsToScan.length > 0) { + await this.scanAssets(job.id, pathsToScan, library.ownerId, job.refreshAllFiles ?? false); } await this.repository.update({ id: job.id, refreshedAt: new Date() }); diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 244978d099d25..8a6eae4cc13b3 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -23,6 +23,7 @@ import { personStub, probeStub, } from '@test'; +import { Stats } from 'node:fs'; import { JobName } from '../job'; import { IAssetRepository, @@ -1853,6 +1854,41 @@ describe(MediaService.name, () => { }, ); }); + + it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, + { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, + ]); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'], + outputOptions: [ + `-c:v h264_rkmpp`, + '-c:a copy', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-g 256', + '-v verbose', + '-vf scale_rkrga=-2:720:format=p010:afbc=1,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime', + '-level 51', + '-rc_mode CQP', + '-qp_init 30', + ], + twoPass: false, + }, + ); + }); }); it('should tonemap when policy is required and video is hdr', async () => { diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 6a5c8ff9d3bb7..5c8e777ad5e4f 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -47,6 +47,7 @@ export class MediaService { private logger = new ImmichLogger(MediaService.name); private configCore: SystemConfigCore; private storageCore: StorageCore; + private hasOpenCL?: boolean = undefined; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -456,8 +457,19 @@ export class MediaService { break; } case TranscodeHWAccel.RKMPP: { + if (this.hasOpenCL === undefined) { + try { + const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'); + const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); + this.hasOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); + } catch { + this.logger.warn('OpenCL not available for transcoding, using CPU instead.'); + this.hasOpenCL = false; + } + } + devices = await this.storageRepository.readdir('/dev/dri'); - handler = new RKMPPConfig(config, devices); + handler = new RKMPPConfig(config, devices, this.hasOpenCL); break; } default: { diff --git a/server/src/domain/media/media.util.ts b/server/src/domain/media/media.util.ts index d5f08ab0de475..3acabb4356df6 100644 --- a/server/src/domain/media/media.util.ts +++ b/server/src/domain/media/media.util.ts @@ -608,6 +608,17 @@ export class VAAPIConfig extends BaseHWConfig { } export class RKMPPConfig extends BaseHWConfig { + private hasOpenCL: boolean; + + constructor( + protected config: SystemConfigFFmpegDto, + devices: string[] = [], + hasOpenCL: boolean = false, + ) { + super(config, devices); + this.hasOpenCL = hasOpenCL; + } + eligibleForTwoPass(): boolean { return false; } @@ -616,19 +627,25 @@ export class RKMPPConfig extends BaseHWConfig { if (this.devices.length === 0) { throw new Error('No RKMPP device found'); } - if (this.shouldToneMap(videoStream)) { - // disable hardware decoding - return []; - } - return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; + return this.shouldToneMap(videoStream) && !this.hasOpenCL + ? [] // disable hardware decoding & filters + : ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; } getFilterOptions(videoStream: VideoStreamInfo) { if (this.shouldToneMap(videoStream)) { - // use software filter options - return super.getFilterOptions(videoStream); - } - if (this.shouldScale(videoStream)) { + if (!this.hasOpenCL) { + return super.getFilterOptions(videoStream); + } + const colors = this.getColors(); + return [ + `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`, + 'hwmap=derive_device=opencl:mode=read', + `tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`, + 'hwmap=derive_device=rkmpp:mode=write:reverse=1', + 'format=drm_prime', + ]; + } else if (this.shouldScale(videoStream)) { return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; } return []; diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index dd5e76577c88f..b779c8b8c3a22 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,9 +1,4 @@ -import { - AssetSearchOneToOneRelationOptions, - AssetSearchOptions, - ReverseGeocodeResult, - SearchExploreItem, -} from '@app/domain'; +import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain'; import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; @@ -114,6 +109,8 @@ export interface MetadataSearchOptions { numResults: number; } +export type AssetPathEntity = Pick; + export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { @@ -134,16 +131,10 @@ export interface IAssetRepository { getRandom(userId: string, count: number): Promise; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getByLibraryId(libraryIds: string[]): Promise; + getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; - getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise; - updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; - getAllByFileCreationDate( - pagination: PaginationOptions, - options?: AssetSearchOneToOneRelationOptions, - ): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; updateAll(ids: string[], options: Partial): Promise; save(asset: Pick & Partial): Promise; diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index 4a3bc552c9cf3..65e322702f2f7 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -4,6 +4,7 @@ export const ICommunicationRepository = 'ICommunicationRepository'; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', + USER_DELETE = 'on_user_delete', ASSET_DELETE = 'on_asset_delete', ASSET_TRASH = 'on_asset_trash', ASSET_UPDATE = 'on_asset_update', @@ -22,6 +23,7 @@ export enum ServerEvent { export interface ClientEventMap { [ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto; + [ClientEvent.USER_DELETE]: string; [ClientEvent.ASSET_DELETE]: string; [ClientEvent.ASSET_TRASH]: string[]; [ClientEvent.ASSET_UPDATE]: AssetResponseDto; diff --git a/server/src/domain/repositories/user.repository.ts b/server/src/domain/repositories/user.repository.ts index cecdb0b06e2df..efd950318f4fb 100644 --- a/server/src/domain/repositories/user.repository.ts +++ b/server/src/domain/repositories/user.repository.ts @@ -32,7 +32,6 @@ export interface IUserRepository { create(user: Partial): Promise; update(id: string, user: Partial): Promise; delete(user: UserEntity, hard?: boolean): Promise; - restore(user: UserEntity): Promise; updateUsage(id: string, delta: number): Promise; syncUsage(id?: string): Promise; } diff --git a/server/src/domain/user/dto/delete-user.dto.ts b/server/src/domain/user/dto/delete-user.dto.ts new file mode 100644 index 0000000000000..88f55f4af517f --- /dev/null +++ b/server/src/domain/user/dto/delete-user.dto.ts @@ -0,0 +1,6 @@ +import { ValidateBoolean } from '../../domain.util'; + +export class DeleteUserDto { + @ValidateBoolean({ optional: true }) + force?: boolean; +} diff --git a/server/src/domain/user/dto/index.ts b/server/src/domain/user/dto/index.ts index 09d7998e8effc..2d166de3689b2 100644 --- a/server/src/domain/user/dto/index.ts +++ b/server/src/domain/user/dto/index.ts @@ -1,3 +1,4 @@ export * from './create-profile-image.dto'; export * from './create-user.dto'; +export * from './delete-user.dto'; export * from './update-user.dto'; diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts index a82337945e59a..bd437ea3444b3 100644 --- a/server/src/domain/user/response-dto/user-response.dto.ts +++ b/server/src/domain/user/response-dto/user-response.dto.ts @@ -1,4 +1,4 @@ -import { UserAvatarColor, UserEntity } from '@app/infra/entities'; +import { UserAvatarColor, UserEntity, UserStatus } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { IsEnum } from 'class-validator'; @@ -33,6 +33,8 @@ export class UserResponseDto extends UserDto { quotaSizeInBytes!: number | null; @ApiProperty({ type: 'integer', format: 'int64' }) quotaUsageInBytes!: number | null; + @ApiProperty({ enumName: 'UserStatus', enum: UserStatus }) + status!: string; } export const mapSimpleUser = (entity: UserEntity): UserDto => { @@ -58,5 +60,6 @@ export function mapUser(entity: UserEntity): UserResponseDto { memoriesEnabled: entity.memoriesEnabled, quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, + status: entity.status, }; } diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index dba0106fb631e..d0e56e4cd36dd 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -1,4 +1,4 @@ -import { UserEntity } from '@app/infra/entities'; +import { UserEntity, UserStatus } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, @@ -243,16 +243,14 @@ describe(UserService.name, () => { it('should throw error if user could not be found', async () => { when(userMock.get).calledWith(userStub.admin.id, { withDeleted: true }).mockResolvedValue(null); await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); - expect(userMock.restore).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); }); it('should restore an user', async () => { userMock.get.mockResolvedValue(userStub.user1); - userMock.restore.mockResolvedValue(userStub.user1); - + userMock.update.mockResolvedValue(userStub.user1); await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1)); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: true }); - expect(userMock.restore).toHaveBeenCalledWith(userStub.user1); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null }); }); }); @@ -260,27 +258,47 @@ describe(UserService.name, () => { it('should throw error if user could not be found', async () => { userMock.get.mockResolvedValue(null); - await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); + await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); expect(userMock.delete).not.toHaveBeenCalled(); }); it('cannot delete admin user', async () => { - await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toBeInstanceOf(ForbiddenException); + await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException); }); it('should require the auth user be an admin', async () => { - await expect(sut.delete(authStub.user1, authStub.admin.user.id)).rejects.toBeInstanceOf(ForbiddenException); + await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException); expect(userMock.delete).not.toHaveBeenCalled(); }); it('should delete user', async () => { userMock.get.mockResolvedValue(userStub.user1); - userMock.delete.mockResolvedValue(userStub.user1); + userMock.update.mockResolvedValue(userStub.user1); - await expect(sut.delete(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1)); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, {}); - expect(userMock.delete).toHaveBeenCalledWith(userStub.user1); + await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUser(userStub.user1)); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + status: UserStatus.DELETED, + deletedAt: expect.any(Date), + }); + }); + + it('should force delete user', async () => { + userMock.get.mockResolvedValue(userStub.user1); + userMock.update.mockResolvedValue(userStub.user1); + + await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual( + mapUser(userStub.user1), + ); + + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + status: UserStatus.REMOVING, + deletedAt: expect.any(Date), + }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.USER_DELETION, + data: { id: userStub.user1.id, force: true }, + }); }); }); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 9a862199b8dde..564163d77576a 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -1,4 +1,4 @@ -import { UserEntity } from '@app/infra/entities'; +import { UserEntity, UserStatus } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { DateTime } from 'luxon'; @@ -18,7 +18,7 @@ import { } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; import { SystemConfigCore } from '../system-config/system-config.core'; -import { CreateUserDto, UpdateUserDto } from './dto'; +import { CreateUserDto, DeleteUserDto, UpdateUserDto } from './dto'; import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto'; import { UserCore } from './user.core'; @@ -73,22 +73,29 @@ export class UserService { return this.userCore.updateUser(auth.user, dto.id, dto).then(mapUser); } - async delete(auth: AuthDto, id: string): Promise { - const user = await this.findOrFail(id, {}); - if (user.isAdmin) { + async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise { + const { force } = dto; + const { isAdmin } = await this.findOrFail(id, {}); + if (isAdmin) { throw new ForbiddenException('Cannot delete admin user'); } await this.albumRepository.softDeleteAll(id); - return this.userRepository.delete(user).then(mapUser); + const status = force ? UserStatus.REMOVING : UserStatus.DELETED; + const user = await this.userRepository.update(id, { status, deletedAt: new Date() }); + + if (force) { + await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } }); + } + + return mapUser(user); } async restore(auth: AuthDto, id: string): Promise { - let user = await this.findOrFail(id, { withDeleted: true }); - user = await this.userRepository.restore(user); + await this.findOrFail(id, { withDeleted: true }); await this.albumRepository.restoreAll(id); - return mapUser(user); + return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser); } async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise { @@ -154,7 +161,7 @@ export class UserService { return true; } - async handleUserDelete({ id }: IEntityJob) { + async handleUserDelete({ id, force }: IEntityJob) { const config = await this.configCore.getConfig(); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { @@ -162,7 +169,7 @@ export class UserService { } // just for extra protection here - if (!this.isReadyForDeletion(user, config.user.deleteDelay)) { + if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) { this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`); return false; } diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index 2f54db27d0cf1..18feb65dce312 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -1,13 +1,14 @@ -import { AssetEntity } from '@app/infra/entities'; +import { AssetEntity, ExifEntity } from '@app/infra/entities'; +import { OptionalBetween } from '@app/infra/infra.utils'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In } from 'typeorm/find-options/operator/In.js'; import { Repository } from 'typeorm/repository/Repository.js'; +import { AssetSearchDto } from './dto/asset-search.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { SearchPropertiesDto } from './dto/search-properties.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; - export interface AssetCheck { id: string; checksum: Buffer; @@ -21,6 +22,7 @@ export interface IAssetRepositoryV1 { get(id: string): Promise; getLocationsByUserId(userId: string): Promise; getDetectedObjectsByUserId(userId: string): Promise; + getAllByUserId(userId: string, dto: AssetSearchDto): Promise; getSearchPropertiesByUserId(userId: string): Promise; getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise; getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise; @@ -31,7 +33,40 @@ export const IAssetRepositoryV1 = 'IAssetRepositoryV1'; @Injectable() export class AssetRepositoryV1 implements IAssetRepositoryV1 { - constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + constructor( + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(ExifEntity) private exifRepository: Repository, + ) {} + + /** + * Retrieves all assets by user ID. + * + * @param ownerId - The ID of the owner. + * @param dto - The AssetSearchDto object containing search criteria. + * @returns A Promise that resolves to an array of AssetEntity objects. + */ + getAllByUserId(ownerId: string, dto: AssetSearchDto): Promise { + return this.assetRepository.find({ + where: { + ownerId, + isVisible: true, + isFavorite: dto.isFavorite, + isArchived: dto.isArchived, + updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore), + }, + relations: { + exifInfo: true, + tags: true, + stack: { assets: true }, + }, + skip: dto.skip || 0, + take: dto.take, + order: { + fileCreatedAt: 'DESC', + }, + withDeleted: true, + }); + } getSearchPropertiesByUserId(userId: string): Promise { return this.assetRepository 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 48354d440ec60..9f0aa371e8635 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -77,6 +77,7 @@ describe('AssetService', () => { beforeEach(() => { assetRepositoryMockV1 = { get: jest.fn(), + getAllByUserId: jest.fn(), getDetectedObjectsByUserId: jest.fn(), getLocationsByUserId: jest.fn(), getSearchPropertiesByUserId: jest.fn(), diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 923cb4ebe871e..821a7de82a9f4 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -113,19 +113,8 @@ export class AssetService { public async getAllAssets(auth: AuthDto, dto: AssetSearchDto): Promise { const userId = dto.userId || auth.user.id; await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); - const assets = await this.assetRepository.getAllByFileCreationDate( - { take: dto.take ?? 1000, skip: dto.skip }, - { - ...dto, - userIds: [userId], - withDeleted: true, - orderDirection: 'DESC', - withExif: true, - isVisible: true, - withStacked: true, - }, - ); - return assets.items.map((asset) => mapAsset(asset, { withStack: true })); + const assets = await this.assetRepositoryV1.getAllByUserId(userId, dto); + return assets.map((asset) => mapAsset(asset, { withStack: true, auth })); } async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise { diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index f3369b12103fb..adfb9d8780238 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -8,13 +8,14 @@ import { SharedLinkService, StorageService, SystemConfigService, - WEB_ROOT_PATH, + WEB_ROOT, } from '@app/domain'; import { ImmichLogger } from '@app/infra/logger'; import { Injectable } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; const render = (index: string, meta: OpenGraphTags) => { const tags = ` @@ -71,7 +72,7 @@ export class AppService { ssr(excludePaths: string[]) { let index = ''; try { - index = readFileSync(WEB_ROOT_PATH).toString(); + index = readFileSync(join(WEB_ROOT, 'index.html')).toString(); } catch { this.logger.warn('Unable to open `www/index.html, skipping SSR.'); } diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 2cf2c6f86d16d..7fa7ccd0fd15c 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -3,6 +3,7 @@ import { CreateUserDto as CreateDto, CreateProfileImageDto, CreateProfileImageResponseDto, + DeleteUserDto, UpdateUserDto as UpdateDto, UserResponseDto, UserService, @@ -66,8 +67,12 @@ export class UserController { @AdminRoute() @Delete(':id') - deleteUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.delete(auth, id); + deleteUser( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: DeleteUserDto, + ): Promise { + return this.service.delete(auth, id, dto); } @AdminRoute() diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts index 05f3f038117f3..d2fe44f1f3ed0 100644 --- a/server/src/immich/main.ts +++ b/server/src/immich/main.ts @@ -1,10 +1,12 @@ -import { envName, isDev, serverVersion } from '@app/domain'; +import { WEB_ROOT, envName, isDev, serverVersion } from '@app/domain'; import { WebSocketAdapter } from '@app/infra'; import { ImmichLogger } from '@app/infra/logger'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { json } from 'body-parser'; import cookieParser from 'cookie-parser'; +import { existsSync } from 'node:fs'; +import sirv from 'sirv'; import { AppModule } from './app.module'; import { AppService } from './app.service'; import { useSwagger } from './app.utils'; @@ -28,7 +30,22 @@ export async function bootstrap() { const excludePaths = ['/.well-known/immich', '/custom.css']; app.setGlobalPrefix('api', { exclude: excludePaths }); - app.useStaticAssets('www'); + if (existsSync(WEB_ROOT)) { + // copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46 + // provides serving of precompressed assets and caching of immutable assets + app.use( + sirv(WEB_ROOT, { + etag: true, + gzip: true, + brotli: true, + setHeaders: (res, pathname) => { + if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) { + res.setHeader('cache-control', 'public,max-age=31536000,immutable'); + } + }, + }), + ); + } app.use(app.get(AppService).ssr(excludePaths)); const server = await app.listen(port); diff --git a/server/src/infra/entities/user.entity.ts b/server/src/infra/entities/user.entity.ts index c574595ea8c59..20c057d790606 100644 --- a/server/src/infra/entities/user.entity.ts +++ b/server/src/infra/entities/user.entity.ts @@ -23,6 +23,12 @@ export enum UserAvatarColor { AMBER = 'amber', } +export enum UserStatus { + ACTIVE = 'active', + REMOVING = 'removing', + DELETED = 'deleted', +} + @Entity('users') export class UserEntity { @PrimaryGeneratedColumn('uuid') @@ -61,6 +67,9 @@ export class UserEntity { @DeleteDateColumn({ type: 'timestamptz' }) deletedAt!: Date | null; + @Column({ type: 'varchar', default: UserStatus.ACTIVE }) + status!: UserStatus; + @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; diff --git a/server/src/infra/migrations/1709870213078-AddUserStatus.ts b/server/src/infra/migrations/1709870213078-AddUserStatus.ts new file mode 100644 index 0000000000000..858f51258fde7 --- /dev/null +++ b/server/src/infra/migrations/1709870213078-AddUserStatus.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserStatus1709870213078 implements MigrationInterface { + name = 'AddUserStatus1709870213078' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "status" character varying NOT NULL DEFAULT 'active'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "status"`); + } + +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 4813056659efa..ff60be9fe0f00 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -2,7 +2,7 @@ import { AssetBuilderOptions, AssetCreate, AssetExploreFieldOptions, - AssetSearchOneToOneRelationOptions, + AssetPathEntity, AssetSearchOptions, AssetStats, AssetStatsOptions, @@ -185,10 +185,10 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [[DummyValue.UUID]] }) - @ChunkedArray() - getByLibraryId(libraryIds: string[]): Promise { - return this.repository.find({ - where: { library: { id: In(libraryIds) } }, + getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { + return paginate(this.repository, pagination, { + select: { id: true, originalPath: true, isOffline: true }, + where: { library: { id: libraryId } }, }); } @@ -233,29 +233,6 @@ export class AssetRepository implements IAssetRepository { }); } - @GenerateSql({ - params: [ - { skip: 20_000, take: 10_000 }, - { - takenBefore: DummyValue.DATE, - userIds: [DummyValue.UUID], - }, - ], - }) - getAllByFileCreationDate( - pagination: PaginationOptions, - options: AssetSearchOneToOneRelationOptions = {}, - ): Paginated { - let builder = this.repository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - return paginatedBuilder(builder, { - mode: PaginationMode.LIMIT_OFFSET, - skip: pagination.skip, - take: pagination.take, - }); - } - /** * Get assets by device's Id on the database * @param ownerId diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index fef184992d830..32880ae181984 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -11,7 +11,7 @@ import { import { ImmichLogger } from '@app/infra/logger'; import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; -import { glob } from 'glob'; +import { glob } from 'fast-glob'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; import fs, { copyFile, readdir, rename, stat, utimes, writeFile } from 'node:fs/promises'; import path from 'node:path'; @@ -123,7 +123,7 @@ export class FilesystemProvider implements IStorageRepository { crawl(crawlOptions: CrawlOptionsDto): Promise { const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; - if (!pathsToCrawl) { + if (pathsToCrawl.length === 0) { return Promise.resolve([]); } @@ -132,8 +132,8 @@ export class FilesystemProvider implements IStorageRepository { return glob(`${base}/**/${extensions}`, { absolute: true, - nocase: true, - nodir: true, + caseSensitiveMatch: false, + onlyFiles: true, dot: includeHidden, ignore: exclusionPatterns, }); diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 0ff26a4f5f848..823193138552e 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -188,6 +188,7 @@ export class SearchRepository implements ISearchRepository { .addCommonTableExpression(cte, 'cte') .from('cte', 'res') .where('res.distance <= :maxDistance', { maxDistance }) + .orderBy('res.distance') .getRawMany(); }); return results.map((row) => ({ diff --git a/server/src/infra/repositories/user.repository.ts b/server/src/infra/repositories/user.repository.ts index 640eda0ee475c..d9f12bb314663 100644 --- a/server/src/infra/repositories/user.repository.ts +++ b/server/src/infra/repositories/user.repository.ts @@ -77,10 +77,6 @@ export class UserRepository implements IUserRepository { return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user); } - async restore(user: UserEntity): Promise { - return this.userRepository.recover(user); - } - @GenerateSql() async getUserStats(): Promise { const stats = await this.userRepository @@ -135,6 +131,6 @@ export class UserRepository implements IUserRepository { private async save(user: Partial) { const { id } = await this.userRepository.save(user); - return this.userRepository.findOneByOrFail({ id }); + return this.userRepository.findOneOrFail({ where: { id }, withDeleted: true }); } } diff --git a/server/src/infra/sql/album.repository.sql b/server/src/infra/sql/album.repository.sql index 3997dd1a22914..d9b2e896e90f8 100644 --- a/server/src/infra/sql/album.repository.sql +++ b/server/src/infra/sql/album.repository.sql @@ -26,6 +26,7 @@ FROM "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -41,6 +42,7 @@ FROM "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -100,6 +102,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -115,6 +118,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -156,6 +160,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -171,6 +176,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -284,6 +290,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -311,6 +318,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -355,6 +363,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -382,6 +391,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -463,6 +473,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -490,6 +501,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -552,6 +564,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", diff --git a/server/src/infra/sql/api.key.repository.sql b/server/src/infra/sql/api.key.repository.sql index 3f6b207ce19dd..22b8fd67226c2 100644 --- a/server/src/infra/sql/api.key.repository.sql +++ b/server/src/infra/sql/api.key.repository.sql @@ -20,6 +20,7 @@ FROM "APIKeyEntity__APIKeyEntity_user"."shouldChangePassword" AS "APIKeyEntity__APIKeyEntity_user_shouldChangePassword", "APIKeyEntity__APIKeyEntity_user"."createdAt" AS "APIKeyEntity__APIKeyEntity_user_createdAt", "APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt", + "APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status", "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", "APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled", "APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes", diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index 54992e5f87779..75b5291b66914 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -293,53 +293,6 @@ DELETE FROM "assets" WHERE "ownerId" = $1 --- AssetRepository.getByLibraryId -SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", - "AssetEntity"."ownerId" AS "AssetEntity_ownerId", - "AssetEntity"."libraryId" AS "AssetEntity_libraryId", - "AssetEntity"."deviceId" AS "AssetEntity_deviceId", - "AssetEntity"."type" AS "AssetEntity_type", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."resizePath" AS "AssetEntity_resizePath", - "AssetEntity"."webpPath" AS "AssetEntity_webpPath", - "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", - "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", - "AssetEntity"."createdAt" AS "AssetEntity_createdAt", - "AssetEntity"."updatedAt" AS "AssetEntity_updatedAt", - "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", - "AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt", - "AssetEntity"."localDateTime" AS "AssetEntity_localDateTime", - "AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt", - "AssetEntity"."isFavorite" AS "AssetEntity_isFavorite", - "AssetEntity"."isArchived" AS "AssetEntity_isArchived", - "AssetEntity"."isExternal" AS "AssetEntity_isExternal", - "AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline", - "AssetEntity"."checksum" AS "AssetEntity_checksum", - "AssetEntity"."duration" AS "AssetEntity_duration", - "AssetEntity"."isVisible" AS "AssetEntity_isVisible", - "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", - "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", - "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId" -FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - AND ( - "AssetEntity__AssetEntity_library"."deletedAt" IS NULL - ) -WHERE - ( - ( - ( - (("AssetEntity__AssetEntity_library"."id" IN ($1))) - ) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" @@ -428,55 +381,6 @@ WHERE AND "isOffline" = $4 ) --- AssetRepository.getAllByFileCreationDate -SELECT - "asset"."id" AS "asset_id", - "asset"."deviceAssetId" AS "asset_deviceAssetId", - "asset"."ownerId" AS "asset_ownerId", - "asset"."libraryId" AS "asset_libraryId", - "asset"."deviceId" AS "asset_deviceId", - "asset"."type" AS "asset_type", - "asset"."originalPath" AS "asset_originalPath", - "asset"."resizePath" AS "asset_resizePath", - "asset"."webpPath" AS "asset_webpPath", - "asset"."thumbhash" AS "asset_thumbhash", - "asset"."encodedVideoPath" AS "asset_encodedVideoPath", - "asset"."createdAt" AS "asset_createdAt", - "asset"."updatedAt" AS "asset_updatedAt", - "asset"."deletedAt" AS "asset_deletedAt", - "asset"."fileCreatedAt" AS "asset_fileCreatedAt", - "asset"."localDateTime" AS "asset_localDateTime", - "asset"."fileModifiedAt" AS "asset_fileModifiedAt", - "asset"."isFavorite" AS "asset_isFavorite", - "asset"."isArchived" AS "asset_isArchived", - "asset"."isExternal" AS "asset_isExternal", - "asset"."isReadOnly" AS "asset_isReadOnly", - "asset"."isOffline" AS "asset_isOffline", - "asset"."checksum" AS "asset_checksum", - "asset"."duration" AS "asset_duration", - "asset"."isVisible" AS "asset_isVisible", - "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", - "asset"."originalFileName" AS "asset_originalFileName", - "asset"."sidecarPath" AS "asset_sidecarPath", - "asset"."stackId" AS "asset_stackId" -FROM - "assets" "asset" -WHERE - ( - "asset"."fileCreatedAt" <= $1 - AND 1 = 1 - AND "asset"."ownerId" IN ($2) - AND 1 = 1 - AND "asset"."isArchived" = $3 - ) - AND ("asset"."deletedAt" IS NULL) -ORDER BY - "asset"."fileCreatedAt" DESC -LIMIT - 10001 -OFFSET - 20000 - -- AssetRepository.getAllByDeviceId SELECT "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", diff --git a/server/src/infra/sql/library.repository.sql b/server/src/infra/sql/library.repository.sql index 433ab6fbac43e..93a6fc97fb107 100644 --- a/server/src/infra/sql/library.repository.sql +++ b/server/src/infra/sql/library.repository.sql @@ -28,6 +28,7 @@ FROM "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", @@ -143,6 +144,7 @@ SELECT "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", @@ -188,6 +190,7 @@ SELECT "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", @@ -227,6 +230,7 @@ SELECT "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index 48a7fc8e5bce1..a11f8805a0487 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -229,6 +229,8 @@ FROM "cte" "res" WHERE res.distance <= $3 +ORDER BY + res.distance ASC COMMIT -- SearchRepository.searchPlaces diff --git a/server/src/infra/sql/shared.link.repository.sql b/server/src/infra/sql/shared.link.repository.sql index 6cac1c44fc5e2..b5e6894130d64 100644 --- a/server/src/infra/sql/shared.link.repository.sql +++ b/server/src/infra/sql/shared.link.repository.sql @@ -155,6 +155,7 @@ FROM "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", @@ -258,6 +259,7 @@ SELECT "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", @@ -311,6 +313,7 @@ FROM "SharedLinkEntity__SharedLinkEntity_user"."shouldChangePassword" AS "SharedLinkEntity__SharedLinkEntity_user_shouldChangePassword", "SharedLinkEntity__SharedLinkEntity_user"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_user_createdAt", "SharedLinkEntity__SharedLinkEntity_user"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_user_deletedAt", + "SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status", "SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt", "SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled", "SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes", diff --git a/server/src/infra/sql/user.repository.sql b/server/src/infra/sql/user.repository.sql index e4c7d3a314b4f..b3741bcf75669 100644 --- a/server/src/infra/sql/user.repository.sql +++ b/server/src/infra/sql/user.repository.sql @@ -13,6 +13,7 @@ SELECT "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt", + "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", @@ -59,6 +60,7 @@ SELECT "user"."shouldChangePassword" AS "user_shouldChangePassword", "user"."createdAt" AS "user_createdAt", "user"."deletedAt" AS "user_deletedAt", + "user"."status" AS "user_status", "user"."updatedAt" AS "user_updatedAt", "user"."memoriesEnabled" AS "user_memoriesEnabled", "user"."quotaSizeInBytes" AS "user_quotaSizeInBytes", @@ -82,6 +84,7 @@ SELECT "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt", + "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", @@ -107,6 +110,7 @@ SELECT "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt", + "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", diff --git a/server/src/infra/sql/user.token.repository.sql b/server/src/infra/sql/user.token.repository.sql index b51e53106e7ef..f09238e1379b0 100644 --- a/server/src/infra/sql/user.token.repository.sql +++ b/server/src/infra/sql/user.token.repository.sql @@ -23,6 +23,7 @@ FROM "UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword", "UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt", "UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt", + "UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status", "UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt", "UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled", "UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes", diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 623538e594d07..14a6eb6913e02 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -3,7 +3,6 @@ import { AuditService, DatabaseService, IDeleteFilesJob, - IStorageRepository, JobName, JobService, LibraryService, @@ -16,7 +15,7 @@ import { SystemConfigService, UserService, } from '@app/domain'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { @@ -34,7 +33,6 @@ export class AppService { private storageService: StorageService, private userService: UserService, private databaseService: DatabaseService, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) {} async init() { diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 63f1229a23fec..e1a5fed83097f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -18,13 +18,10 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getFirstAssetForAlbumId: jest.fn(), getLastUpdatedAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), - getAllByFileCreationDate: jest.fn(), getAllByDeviceId: jest.fn(), updateAll: jest.fn(), - getByLibraryId: jest.fn(), + getLibraryAssetPaths: jest.fn(), getByLibraryIdAndOriginalPath: jest.fn(), - updateOfflineLibraryAssets: jest.fn(), - getPathsNotInLibrary: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), remove: jest.fn(), diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index e365a20bd5b26..402b90eaddab8 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -17,7 +17,6 @@ export const newUserRepositoryMock = (reset = true): jest.Mocked { try { - const { deletedAt } = await deleteUser({ id: user.id }); + const { deletedAt } = await deleteUser({ + id: user.id, + deleteUserDto: { force: forceDelete }, + }); + if (deletedAt == undefined) { dispatch('fail'); } else { @@ -26,20 +34,68 @@ dispatch('fail'); } }; + + const handleConfirm = (e: Event) => { + userIdInput = (e.target as HTMLInputElement).value; + deleteButtonDisabled = userIdInput != user.email; + }; dispatch('cancel')} + disabled={deleteButtonDisabled} >

-

- {user.name}'s account and assets will be permanently deleted after {$serverConfig.userDeleteDelay} days. -

-

Are you sure you want to continue?

+ {#if forceDelete} +

+ {user.name}'s account and assets will be queued for permanent deletion immediately. +

+ {:else} +

+ {user.name}'s account and assets will be scheduled for permanent deletion in {$serverConfig.userDeleteDelay} + days. +

+ {/if} + +
+ + + { + deleteButtonDisabled = forceDelete; + }} + /> +
+ + {#if forceDelete} +

+ WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be + recovered. +

+ +

+ To confirm, type "{user.email}" below +

+ + + {/if}
diff --git a/web/src/lib/components/admin-page/restore-dialoge.svelte b/web/src/lib/components/admin-page/restore-dialoge.svelte index d9a8ed3bc01b2..b98932f829e07 100644 --- a/web/src/lib/components/admin-page/restore-dialoge.svelte +++ b/web/src/lib/components/admin-page/restore-dialoge.svelte @@ -1,5 +1,6 @@ - -
-
-
-
-
- -
-
-
-
-
- -
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index b46768c3e7744..a0eaa8cb47687 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -6,13 +6,12 @@ import { oauth } from '$lib/utils'; import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk'; import SettingAccordion from '../shared-components/settings/setting-accordion.svelte'; - import AppearanceSettings from './appearance-settings.svelte'; + import AppSettings from './app-settings.svelte'; import ChangePasswordSettings from './change-password-settings.svelte'; import DeviceList from './device-list.svelte'; import MemoriesSettings from './memories-settings.svelte'; import OAuthSettings from './oauth-settings.svelte'; import PartnerSettings from './partner-settings.svelte'; - import TrashSettings from './trash-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; @@ -26,8 +25,8 @@ - - + + @@ -42,7 +41,7 @@ - + @@ -59,8 +58,4 @@ - - - - diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 49b836b8a88ee..ff158722e495e 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -96,3 +96,5 @@ export const albumViewSettings = persisted('album-view-settin export const showDeleteModal = persisted('delete-confirm-dialog', true, {}); export const alwaysLoadOriginalFile = persisted('always-load-original-file', false, {}); + +export const playVideoThumbnailOnHover = persisted('play-video-thumbnail-on-hover', true, {}); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 375cd3e75c349..c0244027e281a 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -12,6 +12,7 @@ export interface ReleaseEvent { } export interface Events { on_upload_success: (asset: AssetResponseDto) => void; + on_user_delete: (id: string) => void; on_asset_delete: (assetId: string) => void; on_asset_trash: (assetIds: string[]) => void; on_asset_update: (asset: AssetResponseDto) => void; diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 9f809409f4426..ea681acc06636 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -1,9 +1,10 @@ @@ -123,8 +132,8 @@ {#if shouldShowDeleteConfirmDialog} (shouldShowDeleteConfirmDialog = false)} /> {/if} @@ -132,8 +141,8 @@ {#if shouldShowRestoreDialog} (shouldShowRestoreDialog = false)} /> {/if} @@ -179,9 +188,7 @@ {#if allUsers} {#each allUsers as immichUser, index} - {#if !isDeleted(immichUser)} + {#if !immichUser.deletedAt} {/if} {/if} - {#if isDeleted(immichUser)} + {#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted} diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 02273f750f8ec..563844e07d0eb 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { UserAvatarColor, type UserResponseDto } from '@immich/sdk'; +import { UserAvatarColor, UserStatus, type UserResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; export const userFactory = Sync.makeFactory({ @@ -18,4 +18,5 @@ export const userFactory = Sync.makeFactory({ avatarColor: UserAvatarColor.Primary, quotaUsageInBytes: 0, quotaSizeInBytes: null, + status: UserStatus.Active, }); diff --git a/web/svelte.config.js b/web/svelte.config.js index 3cb982c6b8953..76a9c2e55b27a 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -6,13 +6,8 @@ const config = { preprocess: vitePreprocess(), kit: { adapter: adapter({ - // default options are shown. On some platforms - // these options are set automatically — see below - pages: 'build', - assets: 'build', fallback: 'index.html', - precompress: false, - strict: true, + precompress: true, }), alias: { $lib: 'src/lib',