Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d17640a283 | ||
|
|
9e889bd4dc | ||
|
|
ca4d8d5a7a | ||
|
|
5f54700344 | ||
|
|
0991f506bb | ||
|
|
fa77f168a0 | ||
|
|
3aa65809b1 | ||
|
|
9a66f16102 | ||
|
|
493870ad7b | ||
|
|
1e20bdd74e | ||
|
|
99a7830f6c | ||
|
|
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,
|
||||
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
|
||||
attributes:
|
||||
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
|
||||
description: Report an issue with Immich
|
||||
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
|
||||
attributes:
|
||||
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
|
||||
# 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 }}
|
||||
runs-on: macos-26
|
||||
runs-on: xcode-27
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
@@ -214,8 +214,8 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
permission-contents: read
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
- name: Select Xcode 27.1
|
||||
run: sudo xcode-select -s /Applications/Xcode_27.1.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
||||
uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a 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:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
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:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -22,3 +22,4 @@ jobs:
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
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
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.12.6"
|
||||
version = "1.13.1"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
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-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"
|
||||
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
|
||||
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/603708055"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
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.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"
|
||||
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
|
||||
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/603708017"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
|
||||
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
|
||||
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/603708059"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
|
||||
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
|
||||
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/603708142"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
|
||||
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
|
||||
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/603708102"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.0.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.6"
|
||||
opentofu = "1.13.1"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.11-ubuntu@sha256:23f2328b859c7677c7065c81b479600706ef9cc9004977af5bbd68b159b8ea60
|
||||
image: grafana/grafana:13.2.4-ubuntu@sha256:d838696907b4350d9232eb8da1503dc1d1010a0359d3ce4b3f7d2ff7db28eb75
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -209,6 +209,11 @@ The default configuration looks like this:
|
||||
"timeout": 30000,
|
||||
"tokenEndpointAuthMethod": "client_secret_post"
|
||||
},
|
||||
"passkey": {
|
||||
"enabled": true,
|
||||
"domain": null,
|
||||
"additionalDomains": []
|
||||
},
|
||||
"passwordLogin": {
|
||||
"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
|
||||
|
||||
[[tools.wrangler]]
|
||||
version = "4.137.0"
|
||||
version = "4.151.0"
|
||||
backend = "npm:wrangler"
|
||||
|
||||
[tools.wrangler.options]
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.137.0"
|
||||
wrangler = "4.151.0"
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^75.0.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"exiftool-vendored": "^39.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
"pg": "^8.11.3",
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('/search', () => {
|
||||
'New York',
|
||||
'Shanghai',
|
||||
'State of Berlin',
|
||||
'St.-Petersburg',
|
||||
'St. Petersburg',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
'Virginia',
|
||||
@@ -591,7 +591,7 @@ describe('/search', () => {
|
||||
'New York',
|
||||
'Shanghai',
|
||||
'State of Berlin',
|
||||
'St.-Petersburg',
|
||||
'St. Petersburg',
|
||||
'Tbilisi',
|
||||
'Tokyo',
|
||||
'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 () => {
|
||||
const { status, body } = await request(app)
|
||||
.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.getByLabel('Email').fill('admin@immich.app');
|
||||
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
|
||||
await expect(page).toHaveURL('/auth/onboarding');
|
||||
@@ -66,11 +66,12 @@ test.describe('Registration', () => {
|
||||
await page.goto('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// change password
|
||||
await expect(page.getByRole('heading')).toHaveText('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('Confirm Password').fill('new-password');
|
||||
await page.getByRole('button', { name: 'Change password' }).click();
|
||||
@@ -79,7 +80,7 @@ test.describe('Registration', () => {
|
||||
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password').fill('new-password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
||||
|
||||
// onboarding
|
||||
await expect(page).toHaveURL('/auth/onboarding');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { playwrightHost } from 'src/../playwright.config.js';
|
||||
import { adminConfig, publicConfig, userConfig } from 'src/ui/mock-network/config-fixtures.js';
|
||||
|
||||
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
||||
await context.addCookies([
|
||||
@@ -113,6 +114,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/public/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: publicConfig,
|
||||
});
|
||||
});
|
||||
await context.route('**/api/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: userConfig,
|
||||
});
|
||||
});
|
||||
await context.route('**/api/admin/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: adminConfig,
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/features', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
export const publicConfig = {
|
||||
ffmpeg: { realtime: { enabled: false } },
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false },
|
||||
passkey: { enabled: true },
|
||||
passwordLogin: { enabled: true },
|
||||
server: { loginPageMessage: '' },
|
||||
};
|
||||
|
||||
export const userConfig = {
|
||||
ffmpeg: { realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] } },
|
||||
machineLearning: {
|
||||
enabled: false,
|
||||
clip: { enabled: true },
|
||||
duplicateDetection: { enabled: true },
|
||||
facialRecognition: { enabled: true, minFaces: 3 },
|
||||
ocr: { enabled: true },
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
reverseGeocoding: { enabled: true },
|
||||
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false, accountManagementUrl: '' },
|
||||
passkey: { enabled: true },
|
||||
passwordLogin: { enabled: true },
|
||||
image: { thumbnail: { size: 250 }, preview: { size: 1440 }, fullsize: { enabled: false } },
|
||||
trash: { enabled: true, days: 30 },
|
||||
notifications: { smtp: { enabled: false } },
|
||||
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
|
||||
user: { deleteDelay: 7 },
|
||||
};
|
||||
|
||||
export const adminConfig = {
|
||||
backup: { database: { enabled: true, cronExpression: '0 02 * * *', keepLastAmount: 14 } },
|
||||
ffmpeg: {
|
||||
crf: 23,
|
||||
threads: 0,
|
||||
preset: 'ultrafast',
|
||||
targetVideoCodec: 'h264',
|
||||
acceptedVideoCodecs: ['h264'],
|
||||
targetAudioCodec: 'aac',
|
||||
acceptedAudioCodecs: ['aac', 'mp3', 'opus'],
|
||||
acceptedContainers: ['mov', 'ogg', 'webm'],
|
||||
targetResolution: '720',
|
||||
maxBitrate: '0',
|
||||
bframes: -1,
|
||||
refs: 0,
|
||||
gopSize: 0,
|
||||
temporalAQ: false,
|
||||
cqMode: 'auto',
|
||||
twoPass: false,
|
||||
preferredHwDevice: 'auto',
|
||||
transcode: 'required',
|
||||
tonemap: 'hable',
|
||||
accel: 'disabled',
|
||||
accelDecode: true,
|
||||
realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] },
|
||||
},
|
||||
integrityChecks: {
|
||||
missingFiles: { enabled: true, cronExpression: '0 03 * * *' },
|
||||
untrackedFiles: { enabled: true, cronExpression: '0 03 * * *' },
|
||||
checksumFiles: { enabled: true, cronExpression: '0 03 * * *', timeLimit: 3_600_000, percentageLimit: 1 },
|
||||
},
|
||||
job: {
|
||||
thumbnailGeneration: { concurrency: 3 },
|
||||
metadataExtraction: { concurrency: 5 },
|
||||
videoConversion: { concurrency: 1 },
|
||||
faceDetection: { concurrency: 2 },
|
||||
smartSearch: { concurrency: 2 },
|
||||
backgroundTask: { concurrency: 5 },
|
||||
migration: { concurrency: 5 },
|
||||
search: { concurrency: 5 },
|
||||
sidecar: { concurrency: 5 },
|
||||
library: { concurrency: 5 },
|
||||
notifications: { concurrency: 5 },
|
||||
ocr: { concurrency: 1 },
|
||||
workflow: { concurrency: 5 },
|
||||
editor: { concurrency: 2 },
|
||||
integrityCheck: { concurrency: 1 },
|
||||
},
|
||||
logging: { enabled: true, level: 'log' },
|
||||
machineLearning: {
|
||||
enabled: false,
|
||||
urls: ['http://immich-machine-learning:3003'],
|
||||
availabilityChecks: { enabled: true, timeout: 2000, interval: 30_000 },
|
||||
clip: { enabled: true, modelName: 'ViT-B-32__openai' },
|
||||
duplicateDetection: { enabled: true, maxDistance: 0.01 },
|
||||
facialRecognition: { enabled: true, modelName: 'buffalo_l', minScore: 0.7, maxDistance: 0.5, minFaces: 3 },
|
||||
ocr: {
|
||||
enabled: true,
|
||||
modelName: 'PP-OCRv5_mobile',
|
||||
minDetectionScore: 0.5,
|
||||
minRecognitionScore: 0.8,
|
||||
maxResolution: 736,
|
||||
},
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
reverseGeocoding: { enabled: true },
|
||||
metadata: { faces: { import: false } },
|
||||
oauth: {
|
||||
autoLaunch: false,
|
||||
autoRegister: true,
|
||||
buttonText: 'Login with OAuth',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
defaultStorageQuota: null,
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
accountManagementUrl: '',
|
||||
endSessionEndpoint: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
prompt: '',
|
||||
scope: 'openid email profile',
|
||||
signingAlgorithm: 'RS256',
|
||||
profileSigningAlgorithm: 'none',
|
||||
storageLabelClaim: 'preferred_username',
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
roleClaim: 'immich_role',
|
||||
tokenEndpointAuthMethod: 'client_secret_post',
|
||||
timeout: 30_000,
|
||||
allowInsecureRequests: false,
|
||||
},
|
||||
passkey: { enabled: true, domain: null, additionalDomains: [] },
|
||||
passwordLogin: { enabled: true },
|
||||
storageTemplate: {
|
||||
enabled: false,
|
||||
hashVerificationEnabled: true,
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
image: {
|
||||
thumbnail: { format: 'webp', size: 250, quality: 80, progressive: false },
|
||||
preview: { format: 'jpeg', size: 1440, quality: 80, progressive: false },
|
||||
colorspace: 'p3',
|
||||
extractEmbedded: false,
|
||||
fullsize: { enabled: false, format: 'jpeg', quality: 80, progressive: false },
|
||||
},
|
||||
newVersionCheck: { enabled: true, channel: 'stable' },
|
||||
nightlyTasks: {
|
||||
startTime: '00:00',
|
||||
databaseCleanup: true,
|
||||
generateMemories: true,
|
||||
syncQuotaUsage: true,
|
||||
missingThumbnails: true,
|
||||
clusterNewFaces: true,
|
||||
},
|
||||
trash: { enabled: true, days: 30 },
|
||||
theme: { customCss: '' },
|
||||
library: { scan: { enabled: true, cronExpression: '0 0 * * *' }, watch: { enabled: false } },
|
||||
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
|
||||
notifications: {
|
||||
smtp: {
|
||||
enabled: false,
|
||||
from: '',
|
||||
replyTo: '',
|
||||
transport: { ignoreCert: false, host: '', port: 587, secure: false, username: '', password: '' },
|
||||
},
|
||||
},
|
||||
templates: { email: { welcomeTemplate: '', albumInviteTemplate: '', albumUpdateTemplate: '' } },
|
||||
user: { deleteDelay: 7 },
|
||||
};
|
||||
@@ -324,6 +324,13 @@
|
||||
"oauth_timeout": "Request Timeout",
|
||||
"oauth_timeout_description": "Timeout for requests in milliseconds",
|
||||
"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_settings": "Password Login",
|
||||
"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_force": "Delete Anyway",
|
||||
"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_action_prompt": "{count} deleted permanently",
|
||||
"delete_shared_link": "Delete shared link",
|
||||
@@ -943,6 +952,7 @@
|
||||
"edit_location_action_prompt": "{count} location edited",
|
||||
"edit_location_dialog_title": "Location",
|
||||
"edit_name": "Edit name",
|
||||
"edit_passkey": "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 {Last month} other {Last # months}}",
|
||||
"last_seen": "Last seen",
|
||||
"last_used": "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": "There was an error updating your password",
|
||||
"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_this_device_confirmation": "Are you sure you want to log out this device?",
|
||||
"logs": "Logs",
|
||||
@@ -1427,6 +1439,7 @@
|
||||
"manage_your_api_keys": "Manage your API keys",
|
||||
"manage_your_devices": "Manage your logged-in devices",
|
||||
"manage_your_oauth_connection": "Manage your OAuth connection",
|
||||
"manage_your_passkeys": "Manage your passkeys",
|
||||
"map": "Map",
|
||||
"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",
|
||||
@@ -1546,6 +1559,7 @@
|
||||
"new_album": "New Album",
|
||||
"new_api_key": "New API Key",
|
||||
"new_feature": "New Feature",
|
||||
"new_passkey": "New passkey",
|
||||
"new_password": "New password",
|
||||
"new_person": "New person",
|
||||
"new_pin_code": "New PIN code",
|
||||
@@ -1658,6 +1672,8 @@
|
||||
"partner_sharing": "Partner Sharing",
|
||||
"partners": "Partners",
|
||||
"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_does_not_match": "Password does not match",
|
||||
"password_required": "Password Required",
|
||||
@@ -1842,6 +1858,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
|
||||
"removed_from_stack": "Removed asset from stack",
|
||||
"removed_memory": "Removed memory",
|
||||
"removed_passkey": "Removed passkey: {name}",
|
||||
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
|
||||
"rename": "Rename",
|
||||
"repository": "Repository",
|
||||
@@ -2025,6 +2042,7 @@
|
||||
"share_everyone": "Share everyone",
|
||||
"share_everyone_description": "Give {user} access to all of your people.",
|
||||
"share_link": "Share Link",
|
||||
"share_logs": "Share Logs",
|
||||
"share_original": "Use original (large)",
|
||||
"share_preview": "Use preview (small)",
|
||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||
@@ -2203,6 +2221,8 @@
|
||||
"theme": "Theme",
|
||||
"theme_selection": "Theme selection",
|
||||
"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_tiles_per_row_title": "Number of assets per row ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
|
||||
|
||||
@@ -323,6 +323,13 @@
|
||||
"oauth_timeout": "Таймаут для запросов",
|
||||
"oauth_timeout_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_settings": "Настройки входа с паролем",
|
||||
"password_settings_description": "Управление настройками входа по паролю",
|
||||
@@ -873,6 +880,8 @@
|
||||
"delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии",
|
||||
"delete_local_dialog_ok_force": "Все равно удалить",
|
||||
"delete_others": "Удалить остальные",
|
||||
"delete_passkey": "Удалить ключ доступа",
|
||||
"delete_passkey_prompt": "Удалить этот ключ доступа?",
|
||||
"delete_permanently": "Удалить навсегда",
|
||||
"delete_permanently_action_prompt": "Объекты удалены безвозвратно ({count} шт.)",
|
||||
"delete_shared_link": "Удалить публичную ссылку",
|
||||
@@ -942,6 +951,7 @@
|
||||
"edit_location_action_prompt": "Места изменены ({count} шт.)",
|
||||
"edit_location_dialog_title": "Местоположение",
|
||||
"edit_name": "Изменить имя",
|
||||
"edit_passkey": "Изменить ключ доступа",
|
||||
"edit_people": "Изменить людей",
|
||||
"edit_person": "Изменить человека",
|
||||
"edit_tag": "Изменить тег",
|
||||
@@ -1287,6 +1297,7 @@
|
||||
"last": "Последний",
|
||||
"last_months": "{count, plural, one {Последний месяц} many {# последних месяцев} other {# последних месяца}}",
|
||||
"last_seen": "Последний доступ",
|
||||
"last_used": "Последнее использование",
|
||||
"latest_version": "Последняя версия",
|
||||
"latitude": "Широта",
|
||||
"leave": "Покинуть",
|
||||
@@ -1379,6 +1390,7 @@
|
||||
"login_has_been_disabled": "Вход в систему был отключён",
|
||||
"login_password_changed_error": "Произошла ошибка при обновлении пароля",
|
||||
"login_password_changed_success": "Пароль успешно обновлен",
|
||||
"login_with_passkey": "Войти с ключом доступа",
|
||||
"logout_all_device_confirmation": "Вы действительно хотите завершить все сеансы, кроме текущего?",
|
||||
"logout_this_device_confirmation": "Вы действительно хотите завершить сеанс на этом устройстве?",
|
||||
"logs": "Журнал событий",
|
||||
@@ -1427,6 +1439,7 @@
|
||||
"manage_your_api_keys": "Управление API ключами для взаимодействия с другими программами",
|
||||
"manage_your_devices": "Управление устройствами, на которых вы входили в свой аккаунт",
|
||||
"manage_your_oauth_connection": "Настройки подключённого OAuth",
|
||||
"manage_your_passkeys": "Управление ключами доступа",
|
||||
"map": "Карта",
|
||||
"map_assets_in_bounds": "{count, plural, =0 {Нет фото в этой области} other {# фото}}",
|
||||
"map_cannot_get_user_location": "Невозможно получить местоположение пользователя",
|
||||
@@ -1546,6 +1559,7 @@
|
||||
"new_album": "Новый альбом",
|
||||
"new_api_key": "Новый API ключ",
|
||||
"new_feature": "Новая функциональность",
|
||||
"new_passkey": "Новый ключ доступа",
|
||||
"new_password": "Новый пароль",
|
||||
"new_person": "Новый человек",
|
||||
"new_pin_code": "Новый PIN-код",
|
||||
@@ -1658,6 +1672,8 @@
|
||||
"partner_sharing": "Совместное использование",
|
||||
"partners": "Партнёры",
|
||||
"partners_description": "Делитесь своими фото и видео с другими пользователями",
|
||||
"passkeys": "{count, plural, one {Ключ доступа} few {Ключи доступа} many {Ключи доступа} other {Ключи доступа}}",
|
||||
"passkeys_not_supported": "Этот браузер не поддерживает ключи доступа",
|
||||
"password": "Пароль",
|
||||
"password_does_not_match": "Пароли не совпадают",
|
||||
"password_required": "Требуется пароль",
|
||||
@@ -1842,6 +1858,7 @@
|
||||
"removed_from_favorites_count": "{count, plural, one {# объект удалён} many {# объектов удалено} other {# объекта удалено}} из избранного",
|
||||
"removed_from_stack": "Объект удалён из группы",
|
||||
"removed_memory": "Воспоминание удалено",
|
||||
"removed_passkey": "Удалён ключ доступа: {name}",
|
||||
"removed_tagged_assets": "Тег удалён у {count, plural, one {# объекта} other {# объектов}}",
|
||||
"rename": "Переименовать",
|
||||
"repository": "Репозиторий",
|
||||
@@ -2027,6 +2044,7 @@
|
||||
"share_everyone": "Поделиться со всеми",
|
||||
"share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.",
|
||||
"share_link": "Создать ссылку",
|
||||
"share_logs": "Поделиться журналами",
|
||||
"share_original": "Оригинал (большой размер)",
|
||||
"share_preview": "Предпросмотр (небольшой размер)",
|
||||
"share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.",
|
||||
@@ -2206,6 +2224,8 @@
|
||||
"theme": "Тема",
|
||||
"theme_selection": "Выбор темы",
|
||||
"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_tiles_per_row_title": "Количество объектов в строке ({count})",
|
||||
"theme_setting_colorful_interface_subtitle": "Добавить оттенок к фону.",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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
|
||||
ARG ONNXRUNTIME_VERSION="v1.30.0"
|
||||
@@ -59,7 +59,7 @@ RUN mkdir /opt/armnn && \
|
||||
cd /opt/ann && \
|
||||
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
|
||||
# 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 && \
|
||||
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
|
||||
|
||||
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 && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
@@ -141,7 +141,7 @@ COPY --from=builder-armnn \
|
||||
/opt/ann/build.sh \
|
||||
/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
|
||||
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/
|
||||
|
||||
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 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends libmimalloc3/trixie-backports && \
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections.abc import Hashable
|
||||
from typing import Any
|
||||
|
||||
from immich_ml import allocator
|
||||
from immich_ml.config import clean_name
|
||||
from immich_ml.models.base import InferenceEntry, InferenceModel
|
||||
from immich_ml.schemas import Options
|
||||
|
||||
@@ -15,7 +16,8 @@ class ModelCache:
|
||||
self._expiries: dict[Key, asyncio.TimerHandle] = {}
|
||||
|
||||
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:
|
||||
self._models[key] = entry.model.create(entry.name, entry.options)
|
||||
elif key not in self._expiries:
|
||||
|
||||
@@ -1856,6 +1856,14 @@ class TestCache:
|
||||
assert len(model_cache._models) == 1
|
||||
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:
|
||||
# the real TextDetector, so that keying it on anything but its resolution fails this
|
||||
model_cache = ModelCache()
|
||||
|
||||
Generated
+3
-3
@@ -175,11 +175,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
version = "2026.10.19"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
[[tools.node]]
|
||||
version = "24.21.0"
|
||||
version = "24.22.1"
|
||||
backend = "core:node"
|
||||
specifiers = ["24.22.1"]
|
||||
|
||||
[tools.node."platforms.linux-arm64"]
|
||||
checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz"
|
||||
checksum = "sha256:33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
|
||||
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"]
|
||||
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz"
|
||||
checksum = "sha256:eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
|
||||
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"]
|
||||
checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz"
|
||||
checksum = "sha256:89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz"
|
||||
|
||||
[tools.node."platforms.macos-x64"]
|
||||
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz"
|
||||
checksum = "sha256:d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz"
|
||||
|
||||
[tools.node."platforms.windows-x64"]
|
||||
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
|
||||
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip"
|
||||
checksum = "sha256:d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5"
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip"
|
||||
|
||||
[[tools."npm:@openapitools/openapi-generator-cli"]]
|
||||
version = "2.41.0"
|
||||
@@ -252,13 +253,14 @@ version = "7.5.0"
|
||||
backend = "npm:oazapfts"
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.12.6"
|
||||
version = "1.13.1"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
specifiers = ["1.13.1"]
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
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"
|
||||
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
|
||||
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/603708055"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||
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"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
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"
|
||||
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
|
||||
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/603708017"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64-musl"]
|
||||
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"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710"
|
||||
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
|
||||
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/603708059"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655"
|
||||
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
|
||||
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/603708142"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708"
|
||||
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
|
||||
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/603708102"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "11.27.1"
|
||||
version = "12.14.0"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
specifiers = ["12.14.0"]
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:dbf8f84263d2bc696306d4219be68256d881d9af409345374d878676a7edcf0c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602580"
|
||||
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"
|
||||
checksum = "sha256:cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea"
|
||||
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/643957409"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:6be1426540406747b7731564a46c5b30d3023ea6c19e4ce99f994c4ec122f3d2"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602576"
|
||||
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"
|
||||
checksum = "sha256:3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e"
|
||||
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/643957410"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:352f67ceaaa5aba294a53f483da93c376005b4c7e785fe5411e0ce720fe74741"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602575"
|
||||
checksum = "sha256:50f48e0e17508952790a3fbf58fcf14f3b82efa5f594ca04ad904e29aaf854b0"
|
||||
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/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"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:4b56d1184c3390b08146060fa4b414b8ceec9164aa41bf996e87a0f0bc135851"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602578"
|
||||
checksum = "sha256:6f0f38e917c8ce76ef31db528c357e2bfa354a1bcb16464e3cdec39e6530d784"
|
||||
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/643957404"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
|
||||
@@ -15,10 +15,10 @@ config_roots = [
|
||||
]
|
||||
|
||||
[tools]
|
||||
node = "24.21.0"
|
||||
pnpm = "11.27.1"
|
||||
node = "24.22.1"
|
||||
pnpm = "12.14.0"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.12.6"
|
||||
opentofu = "1.13.1"
|
||||
"npm:@openapitools/openapi-generator-cli" = "2.41.0"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
|
||||
@@ -122,7 +122,7 @@ class Drift extends $Drift {
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await super.close();
|
||||
_updatePool?.close();
|
||||
await _updatePool?.close();
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
|
||||
@@ -75,6 +75,7 @@ abstract class AppConfig with _$AppConfig {
|
||||
.timelineTilesPerRow => timeline.tilesPerRow,
|
||||
.timelineGroupAssetsBy => timeline.groupAssetsBy,
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.timelineSmoothScrubbing => timeline.smoothScrubbing,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapCustomFrom => map.customFrom,
|
||||
@@ -131,6 +132,7 @@ abstract class AppConfig with _$AppConfig {
|
||||
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
|
||||
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
|
||||
.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)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
|
||||
|
||||
@@ -10,5 +10,6 @@ abstract class TimelineConfig with _$TimelineConfig {
|
||||
@Default(4) int tilesPerRow,
|
||||
@Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
|
||||
@Default(true) bool storageIndicator,
|
||||
@Default(false) bool smoothScrubbing,
|
||||
}) = _TimelineConfig;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ enum SettingsKey<T> {
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
timelineSmoothScrubbing<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' show ClientException;
|
||||
import 'package:immich_mobile/constants/constants.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/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/settings.repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart' show ApiException;
|
||||
|
||||
/// 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
|
||||
/// [SettingsRepository].
|
||||
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 SettingsRepository _settingsRepository;
|
||||
final StoreService _storeService;
|
||||
|
||||
final List<LogMessage> _msgBuffer = [];
|
||||
|
||||
@@ -39,11 +50,13 @@ class LogService {
|
||||
static Future<LogService> init({
|
||||
required LogRepository logRepository,
|
||||
required SettingsRepository settingsRepository,
|
||||
required StoreService storeService,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
_instance ??= await create(
|
||||
logRepository: logRepository,
|
||||
settingsRepository: settingsRepository,
|
||||
storeService: storeService,
|
||||
shouldBuffer: shouldBuffer,
|
||||
);
|
||||
return _instance!;
|
||||
@@ -52,16 +65,17 @@ class LogService {
|
||||
static Future<LogService> create({
|
||||
required LogRepository logRepository,
|
||||
required SettingsRepository settingsRepository,
|
||||
required StoreService storeService,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, settingsRepository, shouldBuffer);
|
||||
final instance = LogService._(logRepository, settingsRepository, storeService, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = instance._settingsRepository.appConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
LogService._(this._logRepository, this._settingsRepository, this._shouldBuffer) {
|
||||
LogService._(this._logRepository, this._settingsRepository, this._storeService, this._shouldBuffer) {
|
||||
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
||||
}
|
||||
|
||||
@@ -74,12 +88,12 @@ class LogService {
|
||||
);
|
||||
|
||||
final record = LogMessage(
|
||||
message: r.message,
|
||||
message: _redact(r.message),
|
||||
level: r.level.toLogLevel(),
|
||||
createdAt: r.time,
|
||||
logger: r.loggerName,
|
||||
error: r.error?.toString(),
|
||||
stack: r.stackTrace?.toString(),
|
||||
error: r.error == null ? null : _redactError(r.error!),
|
||||
stack: r.stackTrace == null ? null : _redact(r.stackTrace.toString()),
|
||||
);
|
||||
|
||||
if (_shouldBuffer) {
|
||||
@@ -100,6 +114,64 @@ class LogService {
|
||||
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 {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
|
||||
@@ -74,6 +74,7 @@ void main() async {
|
||||
),
|
||||
);
|
||||
} catch (error, stack) {
|
||||
Logger('main').severe('Failed to start', error, stack);
|
||||
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/websocket.provider.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/theme_data.dart';
|
||||
import 'package:immich_mobile/utils/fallback_localizations.dart';
|
||||
@@ -78,9 +79,12 @@ class BootstrapErrorWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const SafeArea(
|
||||
SafeArea(
|
||||
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 {
|
||||
const _BottomPanel();
|
||||
final String startupError;
|
||||
|
||||
const _BottomPanel({required this.startupError});
|
||||
|
||||
@override
|
||||
State<_BottomPanel> createState() => _BottomPanelState();
|
||||
@@ -157,8 +163,8 @@ class _BottomPanelState extends State<_BottomPanel> {
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_ActionLink(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
@@ -173,6 +179,11 @@ class _BottomPanelState extends State<_BottomPanel> {
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
_ActionLink(
|
||||
icon: Icons.share_outlined,
|
||||
label: context.t.share_logs,
|
||||
onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError),
|
||||
),
|
||||
if (!_cleared)
|
||||
_ActionLink(
|
||||
icon: Icons.delete_outline,
|
||||
@@ -288,21 +299,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(
|
||||
ref
|
||||
.read(authProvider.notifier)
|
||||
.setOpenApiServiceEndpoint()
|
||||
.then(logConnectionInfo)
|
||||
.whenComplete(() => resumeSession()),
|
||||
);
|
||||
}
|
||||
|
||||
void logConnectionInfo(String? endpoint) {
|
||||
if (endpoint == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Resuming session at $endpoint");
|
||||
unawaited(ref.read(authProvider.notifier).setOpenApiServiceEndpoint().whenComplete(() => resumeSession()));
|
||||
}
|
||||
|
||||
Future<void> resumeSession() async {
|
||||
@@ -356,7 +353,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
}
|
||||
},
|
||||
onError: (exception) {
|
||||
log.severe('Failed to update auth info with access token: $accessToken');
|
||||
log.severe('Failed to update auth info', exception);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,14 +121,14 @@ class DeleteAction extends AssetActionBuilder {
|
||||
required bool isLocalOnly,
|
||||
}) async {
|
||||
final assetService = ref.read(assetServiceProvider);
|
||||
final message = context.t.trash_action_prompt(count: remoteIds.length);
|
||||
if (localIds.isNotEmpty) {
|
||||
await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
|
||||
if (!context.mounted) {
|
||||
final count = await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final message = context.t.trash_action_prompt(count: remoteIds.length);
|
||||
await assetService.trash(remoteIds);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -417,21 +417,57 @@ final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>(
|
||||
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 {
|
||||
const _QuickAccessButtonList();
|
||||
|
||||
static const _tileRadius = 20.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider);
|
||||
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(
|
||||
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
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(
|
||||
colors: [
|
||||
context.colorScheme.primary.withAlpha(10),
|
||||
@@ -442,83 +478,33 @@ class _QuickAccessButtonList extends ConsumerWidget {
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
ListTile(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == items.length - 1;
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(20),
|
||||
topRight: const Radius.circular(20),
|
||||
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
|
||||
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
|
||||
topLeft: Radius.circular(isFirst ? _tileRadius : 0),
|
||||
topRight: Radius.circular(isFirst ? _tileRadius : 0),
|
||||
bottomLeft: Radius.circular(isLast ? _tileRadius : 0),
|
||||
bottomRight: Radius.circular(isLast ? _tileRadius : 0),
|
||||
),
|
||||
),
|
||||
leading: const Icon(Icons.folder_outlined, size: 26),
|
||||
title: Text(
|
||||
context.t.folders,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
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()),
|
||||
],
|
||||
contentPadding: item.contentPadding,
|
||||
leading: item.leading,
|
||||
title: Text(item.title, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
|
||||
onTap: item.onTap,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 isBottomWidgetVisible =
|
||||
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
|
||||
final smoothScrubbing = ref.watch(appConfigProvider.select((config) => config.timeline.smoothScrubbing));
|
||||
final snapToMonth = widget.snapToMonth && !smoothScrubbing;
|
||||
|
||||
return PopScope(
|
||||
canPop: !isMultiSelectEnabled,
|
||||
@@ -409,7 +411,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
final Widget timeline;
|
||||
if (widget.withScrubber) {
|
||||
timeline = Scrubber(
|
||||
snapToMonth: widget.snapToMonth,
|
||||
snapToMonth: snapToMonth,
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
|
||||
@@ -84,8 +84,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
// Needs to be logged in
|
||||
if (isAuthenticated) {
|
||||
// switch endpoint if needed
|
||||
final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
|
||||
_log.info("Using server URL: $endpoint");
|
||||
await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
|
||||
|
||||
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/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
@@ -21,7 +20,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
final AppRouter router;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
@@ -74,8 +72,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
onSuccess: (fileId, _) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
onError: (fileId, _) {
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -221,8 +221,6 @@ class BackupNotifier extends StateNotifier<BackupState> {
|
||||
}
|
||||
|
||||
void _handleForegroundBackupError(String localAssetId, String errorMessage) {
|
||||
_logger.severe("Upload failed for $localAssetId: $errorMessage");
|
||||
|
||||
final currentItem = state.uploadItems[localAssetId];
|
||||
if (currentItem != null) {
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -88,7 +88,7 @@ class UploadRepository {
|
||||
} on RequestAbortedException {
|
||||
rethrow;
|
||||
} 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());
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ class UploadRepository {
|
||||
logger.warning("Upload $logContext was cancelled");
|
||||
return UploadResult.cancelled();
|
||||
} catch (error, stackTrace) {
|
||||
logger.warning("Error uploading $logContext: $error: $stackTrace");
|
||||
return UploadResult.error(errorMessage: error.toString());
|
||||
logger.warning("Error uploading $logContext", error, stackTrace);
|
||||
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: error.toString(), error: error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ class UploadResult {
|
||||
final String? remoteAssetId;
|
||||
final String? errorMessage;
|
||||
final int? statusCode;
|
||||
final Object? error;
|
||||
|
||||
const UploadResult({
|
||||
required this.isSuccess,
|
||||
@@ -173,6 +174,7 @@ class UploadResult {
|
||||
this.remoteAssetId,
|
||||
this.errorMessage,
|
||||
this.statusCode,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory UploadResult.success({required String remoteAssetId}) {
|
||||
|
||||
@@ -162,6 +162,7 @@ class AuthService {
|
||||
final localEndpoint = _authRepository.getLocalEndpoint();
|
||||
if (localEndpoint != null) {
|
||||
await _apiService.resolveAndSetEndpoint(localEndpoint);
|
||||
_log.info("Using local endpoint");
|
||||
return localEndpoint;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
@@ -181,9 +182,11 @@ class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final endpoint in endpointList) {
|
||||
for (var i = 0; i < endpointList.length; i++) {
|
||||
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) {
|
||||
_log.severe("Cannot resolve endpoint", error);
|
||||
continue;
|
||||
|
||||
@@ -182,6 +182,7 @@ class ForegroundUploadService {
|
||||
if (result.isSuccess) {
|
||||
onSuccess?.call(fileId, result.remoteAssetId!);
|
||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||
_logger.warning("Error(${result.statusCode}) uploading $fileId", result.error ?? result.errorMessage);
|
||||
onError?.call(fileId, result.errorMessage!);
|
||||
}
|
||||
},
|
||||
@@ -395,7 +396,8 @@ class ForegroundUploadService {
|
||||
} else if (result.errorMessage != null) {
|
||||
_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!);
|
||||
|
||||
@@ -2,7 +2,11 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:path_provider/path_provider.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
|
||||
/// and generate a csv file.
|
||||
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 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 io = logFile.openWrite();
|
||||
try {
|
||||
// Write messages
|
||||
for (final m in await LogService.I.getMessages()) {
|
||||
for (final m in await _read(startupError)) {
|
||||
final created = m.createdAt;
|
||||
final level = m.level.name.padRight(8);
|
||||
final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
|
||||
|
||||
@@ -26,7 +26,7 @@ class OAuthService {
|
||||
);
|
||||
|
||||
final authUrl = dto?.url;
|
||||
log.info('Received Authorization URL: $authUrl');
|
||||
log.info('Received Authorization URL');
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class OAuthService {
|
||||
Future<LoginResponseDto?> oAuthLogin(String oauthUrl, String state, String codeVerifier) async {
|
||||
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')) {
|
||||
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/domain/models/store.model.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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
@@ -72,6 +73,7 @@ abstract final class Bootstrap {
|
||||
await LogService.init(
|
||||
logRepository: LogRepository(dataController.logDb),
|
||||
settingsRepository: settingsRepo,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: shouldBufferLogs,
|
||||
);
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'ffmpeg': {
|
||||
'realtime': {'enabled': false},
|
||||
},
|
||||
'passkey': {'enabled': false},
|
||||
},
|
||||
'ServerConfigDto': {
|
||||
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
@@ -55,12 +56,16 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'PeopleResponse': {'updateStrategy': 'everyone'},
|
||||
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
|
||||
'ServerVersionResponseDto': {'featureLevel': 0},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
'AdminConfigDto': {
|
||||
'passkey': {'enabled': false, 'domain': null, 'additionalDomains': const []},
|
||||
},
|
||||
'UserConfigDto': {
|
||||
'notifications': {
|
||||
'smtp': {'enabled': false},
|
||||
},
|
||||
'passkey': {'enabled': false},
|
||||
},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
||||
// ignore: unused-code
|
||||
|
||||
@@ -36,64 +36,63 @@ class _MesmerizingSliverAppBarState extends ConsumerState<MesmerizingSliverAppBa
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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
|
||||
? SliverToBoxAdapter(
|
||||
child: switch (_scrollProgress) {
|
||||
< 0.8 => const SizedBox(height: 120),
|
||||
_ => const SizedBox(height: 352),
|
||||
},
|
||||
)
|
||||
: SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
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();
|
||||
},
|
||||
),
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
return SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: hideWhenSelecting(
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.title,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
|
||||
);
|
||||
},
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.title,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 List<Shadow> actionIconShadows = [
|
||||
if (_scrollProgress < 0.95)
|
||||
@@ -62,74 +66,74 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
|
||||
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
|
||||
];
|
||||
|
||||
return isMultiSelectEnabled
|
||||
? SliverToBoxAdapter(
|
||||
child: switch (_scrollProgress) {
|
||||
< 0.8 => const SizedBox(height: 120),
|
||||
_ => const SizedBox(height: 352),
|
||||
},
|
||||
)
|
||||
: SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(
|
||||
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,
|
||||
),
|
||||
return SliverAppBar(
|
||||
expandedHeight: 300.0,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
elevation: 0,
|
||||
leading: hideWhenSelecting(
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
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),
|
||||
],
|
||||
flexibleSpace: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||
final scrollProgress = _calculateScrollProgress(settings);
|
||||
),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
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
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
// Update scroll progress for the leading button
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollProgress != scrollProgress) {
|
||||
setState(() {
|
||||
_scrollProgress = scrollProgress;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.person.name,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: _ExpandedBackground(
|
||||
scrollProgress: scrollProgress,
|
||||
person: widget.person,
|
||||
onNameTap: widget.onNameTap,
|
||||
onBirthdayTap: widget.onBirthdayTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
return FlexibleSpaceBar(
|
||||
centerTitle: true,
|
||||
title: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: scrollProgress > 0.95
|
||||
? Text(
|
||||
widget.person.name,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
background: IgnorePointer(
|
||||
ignoring: isMultiSelectEnabled,
|
||||
child: _ExpandedBackground(
|
||||
scrollProgress: scrollProgress,
|
||||
person: widget.person,
|
||||
onNameTap: widget.onNameTap,
|
||||
onBirthdayTap: widget.onBirthdayTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class AssetListSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator)));
|
||||
final smoothScrubbing = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.smoothScrubbing)));
|
||||
|
||||
final assetListSetting = [
|
||||
SettingsSwitchListTile(
|
||||
@@ -27,6 +28,16 @@ class AssetListSettings extends HookConsumerWidget {
|
||||
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 GroupSettings(),
|
||||
];
|
||||
|
||||
@@ -139,6 +139,7 @@ depends = ["//:open-api-dart"]
|
||||
run = [
|
||||
# This receives passed `--enforce-lockfile` args
|
||||
"flutter pub get",
|
||||
{ task = "install:build-runner-patch" },
|
||||
{ task = "install:ios" },
|
||||
]
|
||||
|
||||
@@ -198,6 +199,11 @@ fi
|
||||
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"]
|
||||
description = "Run Dart analysis"
|
||||
hide = true
|
||||
|
||||
+2
-2
@@ -1664,10 +1664,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_connection_pool
|
||||
sha256: ad51972d37bdf82929888e4c85e722bd9a0b5ff8ad389f89b1b00d19603c0d2d
|
||||
sha256: ad8413d9100598ee9ef1a27628f5673b7d9212fb9d15b2742f7fde690f743832
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.7"
|
||||
version: "0.2.13"
|
||||
sqlite3_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.3.3-local.6+3030404
|
||||
version: 3.3.5-local.8+3030501
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
@@ -69,7 +69,7 @@ dependencies:
|
||||
stream_transform: ^2.1.1
|
||||
sqlite3: ^3.4.0
|
||||
sqlite_async: 0.14.2
|
||||
sqlite3_connection_pool: ^0.2.7
|
||||
sqlite3_connection_pool: ^0.2.13
|
||||
thumbhash: 0.1.0+1
|
||||
timezone: ^0.9.4
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
@@ -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:drift/drift.dart';
|
||||
import 'package:drift/native.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/data/db/main/database.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/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/store.service.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:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart' show ApiException;
|
||||
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../test_utils.dart';
|
||||
@@ -30,6 +40,16 @@ void main() {
|
||||
late LogService sut;
|
||||
late LogRepository mockLogRepo;
|
||||
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 {
|
||||
mockLogRepo = MockLogRepository();
|
||||
@@ -45,11 +65,16 @@ void main() {
|
||||
when(() => mockLogRepo.insert(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 {
|
||||
await sut.dispose();
|
||||
await StoreService.I.clear();
|
||||
});
|
||||
|
||||
group("Log Service Init:", () {
|
||||
@@ -87,6 +112,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
@@ -105,6 +131,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: true,
|
||||
);
|
||||
|
||||
@@ -126,6 +153,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
shouldBuffer: false,
|
||||
);
|
||||
|
||||
@@ -160,6 +188,7 @@ void main() {
|
||||
sut = await LogService.create(
|
||||
logRepository: mockLogRepo,
|
||||
settingsRepository: mockSettingsRepository,
|
||||
storeService: StoreService.I,
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ Future<void> _writerTask(String path) async {
|
||||
} finally {
|
||||
await db.close();
|
||||
await dbConnection.close();
|
||||
sqlitePool.close();
|
||||
await sqlitePool.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ void main() {
|
||||
logService = await LogService.init(
|
||||
logRepository: logRepository,
|
||||
settingsRepository: settingsRepository,
|
||||
storeService: MockStoreService(),
|
||||
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/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class _MockHttpClient extends Mock implements http.Client {}
|
||||
@@ -124,4 +125,21 @@ void main() {
|
||||
expect(result.errorMessage, 'boom');
|
||||
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 |'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -97,6 +99,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
|
||||
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 1);
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
await pumpDelete(tester, {asset});
|
||||
@@ -106,6 +109,65 @@ void main() {
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('does not trash a merged asset when the MANAGE_MEDIA prompt is cancelled', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
await pumpDelete(tester, {asset});
|
||||
await respondToDialog(tester, confirm: false);
|
||||
|
||||
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('does not trash a merged asset when the Android system prompt is cancelled', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 30);
|
||||
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 0);
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
await pumpDelete(tester, {asset});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('finishes trashing when the action context is disposed during local cleanup', (tester) async {
|
||||
final asset = owned(localId: 'local');
|
||||
final cleanupResult = Completer<int>();
|
||||
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) => cleanupResult.future);
|
||||
var showAction = true;
|
||||
late StateSetter updateHost;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
StatefulBuilder(
|
||||
builder: (_, setState) {
|
||||
updateHost = setState;
|
||||
return showAction
|
||||
? const ActionIconButton(action: DeleteAction(source: .timeline))
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: context.selected({asset}),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(ImmichIconButton));
|
||||
await tester.pump();
|
||||
updateHost(() => showAction = false);
|
||||
await tester.pump();
|
||||
cleanupResult.complete(1);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('offers an undo that restores the trashed assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
@@ -283,6 +345,7 @@ void main() {
|
||||
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
|
||||
final backedUp = owned(localId: 'local');
|
||||
final localOnly = LocalAssetFactory.create();
|
||||
when(() => cleanupService.deleteLocalAssets(['local', localOnly.id])).thenAnswer((_) async => 1);
|
||||
|
||||
await pumpDelete(tester, {backedUp, localOnly});
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
@@ -9708,6 +9708,415 @@
|
||||
"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": {
|
||||
"delete": {
|
||||
"description": "Bulk delete a list of people at once.",
|
||||
@@ -18211,7 +18620,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.5",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
@@ -18315,6 +18724,10 @@
|
||||
"name": "Partners",
|
||||
"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",
|
||||
"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": {
|
||||
"$ref": "#/components/schemas/AdminConfigOAuthDto"
|
||||
},
|
||||
"passkey": {
|
||||
"$ref": "#/components/schemas/AdminConfigPasskeyDto"
|
||||
},
|
||||
"passwordLogin": {
|
||||
"$ref": "#/components/schemas/AdminConfigPasswordLoginDto"
|
||||
},
|
||||
@@ -18659,6 +19075,7 @@
|
||||
"nightlyTasks",
|
||||
"notifications",
|
||||
"oauth",
|
||||
"passkey",
|
||||
"passwordLogin",
|
||||
"reverseGeocoding",
|
||||
"server",
|
||||
@@ -19521,6 +19938,34 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
@@ -22790,6 +23235,7 @@
|
||||
"AssetGenerateThumbnailsQueueAll",
|
||||
"AssetGenerateThumbnails",
|
||||
"AuditTableCleanup",
|
||||
"AuthChallengeCleanup",
|
||||
"DatabaseBackup",
|
||||
"FacialRecognitionQueueAll",
|
||||
"FacialRecognition",
|
||||
@@ -24918,6 +25364,127 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"ids": {
|
||||
@@ -25280,6 +25847,9 @@
|
||||
"partner.read",
|
||||
"partner.update",
|
||||
"partner.delete",
|
||||
"passkey.read",
|
||||
"passkey.update",
|
||||
"passkey.delete",
|
||||
"person.create",
|
||||
"person.read",
|
||||
"person.update",
|
||||
@@ -25963,6 +26533,9 @@
|
||||
"oauth": {
|
||||
"$ref": "#/components/schemas/PublicConfigOAuthDto"
|
||||
},
|
||||
"passkey": {
|
||||
"$ref": "#/components/schemas/PublicConfigPasskeyDto"
|
||||
},
|
||||
"passwordLogin": {
|
||||
"$ref": "#/components/schemas/PublicConfigPasswordLoginDto"
|
||||
},
|
||||
@@ -25974,6 +26547,7 @@
|
||||
"ffmpeg",
|
||||
"map",
|
||||
"oauth",
|
||||
"passkey",
|
||||
"passwordLogin",
|
||||
"server"
|
||||
],
|
||||
@@ -26048,6 +26622,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PublicConfigPasskeyDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PublicConfigPasswordLoginDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
@@ -33173,6 +33759,9 @@
|
||||
"oauth": {
|
||||
"$ref": "#/components/schemas/UserConfigOAuthDto"
|
||||
},
|
||||
"passkey": {
|
||||
"$ref": "#/components/schemas/UserConfigPasskeyDto"
|
||||
},
|
||||
"passwordLogin": {
|
||||
"$ref": "#/components/schemas/UserConfigPasswordLoginDto"
|
||||
},
|
||||
@@ -33196,6 +33785,7 @@
|
||||
"map",
|
||||
"notifications",
|
||||
"oauth",
|
||||
"passkey",
|
||||
"passwordLogin",
|
||||
"reverseGeocoding",
|
||||
"server",
|
||||
@@ -33384,6 +33974,11 @@
|
||||
},
|
||||
"UserConfigOAuthDto": {
|
||||
"properties": {
|
||||
"accountManagementUrl": {
|
||||
"default": "",
|
||||
"description": "Account management URL",
|
||||
"type": "string"
|
||||
},
|
||||
"autoLaunch": {
|
||||
"description": "Auto launch",
|
||||
"type": "boolean"
|
||||
@@ -33416,6 +34011,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserConfigPasskeyDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserConfigPasswordLoginDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
@@ -33710,11 +34317,6 @@
|
||||
"name": {
|
||||
"description": "User name",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"deprecated": true,
|
||||
"description": "User password (deprecated, use change password endpoint)",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.5",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -8,7 +8,7 @@
|
||||
"format": "prettier --cache --check i18n/",
|
||||
"format:fix": "prettier --cache --write --list-different i18n"
|
||||
},
|
||||
"packageManager": "pnpm@11.27.1",
|
||||
"packageManager": "pnpm@12.14.0",
|
||||
"engines": {
|
||||
"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
|
||||
COPY package* pnpm* .pnpmfile.cjs ./
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.5",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -62,7 +62,7 @@
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fastq": "^1.17.1",
|
||||
"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 () => {
|
||||
let mocked = vi.mocked(checkBulkUpload);
|
||||
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 results = response.results as AssetBulkUploadCheckResults;
|
||||
const requested = new Set(assets.map(({ id }) => id));
|
||||
|
||||
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) {
|
||||
newFiles.push(filepath);
|
||||
} 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
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"oidc-provider": "^9.0.0",
|
||||
"tsx": "^4.20.6"
|
||||
},
|
||||
"packageManager": "pnpm@11.27.1"
|
||||
"packageManager": "pnpm@12.14.0"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"packageManager": "pnpm@11.27.1",
|
||||
"packageManager": "pnpm@12.14.0",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.27.1"
|
||||
"packageManager": "pnpm@12.14.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.5",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -357,6 +357,14 @@ export type AdminConfigOAuthDto = {
|
||||
timeout: number;
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
||||
};
|
||||
export type AdminConfigPasskeyDto = {
|
||||
/** Additional domains */
|
||||
additionalDomains: string[];
|
||||
/** Passkey domain */
|
||||
domain: string | null;
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type AdminConfigPasswordLoginDto = {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
@@ -421,6 +429,7 @@ export type AdminConfigDto = {
|
||||
nightlyTasks: AdminConfigNightlyTasksDto;
|
||||
notifications: AdminConfigNotificationsDto;
|
||||
oauth: AdminConfigOAuthDto;
|
||||
passkey: AdminConfigPasskeyDto;
|
||||
passwordLogin: AdminConfigPasswordLoginDto;
|
||||
reverseGeocoding: AdminConfigReverseGeocodingDto;
|
||||
server: AdminConfigServerDto;
|
||||
@@ -1607,6 +1616,8 @@ export type UserConfigNotificationsDto = {
|
||||
smtp: UserConfigSmtpDto;
|
||||
};
|
||||
export type UserConfigOAuthDto = {
|
||||
/** Account management URL */
|
||||
accountManagementUrl?: string;
|
||||
/** Auto launch */
|
||||
autoLaunch: boolean;
|
||||
/** Button text */
|
||||
@@ -1614,6 +1625,10 @@ export type UserConfigOAuthDto = {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type UserConfigPasskeyDto = {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type UserConfigPasswordLoginDto = {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
@@ -1647,6 +1662,7 @@ export type UserConfigDto = {
|
||||
map: UserConfigMapDto;
|
||||
notifications: UserConfigNotificationsDto;
|
||||
oauth: UserConfigOAuthDto;
|
||||
passkey: UserConfigPasskeyDto;
|
||||
passwordLogin: UserConfigPasswordLoginDto;
|
||||
reverseGeocoding: UserConfigReverseGeocodingDto;
|
||||
server: UserConfigServerDto;
|
||||
@@ -1997,6 +2013,47 @@ export type PartnerUpdateDto = {
|
||||
/** Show partner assets in timeline */
|
||||
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 = {
|
||||
/** IDs to process */
|
||||
ids: string[];
|
||||
@@ -2198,6 +2255,10 @@ export type PublicConfigOAuthDto = {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type PublicConfigPasskeyDto = {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type PublicConfigPasswordLoginDto = {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
@@ -2210,6 +2271,7 @@ export type PublicConfigDto = {
|
||||
ffmpeg: PublicConfigFFmpegDto;
|
||||
map: PublicConfigMapDto;
|
||||
oauth: PublicConfigOAuthDto;
|
||||
passkey: PublicConfigPasskeyDto;
|
||||
passwordLogin: PublicConfigPasswordLoginDto;
|
||||
server: PublicConfigServerDto;
|
||||
};
|
||||
@@ -3206,8 +3268,6 @@ export type UserUpdateMeDto = {
|
||||
email?: string;
|
||||
/** User name */
|
||||
name?: string;
|
||||
/** User password (deprecated, use change password endpoint) */
|
||||
password?: string;
|
||||
};
|
||||
export type OnboardingResponseDto = {
|
||||
/** Is user onboarded */
|
||||
@@ -6126,6 +6186,115 @@ export function updatePartner({ id, 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
|
||||
*/
|
||||
@@ -8221,6 +8390,9 @@ export enum Permission {
|
||||
PartnerRead = "partner.read",
|
||||
PartnerUpdate = "partner.update",
|
||||
PartnerDelete = "partner.delete",
|
||||
PasskeyRead = "passkey.read",
|
||||
PasskeyUpdate = "passkey.update",
|
||||
PasskeyDelete = "passkey.delete",
|
||||
PersonCreate = "person.create",
|
||||
PersonRead = "person.read",
|
||||
PersonUpdate = "person.update",
|
||||
@@ -8455,6 +8627,7 @@ export enum JobName {
|
||||
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
|
||||
AssetGenerateThumbnails = "AssetGenerateThumbnails",
|
||||
AuditTableCleanup = "AuditTableCleanup",
|
||||
AuthChallengeCleanup = "AuthChallengeCleanup",
|
||||
DatabaseBackup = "DatabaseBackup",
|
||||
FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
|
||||
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
|
||||
minimumReleaseAgeExclude:
|
||||
- '@immich/ui'
|
||||
- '@immich/sql-tools@0.6.3'
|
||||
- '@immich/sql-tools@0.6.3 || 0.6.6'
|
||||
- '@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 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -68,7 +68,7 @@ FROM plugin-sdk AS plugins
|
||||
|
||||
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.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 \
|
||||
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
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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 && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
|
||||
+19
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.5",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -41,7 +41,7 @@
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/plugin-sdk": "workspace:*",
|
||||
"@immich/sql-tools": "^0.6.3",
|
||||
"@immich/sql-tools": "^0.6.6",
|
||||
"@nestjs/bullmq": "^12.0.0",
|
||||
"@nestjs/common": "^12.0.0",
|
||||
"@nestjs/core": "^12.0.0",
|
||||
@@ -52,27 +52,29 @@
|
||||
"@nestjs/websockets": "^12.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.222.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.222.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.70.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.68.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.74.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.223.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.223.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.71.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.70.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.76.0",
|
||||
"@opentelemetry/resources": "^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",
|
||||
"@simplewebauthn/server": "14.0.3",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"archiver": "^7.0.0",
|
||||
"aaguids": "^0.0.71",
|
||||
"archiver": "^8.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"bullmq": "^6.0.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"compression": "^1.8.0",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie": "^2.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "4.4.0",
|
||||
"exiftool-vendored": "^35.20.0",
|
||||
"exiftool-vendored": "^39.0.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
@@ -80,12 +82,12 @@
|
||||
"geo-tz": "^8.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"ioredis": "^6.0.0",
|
||||
"jose": "^6.0.0",
|
||||
"js-yaml": "^5.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.29.5",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"kysely-postgres-js": "^5.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"mnemonist": "^0.40.3",
|
||||
@@ -95,7 +97,7 @@
|
||||
"nestjs-kysely": "3.1.4",
|
||||
"nestjs-otel": "^8.0.0",
|
||||
"nestjs-zod": "^5.5.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
"nodemailer": "^10.0.0",
|
||||
"openid-client": "^6.3.3",
|
||||
"pg": "^8.11.3",
|
||||
"picomatch": "^4.0.2",
|
||||
@@ -124,7 +126,7 @@
|
||||
"@nestjs/schematics": "^12.0.0",
|
||||
"@nestjs/testing": "^12.0.0",
|
||||
"@swc/core": "^1.4.14",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/body-parser": "^1.19.6",
|
||||
@@ -165,7 +167,7 @@
|
||||
"testcontainers": "^12.0.0",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"unplugin-swc": "^1.4.5",
|
||||
"unplugin-swc": "^2.0.0",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -60,7 +60,7 @@ export const reverseGeocodeMaxDistance = 25_000;
|
||||
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
|
||||
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;
|
||||
|
||||
@@ -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.',
|
||||
[ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.',
|
||||
[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]:
|
||||
'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]:
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import request from 'supertest';
|
||||
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 { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
|
||||
|
||||
describe(AppController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const passkeyService = mockBaseService(PasskeyService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AppController, [
|
||||
{ provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) },
|
||||
{ provide: PasskeyService, useValue: passkeyService },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.reset();
|
||||
passkeyService.resetAllMocks();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('should not be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/custom.css');
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Controller, Get, Header } from '@nestjs/common';
|
||||
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||
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';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private service: SystemConfigService) {}
|
||||
constructor(
|
||||
private service: SystemConfigService,
|
||||
private passkeyService: PasskeyService,
|
||||
) {}
|
||||
|
||||
@ApiExcludeEndpoint()
|
||||
@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()
|
||||
@Get('custom.css')
|
||||
@Authenticated({ public: true })
|
||||
|
||||
@@ -25,6 +25,7 @@ import { NotificationAdminController } from 'src/controllers/notification-admin.
|
||||
import { NotificationController } from 'src/controllers/notification.controller.js';
|
||||
import { OAuthController } from 'src/controllers/oauth.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 { PluginController } from 'src/controllers/plugin.controller.js';
|
||||
import { QueueController } from 'src/controllers/queue.controller.js';
|
||||
@@ -73,6 +74,7 @@ export const controllers = [
|
||||
NotificationAdminController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PasskeyController,
|
||||
PersonController,
|
||||
PluginController,
|
||||
QueueController,
|
||||
|
||||
@@ -30,6 +30,32 @@ describe(OAuthController.name, () => {
|
||||
);
|
||||
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', () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ export class OAuthController {
|
||||
{
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [
|
||||
{ key: ImmichCookie.OAuthState, value: state },
|
||||
{ key: ImmichCookie.OAuthState, value: dto.state ? null : state },
|
||||
{ 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[];
|
||||
};
|
||||
|
||||
export type Passkey = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
userId: string;
|
||||
credentialId: string;
|
||||
transports: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
usedAt: Date | null;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -277,6 +288,9 @@ export type AssetFace = {
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
isVisible: boolean;
|
||||
ownerId: string;
|
||||
assetVisibility: AssetVisibility;
|
||||
assetDeletedAt: Date | null;
|
||||
};
|
||||
|
||||
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'],
|
||||
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'],
|
||||
pluginMethod: [
|
||||
'plugin_method.name',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AlbumUser, AuthSharedLink } from 'src/database.js';
|
||||
import { HistoryBuilder } from 'src/decorators.js';
|
||||
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.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 { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum.js';
|
||||
import { asDateTimeString } from 'src/utils/date.js';
|
||||
@@ -229,7 +230,7 @@ export type MapAlbumDto = {
|
||||
order: AssetOrder;
|
||||
};
|
||||
|
||||
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
|
||||
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, { auth }: { auth?: AuthDto } = {}): AlbumResponseDto => {
|
||||
const albumUsers: AlbumUserResponseDto[] = [];
|
||||
|
||||
if (entity.albumUsers) {
|
||||
@@ -262,7 +263,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
|
||||
createdAt: asDateTimeString(entity.createdAt),
|
||||
updatedAt: asDateTimeString(entity.updatedAt),
|
||||
id: entity.id,
|
||||
albumUsers,
|
||||
albumUsers: auth?.sharedLink ? [] : albumUsers,
|
||||
shared: hasSharedUser || hasSharedLink,
|
||||
hasSharedLink,
|
||||
startDate: asDateTimeString(startDate),
|
||||
|
||||
@@ -21,6 +21,8 @@ export type AuthDto = {
|
||||
|
||||
export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.sharedLink.showExif;
|
||||
|
||||
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
|
||||
|
||||
const LoginCredentialSchema = z
|
||||
.object({
|
||||
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
|
||||
|
||||
@@ -17,6 +17,7 @@ const PUBLIC_PROPERTIES = [
|
||||
'oauth.autoLaunch',
|
||||
'oauth.buttonText',
|
||||
'oauth.enabled',
|
||||
'passkey.enabled',
|
||||
'passwordLogin.enabled',
|
||||
'server.loginPageMessage',
|
||||
];
|
||||
@@ -38,6 +39,17 @@ describe('config visibility', () => {
|
||||
expect(getKeysDeep(mapUserConfig(defaults))).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES));
|
||||
});
|
||||
|
||||
it('exposes the OAuth account link to users without exposing client secrets or the link publicly', () => {
|
||||
const config = {
|
||||
...defaults,
|
||||
oauth: { ...defaults.oauth, accountManagementUrl: 'https://accounts.example.com', clientSecret: 'private' },
|
||||
};
|
||||
expect(mapUserConfig(config).oauth?.accountManagementUrl).toBe('https://accounts.example.com');
|
||||
expect(mapUserConfig(config).oauth).not.toHaveProperty('clientSecret');
|
||||
expect(mapPublicConfig(config).oauth).not.toHaveProperty('accountManagementUrl');
|
||||
expect(mapPublicConfig(config).oauth).not.toHaveProperty('clientSecret');
|
||||
});
|
||||
|
||||
it('should accept the defaults with the admin schema', () => {
|
||||
expect(AdminConfigDto.schema.safeParse(defaults)).toEqual(expect.objectContaining({ success: true }));
|
||||
});
|
||||
@@ -75,3 +87,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');
|
||||
|
||||
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) =>
|
||||
z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error });
|
||||
|
||||
@@ -291,7 +303,8 @@ const AdminConfigSchemaWithVisibility = z
|
||||
accountManagementUrl: emptyOrUrl('Account management URL must be an empty string or a valid URL')
|
||||
.describe('Account management URL')
|
||||
.optional()
|
||||
.default(''),
|
||||
.default('')
|
||||
.meta({ visibility: User }),
|
||||
scope: z.string().describe('Scope'),
|
||||
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
|
||||
endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe(
|
||||
@@ -322,6 +335,29 @@ const AdminConfigSchemaWithVisibility = z
|
||||
return value;
|
||||
})
|
||||
.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
|
||||
.object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) })
|
||||
.meta({ id: 'AdminConfigPasswordLoginDto' }),
|
||||
@@ -469,7 +505,12 @@ const applyVisibilityRecursive = (
|
||||
|
||||
let visible: z.ZodType | undefined;
|
||||
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)}` }),
|
||||
...((override ?? description) && { description: override ?? description }),
|
||||
});
|
||||
@@ -498,7 +539,7 @@ const stripVisibilityMetadata = <T extends z.ZodType>(schema: T): T => {
|
||||
return schema;
|
||||
};
|
||||
|
||||
const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType<SystemConfig>;
|
||||
const AdminConfigSchema = AdminConfigSchemaWithVisibility;
|
||||
const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||
const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>;
|
||||
|
||||
@@ -682,6 +723,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
timeout: 30_000,
|
||||
allowInsecureRequests: false,
|
||||
},
|
||||
passkey: {
|
||||
enabled: true,
|
||||
domain: null,
|
||||
additionalDomains: [],
|
||||
},
|
||||
passwordLogin: {
|
||||
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,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
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,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showMetadata: sharedLink.showExif,
|
||||
|
||||
@@ -11,11 +11,6 @@ import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/
|
||||
export const UserUpdateMeSchema = z
|
||||
.object({
|
||||
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'),
|
||||
avatarColor: UserAvatarColorSchema.nullish(),
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { ImmichButton } from 'src/emails/components/button.component.js';
|
||||
import ImmichLayout from 'src/emails/components/immich.layout.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 = ({
|
||||
baseUrl,
|
||||
@@ -37,7 +37,9 @@ export const AlbumInviteEmail = ({
|
||||
);
|
||||
|
||||
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 && (
|
||||
<Text className="m-0">
|
||||
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { ImmichButton } from 'src/emails/components/button.component.js';
|
||||
import ImmichLayout from 'src/emails/components/immich.layout.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 = ({
|
||||
baseUrl,
|
||||
@@ -36,7 +36,13 @@ export const AlbumUpdateEmail = ({
|
||||
);
|
||||
|
||||
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 && (
|
||||
<Text className="m-0">
|
||||
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { ImmichButton } from 'src/emails/components/button.component.js';
|
||||
import ImmichLayout from 'src/emails/components/immich.layout.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) => {
|
||||
const usableTemplateVariables = {
|
||||
@@ -37,7 +37,11 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password, customT
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<Text className="m-0">
|
||||
|
||||
@@ -5,6 +5,12 @@ import { asIntegerEnum } from 'src/utils/helpers.js';
|
||||
export enum AuthType {
|
||||
Password = 'password',
|
||||
OAuth = 'oauth',
|
||||
Passkey = 'passkey',
|
||||
}
|
||||
|
||||
export enum AuthChallengeType {
|
||||
PasskeyRegistration = 'PasskeyRegistration',
|
||||
PasskeyAuthentication = 'PasskeyAuthentication',
|
||||
}
|
||||
|
||||
export enum ImmichCookie {
|
||||
@@ -244,6 +250,10 @@ export enum Permission {
|
||||
PartnerUpdate = 'partner.update',
|
||||
PartnerDelete = 'partner.delete',
|
||||
|
||||
PasskeyRead = 'passkey.read',
|
||||
PasskeyUpdate = 'passkey.update',
|
||||
PasskeyDelete = 'passkey.delete',
|
||||
|
||||
PersonCreate = 'person.create',
|
||||
PersonRead = 'person.read',
|
||||
PersonUpdate = 'person.update',
|
||||
@@ -892,6 +902,8 @@ export enum JobName {
|
||||
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
AuthChallengeCleanup = 'AuthChallengeCleanup',
|
||||
|
||||
DatabaseBackup = 'DatabaseBackup',
|
||||
|
||||
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
|
||||
@@ -1018,6 +1030,8 @@ export enum DatabaseLock {
|
||||
IntegrityCheck = 67,
|
||||
VersionCheck = 800,
|
||||
HlsSessionCleanup = 850,
|
||||
FacialRecognition = 900,
|
||||
DuplicateDetection = 1000,
|
||||
}
|
||||
|
||||
export enum MaintenanceAction {
|
||||
@@ -1269,6 +1283,7 @@ export enum ApiTag {
|
||||
NotificationsAdmin = 'Notifications (admin)',
|
||||
ClusterGroups = 'Cluster groups',
|
||||
Partners = 'Partners',
|
||||
Passkeys = 'Passkeys',
|
||||
People = 'People',
|
||||
Plugins = 'Plugins',
|
||||
Queues = 'Queues',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { parse } from 'cookie';
|
||||
import { parseCookie } from 'cookie';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -240,7 +240,7 @@ export class MaintenanceWorkerService {
|
||||
}
|
||||
|
||||
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
|
||||
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken];
|
||||
const jwtToken = parseCookie(headers.cookie || '')[ImmichCookie.MaintenanceToken];
|
||||
return this.login(jwtToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +320,15 @@ where
|
||||
"partner"."sharedById" in ($1)
|
||||
and "partner"."sharedWithId" = $2
|
||||
|
||||
-- AccessRepository.passkey.checkOwnerAccess
|
||||
select
|
||||
"passkey"."id"
|
||||
from
|
||||
"passkey"
|
||||
where
|
||||
"passkey"."id" in ($1)
|
||||
and "passkey"."userId" = $2
|
||||
|
||||
-- AccessRepository.session.checkOwnerAccess
|
||||
select
|
||||
"session"."id"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user