Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Way
96a2725c3e feat: object storage 2024-02-18 23:16:45 +00:00
176 changed files with 5152 additions and 7394 deletions

View File

@@ -32,11 +32,7 @@ jobs:
- name: Run dart analyze
run: dart analyze --fatal-infos
working-directory: ./mobile
- name: Run dart format
run: dart format lib/ --set-exit-if-changed
working-directory: ./mobile
# Enable after riverpod generator migration is completed
# - name: Run dart custom lint
# run: dart run custom_lint

View File

@@ -135,6 +135,38 @@ jobs:
run: npm run test:cov
if: ${{ !cancelled() }}
cli-e2e-tests:
name: CLI (e2e)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./cli
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run npm install (cli)
run: npm ci
- name: Run npm install (server)
run: npm ci && npm run build
working-directory: ./server
- name: Run e2e tests
run: npm run test:e2e
web-unit-tests:
name: Web
runs-on: ubuntu-latest
@@ -173,8 +205,8 @@ jobs:
run: npm run test:cov
if: ${{ !cancelled() }}
e2e-tests:
name: End-to-End Tests
web-e2e-tests:
name: Web (e2e)
runs-on: ubuntu-latest
defaults:
run:
@@ -183,22 +215,11 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run setup cli
run: npm ci && npm run build
working-directory: ./cli
- name: Install dependencies
run: npm ci
@@ -206,12 +227,10 @@ jobs:
run: npx playwright install --with-deps
- name: Docker build
run: docker compose build
run: docker compose -f docker/docker-compose.e2e.yml build
working-directory: ./
- name: Run e2e tests (api & cli)
run: npm run test
- name: Run e2e tests (web)
- name: Run e2e tests
run: npx playwright test
mobile-unit-tests:
@@ -258,19 +277,6 @@ jobs:
run: |
poetry run pytest app --cov=app --cov-report term-missing
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@master
with:
ignore_paths: >-
**/open-api/**
**/openapi/**
**/node_modules/**
generated-api-up-to-date:
name: OpenAPI Clients
runs-on: ubuntu-latest

View File

@@ -24,7 +24,7 @@ server-e2e-api:
.PHONY: e2e
e2e:
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
docker compose -f ./docker/docker-compose.e2e.yml up --build -V --remove-orphans
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans

View File

@@ -10,6 +10,7 @@ module.exports = {
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
@@ -21,6 +22,11 @@ module.exports = {
'unicorn/prefer-module': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'error',
'unicorn/prevent-abbreviations': [
'error',
{
ignore: ['\\.e2e-spec$', /^ignore/i],
},
],
},
};

View File

@@ -1,4 +1,5 @@
**/*.spec.js
test/**
upload/**
.editorconfig
.eslintignore

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine3.19@sha256:c0a3badbd8a0a760de903e00cedbca94588e609299820557e72cba2a53dbaa2c as core
FROM node:20-alpine3.19 as core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

354
cli/package-lock.json generated
View File

@@ -8,9 +8,6 @@
"name": "@immich/cli",
"version": "2.0.8",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"lodash-es": "^4.17.21"
},
"bin": {
"immich": "dist/index.js"
},
@@ -19,11 +16,10 @@
"@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitest/coverage-v8": "^1.2.2",
"byte-size": "^8.1.1",
"cli-progress": "^3.12.0",
@@ -33,6 +29,7 @@
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"glob": "^10.3.1",
"immich": "file:../server",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
@@ -67,7 +64,7 @@
"../server": {
"name": "immich",
"version": "1.94.1",
"extraneous": true,
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@babel/runtime": "^7.22.11",
@@ -137,8 +134,8 @@
"@types/sharp": "^0.31.1",
"@types/supertest": "^6.0.0",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"dotenv": "^16.3.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
@@ -1300,21 +1297,6 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/lodash": {
"version": "4.14.202",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz",
"integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==",
"dev": true
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"dev": true,
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/mock-fs": {
"version": "4.13.4",
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz",
@@ -1340,9 +1322,9 @@
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"dev": true
},
"node_modules/@types/ssh2": {
@@ -1373,16 +1355,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/type-utils": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/type-utils": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -1398,8 +1380,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.56.0"
"@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
"eslint": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -1408,15 +1390,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1427,7 +1409,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
"eslint": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -1436,13 +1418,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1"
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -1453,13 +1435,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@@ -1471,7 +1453,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
"eslint": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -1480,9 +1462,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -1493,13 +1475,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1545,17 +1527,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"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.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"semver": "^7.5.4"
},
"engines": {
@@ -1566,16 +1548,16 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
"eslint": "^7.0.0 || ^8.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@@ -3186,6 +3168,10 @@
"node": ">= 4"
}
},
"node_modules/immich": {
"resolved": "../server",
"link": true
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -3558,11 +3544,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -5240,9 +5221,9 @@
}
},
"node_modules/vite": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz",
"integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
@@ -6456,21 +6437,6 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"@types/lodash": {
"version": "4.14.202",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz",
"integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==",
"dev": true
},
"@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"dev": true,
"requires": {
"@types/lodash": "*"
}
},
"@types/mock-fs": {
"version": "4.13.4",
"resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz",
@@ -6496,9 +6462,9 @@
"dev": true
},
"@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"dev": true
},
"@types/ssh2": {
@@ -6531,16 +6497,16 @@
}
},
"@typescript-eslint/eslint-plugin": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/type-utils": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/type-utils": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -6550,54 +6516,54 @@
}
},
"@typescript-eslint/parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4"
}
},
"@typescript-eslint/scope-manager": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1"
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
}
},
"@typescript-eslint/type-utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
"dev": true,
"requires": {
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
}
},
"@typescript-eslint/types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -6627,27 +6593,27 @@
}
},
"@typescript-eslint/utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"semver": "^7.5.4"
}
},
"@typescript-eslint/visitor-keys": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
}
},
@@ -7828,6 +7794,99 @@
"integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
"dev": true
},
"immich": {
"version": "file:../server",
"requires": {
"@babel/runtime": "^7.22.11",
"@immich/cli": "^2.0.7",
"@nestjs/bullmq": "^10.0.1",
"@nestjs/cli": "^10.1.16",
"@nestjs/common": "^10.2.2",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.2.2",
"@nestjs/platform-express": "^10.2.2",
"@nestjs/platform-socket.io": "^10.2.2",
"@nestjs/schedule": "^4.0.0",
"@nestjs/schematics": "^10.0.2",
"@nestjs/swagger": "^7.1.8",
"@nestjs/testing": "^10.2.2",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.2.2",
"@socket.io/postgres-adapter": "^0.3.1",
"@testcontainers/postgresql": "^10.2.1",
"@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/imagemin": "^8.0.1",
"@types/jest": "29.5.12",
"@types/jest-when": "^3.5.2",
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^20.5.7",
"@types/picomatch": "^2.3.3",
"@types/sharp": "^0.31.1",
"@types/supertest": "^6.0.0",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"archiver": "^6.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^5.1.1",
"bullmq": "^4.8.0",
"chokidar": "^3.5.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"exiftool-vendored": "~24.4.0",
"exiftool-vendored.pl": "12.73",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"glob": "^10.3.3",
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.3.2",
"jest": "^29.6.4",
"jest-when": "^3.6.0",
"joi": "^17.10.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
"mock-fs": "^5.2.0",
"nest-commander": "^3.11.1",
"node-addon-api": "^7.0.0",
"openid-client": "^5.4.3",
"pg": "^8.11.3",
"picomatch": "^4.0.0",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^3.2.3",
"reflect-metadata": "^0.1.13",
"rimraf": "^5.0.1",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"sharp": "^0.33.0",
"source-map-support": "^0.5.21",
"sql-formatter": "^15.0.0",
"supertest": "^6.3.3",
"testcontainers": "^10.2.1",
"thumbhash": "^0.1.1",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.4",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.17",
"typescript": "^5.3.3",
"ua-parser-js": "^1.0.35",
"utimes": "^5.2.1"
}
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -8119,11 +8178,6 @@
"p-locate": "^5.0.0"
}
},
"lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -9364,9 +9418,9 @@
}
},
"vite": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.2.tgz",
"integrity": "sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz",
"integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==",
"dev": true,
"requires": {
"esbuild": "^0.19.3",

View File

@@ -17,11 +17,10 @@
"@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitest/coverage-v8": "^1.2.2",
"byte-size": "^8.1.1",
"cli-progress": "^3.12.0",
@@ -31,6 +30,7 @@
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"glob": "^10.3.1",
"immich": "file:../server",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
@@ -41,14 +41,15 @@
},
"scripts": {
"build": "vite build",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"prepack": "npm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"test:e2e": "vitest --config test/e2e/vitest.config.ts"
},
"repository": {
"type": "git",
@@ -57,8 +58,5 @@
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"lodash-es": "^4.17.21"
}
}

View File

@@ -1,7 +1,5 @@
import { AssetBulkUploadCheckResult } from '@immich/sdk';
import byteSize from 'byte-size';
import cliProgress from 'cli-progress';
import { chunk, zip } from 'lodash-es';
import { createHash } from 'node:crypto';
import fs, { createReadStream } from 'node:fs';
import { access, constants, stat, unlink } from 'node:fs/promises';
@@ -11,23 +9,15 @@ import { ImmichApi } from 'src/services/api.service';
import { CrawlService } from '../services/crawl.service';
import { BaseCommand } from './base-command';
const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][];
enum CheckResponseStatus {
ACCEPT = 'accept',
REJECT = 'reject',
DUPLICATE = 'duplicate',
}
class Asset {
readonly path: string;
readonly deviceId!: string;
id?: string;
deviceAssetId?: string;
fileCreatedAt?: Date;
fileModifiedAt?: Date;
sidecarPath?: string;
fileSize?: number;
fileSize!: number;
albumName?: string;
constructor(path: string) {
@@ -115,141 +105,17 @@ export class UploadOptionsDto {
album? = false;
albumName? = '';
includeHidden? = false;
concurrency? = 4;
}
export class UploadCommand extends BaseCommand {
api!: ImmichApi;
uploadLength!: number;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
this.api = await this.connect();
const api = await this.connect();
console.log('Crawling for assets...');
const files = await this.getFiles(paths, options);
const formatResponse = await api.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
if (files.length === 0) {
console.log('No assets found, exiting');
return;
}
const assetsToCheck = files.map((path) => new Asset(path));
const { newAssets, duplicateAssets } = await this.checkAssets(assetsToCheck, options.concurrency ?? 4);
const totalSizeUploaded = await this.upload(newAssets, options);
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
if (newAssets.length === 0) {
console.log('All assets were already uploaded, nothing to do.');
} else {
console.log(
`${messageStart} uploaded ${newAssets.length} asset${newAssets.length === 1 ? '' : 's'} (${byteSize(totalSizeUploaded)})`,
);
}
if (options.album || options.albumName) {
const { createdAlbumCount, updatedAssetCount } = await this.updateAlbums(
[...newAssets, ...duplicateAssets],
options,
);
console.log(`${messageStart} created ${createdAlbumCount} new album${createdAlbumCount === 1 ? '' : 's'}`);
console.log(`${messageStart} updated ${updatedAssetCount} asset${updatedAssetCount === 1 ? '' : 's'}`);
}
if (!options.delete) {
return;
}
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
return;
}
console.log('Deleting assets that have been uploaded...');
await this.deleteAssets(newAssets, options);
}
public async checkAssets(
assetsToCheck: Asset[],
concurrency: number,
): Promise<{ newAssets: Asset[]; duplicateAssets: Asset[]; rejectedAssets: Asset[] }> {
for (const assets of chunk(assetsToCheck, concurrency)) {
await Promise.all(assets.map((asset: Asset) => asset.prepare()));
}
const checkProgress = new cliProgress.SingleBar(
{ format: 'Checking assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
cliProgress.Presets.shades_classic,
);
checkProgress.start(assetsToCheck.length, 0);
const newAssets = [];
const duplicateAssets = [];
const rejectedAssets = [];
try {
for (const assets of chunk(assetsToCheck, concurrency)) {
const checkedAssets = await this.getStatus(assets);
for (const checked of checkedAssets) {
if (checked.status === CheckResponseStatus.ACCEPT) {
newAssets.push(checked.asset);
} else if (checked.status === CheckResponseStatus.DUPLICATE) {
duplicateAssets.push(checked.asset);
} else {
rejectedAssets.push(checked.asset);
}
checkProgress.increment();
}
}
} finally {
checkProgress.stop();
}
return { newAssets, duplicateAssets, rejectedAssets };
}
public async upload(assetsToUpload: Asset[], options: UploadOptionsDto): Promise<number> {
let totalSize = 0;
// Compute total size first
for (const asset of assetsToUpload) {
totalSize += asset.fileSize ?? 0;
}
if (options.dryRun) {
return totalSize;
}
const uploadProgress = new cliProgress.SingleBar(
{
format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}',
},
cliProgress.Presets.shades_classic,
);
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
let totalSizeUploaded = 0;
try {
for (const assets of chunk(assetsToUpload, options.concurrency)) {
const ids = await this.uploadAssets(assets);
for (const [asset, id] of zipDefined(assets, ids)) {
asset.id = id;
if (asset.fileSize) {
totalSizeUploaded += asset.fileSize ?? 0;
} else {
console.log(`Could not determine file size for ${asset.path}`);
}
}
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
}
} finally {
uploadProgress.stop();
}
return totalSizeUploaded;
}
public async getFiles(paths: string[], options: UploadOptionsDto): Promise<string[]> {
const inputFiles: string[] = [];
for (const pathArgument of paths) {
const fileStat = await fs.promises.lstat(pathArgument);
@@ -258,187 +124,151 @@ export class UploadCommand extends BaseCommand {
}
}
const files: string[] = await this.crawl(paths, options);
files.push(...inputFiles);
return files;
}
public async getAlbums(): Promise<Map<string, string>> {
const existingAlbums = await this.api.getAllAlbums();
const albumMapping = new Map<string, string>();
for (const album of existingAlbums) {
albumMapping.set(album.albumName, album.id);
}
return albumMapping;
}
public async updateAlbums(
assets: Asset[],
options: UploadOptionsDto,
): Promise<{ createdAlbumCount: number; updatedAssetCount: number }> {
if (options.albumName) {
for (const asset of assets) {
asset.albumName = options.albumName;
}
}
const existingAlbums = await this.getAlbums();
const assetsToUpdate = assets.filter(
(asset): asset is Asset & { albumName: string; id: string } => !!(asset.albumName && asset.id),
);
const newAlbumsSet: Set<string> = new Set();
for (const asset of assetsToUpdate) {
if (!existingAlbums.has(asset.albumName)) {
newAlbumsSet.add(asset.albumName);
}
}
const newAlbums = [...newAlbumsSet];
if (options.dryRun) {
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
}
const albumCreationProgress = new cliProgress.SingleBar(
{
format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums',
},
cliProgress.Presets.shades_classic,
);
albumCreationProgress.start(newAlbums.length, 0);
try {
for (const albumNames of chunk(newAlbums, options.concurrency)) {
const newAlbumIds = await Promise.all(
albumNames.map((albumName: string) => this.api.createAlbum({ albumName }).then((r) => r.id)),
);
for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
existingAlbums.set(albumName, albumId);
}
albumCreationProgress.increment(albumNames.length);
}
} finally {
albumCreationProgress.stop();
}
const albumToAssets = new Map<string, string[]>();
for (const asset of assetsToUpdate) {
const albumId = existingAlbums.get(asset.albumName);
if (albumId) {
if (!albumToAssets.has(albumId)) {
albumToAssets.set(albumId, []);
}
albumToAssets.get(albumId)?.push(asset.id);
}
}
const albumUpdateProgress = new cliProgress.SingleBar(
{
format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
},
cliProgress.Presets.shades_classic,
);
albumUpdateProgress.start(assetsToUpdate.length, 0);
try {
for (const [albumId, assets] of albumToAssets.entries()) {
for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) {
await this.api.addAssetsToAlbum(albumId, { ids: assetBatch });
albumUpdateProgress.increment(assetBatch.length);
}
}
} finally {
albumUpdateProgress.stop();
}
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
}
public async deleteAssets(assets: Asset[], options: UploadOptionsDto): Promise<void> {
const deletionProgress = new cliProgress.SingleBar(
{
format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
},
cliProgress.Presets.shades_classic,
);
deletionProgress.start(assets.length, 0);
try {
for (const assetBatch of chunk(assets, options.concurrency)) {
await Promise.all(assetBatch.map((asset: Asset) => asset.delete()));
deletionProgress.update(assetBatch.length);
}
} finally {
deletionProgress.stop();
}
}
private async getStatus(assets: Asset[]): Promise<{ asset: Asset; status: CheckResponseStatus }[]> {
const checkResponse = await this.checkHashes(assets);
const responses = [];
for (const [check, asset] of zipDefined(checkResponse, assets)) {
if (check.assetId) {
asset.id = check.assetId;
}
if (check.action === 'accept') {
responses.push({ asset, status: CheckResponseStatus.ACCEPT });
} else if (check.reason === 'duplicate') {
responses.push({ asset, status: CheckResponseStatus.DUPLICATE });
} else {
responses.push({ asset, status: CheckResponseStatus.REJECT });
}
}
return responses;
}
private async checkHashes(assetsToCheck: Asset[]): Promise<AssetBulkUploadCheckResult[]> {
const checksums = await Promise.all(assetsToCheck.map((asset) => asset.hash()));
const assetBulkUploadCheckDto = {
assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })),
};
const checkResponse = await this.api.checkBulkUpload(assetBulkUploadCheckDto);
return checkResponse.results;
}
private async uploadAssets(assets: Asset[]): Promise<string[]> {
const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData()));
return Promise.all(fileRequests.map((request) => this.uploadAsset(request).then((response) => response.id)));
}
private async crawl(paths: string[], options: UploadOptionsDto): Promise<string[]> {
const formatResponse = await this.api.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
return crawlService.crawl({
const files: string[] = await crawlService.crawl({
pathsToCrawl: paths,
recursive: options.recursive,
exclusionPatterns: options.exclusionPatterns,
includeHidden: options.includeHidden,
});
files.push(...inputFiles);
if (files.length === 0) {
console.log('No assets found, exiting');
return;
}
const assetsToUpload = files.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar(
{
format: '{bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}: {filename}',
},
cliProgress.Presets.shades_classic,
);
let totalSize = 0;
let sizeSoFar = 0;
let totalSizeUploaded = 0;
let uploadCounter = 0;
for (const asset of assetsToUpload) {
// Compute total size first
await asset.prepare();
totalSize += asset.fileSize;
if (options.albumName) {
asset.albumName = options.albumName;
}
}
const existingAlbums = await api.getAllAlbums();
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
try {
for (const asset of assetsToUpload) {
uploadProgress.update({
filename: asset.path,
});
let skipUpload = false;
let skipAsset = false;
let existingAssetId: string | undefined = undefined;
if (!options.skipHash) {
const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] };
const checkResponse = await api.checkBulkUpload(assetBulkUploadCheckDto);
skipUpload = checkResponse.results[0].action === 'reject';
const isDuplicate = checkResponse.results[0].reason === 'duplicate';
if (isDuplicate) {
existingAssetId = checkResponse.results[0].assetId;
}
skipAsset = skipUpload && !isDuplicate;
}
if (!skipAsset && !options.dryRun) {
if (!skipUpload) {
const formData = await asset.getUploadFormData();
const response = await this.uploadAsset(api, formData);
const json = await response.json();
existingAssetId = json.id;
uploadCounter++;
totalSizeUploaded += asset.fileSize;
}
if ((options.album || options.albumName) && asset.albumName !== undefined) {
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
if (!album) {
const response = await api.createAlbum({ albumName: asset.albumName });
album = response;
existingAlbums.push(album);
}
if (existingAssetId) {
await api.addAssetsToAlbum(album.id, {
ids: [existingAssetId],
});
}
}
}
sizeSoFar += asset.fileSize;
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
}
} finally {
uploadProgress.stop();
}
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
if (uploadCounter === 0) {
console.log('All assets were already uploaded, nothing to do.');
} else {
console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
}
if (options.delete) {
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
} else {
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(files.length, 0);
for (const asset of assetsToUpload) {
if (!options.dryRun) {
await asset.delete();
}
deletionProgress.increment();
}
deletionProgress.stop();
console.log('Deletion complete');
}
}
}
private async uploadAsset(data: FormData): Promise<{ id: string }> {
const url = this.api.instanceUrl + '/asset/upload';
private async uploadAsset(api: ImmichApi, data: FormData): Promise<Response> {
const url = api.instanceUrl + '/asset/upload';
const response = await fetch(url, {
method: 'post',
redirect: 'error',
headers: {
'x-api-key': this.api.apiKey,
'x-api-key': api.apiKey,
},
body: data,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(await response.text());
}
return response.json();
return response;
}
}

View File

@@ -15,7 +15,7 @@ const program = new Command()
.version(version)
.description('Command line interface for Immich')
.addOption(
new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
new Option('-d, --config-directory', 'Configuration directory where auth.yml will be stored')
.env('IMMICH_CONFIG_DIR')
.default(defaultConfigDirectory),
);
@@ -43,11 +43,6 @@ program
.env('IMMICH_DRY_RUN')
.default(false),
)
.addOption(
new Option('-c, --concurrency', 'Number of assets to upload at the same time')
.env('IMMICH_UPLOAD_CONCURRENCY')
.default(4),
)
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => {
@@ -65,10 +60,10 @@ program
program
.command('login-key')
.description('Login using an API key')
.argument('url')
.argument('key')
.action(async (url, key) => {
await new LoginCommand(program.opts()).run(url, key);
.argument('[instanceUrl]')
.argument('[apiKey]')
.action(async (paths, options) => {
await new LoginCommand(program.opts()).run(paths, options);
});
program

View File

@@ -1,41 +1,17 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'yaml';
import {
TEST_AUTH_FILE,
TEST_CONFIG_DIR,
TEST_IMMICH_API_KEY,
TEST_IMMICH_INSTANCE_URL,
createTestAuthFile,
deleteAuthFile,
readTestAuthFile,
spyOnConsole,
} from '../../test/cli-test-utils';
import { SessionService } from './session.service';
const TEST_CONFIG_DIR = '/tmp/immich/';
const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {});
const createTestAuthFile = async (contents: string) => {
if (!fs.existsSync(TEST_CONFIG_DIR)) {
// Create config folder if it doesn't exist
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
}
}
fs.writeFileSync(TEST_AUTH_FILE, contents);
};
const readTestAuthFile = async (): Promise<string> => {
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
};
const deleteAuthFile = () => {
try {
fs.unlinkSync(TEST_AUTH_FILE);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};
const mocks = vi.hoisted(() => {
return {
getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })),

View File

@@ -0,0 +1,52 @@
import fs from 'node:fs';
import path from 'node:path';
import { ImmichApi } from 'src/services/api.service';
export const TEST_CONFIG_DIR = '/tmp/immich/';
export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
export const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
export const CLI_BASE_OPTIONS = { configDirectory: TEST_CONFIG_DIR };
export const setup = async () => {
const api = new ImmichApi(process.env.IMMICH_INSTANCE_URL as string, '');
await api.signUpAdmin({ email: 'cli@immich.app', password: 'password', name: 'Administrator' });
const admin = await api.login({ email: 'cli@immich.app', password: 'password' });
const apiKey = await api.createApiKey(
{ name: 'CLI Test' },
{ headers: { Authorization: `Bearer ${admin.accessToken}` } },
);
api.setApiKey(apiKey.secret);
return api;
};
export const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {});
export const createTestAuthFile = async (contents: string) => {
if (!fs.existsSync(TEST_CONFIG_DIR)) {
// Create config folder if it doesn't exist
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
}
}
fs.writeFileSync(TEST_AUTH_FILE, contents);
};
export const readTestAuthFile = async (): Promise<string> => {
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
};
export const deleteAuthFile = () => {
try {
fs.unlinkSync(TEST_AUTH_FILE);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};

View File

@@ -0,0 +1,65 @@
import { restoreTempFolder, testApp } from '@test-utils';
import { readFile, stat } from 'node:fs/promises';
import { CLI_BASE_OPTIONS, TEST_AUTH_FILE, deleteAuthFile, setup, spyOnConsole } from 'test/cli-test-utils';
import yaml from 'yaml';
import { LoginCommand } from '../../src/commands/login.command';
describe(`login-key (e2e)`, () => {
let apiKey: string;
let instanceUrl: string;
spyOnConsole();
beforeAll(async () => {
await testApp.create();
if (process.env.IMMICH_INSTANCE_URL) {
instanceUrl = process.env.IMMICH_INSTANCE_URL;
} else {
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
}
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
deleteAuthFile();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
const api = await setup();
apiKey = api.apiKey;
deleteAuthFile();
});
it('should error when providing an invalid API key', async () => {
await expect(new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
`Failed to connect to server ${instanceUrl}: Error: 401`,
);
});
it('should log in when providing the correct API key', async () => {
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
});
it('should create an auth file when logging in', async () => {
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
const data: string = await readFile(TEST_AUTH_FILE, 'utf8');
const parsedConfig = yaml.parse(data);
expect(parsedConfig).toEqual(expect.objectContaining({ instanceUrl, apiKey }));
});
it('should create an auth file with chmod 600', async () => {
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
const stats = await stat(TEST_AUTH_FILE);
const mode = (stats.mode & 0o777).toString(8);
expect(mode).toEqual('600');
});
});

View File

@@ -0,0 +1,34 @@
import { restoreTempFolder, testApp } from '@test-utils';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { ServerInfoCommand } from '../../src/commands/server-info.command';
describe(`server-info (e2e)`, () => {
const consoleSpy = spyOnConsole();
beforeAll(async () => {
await testApp.create();
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
const api = await setup();
process.env.IMMICH_API_KEY = api.apiKey;
});
it('should show server version', async () => {
await new ServerInfoCommand(CLI_BASE_OPTIONS).run();
expect(consoleSpy.mock.calls).toEqual([
[expect.stringMatching(new RegExp('Server Version: \\d+.\\d+.\\d+'))],
[expect.stringMatching('Image Types: .*')],
[expect.stringMatching('Video Types: .*')],
['Statistics:\n Images: 0\n Videos: 0\n Total: 0'],
]);
});
});

42
cli/test/e2e/setup.ts Normal file
View File

@@ -0,0 +1,42 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { access } from 'node:fs/promises';
import path from 'node:path';
export const directoryExists = (directory: string) =>
access(directory)
.then(() => true)
.catch(() => false);
export default async () => {
let IMMICH_TEST_ASSET_PATH: string = '';
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../../server/test/assets/`);
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
} else {
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
}
if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`,
);
}
if (process.env.DB_HOSTNAME === undefined) {
// DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
.withExposedPorts(5432)
.withDatabase('immich')
.withUsername('postgres')
.withPassword('postgres')
.withReuse()
.start();
process.env.DB_URL = pg.getConnectionUri();
}
process.env.NODE_ENV = 'development';
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/e2e/jobs/immich-e2e-config.json`);
process.env.TZ = 'Z';
};

View File

@@ -0,0 +1,79 @@
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test-utils';
import { ImmichApi } from 'src/services/api.service';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { UploadCommand } from '../../src/commands/upload.command';
describe(`upload (e2e)`, () => {
let api: ImmichApi;
spyOnConsole();
beforeAll(async () => {
await testApp.create();
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
api = await setup();
process.env.IMMICH_API_KEY = api.apiKey;
});
it('should upload a folder recursively', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const assets = await api.getAllAssets();
expect(assets.length).toBeGreaterThan(4);
});
it('should not create a new album', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(0);
});
it('should create album from folder name', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
album: true,
});
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
});
it('should add existing assets to album', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
});
// upload again, but this time add to album
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
album: true,
});
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
});
it('should upload to the specified album name', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
albumName: 'testAlbum',
});
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const testAlbum = albums[0];
expect(testAlbum.albumName).toEqual('testAlbum');
});
});

View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@test-utils': new URL('../../../server/dist/test-utils/utils.js', import.meta.url).pathname,
},
},
test: {
include: ['**/*.e2e-spec.ts'],
globals: true,
globalSetup: 'test/e2e/setup.ts',
pool: 'forks',
poolOptions: {
forks: {
maxForks: 1,
minForks: 1,
},
},
testTimeout: 10_000,
},
});

