Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04e70db2e | ||
|
|
75517057d0 | ||
|
|
3efc91ff9a | ||
|
|
7c07c001cd | ||
|
|
f1f7f82e91 | ||
|
|
b3bd5d1fd1 | ||
|
|
ad9b368cb8 | ||
|
|
7c78b00df6 | ||
|
|
4dfb6e88af | ||
|
|
744797e59d | ||
|
|
689105c949 | ||
|
|
1332a30630 | ||
|
|
648e5608b7 | ||
|
|
42dffaa0c8 | ||
|
|
ec3359f1cb | ||
|
|
235e57bf77 | ||
|
|
630240971f | ||
|
|
44e312cfba | ||
|
|
67c158a0ab | ||
|
|
1554f0d439 | ||
|
|
3e250d7878 | ||
|
|
a6d63285b7 | ||
|
|
c1552140b0 | ||
|
|
238617e566 | ||
|
|
3f3671fbe5 | ||
|
|
01ba579766 | ||
|
|
e2864b6664 | ||
|
|
1756d70fda | ||
|
|
b12be9119f | ||
|
|
e66d5a70e7 | ||
|
|
1a81902a36 | ||
|
|
ec5b4eb939 | ||
|
|
3457823b5b | ||
|
|
ffbe3d1681 | ||
|
|
5c0f0b498a | ||
|
|
c46f748412 | ||
|
|
59be6583b8 | ||
|
|
aeefb962d4 | ||
|
|
f80be1030b | ||
|
|
7df744b684 | ||
|
|
c66634fcfd | ||
|
|
d92248c664 | ||
|
|
67152772d2 | ||
|
|
564b720bce | ||
|
|
6aaebd167d | ||
|
|
2d236024bc | ||
|
|
e09b352067 | ||
|
|
4c89b0f7df | ||
|
|
b020f12f41 | ||
|
|
43848a7d68 | ||
|
|
9e96fdc627 | ||
|
|
434b7efd66 | ||
|
|
814cc14f88 | ||
|
|
59bf69f1d5 |
@@ -9,6 +9,8 @@ body:
|
|||||||
Stick to only a single feature per request. If you list multiple different features at once,
|
Stick to only a single feature per request. If you list multiple different features at once,
|
||||||
your request will be closed.
|
your request will be closed.
|
||||||
|
|
||||||
|
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening a feature request.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
|
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
name: Report an issue with Immich
|
name: Report an issue with Immich
|
||||||
description: Report an issue with Immich
|
description: Report an issue with Immich
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening an issue.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
|
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||||
runs-on: macos-26
|
runs-on: xcode-27
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- id: token
|
- id: token
|
||||||
@@ -214,8 +214,8 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
permission-contents: read
|
permission-contents: read
|
||||||
|
|
||||||
- name: Select Xcode 26
|
- name: Select Xcode 27.1
|
||||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_27.1.app/Contents/Developer
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -230,7 +230,7 @@ jobs:
|
|||||||
working_directory: ./mobile
|
working_directory: ./mobile
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.4'
|
ruby-version: '3.4'
|
||||||
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
|
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Generate a token
|
- name: Generate a token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
if: ${{ inputs.skip != true }}
|
if: ${{ inputs.skip != true }}
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ jobs:
|
|||||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ steps.token.outputs.token }}
|
repo-token: ${{ steps.token.outputs.token }}
|
||||||
|
sync-labels: true
|
||||||
|
|||||||
+16
-26
@@ -1,43 +1,33 @@
|
|||||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||||
|
|
||||||
[[tools.opentofu]]
|
[[tools.opentofu]]
|
||||||
version = "1.12.6"
|
version = "1.13.1"
|
||||||
backend = "aqua:opentofu/opentofu"
|
backend = "aqua:opentofu/opentofu"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64"]
|
[tools.opentofu."platforms.linux-arm64"]
|
||||||
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
|
||||||
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
|
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64"]
|
[tools.opentofu."platforms.linux-x64"]
|
||||||
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64-musl"]
|
|
||||||
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
|
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-arm64"]
|
[tools.opentofu."platforms.macos-arm64"]
|
||||||
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
|
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059"
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-x64"]
|
[tools.opentofu."platforms.macos-x64"]
|
||||||
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
|
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142"
|
||||||
|
|
||||||
[tools.opentofu."platforms.windows-x64"]
|
[tools.opentofu."platforms.windows-x64"]
|
||||||
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
|
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102"
|
||||||
|
|
||||||
[[tools.terragrunt]]
|
[[tools.terragrunt]]
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "1.1.1"
|
terragrunt = "1.1.1"
|
||||||
opentofu = "1.12.6"
|
opentofu = "1.13.1"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ services:
|
|||||||
command: ['./run.sh', '-disable-reporting']
|
command: ['./run.sh', '-disable-reporting']
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
image: grafana/grafana:12.4.11-ubuntu@sha256:23f2328b859c7677c7065c81b479600706ef9cc9004977af5bbd68b159b8ea60
|
image: grafana/grafana:13.2.4-ubuntu@sha256:d838696907b4350d9232eb8da1503dc1d1010a0359d3ce4b3f7d2ff7db28eb75
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ The default configuration looks like this:
|
|||||||
"timeout": 30000,
|
"timeout": 30000,
|
||||||
"tokenEndpointAuthMethod": "client_secret_post"
|
"tokenEndpointAuthMethod": "client_secret_post"
|
||||||
},
|
},
|
||||||
|
"passkey": {
|
||||||
|
"enabled": true,
|
||||||
|
"domain": null,
|
||||||
|
"additionalDomains": []
|
||||||
|
},
|
||||||
"passwordLogin": {
|
"passwordLogin": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||||
|
|
||||||
[[tools.wrangler]]
|
[[tools.wrangler]]
|
||||||
version = "4.137.0"
|
version = "4.151.0"
|
||||||
backend = "npm:wrangler"
|
backend = "npm:wrangler"
|
||||||
|
|
||||||
[tools.wrangler.options]
|
[tools.wrangler.options]
|
||||||
|
|||||||
+1
-1
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
|||||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
wrangler = "4.137.0"
|
wrangler = "4.151.0"
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^75.0.0",
|
"eslint-plugin-unicorn": "^75.0.0",
|
||||||
"exiftool-vendored": "^35.0.0",
|
"exiftool-vendored": "^39.0.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ describe('/search', () => {
|
|||||||
'New York',
|
'New York',
|
||||||
'Shanghai',
|
'Shanghai',
|
||||||
'State of Berlin',
|
'State of Berlin',
|
||||||
'St.-Petersburg',
|
'St. Petersburg',
|
||||||
'Tbilisi',
|
'Tbilisi',
|
||||||
'Tokyo',
|
'Tokyo',
|
||||||
'Virginia',
|
'Virginia',
|
||||||
@@ -591,7 +591,7 @@ describe('/search', () => {
|
|||||||
'New York',
|
'New York',
|
||||||
'Shanghai',
|
'Shanghai',
|
||||||
'State of Berlin',
|
'State of Berlin',
|
||||||
'St.-Petersburg',
|
'St. Petersburg',
|
||||||
'Tbilisi',
|
'Tbilisi',
|
||||||
'Tokyo',
|
'Tokyo',
|
||||||
'Virginia',
|
'Virginia',
|
||||||
|
|||||||
@@ -206,6 +206,13 @@ describe('/shared-links', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not return album users', async () => {
|
||||||
|
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.album.albumUsers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return unauthorized for incorrect shared link', async () => {
|
it('should return unauthorized for incorrect shared link', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/shared-links/me')
|
.get('/shared-links/me')
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { LoginResponseDto, getMyUser, login } from '@immich/sdk';
|
|
||||||
import { createUserDto } from 'src/fixtures.js';
|
|
||||||
import { app, asBearerAuth, utils } from 'src/utils.js';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
describe('/users', () => {
|
|
||||||
let admin: LoginResponseDto;
|
|
||||||
let nonAdmin: LoginResponseDto;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await utils.resetDatabase();
|
|
||||||
admin = await utils.adminSetup({ onboarding: false });
|
|
||||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PUT /users/me', () => {
|
|
||||||
/** @deprecated */
|
|
||||||
it('should allow a user to change their password (deprecated)', async () => {
|
|
||||||
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
|
|
||||||
|
|
||||||
expect(user.shouldChangePassword).toBe(true);
|
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.put(`/users/me`)
|
|
||||||
.send({ password: 'super-secret' })
|
|
||||||
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
email: nonAdmin.userEmail,
|
|
||||||
shouldChangePassword: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } });
|
|
||||||
|
|
||||||
expect(token.accessToken).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -28,7 +28,7 @@ test.describe('Registration', () => {
|
|||||||
await page.goto('/auth/login?autoLaunch=0');
|
await page.goto('/auth/login?autoLaunch=0');
|
||||||
await page.getByLabel('Email').fill('admin@immich.app');
|
await page.getByLabel('Email').fill('admin@immich.app');
|
||||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||||
await page.getByRole('button', { name: 'Login' }).click();
|
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||||
|
|
||||||
// onboarding
|
// onboarding
|
||||||
await expect(page).toHaveURL('/auth/onboarding');
|
await expect(page).toHaveURL('/auth/onboarding');
|
||||||
@@ -66,11 +66,12 @@ test.describe('Registration', () => {
|
|||||||
await page.goto('/auth/login?autoLaunch=0');
|
await page.goto('/auth/login?autoLaunch=0');
|
||||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||||
await page.getByLabel('Password').fill('password');
|
await page.getByLabel('Password').fill('password');
|
||||||
await page.getByRole('button', { name: 'Login' }).click();
|
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||||
|
|
||||||
// change password
|
// change password
|
||||||
await expect(page.getByRole('heading')).toHaveText('Change Password');
|
await expect(page.getByRole('heading')).toHaveText('Change Password');
|
||||||
await expect(page).toHaveURL('/auth/change-password');
|
await expect(page).toHaveURL('/auth/change-password');
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||||
await page.getByLabel('New Password').fill('new-password');
|
await page.getByLabel('New Password').fill('new-password');
|
||||||
await page.getByLabel('Confirm Password').fill('new-password');
|
await page.getByLabel('Confirm Password').fill('new-password');
|
||||||
await page.getByRole('button', { name: 'Change password' }).click();
|
await page.getByRole('button', { name: 'Change password' }).click();
|
||||||
@@ -79,7 +80,7 @@ test.describe('Registration', () => {
|
|||||||
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
|
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
|
||||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||||
await page.getByLabel('Password').fill('new-password');
|
await page.getByLabel('Password').fill('new-password');
|
||||||
await page.getByRole('button', { name: 'Login' }).click();
|
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||||
|
|
||||||
// onboarding
|
// onboarding
|
||||||
await expect(page).toHaveURL('/auth/onboarding');
|
await expect(page).toHaveURL('/auth/onboarding');
|
||||||
|
|||||||
@@ -324,6 +324,13 @@
|
|||||||
"oauth_timeout": "Request Timeout",
|
"oauth_timeout": "Request Timeout",
|
||||||
"oauth_timeout_description": "Timeout for requests in milliseconds",
|
"oauth_timeout_description": "Timeout for requests in milliseconds",
|
||||||
"ocr_job_description": "Use machine learning to recognize text in images",
|
"ocr_job_description": "Use machine learning to recognize text in images",
|
||||||
|
"passkey_additional_domains": "Additional domains",
|
||||||
|
"passkey_additional_domains_description": "Additional, trusted domains (related origin requests). These will only work if the passkey domain is also accessible.",
|
||||||
|
"passkey_domain": "Passkey domain",
|
||||||
|
"passkey_domain_description": "Domain used for passkeys (relying party). Leave blank to infer from the origin header. Changing this value will result in existing passkeys no longer working.",
|
||||||
|
"passkey_enable_description": "Login with a passkey",
|
||||||
|
"passkey_settings": "Passkey Login",
|
||||||
|
"passkey_settings_description": "Manage passkey login settings",
|
||||||
"password_enable_description": "Login with email and password",
|
"password_enable_description": "Login with email and password",
|
||||||
"password_settings": "Password Login",
|
"password_settings": "Password Login",
|
||||||
"password_settings_description": "Manage password login settings",
|
"password_settings_description": "Manage password login settings",
|
||||||
@@ -874,6 +881,8 @@
|
|||||||
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
|
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
|
||||||
"delete_local_dialog_ok_force": "Delete Anyway",
|
"delete_local_dialog_ok_force": "Delete Anyway",
|
||||||
"delete_others": "Delete others",
|
"delete_others": "Delete others",
|
||||||
|
"delete_passkey": "Delete passkey",
|
||||||
|
"delete_passkey_prompt": "Are you sure you want to delete this passkey?",
|
||||||
"delete_permanently": "Delete permanently",
|
"delete_permanently": "Delete permanently",
|
||||||
"delete_permanently_action_prompt": "{count} deleted permanently",
|
"delete_permanently_action_prompt": "{count} deleted permanently",
|
||||||
"delete_shared_link": "Delete shared link",
|
"delete_shared_link": "Delete shared link",
|
||||||
@@ -943,6 +952,7 @@
|
|||||||
"edit_location_action_prompt": "{count} location edited",
|
"edit_location_action_prompt": "{count} location edited",
|
||||||
"edit_location_dialog_title": "Location",
|
"edit_location_dialog_title": "Location",
|
||||||
"edit_name": "Edit name",
|
"edit_name": "Edit name",
|
||||||
|
"edit_passkey": "Edit passkey",
|
||||||
"edit_people": "Edit people",
|
"edit_people": "Edit people",
|
||||||
"edit_person": "Edit person",
|
"edit_person": "Edit person",
|
||||||
"edit_tag": "Edit tag",
|
"edit_tag": "Edit tag",
|
||||||
@@ -1287,6 +1297,7 @@
|
|||||||
"last": "Last",
|
"last": "Last",
|
||||||
"last_months": "{count, plural, one {Last month} other {Last # months}}",
|
"last_months": "{count, plural, one {Last month} other {Last # months}}",
|
||||||
"last_seen": "Last seen",
|
"last_seen": "Last seen",
|
||||||
|
"last_used": "Last used",
|
||||||
"latest_version": "Latest Version",
|
"latest_version": "Latest Version",
|
||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
@@ -1379,6 +1390,7 @@
|
|||||||
"login_has_been_disabled": "Login has been disabled",
|
"login_has_been_disabled": "Login has been disabled",
|
||||||
"login_password_changed_error": "There was an error updating your password",
|
"login_password_changed_error": "There was an error updating your password",
|
||||||
"login_password_changed_success": "Password updated successfully",
|
"login_password_changed_success": "Password updated successfully",
|
||||||
|
"login_with_passkey": "Login with passkey",
|
||||||
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
||||||
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
|
"logout_this_device_confirmation": "Are you sure you want to log out this device?",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
@@ -1427,6 +1439,7 @@
|
|||||||
"manage_your_api_keys": "Manage your API keys",
|
"manage_your_api_keys": "Manage your API keys",
|
||||||
"manage_your_devices": "Manage your logged-in devices",
|
"manage_your_devices": "Manage your logged-in devices",
|
||||||
"manage_your_oauth_connection": "Manage your OAuth connection",
|
"manage_your_oauth_connection": "Manage your OAuth connection",
|
||||||
|
"manage_your_passkeys": "Manage your passkeys",
|
||||||
"map": "Map",
|
"map": "Map",
|
||||||
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
|
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
|
||||||
"map_cannot_get_user_location": "Cannot get user's location",
|
"map_cannot_get_user_location": "Cannot get user's location",
|
||||||
@@ -1546,6 +1559,7 @@
|
|||||||
"new_album": "New Album",
|
"new_album": "New Album",
|
||||||
"new_api_key": "New API Key",
|
"new_api_key": "New API Key",
|
||||||
"new_feature": "New Feature",
|
"new_feature": "New Feature",
|
||||||
|
"new_passkey": "New passkey",
|
||||||
"new_password": "New password",
|
"new_password": "New password",
|
||||||
"new_person": "New person",
|
"new_person": "New person",
|
||||||
"new_pin_code": "New PIN code",
|
"new_pin_code": "New PIN code",
|
||||||
@@ -1658,6 +1672,8 @@
|
|||||||
"partner_sharing": "Partner Sharing",
|
"partner_sharing": "Partner Sharing",
|
||||||
"partners": "Partners",
|
"partners": "Partners",
|
||||||
"partners_description": "Share your photos and videos with other users",
|
"partners_description": "Share your photos and videos with other users",
|
||||||
|
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
|
||||||
|
"passkeys_not_supported": "Passkeys are not supported by this browser",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"password_does_not_match": "Password does not match",
|
"password_does_not_match": "Password does not match",
|
||||||
"password_required": "Password Required",
|
"password_required": "Password Required",
|
||||||
@@ -1842,6 +1858,7 @@
|
|||||||
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
|
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
|
||||||
"removed_from_stack": "Removed asset from stack",
|
"removed_from_stack": "Removed asset from stack",
|
||||||
"removed_memory": "Removed memory",
|
"removed_memory": "Removed memory",
|
||||||
|
"removed_passkey": "Removed passkey: {name}",
|
||||||
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"repository": "Repository",
|
"repository": "Repository",
|
||||||
@@ -2025,6 +2042,7 @@
|
|||||||
"share_everyone": "Share everyone",
|
"share_everyone": "Share everyone",
|
||||||
"share_everyone_description": "Give {user} access to all of your people.",
|
"share_everyone_description": "Give {user} access to all of your people.",
|
||||||
"share_link": "Share Link",
|
"share_link": "Share Link",
|
||||||
|
"share_logs": "Share Logs",
|
||||||
"share_original": "Use original (large)",
|
"share_original": "Use original (large)",
|
||||||
"share_preview": "Use preview (small)",
|
"share_preview": "Use preview (small)",
|
||||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||||
@@ -2203,6 +2221,8 @@
|
|||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"theme_selection": "Theme selection",
|
"theme_selection": "Theme selection",
|
||||||
"theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
|
"theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
|
||||||
|
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Scroll continuously when dragging the scrollbar instead of snapping to months",
|
||||||
|
"theme_setting_asset_list_smooth_scrubbing_title": "Smooth timeline scrubbing",
|
||||||
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
|
||||||
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})",
|
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})",
|
||||||
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
|
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
|
||||||
|
|||||||
@@ -323,6 +323,13 @@
|
|||||||
"oauth_timeout": "Таймаут для запросов",
|
"oauth_timeout": "Таймаут для запросов",
|
||||||
"oauth_timeout_description": "Максимальное время, в течение которого ожидать ответа, в миллисекундах",
|
"oauth_timeout_description": "Максимальное время, в течение которого ожидать ответа, в миллисекундах",
|
||||||
"ocr_job_description": "Использование машинного обучения для распознавания текста на изображениях",
|
"ocr_job_description": "Использование машинного обучения для распознавания текста на изображениях",
|
||||||
|
"passkey_additional_domains": "Дополнительные домены",
|
||||||
|
"passkey_additional_domains_description": "Дополнительные доверенные домены. Для их работы основной домен ключей доступа также должен быть доступен.",
|
||||||
|
"passkey_domain": "Домен ключей доступа",
|
||||||
|
"passkey_domain_description": "Домен для ключей доступа. Оставьте пустым для определения по адресу сайта. При изменении домена существующие ключи перестанут работать.",
|
||||||
|
"passkey_enable_description": "Вход с помощью ключа доступа",
|
||||||
|
"passkey_settings": "Вход по ключу доступа",
|
||||||
|
"passkey_settings_description": "Настройки входа по ключу доступа",
|
||||||
"password_enable_description": "Вход по электронной почте и паролю",
|
"password_enable_description": "Вход по электронной почте и паролю",
|
||||||
"password_settings": "Настройки входа с паролем",
|
"password_settings": "Настройки входа с паролем",
|
||||||
"password_settings_description": "Управление настройками входа по паролю",
|
"password_settings_description": "Управление настройками входа по паролю",
|
||||||
@@ -873,6 +880,8 @@
|
|||||||
"delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии",
|
"delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии",
|
||||||
"delete_local_dialog_ok_force": "Все равно удалить",
|
"delete_local_dialog_ok_force": "Все равно удалить",
|
||||||
"delete_others": "Удалить остальные",
|
"delete_others": "Удалить остальные",
|
||||||
|
"delete_passkey": "Удалить ключ доступа",
|
||||||
|
"delete_passkey_prompt": "Удалить этот ключ доступа?",
|
||||||
"delete_permanently": "Удалить навсегда",
|
"delete_permanently": "Удалить навсегда",
|
||||||
"delete_permanently_action_prompt": "Объекты удалены безвозвратно ({count} шт.)",
|
"delete_permanently_action_prompt": "Объекты удалены безвозвратно ({count} шт.)",
|
||||||
"delete_shared_link": "Удалить публичную ссылку",
|
"delete_shared_link": "Удалить публичную ссылку",
|
||||||
@@ -942,6 +951,7 @@
|
|||||||
"edit_location_action_prompt": "Места изменены ({count} шт.)",
|
"edit_location_action_prompt": "Места изменены ({count} шт.)",
|
||||||
"edit_location_dialog_title": "Местоположение",
|
"edit_location_dialog_title": "Местоположение",
|
||||||
"edit_name": "Изменить имя",
|
"edit_name": "Изменить имя",
|
||||||
|
"edit_passkey": "Изменить ключ доступа",
|
||||||
"edit_people": "Изменить людей",
|
"edit_people": "Изменить людей",
|
||||||
"edit_person": "Изменить человека",
|
"edit_person": "Изменить человека",
|
||||||
"edit_tag": "Изменить тег",
|
"edit_tag": "Изменить тег",
|
||||||
@@ -1287,6 +1297,7 @@
|
|||||||
"last": "Последний",
|
"last": "Последний",
|
||||||
"last_months": "{count, plural, one {Последний месяц} many {# последних месяцев} other {# последних месяца}}",
|
"last_months": "{count, plural, one {Последний месяц} many {# последних месяцев} other {# последних месяца}}",
|
||||||
"last_seen": "Последний доступ",
|
"last_seen": "Последний доступ",
|
||||||
|
"last_used": "Последнее использование",
|
||||||
"latest_version": "Последняя версия",
|
"latest_version": "Последняя версия",
|
||||||
"latitude": "Широта",
|
"latitude": "Широта",
|
||||||
"leave": "Покинуть",
|
"leave": "Покинуть",
|
||||||
@@ -1379,6 +1390,7 @@
|
|||||||
"login_has_been_disabled": "Вход в систему был отключён",
|
"login_has_been_disabled": "Вход в систему был отключён",
|
||||||
"login_password_changed_error": "Произошла ошибка при обновлении пароля",
|
"login_password_changed_error": "Произошла ошибка при обновлении пароля",
|
||||||
"login_password_changed_success": "Пароль успешно обновлен",
|
"login_password_changed_success": "Пароль успешно обновлен",
|
||||||
|
"login_with_passkey": "Войти с ключом доступа",
|
||||||
"logout_all_device_confirmation": "Вы действительно хотите завершить все сеансы, кроме текущего?",
|
"logout_all_device_confirmation": "Вы действительно хотите завершить все сеансы, кроме текущего?",
|
||||||
"logout_this_device_confirmation": "Вы действительно хотите завершить сеанс на этом устройстве?",
|
"logout_this_device_confirmation": "Вы действительно хотите завершить сеанс на этом устройстве?",
|
||||||
"logs": "Журнал событий",
|
"logs": "Журнал событий",
|
||||||
@@ -1427,6 +1439,7 @@
|
|||||||
"manage_your_api_keys": "Управление API ключами для взаимодействия с другими программами",
|
"manage_your_api_keys": "Управление API ключами для взаимодействия с другими программами",
|
||||||
"manage_your_devices": "Управление устройствами, на которых вы входили в свой аккаунт",
|
"manage_your_devices": "Управление устройствами, на которых вы входили в свой аккаунт",
|
||||||
"manage_your_oauth_connection": "Настройки подключённого OAuth",
|
"manage_your_oauth_connection": "Настройки подключённого OAuth",
|
||||||
|
"manage_your_passkeys": "Управление ключами доступа",
|
||||||
"map": "Карта",
|
"map": "Карта",
|
||||||
"map_assets_in_bounds": "{count, plural, =0 {Нет фото в этой области} other {# фото}}",
|
"map_assets_in_bounds": "{count, plural, =0 {Нет фото в этой области} other {# фото}}",
|
||||||
"map_cannot_get_user_location": "Невозможно получить местоположение пользователя",
|
"map_cannot_get_user_location": "Невозможно получить местоположение пользователя",
|
||||||
@@ -1546,6 +1559,7 @@
|
|||||||
"new_album": "Новый альбом",
|
"new_album": "Новый альбом",
|
||||||
"new_api_key": "Новый API ключ",
|
"new_api_key": "Новый API ключ",
|
||||||
"new_feature": "Новая функциональность",
|
"new_feature": "Новая функциональность",
|
||||||
|
"new_passkey": "Новый ключ доступа",
|
||||||
"new_password": "Новый пароль",
|
"new_password": "Новый пароль",
|
||||||
"new_person": "Новый человек",
|
"new_person": "Новый человек",
|
||||||
"new_pin_code": "Новый PIN-код",
|
"new_pin_code": "Новый PIN-код",
|
||||||
@@ -1658,6 +1672,8 @@
|
|||||||
"partner_sharing": "Совместное использование",
|
"partner_sharing": "Совместное использование",
|
||||||
"partners": "Партнёры",
|
"partners": "Партнёры",
|
||||||
"partners_description": "Делитесь своими фото и видео с другими пользователями",
|
"partners_description": "Делитесь своими фото и видео с другими пользователями",
|
||||||
|
"passkeys": "{count, plural, one {Ключ доступа} few {Ключи доступа} many {Ключи доступа} other {Ключи доступа}}",
|
||||||
|
"passkeys_not_supported": "Этот браузер не поддерживает ключи доступа",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"password_does_not_match": "Пароли не совпадают",
|
"password_does_not_match": "Пароли не совпадают",
|
||||||
"password_required": "Требуется пароль",
|
"password_required": "Требуется пароль",
|
||||||
@@ -1842,6 +1858,7 @@
|
|||||||
"removed_from_favorites_count": "{count, plural, one {# объект удалён} many {# объектов удалено} other {# объекта удалено}} из избранного",
|
"removed_from_favorites_count": "{count, plural, one {# объект удалён} many {# объектов удалено} other {# объекта удалено}} из избранного",
|
||||||
"removed_from_stack": "Объект удалён из группы",
|
"removed_from_stack": "Объект удалён из группы",
|
||||||
"removed_memory": "Воспоминание удалено",
|
"removed_memory": "Воспоминание удалено",
|
||||||
|
"removed_passkey": "Удалён ключ доступа: {name}",
|
||||||
"removed_tagged_assets": "Тег удалён у {count, plural, one {# объекта} other {# объектов}}",
|
"removed_tagged_assets": "Тег удалён у {count, plural, one {# объекта} other {# объектов}}",
|
||||||
"rename": "Переименовать",
|
"rename": "Переименовать",
|
||||||
"repository": "Репозиторий",
|
"repository": "Репозиторий",
|
||||||
@@ -2027,6 +2044,7 @@
|
|||||||
"share_everyone": "Поделиться со всеми",
|
"share_everyone": "Поделиться со всеми",
|
||||||
"share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.",
|
"share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.",
|
||||||
"share_link": "Создать ссылку",
|
"share_link": "Создать ссылку",
|
||||||
|
"share_logs": "Поделиться журналами",
|
||||||
"share_original": "Оригинал (большой размер)",
|
"share_original": "Оригинал (большой размер)",
|
||||||
"share_preview": "Предпросмотр (небольшой размер)",
|
"share_preview": "Предпросмотр (небольшой размер)",
|
||||||
"share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.",
|
"share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.",
|
||||||
@@ -2206,6 +2224,8 @@
|
|||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
"theme_selection": "Выбор темы",
|
"theme_selection": "Выбор темы",
|
||||||
"theme_selection_description": "Автоматически устанавливать светлую или тёмную тему в зависимости от настроек вашего браузера",
|
"theme_selection_description": "Автоматически устанавливать светлую или тёмную тему в зависимости от настроек вашего браузера",
|
||||||
|
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Прокручивать ленту плавно при перетаскивании полосы прокрутки, без переходов по месяцам",
|
||||||
|
"theme_setting_asset_list_smooth_scrubbing_title": "Плавная прокрутка ленты",
|
||||||
"theme_setting_asset_list_storage_indicator_title": "Отображать индикатор хранилища на плитках объектов",
|
"theme_setting_asset_list_storage_indicator_title": "Отображать индикатор хранилища на плитках объектов",
|
||||||
"theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({count})",
|
"theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({count})",
|
||||||
"theme_setting_colorful_interface_subtitle": "Добавить оттенок к фону.",
|
"theme_setting_colorful_interface_subtitle": "Добавить оттенок к фону.",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
ARG DEVICE=cpu
|
ARG DEVICE=cpu
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS builder-cpu
|
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-cpu
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b AS builder-openvino
|
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-openvino
|
||||||
|
|
||||||
# renovate: datasource=github-releases depName=microsoft/onnxruntime
|
# renovate: datasource=github-releases depName=microsoft/onnxruntime
|
||||||
ARG ONNXRUNTIME_VERSION="v1.30.0"
|
ARG ONNXRUNTIME_VERSION="v1.30.0"
|
||||||
@@ -59,7 +59,7 @@ RUN mkdir /opt/armnn && \
|
|||||||
cd /opt/ann && \
|
cd /opt/ann && \
|
||||||
sh build.sh
|
sh build.sh
|
||||||
|
|
||||||
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS builder-rknn
|
FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS builder-rknn
|
||||||
|
|
||||||
# Warning: 25GiB+ disk space required to pull this image
|
# Warning: 25GiB+ disk space required to pull this image
|
||||||
# TODO: find a way to reduce the image size
|
# TODO: find a way to reduce the image size
|
||||||
@@ -81,11 +81,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy && \
|
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy && \
|
||||||
if [ "$DEVICE" = "openvino" ]; then uv pip install --no-deps /opt/onnxruntime_openvino-*.whl; fi
|
if [ "$DEVICE" = "openvino" ]; then uv pip install --no-deps /opt/onnxruntime_openvino-*.whl; fi
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-cpu
|
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-cpu
|
||||||
|
|
||||||
ENV MACHINE_LEARNING_MODEL_ARENA=false
|
ENV MACHINE_LEARNING_MODEL_ARENA=false
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-openvino
|
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-openvino
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
@@ -141,7 +141,7 @@ COPY --from=builder-armnn \
|
|||||||
/opt/ann/build.sh \
|
/opt/ann/build.sh \
|
||||||
/opt/armnn/
|
/opt/armnn/
|
||||||
|
|
||||||
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS prod-rknn
|
FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS prod-rknn
|
||||||
|
|
||||||
# renovate: datasource=github-tags depName=airockchip/rknn-toolkit2
|
# renovate: datasource=github-tags depName=airockchip/rknn-toolkit2
|
||||||
ARG RKNN_TOOLKIT_VERSION="v2.3.0"
|
ARG RKNN_TOOLKIT_VERSION="v2.3.0"
|
||||||
@@ -150,7 +150,7 @@ ENV MACHINE_LEARNING_MODEL_ARENA=false
|
|||||||
|
|
||||||
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
|
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS mimalloc
|
FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS mimalloc
|
||||||
RUN echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/backports.list && \
|
RUN echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/backports.list && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends libmimalloc3/trixie-backports && \
|
apt-get install -y --no-install-recommends libmimalloc3/trixie-backports && \
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from collections.abc import Hashable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from immich_ml import allocator
|
from immich_ml import allocator
|
||||||
|
from immich_ml.config import clean_name
|
||||||
from immich_ml.models.base import InferenceEntry, InferenceModel
|
from immich_ml.models.base import InferenceEntry, InferenceModel
|
||||||
from immich_ml.schemas import Options
|
from immich_ml.schemas import Options
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ class ModelCache:
|
|||||||
self._expiries: dict[Key, asyncio.TimerHandle] = {}
|
self._expiries: dict[Key, asyncio.TimerHandle] = {}
|
||||||
|
|
||||||
def get[O: Options](self, entry: InferenceEntry[O], ttl: int | None = None) -> InferenceModel[O]:
|
def get[O: Options](self, entry: InferenceEntry[O], ttl: int | None = None) -> InferenceModel[O]:
|
||||||
key = (entry.model, entry.name, entry.model.graph(entry.options))
|
# names that differ only in what cleaning drops, like an `immich-app/` prefix, are the same model
|
||||||
|
key = (entry.model, clean_name(entry.name), entry.model.graph(entry.options))
|
||||||
if key not in self._models:
|
if key not in self._models:
|
||||||
self._models[key] = entry.model.create(entry.name, entry.options)
|
self._models[key] = entry.model.create(entry.name, entry.options)
|
||||||
elif key not in self._expiries:
|
elif key not in self._expiries:
|
||||||
|
|||||||
@@ -1856,6 +1856,14 @@ class TestCache:
|
|||||||
assert len(model_cache._models) == 1
|
assert len(model_cache._models) == 1
|
||||||
model.create.assert_called_once()
|
model.create.assert_called_once()
|
||||||
|
|
||||||
|
async def test_shares_an_instance_across_names_of_the_same_model(self) -> None:
|
||||||
|
model_cache = ModelCache()
|
||||||
|
bare = model_cache.get(InferenceEntry(OpenClipTextualEncoder, "ViT-B-32__openai", TextualOptions()))
|
||||||
|
prefixed = model_cache.get(
|
||||||
|
InferenceEntry(OpenClipTextualEncoder, "immich-app/ViT-B-32__openai", TextualOptions(language="en-US"))
|
||||||
|
)
|
||||||
|
assert bare is prefixed
|
||||||
|
|
||||||
async def test_separate_instances_per_graph_option(self) -> None:
|
async def test_separate_instances_per_graph_option(self) -> None:
|
||||||
# the real TextDetector, so that keying it on anything but its resolution fails this
|
# the real TextDetector, so that keying it on anything but its resolution fails this
|
||||||
model_cache = ModelCache()
|
model_cache = ModelCache()
|
||||||
|
|||||||
Generated
+3
-3
@@ -175,11 +175,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.11.12"
|
version = "2026.10.19"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -212,36 +212,37 @@ checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882
|
|||||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
|
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
|
||||||
|
|
||||||
[[tools.node]]
|
[[tools.node]]
|
||||||
version = "24.21.0"
|
version = "24.22.1"
|
||||||
backend = "core:node"
|
backend = "core:node"
|
||||||
|
specifiers = ["24.22.1"]
|
||||||
|
|
||||||
[tools.node."platforms.linux-arm64"]
|
[tools.node."platforms.linux-arm64"]
|
||||||
checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
|
checksum = "sha256:33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3"
|
||||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz"
|
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.linux-arm64-musl"]
|
[tools.node."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
|
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
|
||||||
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz"
|
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.linux-x64"]
|
[tools.node."platforms.linux-x64"]
|
||||||
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
checksum = "sha256:eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c"
|
||||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
|
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.linux-x64-musl"]
|
[tools.node."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
||||||
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
|
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.macos-arm64"]
|
[tools.node."platforms.macos-arm64"]
|
||||||
checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057"
|
checksum = "sha256:89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7"
|
||||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz"
|
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.macos-x64"]
|
[tools.node."platforms.macos-x64"]
|
||||||
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
|
checksum = "sha256:d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035"
|
||||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
|
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz"
|
||||||
|
|
||||||
[tools.node."platforms.windows-x64"]
|
[tools.node."platforms.windows-x64"]
|
||||||
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
|
checksum = "sha256:d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5"
|
||||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
|
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip"
|
||||||
|
|
||||||
[[tools."npm:@openapitools/openapi-generator-cli"]]
|
[[tools."npm:@openapitools/openapi-generator-cli"]]
|
||||||
version = "2.41.0"
|
version = "2.41.0"
|
||||||
@@ -252,13 +253,14 @@ version = "7.5.0"
|
|||||||
backend = "npm:oazapfts"
|
backend = "npm:oazapfts"
|
||||||
|
|
||||||
[[tools.opentofu]]
|
[[tools.opentofu]]
|
||||||
version = "1.12.6"
|
version = "1.13.1"
|
||||||
backend = "aqua:opentofu/opentofu"
|
backend = "aqua:opentofu/opentofu"
|
||||||
|
specifiers = ["1.13.1"]
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64"]
|
[tools.opentofu."platforms.linux-arm64"]
|
||||||
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
||||||
@@ -266,9 +268,9 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.
|
|||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64"]
|
[tools.opentofu."platforms.linux-x64"]
|
||||||
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64-musl"]
|
[tools.opentofu."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
||||||
@@ -276,58 +278,53 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.
|
|||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-arm64"]
|
[tools.opentofu."platforms.macos-arm64"]
|
||||||
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
|
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059"
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-x64"]
|
[tools.opentofu."platforms.macos-x64"]
|
||||||
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
|
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142"
|
||||||
|
|
||||||
[tools.opentofu."platforms.windows-x64"]
|
[tools.opentofu."platforms.windows-x64"]
|
||||||
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
|
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102"
|
||||||
|
|
||||||
[[tools.pnpm]]
|
[[tools.pnpm]]
|
||||||
version = "11.27.1"
|
version = "12.14.0"
|
||||||
backend = "aqua:pnpm/pnpm"
|
backend = "aqua:pnpm/pnpm"
|
||||||
|
specifiers = ["12.14.0"]
|
||||||
|
|
||||||
[tools.pnpm."platforms.linux-arm64"]
|
[tools.pnpm."platforms.linux-arm64"]
|
||||||
checksum = "sha256:dbf8f84263d2bc696306d4219be68256d881d9af409345374d878676a7edcf0c"
|
checksum = "sha256:cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602580"
|
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957409"
|
||||||
provenance = "github-attestations"
|
|
||||||
|
|
||||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
|
||||||
checksum = "sha256:7a7854e90bdb7d339430d9943ea482b34d7bde817cfc1c749d3c04ddd464142d"
|
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64-musl.tar.gz"
|
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602583"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.linux-x64"]
|
[tools.pnpm."platforms.linux-x64"]
|
||||||
checksum = "sha256:6be1426540406747b7731564a46c5b30d3023ea6c19e4ce99f994c4ec122f3d2"
|
checksum = "sha256:3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-x64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602576"
|
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957410"
|
||||||
provenance = "github-attestations"
|
|
||||||
|
|
||||||
[tools.pnpm."platforms.linux-x64-musl"]
|
|
||||||
checksum = "sha256:cc66c1558a1a03b320ff5b4022f755386fa1de54db3f97ff624112015be1ccf1"
|
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64-musl.tar.gz"
|
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602579"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.macos-arm64"]
|
[tools.pnpm."platforms.macos-arm64"]
|
||||||
checksum = "sha256:352f67ceaaa5aba294a53f483da93c376005b4c7e785fe5411e0ce720fe74741"
|
checksum = "sha256:50f48e0e17508952790a3fbf58fcf14f3b82efa5f594ca04ad904e29aaf854b0"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-darwin-arm64.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602575"
|
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957413"
|
||||||
|
provenance = "github-attestations"
|
||||||
|
|
||||||
|
[tools.pnpm."platforms.macos-x64"]
|
||||||
|
checksum = "sha256:2c412e07382049b4edb04fa6da78f3d4ef7810b75853c80544dcfb7c0ed79880"
|
||||||
|
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-x64.tar.gz"
|
||||||
|
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957411"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.windows-x64"]
|
[tools.pnpm."platforms.windows-x64"]
|
||||||
checksum = "sha256:4b56d1184c3390b08146060fa4b414b8ceec9164aa41bf996e87a0f0bc135851"
|
checksum = "sha256:6f0f38e917c8ce76ef31db528c357e2bfa354a1bcb16464e3cdec39e6530d784"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-win32-x64.zip"
|
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-win32-x64.zip"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602578"
|
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957404"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[[tools.terragrunt]]
|
[[tools.terragrunt]]
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ config_roots = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
node = "24.21.0"
|
node = "24.22.1"
|
||||||
pnpm = "11.27.1"
|
pnpm = "12.14.0"
|
||||||
terragrunt = "1.1.1"
|
terragrunt = "1.1.1"
|
||||||
opentofu = "1.12.6"
|
opentofu = "1.13.1"
|
||||||
"npm:@openapitools/openapi-generator-cli" = "2.41.0"
|
"npm:@openapitools/openapi-generator-cli" = "2.41.0"
|
||||||
"npm:oazapfts" = "7.5.0"
|
"npm:oazapfts" = "7.5.0"
|
||||||
"github:extism/cli" = "1.6.3"
|
"github:extism/cli" = "1.6.3"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ abstract class AppConfig with _$AppConfig {
|
|||||||
.timelineTilesPerRow => timeline.tilesPerRow,
|
.timelineTilesPerRow => timeline.tilesPerRow,
|
||||||
.timelineGroupAssetsBy => timeline.groupAssetsBy,
|
.timelineGroupAssetsBy => timeline.groupAssetsBy,
|
||||||
.timelineStorageIndicator => timeline.storageIndicator,
|
.timelineStorageIndicator => timeline.storageIndicator,
|
||||||
|
.timelineSmoothScrubbing => timeline.smoothScrubbing,
|
||||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||||
.mapRelativeDate => map.relativeDays,
|
.mapRelativeDate => map.relativeDays,
|
||||||
.mapCustomFrom => map.customFrom,
|
.mapCustomFrom => map.customFrom,
|
||||||
@@ -131,6 +132,7 @@ abstract class AppConfig with _$AppConfig {
|
|||||||
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
|
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
|
||||||
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
|
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
|
||||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||||
|
.timelineSmoothScrubbing => copyWith(timeline: timeline.copyWith(smoothScrubbing: value as bool)),
|
||||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||||
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
|
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ abstract class TimelineConfig with _$TimelineConfig {
|
|||||||
@Default(4) int tilesPerRow,
|
@Default(4) int tilesPerRow,
|
||||||
@Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
|
@Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
|
||||||
@Default(true) bool storageIndicator,
|
@Default(true) bool storageIndicator,
|
||||||
|
@Default(false) bool smoothScrubbing,
|
||||||
}) = _TimelineConfig;
|
}) = _TimelineConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ enum SettingsKey<T> {
|
|||||||
timelineTilesPerRow<int>(),
|
timelineTilesPerRow<int>(),
|
||||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
|
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
|
||||||
timelineStorageIndicator<bool>(),
|
timelineStorageIndicator<bool>(),
|
||||||
|
timelineSmoothScrubbing<bool>(),
|
||||||
|
|
||||||
// Log
|
// Log
|
||||||
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
|
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' show ClientException;
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:openapi/api.dart' show ApiException;
|
||||||
|
|
||||||
/// Service responsible for handling application logging.
|
/// Service responsible for handling application logging.
|
||||||
///
|
///
|
||||||
@@ -14,8 +18,15 @@ import 'package:logging/logging.dart';
|
|||||||
/// writes them to a persistent [LogRepository], and manages log levels via
|
/// writes them to a persistent [LogRepository], and manages log levels via
|
||||||
/// [SettingsRepository].
|
/// [SettingsRepository].
|
||||||
class LogService {
|
class LogService {
|
||||||
|
static final _schemeAndHost = RegExp(r'''https?://[^\s/?#'"<>(),]*''');
|
||||||
|
static final _ipAddress = RegExp(
|
||||||
|
r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|'
|
||||||
|
r'\[?\b(?:(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:)+(?::[0-9a-f]{1,4})+)(?:%\w+)?\]?',
|
||||||
|
);
|
||||||
|
|
||||||
final LogRepository _logRepository;
|
final LogRepository _logRepository;
|
||||||
final SettingsRepository _settingsRepository;
|
final SettingsRepository _settingsRepository;
|
||||||
|
final StoreService _storeService;
|
||||||
|
|
||||||
final List<LogMessage> _msgBuffer = [];
|
final List<LogMessage> _msgBuffer = [];
|
||||||
|
|
||||||
@@ -39,11 +50,13 @@ class LogService {
|
|||||||
static Future<LogService> init({
|
static Future<LogService> init({
|
||||||
required LogRepository logRepository,
|
required LogRepository logRepository,
|
||||||
required SettingsRepository settingsRepository,
|
required SettingsRepository settingsRepository,
|
||||||
|
required StoreService storeService,
|
||||||
bool shouldBuffer = true,
|
bool shouldBuffer = true,
|
||||||
}) async {
|
}) async {
|
||||||
_instance ??= await create(
|
_instance ??= await create(
|
||||||
logRepository: logRepository,
|
logRepository: logRepository,
|
||||||
settingsRepository: settingsRepository,
|
settingsRepository: settingsRepository,
|
||||||
|
storeService: storeService,
|
||||||
shouldBuffer: shouldBuffer,
|
shouldBuffer: shouldBuffer,
|
||||||
);
|
);
|
||||||
return _instance!;
|
return _instance!;
|
||||||
@@ -52,16 +65,17 @@ class LogService {
|
|||||||
static Future<LogService> create({
|
static Future<LogService> create({
|
||||||
required LogRepository logRepository,
|
required LogRepository logRepository,
|
||||||
required SettingsRepository settingsRepository,
|
required SettingsRepository settingsRepository,
|
||||||
|
required StoreService storeService,
|
||||||
bool shouldBuffer = true,
|
bool shouldBuffer = true,
|
||||||
}) async {
|
}) async {
|
||||||
final instance = LogService._(logRepository, settingsRepository, shouldBuffer);
|
final instance = LogService._(logRepository, settingsRepository, storeService, shouldBuffer);
|
||||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||||
final level = instance._settingsRepository.appConfig.logLevel;
|
final level = instance._settingsRepository.appConfig.logLevel;
|
||||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
LogService._(this._logRepository, this._settingsRepository, this._shouldBuffer) {
|
LogService._(this._logRepository, this._settingsRepository, this._storeService, this._shouldBuffer) {
|
||||||
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,12 +88,12 @@ class LogService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final record = LogMessage(
|
final record = LogMessage(
|
||||||
message: r.message,
|
message: _redact(r.message),
|
||||||
level: r.level.toLogLevel(),
|
level: r.level.toLogLevel(),
|
||||||
createdAt: r.time,
|
createdAt: r.time,
|
||||||
logger: r.loggerName,
|
logger: r.loggerName,
|
||||||
error: r.error?.toString(),
|
error: r.error == null ? null : _redactError(r.error!),
|
||||||
stack: r.stackTrace?.toString(),
|
stack: r.stackTrace == null ? null : _redact(r.stackTrace.toString()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_shouldBuffer) {
|
if (_shouldBuffer) {
|
||||||
@@ -100,6 +114,64 @@ class LogService {
|
|||||||
return [..._msgBuffer.reversed, ...logsFromDb];
|
return [..._msgBuffer.reversed, ...logsFromDb];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _redact(String text) {
|
||||||
|
var redacted = text.replaceAllMapped(_schemeAndHost, (match) => _urlLabel(Uri.tryParse(match[0]!)));
|
||||||
|
final accessToken = _storeService.tryGet(StoreKey.accessToken);
|
||||||
|
if (accessToken != null) {
|
||||||
|
redacted = redacted.replaceAll(accessToken, '[ACCESS TOKEN]');
|
||||||
|
}
|
||||||
|
// as a header map prints it, so a short value like "1" can't match other text
|
||||||
|
for (final header in _settingsRepository.appConfig.network.customHeaders.entries) {
|
||||||
|
redacted = redacted.replaceAll(
|
||||||
|
RegExp('${RegExp.escape(header.key)}:\\s+${RegExp.escape(header.value)}'),
|
||||||
|
'${header.key}: [HEADER VALUE]',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _redactError(Object error) {
|
||||||
|
final redacted = _redact(error.toString());
|
||||||
|
final httpError = error is ApiException ? error.innerException : error;
|
||||||
|
if (httpError is! ClientException) {
|
||||||
|
return redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok_http also prints the host and its ip on their own: "Failed to connect to host/203.0.113.9:443"
|
||||||
|
final url = httpError.uri;
|
||||||
|
final withoutHost = url == null || url.host.isEmpty
|
||||||
|
? redacted
|
||||||
|
: redacted.replaceAll(RegExp('\\b${RegExp.escape(url.host)}\\b'), _urlLabel(url));
|
||||||
|
return withoutHost.replaceAll(_ipAddress, '[IP ADDRESS]');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _urlLabel(Uri? url) {
|
||||||
|
if (url == null) {
|
||||||
|
return '[OTHER URL]';
|
||||||
|
}
|
||||||
|
|
||||||
|
// a server url that is also a switching entry gets the entry's label
|
||||||
|
final network = _settingsRepository.appConfig.network;
|
||||||
|
for (var i = 0; i < network.externalEndpointList.length; i++) {
|
||||||
|
if (_isSameAddress(url, network.externalEndpointList[i])) {
|
||||||
|
return '[EXTERNAL ENDPOINT ${i + 1}]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_isSameAddress(url, network.localEndpoint)) {
|
||||||
|
return '[LOCAL ENDPOINT]';
|
||||||
|
}
|
||||||
|
if (_isSameAddress(url, _storeService.tryGet(StoreKey.serverUrl)) ||
|
||||||
|
_isSameAddress(url, _storeService.tryGet(StoreKey.serverEndpoint))) {
|
||||||
|
return '[SERVER URL]';
|
||||||
|
}
|
||||||
|
return '[OTHER URL]';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSameAddress(Uri url, String? savedUrl) {
|
||||||
|
final saved = savedUrl == null ? null : Uri.tryParse(savedUrl);
|
||||||
|
return saved != null && saved.scheme == url.scheme && saved.host == url.host && saved.port == url.port;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearLogs() async {
|
Future<void> clearLogs() async {
|
||||||
_flushTimer?.cancel();
|
_flushTimer?.cancel();
|
||||||
_flushTimer = null;
|
_flushTimer = null;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ void main() async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
|
Logger('main').severe('Failed to start', error, stack);
|
||||||
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||||
import 'package:immich_mobile/theme/theme_data.dart';
|
import 'package:immich_mobile/theme/theme_data.dart';
|
||||||
import 'package:immich_mobile/utils/fallback_localizations.dart';
|
import 'package:immich_mobile/utils/fallback_localizations.dart';
|
||||||
@@ -78,9 +79,12 @@ class BootstrapErrorWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||||
|
child: _BottomPanel(startupError: '$error\n\n$stack'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -93,7 +97,9 @@ class BootstrapErrorWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BottomPanel extends StatefulWidget {
|
class _BottomPanel extends StatefulWidget {
|
||||||
const _BottomPanel();
|
final String startupError;
|
||||||
|
|
||||||
|
const _BottomPanel({required this.startupError});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_BottomPanel> createState() => _BottomPanelState();
|
State<_BottomPanel> createState() => _BottomPanelState();
|
||||||
@@ -157,8 +163,8 @@ class _BottomPanelState extends State<_BottomPanel> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_ActionLink(
|
_ActionLink(
|
||||||
icon: Icons.chat_bubble_outline,
|
icon: Icons.chat_bubble_outline,
|
||||||
@@ -173,6 +179,11 @@ class _BottomPanelState extends State<_BottomPanel> {
|
|||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_ActionLink(
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
label: context.t.share_logs,
|
||||||
|
onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError),
|
||||||
|
),
|
||||||
if (!_cleared)
|
if (!_cleared)
|
||||||
_ActionLink(
|
_ActionLink(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
@@ -288,21 +299,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
unawaited(
|
unawaited(ref.read(authProvider.notifier).setOpenApiServiceEndpoint().whenComplete(() => resumeSession()));
|
||||||
ref
|
|
||||||
.read(authProvider.notifier)
|
|
||||||
.setOpenApiServiceEndpoint()
|
|
||||||
.then(logConnectionInfo)
|
|
||||||
.whenComplete(() => resumeSession()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void logConnectionInfo(String? endpoint) {
|
|
||||||
if (endpoint == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Resuming session at $endpoint");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resumeSession() async {
|
Future<void> resumeSession() async {
|
||||||
@@ -356,7 +353,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (exception) {
|
onError: (exception) {
|
||||||
log.severe('Failed to update auth info with access token: $accessToken');
|
log.severe('Failed to update auth info', exception);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,21 +417,57 @@ final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>(
|
|||||||
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith);
|
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class _QuickAccessItem {
|
||||||
|
final Widget leading;
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
|
|
||||||
|
const _QuickAccessItem({required this.leading, required this.title, required this.onTap, this.contentPadding});
|
||||||
|
}
|
||||||
|
|
||||||
class _QuickAccessButtonList extends ConsumerWidget {
|
class _QuickAccessButtonList extends ConsumerWidget {
|
||||||
const _QuickAccessButtonList();
|
const _QuickAccessButtonList();
|
||||||
|
|
||||||
|
static const _tileRadius = 20.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider);
|
final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider);
|
||||||
final partners = partnerSharedWithAsync.valueOrNull ?? [];
|
final partners = partnerSharedWithAsync.valueOrNull ?? [];
|
||||||
|
|
||||||
|
final items = [
|
||||||
|
_QuickAccessItem(
|
||||||
|
leading: const Icon(Icons.folder_outlined, size: 26),
|
||||||
|
title: context.t.folders,
|
||||||
|
onTap: () => context.pushRoute(FolderRoute()),
|
||||||
|
),
|
||||||
|
_QuickAccessItem(
|
||||||
|
leading: const Icon(Icons.lock_outline_rounded, size: 26),
|
||||||
|
title: context.t.locked_folder,
|
||||||
|
onTap: () => context.pushRoute(const LockedFolderRoute()),
|
||||||
|
),
|
||||||
|
_QuickAccessItem(
|
||||||
|
leading: const Icon(Icons.group_outlined, size: 26),
|
||||||
|
title: context.t.partners,
|
||||||
|
onTap: () => context.pushRoute(const PartnerRoute()),
|
||||||
|
),
|
||||||
|
for (final partner in partners)
|
||||||
|
_QuickAccessItem(
|
||||||
|
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
|
||||||
|
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||||
|
title: context.t.partner_list_user_photos(user: partner.name),
|
||||||
|
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
|
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
|
||||||
sliver: SliverToBoxAdapter(
|
sliver: SliverToBoxAdapter(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
|
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
borderRadius: const BorderRadius.all(Radius.circular(_tileRadius)),
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
context.colorScheme.primary.withAlpha(10),
|
context.colorScheme.primary.withAlpha(10),
|
||||||
@@ -442,83 +478,33 @@ class _QuickAccessButtonList extends ConsumerWidget {
|
|||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ListView(
|
child: ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
itemCount: items.length,
|
||||||
ListTile(
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
final isFirst = index == 0;
|
||||||
|
final isLast = index == items.length - 1;
|
||||||
|
return ListTile(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: const Radius.circular(20),
|
topLeft: Radius.circular(isFirst ? _tileRadius : 0),
|
||||||
topRight: const Radius.circular(20),
|
topRight: Radius.circular(isFirst ? _tileRadius : 0),
|
||||||
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
|
bottomLeft: Radius.circular(isLast ? _tileRadius : 0),
|
||||||
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
|
bottomRight: Radius.circular(isLast ? _tileRadius : 0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
leading: const Icon(Icons.folder_outlined, size: 26),
|
contentPadding: item.contentPadding,
|
||||||
title: Text(
|
leading: item.leading,
|
||||||
context.t.folders,
|
title: Text(item.title, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
|
||||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
onTap: item.onTap,
|
||||||
),
|
);
|
||||||
onTap: () => context.pushRoute(FolderRoute()),
|
},
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.lock_outline_rounded, size: 26),
|
|
||||||
title: Text(
|
|
||||||
context.t.locked_folder,
|
|
||||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
onTap: () => context.pushRoute(const LockedFolderRoute()),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.group_outlined, size: 26),
|
|
||||||
title: Text(
|
|
||||||
context.t.partners,
|
|
||||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
onTap: () => context.pushRoute(const PartnerRoute()),
|
|
||||||
),
|
|
||||||
_PartnerList(partners: partners.toList()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PartnerList extends StatelessWidget {
|
|
||||||
const _PartnerList({required this.partners});
|
|
||||||
|
|
||||||
final List<Partner> partners;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ListView.builder(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: partners.length,
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final partner = partners[index];
|
|
||||||
final isLastItem = index == partners.length - 1;
|
|
||||||
return ListTile(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
|
|
||||||
bottomRight: Radius.circular(isLastItem ? 20 : 0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
|
|
||||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
|
||||||
title: Text(
|
|
||||||
context.t.partner_list_user_photos(user: partner.name),
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
|
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
|
||||||
final isBottomWidgetVisible =
|
final isBottomWidgetVisible =
|
||||||
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
|
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
|
||||||
|
final smoothScrubbing = ref.watch(appConfigProvider.select((config) => config.timeline.smoothScrubbing));
|
||||||
|
final snapToMonth = widget.snapToMonth && !smoothScrubbing;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !isMultiSelectEnabled,
|
canPop: !isMultiSelectEnabled,
|
||||||
@@ -409,7 +411,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
final Widget timeline;
|
final Widget timeline;
|
||||||
if (widget.withScrubber) {
|
if (widget.withScrubber) {
|
||||||
timeline = Scrubber(
|
timeline = Scrubber(
|
||||||
snapToMonth: widget.snapToMonth,
|
snapToMonth: snapToMonth,
|
||||||
layoutSegments: segments,
|
layoutSegments: segments,
|
||||||
timelineHeight: maxHeight,
|
timelineHeight: maxHeight,
|
||||||
topPadding: topPadding,
|
topPadding: topPadding,
|
||||||
|
|||||||
@@ -84,8 +84,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
// Needs to be logged in
|
// Needs to be logged in
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
// switch endpoint if needed
|
// switch endpoint if needed
|
||||||
final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
|
await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
|
||||||
_log.info("Using server URL: $endpoint");
|
|
||||||
|
|
||||||
await _ref.read(serverInfoProvider.notifier).getServerVersion();
|
await _ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
|||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||||
@@ -21,7 +20,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
|||||||
final AppRouter router;
|
final AppRouter router;
|
||||||
final ForegroundUploadService _foregroundUploadService;
|
final ForegroundUploadService _foregroundUploadService;
|
||||||
final ShareIntentService _shareIntentService;
|
final ShareIntentService _shareIntentService;
|
||||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
|
||||||
|
|
||||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||||
|
|
||||||
@@ -74,8 +72,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
|||||||
onSuccess: (fileId, _) {
|
onSuccess: (fileId, _) {
|
||||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||||
},
|
},
|
||||||
onError: (fileId, errorMessage) {
|
onError: (fileId, _) {
|
||||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
|
||||||
_updateStatus(fileId, UploadStatus.failed);
|
_updateStatus(fileId, UploadStatus.failed);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -221,8 +221,6 @@ class BackupNotifier extends StateNotifier<BackupState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
|
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
|
||||||
_logger.severe("Upload failed for $localAssetId: $errorMessage");
|
|
||||||
|
|
||||||
final currentItem = state.uploadItems[localAssetId];
|
final currentItem = state.uploadItems[localAssetId];
|
||||||
if (currentItem != null) {
|
if (currentItem != null) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class UploadRepository {
|
|||||||
} on RequestAbortedException {
|
} on RequestAbortedException {
|
||||||
rethrow;
|
rethrow;
|
||||||
} on ClientException catch (error) {
|
} on ClientException catch (error) {
|
||||||
logger.warning("Upload $logContext failed before a response, resending once: $error");
|
logger.warning("Upload $logContext failed before a response, resending once", error);
|
||||||
response = await client.send(buildRequest());
|
response = await client.send(buildRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ class UploadRepository {
|
|||||||
logger.warning("Upload $logContext was cancelled");
|
logger.warning("Upload $logContext was cancelled");
|
||||||
return UploadResult.cancelled();
|
return UploadResult.cancelled();
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
logger.warning("Error uploading $logContext: $error: $stackTrace");
|
logger.warning("Error uploading $logContext", error, stackTrace);
|
||||||
return UploadResult.error(errorMessage: error.toString());
|
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: error.toString(), error: error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +166,7 @@ class UploadResult {
|
|||||||
final String? remoteAssetId;
|
final String? remoteAssetId;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
|
final Object? error;
|
||||||
|
|
||||||
const UploadResult({
|
const UploadResult({
|
||||||
required this.isSuccess,
|
required this.isSuccess,
|
||||||
@@ -173,6 +174,7 @@ class UploadResult {
|
|||||||
this.remoteAssetId,
|
this.remoteAssetId,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.statusCode,
|
this.statusCode,
|
||||||
|
this.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UploadResult.success({required String remoteAssetId}) {
|
factory UploadResult.success({required String remoteAssetId}) {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ class AuthService {
|
|||||||
final localEndpoint = _authRepository.getLocalEndpoint();
|
final localEndpoint = _authRepository.getLocalEndpoint();
|
||||||
if (localEndpoint != null) {
|
if (localEndpoint != null) {
|
||||||
await _apiService.resolveAndSetEndpoint(localEndpoint);
|
await _apiService.resolveAndSetEndpoint(localEndpoint);
|
||||||
|
_log.info("Using local endpoint");
|
||||||
return localEndpoint;
|
return localEndpoint;
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
@@ -181,9 +182,11 @@ class AuthService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final endpoint in endpointList) {
|
for (var i = 0; i < endpointList.length; i++) {
|
||||||
try {
|
try {
|
||||||
return await _apiService.resolveAndSetEndpoint(endpoint.url);
|
final url = await _apiService.resolveAndSetEndpoint(endpointList[i].url);
|
||||||
|
_log.info("Using external endpoint ${i + 1}");
|
||||||
|
return url;
|
||||||
} on ApiException catch (error) {
|
} on ApiException catch (error) {
|
||||||
_log.severe("Cannot resolve endpoint", error);
|
_log.severe("Cannot resolve endpoint", error);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ class ForegroundUploadService {
|
|||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
onSuccess?.call(fileId, result.remoteAssetId!);
|
onSuccess?.call(fileId, result.remoteAssetId!);
|
||||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||||
|
_logger.warning("Error(${result.statusCode}) uploading $fileId", result.error ?? result.errorMessage);
|
||||||
onError?.call(fileId, result.errorMessage!);
|
onError?.call(fileId, result.errorMessage!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -395,7 +396,8 @@ class ForegroundUploadService {
|
|||||||
} else if (result.errorMessage != null) {
|
} else if (result.errorMessage != null) {
|
||||||
_logger.severe(
|
_logger.severe(
|
||||||
() =>
|
() =>
|
||||||
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
|
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt}",
|
||||||
|
result.error ?? result.errorMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:immich_mobile/data/db/logger/database.dart';
|
||||||
|
import 'package:immich_mobile/data/db/main/database.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
@@ -15,15 +19,37 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
|
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
|
||||||
/// and generate a csv file.
|
/// and generate a csv file.
|
||||||
abstract final class ImmichLogger {
|
abstract final class ImmichLogger {
|
||||||
static Future<void> shareLogs(BuildContext context) async {
|
// the startup error screen can show before the log service starts, and then the startup error was not logged
|
||||||
|
static Future<List<LogMessage>> _read(String? startupError) async {
|
||||||
|
try {
|
||||||
|
return await LogService.I.getMessages();
|
||||||
|
} on LoggerUnInitializedException {
|
||||||
|
final connection = await openSqliteConnection(name: 'immich_logs');
|
||||||
|
final logs = DriftLogger.sqlite(connection);
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
// if the log service had started, main would have logged this already
|
||||||
|
if (startupError != null)
|
||||||
|
LogMessage(message: startupError, level: LogLevel.severe, createdAt: DateTime.now()),
|
||||||
|
...await LogRepository(logs).getAll(),
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
await logs.close();
|
||||||
|
await connection.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> shareLogs(BuildContext context, {String? startupError}) async {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final dateTime = DateTime.now().toIso8601String();
|
final dateTime = DateTime.now().toIso8601String();
|
||||||
final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
|
final safeTimestamp = dateTime.replaceAll(':', '-');
|
||||||
|
final filePath = '${tempDir.path}/Immich_log_$safeTimestamp.log';
|
||||||
final logFile = await File(filePath).create();
|
final logFile = await File(filePath).create();
|
||||||
final io = logFile.openWrite();
|
final io = logFile.openWrite();
|
||||||
try {
|
try {
|
||||||
// Write messages
|
// Write messages
|
||||||
for (final m in await LogService.I.getMessages()) {
|
for (final m in await _read(startupError)) {
|
||||||
final created = m.createdAt;
|
final created = m.createdAt;
|
||||||
final level = m.level.name.padRight(8);
|
final level = m.level.name.padRight(8);
|
||||||
final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
|
final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class OAuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final authUrl = dto?.url;
|
final authUrl = dto?.url;
|
||||||
log.info('Received Authorization URL: $authUrl');
|
log.info('Received Authorization URL');
|
||||||
|
|
||||||
return authUrl;
|
return authUrl;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ class OAuthService {
|
|||||||
Future<LoginResponseDto?> oAuthLogin(String oauthUrl, String state, String codeVerifier) async {
|
Future<LoginResponseDto?> oAuthLogin(String oauthUrl, String state, String codeVerifier) async {
|
||||||
String result = await FlutterWebAuth2.authenticate(url: oauthUrl, callbackUrlScheme: callbackUrlScheme);
|
String result = await FlutterWebAuth2.authenticate(url: oauthUrl, callbackUrlScheme: callbackUrlScheme);
|
||||||
|
|
||||||
log.info('Received OAuth callback: $result');
|
log.info('Received OAuth callback');
|
||||||
|
|
||||||
if (result.startsWith('app.immich:/oauth-callback')) {
|
if (result.startsWith('app.immich:/oauth-callback')) {
|
||||||
result = result.replaceAll('app.immich:/oauth-callback', 'app.immich:///oauth-callback');
|
result = result.replaceAll('app.immich:/oauth-callback', 'app.immich:///oauth-callback');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/constants/constants.dart';
|
|||||||
import 'package:immich_mobile/data/data_controller.dart';
|
import 'package:immich_mobile/data/data_controller.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
@@ -72,6 +73,7 @@ abstract final class Bootstrap {
|
|||||||
await LogService.init(
|
await LogService.init(
|
||||||
logRepository: LogRepository(dataController.logDb),
|
logRepository: LogRepository(dataController.logDb),
|
||||||
settingsRepository: settingsRepo,
|
settingsRepository: settingsRepo,
|
||||||
|
storeService: StoreService.I,
|
||||||
shouldBuffer: shouldBufferLogs,
|
shouldBuffer: shouldBufferLogs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
|||||||
'ffmpeg': {
|
'ffmpeg': {
|
||||||
'realtime': {'enabled': false},
|
'realtime': {'enabled': false},
|
||||||
},
|
},
|
||||||
|
'passkey': {'enabled': false},
|
||||||
},
|
},
|
||||||
'ServerConfigDto': {
|
'ServerConfigDto': {
|
||||||
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
@@ -55,12 +56,16 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
|||||||
'PeopleResponse': {'updateStrategy': 'everyone'},
|
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||||
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
||||||
'ServerVersionResponseDto': {'featureLevel': 0},
|
'ServerVersionResponseDto': {'featureLevel': 0},
|
||||||
|
'WorkflowResponseDto': {'logging': false},
|
||||||
|
'AdminConfigDto': {
|
||||||
|
'passkey': {'enabled': false, 'domain': null, 'additionalDomains': const []},
|
||||||
|
},
|
||||||
'UserConfigDto': {
|
'UserConfigDto': {
|
||||||
'notifications': {
|
'notifications': {
|
||||||
'smtp': {'enabled': false},
|
'smtp': {'enabled': false},
|
||||||
},
|
},
|
||||||
|
'passkey': {'enabled': false},
|
||||||
},
|
},
|
||||||
'WorkflowResponseDto': {'logging': false},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ignore: unused-code
|
// ignore: unused-code
|
||||||
|
|||||||
@@ -36,64 +36,63 @@ class _MesmerizingSliverAppBarState extends ConsumerState<MesmerizingSliverAppBa
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||||
|
Widget hideWhenSelecting(Widget child) => IgnorePointer(
|
||||||
|
ignoring: isMultiSelectEnabled,
|
||||||
|
child: AnimatedOpacity(duration: Durations.medium1, opacity: isMultiSelectEnabled ? 0 : 1, child: child),
|
||||||
|
);
|
||||||
|
|
||||||
return isMultiSelectEnabled
|
return SliverAppBar(
|
||||||
? SliverToBoxAdapter(
|
expandedHeight: 300.0,
|
||||||
child: switch (_scrollProgress) {
|
floating: false,
|
||||||
< 0.8 => const SizedBox(height: 120),
|
pinned: true,
|
||||||
_ => const SizedBox(height: 352),
|
snap: false,
|
||||||
},
|
elevation: 0,
|
||||||
)
|
leading: hideWhenSelecting(
|
||||||
: SliverAppBar(
|
IconButton(
|
||||||
expandedHeight: 300.0,
|
icon: Icon(
|
||||||
floating: false,
|
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||||
pinned: true,
|
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
||||||
snap: false,
|
shadows: [
|
||||||
elevation: 0,
|
_scrollProgress < 0.95
|
||||||
leading: IconButton(
|
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
||||||
icon: Icon(
|
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
],
|
||||||
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
),
|
||||||
shadows: [
|
onPressed: () {
|
||||||
_scrollProgress < 0.95
|
context.pop();
|
||||||
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
},
|
||||||
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
),
|
||||||
],
|
),
|
||||||
),
|
flexibleSpace: Builder(
|
||||||
onPressed: () {
|
builder: (context) {
|
||||||
context.pop();
|
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||||
},
|
final scrollProgress = _calculateScrollProgress(settings);
|
||||||
),
|
|
||||||
flexibleSpace: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
|
||||||
final scrollProgress = _calculateScrollProgress(settings);
|
|
||||||
|
|
||||||
// Update scroll progress for the leading button
|
// Update scroll progress for the leading button
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && _scrollProgress != scrollProgress) {
|
if (mounted && _scrollProgress != scrollProgress) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scrollProgress = scrollProgress;
|
_scrollProgress = scrollProgress;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: AnimatedSwitcher(
|
title: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: scrollProgress > 0.95
|
child: scrollProgress > 0.95
|
||||||
? Text(
|
? Text(
|
||||||
widget.title,
|
widget.title,
|
||||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
|
||||||
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||||
|
Widget hideWhenSelecting(Widget child) => IgnorePointer(
|
||||||
|
ignoring: isMultiSelectEnabled,
|
||||||
|
child: AnimatedOpacity(duration: Durations.medium1, opacity: isMultiSelectEnabled ? 0 : 1, child: child),
|
||||||
|
);
|
||||||
final Color? actionIconColor = Color.lerp(Colors.white, context.primaryColor, _scrollProgress);
|
final Color? actionIconColor = Color.lerp(Colors.white, context.primaryColor, _scrollProgress);
|
||||||
final List<Shadow> actionIconShadows = [
|
final List<Shadow> actionIconShadows = [
|
||||||
if (_scrollProgress < 0.95)
|
if (_scrollProgress < 0.95)
|
||||||
@@ -62,74 +66,74 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
|
|||||||
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||||
];
|
];
|
||||||
|
|
||||||
return isMultiSelectEnabled
|
return SliverAppBar(
|
||||||
? SliverToBoxAdapter(
|
expandedHeight: 300.0,
|
||||||
child: switch (_scrollProgress) {
|
floating: false,
|
||||||
< 0.8 => const SizedBox(height: 120),
|
pinned: true,
|
||||||
_ => const SizedBox(height: 352),
|
snap: false,
|
||||||
},
|
elevation: 0,
|
||||||
)
|
leading: hideWhenSelecting(
|
||||||
: SliverAppBar(
|
IconButton(
|
||||||
expandedHeight: 300.0,
|
icon: Icon(
|
||||||
floating: false,
|
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
||||||
pinned: true,
|
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
||||||
snap: false,
|
shadows: [
|
||||||
elevation: 0,
|
_scrollProgress < 0.95
|
||||||
leading: IconButton(
|
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
||||||
icon: Icon(
|
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||||
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
|
|
||||||
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
|
|
||||||
shadows: [
|
|
||||||
_scrollProgress < 0.95
|
|
||||||
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
|
|
||||||
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
context.pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
|
||||||
onPressed: widget.onShowOptions,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
flexibleSpace: Builder(
|
),
|
||||||
builder: (context) {
|
onPressed: () {
|
||||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
context.pop();
|
||||||
final scrollProgress = _calculateScrollProgress(settings);
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
hideWhenSelecting(
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
|
||||||
|
onPressed: widget.onShowOptions,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
flexibleSpace: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||||
|
final scrollProgress = _calculateScrollProgress(settings);
|
||||||
|
|
||||||
// Update scroll progress for the leading button
|
// Update scroll progress for the leading button
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && _scrollProgress != scrollProgress) {
|
if (mounted && _scrollProgress != scrollProgress) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scrollProgress = scrollProgress;
|
_scrollProgress = scrollProgress;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: AnimatedSwitcher(
|
title: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: scrollProgress > 0.95
|
child: scrollProgress > 0.95
|
||||||
? Text(
|
? Text(
|
||||||
widget.person.name,
|
widget.person.name,
|
||||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
background: _ExpandedBackground(
|
background: IgnorePointer(
|
||||||
scrollProgress: scrollProgress,
|
ignoring: isMultiSelectEnabled,
|
||||||
person: widget.person,
|
child: _ExpandedBackground(
|
||||||
onNameTap: widget.onNameTap,
|
scrollProgress: scrollProgress,
|
||||||
onBirthdayTap: widget.onBirthdayTap,
|
person: widget.person,
|
||||||
),
|
onNameTap: widget.onNameTap,
|
||||||
);
|
onBirthdayTap: widget.onBirthdayTap,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class AssetListSettings extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator)));
|
final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator)));
|
||||||
|
final smoothScrubbing = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.smoothScrubbing)));
|
||||||
|
|
||||||
final assetListSetting = [
|
final assetListSetting = [
|
||||||
SettingsSwitchListTile(
|
SettingsSwitchListTile(
|
||||||
@@ -27,6 +28,16 @@ class AssetListSettings extends HookConsumerWidget {
|
|||||||
ref.invalidate(settingsProvider);
|
ref.invalidate(settingsProvider);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
SettingsSwitchListTile(
|
||||||
|
valueNotifier: smoothScrubbing,
|
||||||
|
title: context.t.theme_setting_asset_list_smooth_scrubbing_title,
|
||||||
|
subtitle: context.t.theme_setting_asset_list_smooth_scrubbing_subtitle,
|
||||||
|
onChanged: (value) {
|
||||||
|
unawaited(ref.read(settingsProvider).write(.timelineSmoothScrubbing, value));
|
||||||
|
ref.invalidate(appSettingsServiceProvider);
|
||||||
|
ref.invalidate(settingsProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
const LayoutSettings(),
|
const LayoutSettings(),
|
||||||
const GroupSettings(),
|
const GroupSettings(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ depends = ["//:open-api-dart"]
|
|||||||
run = [
|
run = [
|
||||||
# This receives passed `--enforce-lockfile` args
|
# This receives passed `--enforce-lockfile` args
|
||||||
"flutter pub get",
|
"flutter pub get",
|
||||||
|
{ task = "install:build-runner-patch" },
|
||||||
{ task = "install:ios" },
|
{ task = "install:ios" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -198,6 +199,11 @@ fi
|
|||||||
cd ios && pod install
|
cd ios && pod install
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
[tasks."install:build-runner-patch"]
|
||||||
|
description = "Patch build_runner to prevent crashes when sources are deleted"
|
||||||
|
hide = true
|
||||||
|
run = "node ./scripts/build_runner_patch.mjs"
|
||||||
|
|
||||||
[tasks."analyze:dart"]
|
[tasks."analyze:dart"]
|
||||||
description = "Run Dart analysis"
|
description = "Run Dart analysis"
|
||||||
hide = true
|
hide = true
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 3.3.3-local.6+3030404
|
version: 3.3.5-local.7+3030500
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.12.0 <4.0.0'
|
sdk: '>=3.12.0 <4.0.0'
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Prevents build_runner crashing when a source file is deleted from a path dependency, which commonly occurs to `generated/openapi` on branch switch
|
||||||
|
//
|
||||||
|
// Fixed upstream in 2.15.3 (https://github.com/dart-lang/build/pull/5022), but we can't take that dependency due to Dart analyzer being locked
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const packageConfig = new URL('../.dart_tool/package_config.json', import.meta.url);
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(packageConfig, 'utf8'));
|
||||||
|
const buildRunner = new URL(config.packages.find(({ name }) => name === 'build_runner').rootUri);
|
||||||
|
|
||||||
|
const file = new URL(`${buildRunner}/lib/src/build_plan/build_plan.dart`);
|
||||||
|
|
||||||
|
const buggy = 'for (final id in deletedOutputs) {';
|
||||||
|
const fixed = 'for (final id in deletedOutputs.difference(buildInputs.deletedSources.build().asSet())) {';
|
||||||
|
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
if (source.includes(buggy)) {
|
||||||
|
writeFileSync(file, source.replace(buggy, fixed));
|
||||||
|
console.log('build_runner patch: stopped deleting removed sources from path dependencies');
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' show ClientException;
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/data/db/main/database.dart';
|
||||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:openapi/api.dart' show ApiException;
|
||||||
|
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../test_utils.dart';
|
import '../../test_utils.dart';
|
||||||
@@ -30,6 +40,16 @@ void main() {
|
|||||||
late LogService sut;
|
late LogService sut;
|
||||||
late LogRepository mockLogRepo;
|
late LogRepository mockLogRepo;
|
||||||
late MockSettingsRepository mockSettingsRepository;
|
late MockSettingsRepository mockSettingsRepository;
|
||||||
|
late Drift db;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||||
|
await StoreService.init(storeRepository: StoreRepository(db), listenUpdates: false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
mockLogRepo = MockLogRepository();
|
mockLogRepo = MockLogRepository();
|
||||||
@@ -45,11 +65,16 @@ void main() {
|
|||||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||||
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
|
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
|
||||||
|
|
||||||
sut = await LogService.create(logRepository: mockLogRepo, settingsRepository: mockSettingsRepository);
|
sut = await LogService.create(
|
||||||
|
logRepository: mockLogRepo,
|
||||||
|
settingsRepository: mockSettingsRepository,
|
||||||
|
storeService: StoreService.I,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await sut.dispose();
|
await sut.dispose();
|
||||||
|
await StoreService.I.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
group("Log Service Init:", () {
|
group("Log Service Init:", () {
|
||||||
@@ -87,6 +112,7 @@ void main() {
|
|||||||
sut = await LogService.create(
|
sut = await LogService.create(
|
||||||
logRepository: mockLogRepo,
|
logRepository: mockLogRepo,
|
||||||
settingsRepository: mockSettingsRepository,
|
settingsRepository: mockSettingsRepository,
|
||||||
|
storeService: StoreService.I,
|
||||||
shouldBuffer: true,
|
shouldBuffer: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,6 +131,7 @@ void main() {
|
|||||||
sut = await LogService.create(
|
sut = await LogService.create(
|
||||||
logRepository: mockLogRepo,
|
logRepository: mockLogRepo,
|
||||||
settingsRepository: mockSettingsRepository,
|
settingsRepository: mockSettingsRepository,
|
||||||
|
storeService: StoreService.I,
|
||||||
shouldBuffer: true,
|
shouldBuffer: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,6 +153,7 @@ void main() {
|
|||||||
sut = await LogService.create(
|
sut = await LogService.create(
|
||||||
logRepository: mockLogRepo,
|
logRepository: mockLogRepo,
|
||||||
settingsRepository: mockSettingsRepository,
|
settingsRepository: mockSettingsRepository,
|
||||||
|
storeService: StoreService.I,
|
||||||
shouldBuffer: false,
|
shouldBuffer: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,6 +188,7 @@ void main() {
|
|||||||
sut = await LogService.create(
|
sut = await LogService.create(
|
||||||
logRepository: mockLogRepo,
|
logRepository: mockLogRepo,
|
||||||
settingsRepository: mockSettingsRepository,
|
settingsRepository: mockSettingsRepository,
|
||||||
|
storeService: StoreService.I,
|
||||||
shouldBuffer: true,
|
shouldBuffer: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -176,4 +205,144 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group("Log Service Redaction:", () {
|
||||||
|
const accessToken = 'Kq3Zr8WbN2xYp5aLm7cT9vQ4sD6fH1jE0gU2iO5wA8y';
|
||||||
|
|
||||||
|
test('Replaces urls, the access token and header values when a line is logged', () async {
|
||||||
|
await StoreService.I.put(StoreKey.serverUrl, 'https://immich.example.com');
|
||||||
|
await StoreService.I.put(StoreKey.serverEndpoint, 'https://api.example.com/api');
|
||||||
|
await StoreService.I.put(StoreKey.accessToken, accessToken);
|
||||||
|
when(() => mockSettingsRepository.appConfig).thenReturn(
|
||||||
|
const AppConfig(
|
||||||
|
network: NetworkConfig(
|
||||||
|
localEndpoint: 'http://192.168.1.5:2283',
|
||||||
|
externalEndpointList: ['https://immich.example.com', 'http://nas.lan:2283'],
|
||||||
|
customHeaders: {'X-Proxy-Key': 'cf-secret-abcdef0123', 'X-Debug': '1'},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rows = {
|
||||||
|
'Resuming session at https://immich.example.com/api': 'Resuming session at [EXTERNAL ENDPOINT 1]/api',
|
||||||
|
'GET http://nas.lan:2283/api/server/ping': 'GET [EXTERNAL ENDPOINT 2]/api/server/ping',
|
||||||
|
'GET http://192.168.1.5:2283/api/server/ping': 'GET [LOCAL ENDPOINT]/api/server/ping',
|
||||||
|
'Using server URL: https://api.example.com/api': 'Using server URL: [SERVER URL]/api',
|
||||||
|
'Received Authorization URL: https://auth.example.org/authorize':
|
||||||
|
'Received Authorization URL: [OTHER URL]/authorize',
|
||||||
|
'Causing statement: INSERT INTO "store_entity", parameters: 11, $accessToken':
|
||||||
|
'Causing statement: INSERT INTO "store_entity", parameters: 11, [ACCESS TOKEN]',
|
||||||
|
'Could not enqueue task DownloadTask{headers: {X-Proxy-Key: cf-secret-abcdef0123, X-Debug: 1}, retries: 1}':
|
||||||
|
'Could not enqueue task DownloadTask{headers: {X-Proxy-Key: [HEADER VALUE], X-Debug: [HEADER VALUE]}, retries: 1}',
|
||||||
|
'X-Proxy-Key:\tcf-secret-abcdef0123': 'X-Proxy-Key: [HEADER VALUE]',
|
||||||
|
'Uploaded 1 assets in 1.2s': 'Uploaded 1 assets in 1.2s',
|
||||||
|
};
|
||||||
|
for (final row in rows.keys) {
|
||||||
|
Logger('Test').info(row);
|
||||||
|
}
|
||||||
|
await sut.flush();
|
||||||
|
|
||||||
|
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||||
|
expect(stored.map((m) => m.message), rows.values);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hides the host and ip in http client errors', () async {
|
||||||
|
when(
|
||||||
|
() => mockSettingsRepository.appConfig,
|
||||||
|
).thenReturn(const AppConfig(network: NetworkConfig(externalEndpointList: ['http://immich:2283'])));
|
||||||
|
final connectError = ClientException(
|
||||||
|
'java.net.ConnectException: Failed to connect to immich/192.168.1.5:2283',
|
||||||
|
Uri.parse('http://immich:2283/api/server/ping'),
|
||||||
|
);
|
||||||
|
final unknownHostError = ClientException(
|
||||||
|
'java.net.UnknownHostException: Unable to resolve host "typo.example.org": No address associated with hostname',
|
||||||
|
Uri.parse('https://alice:secret@typo.example.org/api/users/me'),
|
||||||
|
);
|
||||||
|
|
||||||
|
Logger('Test').severe(
|
||||||
|
'Error while checking server availability',
|
||||||
|
ApiException.withInner(
|
||||||
|
400,
|
||||||
|
'HTTP connection failed: GET /server/ping',
|
||||||
|
connectError,
|
||||||
|
StackTrace.fromString('#0 ApiService._isEndpointAvailable (package:immich_mobile/services/api.service.dart)'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Logger('Test').severe('Error validating auxiliary endpoint', unknownHostError);
|
||||||
|
await sut.flush();
|
||||||
|
|
||||||
|
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||||
|
expect(
|
||||||
|
stored.first.error,
|
||||||
|
'ApiException 400: HTTP connection failed: GET /server/ping (Inner exception: ClientException: '
|
||||||
|
'java.net.ConnectException: Failed to connect to [EXTERNAL ENDPOINT 1]/[IP ADDRESS]:2283, '
|
||||||
|
'uri=[EXTERNAL ENDPOINT 1]/api/server/ping)\n\n'
|
||||||
|
'#0 ApiService._isEndpointAvailable (package:immich_mobile/services/api.service.dart)',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stored.last.error,
|
||||||
|
'ClientException: java.net.UnknownHostException: Unable to resolve host "[OTHER URL]": '
|
||||||
|
'No address associated with hostname, uri=[OTHER URL]/api/users/me',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hides ipv6 addresses in http client errors', () async {
|
||||||
|
const rows = {
|
||||||
|
'java.net.ConnectException: Failed to connect to immich.example.com/[2001:db8::5]:443':
|
||||||
|
'java.net.ConnectException: Failed to connect to [OTHER URL]/[IP ADDRESS]:443',
|
||||||
|
'java.net.ConnectException: Failed to connect to immich.example.com/2001:db8::5:443':
|
||||||
|
'java.net.ConnectException: Failed to connect to [OTHER URL]/[IP ADDRESS]',
|
||||||
|
'java.net.SocketTimeoutException: failed to connect to immich.example.com/2001:db8::5 (port 443) '
|
||||||
|
'from /2001:db8:1234:5678:9abc:def0:1234:5678 (port 41234) after 30000ms':
|
||||||
|
'java.net.SocketTimeoutException: failed to connect to [OTHER URL]/[IP ADDRESS] (port 443) '
|
||||||
|
'from /[IP ADDRESS] (port 41234) after 30000ms',
|
||||||
|
'java.net.ConnectException: Failed to connect to /[fe80::5%wlan0]:2283':
|
||||||
|
'java.net.ConnectException: Failed to connect to /[IP ADDRESS]:2283',
|
||||||
|
'at 12:34:56': 'at 12:34:56',
|
||||||
|
'0:00:01.500000': '0:00:01.500000',
|
||||||
|
'deadbeef': 'deadbeef',
|
||||||
|
'#0 ApiClient.invokeAPI (package:openapi/api_client.dart:131:7)':
|
||||||
|
'#0 ApiClient.invokeAPI (package:openapi/api_client.dart:131:7)',
|
||||||
|
};
|
||||||
|
for (final row in rows.keys) {
|
||||||
|
Logger('Test').severe('Upload error', ClientException(row, Uri.parse('https://immich.example.com/api/assets')));
|
||||||
|
}
|
||||||
|
await sut.flush();
|
||||||
|
|
||||||
|
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||||
|
expect(stored.map((m) => m.error), [
|
||||||
|
for (final row in rows.values) 'ClientException: $row, uri=[OTHER URL]/api/assets',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Keeps the labels of a line logged before a logout', () async {
|
||||||
|
final settings = await SettingsRepository.ensureInitialized(db);
|
||||||
|
addTearDown(SettingsRepository.reset);
|
||||||
|
await settings.write(SettingsKey.networkExternalEndpointList, ['https://immich.example.com']);
|
||||||
|
await StoreService.I.put(StoreKey.accessToken, accessToken);
|
||||||
|
await sut.dispose();
|
||||||
|
sut = await LogService.create(
|
||||||
|
logRepository: mockLogRepo,
|
||||||
|
settingsRepository: settings,
|
||||||
|
storeService: StoreService.I,
|
||||||
|
);
|
||||||
|
|
||||||
|
Logger('Test').info('Resuming session at https://immich.example.com/api with $accessToken');
|
||||||
|
// the store and settings changes of AuthService.clearLocalData
|
||||||
|
await StoreService.I.delete(StoreKey.accessToken);
|
||||||
|
await settings.clear(const [
|
||||||
|
.networkAutoEndpointSwitching,
|
||||||
|
.networkPreferredWifiName,
|
||||||
|
.networkLocalEndpoint,
|
||||||
|
.networkExternalEndpointList,
|
||||||
|
]);
|
||||||
|
Logger('Test').info('Resuming session at https://immich.example.com/api');
|
||||||
|
await sut.flush();
|
||||||
|
|
||||||
|
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
|
||||||
|
expect(stored.map((m) => m.message), [
|
||||||
|
'Resuming session at [EXTERNAL ENDPOINT 1]/api with [ACCESS TOKEN]',
|
||||||
|
'Resuming session at [OTHER URL]/api',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ void main() {
|
|||||||
logService = await LogService.init(
|
logService = await LogService.init(
|
||||||
logRepository: logRepository,
|
logRepository: logRepository,
|
||||||
settingsRepository: settingsRepository,
|
settingsRepository: settingsRepository,
|
||||||
|
storeService: MockStoreService(),
|
||||||
shouldBuffer: false,
|
shouldBuffer: false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class _MockHttpClient extends Mock implements http.Client {}
|
class _MockHttpClient extends Mock implements http.Client {}
|
||||||
@@ -124,4 +125,21 @@ void main() {
|
|||||||
expect(result.errorMessage, 'boom');
|
expect(result.errorMessage, 'boom');
|
||||||
verify(() => client.send(any())).called(1);
|
verify(() => client.send(any())).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a transport failure is logged as the error, not in the message', () async {
|
||||||
|
final error = http.ClientException('Failed to connect to /203.0.113.9:2283');
|
||||||
|
stubSend((_) => throw error);
|
||||||
|
final level = Logger.root.level;
|
||||||
|
addTearDown(() => Logger.root.level = level);
|
||||||
|
Logger.root.level = Level.ALL;
|
||||||
|
final records = <LogRecord>[];
|
||||||
|
final subscription = sut.logger.onRecord.listen(records.add);
|
||||||
|
|
||||||
|
final result = await upload();
|
||||||
|
await subscription.cancel();
|
||||||
|
|
||||||
|
expect(records.map((r) => r.error), [error, error]);
|
||||||
|
expect(records.map((r) => r.message), everyElement(isNot(contains('203.0.113.9'))));
|
||||||
|
expect(result.error, error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/data/db/logger/database.dart';
|
||||||
|
import 'package:immich_mobile/data/db/main/database.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
|
import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Shares the startup error and the stored logs before the log service starts', (tester) async {
|
||||||
|
final dir = Directory.systemTemp.createTempSync('immich-logs');
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||||
|
(_) async => dir.path,
|
||||||
|
);
|
||||||
|
late String shared;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/share'),
|
||||||
|
(call) async {
|
||||||
|
shared = File(((call.arguments as Map)['paths'] as List).single as String).readAsStringSync();
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
final connection = await openSqliteConnection(name: 'immich_logs');
|
||||||
|
final logs = DriftLogger.sqlite(connection);
|
||||||
|
await LogRepository(logs).insertAll([
|
||||||
|
LogMessage(
|
||||||
|
message: 'Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api',
|
||||||
|
level: LogLevel.warning,
|
||||||
|
createdAt: DateTime(2026, 10, 15),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await logs.close();
|
||||||
|
await connection.close();
|
||||||
|
|
||||||
|
await ImmichLogger.shareLogs(
|
||||||
|
tester.element(find.byType(SizedBox)),
|
||||||
|
startupError: 'SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shared.split('\n').first,
|
||||||
|
endsWith('| SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com |'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shared,
|
||||||
|
contains('| Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api |'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9708,6 +9708,415 @@
|
|||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/passkeys": {
|
||||||
|
"get": {
|
||||||
|
"description": "Search the passkeys of the current user.",
|
||||||
|
"operationId": "searchPasskeys",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by passkey ID",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Search passkeys",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "passkey.read",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/passkeys/authentication/finish": {
|
||||||
|
"post": {
|
||||||
|
"description": "Verify the response from the authenticator and receive a session token.",
|
||||||
|
"operationId": "finishAuthentication",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyAuthenticationFinishDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LoginResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Finish passkey authentication",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/passkeys/authentication/start": {
|
||||||
|
"post": {
|
||||||
|
"description": "Generate the options used by the browser to sign in with a passkey.",
|
||||||
|
"operationId": "startAuthentication",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyAuthenticationOptionsDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Start passkey authentication",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/passkeys/registration/finish": {
|
||||||
|
"post": {
|
||||||
|
"description": "Verify the response from the authenticator and save the new passkey for the current user.",
|
||||||
|
"operationId": "finishRegistration",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyRegistrationFinishDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Finish passkey registration",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/passkeys/registration/start": {
|
||||||
|
"post": {
|
||||||
|
"description": "Generate the options used by the browser to create a new passkey for the current user.",
|
||||||
|
"operationId": "startRegistration",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyRegistrationOptionsDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Start passkey registration",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/passkeys/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Delete a passkey by its ID. The current user must own this passkey.",
|
||||||
|
"operationId": "deletePasskey",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Delete a passkey",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "passkey.delete",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"description": "Retrieve a passkey by its ID. The current user must own this passkey.",
|
||||||
|
"operationId": "getPasskey",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Retrieve a passkey",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "passkey.read",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"description": "Update the name of a passkey by its ID. The current user must own this passkey.",
|
||||||
|
"operationId": "updatePasskey",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyUpdateDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Update a passkey",
|
||||||
|
"tags": [
|
||||||
|
"Passkeys"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v4.0.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "passkey.update",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/people": {
|
"/people": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"description": "Bulk delete a list of people at once.",
|
"description": "Bulk delete a list of people at once.",
|
||||||
@@ -18211,7 +18620,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "3.3.3",
|
"version": "3.3.5",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -18315,6 +18724,10 @@
|
|||||||
"name": "Partners",
|
"name": "Partners",
|
||||||
"description": "A partner is a link with another user that allows sharing of assets between two users."
|
"description": "A partner is a link with another user that allows sharing of assets between two users."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Passkeys",
|
||||||
|
"description": "A passkey is a WebAuthn credential that can be used to login without a password."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "People",
|
"name": "People",
|
||||||
"description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job."
|
"description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job."
|
||||||
@@ -18619,6 +19032,9 @@
|
|||||||
"oauth": {
|
"oauth": {
|
||||||
"$ref": "#/components/schemas/AdminConfigOAuthDto"
|
"$ref": "#/components/schemas/AdminConfigOAuthDto"
|
||||||
},
|
},
|
||||||
|
"passkey": {
|
||||||
|
"$ref": "#/components/schemas/AdminConfigPasskeyDto"
|
||||||
|
},
|
||||||
"passwordLogin": {
|
"passwordLogin": {
|
||||||
"$ref": "#/components/schemas/AdminConfigPasswordLoginDto"
|
"$ref": "#/components/schemas/AdminConfigPasswordLoginDto"
|
||||||
},
|
},
|
||||||
@@ -18659,6 +19075,7 @@
|
|||||||
"nightlyTasks",
|
"nightlyTasks",
|
||||||
"notifications",
|
"notifications",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
"passkey",
|
||||||
"passwordLogin",
|
"passwordLogin",
|
||||||
"reverseGeocoding",
|
"reverseGeocoding",
|
||||||
"server",
|
"server",
|
||||||
@@ -19521,6 +19938,34 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AdminConfigPasskeyDto": {
|
||||||
|
"properties": {
|
||||||
|
"additionalDomains": {
|
||||||
|
"description": "Additional domains",
|
||||||
|
"items": {
|
||||||
|
"format": "uri",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"description": "Passkey domain",
|
||||||
|
"format": "uri",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"description": "Enabled",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"additionalDomains",
|
||||||
|
"domain",
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AdminConfigPasswordLoginDto": {
|
"AdminConfigPasswordLoginDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
@@ -22790,6 +23235,7 @@
|
|||||||
"AssetGenerateThumbnailsQueueAll",
|
"AssetGenerateThumbnailsQueueAll",
|
||||||
"AssetGenerateThumbnails",
|
"AssetGenerateThumbnails",
|
||||||
"AuditTableCleanup",
|
"AuditTableCleanup",
|
||||||
|
"AuthChallengeCleanup",
|
||||||
"DatabaseBackup",
|
"DatabaseBackup",
|
||||||
"FacialRecognitionQueueAll",
|
"FacialRecognitionQueueAll",
|
||||||
"FacialRecognition",
|
"FacialRecognition",
|
||||||
@@ -24918,6 +25364,127 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PasskeyAuthenticationFinishDto": {
|
||||||
|
"properties": {
|
||||||
|
"response": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"description": "Response returned by @simplewebauthn/browser's `startAuthentication()`",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"description": "Credential ID",
|
||||||
|
"format": "base64url",
|
||||||
|
"pattern": "^[A-Za-z0-9_-]*$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"properties": {
|
||||||
|
"userHandle": {
|
||||||
|
"description": "User handle",
|
||||||
|
"format": "base64url",
|
||||||
|
"pattern": "^[A-Za-z0-9_-]*$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"response"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"response"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PasskeyAuthenticationOptionsDto": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"description": "Response returned by @simplewebauthn/server's `generateAuthenticationOptions`",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PasskeyRegistrationFinishDto": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"description": "Passkey name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"description": "Response returned by @simplewebauthn/browser's `startAuthentication()`"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"response"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PasskeyRegistrationOptionsDto": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"description": "Response returned by @simplewebauthn/server's `generateRegistrationOptions`",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PasskeyResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"description": "Creation date",
|
||||||
|
"example": "2024-01-01T00:00:00.000Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Passkey ID",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Passkey name",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"description": "Last update date",
|
||||||
|
"example": "2024-01-01T00:00:00.000Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"usedAt": {
|
||||||
|
"description": "Last used date",
|
||||||
|
"example": "2024-01-01T00:00:00.000Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"createdAt",
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"updatedAt",
|
||||||
|
"usedAt"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PasskeyUpdateDto": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"description": "Passkey name",
|
||||||
|
"minLength": 1,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PeopleDeleteDto": {
|
"PeopleDeleteDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"ids": {
|
"ids": {
|
||||||
@@ -25280,6 +25847,9 @@
|
|||||||
"partner.read",
|
"partner.read",
|
||||||
"partner.update",
|
"partner.update",
|
||||||
"partner.delete",
|
"partner.delete",
|
||||||
|
"passkey.read",
|
||||||
|
"passkey.update",
|
||||||
|
"passkey.delete",
|
||||||
"person.create",
|
"person.create",
|
||||||
"person.read",
|
"person.read",
|
||||||
"person.update",
|
"person.update",
|
||||||
@@ -25963,6 +26533,9 @@
|
|||||||
"oauth": {
|
"oauth": {
|
||||||
"$ref": "#/components/schemas/PublicConfigOAuthDto"
|
"$ref": "#/components/schemas/PublicConfigOAuthDto"
|
||||||
},
|
},
|
||||||
|
"passkey": {
|
||||||
|
"$ref": "#/components/schemas/PublicConfigPasskeyDto"
|
||||||
|
},
|
||||||
"passwordLogin": {
|
"passwordLogin": {
|
||||||
"$ref": "#/components/schemas/PublicConfigPasswordLoginDto"
|
"$ref": "#/components/schemas/PublicConfigPasswordLoginDto"
|
||||||
},
|
},
|
||||||
@@ -25974,6 +26547,7 @@
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"map",
|
"map",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
"passkey",
|
||||||
"passwordLogin",
|
"passwordLogin",
|
||||||
"server"
|
"server"
|
||||||
],
|
],
|
||||||
@@ -26048,6 +26622,18 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PublicConfigPasskeyDto": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"description": "Enabled",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PublicConfigPasswordLoginDto": {
|
"PublicConfigPasswordLoginDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
@@ -33173,6 +33759,9 @@
|
|||||||
"oauth": {
|
"oauth": {
|
||||||
"$ref": "#/components/schemas/UserConfigOAuthDto"
|
"$ref": "#/components/schemas/UserConfigOAuthDto"
|
||||||
},
|
},
|
||||||
|
"passkey": {
|
||||||
|
"$ref": "#/components/schemas/UserConfigPasskeyDto"
|
||||||
|
},
|
||||||
"passwordLogin": {
|
"passwordLogin": {
|
||||||
"$ref": "#/components/schemas/UserConfigPasswordLoginDto"
|
"$ref": "#/components/schemas/UserConfigPasswordLoginDto"
|
||||||
},
|
},
|
||||||
@@ -33196,6 +33785,7 @@
|
|||||||
"map",
|
"map",
|
||||||
"notifications",
|
"notifications",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
"passkey",
|
||||||
"passwordLogin",
|
"passwordLogin",
|
||||||
"reverseGeocoding",
|
"reverseGeocoding",
|
||||||
"server",
|
"server",
|
||||||
@@ -33416,6 +34006,18 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"UserConfigPasskeyDto": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"description": "Enabled",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"UserConfigPasswordLoginDto": {
|
"UserConfigPasswordLoginDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
@@ -33710,11 +34312,6 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"description": "User name",
|
"description": "User name",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"deprecated": true,
|
|
||||||
"description": "User password (deprecated, use change password endpoint)",
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-monorepo",
|
"name": "immich-monorepo",
|
||||||
"version": "3.3.3",
|
"version": "3.3.5",
|
||||||
"description": "Monorepo for Immich",
|
"description": "Monorepo for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"format": "prettier --cache --check i18n/",
|
"format": "prettier --cache --check i18n/",
|
||||||
"format:fix": "prettier --cache --write --list-different i18n"
|
"format:fix": "prettier --cache --write --list-different i18n"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.27.1",
|
"packageManager": "pnpm@12.14.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10.0.0"
|
"pnpm": ">=10.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.21.0-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe AS core
|
FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e AS core
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY package* pnpm* .pnpmfile.cjs ./
|
COPY package* pnpm* .pnpmfile.cjs ./
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "3.3.3",
|
"version": "3.3.5",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^5.0.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"fastq": "^1.17.1",
|
"fastq": "^1.17.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
|||||||
@@ -200,6 +200,39 @@ describe('checkForDuplicates', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores accepted files that were not part of the request', async () => {
|
||||||
|
vi.mocked(checkBulkUpload).mockResolvedValue({
|
||||||
|
results: [
|
||||||
|
{ action: AssetUploadAction.Accept, id: testFilePath },
|
||||||
|
{ action: AssetUploadAction.Accept, id: '/etc/passwd' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
|
||||||
|
duplicates: [],
|
||||||
|
newFiles: [testFilePath],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores rejected files that were not part of the request', async () => {
|
||||||
|
vi.mocked(checkBulkUpload).mockResolvedValue({
|
||||||
|
results: [
|
||||||
|
{ action: AssetUploadAction.Accept, id: testFilePath },
|
||||||
|
{
|
||||||
|
action: AssetUploadAction.Reject,
|
||||||
|
id: '/etc/passwd',
|
||||||
|
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
|
||||||
|
reason: AssetRejectReason.Duplicate,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
|
||||||
|
duplicates: [],
|
||||||
|
newFiles: [testFilePath],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns results when check duplicates retry is successful', async () => {
|
it('returns results when check duplicates retry is successful', async () => {
|
||||||
let mocked = vi.mocked(checkBulkUpload);
|
let mocked = vi.mocked(checkBulkUpload);
|
||||||
for (let i = 1; i < retry; i++) {
|
for (let i = 1; i < retry; i++) {
|
||||||
|
|||||||
@@ -232,8 +232,14 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
|
|||||||
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
|
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
|
||||||
|
|
||||||
const results = response.results as AssetBulkUploadCheckResults;
|
const results = response.results as AssetBulkUploadCheckResults;
|
||||||
|
const requested = new Set(assets.map(({ id }) => id));
|
||||||
|
|
||||||
for (const { id: filepath, assetId, action } of results) {
|
for (const { id: filepath, assetId, action } of results) {
|
||||||
|
if (!requested.has(filepath)) {
|
||||||
|
console.warn(`Ignoring unexpected file in bulk upload check response: ${filepath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === AssetUploadAction.Accept) {
|
if (action === AssetUploadAction.Accept) {
|
||||||
newFiles.push(filepath);
|
newFiles.push(filepath);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.21.0-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe
|
FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
"oidc-provider": "^9.0.0",
|
"oidc-provider": "^9.0.0",
|
||||||
"tsx": "^4.20.6"
|
"tsx": "^4.20.6"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.27.1"
|
"packageManager": "pnpm@12.14.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"packageManager": "pnpm@11.27.1",
|
"packageManager": "pnpm@12.14.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@extism/js-pdk": "^1.1.1",
|
"@extism/js-pdk": "^1.1.1",
|
||||||
"@immich/sdk": "workspace:*",
|
"@immich/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -33,5 +33,5 @@
|
|||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.27.1"
|
"packageManager": "pnpm@12.14.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "3.3.3",
|
"version": "3.3.5",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -357,6 +357,14 @@ export type AdminConfigOAuthDto = {
|
|||||||
timeout: number;
|
timeout: number;
|
||||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
||||||
};
|
};
|
||||||
|
export type AdminConfigPasskeyDto = {
|
||||||
|
/** Additional domains */
|
||||||
|
additionalDomains: string[];
|
||||||
|
/** Passkey domain */
|
||||||
|
domain: string | null;
|
||||||
|
/** Enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
export type AdminConfigPasswordLoginDto = {
|
export type AdminConfigPasswordLoginDto = {
|
||||||
/** Enabled */
|
/** Enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -421,6 +429,7 @@ export type AdminConfigDto = {
|
|||||||
nightlyTasks: AdminConfigNightlyTasksDto;
|
nightlyTasks: AdminConfigNightlyTasksDto;
|
||||||
notifications: AdminConfigNotificationsDto;
|
notifications: AdminConfigNotificationsDto;
|
||||||
oauth: AdminConfigOAuthDto;
|
oauth: AdminConfigOAuthDto;
|
||||||
|
passkey: AdminConfigPasskeyDto;
|
||||||
passwordLogin: AdminConfigPasswordLoginDto;
|
passwordLogin: AdminConfigPasswordLoginDto;
|
||||||
reverseGeocoding: AdminConfigReverseGeocodingDto;
|
reverseGeocoding: AdminConfigReverseGeocodingDto;
|
||||||
server: AdminConfigServerDto;
|
server: AdminConfigServerDto;
|
||||||
@@ -1614,6 +1623,10 @@ export type UserConfigOAuthDto = {
|
|||||||
/** Enabled */
|
/** Enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
export type UserConfigPasskeyDto = {
|
||||||
|
/** Enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
export type UserConfigPasswordLoginDto = {
|
export type UserConfigPasswordLoginDto = {
|
||||||
/** Enabled */
|
/** Enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -1647,6 +1660,7 @@ export type UserConfigDto = {
|
|||||||
map: UserConfigMapDto;
|
map: UserConfigMapDto;
|
||||||
notifications: UserConfigNotificationsDto;
|
notifications: UserConfigNotificationsDto;
|
||||||
oauth: UserConfigOAuthDto;
|
oauth: UserConfigOAuthDto;
|
||||||
|
passkey: UserConfigPasskeyDto;
|
||||||
passwordLogin: UserConfigPasswordLoginDto;
|
passwordLogin: UserConfigPasswordLoginDto;
|
||||||
reverseGeocoding: UserConfigReverseGeocodingDto;
|
reverseGeocoding: UserConfigReverseGeocodingDto;
|
||||||
server: UserConfigServerDto;
|
server: UserConfigServerDto;
|
||||||
@@ -1997,6 +2011,47 @@ export type PartnerUpdateDto = {
|
|||||||
/** Show partner assets in timeline */
|
/** Show partner assets in timeline */
|
||||||
inTimeline: boolean;
|
inTimeline: boolean;
|
||||||
};
|
};
|
||||||
|
export type PasskeyResponseDto = {
|
||||||
|
/** Creation date */
|
||||||
|
createdAt: string;
|
||||||
|
/** Passkey ID */
|
||||||
|
id: string;
|
||||||
|
/** Passkey name */
|
||||||
|
name: string | null;
|
||||||
|
/** Last update date */
|
||||||
|
updatedAt: string;
|
||||||
|
/** Last used date */
|
||||||
|
usedAt: string | null;
|
||||||
|
};
|
||||||
|
export type PasskeyAuthenticationFinishDto = {
|
||||||
|
/** Response returned by @simplewebauthn/browser's `startAuthentication()` */
|
||||||
|
response: {
|
||||||
|
/** Credential ID */
|
||||||
|
id: string;
|
||||||
|
response: {
|
||||||
|
/** User handle */
|
||||||
|
userHandle?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type PasskeyAuthenticationOptionsDto = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
export type PasskeyRegistrationFinishDto = {
|
||||||
|
/** Passkey name */
|
||||||
|
name?: string;
|
||||||
|
/** Response returned by @simplewebauthn/browser's `startAuthentication()` */
|
||||||
|
response: any;
|
||||||
|
};
|
||||||
|
export type PasskeyRegistrationOptionsDto = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
export type PasskeyUpdateDto = {
|
||||||
|
/** Passkey name */
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
export type PeopleDeleteDto = {
|
export type PeopleDeleteDto = {
|
||||||
/** IDs to process */
|
/** IDs to process */
|
||||||
ids: string[];
|
ids: string[];
|
||||||
@@ -2198,6 +2253,10 @@ export type PublicConfigOAuthDto = {
|
|||||||
/** Enabled */
|
/** Enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
export type PublicConfigPasskeyDto = {
|
||||||
|
/** Enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
export type PublicConfigPasswordLoginDto = {
|
export type PublicConfigPasswordLoginDto = {
|
||||||
/** Enabled */
|
/** Enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -2210,6 +2269,7 @@ export type PublicConfigDto = {
|
|||||||
ffmpeg: PublicConfigFFmpegDto;
|
ffmpeg: PublicConfigFFmpegDto;
|
||||||
map: PublicConfigMapDto;
|
map: PublicConfigMapDto;
|
||||||
oauth: PublicConfigOAuthDto;
|
oauth: PublicConfigOAuthDto;
|
||||||
|
passkey: PublicConfigPasskeyDto;
|
||||||
passwordLogin: PublicConfigPasswordLoginDto;
|
passwordLogin: PublicConfigPasswordLoginDto;
|
||||||
server: PublicConfigServerDto;
|
server: PublicConfigServerDto;
|
||||||
};
|
};
|
||||||
@@ -3206,8 +3266,6 @@ export type UserUpdateMeDto = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
/** User name */
|
/** User name */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** User password (deprecated, use change password endpoint) */
|
|
||||||
password?: string;
|
|
||||||
};
|
};
|
||||||
export type OnboardingResponseDto = {
|
export type OnboardingResponseDto = {
|
||||||
/** Is user onboarded */
|
/** Is user onboarded */
|
||||||
@@ -6126,6 +6184,115 @@ export function updatePartner({ id, partnerUpdateDto }: {
|
|||||||
body: partnerUpdateDto
|
body: partnerUpdateDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Search passkeys
|
||||||
|
*/
|
||||||
|
export function searchPasskeys({ id }: {
|
||||||
|
id?: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: PasskeyResponseDto[];
|
||||||
|
}>(`/passkeys${QS.query(QS.explode({
|
||||||
|
id
|
||||||
|
}))}`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Finish passkey authentication
|
||||||
|
*/
|
||||||
|
export function finishAuthentication({ passkeyAuthenticationFinishDto }: {
|
||||||
|
passkeyAuthenticationFinishDto: PasskeyAuthenticationFinishDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 201;
|
||||||
|
data: LoginResponseDto;
|
||||||
|
}>("/passkeys/authentication/finish", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "POST",
|
||||||
|
body: passkeyAuthenticationFinishDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Start passkey authentication
|
||||||
|
*/
|
||||||
|
export function startAuthentication(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: PasskeyAuthenticationOptionsDto;
|
||||||
|
}>("/passkeys/authentication/start", {
|
||||||
|
...opts,
|
||||||
|
method: "POST"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Finish passkey registration
|
||||||
|
*/
|
||||||
|
export function finishRegistration({ passkeyRegistrationFinishDto }: {
|
||||||
|
passkeyRegistrationFinishDto: PasskeyRegistrationFinishDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 201;
|
||||||
|
data: PasskeyResponseDto;
|
||||||
|
}>("/passkeys/registration/finish", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "POST",
|
||||||
|
body: passkeyRegistrationFinishDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Start passkey registration
|
||||||
|
*/
|
||||||
|
export function startRegistration(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: PasskeyRegistrationOptionsDto;
|
||||||
|
}>("/passkeys/registration/start", {
|
||||||
|
...opts,
|
||||||
|
method: "POST"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Delete a passkey
|
||||||
|
*/
|
||||||
|
export function deletePasskey({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText(`/passkeys/${encodeURIComponent(id)}`, {
|
||||||
|
...opts,
|
||||||
|
method: "DELETE"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve a passkey
|
||||||
|
*/
|
||||||
|
export function getPasskey({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: PasskeyResponseDto;
|
||||||
|
}>(`/passkeys/${encodeURIComponent(id)}`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update a passkey
|
||||||
|
*/
|
||||||
|
export function updatePasskey({ id, passkeyUpdateDto }: {
|
||||||
|
id: string;
|
||||||
|
passkeyUpdateDto: PasskeyUpdateDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: PasskeyResponseDto;
|
||||||
|
}>(`/passkeys/${encodeURIComponent(id)}`, oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "PATCH",
|
||||||
|
body: passkeyUpdateDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Delete people
|
* Delete people
|
||||||
*/
|
*/
|
||||||
@@ -8221,6 +8388,9 @@ export enum Permission {
|
|||||||
PartnerRead = "partner.read",
|
PartnerRead = "partner.read",
|
||||||
PartnerUpdate = "partner.update",
|
PartnerUpdate = "partner.update",
|
||||||
PartnerDelete = "partner.delete",
|
PartnerDelete = "partner.delete",
|
||||||
|
PasskeyRead = "passkey.read",
|
||||||
|
PasskeyUpdate = "passkey.update",
|
||||||
|
PasskeyDelete = "passkey.delete",
|
||||||
PersonCreate = "person.create",
|
PersonCreate = "person.create",
|
||||||
PersonRead = "person.read",
|
PersonRead = "person.read",
|
||||||
PersonUpdate = "person.update",
|
PersonUpdate = "person.update",
|
||||||
@@ -8455,6 +8625,7 @@ export enum JobName {
|
|||||||
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
|
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
|
||||||
AssetGenerateThumbnails = "AssetGenerateThumbnails",
|
AssetGenerateThumbnails = "AssetGenerateThumbnails",
|
||||||
AuditTableCleanup = "AuditTableCleanup",
|
AuditTableCleanup = "AuditTableCleanup",
|
||||||
|
AuthChallengeCleanup = "AuthChallengeCleanup",
|
||||||
DatabaseBackup = "DatabaseBackup",
|
DatabaseBackup = "DatabaseBackup",
|
||||||
FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
|
FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
|
||||||
FacialRecognition = "FacialRecognition",
|
FacialRecognition = "FacialRecognition",
|
||||||
|
|||||||
Generated
+876
-430
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -73,5 +73,5 @@ shamefullyHoist: false
|
|||||||
verifyDepsBeforeRun: install
|
verifyDepsBeforeRun: install
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- '@immich/ui'
|
- '@immich/ui'
|
||||||
- '@immich/sql-tools@0.6.3'
|
- '@immich/sql-tools@0.6.3 || 0.6.6'
|
||||||
- '@nestjs/cli@12.0.1'
|
- '@nestjs/cli@12.0.1'
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202610131110@sha256:082dc5e2611953d63d7b30dc8427da37ff5cd1d932aa9e99ccc4a26298b5f7a5 AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
@@ -68,7 +68,7 @@ FROM plugin-sdk AS plugins
|
|||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.9.12@sha256:868b004119abb2e7c146618da63371ec65da40bfb0f38a0ec4128ca9e96c057a /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2026.10.15@sha256:0593528c637b0dae8a0acca66b5d49266cf262a811f06530d7273d3cc002f3a0 /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
COPY ./mise.toml ./mise.toml
|
COPY ./mise.toml ./mise.toml
|
||||||
COPY ./mise.lock ./mise.lock
|
COPY ./mise.lock ./mise.lock
|
||||||
@@ -86,7 +86,7 @@ RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
mise //:plugins
|
mise //:plugins
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202610131110@sha256:9d4ce4bfd72d77edddd3072fbca78b7f4307a32c8cbe75276349944dd6405be3
|
FROM ghcr.io/immich-app/base-server-prod:202610211607@sha256:e50a1b36e2638dce42981ddfa84c134246c48fb2ad290fab6acfc1bb9b2bd891
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202610131110@sha256:082dc5e2611953d63d7b30dc8427da37ff5cd1d932aa9e99ccc4a26298b5f7a5 AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS dev
|
||||||
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.9.12@sha256:868b004119abb2e7c146618da63371ec65da40bfb0f38a0ec4128ca9e96c057a /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2026.10.15@sha256:0593528c637b0dae8a0acca66b5d49266cf262a811f06530d7273d3cc002f3a0 /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||||
|
|||||||
+19
-17
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "3.3.3",
|
"version": "3.3.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@extism/extism": "2.0.0-rc13",
|
"@extism/extism": "2.0.0-rc13",
|
||||||
"@immich/plugin-sdk": "workspace:*",
|
"@immich/plugin-sdk": "workspace:*",
|
||||||
"@immich/sql-tools": "^0.6.3",
|
"@immich/sql-tools": "^0.6.6",
|
||||||
"@nestjs/bullmq": "^12.0.0",
|
"@nestjs/bullmq": "^12.0.0",
|
||||||
"@nestjs/common": "^12.0.0",
|
"@nestjs/common": "^12.0.0",
|
||||||
"@nestjs/core": "^12.0.0",
|
"@nestjs/core": "^12.0.0",
|
||||||
@@ -52,27 +52,29 @@
|
|||||||
"@nestjs/websockets": "^12.0.0",
|
"@nestjs/websockets": "^12.0.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.222.0",
|
"@opentelemetry/exporter-prometheus": "^0.223.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.222.0",
|
"@opentelemetry/instrumentation-http": "^0.223.0",
|
||||||
"@opentelemetry/instrumentation-ioredis": "^0.70.0",
|
"@opentelemetry/instrumentation-ioredis": "^0.71.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.68.0",
|
"@opentelemetry/instrumentation-nestjs-core": "^0.70.0",
|
||||||
"@opentelemetry/instrumentation-pg": "^0.74.0",
|
"@opentelemetry/instrumentation-pg": "^0.76.0",
|
||||||
"@opentelemetry/resources": "^2.0.1",
|
"@opentelemetry/resources": "^2.0.1",
|
||||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||||
"@opentelemetry/sdk-node": "^0.222.0",
|
"@opentelemetry/sdk-node": "^0.223.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
|
"@simplewebauthn/server": "14.0.3",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"archiver": "^7.0.0",
|
"aaguids": "^0.0.71",
|
||||||
|
"archiver": "^8.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"bullmq": "^6.0.0",
|
"bullmq": "^6.0.0",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^5.0.0",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^2.0.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cron": "4.4.0",
|
"cron": "4.4.0",
|
||||||
"exiftool-vendored": "^35.20.0",
|
"exiftool-vendored": "^39.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
@@ -80,12 +82,12 @@
|
|||||||
"geo-tz": "^8.0.0",
|
"geo-tz": "^8.0.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^6.0.0",
|
||||||
"jose": "^6.0.0",
|
"jose": "^6.0.0",
|
||||||
"js-yaml": "^5.0.0",
|
"js-yaml": "^5.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"kysely": "^0.29.5",
|
"kysely": "^0.29.5",
|
||||||
"kysely-postgres-js": "^3.0.0",
|
"kysely-postgres-js": "^5.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"mnemonist": "^0.40.3",
|
"mnemonist": "^0.40.3",
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
"nestjs-kysely": "3.1.4",
|
"nestjs-kysely": "3.1.4",
|
||||||
"nestjs-otel": "^8.0.0",
|
"nestjs-otel": "^8.0.0",
|
||||||
"nestjs-zod": "^5.5.0",
|
"nestjs-zod": "^5.5.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^10.0.0",
|
||||||
"openid-client": "^6.3.3",
|
"openid-client": "^6.3.3",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
@@ -124,7 +126,7 @@
|
|||||||
"@nestjs/schematics": "^12.0.0",
|
"@nestjs/schematics": "^12.0.0",
|
||||||
"@nestjs/testing": "^12.0.0",
|
"@nestjs/testing": "^12.0.0",
|
||||||
"@swc/core": "^1.4.14",
|
"@swc/core": "^1.4.14",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^8.0.0",
|
||||||
"@types/async-lock": "^1.4.2",
|
"@types/async-lock": "^1.4.2",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/body-parser": "^1.19.6",
|
"@types/body-parser": "^1.19.6",
|
||||||
@@ -165,7 +167,7 @@
|
|||||||
"testcontainers": "^12.0.0",
|
"testcontainers": "^12.0.0",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"unplugin-swc": "^1.4.5",
|
"unplugin-swc": "^2.0.0",
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const reverseGeocodeMaxDistance = 25_000;
|
|||||||
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
|
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
|
||||||
export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
||||||
|
|
||||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
export const excludePaths = ['/.well-known/immich', '/.well-known/webauthn', '/custom.css', '/favicon.ico'];
|
||||||
|
|
||||||
export const FACE_THUMBNAIL_SIZE = 250;
|
export const FACE_THUMBNAIL_SIZE = 250;
|
||||||
|
|
||||||
@@ -175,6 +175,7 @@ export const endpointTags: Record<ApiTag, string> = {
|
|||||||
'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.',
|
'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.',
|
||||||
[ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.',
|
[ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.',
|
||||||
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
|
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
|
||||||
|
[ApiTag.Passkeys]: 'A passkey is a WebAuthn credential that can be used to login without a password.',
|
||||||
[ApiTag.People]:
|
[ApiTag.People]:
|
||||||
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
|
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
|
||||||
[ApiTag.Plugins]:
|
[ApiTag.Plugins]:
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { AppController } from 'src/controllers/app.controller.js';
|
import { AppController } from 'src/controllers/app.controller.js';
|
||||||
|
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||||
import { SystemConfigService } from 'src/services/system-config.service.js';
|
import { SystemConfigService } from 'src/services/system-config.service.js';
|
||||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
|
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
|
||||||
|
|
||||||
describe(AppController.name, () => {
|
describe(AppController.name, () => {
|
||||||
let ctx: ControllerContext;
|
let ctx: ControllerContext;
|
||||||
|
const passkeyService = mockBaseService(PasskeyService);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
ctx = await controllerSetup(AppController, [
|
ctx = await controllerSetup(AppController, [
|
||||||
{ provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) },
|
{ provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) },
|
||||||
|
{ provide: PasskeyService, useValue: passkeyService },
|
||||||
]);
|
]);
|
||||||
return () => ctx.close();
|
return () => ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ctx.reset();
|
ctx.reset();
|
||||||
|
passkeyService.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /.well-known/immich', () => {
|
describe('GET /.well-known/immich', () => {
|
||||||
@@ -34,6 +38,13 @@ describe(AppController.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /.well-known/webauthn', () => {
|
||||||
|
it('should not be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).post('/.well-known/webauthn');
|
||||||
|
expect(ctx.authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /custom.css', () => {
|
describe('GET /custom.css', () => {
|
||||||
it('should not be an authenticated route', async () => {
|
it('should not be an authenticated route', async () => {
|
||||||
await request(ctx.getHttpServer()).post('/custom.css');
|
await request(ctx.getHttpServer()).post('/custom.css');
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Controller, Get, Header } from '@nestjs/common';
|
import { Controller, Get, Header } from '@nestjs/common';
|
||||||
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||||
import { Authenticated } from 'src/middleware/auth.guard.js';
|
import { Authenticated } from 'src/middleware/auth.guard.js';
|
||||||
|
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||||
import { SystemConfigService } from 'src/services/system-config.service.js';
|
import { SystemConfigService } from 'src/services/system-config.service.js';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private service: SystemConfigService) {}
|
constructor(
|
||||||
|
private service: SystemConfigService,
|
||||||
|
private passkeyService: PasskeyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@ApiExcludeEndpoint()
|
@ApiExcludeEndpoint()
|
||||||
@Get('.well-known/immich')
|
@Get('.well-known/immich')
|
||||||
@@ -18,6 +22,13 @@ export class AppController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiExcludeEndpoint()
|
||||||
|
@Get('.well-known/webauthn')
|
||||||
|
@Authenticated({ public: true })
|
||||||
|
getWebAuthnWellKnown() {
|
||||||
|
return this.passkeyService.getWellKnown();
|
||||||
|
}
|
||||||
|
|
||||||
@ApiExcludeEndpoint()
|
@ApiExcludeEndpoint()
|
||||||
@Get('custom.css')
|
@Get('custom.css')
|
||||||
@Authenticated({ public: true })
|
@Authenticated({ public: true })
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { NotificationAdminController } from 'src/controllers/notification-admin.
|
|||||||
import { NotificationController } from 'src/controllers/notification.controller.js';
|
import { NotificationController } from 'src/controllers/notification.controller.js';
|
||||||
import { OAuthController } from 'src/controllers/oauth.controller.js';
|
import { OAuthController } from 'src/controllers/oauth.controller.js';
|
||||||
import { PartnerController } from 'src/controllers/partner.controller.js';
|
import { PartnerController } from 'src/controllers/partner.controller.js';
|
||||||
|
import { PasskeyController } from 'src/controllers/passkey.controller.js';
|
||||||
import { PersonController } from 'src/controllers/person.controller.js';
|
import { PersonController } from 'src/controllers/person.controller.js';
|
||||||
import { PluginController } from 'src/controllers/plugin.controller.js';
|
import { PluginController } from 'src/controllers/plugin.controller.js';
|
||||||
import { QueueController } from 'src/controllers/queue.controller.js';
|
import { QueueController } from 'src/controllers/queue.controller.js';
|
||||||
@@ -73,6 +74,7 @@ export const controllers = [
|
|||||||
NotificationAdminController,
|
NotificationAdminController,
|
||||||
OAuthController,
|
OAuthController,
|
||||||
PartnerController,
|
PartnerController,
|
||||||
|
PasskeyController,
|
||||||
PersonController,
|
PersonController,
|
||||||
PluginController,
|
PluginController,
|
||||||
QueueController,
|
QueueController,
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ describe(OAuthController.name, () => {
|
|||||||
);
|
);
|
||||||
expect(service.authorize).not.toHaveBeenCalled();
|
expect(service.authorize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set the state and code verifier cookies when the server generates them', async () => {
|
||||||
|
service.authorize.mockResolvedValue({ url: 'http://idp/auth', state: 'server-state', codeVerifier: 'verifier' });
|
||||||
|
|
||||||
|
const { status, headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/oauth/authorize')
|
||||||
|
.send({ redirectUri: 'http://immich/auth/login' });
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
const cookies = (headers['set-cookie'] as unknown as string[]) ?? [];
|
||||||
|
expect(cookies).toEqual([
|
||||||
|
expect.stringContaining('immich_oauth_state=server-state'),
|
||||||
|
expect.stringContaining('immich_oauth_code_verifier=verifier'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set cookies for client supplied state and code challenge', async () => {
|
||||||
|
service.authorize.mockResolvedValue({ url: 'http://idp/auth', state: 'client-state', codeVerifier: null });
|
||||||
|
|
||||||
|
const { status, headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/oauth/authorize')
|
||||||
|
.send({ redirectUri: 'http://immich/auth/login', state: 'client-state', codeChallenge: 'challenge' });
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(headers['set-cookie']).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /oauth/callback', () => {
|
describe('POST /oauth/callback', () => {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class OAuthController {
|
|||||||
{
|
{
|
||||||
isSecure: loginDetails.isSecure,
|
isSecure: loginDetails.isSecure,
|
||||||
values: [
|
values: [
|
||||||
{ key: ImmichCookie.OAuthState, value: state },
|
{ key: ImmichCookie.OAuthState, value: dto.state ? null : state },
|
||||||
{ key: ImmichCookie.OAuthCodeVerifier, value: codeVerifier },
|
{ key: ImmichCookie.OAuthCodeVerifier, value: codeVerifier },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import { PasskeyController } from 'src/controllers/passkey.controller.js';
|
||||||
|
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||||
|
import { factory } from 'test/small.factory.js';
|
||||||
|
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
|
||||||
|
|
||||||
|
const registrationResponse = {
|
||||||
|
id: 'Y3JlZGVudGlhbC1pZA',
|
||||||
|
rawId: 'credential-id',
|
||||||
|
type: 'public-key',
|
||||||
|
clientExtensionResults: {},
|
||||||
|
response: {
|
||||||
|
clientDataJSON: 'client-data',
|
||||||
|
attestationObject: 'attestation-object',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const authenticationResponse = {
|
||||||
|
id: 'Y3JlZGVudGlhbC1pZA',
|
||||||
|
rawId: 'credential-id',
|
||||||
|
type: 'public-key',
|
||||||
|
clientExtensionResults: {},
|
||||||
|
response: {
|
||||||
|
clientDataJSON: 'client-data',
|
||||||
|
authenticatorData: 'authenticator-data',
|
||||||
|
signature: 'signature',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(PasskeyController.name, () => {
|
||||||
|
let ctx: ControllerContext;
|
||||||
|
const service = mockBaseService(PasskeyService);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await controllerSetup(PasskeyController, [{ provide: PasskeyService, useValue: service }]);
|
||||||
|
return () => ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.resetAllMocks();
|
||||||
|
ctx.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /passkeys/registration/start', () => {
|
||||||
|
it('should respond with 200', async () => {
|
||||||
|
service.startRegistration.mockResolvedValue({ challenge: 'challenge' } as any);
|
||||||
|
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/registration/start');
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(headers['set-cookie']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /passkeys/registration/finish', () => {
|
||||||
|
it('should require a response', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.post('/passkeys/registration/finish')
|
||||||
|
.send({ name: 'Passkey' });
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.validationError([
|
||||||
|
{ path: ['response'], message: 'Invalid input: expected nonoptional, received undefined' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond with 201', async () => {
|
||||||
|
service.finishRegistration.mockResolvedValue({} as any);
|
||||||
|
const { status, headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/passkeys/registration/finish')
|
||||||
|
.send({ name: 'Passkey', response: registrationResponse });
|
||||||
|
expect(status).toEqual(201);
|
||||||
|
expect(headers['set-cookie']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /passkeys/authentication/start', () => {
|
||||||
|
it('should respond with 200', async () => {
|
||||||
|
service.startAuthentication.mockResolvedValue({ challenge: 'challenge' } as any);
|
||||||
|
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/authentication/start');
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(headers['set-cookie']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /passkeys/authentication/finish', () => {
|
||||||
|
it('should require a response', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).post('/passkeys/authentication/finish').send({});
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.validationError([
|
||||||
|
{ path: ['response'], message: 'Invalid input: expected object, received undefined' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond with 201 and set the auth cookies', async () => {
|
||||||
|
service.finishAuthentication.mockResolvedValue({ accessToken: 'token' } as any);
|
||||||
|
const { status, headers } = await request(ctx.getHttpServer())
|
||||||
|
.post('/passkeys/authentication/finish')
|
||||||
|
.send({ response: authenticationResponse });
|
||||||
|
expect(status).toEqual(201);
|
||||||
|
expect(headers['set-cookie']).toEqual([
|
||||||
|
expect.stringContaining('immich_access_token=token; Max-Age=34560000; Path=/; Expires='),
|
||||||
|
expect.stringContaining('immich_auth_type=passkey; Max-Age=34560000; Path=/; Expires='),
|
||||||
|
expect.stringContaining('immich_is_authenticated=true; Max-Age=34560000; Path=/; Expires='),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /passkeys', () => {
|
||||||
|
it('should reject an invalid id', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys').query({ id: '123' });
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /passkeys/:id', () => {
|
||||||
|
it('should require a valid id', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys/123');
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /passkeys/:id', () => {
|
||||||
|
it('should require a name', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).patch(`/passkeys/${factory.uuid()}`).send({});
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.validationError([
|
||||||
|
{ path: ['name'], message: 'Invalid input: expected string, received undefined' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an empty name', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.patch(`/passkeys/${factory.uuid()}`)
|
||||||
|
.send({ name: '' });
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(
|
||||||
|
factory.responses.validationError([
|
||||||
|
{ path: ['name'], message: 'Too small: expected string to have >=1 characters' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(service.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a null name', async () => {
|
||||||
|
const auth = factory.auth();
|
||||||
|
const id = factory.uuid();
|
||||||
|
ctx.authenticate.mockResolvedValue(auth);
|
||||||
|
service.update.mockResolvedValue({} as any);
|
||||||
|
const { status } = await request(ctx.getHttpServer()).patch(`/passkeys/${id}`).send({ name: null });
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
expect(service.update).toHaveBeenCalledWith(auth, id, { name: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /passkeys/:id', () => {
|
||||||
|
it('should require a valid id', async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).delete('/passkeys/123');
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { LoginDetails } from 'src/services/auth.service.js';
|
||||||
|
import { Endpoint, HistoryBuilder } from 'src/decorators.js';
|
||||||
|
import { type AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js';
|
||||||
|
import {
|
||||||
|
PasskeyAuthenticationFinishDto,
|
||||||
|
PasskeyAuthenticationOptionsDto,
|
||||||
|
PasskeyRegistrationFinishDto,
|
||||||
|
PasskeyRegistrationOptionsDto,
|
||||||
|
PasskeyResponseDto,
|
||||||
|
PasskeySearchDto,
|
||||||
|
PasskeyUpdateDto,
|
||||||
|
} from 'src/dtos/passkey.dto.js';
|
||||||
|
import { ApiTag, AuthType, ImmichCookie, Permission } from 'src/enum.js';
|
||||||
|
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard.js';
|
||||||
|
import { PasskeyService } from 'src/services/passkey.service.js';
|
||||||
|
import { respondWithCookie } from 'src/utils/response.js';
|
||||||
|
import { UUIDParamDto } from 'src/validation.js';
|
||||||
|
|
||||||
|
@ApiTags(ApiTag.Passkeys)
|
||||||
|
@Controller('passkeys')
|
||||||
|
export class PasskeyController {
|
||||||
|
constructor(private service: PasskeyService) {}
|
||||||
|
|
||||||
|
@Post('registration/start')
|
||||||
|
@Authenticated({ permission: false })
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Start passkey registration',
|
||||||
|
description: 'Generate the options used by the browser to create a new passkey for the current user.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
startRegistration(@Req() request: Request, @Auth() auth: AuthDto): Promise<PasskeyRegistrationOptionsDto> {
|
||||||
|
return this.service.startRegistration(auth, request.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('registration/finish')
|
||||||
|
@Authenticated({ permission: false })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Finish passkey registration',
|
||||||
|
description: 'Verify the response from the authenticator and save the new passkey for the current user.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
finishRegistration(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Body() dto: PasskeyRegistrationFinishDto,
|
||||||
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
|
): Promise<PasskeyResponseDto> {
|
||||||
|
return this.service.finishRegistration(auth, dto, request.headers, loginDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('authentication/start')
|
||||||
|
@Authenticated({ public: true })
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Start passkey authentication',
|
||||||
|
description: 'Generate the options used by the browser to sign in with a passkey.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
startAuthentication(@Req() request: Request): Promise<PasskeyAuthenticationOptionsDto> {
|
||||||
|
return this.service.startAuthentication(request.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('authentication/finish')
|
||||||
|
@Authenticated({ public: true })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Finish passkey authentication',
|
||||||
|
description: 'Verify the response from the authenticator and receive a session token.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
async finishAuthentication(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
@Body() dto: PasskeyAuthenticationFinishDto,
|
||||||
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
|
): Promise<LoginResponseDto> {
|
||||||
|
const body = await this.service.finishAuthentication(dto, request.headers, loginDetails);
|
||||||
|
return respondWithCookie(res, body, {
|
||||||
|
isSecure: loginDetails.isSecure,
|
||||||
|
values: [
|
||||||
|
{ key: ImmichCookie.AccessToken, value: body.accessToken },
|
||||||
|
{ key: ImmichCookie.AuthType, value: AuthType.Passkey },
|
||||||
|
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Authenticated({ permission: Permission.PasskeyRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Search passkeys',
|
||||||
|
description: 'Search the passkeys of the current user.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
searchPasskeys(@Auth() auth: AuthDto, @Query() dto: PasskeySearchDto): Promise<PasskeyResponseDto[]> {
|
||||||
|
return this.service.search(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Authenticated({ permission: Permission.PasskeyRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Retrieve a passkey',
|
||||||
|
description: 'Retrieve a passkey by its ID. The current user must own this passkey.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
getPasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PasskeyResponseDto> {
|
||||||
|
return this.service.get(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Authenticated({ permission: Permission.PasskeyUpdate })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Update a passkey',
|
||||||
|
description: 'Update the name of a passkey by its ID. The current user must own this passkey.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
updatePasskey(
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Param() { id }: UUIDParamDto,
|
||||||
|
@Body() dto: PasskeyUpdateDto,
|
||||||
|
): Promise<PasskeyResponseDto> {
|
||||||
|
return this.service.update(auth, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Authenticated({ permission: Permission.PasskeyDelete })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Delete a passkey',
|
||||||
|
description: 'Delete a passkey by its ID. The current user must own this passkey.',
|
||||||
|
history: HistoryBuilder.v4().alpha('v4.0.0'),
|
||||||
|
})
|
||||||
|
deletePasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
|
return this.service.delete(auth, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,17 @@ export type ApiKey = {
|
|||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Passkey = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
userId: string;
|
||||||
|
credentialId: string;
|
||||||
|
transports: string[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
usedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Tag = {
|
export type Tag = {
|
||||||
id: string;
|
id: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -277,6 +288,9 @@ export type AssetFace = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
updateId: string;
|
updateId: string;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
ownerId: string;
|
||||||
|
assetVisibility: AssetVisibility;
|
||||||
|
assetDeletedAt: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Plugin = Selectable<PluginTable>;
|
export type Plugin = Selectable<PluginTable>;
|
||||||
@@ -406,6 +420,7 @@ export const columns = {
|
|||||||
],
|
],
|
||||||
tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'],
|
tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'],
|
||||||
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
||||||
|
passkey: ['id', 'name', 'userId', 'credentialId', 'transports', 'createdAt', 'updatedAt', 'usedAt'],
|
||||||
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
|
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
|
||||||
pluginMethod: [
|
pluginMethod: [
|
||||||
'plugin_method.name',
|
'plugin_method.name',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AlbumUser, AuthSharedLink } from 'src/database.js';
|
|||||||
import { HistoryBuilder } from 'src/decorators.js';
|
import { HistoryBuilder } from 'src/decorators.js';
|
||||||
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto.js';
|
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto.js';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto.js';
|
import { MapAsset } from 'src/dtos/asset-response.dto.js';
|
||||||
|
import { AuthDto } from 'src/dtos/auth.dto.js';
|
||||||
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js';
|
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js';
|
||||||
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum.js';
|
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum.js';
|
||||||
import { asDateTimeString } from 'src/utils/date.js';
|
import { asDateTimeString } from 'src/utils/date.js';
|
||||||
@@ -229,7 +230,7 @@ export type MapAlbumDto = {
|
|||||||
order: AssetOrder;
|
order: AssetOrder;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
|
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, { auth }: { auth?: AuthDto } = {}): AlbumResponseDto => {
|
||||||
const albumUsers: AlbumUserResponseDto[] = [];
|
const albumUsers: AlbumUserResponseDto[] = [];
|
||||||
|
|
||||||
if (entity.albumUsers) {
|
if (entity.albumUsers) {
|
||||||
@@ -262,7 +263,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
|
|||||||
createdAt: asDateTimeString(entity.createdAt),
|
createdAt: asDateTimeString(entity.createdAt),
|
||||||
updatedAt: asDateTimeString(entity.updatedAt),
|
updatedAt: asDateTimeString(entity.updatedAt),
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
albumUsers,
|
albumUsers: auth?.sharedLink ? [] : albumUsers,
|
||||||
shared: hasSharedUser || hasSharedLink,
|
shared: hasSharedUser || hasSharedLink,
|
||||||
hasSharedLink,
|
hasSharedLink,
|
||||||
startDate: asDateTimeString(startDate),
|
startDate: asDateTimeString(startDate),
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type AuthDto = {
|
|||||||
|
|
||||||
export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.sharedLink.showExif;
|
export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.sharedLink.showExif;
|
||||||
|
|
||||||
|
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
|
||||||
|
|
||||||
const LoginCredentialSchema = z
|
const LoginCredentialSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
|
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const PUBLIC_PROPERTIES = [
|
|||||||
'oauth.autoLaunch',
|
'oauth.autoLaunch',
|
||||||
'oauth.buttonText',
|
'oauth.buttonText',
|
||||||
'oauth.enabled',
|
'oauth.enabled',
|
||||||
|
'passkey.enabled',
|
||||||
'passwordLogin.enabled',
|
'passwordLogin.enabled',
|
||||||
'server.loginPageMessage',
|
'server.loginPageMessage',
|
||||||
];
|
];
|
||||||
@@ -75,3 +76,45 @@ describe('config visibility', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parsePasskey = (passkey: Record<string, unknown>) => AdminConfigDto.schema.safeParse({ ...defaults, passkey });
|
||||||
|
const passkeyMessages = (passkey: Record<string, unknown>) =>
|
||||||
|
parsePasskey(passkey).error?.issues.map((issue) => issue.message);
|
||||||
|
|
||||||
|
describe('passkey config', () => {
|
||||||
|
it('should allow enabling without a domain', () => {
|
||||||
|
expect(parsePasskey({ enabled: true, domain: null, additionalDomains: [] }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject domains that are not https', () => {
|
||||||
|
expect(passkeyMessages({ enabled: true, domain: 'ftp://immich.example', additionalDomains: [] })).toEqual([
|
||||||
|
'Invalid URL',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject domains with a path', () => {
|
||||||
|
expect(
|
||||||
|
passkeyMessages({ enabled: true, domain: null, additionalDomains: ['https://immich.tailnet.example/photos'] }),
|
||||||
|
).toEqual(['Additional domain must not include a path, query, or fragment']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject duplicate additional domains', () => {
|
||||||
|
expect(
|
||||||
|
passkeyMessages({
|
||||||
|
enabled: true,
|
||||||
|
domain: null,
|
||||||
|
additionalDomains: ['https://immich.tailnet.example', 'https://immich.tailnet.example/'],
|
||||||
|
}),
|
||||||
|
).toEqual(['Duplicate domain']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject the passkey domain as an additional domain', () => {
|
||||||
|
expect(
|
||||||
|
passkeyMessages({
|
||||||
|
enabled: true,
|
||||||
|
domain: 'https://immich.example',
|
||||||
|
additionalDomains: ['https://immich.example/'],
|
||||||
|
}),
|
||||||
|
).toEqual(['The passkey domain is always allowed and must not be listed as an additional domain']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ const cronExpressionSchema = z
|
|||||||
})
|
})
|
||||||
.describe('Cron expression');
|
.describe('Cron expression');
|
||||||
|
|
||||||
|
const httpsDomain = (label: string) =>
|
||||||
|
z
|
||||||
|
.url({ protocol: /^https$/, hostname: z.regexes.domain })
|
||||||
|
.normalize()
|
||||||
|
.refine((url) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).href === `${new URL(url).origin}/`;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, `${label} must not include a path, query, or fragment`);
|
||||||
|
|
||||||
const emptyOrUrl = (error: string) =>
|
const emptyOrUrl = (error: string) =>
|
||||||
z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error });
|
z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error });
|
||||||
|
|
||||||
@@ -322,6 +334,29 @@ const AdminConfigSchemaWithVisibility = z
|
|||||||
return value;
|
return value;
|
||||||
})
|
})
|
||||||
.meta({ id: 'AdminConfigOAuthDto' }),
|
.meta({ id: 'AdminConfigOAuthDto' }),
|
||||||
|
passkey: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enabled').meta({ visibility: Public }),
|
||||||
|
domain: httpsDomain('Passkey domain').nullable().describe('Passkey domain'),
|
||||||
|
additionalDomains: z
|
||||||
|
.array(httpsDomain('Additional domain'))
|
||||||
|
.refine(
|
||||||
|
(domains) => domains.length === new Set(domains.map((domain) => new URL(domain).origin)).size,
|
||||||
|
'Duplicate domain',
|
||||||
|
)
|
||||||
|
.describe('Additional domains'),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
const origin = value.domain && new URL(value.domain).origin;
|
||||||
|
if (origin && value.additionalDomains.some((domain) => new URL(domain).origin === origin)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'The passkey domain is always allowed and must not be listed as an additional domain',
|
||||||
|
input: value.additionalDomains,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.meta({ id: 'AdminConfigPasskeyDto' }),
|
||||||
passwordLogin: z
|
passwordLogin: z
|
||||||
.object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) })
|
.object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) })
|
||||||
.meta({ id: 'AdminConfigPasswordLoginDto' }),
|
.meta({ id: 'AdminConfigPasswordLoginDto' }),
|
||||||
@@ -469,7 +504,12 @@ const applyVisibilityRecursive = (
|
|||||||
|
|
||||||
let visible: z.ZodType | undefined;
|
let visible: z.ZodType | undefined;
|
||||||
if (Object.keys(shape).length > 0) {
|
if (Object.keys(shape).length > 0) {
|
||||||
visible = z.object(shape).meta({
|
const isComplete = Object.keys(shape).length === Object.keys(object.shape).length;
|
||||||
|
const rebuilt =
|
||||||
|
schema instanceof z.ZodPipe && isComplete
|
||||||
|
? z.object(shape).pipe(schema.def.out as z.ZodTransform<unknown, Record<string, unknown>>)
|
||||||
|
: z.object(shape);
|
||||||
|
visible = rebuilt.meta({
|
||||||
...(id && { id: `${visibility}${id.slice(Admin.length)}` }),
|
...(id && { id: `${visibility}${id.slice(Admin.length)}` }),
|
||||||
...((override ?? description) && { description: override ?? description }),
|
...((override ?? description) && { description: override ?? description }),
|
||||||
});
|
});
|
||||||
@@ -498,7 +538,7 @@ const stripVisibilityMetadata = <T extends z.ZodType>(schema: T): T => {
|
|||||||
return schema;
|
return schema;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType<SystemConfig>;
|
const AdminConfigSchema = AdminConfigSchemaWithVisibility;
|
||||||
const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>;
|
const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||||
const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>;
|
const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||||
|
|
||||||
@@ -682,6 +722,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
allowInsecureRequests: false,
|
allowInsecureRequests: false,
|
||||||
},
|
},
|
||||||
|
passkey: {
|
||||||
|
enabled: true,
|
||||||
|
domain: null,
|
||||||
|
additionalDomains: [],
|
||||||
|
},
|
||||||
passwordLogin: {
|
passwordLogin: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import z from 'zod';
|
||||||
|
import { Passkey } from 'src/database.js';
|
||||||
|
import { isoDatetimeToDate } from 'src/validation.js';
|
||||||
|
|
||||||
|
const PasskeyRegistrationOptionsSchema = z
|
||||||
|
.looseRecord(z.string(), z.any())
|
||||||
|
.describe("Response returned by @simplewebauthn/server's `generateRegistrationOptions`")
|
||||||
|
.meta({ id: 'PasskeyRegistrationOptionsDto' });
|
||||||
|
|
||||||
|
const PasskeyAuthenticationOptionsSchema = z
|
||||||
|
.looseRecord(z.string(), z.any())
|
||||||
|
.describe("Response returned by @simplewebauthn/server's `generateAuthenticationOptions`")
|
||||||
|
.meta({ id: 'PasskeyAuthenticationOptionsDto' });
|
||||||
|
|
||||||
|
const PasskeyRegistrationFinishSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional().describe('Passkey name'),
|
||||||
|
response: z.unknown().describe("Response returned by @simplewebauthn/browser's `startAuthentication()`"),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PasskeyRegistrationFinishDto' });
|
||||||
|
|
||||||
|
const PasskeyAuthenticationFinishSchema = z
|
||||||
|
.object({
|
||||||
|
response: z
|
||||||
|
.looseObject({
|
||||||
|
id: z.base64url().describe('Credential ID'),
|
||||||
|
response: z.looseObject({ userHandle: z.base64url().optional().describe('User handle') }),
|
||||||
|
})
|
||||||
|
.describe("Response returned by @simplewebauthn/browser's `startAuthentication()`"),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PasskeyAuthenticationFinishDto' });
|
||||||
|
|
||||||
|
const PasskeySearchSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.uuidv4().optional().describe('Filter by passkey ID'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PasskeySearchDto' });
|
||||||
|
|
||||||
|
const PasskeyUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).nullable().describe('Passkey name'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PasskeyUpdateDto' });
|
||||||
|
|
||||||
|
const PasskeyResponseSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.uuidv4().describe('Passkey ID'),
|
||||||
|
name: z.string().nullable().describe('Passkey name'),
|
||||||
|
createdAt: isoDatetimeToDate.describe('Creation date'),
|
||||||
|
updatedAt: isoDatetimeToDate.describe('Last update date'),
|
||||||
|
usedAt: isoDatetimeToDate.nullable().describe('Last used date'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PasskeyResponseDto' });
|
||||||
|
|
||||||
|
export class PasskeyRegistrationOptionsDto extends createZodDto(PasskeyRegistrationOptionsSchema) {}
|
||||||
|
export class PasskeyAuthenticationOptionsDto extends createZodDto(PasskeyAuthenticationOptionsSchema) {}
|
||||||
|
export class PasskeyRegistrationFinishDto extends createZodDto(PasskeyRegistrationFinishSchema) {}
|
||||||
|
export class PasskeyAuthenticationFinishDto extends createZodDto(PasskeyAuthenticationFinishSchema) {}
|
||||||
|
export class PasskeySearchDto extends createZodDto(PasskeySearchSchema) {}
|
||||||
|
export class PasskeyUpdateDto extends createZodDto(PasskeyUpdateSchema) {}
|
||||||
|
export class PasskeyResponseDto extends createZodDto(PasskeyResponseSchema) {}
|
||||||
|
|
||||||
|
export const mapPasskey = (passkey: Passkey): PasskeyResponseDto => ({
|
||||||
|
id: passkey.id,
|
||||||
|
name: passkey.name,
|
||||||
|
createdAt: passkey.createdAt,
|
||||||
|
updatedAt: passkey.updatedAt,
|
||||||
|
usedAt: passkey.usedAt,
|
||||||
|
});
|
||||||
@@ -114,7 +114,7 @@ export function mapSharedLink(sharedLink: SharedLink, { auth }: { auth: AuthDto
|
|||||||
createdAt: sharedLink.createdAt,
|
createdAt: sharedLink.createdAt,
|
||||||
expiresAt: sharedLink.expiresAt,
|
expiresAt: sharedLink.expiresAt,
|
||||||
assets: assets.map((asset) => mapAsset(asset, { auth })),
|
assets: assets.map((asset) => mapAsset(asset, { auth })),
|
||||||
album: sharedLink.album ? mapAlbum(sharedLink.album) : undefined,
|
album: sharedLink.album ? mapAlbum(sharedLink.album, { auth }) : undefined,
|
||||||
allowUpload: sharedLink.allowUpload,
|
allowUpload: sharedLink.allowUpload,
|
||||||
allowDownload: sharedLink.allowDownload,
|
allowDownload: sharedLink.allowDownload,
|
||||||
showMetadata: sharedLink.showExif,
|
showMetadata: sharedLink.showExif,
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/
|
|||||||
export const UserUpdateMeSchema = z
|
export const UserUpdateMeSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: toEmail.optional().describe('User email'),
|
email: toEmail.optional().describe('User email'),
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('User password (deprecated, use change password endpoint)')
|
|
||||||
.meta({ deprecated: true }),
|
|
||||||
name: z.string().optional().describe('User name'),
|
name: z.string().optional().describe('User name'),
|
||||||
avatarColor: UserAvatarColorSchema.nullish(),
|
avatarColor: UserAvatarColorSchema.nullish(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
|||||||
import { ImmichButton } from 'src/emails/components/button.component.js';
|
import { ImmichButton } from 'src/emails/components/button.component.js';
|
||||||
import ImmichLayout from 'src/emails/components/immich.layout.js';
|
import ImmichLayout from 'src/emails/components/immich.layout.js';
|
||||||
import { AlbumInviteEmailProps } from 'src/repositories/email.repository.js';
|
import { AlbumInviteEmailProps } from 'src/repositories/email.repository.js';
|
||||||
import { replaceTemplateTags } from 'src/utils/replace-template-tags.js';
|
import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js';
|
||||||
|
|
||||||
export const AlbumInviteEmail = ({
|
export const AlbumInviteEmail = ({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -37,7 +37,9 @@ export const AlbumInviteEmail = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'You have been added to a shared album.'}>
|
<ImmichLayout
|
||||||
|
preview={customTemplate ? templatePreview(customTemplate, variables) : 'You have been added to a shared album.'}
|
||||||
|
>
|
||||||
{customTemplate && (
|
{customTemplate && (
|
||||||
<Text className="m-0">
|
<Text className="m-0">
|
||||||
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
|||||||
import { ImmichButton } from 'src/emails/components/button.component.js';
|
import { ImmichButton } from 'src/emails/components/button.component.js';
|
||||||
import ImmichLayout from 'src/emails/components/immich.layout.js';
|
import ImmichLayout from 'src/emails/components/immich.layout.js';
|
||||||
import { AlbumUpdateEmailProps } from 'src/repositories/email.repository.js';
|
import { AlbumUpdateEmailProps } from 'src/repositories/email.repository.js';
|
||||||
import { replaceTemplateTags } from 'src/utils/replace-template-tags.js';
|
import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js';
|
||||||
|
|
||||||
export const AlbumUpdateEmail = ({
|
export const AlbumUpdateEmail = ({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -36,7 +36,13 @@ export const AlbumUpdateEmail = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'New media has been added to a shared album.'}>
|
<ImmichLayout
|
||||||
|
preview={
|
||||||
|
customTemplate
|
||||||
|
? templatePreview(customTemplate, usableTemplateVariables)
|
||||||
|
: 'New media has been added to a shared album.'
|
||||||
|
}
|
||||||
|
>
|
||||||
{customTemplate && (
|
{customTemplate && (
|
||||||
<Text className="m-0">
|
<Text className="m-0">
|
||||||
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
|||||||
import { ImmichButton } from 'src/emails/components/button.component.js';
|
import { ImmichButton } from 'src/emails/components/button.component.js';
|
||||||
import ImmichLayout from 'src/emails/components/immich.layout.js';
|
import ImmichLayout from 'src/emails/components/immich.layout.js';
|
||||||
import { WelcomeEmailProps } from 'src/repositories/email.repository.js';
|
import { WelcomeEmailProps } from 'src/repositories/email.repository.js';
|
||||||
import { replaceTemplateTags } from 'src/utils/replace-template-tags.js';
|
import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js';
|
||||||
|
|
||||||
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {
|
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {
|
||||||
const usableTemplateVariables = {
|
const usableTemplateVariables = {
|
||||||
@@ -37,7 +37,11 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password, customT
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ImmichLayout
|
<ImmichLayout
|
||||||
preview={customTemplate ? emailContent.toString() : 'You have been invited to a new Immich instance.'}
|
preview={
|
||||||
|
customTemplate
|
||||||
|
? templatePreview(customTemplate, usableTemplateVariables)
|
||||||
|
: 'You have been invited to a new Immich instance.'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{customTemplate && (
|
{customTemplate && (
|
||||||
<Text className="m-0">
|
<Text className="m-0">
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { asIntegerEnum } from 'src/utils/helpers.js';
|
|||||||
export enum AuthType {
|
export enum AuthType {
|
||||||
Password = 'password',
|
Password = 'password',
|
||||||
OAuth = 'oauth',
|
OAuth = 'oauth',
|
||||||
|
Passkey = 'passkey',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AuthChallengeType {
|
||||||
|
PasskeyRegistration = 'PasskeyRegistration',
|
||||||
|
PasskeyAuthentication = 'PasskeyAuthentication',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ImmichCookie {
|
export enum ImmichCookie {
|
||||||
@@ -244,6 +250,10 @@ export enum Permission {
|
|||||||
PartnerUpdate = 'partner.update',
|
PartnerUpdate = 'partner.update',
|
||||||
PartnerDelete = 'partner.delete',
|
PartnerDelete = 'partner.delete',
|
||||||
|
|
||||||
|
PasskeyRead = 'passkey.read',
|
||||||
|
PasskeyUpdate = 'passkey.update',
|
||||||
|
PasskeyDelete = 'passkey.delete',
|
||||||
|
|
||||||
PersonCreate = 'person.create',
|
PersonCreate = 'person.create',
|
||||||
PersonRead = 'person.read',
|
PersonRead = 'person.read',
|
||||||
PersonUpdate = 'person.update',
|
PersonUpdate = 'person.update',
|
||||||
@@ -892,6 +902,8 @@ export enum JobName {
|
|||||||
|
|
||||||
AuditTableCleanup = 'AuditTableCleanup',
|
AuditTableCleanup = 'AuditTableCleanup',
|
||||||
|
|
||||||
|
AuthChallengeCleanup = 'AuthChallengeCleanup',
|
||||||
|
|
||||||
DatabaseBackup = 'DatabaseBackup',
|
DatabaseBackup = 'DatabaseBackup',
|
||||||
|
|
||||||
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
|
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
|
||||||
@@ -1018,6 +1030,8 @@ export enum DatabaseLock {
|
|||||||
IntegrityCheck = 67,
|
IntegrityCheck = 67,
|
||||||
VersionCheck = 800,
|
VersionCheck = 800,
|
||||||
HlsSessionCleanup = 850,
|
HlsSessionCleanup = 850,
|
||||||
|
FacialRecognition = 900,
|
||||||
|
DuplicateDetection = 1000,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MaintenanceAction {
|
export enum MaintenanceAction {
|
||||||
@@ -1269,6 +1283,7 @@ export enum ApiTag {
|
|||||||
NotificationsAdmin = 'Notifications (admin)',
|
NotificationsAdmin = 'Notifications (admin)',
|
||||||
ClusterGroups = 'Cluster groups',
|
ClusterGroups = 'Cluster groups',
|
||||||
Partners = 'Partners',
|
Partners = 'Partners',
|
||||||
|
Passkeys = 'Passkeys',
|
||||||
People = 'People',
|
People = 'People',
|
||||||
Plugins = 'Plugins',
|
Plugins = 'Plugins',
|
||||||
Queues = 'Queues',
|
Queues = 'Queues',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { parse } from 'cookie';
|
import { parseCookie } from 'cookie';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -240,7 +240,7 @@ export class MaintenanceWorkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
|
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
|
||||||
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken];
|
const jwtToken = parseCookie(headers.cookie || '')[ImmichCookie.MaintenanceToken];
|
||||||
return this.login(jwtToken);
|
return this.login(jwtToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,6 +320,15 @@ where
|
|||||||
"partner"."sharedById" in ($1)
|
"partner"."sharedById" in ($1)
|
||||||
and "partner"."sharedWithId" = $2
|
and "partner"."sharedWithId" = $2
|
||||||
|
|
||||||
|
-- AccessRepository.passkey.checkOwnerAccess
|
||||||
|
select
|
||||||
|
"passkey"."id"
|
||||||
|
from
|
||||||
|
"passkey"
|
||||||
|
where
|
||||||
|
"passkey"."id" in ($1)
|
||||||
|
and "passkey"."userId" = $2
|
||||||
|
|
||||||
-- AccessRepository.session.checkOwnerAccess
|
-- AccessRepository.session.checkOwnerAccess
|
||||||
select
|
select
|
||||||
"session"."id"
|
"session"."id"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- NOTE: This file is auto generated by ./sql-generator
|
||||||
|
|
||||||
|
-- AuthChallengeRepository.consume
|
||||||
|
delete from "auth_challenge"
|
||||||
|
where
|
||||||
|
"challenge" = $1
|
||||||
|
and "type" = $2
|
||||||
|
and "expiresAt" > $3
|
||||||
|
returning
|
||||||
|
"id",
|
||||||
|
"userId"
|
||||||
|
|
||||||
|
-- AuthChallengeRepository.deleteExpired
|
||||||
|
delete from "auth_challenge"
|
||||||
|
where
|
||||||
|
"expiresAt" <= $1
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- NOTE: This file is auto generated by ./sql-generator
|
||||||
|
|
||||||
|
-- PasskeyRepository.delete
|
||||||
|
delete from "passkey"
|
||||||
|
where
|
||||||
|
"id" = $1::uuid
|
||||||
|
|
||||||
|
-- PasskeyRepository.getByCredentialId
|
||||||
|
select
|
||||||
|
*
|
||||||
|
from
|
||||||
|
"passkey"
|
||||||
|
where
|
||||||
|
"credentialId" = $1
|
||||||
|
|
||||||
|
-- PasskeyRepository.get
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"userId",
|
||||||
|
"credentialId",
|
||||||
|
"transports",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"usedAt"
|
||||||
|
from
|
||||||
|
"passkey"
|
||||||
|
where
|
||||||
|
"id" = $1::uuid
|
||||||
|
|
||||||
|
-- PasskeyRepository.search
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"userId",
|
||||||
|
"credentialId",
|
||||||
|
"transports",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"usedAt"
|
||||||
|
from
|
||||||
|
"passkey"
|
||||||
|
where
|
||||||
|
"userId" = $1
|
||||||
|
order by
|
||||||
|
"createdAt" desc
|
||||||
@@ -148,168 +148,418 @@ limit
|
|||||||
|
|
||||||
-- PersonRepository.getAllForUser
|
-- PersonRepository.getAllForUser
|
||||||
with
|
with
|
||||||
"similarity_threshold" as (
|
"faces" as (
|
||||||
select
|
select
|
||||||
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
|
"visible"."personGroupId",
|
||||||
)
|
count(*) as "faceCount"
|
||||||
select
|
|
||||||
(
|
|
||||||
select
|
|
||||||
to_json(obj)
|
|
||||||
from
|
from
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
"person".*
|
"asset_face"."personGroupId"
|
||||||
from
|
from
|
||||||
"person"
|
"asset_face"
|
||||||
where
|
where
|
||||||
"person"."personGroupId" = "person_group"."id"
|
"asset_face"."deletedAt" is null
|
||||||
and "person"."ownerId" = $1
|
and "asset_face"."isVisible" is true
|
||||||
) as obj
|
and "asset_face"."assetVisibility" = 'timeline'
|
||||||
) as "ownedPerson",
|
and "asset_face"."assetDeletedAt" is null
|
||||||
(
|
and "asset_face"."personGroupId" in (
|
||||||
select
|
|
||||||
coalesce(json_agg(agg), '[]')
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"person_user"."sharedById",
|
|
||||||
"person_user"."role",
|
|
||||||
"other"."name",
|
|
||||||
"other"."birthDate"
|
|
||||||
from
|
|
||||||
"person" as "other"
|
|
||||||
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
|
|
||||||
and "person_user"."sharedById" = "other"."ownerId"
|
|
||||||
and "person_user"."sharedWithId" = $2
|
|
||||||
where
|
|
||||||
"other"."personGroupId" = "person_group"."id"
|
|
||||||
) as agg
|
|
||||||
) as "otherPeople",
|
|
||||||
(
|
|
||||||
select
|
|
||||||
coalesce(json_agg(agg), '[]')
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"avatarColor",
|
|
||||||
"profileImagePath",
|
|
||||||
"profileChangedAt",
|
|
||||||
"person_user"."role"
|
|
||||||
from
|
|
||||||
"person_user"
|
|
||||||
inner join "user" on "user"."id" = "person_user"."sharedById"
|
|
||||||
and "user"."deletedAt" is null
|
|
||||||
where
|
|
||||||
"person_user"."personGroupId" = "person_group"."id"
|
|
||||||
and "person_user"."sharedWithId" = $3
|
|
||||||
order by
|
|
||||||
"user"."name"
|
|
||||||
) as agg
|
|
||||||
) as "sharedBy",
|
|
||||||
(
|
|
||||||
select
|
|
||||||
coalesce(json_agg(agg), '[]')
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"avatarColor",
|
|
||||||
"profileImagePath",
|
|
||||||
"profileChangedAt",
|
|
||||||
"person_user"."role"
|
|
||||||
from
|
|
||||||
"person_user"
|
|
||||||
inner join "user" on "user"."id" = "person_user"."sharedWithId"
|
|
||||||
and "user"."deletedAt" is null
|
|
||||||
where
|
|
||||||
"person_user"."personGroupId" = "person_group"."id"
|
|
||||||
and "person_user"."sharedById" = $4
|
|
||||||
order by
|
|
||||||
"user"."name"
|
|
||||||
) as agg
|
|
||||||
) as "sharedWith"
|
|
||||||
from
|
|
||||||
"similarity_threshold",
|
|
||||||
"person_group"
|
|
||||||
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
|
|
||||||
and "owned"."ownerId" = $5
|
|
||||||
inner join (
|
|
||||||
select
|
|
||||||
"asset_face"."personGroupId",
|
|
||||||
count("asset"."id") as "faceCount"
|
|
||||||
from
|
|
||||||
"asset_face"
|
|
||||||
inner join "asset" on "asset"."id" = "asset_face"."assetId"
|
|
||||||
and "asset"."visibility" = 'timeline'
|
|
||||||
and "asset"."deletedAt" is null
|
|
||||||
and (
|
|
||||||
"asset"."ownerId" = any ($6::uuid[])
|
|
||||||
or exists (
|
|
||||||
select
|
|
||||||
1 as "exists"
|
|
||||||
from
|
|
||||||
"album_asset"
|
|
||||||
inner join "album" on "album"."id" = "album_asset"."albumId"
|
|
||||||
and "album"."deletedAt" is null
|
|
||||||
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
|
||||||
and "album_user"."userId" = $7::uuid
|
|
||||||
where
|
|
||||||
"album_asset"."assetId" = "asset"."id"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
where
|
|
||||||
"asset_face"."personGroupId" = any (
|
|
||||||
array(
|
|
||||||
(
|
|
||||||
select
|
select
|
||||||
"person"."personGroupId"
|
"person"."personGroupId"
|
||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
where
|
where
|
||||||
"person"."ownerId" = $8
|
"person"."ownerId" = $1
|
||||||
)
|
)
|
||||||
|
and "asset_face"."ownerId" = any ($2::uuid[])
|
||||||
|
union all
|
||||||
|
select
|
||||||
|
"asset_face"."personGroupId"
|
||||||
|
from
|
||||||
|
"asset_face"
|
||||||
|
where
|
||||||
|
"asset_face"."deletedAt" is null
|
||||||
|
and "asset_face"."isVisible" is true
|
||||||
|
and "asset_face"."assetVisibility" = 'timeline'
|
||||||
|
and "asset_face"."assetDeletedAt" is null
|
||||||
|
and "asset_face"."personGroupId" in (
|
||||||
|
select
|
||||||
|
"person"."personGroupId"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."ownerId" = $3
|
||||||
|
)
|
||||||
|
and not "asset_face"."ownerId" = any ($4::uuid[])
|
||||||
|
and exists (
|
||||||
|
select
|
||||||
|
1 as "exists"
|
||||||
|
from
|
||||||
|
"album_asset"
|
||||||
|
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||||
|
and "album"."deletedAt" is null
|
||||||
|
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
||||||
|
and "album_user"."userId" = $5
|
||||||
|
where
|
||||||
|
"album_asset"."assetId" = "asset_face"."assetId"
|
||||||
|
)
|
||||||
|
) as "visible"
|
||||||
|
group by
|
||||||
|
"visible"."personGroupId"
|
||||||
|
),
|
||||||
|
"people" as (
|
||||||
|
select
|
||||||
|
"person".*,
|
||||||
|
"faces"."faceCount"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
inner join "faces" on "faces"."personGroupId" = "person"."personGroupId"
|
||||||
|
where
|
||||||
|
"person"."ownerId" = $6
|
||||||
|
and (
|
||||||
|
"person"."name" != $7
|
||||||
|
or "faces"."faceCount" >= coalesce(
|
||||||
|
(
|
||||||
|
select
|
||||||
|
cast(value -> 'people' ->> 'minimumFaces' as integer) as "value"
|
||||||
|
from
|
||||||
|
"user_metadata"
|
||||||
|
where
|
||||||
|
"userId" = $8
|
||||||
|
and "key" = 'preferences'
|
||||||
|
),
|
||||||
|
3
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
and "asset_face"."deletedAt" is null
|
),
|
||||||
and "asset_face"."isVisible" is true
|
"page" as (
|
||||||
group by
|
select
|
||||||
"asset_face"."personGroupId"
|
"people".*
|
||||||
) as "faces" on "faces"."personGroupId" = "person_group"."id"
|
from
|
||||||
where
|
"people"
|
||||||
(
|
where
|
||||||
"owned"."name" != $9
|
"people"."isHidden" = $9
|
||||||
or "faces"."faceCount" >= COALESCE(
|
order by
|
||||||
(
|
"people"."isHidden" asc,
|
||||||
SELECT
|
"people"."isFavorite" desc,
|
||||||
value -> 'people' ->> 'minimumFaces'
|
"people"."name" = '',
|
||||||
FROM
|
"people"."faceCount" desc,
|
||||||
user_metadata
|
"people"."name",
|
||||||
WHERE
|
"people"."createdAt",
|
||||||
"userId" = $10
|
"people"."personGroupId"
|
||||||
AND key = 'preferences'
|
limit
|
||||||
),
|
$10
|
||||||
'3'
|
offset
|
||||||
)::int
|
$11
|
||||||
)
|
)
|
||||||
and "owned"."isHidden" = $11
|
select
|
||||||
and 1 = 1
|
"counts"."total",
|
||||||
order by
|
"counts"."hidden",
|
||||||
"owned"."isHidden" asc,
|
(
|
||||||
"owned"."isFavorite" desc,
|
select
|
||||||
NULLIF("owned"."name", '') is null asc,
|
coalesce(json_agg(agg), '[]')
|
||||||
"faces"."faceCount" desc,
|
from
|
||||||
NULLIF("owned"."name", '') asc nulls last,
|
(
|
||||||
"owned"."createdAt"
|
select
|
||||||
limit
|
"person".*,
|
||||||
$12
|
(
|
||||||
offset
|
select
|
||||||
$13
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"person_user"."sharedById",
|
||||||
|
"person_user"."role",
|
||||||
|
"other"."name",
|
||||||
|
"other"."birthDate"
|
||||||
|
from
|
||||||
|
"person" as "other"
|
||||||
|
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
|
||||||
|
and "person_user"."sharedById" = "other"."ownerId"
|
||||||
|
and "person_user"."sharedWithId" = $12
|
||||||
|
where
|
||||||
|
"other"."personGroupId" = "person"."personGroupId"
|
||||||
|
) as agg
|
||||||
|
) as "otherPeople",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"avatarColor",
|
||||||
|
"profileImagePath",
|
||||||
|
"profileChangedAt",
|
||||||
|
"person_user"."role"
|
||||||
|
from
|
||||||
|
"person_user"
|
||||||
|
inner join "user" on "user"."id" = "person_user"."sharedById"
|
||||||
|
and "user"."deletedAt" is null
|
||||||
|
where
|
||||||
|
"person_user"."personGroupId" = "person"."personGroupId"
|
||||||
|
and "person_user"."sharedWithId" = $13
|
||||||
|
order by
|
||||||
|
"user"."name"
|
||||||
|
) as agg
|
||||||
|
) as "sharedBy",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"avatarColor",
|
||||||
|
"profileImagePath",
|
||||||
|
"profileChangedAt",
|
||||||
|
"person_user"."role"
|
||||||
|
from
|
||||||
|
"person_user"
|
||||||
|
inner join "user" on "user"."id" = "person_user"."sharedWithId"
|
||||||
|
and "user"."deletedAt" is null
|
||||||
|
where
|
||||||
|
"person_user"."personGroupId" = "person"."personGroupId"
|
||||||
|
and "person_user"."sharedById" = $14
|
||||||
|
order by
|
||||||
|
"user"."name"
|
||||||
|
) as agg
|
||||||
|
) as "sharedWith"
|
||||||
|
from
|
||||||
|
"page" as "person"
|
||||||
|
) as agg
|
||||||
|
) as "people"
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
count(*) as "total",
|
||||||
|
count(*) filter (
|
||||||
|
where
|
||||||
|
"people"."isHidden" = $15
|
||||||
|
) as "hidden"
|
||||||
|
from
|
||||||
|
"people"
|
||||||
|
) as "counts"
|
||||||
|
|
||||||
|
-- PersonRepository.getAllForUser (closest person)
|
||||||
|
with
|
||||||
|
"faces" as (
|
||||||
|
select
|
||||||
|
"visible"."personGroupId",
|
||||||
|
count(*) as "faceCount"
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"asset_face"."personGroupId"
|
||||||
|
from
|
||||||
|
"asset_face"
|
||||||
|
where
|
||||||
|
"asset_face"."deletedAt" is null
|
||||||
|
and "asset_face"."isVisible" is true
|
||||||
|
and "asset_face"."assetVisibility" = 'timeline'
|
||||||
|
and "asset_face"."assetDeletedAt" is null
|
||||||
|
and "asset_face"."personGroupId" in (
|
||||||
|
select
|
||||||
|
"person"."personGroupId"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."ownerId" = $1
|
||||||
|
)
|
||||||
|
and "asset_face"."ownerId" = any ($2::uuid[])
|
||||||
|
union all
|
||||||
|
select
|
||||||
|
"asset_face"."personGroupId"
|
||||||
|
from
|
||||||
|
"asset_face"
|
||||||
|
where
|
||||||
|
"asset_face"."deletedAt" is null
|
||||||
|
and "asset_face"."isVisible" is true
|
||||||
|
and "asset_face"."assetVisibility" = 'timeline'
|
||||||
|
and "asset_face"."assetDeletedAt" is null
|
||||||
|
and "asset_face"."personGroupId" in (
|
||||||
|
select
|
||||||
|
"person"."personGroupId"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
where
|
||||||
|
"person"."ownerId" = $3
|
||||||
|
)
|
||||||
|
and not "asset_face"."ownerId" = any ($4::uuid[])
|
||||||
|
and exists (
|
||||||
|
select
|
||||||
|
1 as "exists"
|
||||||
|
from
|
||||||
|
"album_asset"
|
||||||
|
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||||
|
and "album"."deletedAt" is null
|
||||||
|
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
||||||
|
and "album_user"."userId" = $5
|
||||||
|
where
|
||||||
|
"album_asset"."assetId" = "asset_face"."assetId"
|
||||||
|
)
|
||||||
|
) as "visible"
|
||||||
|
group by
|
||||||
|
"visible"."personGroupId"
|
||||||
|
),
|
||||||
|
"people" as (
|
||||||
|
select
|
||||||
|
"person".*,
|
||||||
|
"faces"."faceCount"
|
||||||
|
from
|
||||||
|
"person"
|
||||||
|
inner join "faces" on "faces"."personGroupId" = "person"."personGroupId"
|
||||||
|
where
|
||||||
|
"person"."ownerId" = $6
|
||||||
|
and (
|
||||||
|
"person"."name" != $7
|
||||||
|
or "faces"."faceCount" >= coalesce(
|
||||||
|
(
|
||||||
|
select
|
||||||
|
cast(value -> 'people' ->> 'minimumFaces' as integer) as "value"
|
||||||
|
from
|
||||||
|
"user_metadata"
|
||||||
|
where
|
||||||
|
"userId" = $8
|
||||||
|
and "key" = 'preferences'
|
||||||
|
),
|
||||||
|
3
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"page" as (
|
||||||
|
select
|
||||||
|
"people".*
|
||||||
|
from
|
||||||
|
"people"
|
||||||
|
where
|
||||||
|
"people"."isHidden" = $9
|
||||||
|
order by
|
||||||
|
"people"."isHidden" asc,
|
||||||
|
"people"."isFavorite" desc,
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"face_search"."embedding"
|
||||||
|
from
|
||||||
|
"face_search"
|
||||||
|
where
|
||||||
|
"face_search"."faceId" = "people"."faceAssetId"
|
||||||
|
) <=> (
|
||||||
|
select
|
||||||
|
"face_search"."embedding"
|
||||||
|
from
|
||||||
|
"face_search"
|
||||||
|
inner join "person" as "closest" on "closest"."faceAssetId" = "face_search"."faceId"
|
||||||
|
where
|
||||||
|
"closest"."ownerId" = $10
|
||||||
|
and "closest"."personGroupId" = $11
|
||||||
|
),
|
||||||
|
"people"."name" = '',
|
||||||
|
"people"."faceCount" desc,
|
||||||
|
"people"."name",
|
||||||
|
"people"."createdAt",
|
||||||
|
"people"."personGroupId"
|
||||||
|
limit
|
||||||
|
$12
|
||||||
|
offset
|
||||||
|
$13
|
||||||
|
)
|
||||||
|
select
|
||||||
|
"counts"."total",
|
||||||
|
"counts"."hidden",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"person".*,
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"person_user"."sharedById",
|
||||||
|
"person_user"."role",
|
||||||
|
"other"."name",
|
||||||
|
"other"."birthDate"
|
||||||
|
from
|
||||||
|
"person" as "other"
|
||||||
|
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
|
||||||
|
and "person_user"."sharedById" = "other"."ownerId"
|
||||||
|
and "person_user"."sharedWithId" = $14
|
||||||
|
where
|
||||||
|
"other"."personGroupId" = "person"."personGroupId"
|
||||||
|
) as agg
|
||||||
|
) as "otherPeople",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"avatarColor",
|
||||||
|
"profileImagePath",
|
||||||
|
"profileChangedAt",
|
||||||
|
"person_user"."role"
|
||||||
|
from
|
||||||
|
"person_user"
|
||||||
|
inner join "user" on "user"."id" = "person_user"."sharedById"
|
||||||
|
and "user"."deletedAt" is null
|
||||||
|
where
|
||||||
|
"person_user"."personGroupId" = "person"."personGroupId"
|
||||||
|
and "person_user"."sharedWithId" = $15
|
||||||
|
order by
|
||||||
|
"user"."name"
|
||||||
|
) as agg
|
||||||
|
) as "sharedBy",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"avatarColor",
|
||||||
|
"profileImagePath",
|
||||||
|
"profileChangedAt",
|
||||||
|
"person_user"."role"
|
||||||
|
from
|
||||||
|
"person_user"
|
||||||
|
inner join "user" on "user"."id" = "person_user"."sharedWithId"
|
||||||
|
and "user"."deletedAt" is null
|
||||||
|
where
|
||||||
|
"person_user"."personGroupId" = "person"."personGroupId"
|
||||||
|
and "person_user"."sharedById" = $16
|
||||||
|
order by
|
||||||
|
"user"."name"
|
||||||
|
) as agg
|
||||||
|
) as "sharedWith"
|
||||||
|
from
|
||||||
|
"page" as "person"
|
||||||
|
) as agg
|
||||||
|
) as "people"
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
count(*) as "total",
|
||||||
|
count(*) filter (
|
||||||
|
where
|
||||||
|
"people"."isHidden" = $17
|
||||||
|
) as "hidden"
|
||||||
|
from
|
||||||
|
"people"
|
||||||
|
) as "counts"
|
||||||
|
|
||||||
-- PersonRepository.getAllWithoutFaces
|
-- PersonRepository.getAllWithoutFaces
|
||||||
select
|
select
|
||||||
@@ -592,103 +842,6 @@ set
|
|||||||
where
|
where
|
||||||
"asset_face"."id" = $2
|
"asset_face"."id" = $2
|
||||||
|
|
||||||
-- PersonRepository.getForUser
|
|
||||||
select
|
|
||||||
(
|
|
||||||
select
|
|
||||||
to_json(obj)
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"person".*
|
|
||||||
from
|
|
||||||
"person"
|
|
||||||
where
|
|
||||||
"person"."personGroupId" = "person_group"."id"
|
|
||||||
and "person"."ownerId" = $1
|
|
||||||
) as obj
|
|
||||||
) as "ownedPerson",
|
|
||||||
(
|
|
||||||
select
|
|
||||||
coalesce(json_agg(agg), '[]')
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"person_user"."sharedById",
|
|
||||||
"person_user"."role",
|
|
||||||
"other"."name",
|
|
||||||
"other"."birthDate"
|
|
||||||
from
|
|
||||||
"person" as "other"
|
|
||||||
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
|
|
||||||
and "person_user"."sharedById" = "other"."ownerId"
|
|
||||||
and "person_user"."sharedWithId" = $2
|
|
||||||
where
|
|
||||||
"other"."personGroupId" = "person_group"."id"
|
|
||||||
) as agg
|
|
||||||
) as "otherPeople",
|
|
||||||
(
|
|
||||||
select
|
|
||||||
coalesce(json_agg(agg), '[]')
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"avatarColor",
|
|
||||||
"profileImagePath",
|
|
||||||
"profileChangedAt",
|
|
||||||
"person_user"."role"
|
|
||||||
from
|
|
||||||
"person_user"
|
|
||||||
inner join "user" on "user"."id" = "person_user"."sharedById"
|
|
||||||
and "user"."deletedAt" is null
|
|
||||||
where
|
|
||||||
"person_user"."personGroupId" = "person_group"."id"
|
|
||||||
and "person_user"."sharedWithId" = $3
|
|
||||||
order by
|
|
||||||
"user"."name"
|
|
||||||
) as agg
|
|
||||||
) as "sharedBy",
|
|
||||||
(
|
|
||||||
select
|
|
||||||
coalesce(json_agg(agg), '[]')
|
|
||||||
from
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"avatarColor",
|
|
||||||
"profileImagePath",
|
|
||||||
"profileChangedAt",
|
|
||||||
"person_user"."role"
|
|
||||||
from
|
|
||||||
"person_user"
|
|
||||||
inner join "user" on "user"."id" = "person_user"."sharedWithId"
|
|
||||||
and "user"."deletedAt" is null
|
|
||||||
where
|
|
||||||
"person_user"."personGroupId" = "person_group"."id"
|
|
||||||
and "person_user"."sharedById" = $4
|
|
||||||
order by
|
|
||||||
"user"."name"
|
|
||||||
) as agg
|
|
||||||
) as "sharedWith"
|
|
||||||
from
|
|
||||||
"person_group"
|
|
||||||
where
|
|
||||||
"person_group"."id" = $5
|
|
||||||
and exists (
|
|
||||||
select
|
|
||||||
"person"."ownerId"
|
|
||||||
from
|
|
||||||
"person"
|
|
||||||
where
|
|
||||||
"person"."personGroupId" = "person_group"."id"
|
|
||||||
and "person"."ownerId" = $6
|
|
||||||
)
|
|
||||||
|
|
||||||
-- PersonRepository.getByGroupId
|
-- PersonRepository.getByGroupId
|
||||||
select
|
select
|
||||||
"person".*,
|
"person".*,
|
||||||
@@ -796,11 +949,6 @@ where
|
|||||||
"person_group"."id" = $5
|
"person_group"."id" = $5
|
||||||
|
|
||||||
-- PersonRepository.getByName
|
-- PersonRepository.getByName
|
||||||
with
|
|
||||||
"similarity_threshold" as (
|
|
||||||
select
|
|
||||||
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
|
|
||||||
)
|
|
||||||
select
|
select
|
||||||
"person".*,
|
"person".*,
|
||||||
(
|
(
|
||||||
@@ -871,11 +1019,15 @@ select
|
|||||||
) as agg
|
) as agg
|
||||||
) as "sharedWith"
|
) as "sharedWith"
|
||||||
from
|
from
|
||||||
"similarity_threshold",
|
|
||||||
"person"
|
"person"
|
||||||
where
|
where
|
||||||
"person"."ownerId" = $4
|
"person"."ownerId" = $4
|
||||||
and f_unaccent ("person"."name") %> f_unaccent ($5)
|
and f_unaccent ("person"."name") %> (
|
||||||
|
select
|
||||||
|
f_unaccent ($5)
|
||||||
|
where
|
||||||
|
set_config('pg_trgm.word_similarity_threshold', '0.5', true) is not null
|
||||||
|
)
|
||||||
order by
|
order by
|
||||||
f_unaccent ("person"."name") <->>> f_unaccent ($6)
|
f_unaccent ("person"."name") <->>> f_unaccent ($6)
|
||||||
limit
|
limit
|
||||||
@@ -893,90 +1045,47 @@ where
|
|||||||
and "person"."name" != $2
|
and "person"."name" != $2
|
||||||
)
|
)
|
||||||
|
|
||||||
-- PersonRepository.getNumberOfPeople
|
-- PersonRepository.getStatistics
|
||||||
select
|
select
|
||||||
coalesce(count(*), 0) as "total",
|
count(distinct "visible"."assetId") as "assets"
|
||||||
coalesce(
|
|
||||||
count(*) filter (
|
|
||||||
where
|
|
||||||
"people"."isHidden" = $1
|
|
||||||
),
|
|
||||||
0
|
|
||||||
) as "hidden"
|
|
||||||
from
|
from
|
||||||
(
|
(
|
||||||
with
|
|
||||||
"similarity_threshold" as (
|
|
||||||
select
|
|
||||||
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
|
|
||||||
)
|
|
||||||
select
|
select
|
||||||
"owned"."isHidden"
|
"asset_face"."assetId"
|
||||||
from
|
from
|
||||||
"similarity_threshold",
|
"asset_face"
|
||||||
"person_group"
|
|
||||||
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
|
|
||||||
and "owned"."ownerId" = $2
|
|
||||||
inner join (
|
|
||||||
select
|
|
||||||
"asset_face"."personGroupId",
|
|
||||||
count("asset"."id") as "faceCount"
|
|
||||||
from
|
|
||||||
"asset_face"
|
|
||||||
inner join "asset" on "asset"."id" = "asset_face"."assetId"
|
|
||||||
and "asset"."visibility" = 'timeline'
|
|
||||||
and "asset"."deletedAt" is null
|
|
||||||
and (
|
|
||||||
"asset"."ownerId" = any ($3::uuid[])
|
|
||||||
or exists (
|
|
||||||
select
|
|
||||||
1 as "exists"
|
|
||||||
from
|
|
||||||
"album_asset"
|
|
||||||
inner join "album" on "album"."id" = "album_asset"."albumId"
|
|
||||||
and "album"."deletedAt" is null
|
|
||||||
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
|
||||||
and "album_user"."userId" = $4::uuid
|
|
||||||
where
|
|
||||||
"album_asset"."assetId" = "asset"."id"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
where
|
|
||||||
"asset_face"."personGroupId" = any (
|
|
||||||
array(
|
|
||||||
(
|
|
||||||
select
|
|
||||||
"person"."personGroupId"
|
|
||||||
from
|
|
||||||
"person"
|
|
||||||
where
|
|
||||||
"person"."ownerId" = $5
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and "asset_face"."deletedAt" is null
|
|
||||||
and "asset_face"."isVisible" is true
|
|
||||||
group by
|
|
||||||
"asset_face"."personGroupId"
|
|
||||||
) as "faces" on "faces"."personGroupId" = "person_group"."id"
|
|
||||||
where
|
where
|
||||||
(
|
"asset_face"."deletedAt" is null
|
||||||
"owned"."name" != $6
|
and "asset_face"."isVisible" is true
|
||||||
or "faces"."faceCount" >= COALESCE(
|
and "asset_face"."assetVisibility" = 'timeline'
|
||||||
(
|
and "asset_face"."assetDeletedAt" is null
|
||||||
SELECT
|
and "asset_face"."personGroupId" = $1
|
||||||
value -> 'people' ->> 'minimumFaces'
|
and "asset_face"."ownerId" = any ($2::uuid[])
|
||||||
FROM
|
union all
|
||||||
user_metadata
|
select
|
||||||
WHERE
|
"asset_face"."assetId"
|
||||||
"userId" = $7
|
from
|
||||||
AND key = 'preferences'
|
"asset_face"
|
||||||
),
|
where
|
||||||
'3'
|
"asset_face"."deletedAt" is null
|
||||||
)::int
|
and "asset_face"."isVisible" is true
|
||||||
|
and "asset_face"."assetVisibility" = 'timeline'
|
||||||
|
and "asset_face"."assetDeletedAt" is null
|
||||||
|
and "asset_face"."personGroupId" = $3
|
||||||
|
and not "asset_face"."ownerId" = any ($4::uuid[])
|
||||||
|
and exists (
|
||||||
|
select
|
||||||
|
1 as "exists"
|
||||||
|
from
|
||||||
|
"album_asset"
|
||||||
|
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||||
|
and "album"."deletedAt" is null
|
||||||
|
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
||||||
|
and "album_user"."userId" = $5
|
||||||
|
where
|
||||||
|
"album_asset"."assetId" = "asset_face"."assetId"
|
||||||
)
|
)
|
||||||
and 1 = 1
|
) as "visible"
|
||||||
) as "people"
|
|
||||||
|
|
||||||
-- PersonRepository.createGroup
|
-- PersonRepository.createGroup
|
||||||
insert into
|
insert into
|
||||||
|
|||||||
@@ -528,6 +528,26 @@ class ClusterGroupRequestAccess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PasskeyAccess {
|
||||||
|
constructor(private db: Kysely<DB>) {}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||||
|
@ChunkedSet({ paramIndex: 1 })
|
||||||
|
async checkOwnerAccess(userId: string, passkeyIds: Set<string>) {
|
||||||
|
if (passkeyIds.size === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.selectFrom('passkey')
|
||||||
|
.select('passkey.id')
|
||||||
|
.where('passkey.id', 'in', [...passkeyIds])
|
||||||
|
.where('passkey.userId', '=', userId)
|
||||||
|
.execute()
|
||||||
|
.then((passkeys) => new Set(passkeys.map((passkey) => passkey.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PersonAccess {
|
class PersonAccess {
|
||||||
constructor(private db: Kysely<DB>) {}
|
constructor(private db: Kysely<DB>) {}
|
||||||
|
|
||||||
@@ -673,6 +693,7 @@ export class AccessRepository {
|
|||||||
clusterGroupRequest: ClusterGroupRequestAccess;
|
clusterGroupRequest: ClusterGroupRequestAccess;
|
||||||
person: PersonAccess;
|
person: PersonAccess;
|
||||||
partner: PartnerAccess;
|
partner: PartnerAccess;
|
||||||
|
passkey: PasskeyAccess;
|
||||||
session: SessionAccess;
|
session: SessionAccess;
|
||||||
stack: StackAccess;
|
stack: StackAccess;
|
||||||
tag: TagAccess;
|
tag: TagAccess;
|
||||||
@@ -692,6 +713,7 @@ export class AccessRepository {
|
|||||||
this.clusterGroupRequest = new ClusterGroupRequestAccess(db);
|
this.clusterGroupRequest = new ClusterGroupRequestAccess(db);
|
||||||
this.person = new PersonAccess(db);
|
this.person = new PersonAccess(db);
|
||||||
this.partner = new PartnerAccess(db);
|
this.partner = new PartnerAccess(db);
|
||||||
|
this.passkey = new PasskeyAccess(db);
|
||||||
this.session = new SessionAccess(db);
|
this.session = new SessionAccess(db);
|
||||||
this.stack = new StackAccess(db);
|
this.stack = new StackAccess(db);
|
||||||
this.tag = new TagAccess(db);
|
this.tag = new TagAccess(db);
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ export class AssetRepository {
|
|||||||
qb.where((eb) => {
|
qb.where((eb) => {
|
||||||
// TODO this should become a shared `hasAccess` style helper once implement sharing in more places
|
// TODO this should become a shared `hasAccess` style helper once implement sharing in more places
|
||||||
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
|
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
|
||||||
return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner;
|
return options.personId ? eb.or([isOwner, inSharedAlbum(auth.user.id, 'asset.id')]) : isOwner;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||||
@@ -884,7 +884,7 @@ export class AssetRepository {
|
|||||||
.$if(!!options.userIds, (qb) =>
|
.$if(!!options.userIds, (qb) =>
|
||||||
qb.where((eb) => {
|
qb.where((eb) => {
|
||||||
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
|
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
|
||||||
return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner;
|
return options.personId ? eb.or([isOwner, inSharedAlbum(auth.user.id, 'asset.id')]) : isOwner;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import type { Insertable, Kysely } from 'kysely';
|
||||||
|
import { DummyValue, GenerateSql } from 'src/decorators.js';
|
||||||
|
import { AuthChallengeType } from 'src/enum.js';
|
||||||
|
import { DB } from 'src/schema/index.js';
|
||||||
|
import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthChallengeRepository {
|
||||||
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
||||||
|
create(dto: Insertable<AuthChallengeTable>) {
|
||||||
|
return this.db.insertInto('auth_challenge').values(dto).executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ type: AuthChallengeType.PasskeyRegistration, challenge: DummyValue.STRING }] })
|
||||||
|
consume({ type, challenge }: { type: AuthChallengeType; challenge: string }) {
|
||||||
|
return this.db
|
||||||
|
.deleteFrom('auth_challenge')
|
||||||
|
.where('challenge', '=', challenge)
|
||||||
|
.where('type', '=', type)
|
||||||
|
.where('expiresAt', '>', new Date())
|
||||||
|
.returning(['id', 'userId'])
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql()
|
||||||
|
async deleteExpired() {
|
||||||
|
const result = await this.db.deleteFrom('auth_challenge').where('expiresAt', '<=', new Date()).executeTakeFirst();
|
||||||
|
return Number(result.numDeletedRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,28 @@ describe(EmailRepository.name, () => {
|
|||||||
expect(result.text).toContain('Vacation');
|
expect(result.text).toContain('Vacation');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should escape user controlled values in a custom template', async () => {
|
||||||
|
const request: EmailRenderRequest = {
|
||||||
|
template: EmailTemplate.ALBUM_INVITE,
|
||||||
|
data: {
|
||||||
|
albumName: '</p><a href="https://example.com">Reset your password</a><p>',
|
||||||
|
albumId: '123',
|
||||||
|
senderName: '<script>alert(1)</script>',
|
||||||
|
recipientName: "Tom & Jerry O'Brien",
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
},
|
||||||
|
customTemplate: '<p>Hey {recipientName}, {senderName} added you to {albumName}</p>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sut.renderEmail(request);
|
||||||
|
|
||||||
|
expect(result.html).toContain('<script>alert(1)</script>');
|
||||||
|
expect(result.html).toContain('<a href="https://example.com">');
|
||||||
|
expect(result.html).not.toContain('<script>alert(1)</script>');
|
||||||
|
expect(result.html).not.toContain('<a href="https://example.com">');
|
||||||
|
expect(result.html).toContain('<p>Hey Tom & Jerry O'Brien');
|
||||||
|
});
|
||||||
|
|
||||||
it('should render the email correctly for ALBUM_UPDATE template', async () => {
|
it('should render the email correctly for ALBUM_UPDATE template', async () => {
|
||||||
const request: EmailRenderRequest = {
|
const request: EmailRenderRequest = {
|
||||||
template: EmailTemplate.ALBUM_UPDATE,
|
template: EmailTemplate.ALBUM_UPDATE,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user