diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6b17774e..d704aa629 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 b455e2656..55875e732 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 ca8b1e397..f4adc1af0 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 ebf1514cd..cf7bcb4f5 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 64f8b75a9..322a58b96 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 6ab6617a6..047c47260 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 66c403c81..3b4c6d428 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 b588e1951..7e8863a93 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 999237a2e..90143025a 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 db3bb7a07..3a1599597 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 7d853c219..1eeb41e5a 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 690870596..f6d50b1ce 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 f04293f88..6e5c43ee6 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 0b9bd0706..874b18f09 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 9574fa26f..38fe3c6f1 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 62719facd..000000000 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 1ded0cbd2..000000000 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 a48b5af35..000000000 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 d70669a9d..000000000 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 63084db44..000000000 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 b7e59a90b..000000000 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 cab58441c..000000000 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 7c8e944b9..000000000 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 7871ed6f9..000000000 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 000000000..cc6cb23b6 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 000000000..024337c2e --- /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 000000000..b910b3790 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 000000000..216466f58 --- /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 d4e7085f1..000000000 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 000000000..6b357f1d5 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 000000000..7f8381869 --- /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 000000000..3df5e04e1 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 000000000..8c4505d97 --- /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 000000000..297f8a926 Binary files /dev/null and b/design/immich-logo.png differ diff --git a/design/immich-logo.svg b/design/immich-logo.svg index e7edba069..376fa6f3e 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 000000000..215687af8 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 000000000..478158d39 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 3dfa11fd7..000000000 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 9a756a506..000000000 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 a3687fb12..000000000 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 a8dd15eae..000000000 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 3228b348c..000000000 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 b4baf2630..000000000 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 e43fb897e..000000000 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 7b3d1ed04..000000000 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 a0b139b84..000000000 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 d5d819ae3..000000000 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 e6c7c6d2e..000000000 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 f9d16c175..000000000 Binary files a/design/web-home.jpeg and /dev/null differ diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index 2f6ae3ebd..ef9c0a5bb 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 db3d1ba7d..420cd2a43 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 000000000..c03915e2c --- /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 b1d4b67b2..ba60e8b11 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 338d6d2a1..f3d06eacc 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 14685df51..99d3a91cd 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 4faa5eac3..2310b4718 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 f1bb35531..a13bb58eb 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 de7d9ef4c..9c554abc5 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 8b854eda0..3c3fd7926 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 3e6c2f1fc..dc2cadc49 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 d448a605c..911f25381 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 6a1a1b396..031985c5f 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 76e289ade..37892be0c 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 d62497b8e..dde9ed22c 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 fc881d29a..327f4fd35 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 45d32ebfc..14f0b3a81 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 a656dbc4c..a11d60267 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 575edaa4e..0dcf80996 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 6081988b7..a9ac5b338 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 457160ad3..dcfe3dc85 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 6d745e984..310133e0b 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 328fe1536..00cbeb8ac 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 129dddb34..f1e83434b 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 579ace7c5..5441293f7 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 bdd8e1d4b..ddebdaf77 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 536c671b8..d8ff4d30f 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 000000000..50894b616 --- /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 ce45b3259..5d0c4ddf3 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 62f314806..61df5d4de 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 700a5b849..69d85fbbd 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 000000000..02abb4eff --- /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 0a093e453..5b49d8d67 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 f92b8fe9f..241c1698c 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 5e5f70299..312153788 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 f37ba588a..d186845d9 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 000000000..d62f40b1e --- /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 008e0c4f2..37602d04b 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 d4e0bf07d..df68128e7 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 000000000..cbbe1b56d --- /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 000000000..475681d42 --- /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 7fce31d5e..2eef7f0c8 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 b0a3ba85f..61df36243 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 d0fdf97e1..71fa57f48 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 000000000..88abba045 --- /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 d0ab2a8ac..85f240ae9 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 8819825b9..2540baf77 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 ff333b7f7..0c2230835 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 2bd1cfddd..ec5e78fac 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 e9ce46712..acf540aff 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 9fd67774f..000000000 --- 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 88f2f598b..000000000 --- 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 6badd4c67..000000000 --- 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 c03c4ada5..000000000 --- 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 8d2a1b79b..63d439586 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 f0206d337..e89e6d057 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 b0464a34d..b4aa2a141 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 e0b133126..070683eb0 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 c34093b0a..000000000 --- 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 a381253f5..000000000 --- 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 c538db3a8..000000000 --- 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 54dd09902..2468c0c97 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 da9cb0e44..70d647989 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 0dc9c5414..b723474dd 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 1010185b2..5d5333f3a 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 a44624c43..03042cf55 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 c74e97ea3..25894c9b5 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 244978d09..8a6eae4cc 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 6a5c8ff9d..5c8e777ad 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 d5f08ab0d..3acabb435 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 dd5e76577..b779c8b8c 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 4a3bc552c..65e322702 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 cecdb0b06..efd950318 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 000000000..88f55f4af --- /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 09d7998e8..2d166de36 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 a82337945..bd437ea34 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 dba0106fb..d0e56e4cd 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 9a862199b..564163d77 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 2f54db27d..18feb65dc 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 48354d440..9f0aa371e 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 923cb4ebe..821a7de82 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 f3369b121..adfb9d878 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 2cf2c6f86..7fa7ccd0f 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 05f3f0381..d2fe44f1f 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 c574595ea..20c057d79 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 000000000..858f51258 --- /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 481305665..ff60be9fe 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 fef184992..32880ae18 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 0ff26a4f5..823193138 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 640eda0ee..d9f12bb31 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 3997dd1a2..d9b2e896e 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 3f6b207ce..22b8fd672 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 54992e5f8..75b5291b6 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 433ab6fba..93a6fc97f 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 48a7fc8e5..a11f8805a 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 6cac1c44f..b5e689413 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 e4c7d3a31..b3741bcf7 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 b51e53106..f09238e13 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 623538e59..14a6eb691 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 63f1229a2..e1a5fed83 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 e365a20bd..402b90ead 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 d9a8ed3bc..b98932f82 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 b46768c3e..a0eaa8cb4 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 49b836b8a..ff158722e 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 375cd3e75..c0244027e 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 9f809409f..ea681acc0 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 02273f750..563844e07 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 3cb982c6b..76a9c2e55 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',