3
cli/test/global-setup.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = async () => {
process.env.TZ = 'UTC';
};

3
cli/testSetup.js Normal file
View File

@@ -0,0 +1,3 @@
// add all jest-extended matchers
import * as matchers from 'jest-extended';
expect.extend(matchers);

View File

@@ -13,7 +13,6 @@ x-server-build: &server-common
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- REDIS_HOSTNAME=redis
- IMMICH_MACHINE_LEARNING_ENABLED=false
volumes:
- upload:/usr/src/app/upload
depends_on:
@@ -27,22 +26,23 @@ services:
ports:
- 2283:3001
# immich-microservices:
# command: [ "./start.sh", "microservices" ]
# <<: *server-common
immich-microservices:
command: [ "./start.sh", "microservices" ]
<<: *server-common
redis:
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
restart: always
database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
command: -c fsync=off -c shared_preload_libraries=vectors.so
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: immich
ports:
- 5433:5432
- 5432:5432
volumes:
model-cache:

View File

@@ -7,7 +7,7 @@ When contributing code through a pull request, please check the following:
- [ ] `npm run lint` (linting via ESLint)
- [ ] `npm run format` (formatting via Prettier)
- [ ] `npm run check:svelte` (Type checking via SvelteKit)
- [ ] `npm test` (unit tests)
- [ ] `npm test` (Tests via Jest)
:::tip
Run all web checks with `npm run check:all`
@@ -18,7 +18,7 @@ Run all web checks with `npm run check:all`
- [ ] `npm run lint` (linting via ESLint)
- [ ] `npm run format` (formatting via Prettier)
- [ ] `npm run check` (Type checking via `tsc`)
- [ ] `npm test` (unit tests)
- [ ] `npm test` (Tests via Jest)
:::tip
Run all server checks with `npm run check:all`

View File

@@ -42,24 +42,23 @@ Finally, files can be deleted from Immich via the `Remove Offline Files` job. Th
### Import Paths
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible.
If the import paths are edited in a way that an external file is no longer in any import path, it will be removed from the library in the same way a deleted file would. If the file is moved back to an import path, it will be added again as if it was a new file.
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. If the import paths are edited in a way that an external file is no longer in any import path, it will be removed from the library in the same way a deleted file would. If the file is moved back to an import path, it will be added again as if it was a new file.
### Troubleshooting
Sometimes, an external library will not scan correctly. This can happen if immich_server or immich_microservices can't access the files. Here are some things to check:
Sometimes, an external library will not scan correctly. This can happen if the immich_server or immich_microservices can't access the files. Here are some things to check:
- Is the external path set correctly? Each import path must be contained in the external path.
- Make sure the external path does not contain spaces
- Is the external path set correctly?
- In the docker-compose file, are the volumes mounted correctly?
- Are the volumes identical between the `server` and `microservices` container?
- Are the import paths set correctly, and do they match the path set in docker-compose file?
- Make sure you don't use symlinks in your import libraries, and that you aren't linking across docker mounts.
- Are you using symbolic link in your import library?
- Are the permissions set correctly?
- Make sure you are using forward slashes (`/`) and not backward slashes.
- Are you using forward slashes everywhere? (`/`)
- Are you using symlink across docker mounts?
- Are you using [spaces in the internal path](/docs/features/libraries#:~:text=can%20be%20accessed.-,NOTE,-Spaces%20in%20the)?
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_microservices /bin/bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the `immich_server` container. If you cannot access this directory in both the `microservices` and `server` containers, Immich won't be able to import files.
If all else fails, you can always start a shell inside the container and check if the path is accessible. For example, `docker exec -it immich_microservices /bin/bash` will start a bash shell. If your import path, for instance, is `/data/import/photos`, you can check if the files are accessible by running `ls /data/import/photos`. Also check the `immich_server` container in the same way.
### Security Considerations

View File

@@ -60,17 +60,14 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## Database
| Variable | Description | Default | Services |
| :---------------------------------- | :------------------------------------------------------------ | :----------: | :-------------------- |
| `DB_URL` | Database URL | | server, microservices |
| `DB_HOSTNAME` | Database Host | `localhost` | server, microservices |
| `DB_PORT` | Database Port | `5432` | server, microservices |
| `DB_USERNAME` | Database User | `postgres` | server, microservices |
| `DB_PASSWORD` | Database Password | `postgres` | server, microservices |
| `DB_DATABASE` | Database Name | `immich` | server, microservices |
| `DB_VECTOR_EXTENSION`<sup>\*1</sup> | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server, microservices |
\*1: This setting cannot be changed after the server has successfully started up
| Variable | Description | Default | Services |
| :------------ | :---------------- | :---------: | :-------------------- |
| `DB_URL` | Database URL | | server, microservices |
| `DB_HOSTNAME` | Database Host | `localhost` | server, microservices |
| `DB_PORT` | Database Port | `5432` | server, microservices |
| `DB_USERNAME` | Database User | `postgres` | server, microservices |
| `DB_PASSWORD` | Database Password | `postgres` | server, microservices |
| `DB_DATABASE` | Database Name | `immich` | server, microservices |
:::info

View File

@@ -2964,9 +2964,9 @@
}
},
"node_modules/@mdx-js/react": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz",
"integrity": "sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.0.tgz",
"integrity": "sha512-nDctevR9KyYFyV+m+/+S4cpzCWHqj+iHDHq3QrsWezcC+B17uZdIWgCguESUkwFhM3n/56KxWVE3V6EokrmONQ==",
"dependencies": {
"@types/mdx": "^2.0.0"
},

2388
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,24 +5,18 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "vitest --config vitest.config.ts",
"test:web": "npx playwright test",
"start:web": "npx playwright test --ui"
"test": "npx playwright test",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.41.2",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^1.3.0",
"pg": "^8.11.3",
"supertest": "^6.3.4",
"typescript": "^5.3.3",
"vitest": "^1.3.0"
"typescript": "^5.3.3"
}
}

View File

@@ -1,7 +1,7 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src/web/specs',
testDir: './specs/',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
@@ -53,7 +53,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'docker compose up --build -V --remove-orphans',
command:
'docker compose -f ../docker/docker-compose.e2e.yml up --build -V --remove-orphans',
url: 'http://127.0.0.1:2283',
reuseExistingServer: true,
},

View File

@@ -1,17 +1,13 @@
import { test, expect } from '@playwright/test';
import { apiUtils, dbUtils, webUtils } from 'src/utils';
import { app } from '../test-utils';
test.describe('Registration', () => {
test.beforeAll(() => {
apiUtils.setup();
});
test.beforeEach(async () => {
await dbUtils.reset();
await app.reset();
});
test.afterAll(async () => {
await dbUtils.teardown();
await app.teardown();
});
test('admin registration', async ({ page }) => {
@@ -45,8 +41,7 @@ test.describe('Registration', () => {
});
test('user registration', async ({ context, page }) => {
const admin = await apiUtils.adminSetup();
await webUtils.setAuthCookies(context, admin.accessToken);
await app.adminSetup(context);
// create user
await page.goto('/admin/user-management');

View File

@@ -1,291 +0,0 @@
import {
LoginResponseDto,
getAuthDevices,
login,
signUpAdmin,
} from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import {
deviceDto,
errorDto,
loginResponseDto,
signupResponseDto,
} from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const { name, email, password } = signupDto.admin;
describe(`/auth/admin-sign-up`, () => {
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
});
describe('POST /auth/admin-sign-up', () => {
const invalid = [
{
should: 'require an email address',
data: { name, password },
},
{
should: 'require a password',
data: { name, email },
},
{
should: 'require a name',
data: { email, password },
},
{
should: 'require a valid email',
data: { name, email: 'immich', password },
},
];
for (const { should, data } of invalid) {
it(`should ${should}`, async () => {
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send(data);
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it(`should sign up the admin`, async () => {
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send(signupDto.admin);
expect(status).toBe(201);
expect(body).toEqual(signupResponseDto.admin);
});
it('should sign up the admin with a local domain', async () => {
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send({ ...signupDto.admin, email: 'admin@local' });
expect(status).toEqual(201);
expect(body).toEqual({
...signupResponseDto.admin,
email: 'admin@local',
});
});
it('should transform email to lower case', async () => {
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send({ ...signupDto.admin, email: 'aDmIn@IMMICH.cloud' });
expect(status).toEqual(201);
expect(body).toEqual(signupResponseDto.admin);
});
it('should not allow a second admin to sign up', async () => {
await signUpAdmin({ signUpDto: signupDto.admin });
const { status, body } = await request(app)
.post('/auth/admin-sign-up')
.send(signupDto.admin);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
});
});
});
describe('/auth/*', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
await dbUtils.reset();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(app)
.post('/auth/login')
.send({ email, password: 'incorrect' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.incorrectLogin);
});
for (const key of Object.keys(loginDto.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.post('/auth/login')
.send({ ...loginDto.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it('should accept a correct password', async () => {
const { status, body, headers } = await request(app)
.post('/auth/login')
.send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseDto.admin);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0]).toEqual(
`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`
);
expect(cookies[1]).toEqual(
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'
);
expect(cookies[2]).toEqual(
'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'
);
});
});
describe('GET /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/auth/devices');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get a list of authorized devices', async () => {
const { status, body } = await request(app)
.get('/auth/devices')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceDto.current]);
});
});
describe('DELETE /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/auth/devices`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await login({ loginCredentialDto: loginDto.admin });
}
await expect(
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
).resolves.toHaveLength(6);
const { status } = await request(app)
.delete(`/auth/devices`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await expect(
getAuthDevices({ headers: asBearerAuth(admin.accessToken) })
).resolves.toHaveLength(1);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(app)
.delete(`/auth/devices/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no authDevice.delete access')
);
});
it('should logout a device', async () => {
const [device] = await getAuthDevices({
headers: asBearerAuth(admin.accessToken),
});
const { status } = await request(app)
.delete(`/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const response = await request(app)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.body).toEqual(errorDto.invalidToken);
expect(response.status).toBe(401);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post(`/auth/change-password`)
.send({ password, newPassword: 'Password1234' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require the current password', async () => {
const { status, body } = await request(app)
.post(`/auth/change-password`)
.send({ password: 'wrong-password', newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(app)
.post(`/auth/change-password`)
.send({ password, newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
await login({
loginCredentialDto: {
email: 'admin@immich.cloud',
password: 'Password1234',
},
});
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(app)
.post(`/auth/logout`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
});
});

View File

@@ -1,62 +0,0 @@
import { AssetResponseDto, LoginResponseDto } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/download', () => {
let admin: LoginResponseDto;
let asset1: AssetResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup();
asset1 = await apiUtils.createAsset(admin.accessToken);
});
describe('POST /download/info', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post(`/download/info`)
.send({ assetIds: [asset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should download info', async () => {
const { status, body } = await request(app)
.post('/download/info')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ assetIds: [asset1.id] });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
archives: [expect.objectContaining({ assetIds: [asset1.id] })],
})
);
});
});
describe('POST /download/asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(
`/download/asset/${asset1.id}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should download file', async () => {
const response = await request(app)
.post(`/download/asset/${asset1.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toEqual('image/jpeg');
});
});
});

View File

@@ -1,30 +0,0 @@
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`/oauth`, () => {
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
await apiUtils.adminSetup();
});
describe('POST /oauth/authorize', () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app)
.post('/oauth/authorize')
.send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'redirectUri must be a string',
'redirectUri should not be empty',
])
);
});
});
});

View File

@@ -1,323 +0,0 @@
import {
LoginResponseDto,
UserResponseDto,
createUser,
deleteUser,
getUserById,
} from '@immich/sdk';
import { createUserDto, userDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/server-info', () => {
let admin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
});
describe('GET /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/user');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should start with the admin', async () => {
const { status, body } = await request(app)
.get('/user')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
});
it('should hide deleted users', async () => {
const user1 = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
await deleteUser(
{ id: user1.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app)
.get(`/user`)
.query({ isAll: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' });
});
it('should include deleted users', async () => {
const user1 = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
await deleteUser(
{ id: user1.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app)
.get(`/user`)
.query({ isAll: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0]).toMatchObject({
id: user1.userId,
email: 'user1@immich.cloud',
deletedAt: expect.any(String),
});
expect(body[1]).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
});
});
});
describe('GET /user/info/:id', () => {
it('should require authentication', async () => {
const { status } = await request(app).get(`/user/info/${admin.userId}`);
expect(status).toEqual(401);
});
it('should get the user info', async () => {
const { status, body } = await request(app)
.get(`/user/info/${admin.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
});
});
});
describe('GET /user/me', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/user/me`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get my info', async () => {
const { status, body } = await request(app)
.get(`/user/me`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
});
});
});
describe('POST /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post(`/user`)
.send(createUserDto.user1);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
for (const key of Object.keys(createUserDto.user1)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.post(`/user`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it('should ignore `isAdmin`', async () => {
const { status, body } = await request(app)
.post(`/user`)
.send({
isAdmin: true,
email: 'user1@immich.cloud',
password: 'Password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
email: 'user1@immich.cloud',
isAdmin: false,
shouldChangePassword: true,
});
expect(status).toBe(201);
});
it('should create a user without memories enabled', async () => {
const { status, body } = await request(app)
.post(`/user`)
.send({
email: 'no-memories@immich.cloud',
password: 'Password123',
name: 'No Memories',
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
email: 'no-memories@immich.cloud',
memoriesEnabled: false,
});
expect(status).toBe(201);
});
});
describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await createUser(
{ createUserDto: createUserDto.user1 },
{ headers: asBearerAuth(admin.accessToken) }
);
});
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/user/${userToDelete.id}`
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/user/${userToDelete.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...userToDelete,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
});
});
describe('PUT /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/user`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
for (const key of Object.keys(userDto.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/user`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...userDto.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it('should not allow a non-admin to become an admin', async () => {
const user = await apiUtils.userSetup(
admin.accessToken,
createUserDto.user1
);
const { status, body } = await request(app)
.put(`/user`)
.send({ isAdmin: true, id: user.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
});
it('ignores updates to profileImagePath', async () => {
const { status, body } = await request(app)
.put(`/user`)
.send({ id: admin.userId, profileImagePath: 'invalid.jpg' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' });
});
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app)
.put(`/user`)
.send({
id: admin.userId,
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
deletedAt: '2023-01-01T00:00:00.000Z',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toStrictEqual(before);
});
it('should update first and last name', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app)
.put(`/user`)
.send({
id: admin.userId,
name: 'Name',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...before,
updatedAt: expect.any(String),
name: 'Name',
});
expect(before.updatedAt).not.toEqual(body.updatedAt);
});
it('should update memories enabled', async () => {
const before = await getUserById(
{ id: admin.userId },
{ headers: asBearerAuth(admin.accessToken) }
);
const { status, body } = await request(app)
.put(`/user`)
.send({
id: admin.userId,
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
...before,
updatedAt: expect.anything(),
memoriesEnabled: false,
});
expect(before.updatedAt).not.toEqual(body.updatedAt);
});
});
});

View File

@@ -1,58 +0,0 @@
import { stat } from 'node:fs/promises';
import { apiUtils, app, dbUtils, immichCli } from 'src/utils';
import { beforeEach, beforeAll, describe, expect, it } from 'vitest';
describe(`immich login-key`, () => {
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
});
it('should require a url', async () => {
const { stderr, exitCode } = await immichCli(['login-key']);
expect(stderr).toBe("error: missing required argument 'url'");
expect(exitCode).toBe(1);
});
it('should require a key', async () => {
const { stderr, exitCode } = await immichCli(['login-key', app]);
expect(stderr).toBe("error: missing required argument 'key'");
expect(exitCode).toBe(1);
});
it('should require a valid key', async () => {
const { stderr, exitCode } = await immichCli([
'login-key',
app,
'immich-is-so-cool',
]);
expect(stderr).toContain(
'Failed to connect to server http://127.0.0.1:2283/api: Error: 401'
);
expect(exitCode).toBe(1);
});
it('should login', async () => {
const admin = await apiUtils.adminSetup();
const key = await apiUtils.createApiKey(admin.accessToken);
const { stdout, stderr, exitCode } = await immichCli([
'login-key',
app,
`${key.secret}`,
]);
expect(stdout.split('\n')).toEqual([
'Logging in...',
'Logged in as admin@immich.cloud',
'Wrote auth info to /tmp/immich/auth.yml',
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const stats = await stat('/tmp/immich/auth.yml');
const mode = (stats.mode & 0o777).toString(8);
expect(mode).toEqual('600');
});
});

View File

@@ -1,28 +0,0 @@
import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe(`immich server-info`, () => {
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
await cliUtils.login();
});
it('should return the server info', async () => {
const { stderr, stdout, exitCode } = await immichCli(['server-info']);
expect(stdout.split('\n')).toEqual([
expect.stringContaining('Server Version:'),
expect.stringContaining('Image Types:'),
expect.stringContaining('Video Types:'),
'Statistics:',
' Images: 0',
' Videos: 0',
' Total: 0',
]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
});
});

View File

@@ -1,178 +0,0 @@
import { getAllAlbums, getAllAssets } from '@immich/sdk';
import {
apiUtils,
asKeyAuth,
cliUtils,
dbUtils,
immichCli,
testAssetDir,
} from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, readdir, rm, symlink } from 'fs/promises';
describe(`immich upload`, () => {
let key: string;
beforeAll(() => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
key = await cliUtils.login();
});
describe('immich upload --recursive', () => {
it('should upload a folder recursively', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
])
);
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(9);
});
});
describe('immich upload --recursive --album', () => {
it('should create albums from folder names', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album',
]);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Successfully created 1 new album'),
expect.stringContaining('Successfully updated 9 assets'),
])
);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(9);
const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums.length).toBe(1);
expect(albums[0].albumName).toBe('nature');
});
it('should add existing assets to albums', async () => {
const response1 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
]);
expect(response1.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
])
);
expect(response1.stderr).toBe('');
expect(response1.exitCode).toBe(0);
const assets1 = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets1.length).toBe(9);
const albums1 = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums1.length).toBe(0);
const response2 = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album',
]);
expect(response2.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining(
'All assets were already uploaded, nothing to do.'
),
expect.stringContaining('Successfully updated 9 assets'),
])
);
expect(response2.stderr).toBe('');
expect(response2.exitCode).toBe(0);
const assets2 = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets2.length).toBe(9);
const albums2 = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums2.length).toBe(1);
expect(albums2[0].albumName).toBe('nature');
});
});
describe('immich upload --recursive --album-name=e2e', () => {
it('should create a named album', async () => {
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`${testAssetDir}/albums/nature/`,
'--recursive',
'--album-name=e2e',
]);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Successfully created 1 new album'),
expect.stringContaining('Successfully updated 9 assets'),
])
);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(9);
const albums = await getAllAlbums({}, { headers: asKeyAuth(key) });
expect(albums.length).toBe(1);
expect(albums[0].albumName).toBe('e2e');
});
});
describe('immich upload --delete', () => {
it('should delete local files if specified', async () => {
await mkdir(`/tmp/albums/nature`, { recursive: true });
const filesToLink = await readdir(`${testAssetDir}/albums/nature`);
for (const file of filesToLink) {
await symlink(
`${testAssetDir}/albums/nature/${file}`,
`/tmp/albums/nature/${file}`
);
}
const { stderr, stdout, exitCode } = await immichCli([
'upload',
`/tmp/albums/nature`,
'--delete',
]);
const files = await readdir(`/tmp/albums/nature`);
await rm(`/tmp/albums/nature`, { recursive: true });
expect(files).toEqual([]);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Deleting assets that have been uploaded'),
])
);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(9);
});
});
});

View File

@@ -1,29 +0,0 @@
import { readFileSync } from 'node:fs';
import { apiUtils, immichCli } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
const pkg = JSON.parse(readFileSync('../cli/package.json', 'utf8'));
describe(`immich --version`, () => {
beforeAll(() => {
apiUtils.setup();
});
describe('immich --version', () => {
it('should print the cli version', async () => {
const { stdout, stderr, exitCode } = await immichCli(['--version']);
expect(stdout).toEqual(pkg.version);
expect(stderr).toEqual('');
expect(exitCode).toBe(0);
});
});
describe('immich -V', () => {
it('should print the cli version', async () => {
const { stdout, stderr, exitCode } = await immichCli(['-V']);
expect(stdout).toEqual(pkg.version);
expect(stderr).toEqual('');
expect(exitCode).toBe(0);
});
});
});

View File

@@ -1,80 +0,0 @@
import { UserAvatarColor } from '@immich/sdk';
export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
};
const adminLoginDto = {
email: 'admin@immich.cloud',
password: 'password',
};
const adminSignupDto = { ...adminLoginDto, name: 'Immich Admin' };
export const loginDto = {
admin: adminLoginDto,
};
export const signupDto = {
admin: adminSignupDto,
};
export const createUserDto = {
user1: {
email: 'user1@immich.cloud',
name: 'User 1',
password: 'password1',
},
user2: {
email: 'user2@immich.cloud',
name: 'User 2',
password: 'password12',
},
user3: {
email: 'user3@immich.cloud',
name: 'User 3',
password: 'password123',
},
};
export const userDto = {
admin: {
name: signupDto.admin.name,
email: signupDto.admin.email,
password: signupDto.admin.password,
storageLabel: 'admin',
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.Primary,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
user1: {
name: createUserDto.user1.name,
email: createUserDto.user1.email,
password: createUserDto.user1.password,
storageLabel: null,
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.Primary,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
};

View File

@@ -1,103 +0,0 @@
import { expect } from 'vitest';
export const errorDto = {
unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required',
},
forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String),
},
wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password',
},
invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token',
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
},
invalidSharePassword: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid password',
},
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
}),
noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
},
incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password',
},
alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin',
},
noDeleteUploadLibrary: {
error: 'Bad Request',
statusCode: 400,
message: 'Cannot delete the last upload library',
},
};
export const signupResponseDto = {
admin: {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.cloud',
storageLabel: 'admin',
externalPath: null,
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
memoriesEnabled: true,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
},
};
export const loginResponseDto = {
admin: {
accessToken: expect.any(String),
name: 'Immich Admin',
isAdmin: true,
profileImagePath: '',
shouldChangePassword: true,
userEmail: 'admin@immich.cloud',
userId: expect.any(String),
},
};
export const deviceDto = {
current: {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
deviceOS: '',
deviceType: '',
},
};

View File

@@ -1,26 +0,0 @@
import { spawn, exec } from 'child_process';
export default async () => {
let _resolve: () => unknown;
const promise = new Promise<void>((resolve) => (_resolve = resolve));
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
child.stdout.on('data', (data) => {
const input = data.toString();
console.log(input);
if (input.includes('Immich Server is listening')) {
_resolve();
}
});
child.stderr.on('data', (data) => console.log(data.toString()));
await promise;
return async () => {
await new Promise<void>((resolve) =>
exec('docker compose down', () => resolve())
);
};
};

View File

@@ -1,213 +0,0 @@
import {
AssetResponseDto,
CreateAssetDto,
CreateUserDto,
LoginResponseDto,
createApiKey,
createUser,
defaults,
login,
setAdminOnboarding,
signUpAdmin,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { spawn } from 'child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
import { loginDto, signupDto } from 'src/fixtures';
import request from 'supertest';
export const app = 'http://127.0.0.1:2283/api';
const directoryExists = (directory: string) =>
access(directory)
.then(() => true)
.catch(() => false);
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
);
}
const setBaseUrl = () => (defaults.baseUrl = app);
export const asBearerAuth = (accessToken: string) => ({
Authorization: `Bearer ${accessToken}`,
});
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
let client: pg.Client | null = null;
export const dbUtils = {
reset: async () => {
try {
if (!client) {
client = new pg.Client(
'postgres://postgres:postgres@127.0.0.1:5433/immich'
);
await client.connect();
}
for (const table of [
'albums',
'assets',
'api_keys',
'user_token',
'users',
'system_metadata',
]) {
await client.query(`DELETE FROM ${table} CASCADE;`);
}
} catch (error) {
console.error('Failed to reset database', error);
throw error;
}
},
teardown: async () => {
try {
if (client) {
await client.end();
client = null;
}
} catch (error) {
console.error('Failed to teardown database', error);
throw error;
}
},
};
export interface CliResponse {
stdout: string;
stderr: string;
exitCode: number | null;
}
export const immichCli = async (args: string[]) => {
let _resolve: (value: CliResponse) => void;
const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve));
const _args = ['node_modules/.bin/immich', '-d', '/tmp/immich/', ...args];
const child = spawn('node', _args, {
stdio: 'pipe',
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => (stdout += data.toString()));
child.stderr.on('data', (data) => (stderr += data.toString()));
child.on('exit', (exitCode) => {
_resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
});
});
return deferred;
};
export interface AdminSetupOptions {
onboarding?: boolean;
}
export const apiUtils = {
setup: () => {
setBaseUrl();
},
adminSetup: async (options?: AdminSetupOptions) => {
options = options || { onboarding: true };
await signUpAdmin({ signUpDto: signupDto.admin });
const response = await login({ loginCredentialDto: loginDto.admin });
if (options.onboarding) {
await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) });
}
return response;
},
userSetup: async (accessToken: string, dto: CreateUserDto) => {
await createUser(
{ createUserDto: dto },
{ headers: asBearerAuth(accessToken) }
);
return login({
loginCredentialDto: { email: dto.email, password: dto.password },
});
},
createApiKey: (accessToken: string) => {
return createApiKey(
{ apiKeyCreateDto: { name: 'e2e' } },
{ headers: asBearerAuth(accessToken) }
);
},
createAsset: async (
accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'>
) => {
dto = dto || {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
};
const { body } = await request(app)
.post(`/asset/upload`)
.field('deviceAssetId', dto.deviceAssetId)
.field('deviceId', dto.deviceId)
.field('fileCreatedAt', dto.fileCreatedAt)
.field('fileModifiedAt', dto.fileModifiedAt)
.attach('assetData', randomBytes(32), 'example.jpg')
.set('Authorization', `Bearer ${accessToken}`);
return body as AssetResponseDto;
},
};
export const cliUtils = {
login: async () => {
const admin = await apiUtils.adminSetup();
const key = await apiUtils.createApiKey(admin.accessToken);
await immichCli(['login-key', app, `${key.secret}`]);
return key.secret;
},
};
export const webUtils = {
setAuthCookies: async (context: BrowserContext, accessToken: string) =>
await context.addCookies([
{
name: 'immich_access_token',
value: accessToken,
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
{
name: 'immich_auth_type',
value: 'password',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
{
name: 'immich_is_authenticated',
value: 'true',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
httpOnly: false,
secure: false,
sameSite: 'Lax',
},
]),
};

View File

@@ -1,58 +0,0 @@
import {
AlbumResponseDto,
AssetResponseDto,
LoginResponseDto,
SharedLinkResponseDto,
SharedLinkType,
createAlbum,
createSharedLink,
} from '@immich/sdk';
import { test } from '@playwright/test';
import { apiUtils, asBearerAuth, dbUtils } from 'src/utils';
test.describe('Shared Links', () => {
let admin: LoginResponseDto;
let asset: AssetResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
test.beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup();
asset = await apiUtils.createAsset(admin.accessToken);
album = await createAlbum(
{
createAlbumDto: {
albumName: 'Test Album',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) }
// { headers: asBearerAuth(admin.accessToken)},
);
sharedLink = await createSharedLink(
{
sharedLinkCreateDto: {
type: SharedLinkType.Album,
albumId: album.id,
},
},
{ headers: asBearerAuth(admin.accessToken) }
);
});
test.afterAll(async () => {
await dbUtils.teardown();
});
test('download from a shared link', async ({ page }) => {
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator('.group > div').first().hover();
await page.waitForSelector('#asset-group-by-date svg');
await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING').waitFor();
});
});

88
e2e/test-utils.ts Normal file
View File

@@ -0,0 +1,88 @@
import pg from 'pg';
import { defaults, login, setAdminOnboarding, signUpAdmin } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
const client = new pg.Client(
'postgres://postgres:postgres@localhost:5432/immich'
);
let connected = false;
const loginCredentialDto = {
email: 'admin@immich.cloud',
password: 'password',
};
const signUpDto = { ...loginCredentialDto, name: 'Immich Admin' };
const setBaseUrl = () => (defaults.baseUrl = 'http://127.0.0.1:2283/api');
const asAuthHeader = (accessToken: string) => ({
Authorization: `Bearer ${accessToken}`,
});
export const app = {
adminSetup: async (context: BrowserContext) => {
setBaseUrl();
await signUpAdmin({ signUpDto });
const response = await login({ loginCredentialDto });
await context.addCookies([
{
name: 'immich_access_token',
value: response.accessToken,
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
{
name: 'immich_auth_type',
value: 'password',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
{
name: 'immich_is_authenticated',
value: 'true',
domain: '127.0.0.1',
path: '/',
expires: 1742402728,
httpOnly: false,
secure: false,
sameSite: 'Lax',
},
]);
await setAdminOnboarding({ headers: asAuthHeader(response.accessToken) });
return response;
},
reset: async () => {
try {
if (!connected) {
await client.connect();
connected = true;
}
for (const table of ['user_token', 'users', 'system_metadata']) {
await client.query(`DELETE FROM ${table} CASCADE;`);
}
} catch (error) {
console.error('Failed to reset database', error);
}
},
teardown: async () => {
try {
if (connected) {
await client.end();
}
} catch (error) {
console.error('Failed to teardown database', error);
}
},
};

View File

@@ -16,7 +16,8 @@
"skipLibCheck": true,
"esModuleInterop": true,
"rootDirs": ["src"],
"baseUrl": "./"
"baseUrl": "./",
"types": ["vitest/globals"]
},
"exclude": ["dist", "node_modules"]
}

View File

@@ -1,13 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/{api,cli}/specs/*.e2e-spec.ts'],
globalSetup: ['src/setup.ts'],
poolOptions: {
threads: {
singleThread: true,
},
},
},
});

View File

@@ -1,13 +1,15 @@
#!/usr/bin/env bash
echo "Starting Immich installation..."
ip_address=$(hostname -I | awk '{print $1}')
RED='\033[0;31m'
GREEN='\032[0;31m'
NC='\033[0m' # No Color
create_immich_directory() {
echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data
cd ./immich-app || exit
cd ./immich-app
}
download_docker_compose_file() {
@@ -32,18 +34,18 @@ replace_env_value() {
populate_upload_location() {
echo "Populating default UPLOAD_LOCATION value..."
upload_location=$(pwd)/immich-data
replace_env_value "UPLOAD_LOCATION" "$upload_location"
replace_env_value "UPLOAD_LOCATION" $upload_location
}
start_docker_compose() {
echo "Starting Immich's docker containers"
if docker compose >/dev/null 2>&1; then
if docker compose > /dev/null 2>&1; then
docker_bin="docker compose"
elif docker-compose >/dev/null 2>&1; then
elif docker-compose > /dev/null 2>&1; then
docker_bin="docker-compose"
else
echo "Cannot find \`docker compose\` or \`docker-compose\`."
echo 'Cannot find `docker compose` or `docker-compose`.'
exit 1
fi

View File

@@ -1,3 +1 @@
#!/usr/bin/env sh
g++ -shared -O3 -o libann.so -fuse-ld=gold -std=c++17 -I"$ARMNN_PATH"/include -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -L"$ARMNN_PATH" ann.cpp
g++ -shared -O3 -o libann.so -fuse-ld=gold -std=c++17 -I$ARMNN_PATH/include -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -L$ARMNN_PATH ann.cpp

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh
#!/bin/sh
cd armnn-23.11/ || exit
cd armnn-23.11/
g++ -o ../armnnconverter -O1 -DARMNN_ONNX_PARSER -DARMNN_SERIALIZER -DARMNN_TF_LITE_PARSER -fuse-ld=gold -std=c++17 -Iinclude -Isrc/armnnUtils -Ithird-party -larmnn -larmnnDeserializer -larmnnTfLiteParser -larmnnOnnxParser -larmnnSerializer -L../armnn src/armnnConverter/ArmnnConverter.cpp

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.95.0"
version = "1.94.1"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env sh
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
export LD_PRELOAD="$lib_path"
export LD_PRELOAD="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
export LD_BIND_NOW=1
: "${MACHINE_LEARNING_HOST:=0.0.0.0}"
@@ -11,8 +10,8 @@ export LD_BIND_NOW=1
gunicorn app.main:app \
-k app.config.CustomUvicornWorker \
-w "$MACHINE_LEARNING_WORKERS" \
-b "$MACHINE_LEARNING_HOST":"$MACHINE_LEARNING_PORT" \
-t "$MACHINE_LEARNING_WORKER_TIMEOUT" \
-w $MACHINE_LEARNING_WORKERS \
-b $MACHINE_LEARNING_HOST:$MACHINE_LEARNING_PORT \
-t $MACHINE_LEARNING_WORKER_TIMEOUT \
--log-config-json log_conf.json \
--graceful-timeout 0

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#/bin/bash
#
# Pump one or both of the server/mobile versions in appropriate files
@@ -25,10 +25,10 @@ while getopts 's:m:' flag; do
esac
done
CURRENT_SERVER=$(jq -r '.version' server/package.json)
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
CURRENT_SERVER=$(cat server/package.json | jq -r '.version')
MAJOR=$(echo $CURRENT_SERVER | cut -d '.' -f1)
MINOR=$(echo $CURRENT_SERVER | cut -d '.' -f2)
PATCH=$(echo $CURRENT_SERVER | cut -d '.' -f3)
if [[ $SERVER_PUMP == "major" ]]; then
MAJOR=$((MAJOR + 1))
@@ -48,7 +48,7 @@ fi
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
CURRENT_MOBILE=$(cat mobile/pubspec.yaml | grep "^version: .*+[0-9]\+$" | cut -d "+" -f2)
NEXT_MOBILE=$CURRENT_MOBILE
if [[ $MOBILE_PUMP == "true" ]]; then
set $((NEXT_MOBILE++))
@@ -61,10 +61,9 @@ fi
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
npm --prefix server version "$SERVER_PUMP"
npm --prefix web version "$SERVER_PUMP"
npm --prefix server version $SERVER_PUMP
make open-api
poetry --directory machine-learning version "$SERVER_PUMP"
poetry --directory machine-learning version $SERVER_PUMP
fi
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
@@ -76,4 +75,4 @@ sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"
echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 122,
"android.injected.version.name" => "1.95.0",
"android.injected.version.code" => 121,
"android.injected.version.name" => "1.94.1",
}
)
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')

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env sh
# The default execution directory of this script is the ci_scripts directory.
cd "$CI_WORKSPACE"/mobile || exit
cd $CI_WORKSPACE/mobile
# Install Flutter using git.
git clone https://github.com/flutter/flutter.git --depth 1 -b stable "$HOME"/flutter
git clone https://github.com/flutter/flutter.git --depth 1 -b stable $HOME/flutter
export PATH="$PATH:$HOME/flutter/bin"
# Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms.
@@ -14,7 +14,7 @@ flutter precache --ios
flutter pub get
# Install CocoaPods using Homebrew.
export HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
brew install cocoapods
# Install CocoaPods dependencies.

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.95.0"
version_number: "1.94.1"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -290,7 +290,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final now = Timeline.now;
if (now > (_hapticFeedbackTS + feedbackInterval)) {
_hapticFeedbackTS = now;
HapticFeedback.mediumImpact();
HapticFeedback.heavyImpact();
}
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class MemoryProgressIndicator extends StatelessWidget {
/// The number of ticks in the progress indicator
@@ -38,9 +39,9 @@ class MemoryProgressIndicator extends StatelessWidget {
decoration: BoxDecoration(
border: i == 0
? null
: const Border(
: Border(
left: BorderSide(
color: Colors.black,
color: context.colorScheme.onSecondaryContainer,
width: 1,
),
),

View File

@@ -110,8 +110,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
final headers = {"x-immich-user-token": accessToken};
if (endpoint.userInfo.isNotEmpty) {
headers["Authorization"] =
"Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
}
debugPrint("Attempting to connect to websocket");
@@ -162,7 +161,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
socket.on('on_asset_hidden', _handleOnAssetHidden);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {

View File

@@ -63,7 +63,7 @@ class AssetService {
/// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>?> _getRemoteAssets(User user) async {
const int chunkSize = 10000;
const int chunkSize = 5000;
try {
final DateTime now = DateTime.now().toUtc();
final List<Asset> allAssets = [];

View File

@@ -27,12 +27,7 @@ class ShareService {
final downloadedXFiles = <XFile>[];
for (var asset in assets) {
if (asset.isLocal) {
// Prefer local assets to share
File? f = await asset.local!.file;
downloadedXFiles.add(XFile(f!.path));
} else if (asset.isRemote) {
// Download remote asset otherwise
if (asset.isRemote) {
final tempDir = await getTemporaryDirectory();
final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create();
@@ -48,6 +43,9 @@ class ShareService {
tempFile.writeAsBytesSync(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
} else {
File? f = await asset.local!.file;
downloadedXFiles.add(XFile(f!.path));
}
}

View File

@@ -120,7 +120,7 @@ class ImmichImage extends StatelessWidget {
return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 200),
fadeOutDuration: const Duration(milliseconds: 400),
placeholderBuilder: (context) {
if (placeholder != null) {
// Use the gray box placeholder

View File

@@ -182,9 +182,6 @@ doc/UserAvatarColor.md
doc/UserDto.md
doc/UserResponseDto.md
doc/ValidateAccessTokenResponseDto.md
doc/ValidateLibraryDto.md
doc/ValidateLibraryImportPathResponseDto.md
doc/ValidateLibraryResponseDto.md
doc/VideoCodec.md
git_push.sh
lib/api.dart
@@ -375,9 +372,6 @@ lib/model/user_avatar_color.dart
lib/model/user_dto.dart
lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
lib/model/validate_library_dto.dart
lib/model/validate_library_import_path_response_dto.dart
lib/model/validate_library_response_dto.dart
lib/model/video_codec.dart
pubspec.yaml
test/activity_api_test.dart
@@ -559,7 +553,4 @@ test/user_avatar_color_test.dart
test/user_dto_test.dart
test/user_response_dto_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
test/validate_library_response_dto_test.dart
test/video_codec_test.dart

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.95.0
- API version: 1.94.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -141,7 +141,6 @@ Class | Method | HTTP request | Description
*LibraryApi* | [**removeOfflineFiles**](doc//LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline |
*LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan |
*LibraryApi* | [**updateLibrary**](doc//LibraryApi.md#updatelibrary) | **PUT** /library/{id} |
*LibraryApi* | [**validate**](doc//LibraryApi.md#validate) | **POST** /library/{id}/validate |
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |
@@ -373,9 +372,6 @@ Class | Method | HTTP request | Description
- [UserDto](doc//UserDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
- [ValidateLibraryDto](doc//ValidateLibraryDto.md)
- [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md)
- [ValidateLibraryResponseDto](doc//ValidateLibraryResponseDto.md)
- [VideoCodec](doc//VideoCodec.md)

View File

@@ -17,7 +17,6 @@ Method | HTTP request | Description
[**removeOfflineFiles**](LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline |
[**scanLibrary**](LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan |
[**updateLibrary**](LibraryApi.md#updatelibrary) | **PUT** /library/{id} |
[**validate**](LibraryApi.md#validate) | **POST** /library/{id}/validate |
# **createLibrary**
@@ -457,60 +456,3 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **validate**
> ValidateLibraryResponseDto validate(id, validateLibraryDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = LibraryApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final validateLibraryDto = ValidateLibraryDto(); // ValidateLibraryDto |
try {
final result = api_instance.validate(id, validateLibraryDto);
print(result);
} catch (e) {
print('Exception when calling LibraryApi->validate: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**validateLibraryDto** | [**ValidateLibraryDto**](ValidateLibraryDto.md)| |
### Return type
[**ValidateLibraryResponseDto**](ValidateLibraryResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **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)

View File

@@ -1,16 +0,0 @@
# openapi.model.ValidateLibraryDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**exclusionPatterns** | **List<String>** | | [optional] [default to const []]
**importPaths** | **List<String>** | | [optional] [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,17 +0,0 @@
# openapi.model.ValidateLibraryImportPathResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**importPath** | **String** | |
**isValid** | **bool** | | [optional] [default to false]
**message** | **String** | | [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)

View File

@@ -1,15 +0,0 @@
# openapi.model.ValidateLibraryResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**importPaths** | [**List<ValidateLibraryImportPathResponseDto>**](ValidateLibraryImportPathResponseDto.md) | | [optional] [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -209,9 +209,6 @@ part 'model/user_avatar_color.dart';
part 'model/user_dto.dart';
part 'model/user_response_dto.dart';
part 'model/validate_access_token_response_dto.dart';
part 'model/validate_library_dto.dart';
part 'model/validate_library_import_path_response_dto.dart';
part 'model/validate_library_response_dto.dart';
part 'model/video_codec.dart';

View File

@@ -378,56 +378,4 @@ class LibraryApi {
}
return null;
}
/// Performs an HTTP 'POST /library/{id}/validate' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [ValidateLibraryDto] validateLibraryDto (required):
Future<Response> validateWithHttpInfo(String id, ValidateLibraryDto validateLibraryDto,) async {
// ignore: prefer_const_declarations
final path = r'/library/{id}/validate'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = validateLibraryDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [ValidateLibraryDto] validateLibraryDto (required):
Future<ValidateLibraryResponseDto?> validate(String id, ValidateLibraryDto validateLibraryDto,) async {
final response = await validateWithHttpInfo(id, validateLibraryDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ValidateLibraryResponseDto',) as ValidateLibraryResponseDto;
}
return null;
}
}

View File

@@ -500,12 +500,6 @@ class ApiClient {
return UserResponseDto.fromJson(value);
case 'ValidateAccessTokenResponseDto':
return ValidateAccessTokenResponseDto.fromJson(value);
case 'ValidateLibraryDto':
return ValidateLibraryDto.fromJson(value);
case 'ValidateLibraryImportPathResponseDto':
return ValidateLibraryImportPathResponseDto.fromJson(value);
case 'ValidateLibraryResponseDto':
return ValidateLibraryResponseDto.fromJson(value);
case 'VideoCodec':
return VideoCodecTypeTransformer().decode(value);
default:

View File

@@ -1,108 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ValidateLibraryDto {
/// Returns a new [ValidateLibraryDto] instance.
ValidateLibraryDto({
this.exclusionPatterns = const [],
this.importPaths = const [],
});
List<String> exclusionPatterns;
List<String> importPaths;
@override
bool operator ==(Object other) => identical(this, other) || other is ValidateLibraryDto &&
_deepEquality.equals(other.exclusionPatterns, exclusionPatterns) &&
_deepEquality.equals(other.importPaths, importPaths);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(exclusionPatterns.hashCode) +
(importPaths.hashCode);
@override
String toString() => 'ValidateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'exclusionPatterns'] = this.exclusionPatterns;
json[r'importPaths'] = this.importPaths;
return json;
}
/// Returns a new [ValidateLibraryDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ValidateLibraryDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ValidateLibraryDto(
exclusionPatterns: json[r'exclusionPatterns'] is Iterable
? (json[r'exclusionPatterns'] as Iterable).cast<String>().toList(growable: false)
: const [],
importPaths: json[r'importPaths'] is Iterable
? (json[r'importPaths'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<ValidateLibraryDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ValidateLibraryDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ValidateLibraryDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ValidateLibraryDto> mapFromJson(dynamic json) {
final map = <String, ValidateLibraryDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ValidateLibraryDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ValidateLibraryDto-objects as value to a dart map
static Map<String, List<ValidateLibraryDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ValidateLibraryDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ValidateLibraryDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@@ -1,122 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ValidateLibraryImportPathResponseDto {
/// Returns a new [ValidateLibraryImportPathResponseDto] instance.
ValidateLibraryImportPathResponseDto({
required this.importPath,
this.isValid = false,
this.message,
});
String importPath;
bool isValid;
///
/// 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.
///
String? message;
@override
bool operator ==(Object other) => identical(this, other) || other is ValidateLibraryImportPathResponseDto &&
other.importPath == importPath &&
other.isValid == isValid &&
other.message == message;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(importPath.hashCode) +
(isValid.hashCode) +
(message == null ? 0 : message!.hashCode);
@override
String toString() => 'ValidateLibraryImportPathResponseDto[importPath=$importPath, isValid=$isValid, message=$message]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'importPath'] = this.importPath;
json[r'isValid'] = this.isValid;
if (this.message != null) {
json[r'message'] = this.message;
} else {
// json[r'message'] = null;
}
return json;
}
/// Returns a new [ValidateLibraryImportPathResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ValidateLibraryImportPathResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ValidateLibraryImportPathResponseDto(
importPath: mapValueOfType<String>(json, r'importPath')!,
isValid: mapValueOfType<bool>(json, r'isValid') ?? false,
message: mapValueOfType<String>(json, r'message'),
);
}
return null;
}
static List<ValidateLibraryImportPathResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ValidateLibraryImportPathResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ValidateLibraryImportPathResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ValidateLibraryImportPathResponseDto> mapFromJson(dynamic json) {
final map = <String, ValidateLibraryImportPathResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ValidateLibraryImportPathResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ValidateLibraryImportPathResponseDto-objects as value to a dart map
static Map<String, List<ValidateLibraryImportPathResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ValidateLibraryImportPathResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ValidateLibraryImportPathResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'importPath',
};
}

View File

@@ -1,97 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ValidateLibraryResponseDto {
/// Returns a new [ValidateLibraryResponseDto] instance.
ValidateLibraryResponseDto({
this.importPaths = const [],
});
List<ValidateLibraryImportPathResponseDto> importPaths;
@override
bool operator ==(Object other) => identical(this, other) || other is ValidateLibraryResponseDto &&
_deepEquality.equals(other.importPaths, importPaths);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(importPaths.hashCode);
@override
String toString() => 'ValidateLibraryResponseDto[importPaths=$importPaths]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'importPaths'] = this.importPaths;
return json;
}
/// Returns a new [ValidateLibraryResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ValidateLibraryResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ValidateLibraryResponseDto(
importPaths: ValidateLibraryImportPathResponseDto.listFromJson(json[r'importPaths']),
);
}
return null;
}
static List<ValidateLibraryResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ValidateLibraryResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ValidateLibraryResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ValidateLibraryResponseDto> mapFromJson(dynamic json) {
final map = <String, ValidateLibraryResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ValidateLibraryResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ValidateLibraryResponseDto-objects as value to a dart map
static Map<String, List<ValidateLibraryResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ValidateLibraryResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ValidateLibraryResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@@ -57,10 +57,5 @@ void main() {
// TODO
});
//Future<ValidateLibraryResponseDto> validate(String id, ValidateLibraryDto validateLibraryDto) async
test('test validate', () async {
// TODO
});
});
}

View File

@@ -1,32 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for ValidateLibraryDto
void main() {
// final instance = ValidateLibraryDto();
group('test ValidateLibraryDto', () {
// List<String> exclusionPatterns (default value: const [])
test('to test the property `exclusionPatterns`', () async {
// TODO
});
// List<String> importPaths (default value: const [])
test('to test the property `importPaths`', () async {
// TODO
});
});
}

View File

@@ -1,37 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for ValidateLibraryImportPathResponseDto
void main() {
// final instance = ValidateLibraryImportPathResponseDto();
group('test ValidateLibraryImportPathResponseDto', () {
// String importPath
test('to test the property `importPath`', () async {
// TODO
});
// bool isValid (default value: false)
test('to test the property `isValid`', () async {
// TODO
});
// String message
test('to test the property `message`', () async {
// TODO
});
});
}

View File

@@ -1,27 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for ValidateLibraryResponseDto
void main() {
// final instance = ValidateLibraryResponseDto();
group('test ValidateLibraryResponseDto', () {
// List<ValidateLibraryImportPathResponseDto> importPaths (default value: const [])
test('to test the property `importPaths`', () async {
// TODO
});
});
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.95.0+122
version: 1.94.1+121
isar_version: &isar_version 3.1.0+1
environment:

View File

@@ -1,6 +1,4 @@
#!/usr/bin/env sh
cd .isar || exit
cd .isar
bash tool/build_android.sh x86
bash tool/build_android.sh x64
bash tool/build_android.sh armv7

View File

@@ -3618,58 +3618,6 @@
]
}
},
"/library/{id}/validate": {
"post": {
"operationId": "validate",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateLibraryDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateLibraryResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Library"
]
}
},
"/oauth/authorize": {
"post": {
"operationId": "startOAuth",
@@ -6413,7 +6361,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.95.0",
"version": "1.94.1",
"contact": {}
},
"tags": [],
@@ -10458,52 +10406,6 @@
],
"type": "object"
},
"ValidateLibraryDto": {
"properties": {
"exclusionPatterns": {
"items": {
"type": "string"
},
"type": "array"
},
"importPaths": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"ValidateLibraryImportPathResponseDto": {
"properties": {
"importPath": {
"type": "string"
},
"isValid": {
"default": false,
"type": "boolean"
},
"message": {
"type": "string"
}
},
"required": [
"importPath"
],
"type": "object"
},
"ValidateLibraryResponseDto": {
"properties": {
"importPaths": {
"items": {
"$ref": "#/components/schemas/ValidateLibraryImportPathResponseDto"
},
"type": "array"
}
},
"type": "object"
},
"VideoCodec": {
"enum": [
"h264",

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.0
* The version of the OpenAPI document: 1.94.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -5316,63 +5316,6 @@ export interface ValidateAccessTokenResponseDto {
*/
'authStatus': boolean;
}
/**
*
* @export
* @interface ValidateLibraryDto
*/
export interface ValidateLibraryDto {
/**
*
* @type {Array<string>}
* @memberof ValidateLibraryDto
*/
'exclusionPatterns'?: Array<string>;
/**
*
* @type {Array<string>}
* @memberof ValidateLibraryDto
*/
'importPaths'?: Array<string>;
}
/**
*
* @export
* @interface ValidateLibraryImportPathResponseDto
*/
export interface ValidateLibraryImportPathResponseDto {
/**
*
* @type {string}
* @memberof ValidateLibraryImportPathResponseDto
*/
'importPath': string;
/**
*
* @type {boolean}
* @memberof ValidateLibraryImportPathResponseDto
*/
'isValid'?: boolean;
/**
*
* @type {string}
* @memberof ValidateLibraryImportPathResponseDto
*/
'message'?: string;
}
/**
*
* @export
* @interface ValidateLibraryResponseDto
*/
export interface ValidateLibraryResponseDto {
/**
*
* @type {Array<ValidateLibraryImportPathResponseDto>}
* @memberof ValidateLibraryResponseDto
*/
'importPaths'?: Array<ValidateLibraryImportPathResponseDto>;
}
/**
*
* @export
@@ -12870,54 +12813,6 @@ export const LibraryApiAxiosParamCreator = function (configuration?: Configurati
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateLibraryDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {ValidateLibraryDto} validateLibraryDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
validate: async (id: string, validateLibraryDto: ValidateLibraryDto, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('validate', 'id', id)
// verify required parameter 'validateLibraryDto' is not null or undefined
assertParamExists('validate', 'validateLibraryDto', validateLibraryDto)
const localVarPath = `/library/{id}/validate`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(validateLibraryDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@@ -13030,19 +12925,6 @@ export const LibraryApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['LibraryApi.updateLibrary']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
/**
*
* @param {string} id
* @param {ValidateLibraryDto} validateLibraryDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async validate(id: string, validateLibraryDto: ValidateLibraryDto, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ValidateLibraryResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.validate(id, validateLibraryDto, options);
const index = configuration?.serverIndex ?? 0;
const operationBasePath = operationServerMap['LibraryApi.validate']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
}
};
@@ -13124,15 +13006,6 @@ export const LibraryApiFactory = function (configuration?: Configuration, basePa
updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: RawAxiosRequestConfig): AxiosPromise<LibraryResponseDto> {
return localVarFp.updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {LibraryApiValidateRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
validate(requestParameters: LibraryApiValidateRequest, options?: RawAxiosRequestConfig): AxiosPromise<ValidateLibraryResponseDto> {
return localVarFp.validate(requestParameters.id, requestParameters.validateLibraryDto, options).then((request) => request(axios, basePath));
},
};
};
@@ -13248,27 +13121,6 @@ export interface LibraryApiUpdateLibraryRequest {
readonly updateLibraryDto: UpdateLibraryDto
}
/**
* Request parameters for validate operation in LibraryApi.
* @export
* @interface LibraryApiValidateRequest
*/
export interface LibraryApiValidateRequest {
/**
*
* @type {string}
* @memberof LibraryApiValidate
*/
readonly id: string
/**
*
* @type {ValidateLibraryDto}
* @memberof LibraryApiValidate
*/
readonly validateLibraryDto: ValidateLibraryDto
}
/**
* LibraryApi - object-oriented interface
* @export
@@ -13362,17 +13214,6 @@ export class LibraryApi extends BaseAPI {
public updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: RawAxiosRequestConfig) {
return LibraryApiFp(this.configuration).updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {LibraryApiValidateRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof LibraryApi
*/
public validate(requestParameters: LibraryApiValidateRequest, options?: RawAxiosRequestConfig) {
return LibraryApiFp(this.configuration).validate(requestParameters.id, requestParameters.validateLibraryDto, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.0
* The version of the OpenAPI document: 1.94.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.0
* The version of the OpenAPI document: 1.94.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.0
* The version of the OpenAPI document: 1.94.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.0
* The version of the OpenAPI document: 1.94.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.95.0
* 1.94.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -480,18 +480,6 @@ export type LibraryStatsResponseDto = {
usage: number;
videos: number;
};
export type ValidateLibraryDto = {
exclusionPatterns?: string[];
importPaths?: string[];
};
export type ValidateLibraryImportPathResponseDto = {
importPath: string;
isValid?: boolean;
message?: string;
};
export type ValidateLibraryResponseDto = {
importPaths?: ValidateLibraryImportPathResponseDto[];
};
export type OAuthConfigDto = {
redirectUri: string;
};
@@ -1913,19 +1901,6 @@ export function getLibraryStatistics({ id }: {
...opts
}));
}
export function validate({ id, validateLibraryDto }: {
id: string;
validateLibraryDto: ValidateLibraryDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ValidateLibraryResponseDto;
}>(`/library/${encodeURIComponent(id)}/validate`, oazapfts.json({
...opts,
method: "POST",
body: validateLibraryDto
})));
}
export function startOAuth({ oAuthConfigDto }: {
oAuthConfigDto: OAuthConfigDto;
}, opts?: Oazapfts.RequestOpts) {

View File

@@ -24,7 +24,7 @@ RUN npm prune --omit=dev --omit=optional
COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
# web build
FROM node:iron-alpine3.18@sha256:a02826c7340c37a29179152723190bcc3044f933c925f3c2d78abb20f794de3f as web
FROM node:iron-alpine3.18 as web
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
@@ -59,4 +59,4 @@ COPY LICENSE /LICENSE
ENV PATH="${PATH}:/usr/src/app/bin"
VOLUME /usr/src/app/upload
EXPOSE 3001
ENTRYPOINT ["tini", "--", "/bin/bash"]
ENTRYPOINT ["tini", "--", "/bin/sh"]

View File

@@ -1,3 +1,2 @@
#!/usr/bin/env bash
node /usr/src/app/node_modules/.bin/immich "$@"

View File

@@ -1,3 +1,2 @@
#!/usr/bin/env sh
/usr/src/app/start.sh immich-admin "$1"
/usr/src/app/start.sh immich-admin $1

View File

@@ -1,3 +1,2 @@
#!/usr/bin/env bash
node /usr/src/app/node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@"

View File

@@ -1,3 +1,2 @@
#!/usr/bin/env bash
node /usr/src/app/node_modules/.bin/jest --config e2e/"$1"/jest-e2e.json --runInBand
node /usr/src/app/node_modules/.bin/jest --config e2e/$1/jest-e2e.json --runInBand

View File

@@ -1,16 +1,6 @@
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')
@@ -21,9 +11,6 @@ export default async () => {
process.env.DB_URL = pg.getConnectionUri();
process.env.NODE_ENV = 'development';
process.env.LOG_LEVEL = 'fatal';
process.env.TZ = 'Z';
if (process.env.LOG_LEVEL === undefined) {
process.env.LOG_LEVEL = 'fatal';
}
};

View File

@@ -0,0 +1,294 @@
import { AuthController } from '@app/immich';
import {
adminSignupStub,
changePasswordStub,
deviceStub,
errorStub,
loginResponseStub,
loginStub,
uuidStub,
} from '@test/fixtures';
import request from 'supertest';
import { api } from '../../client';
import { testApp } from '../utils';
const name = 'Immich Admin';
const password = 'Password123';
const email = 'admin@immich.app';
const adminSignupResponse = {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.app',
storageLabel: 'admin',
externalPath: null,
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
memoriesEnabled: true,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
};
describe(`${AuthController.name} (e2e)`, () => {
let server: any;
let accessToken: string;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
const response = await api.authApi.adminLogin(server);
accessToken = response.accessToken;
});
describe('POST /auth/admin-sign-up', () => {
beforeEach(async () => {
await testApp.reset();
});
const invalid = [
{
should: 'require an email address',
data: { name, password },
},
{
should: 'require a password',
data: { name, email },
},
{
should: 'require a name',
data: { email, password },
},
{
should: 'require a valid email',
data: { name, email: 'immich', password },
},
];
for (const { should, data } of invalid) {
it(`should ${should}`, async () => {
const { status, body } = await request(server).post('/auth/admin-sign-up').send(data);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it(`should sign up the admin`, async () => {
await api.authApi.adminSignUp(server);
});
it('should sign up the admin with a local domain', async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, email: 'admin@local' });
expect(status).toEqual(201);
expect(body).toEqual({ ...adminSignupResponse, email: 'admin@local' });
});
it('should transform email to lower case', async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, email: 'aDmIn@IMMICH.app' });
expect(status).toEqual(201);
expect(body).toEqual(adminSignupResponse);
});
it('should not allow a second admin to sign up', async () => {
await api.authApi.adminSignUp(server);
const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
expect(status).toBe(400);
expect(body).toEqual(errorStub.alreadyHasAdmin);
});
for (const key of Object.keys(adminSignupStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(server).post('/auth/login').send({ email, password: 'incorrect' });
expect(body).toEqual(errorStub.incorrectLogin);
expect(status).toBe(401);
});
for (const key of Object.keys(loginStub.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/login')
.send({ ...loginStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should accept a correct password', async () => {
const { status, body, headers } = await request(server).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseStub.admin.response);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;');
});
});
describe('GET /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/auth/devices');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get a list of authorized devices', async () => {
const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceStub.current]);
});
});
describe('DELETE /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/auth/devices`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await api.authApi.adminLogin(server);
}
await expect(api.authApi.getAuthDevices(server, accessToken)).resolves.toHaveLength(6);
const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
await api.authApi.validateToken(server, accessToken);
});
});
describe('DELETE /auth/devices/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/auth/devices/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(server)
.delete(`/auth/devices/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
const [device] = await api.authApi.getAuthDevices(server, accessToken);
const { status } = await request(server)
.delete(`/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`);
expect(response.body).toEqual(errorStub.invalidToken);
expect(response.status).toBe(401);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(server).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(server)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/auth/change-password`).send(changePasswordStub);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(changePasswordStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/change-password')
.send({ ...changePasswordStub, [key]: null })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should require the current password', async () => {
const { status, body } = await request(server)
.post(`/auth/change-password`)
.send({ ...changePasswordStub, password: 'wrong-password' })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(server)
.post(`/auth/change-password`)
.send(changePasswordStub)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
await api.authApi.login(server, { email: 'admin@immich.app', password: 'Password1234' });
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(server).post(`/auth/logout`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0' });
});
});
});

View File

@@ -0,0 +1,89 @@
import { AssetResponseDto, IAssetRepository, LibraryResponseDto, LoginResponseDto, mapAsset } from '@app/domain';
import { AssetController } from '@app/immich';
import { AssetEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../../client';
import { generateAsset, testApp } from '../utils';
describe(`${AssetController.name} (e2e)`, () => {
let app: INestApplication;
let server: any;
let assetRepository: IAssetRepository;
let user1: LoginResponseDto;
let libraries: LibraryResponseDto[];
let asset1: AssetResponseDto;
const createAsset = async (
loginResponse: LoginResponseDto,
fileCreatedAt: Date,
other: Partial<AssetEntity> = {},
) => {
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>(IAssetRepository);
await testApp.reset();
await api.authApi.adminSignUp(server);
const admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
user1 = await api.authApi.login(server, userDto.user1);
libraries = await api.libraryApi.getAll(server, user1.accessToken);
asset1 = await createAsset(user1, new Date('1970-01-01'));
});
afterAll(async () => {
await testApp.teardown();
});
describe('POST /download/info', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.post(`/download/info`)
.send({ assetIds: [asset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should download info', async () => {
const { status, body } = await request(server)
.post('/download/info')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset1.id] });
expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ archives: [expect.objectContaining({ assetIds: [asset1.id] })] }));
});
});
describe('POST /download/asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/download/asset/${asset1.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should download file', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example');
const response = await request(server)
.post(`/download/asset/${asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(response.status).toBe(200);
expect(response.headers['content-type']).toEqual('image/jpeg');
});
});
});

View File

@@ -2,7 +2,6 @@ import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
import { LibraryController } from '@app/immich';
import { LibraryType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import { IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder } from 'src/test-utils/utils';
import request from 'supertest';
import { api } from '../../client';
import { testApp } from '../utils';
@@ -21,7 +20,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
});
beforeEach(async () => {
await restoreTempFolder();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
@@ -249,16 +247,15 @@ describe(`${LibraryController.name} (e2e)`, () => {
});
it('should change the import paths', async () => {
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [IMMICH_TEST_ASSET_TEMP_PATH] });
.send({ importPaths: ['/path/to/import'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
importPaths: [IMMICH_TEST_ASSET_TEMP_PATH],
importPaths: ['/path/to/import'],
}),
);
});
@@ -438,93 +435,4 @@ describe(`${LibraryController.name} (e2e)`, () => {
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('POST /library/:id/validate', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/validate`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
describe('Validate import path', () => {
let library: LibraryResponseDto;
beforeEach(async () => {
// Create an external library with default settings
library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
});
it('should fail with no external path set', async () => {
const { status, body } = await request(server)
.post(`/library/${library.id}/validate`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('User has no external path set'));
});
describe('With external path set', () => {
beforeEach(async () => {
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
});
it('should pass with no import paths', async () => {
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
expect(response.importPaths).toEqual([]);
});
it('should not allow paths outside of the external path', async () => {
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/../`;
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
importPaths: [pathToTest],
});
expect(response.importPaths?.length).toEqual(1);
const pathResponse = response?.importPaths?.at(0);
expect(pathResponse).toEqual({
importPath: pathToTest,
isValid: false,
message: `Not contained in user's external path`,
});
});
it('should fail if path does not exist', async () => {
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
importPaths: [pathToTest],
});
expect(response.importPaths?.length).toEqual(1);
const pathResponse = response?.importPaths?.at(0);
expect(pathResponse).toEqual({
importPath: pathToTest,
isValid: false,
message: `Path does not exist (ENOENT)`,
});
});
it('should fail if path is a file', async () => {
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
importPaths: [pathToTest],
});
expect(response.importPaths?.length).toEqual(1);
const pathResponse = response?.importPaths?.at(0);
expect(pathResponse).toEqual({
importPath: pathToTest,
isValid: false,
message: `Path does not exist (ENOENT)`,
});
});
});
});
});
});

View File

@@ -0,0 +1,30 @@
import { OAuthController } from '@app/immich';
import { errorStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${OAuthController.name} (e2e)`, () => {
let server: any;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
});
describe('POST /oauth/authorize', () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(server).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
});
});
});

View File

@@ -1,63 +1,68 @@
import { LoginResponseDto, createPartner } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils';
import { LoginResponseDto, PartnerDirection } from '@app/domain';
import { PartnerController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
import { api } from '../../client';
import { testApp } from '../utils';
describe('/partner', () => {
let admin: LoginResponseDto;
describe(`${PartnerController.name} (e2e)`, () => {
let server: any;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let user3: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
server = (await testApp.create()).getHttpServer();
admin = await apiUtils.adminSetup();
await testApp.reset();
await api.authApi.adminSignUp(server);
const 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.user3),
]);
[user1, user2, user3] = await Promise.all([
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
api.authApi.login(server, userDto.user3),
]);
await Promise.all([
createPartner(
{ id: user2.userId },
{ headers: asBearerAuth(user1.accessToken) }
),
createPartner(
{ id: user1.userId },
{ headers: asBearerAuth(user2.accessToken) }
),
api.partnerApi.create(server, user1.accessToken, user2.userId),
api.partnerApi.create(server, user2.accessToken, user1.userId),
]);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /partner', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/partner');
const { status, body } = await request(server).get('/partner');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get all partners shared by user', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: 'shared-by' });
.query({ direction: PartnerDirection.SharedBy });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
});
it('should get all partners that share with user', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: 'shared-with' });
.query({ direction: PartnerDirection.SharedWith });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
@@ -66,16 +71,14 @@ describe('/partner', () => {
describe('POST /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(
`/partner/${user3.userId}`
);
const { status, body } = await request(server).post(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
expect(body).toEqual(errorStub.unauthorized);
});
it('should share with new partner', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.post(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -84,52 +87,44 @@ describe('/partner', () => {
});
it('should not share with new partner if already sharing with this partner', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.post(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Partner already exists' })
);
expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
});
});
describe('PUT /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/partner/${user2.userId}`
);
const { status, body } = await request(server).put(`/partner/${user2.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
expect(body).toEqual(errorStub.unauthorized);
});
it('should update partner', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.put(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: false });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({ id: user2.userId, inTimeline: false })
);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
});
});
describe('DELETE /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/partner/${user3.userId}`
);
const { status, body } = await request(server).delete(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
expect(body).toEqual(errorStub.unauthorized);
});
it('should delete partner', async () => {
const { status } = await request(app)
const { status } = await request(server)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
@@ -137,14 +132,12 @@ describe('/partner', () => {
});
it('should throw a bad request if partner not found', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Partner not found' })
);
expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
});
});
});

View File

@@ -1,30 +1,38 @@
import { LoginResponseDto, getServerConfig } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import { LoginResponseDto } from '@app/domain';
import { ServerInfoController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
import { api } from '../../client';
import { testApp } from '../utils';
describe('/server-info', () => {
describe(`${ServerInfoController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonAdmin = await api.authApi.login(server, userDto.user1);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /server-info', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/server-info');
const { status, body } = await request(server).get('/server-info');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return the disk information', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/server-info')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@@ -42,7 +50,7 @@ describe('/server-info', () => {
describe('GET /server-info/ping', () => {
it('should respond with pong', async () => {
const { status, body } = await request(app).get('/server-info/ping');
const { status, body } = await request(server).get('/server-info/ping');
expect(status).toBe(200);
expect(body).toEqual({ res: 'pong' });
});
@@ -50,7 +58,7 @@ describe('/server-info', () => {
describe('GET /server-info/version', () => {
it('should respond with the server version', async () => {
const { status, body } = await request(app).get('/server-info/version');
const { status, body } = await request(server).get('/server-info/version');
expect(status).toBe(200);
expect(body).toEqual({
major: expect.any(Number),
@@ -62,12 +70,12 @@ describe('/server-info', () => {
describe('GET /server-info/features', () => {
it('should respond with the server features', async () => {
const { status, body } = await request(app).get('/server-info/features');
const { status, body } = await request(server).get('/server-info/features');
expect(status).toBe(200);
expect(body).toEqual({
smartSearch: false,
smartSearch: true,
configFile: false,
facialRecognition: false,
facialRecognition: true,
map: true,
reverseGeocoding: true,
oauth: false,
@@ -82,7 +90,7 @@ describe('/server-info', () => {
describe('GET /server-info/config', () => {
it('should respond with the server configuration', async () => {
const { status, body } = await request(app).get('/server-info/config');
const { status, body } = await request(server).get('/server-info/config');
expect(status).toBe(200);
expect(body).toEqual({
loginPageMessage: '',
@@ -97,23 +105,21 @@ describe('/server-info', () => {
describe('GET /server-info/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
'/server-info/statistics'
);
const { status, body } = await request(server).get('/server-info/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
expect(body).toEqual(errorStub.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/server-info/statistics')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
expect(body).toEqual(errorStub.forbidden);
});
it('should return the server stats', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/server-info/statistics')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@@ -145,9 +151,7 @@ describe('/server-info', () => {
describe('GET /server-info/media-types', () => {
it('should return accepted media types', async () => {
const { status, body } = await request(app).get(
'/server-info/media-types'
);
const { status, body } = await request(server).get('/server-info/media-types');
expect(status).toBe(200);
expect(body).toEqual({
sidecar: ['.xmp'],
@@ -159,7 +163,7 @@ describe('/server-info', () => {
describe('GET /server-info/theme', () => {
it('should respond with the server theme', async () => {
const { status, body } = await request(app).get('/server-info/theme');
const { status, body } = await request(server).get('/server-info/theme');
expect(status).toBe(200);
expect(body).toEqual({
customCss: '',
@@ -169,15 +173,15 @@ describe('/server-info', () => {
describe('POST /server-info/admin-onboarding', () => {
it('should set admin onboarding', async () => {
const config = await getServerConfig({});
const config = await api.serverInfoApi.getConfig(server);
expect(config.isOnboarded).toBe(false);
const { status } = await request(app)
const { status } = await request(server)
.post('/server-info/admin-onboarding')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const newConfig = await getServerConfig({});
const newConfig = await api.serverInfoApi.getConfig(server);
expect(newConfig.isOnboarded).toBe(true);
});
});

View File

@@ -1,47 +1,49 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import { LoginResponseDto } from '@app/domain';
import { SystemConfigController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
import { api } from '../../client';
import { testApp } from '../utils';
describe('/system-config', () => {
describe(`${SystemConfigController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup();
nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1);
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonAdmin = await api.authApi.login(server, userDto.user1);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /system-config/map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
'/system-config/map/style.json'
);
const { status, body } = await request(server).get('/system-config/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'theme must be one of the following values: light, dark',
])
);
expect(body).toEqual(errorStub.badRequest(['theme must be one of the following values: light, dark']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -50,7 +52,7 @@ describe('/system-config', () => {
});
it('should return the dark style.json', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
@@ -59,7 +61,7 @@ describe('/system-config', () => {
});
it('should not require admin authentication', async () => {
const { status, body } = await request(app)
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);

View File

@@ -0,0 +1,299 @@
import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain';
import { AppModule, UserController } from '@app/immich';
import { UserEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { errorStub, userDto, userSignupStub, userStub } from '@test/fixtures';
import request from 'supertest';
import { Repository } from 'typeorm';
import { api } from '../../client';
import { testApp } from '../utils';
describe(`${UserController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let userService: UserService;
let userRepository: Repository<UserEntity>;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
userService = app.get<UserService>(UserService);
});
describe('GET /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/user');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should start with the admin', async () => {
const { status, body } = await request(server).get('/user').set('Authorization', `Bearer ${accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
});
it('should hide deleted users', async () => {
const user1 = await api.userApi.create(server, accessToken, {
email: `user1@immich.app`,
password: 'Password123',
name: `User 1`,
});
await api.userApi.delete(server, accessToken, user1.id);
const { status, body } = await request(server)
.get(`/user`)
.query({ isAll: true })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
});
it('should include deleted users', async () => {
const user1 = await api.userApi.create(server, accessToken, userDto.user1);
await api.userApi.delete(server, accessToken, user1.id);
const { status, body } = await request(server)
.get(`/user`)
.query({ isAll: false })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0]).toMatchObject({ id: user1.id, email: 'user1@immich.app', deletedAt: expect.any(String) });
expect(body[1]).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('GET /user/info/:id', () => {
it('should require authentication', async () => {
const { status } = await request(server).get(`/user/info/${loginResponse.userId}`);
expect(status).toEqual(401);
});
it('should get the user info', async () => {
const { status, body } = await request(server)
.get(`/user/info/${loginResponse.userId}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('GET /user/me', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/user/me`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get my info', async () => {
const { status, body } = await request(server).get(`/user/me`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('POST /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/user`).send(userSignupStub);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(userSignupStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post(`/user`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should ignore `isAdmin`', async () => {
const { status, body } = await request(server)
.post(`/user`)
.send({
isAdmin: true,
email: 'user1@immich.app',
password: 'Password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toMatchObject({
email: 'user1@immich.app',
isAdmin: false,
shouldChangePassword: true,
});
expect(status).toBe(201);
});
it('should create a user without memories enabled', async () => {
const { status, body } = await request(server)
.post(`/user`)
.send({
email: 'no-memories@immich.app',
password: 'Password123',
name: 'No Memories',
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toMatchObject({
email: 'no-memories@immich.app',
memoriesEnabled: false,
});
expect(status).toBe(201);
});
});
describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await api.userApi.create(server, accessToken, {
email: userStub.user1.email,
name: userStub.user1.name,
password: 'superSecurePassword',
});
});
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/user/${userToDelete.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should delete user', async () => {
const deleteRequest = await request(server)
.delete(`/user/${userToDelete.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(deleteRequest.status).toBe(200);
expect(deleteRequest.body).toEqual({
...userToDelete,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
await userRepository.save({ id: deleteRequest.body.id, deletedAt: new Date('1970-01-01').toISOString() });
await userService.handleUserDelete({ id: userToDelete.id });
const { status, body } = await request(server)
.get('/user')
.query({ isAll: false })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
});
describe('PUT /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/user`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(userStub.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.put(`/user`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should not allow a non-admin to become an admin', async () => {
const user = await api.userApi.create(server, accessToken, {
email: 'user1@immich.app',
password: 'Password123',
name: 'Immich User',
});
const { status, body } = await request(server)
.put(`/user`)
.send({ isAdmin: true, id: user.id })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.alreadyHasAdmin);
});
it('ignores updates to profileImagePath', async () => {
const user = await api.userApi.update(server, accessToken, {
id: loginResponse.userId,
profileImagePath: 'invalid.jpg',
} as any);
expect(user).toMatchObject({ id: loginResponse.userId, profileImagePath: '' });
});
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: loginResponse.userId,
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
deletedAt: '2023-01-01T00:00:00.000Z',
} as any);
expect(after).toStrictEqual(before);
});
it('should update first and last name', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: before.id,
name: 'Name',
});
expect(after).toEqual({
...before,
updatedAt: expect.any(String),
name: 'Name',
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
it('should update memories enabled', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: before.id,
memoriesEnabled: false,
});
expect(after).toMatchObject({
...before,
updatedAt: expect.anything(),
memoriesEnabled: false,
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
});
});

View File

@@ -4,8 +4,6 @@ import {
LibraryStatsResponseDto,
ScanLibraryDto,
UpdateLibraryDto,
ValidateLibraryDto,
ValidateLibraryResponseDto,
} from '@app/domain';
import request from 'supertest';
@@ -60,12 +58,4 @@ export const libraryApi = {
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;
},
};

3089
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More