Author SHA1 Message Date
Codex e04e70db2e Close startup log connection and use portable export filenames 2026-10-22 05:39:19 +03:00
Codex 75517057d0 Keep Svelte compiler warning option portable with pnpm 12 2026-10-22 05:33:35 +03:00
Codex 3efc91ff9a Prepare localized 3.3.5-local.7 release with updated dependencies 2026-10-22 05:29:35 +03:00
Clément DelestreandCodex 7c07c001cd fix(mobile): keep header image when starting selection (#32356)
* fix(mobile): keep header image when starting selection

* fix(mobile): fade out header actions during selection

Hide the back button and person options menu with the same fade as the
timeline app bar, and block taps on the person name and birthday while
assets are selected.

---------

Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit 745bdd2edf72ce0f75e748e2d68c37b6ea196cc5)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex f1f7f82e91 chore(deps): update base-image to v202610211607 (#32644)
Co-authored-by: bo0tzz <git@bo0tzz.me>
(cherry picked from commit b9269f873982c9b1c0f9267aefad8b8b9eacb7d9)
2026-10-22 05:25:45 +03:00
bo0tzzandCodex b3bd5d1fd1 feat(server): remove deprecated password field from update-me (#32623)
* feat(server): remove deprecated password field from update-me

* fix: tests for removed password field

(cherry picked from commit 3f6a071203187681b6c06b977665dc624c431336)
2026-10-22 05:25:45 +03:00
bo0tzzandCodex ad9b368cb8 fix(server): exclude partners' locked assets from legacy search (#32604)
(cherry picked from commit 8dab52c7491c79cba742315dea8d7f5212915875)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 7c78b00df6 fix(deps): update dependency ioredis to v6 (#32137)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 48474ecff56fe02cb84a8136d923863d4978a7e0)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 4dfb6e88af chore(deps): update node.js to v24.22.1 (#32633)
Co-authored-by: bo0tzz <git@bo0tzz.me>
(cherry picked from commit c5086b68f76b78964e22c52bd78060344d82aebf)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 744797e59d chore(deps): update node.js to v24.22.1 (#32634)
(cherry picked from commit c78e5c37843916199e380f077add45bd15a8bba3)
2026-10-22 05:25:45 +03:00
renovate[bot]andCodex 689105c949 fix(deps): update dependency exiftool-vendored to v39 (#31917)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit bb9f18a1d007d530fcf477a0fa56528fdfa91dd0)
2026-10-22 05:25:45 +03:00
bo0tzzandCodex 1332a30630 chore(deps): update dependency certifi to v2026.10.19 (#32636)
(cherry picked from commit cda02c46e6cb36cf17d54570b2a024c51111cdf0)
2026-10-22 05:25:15 +03:00
bo0tzzandCodex 648e5608b7 chore(deps): update python docker digests (#32635)
(cherry picked from commit f97b00284d5752c2cddced549527ae692e820957)
2026-10-22 05:25:14 +03:00
Jonathan JogenforsandCodex 42dffaa0c8 fix(server): dont follow symlinks (#32614)
(cherry picked from commit 10e216b8385ac40e6aa150f75fc4730c4e8a7494)
2026-10-22 05:24:55 +03:00
renovate[bot]andCodex ec3359f1cb fix(deps): update typescript-projects (#32132)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 767f47a8fb35e0605e49b0dd4f30587bb76085ad)
2026-10-22 05:23:59 +03:00
renovate[bot]andCodex 235e57bf77 fix(deps): update dependency cookie to v2 (#31916)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit e505e5113b43177ad215d075dca25dc056dd1a40)
2026-10-22 05:23:59 +03:00
Adam GastineauandCodex 630240971f chore(mobile): use Xcode and iOS 27.1 SDK for CI builds (#32632)
(cherry picked from commit cb4195ad439ba962a2def7f04738eafc749554d9)
2026-10-22 05:23:59 +03:00
renovate[bot]andCodex 44e312cfba chore(deps): update pnpm to v12 (#31911)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 638cfbca5e06486caa00423a83d557c66d6a5ae3)
2026-10-22 05:23:59 +03:00
renovate[bot]andCodex 67c158a0ab chore(deps): update grafana/grafana docker tag to v13 (#31910)
(cherry picked from commit 1d4be15bc59d25b180ee8712e784d73d8e9d6889)
2026-10-22 05:23:24 +03:00
renovate[bot]andCodex 1554f0d439 chore(deps): update dependency unplugin-swc to v2 (#31909)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 493b40a7a6c34dac05533c3d3682a38f0eef6038)
2026-10-22 05:23:24 +03:00
renovate[bot]andCodex 3e250d7878 fix(deps): update dependency nodemailer to v10 [security] (#31907)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 5d2cd0800efad0e396790782af25378c8cb345ab)
2026-10-22 05:23:24 +03:00
renovate[bot]andCodex a6d63285b7 fix(deps): update dependency chokidar to v5 (#31915)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit d1e874fc1e16ab53c80f723dd1905cd7e106cac0)
2026-10-22 05:23:23 +03:00
renovate[bot]andCodex c1552140b0 fix(deps): update dependency happy-dom to v21 (#32589)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit f1cd87e37be8a47f463cb53241a651407a0eebf2)
2026-10-22 05:23:23 +03:00
renovate[bot]andCodex 238617e566 fix(deps): update dependency archiver to v8 (#31913)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 51da695c62f3415a97c7b752ef62927e39ab086f)
2026-10-22 05:23:23 +03:00
YarosandCodex 3f3671fbe5 fix(ml): preloaded model loaded twice (#32610)
fix(ml): reuse cached model across names that clean to the same model

(cherry picked from commit 166e93559f123313cf9cb4e7324c4ee30e21c618)
2026-10-22 05:23:22 +03:00
bo0tzzandCodex 01ba579766 perf(server): index updateId on asset_face, stack and workflow (#32432)
asset_face, stack and workflow were the only tables with an updateId
column but no index on it. Face and stack sync (AssetFacesV1/V2,
StacksV1, PartnerStacksV1) filter and order on updateId, so every mobile
sync seq-scanned every face on the instance and walked all of the user's
(or partners') stacks, even when nothing had changed. workflow isn't
synced; it gets the index for consistency.

Incremental sync with nothing changed, PG 14:

| query                              | before   | after    |
| ---------------------------------- | -------- | -------- |
| assetFace.getUpserts (425k faces)  | 33-37 ms | 0.15 ms  |
| stack.getUpserts (6k stacks)       | 0.8 ms   | 0.004 ms |
| partnerStack.getUpserts (6k)       | 0.6 ms   | 0.007 ms |

The face scan grows with the instance's face count (~10x that at 4M
faces); the stack one with the user's stack count.

(cherry picked from commit 9df807ed4a9536a28dad463270082095ec1fe94d)
2026-10-22 05:23:22 +03:00
bo0tzzandCodex e2864b6664 fix(server): do not expose album members to shared link viewers (#32607)
(cherry picked from commit 380f3e74e622938b6b6c450859df8655be4e21ba)
2026-10-22 05:23:22 +03:00
bo0tzzandCodex 1756d70fda fix(server): only set OAuth cookies for server-generated state (#32600)
(cherry picked from commit d468c468867edcbafd4d934d3ba97bee2414050f)
2026-10-22 05:23:22 +03:00
Jason RasmussenandCodex b12be9119f feat: passkeys (#32194)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 6233a79bf7e4cec11e9ea1094908ff3b228fcbe0)
2026-10-22 05:23:22 +03:00
Santo ShakilandCodex e66d5a70e7 fix(mobile): hide server addresses in upload error logs (#32550)
* fix(mobile): hide server addresses in upload error logs

* fix(mobile): remove duplicate upload failure logs

(cherry picked from commit 211773f778ca24ac33e64d46e50b3ab46702c851)
2026-10-22 05:22:44 +03:00
renovate[bot]andCodex 1a81902a36 chore(deps): update dependency opentofu to v1.13.1 (#32130)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 99fb4b1fec4ec3e445a86307766c02552fdae982)
2026-10-22 05:22:12 +03:00
renovate[bot]andCodex ec5b4eb939 chore(deps): update grafana/grafana docker tag to v12.4.13-ubuntu (#32127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit f4720273e1c42f53bb7b218a3fc116ca2e1547a1)
2026-10-22 05:21:33 +03:00
renovate[bot]andCodex 3457823b5b chore(deps): update node.js to v24.22.0 (#32585)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 215c496f6276e5f0ffb8e8b9b6555e7684b954b1)
2026-10-22 05:21:33 +03:00
renovate[bot]andCodex ffbe3d1681 chore(deps): update node.js to v24.22.0 (#32586)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit cd5d091f72838fc4b16ce2c9ebb59686c415ecdf)
2026-10-22 05:21:33 +03:00
bo0tzzandCodex 5c0f0b498a fix(ci): remove scope labels when they no longer match (#31026)
(cherry picked from commit 5d7556fe6d0699fc932b94a328ecb5e5145a0fce)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex c46f748412 chore: link CONTRIBUTING.md in issue and feature request templates (#32514)
(cherry picked from commit f1984aa0905626ee05b5fd9fdef3cf35b77d951e)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex 59be6583b8 fix(server): only serve edited assets to shared links in archive downloads (#32562)
(cherry picked from commit cd47ee30676e8575a5048d12cde2ee86fc02b965)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex aeefb962d4 fix(server): sanitize EXIF template variables and tighten storage path check (#32566)
(cherry picked from commit 6141f8bdbf6d21a14558d9f7d7325918023d1037)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex f80be1030b fix(server): restrict partner timeline requests to timeline assets (#32565)
* fix(server): restrict partner timeline requests to timeline assets

* test(server): update medium timeline tests for consolidated check

(cherry picked from commit e9fb180f466a687cebda9b7e17cef403de4b81fa)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex 7df744b684 fix(cli): only act on files that were sent in the bulk upload check (#32564)
(cherry picked from commit 9e20805e21c72b95f8c23f4985c8a20b4c3da12a)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex c66634fcfd fix(server): escape template variables in custom email templates (#32563)
* fix(server): escape template variables in custom email templates

* fix(server): strip preview tags until stable

(cherry picked from commit 616839e8d43a9de21d0c9391847fba97ac41a759)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex d92248c664 fix(server): require upload access for checksum duplicate check (#32559)
* fix(server): require upload access for checksum duplicate check

* fix(server): use the upload access check for the checksum short circuit

(cherry picked from commit 5c02b17495947d7332cce13273226082bd8ae2a1)
2026-10-22 05:21:00 +03:00
bo0tzzandCodex 67152772d2 fix(server): anchor plugin allowedHosts matching (#32560)
(cherry picked from commit 4acf935ba3ee7b6171e3965cb3ce47b2ba9bad1a)
2026-10-22 05:21:00 +03:00
Adam GastineauandCodex 564b720bce fix(mobile): prevent crash when generated/openapi has unexpected files (#32599)
(cherry picked from commit 4f38929bdaaa88d48429ac642abf9ec292e3ca82)
2026-10-22 05:21:00 +03:00
Tiago DeaneandCodex 6aaebd167d feat: option for mobile smooth timeline scrubbing (#32552)
* feat(mobile): add smooth timeline scrubbing setting

* refactor: read smooth scrubbing setting where the scrubber is built

(cherry picked from commit 222d9276d31e890d3d9beb6dae5d96a6f6ab52f0)
2026-10-22 05:21:00 +03:00
renovate[bot]andCodex 2d236024bc fix(deps): update dependency kysely-postgres-js to v5 (#31919)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 21fe4994a203da3876271bc290c47b69694fd42a)
2026-10-22 05:20:59 +03:00
renovate[bot]andCodex e09b352067 fix(deps): update typescript-projects (#32581)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 50edbedf15526c3b43c43a261a1aaddfb782b5ce)
2026-10-22 05:20:59 +03:00
MertandCodex 4c89b0f7df perf(server): more efficient person queries (#32523)
(cherry picked from commit 38a2ce4daa678615561d2b87a8f2bb30f70fdc53)
2026-10-22 05:20:19 +03:00
renovate[bot]andCodex b020f12f41 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.10.15 (#32580)
(cherry picked from commit a22480e3eb2c07535c126f7ac20f6e04f51c6200)
2026-10-22 05:19:50 +03:00
bo0tzzandCodex 43848a7d68 fix: locking around concurrency-1 jobs (#28410)
* fix: locking around concurrency-1 jobs

* fix: no lock on queue duplicates

(cherry picked from commit 477452b5c51ca71ecdf74205e0004eaec4e18874)
2026-10-22 05:19:10 +03:00
renovate[bot]andCodex 9e96fdc627 chore(deps): update github-actions (#32584)
(cherry picked from commit be825a0518addd098166471706a64c8305cafca2)
2026-10-22 05:19:10 +03:00
Mateus NavarroandCodex 434b7efd66 fix(mobile): border radius on list tiles in library page (#32476) (#32503)
* fix(mobile): standardize list tile radius in Quick Access Button List when pressed (#32476)

* fix(mobile): adjust list tile border radius based on partner presence in Quick Access Button List

* refactor(mobile): improve way to handle border radius of items in the QuickAccessButtonList

(cherry picked from commit fff2bd73f9b38a5a5b510ea69a77a470dd1acf56)
2026-10-22 05:18:40 +03:00
Santo ShakilandCodex 814cc14f88 feat(mobile): share logs from the startup error screen (#32288)
* fix(mobile): hide server addresses and tokens in shared logs

* feat(mobile): share logs from the startup error screen

* use only the shared logs patterns on the startup error screen

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* redact the startup error screen share

* fix(mobile): hide server addresses and tokens in shared logs

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* fix(mobile): redact logs when they are written

* log the startup error and share it as a log row

* fix(mobile): hide ipv6 addresses and name labels after their settings

* comment why the startup error row is only added here

(cherry picked from commit f7fa8a3a2d2c3b0ab59f90fbcc3c230c58a2aede)
2026-10-22 05:18:40 +03:00
Santo ShakilandCodex 59bf69f1d5 fix(mobile): hide server addresses and tokens in shared logs (#32287)
* fix(mobile): hide server addresses and tokens in shared logs

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* fix(mobile): redact logs when they are written

* fix(mobile): hide ipv6 addresses and name labels after their settings

(cherry picked from commit 89d2de1274a3e1a70a6169f4cfe1feebfb327791)
2026-10-22 05:18:40 +03:00
179 changed files with 7097 additions and 1880 deletions
@@ -9,6 +9,8 @@ body:
Stick to only a single feature per request. If you list multiple different features at once, Stick to only a single feature per request. If you list multiple different features at once,
your request will be closed. your request will be closed.
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening a feature request.
- type: checkboxes - type: checkboxes
attributes: attributes:
label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request. label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
+5
View File
@@ -1,6 +1,11 @@
name: Report an issue with Immich name: Report an issue with Immich
description: Report an issue with Immich description: Report an issue with Immich
body: body:
- type: markdown
attributes:
value: |
Please read our [contributing guidelines](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before opening an issue.
- type: checkboxes - type: checkboxes
attributes: attributes:
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report. label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
+4 -4
View File
@@ -204,7 +204,7 @@ jobs:
contents: read contents: read
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload) # Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }} if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: macos-26 runs-on: xcode-27
steps: steps:
- id: token - id: token
@@ -214,8 +214,8 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read permission-contents: read
- name: Select Xcode 26 - name: Select Xcode 27.1
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_27.1.app/Contents/Developer
- name: Checkout code - name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -230,7 +230,7 @@ jobs:
working_directory: ./mobile working_directory: ./mobile
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with: with:
ruby-version: '3.4' ruby-version: '3.4'
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
+1 -1
View File
@@ -115,7 +115,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with: with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token - name: Generate a token
id: generate_token id: generate_token
if: ${{ inputs.skip != true }} if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with: with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1
View File
@@ -22,3 +22,4 @@ jobs:
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
sync-labels: true
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
+16 -26
View File
@@ -1,43 +1,33 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.opentofu]] [[tools.opentofu]]
version = "1.12.6" version = "1.13.1"
backend = "aqua:opentofu/opentofu" backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"] [tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e" checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
[tools.opentofu."platforms.linux-x64"] [tools.opentofu."platforms.linux-x64"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec" checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
[tools.opentofu."platforms.macos-arm64"] [tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74" checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059"
[tools.opentofu."platforms.macos-x64"] [tools.opentofu."platforms.macos-x64"]
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92" checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142"
[tools.opentofu."platforms.windows-x64"] [tools.opentofu."platforms.windows-x64"]
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538" checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102"
[[tools.terragrunt]] [[tools.terragrunt]]
version = "1.0.3" version = "1.0.3"
+1 -1
View File
@@ -1,6 +1,6 @@
[tools] [tools]
terragrunt = "1.1.1" terragrunt = "1.1.1"
opentofu = "1.12.6" opentofu = "1.13.1"
[tasks."tg:fmt"] [tasks."tg:fmt"]
run = "terragrunt hclfmt" run = "terragrunt hclfmt"
+1 -1
View File
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.4.11-ubuntu@sha256:23f2328b859c7677c7065c81b479600706ef9cc9004977af5bbd68b159b8ea60 image: grafana/grafana:13.2.4-ubuntu@sha256:d838696907b4350d9232eb8da1503dc1d1010a0359d3ce4b3f7d2ff7db28eb75
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana
+5
View File
@@ -209,6 +209,11 @@ The default configuration looks like this:
"timeout": 30000, "timeout": 30000,
"tokenEndpointAuthMethod": "client_secret_post" "tokenEndpointAuthMethod": "client_secret_post"
}, },
"passkey": {
"enabled": true,
"domain": null,
"additionalDomains": []
},
"passwordLogin": { "passwordLogin": {
"enabled": true "enabled": true
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]] [[tools.wrangler]]
version = "4.137.0" version = "4.151.0"
backend = "npm:wrangler" backend = "npm:wrangler"
[tools.wrangler.options] [tools.wrangler.options]
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}" run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools] [tools]
wrangler = "4.137.0" wrangler = "4.151.0"
+1 -1
View File
@@ -41,7 +41,7 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0", "eslint-plugin-unicorn": "^75.0.0",
"exiftool-vendored": "^35.0.0", "exiftool-vendored": "^39.0.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"pg": "^8.11.3", "pg": "^8.11.3",
+2 -2
View File
@@ -567,7 +567,7 @@ describe('/search', () => {
'New York', 'New York',
'Shanghai', 'Shanghai',
'State of Berlin', 'State of Berlin',
'St.-Petersburg', 'St. Petersburg',
'Tbilisi', 'Tbilisi',
'Tokyo', 'Tokyo',
'Virginia', 'Virginia',
@@ -591,7 +591,7 @@ describe('/search', () => {
'New York', 'New York',
'Shanghai', 'Shanghai',
'State of Berlin', 'State of Berlin',
'St.-Petersburg', 'St. Petersburg',
'Tbilisi', 'Tbilisi',
'Tokyo', 'Tokyo',
'Virginia', 'Virginia',
@@ -206,6 +206,13 @@ describe('/shared-links', () => {
); );
}); });
it('should not return album users', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key });
expect(status).toBe(200);
expect(body.album.albumUsers).toEqual([]);
});
it('should return unauthorized for incorrect shared link', async () => { it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/shared-links/me') .get('/shared-links/me')
-40
View File
@@ -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();
});
});
});
+4 -3
View File
@@ -28,7 +28,7 @@ test.describe('Registration', () => {
await page.goto('/auth/login?autoLaunch=0'); await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('admin@immich.app'); await page.getByLabel('Email').fill('admin@immich.app');
await page.getByLabel('Password', { exact: true }).fill('password'); await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login', exact: true }).click();
// onboarding // onboarding
await expect(page).toHaveURL('/auth/onboarding'); await expect(page).toHaveURL('/auth/onboarding');
@@ -66,11 +66,12 @@ test.describe('Registration', () => {
await page.goto('/auth/login?autoLaunch=0'); await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('password'); await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login', exact: true }).click();
// change password // change password
await expect(page.getByRole('heading')).toHaveText('Change Password'); await expect(page.getByRole('heading')).toHaveText('Change Password');
await expect(page).toHaveURL('/auth/change-password'); await expect(page).toHaveURL('/auth/change-password');
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByLabel('New Password').fill('new-password'); await page.getByLabel('New Password').fill('new-password');
await page.getByLabel('Confirm Password').fill('new-password'); await page.getByLabel('Confirm Password').fill('new-password');
await page.getByRole('button', { name: 'Change password' }).click(); await page.getByRole('button', { name: 'Change password' }).click();
@@ -79,7 +80,7 @@ test.describe('Registration', () => {
await expect(page).toHaveURL('/auth/login?autoLaunch=0'); await expect(page).toHaveURL('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('new-password'); await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click(); await page.getByRole('button', { name: 'Login', exact: true }).click();
// onboarding // onboarding
await expect(page).toHaveURL('/auth/onboarding'); await expect(page).toHaveURL('/auth/onboarding');
+20
View File
@@ -324,6 +324,13 @@
"oauth_timeout": "Request Timeout", "oauth_timeout": "Request Timeout",
"oauth_timeout_description": "Timeout for requests in milliseconds", "oauth_timeout_description": "Timeout for requests in milliseconds",
"ocr_job_description": "Use machine learning to recognize text in images", "ocr_job_description": "Use machine learning to recognize text in images",
"passkey_additional_domains": "Additional domains",
"passkey_additional_domains_description": "Additional, trusted domains (related origin requests). These will only work if the passkey domain is also accessible.",
"passkey_domain": "Passkey domain",
"passkey_domain_description": "Domain used for passkeys (relying party). Leave blank to infer from the origin header. Changing this value will result in existing passkeys no longer working.",
"passkey_enable_description": "Login with a passkey",
"passkey_settings": "Passkey Login",
"passkey_settings_description": "Manage passkey login settings",
"password_enable_description": "Login with email and password", "password_enable_description": "Login with email and password",
"password_settings": "Password Login", "password_settings": "Password Login",
"password_settings_description": "Manage password login settings", "password_settings_description": "Manage password login settings",
@@ -874,6 +881,8 @@
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
"delete_local_dialog_ok_force": "Delete Anyway", "delete_local_dialog_ok_force": "Delete Anyway",
"delete_others": "Delete others", "delete_others": "Delete others",
"delete_passkey": "Delete passkey",
"delete_passkey_prompt": "Are you sure you want to delete this passkey?",
"delete_permanently": "Delete permanently", "delete_permanently": "Delete permanently",
"delete_permanently_action_prompt": "{count} deleted permanently", "delete_permanently_action_prompt": "{count} deleted permanently",
"delete_shared_link": "Delete shared link", "delete_shared_link": "Delete shared link",
@@ -943,6 +952,7 @@
"edit_location_action_prompt": "{count} location edited", "edit_location_action_prompt": "{count} location edited",
"edit_location_dialog_title": "Location", "edit_location_dialog_title": "Location",
"edit_name": "Edit name", "edit_name": "Edit name",
"edit_passkey": "Edit passkey",
"edit_people": "Edit people", "edit_people": "Edit people",
"edit_person": "Edit person", "edit_person": "Edit person",
"edit_tag": "Edit tag", "edit_tag": "Edit tag",
@@ -1287,6 +1297,7 @@
"last": "Last", "last": "Last",
"last_months": "{count, plural, one {Last month} other {Last # months}}", "last_months": "{count, plural, one {Last month} other {Last # months}}",
"last_seen": "Last seen", "last_seen": "Last seen",
"last_used": "Last used",
"latest_version": "Latest Version", "latest_version": "Latest Version",
"latitude": "Latitude", "latitude": "Latitude",
"leave": "Leave", "leave": "Leave",
@@ -1379,6 +1390,7 @@
"login_has_been_disabled": "Login has been disabled", "login_has_been_disabled": "Login has been disabled",
"login_password_changed_error": "There was an error updating your password", "login_password_changed_error": "There was an error updating your password",
"login_password_changed_success": "Password updated successfully", "login_password_changed_success": "Password updated successfully",
"login_with_passkey": "Login with passkey",
"logout_all_device_confirmation": "Are you sure you want to log out all devices?", "logout_all_device_confirmation": "Are you sure you want to log out all devices?",
"logout_this_device_confirmation": "Are you sure you want to log out this device?", "logout_this_device_confirmation": "Are you sure you want to log out this device?",
"logs": "Logs", "logs": "Logs",
@@ -1427,6 +1439,7 @@
"manage_your_api_keys": "Manage your API keys", "manage_your_api_keys": "Manage your API keys",
"manage_your_devices": "Manage your logged-in devices", "manage_your_devices": "Manage your logged-in devices",
"manage_your_oauth_connection": "Manage your OAuth connection", "manage_your_oauth_connection": "Manage your OAuth connection",
"manage_your_passkeys": "Manage your passkeys",
"map": "Map", "map": "Map",
"map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}", "map_assets_in_bounds": "{count, plural, =0 {No photos in this area} one {# photo} other {# photos}}",
"map_cannot_get_user_location": "Cannot get user's location", "map_cannot_get_user_location": "Cannot get user's location",
@@ -1546,6 +1559,7 @@
"new_album": "New Album", "new_album": "New Album",
"new_api_key": "New API Key", "new_api_key": "New API Key",
"new_feature": "New Feature", "new_feature": "New Feature",
"new_passkey": "New passkey",
"new_password": "New password", "new_password": "New password",
"new_person": "New person", "new_person": "New person",
"new_pin_code": "New PIN code", "new_pin_code": "New PIN code",
@@ -1658,6 +1672,8 @@
"partner_sharing": "Partner Sharing", "partner_sharing": "Partner Sharing",
"partners": "Partners", "partners": "Partners",
"partners_description": "Share your photos and videos with other users", "partners_description": "Share your photos and videos with other users",
"passkeys": "{count, plural, one {Passkey} other {Passkeys}}",
"passkeys_not_supported": "Passkeys are not supported by this browser",
"password": "Password", "password": "Password",
"password_does_not_match": "Password does not match", "password_does_not_match": "Password does not match",
"password_required": "Password Required", "password_required": "Password Required",
@@ -1842,6 +1858,7 @@
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
"removed_from_stack": "Removed asset from stack", "removed_from_stack": "Removed asset from stack",
"removed_memory": "Removed memory", "removed_memory": "Removed memory",
"removed_passkey": "Removed passkey: {name}",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}", "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename", "rename": "Rename",
"repository": "Repository", "repository": "Repository",
@@ -2025,6 +2042,7 @@
"share_everyone": "Share everyone", "share_everyone": "Share everyone",
"share_everyone_description": "Give {user} access to all of your people.", "share_everyone_description": "Give {user} access to all of your people.",
"share_link": "Share Link", "share_link": "Share Link",
"share_logs": "Share Logs",
"share_original": "Use original (large)", "share_original": "Use original (large)",
"share_preview": "Use preview (small)", "share_preview": "Use preview (small)",
"share_quality_body": "Press and hold the share button to choose the image quality before you share.", "share_quality_body": "Press and hold the share button to choose the image quality before you share.",
@@ -2203,6 +2221,8 @@
"theme": "Theme", "theme": "Theme",
"theme_selection": "Theme selection", "theme_selection": "Theme selection",
"theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference", "theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Scroll continuously when dragging the scrollbar instead of snapping to months",
"theme_setting_asset_list_smooth_scrubbing_title": "Smooth timeline scrubbing",
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})",
"theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.",
+20
View File
@@ -323,6 +323,13 @@
"oauth_timeout": "Таймаут для запросов", "oauth_timeout": "Таймаут для запросов",
"oauth_timeout_description": "Максимальное время, в течение которого ожидать ответа, в миллисекундах", "oauth_timeout_description": "Максимальное время, в течение которого ожидать ответа, в миллисекундах",
"ocr_job_description": "Использование машинного обучения для распознавания текста на изображениях", "ocr_job_description": "Использование машинного обучения для распознавания текста на изображениях",
"passkey_additional_domains": "Дополнительные домены",
"passkey_additional_domains_description": "Дополнительные доверенные домены. Для их работы основной домен ключей доступа также должен быть доступен.",
"passkey_domain": "Домен ключей доступа",
"passkey_domain_description": "Домен для ключей доступа. Оставьте пустым для определения по адресу сайта. При изменении домена существующие ключи перестанут работать.",
"passkey_enable_description": "Вход с помощью ключа доступа",
"passkey_settings": "Вход по ключу доступа",
"passkey_settings_description": "Настройки входа по ключу доступа",
"password_enable_description": "Вход по электронной почте и паролю", "password_enable_description": "Вход по электронной почте и паролю",
"password_settings": "Настройки входа с паролем", "password_settings": "Настройки входа с паролем",
"password_settings_description": "Управление настройками входа по паролю", "password_settings_description": "Управление настройками входа по паролю",
@@ -873,6 +880,8 @@
"delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии", "delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии",
"delete_local_dialog_ok_force": "Все равно удалить", "delete_local_dialog_ok_force": "Все равно удалить",
"delete_others": "Удалить остальные", "delete_others": "Удалить остальные",
"delete_passkey": "Удалить ключ доступа",
"delete_passkey_prompt": "Удалить этот ключ доступа?",
"delete_permanently": "Удалить навсегда", "delete_permanently": "Удалить навсегда",
"delete_permanently_action_prompt": "Объекты удалены безвозвратно ({count} шт.)", "delete_permanently_action_prompt": "Объекты удалены безвозвратно ({count} шт.)",
"delete_shared_link": "Удалить публичную ссылку", "delete_shared_link": "Удалить публичную ссылку",
@@ -942,6 +951,7 @@
"edit_location_action_prompt": "Места изменены ({count} шт.)", "edit_location_action_prompt": "Места изменены ({count} шт.)",
"edit_location_dialog_title": "Местоположение", "edit_location_dialog_title": "Местоположение",
"edit_name": "Изменить имя", "edit_name": "Изменить имя",
"edit_passkey": "Изменить ключ доступа",
"edit_people": "Изменить людей", "edit_people": "Изменить людей",
"edit_person": "Изменить человека", "edit_person": "Изменить человека",
"edit_tag": "Изменить тег", "edit_tag": "Изменить тег",
@@ -1287,6 +1297,7 @@
"last": "Последний", "last": "Последний",
"last_months": "{count, plural, one {Последний месяц} many {# последних месяцев} other {# последних месяца}}", "last_months": "{count, plural, one {Последний месяц} many {# последних месяцев} other {# последних месяца}}",
"last_seen": "Последний доступ", "last_seen": "Последний доступ",
"last_used": "Последнее использование",
"latest_version": "Последняя версия", "latest_version": "Последняя версия",
"latitude": "Широта", "latitude": "Широта",
"leave": "Покинуть", "leave": "Покинуть",
@@ -1379,6 +1390,7 @@
"login_has_been_disabled": "Вход в систему был отключён", "login_has_been_disabled": "Вход в систему был отключён",
"login_password_changed_error": "Произошла ошибка при обновлении пароля", "login_password_changed_error": "Произошла ошибка при обновлении пароля",
"login_password_changed_success": "Пароль успешно обновлен", "login_password_changed_success": "Пароль успешно обновлен",
"login_with_passkey": "Войти с ключом доступа",
"logout_all_device_confirmation": "Вы действительно хотите завершить все сеансы, кроме текущего?", "logout_all_device_confirmation": "Вы действительно хотите завершить все сеансы, кроме текущего?",
"logout_this_device_confirmation": "Вы действительно хотите завершить сеанс на этом устройстве?", "logout_this_device_confirmation": "Вы действительно хотите завершить сеанс на этом устройстве?",
"logs": "Журнал событий", "logs": "Журнал событий",
@@ -1427,6 +1439,7 @@
"manage_your_api_keys": "Управление API ключами для взаимодействия с другими программами", "manage_your_api_keys": "Управление API ключами для взаимодействия с другими программами",
"manage_your_devices": "Управление устройствами, на которых вы входили в свой аккаунт", "manage_your_devices": "Управление устройствами, на которых вы входили в свой аккаунт",
"manage_your_oauth_connection": "Настройки подключённого OAuth", "manage_your_oauth_connection": "Настройки подключённого OAuth",
"manage_your_passkeys": "Управление ключами доступа",
"map": "Карта", "map": "Карта",
"map_assets_in_bounds": "{count, plural, =0 {Нет фото в этой области} other {# фото}}", "map_assets_in_bounds": "{count, plural, =0 {Нет фото в этой области} other {# фото}}",
"map_cannot_get_user_location": "Невозможно получить местоположение пользователя", "map_cannot_get_user_location": "Невозможно получить местоположение пользователя",
@@ -1546,6 +1559,7 @@
"new_album": "Новый альбом", "new_album": "Новый альбом",
"new_api_key": "Новый API ключ", "new_api_key": "Новый API ключ",
"new_feature": "Новая функциональность", "new_feature": "Новая функциональность",
"new_passkey": "Новый ключ доступа",
"new_password": "Новый пароль", "new_password": "Новый пароль",
"new_person": "Новый человек", "new_person": "Новый человек",
"new_pin_code": "Новый PIN-код", "new_pin_code": "Новый PIN-код",
@@ -1658,6 +1672,8 @@
"partner_sharing": "Совместное использование", "partner_sharing": "Совместное использование",
"partners": "Партнёры", "partners": "Партнёры",
"partners_description": "Делитесь своими фото и видео с другими пользователями", "partners_description": "Делитесь своими фото и видео с другими пользователями",
"passkeys": "{count, plural, one {Ключ доступа} few {Ключи доступа} many {Ключи доступа} other {Ключи доступа}}",
"passkeys_not_supported": "Этот браузер не поддерживает ключи доступа",
"password": "Пароль", "password": "Пароль",
"password_does_not_match": "Пароли не совпадают", "password_does_not_match": "Пароли не совпадают",
"password_required": "Требуется пароль", "password_required": "Требуется пароль",
@@ -1842,6 +1858,7 @@
"removed_from_favorites_count": "{count, plural, one {# объект удалён} many {# объектов удалено} other {# объекта удалено}} из избранного", "removed_from_favorites_count": "{count, plural, one {# объект удалён} many {# объектов удалено} other {# объекта удалено}} из избранного",
"removed_from_stack": "Объект удалён из группы", "removed_from_stack": "Объект удалён из группы",
"removed_memory": "Воспоминание удалено", "removed_memory": "Воспоминание удалено",
"removed_passkey": "Удалён ключ доступа: {name}",
"removed_tagged_assets": "Тег удалён у {count, plural, one {# объекта} other {# объектов}}", "removed_tagged_assets": "Тег удалён у {count, plural, one {# объекта} other {# объектов}}",
"rename": "Переименовать", "rename": "Переименовать",
"repository": "Репозиторий", "repository": "Репозиторий",
@@ -2027,6 +2044,7 @@
"share_everyone": "Поделиться со всеми", "share_everyone": "Поделиться со всеми",
"share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.", "share_everyone_description": "Предоставить пользователю {user} доступ всем своим людям.",
"share_link": "Создать ссылку", "share_link": "Создать ссылку",
"share_logs": "Поделиться журналами",
"share_original": "Оригинал (большой размер)", "share_original": "Оригинал (большой размер)",
"share_preview": "Предпросмотр (небольшой размер)", "share_preview": "Предпросмотр (небольшой размер)",
"share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.", "share_quality_body": "Нажмите и удерживайте кнопку «Поделиться», чтобы выбрать качество изображения перед отправкой.",
@@ -2206,6 +2224,8 @@
"theme": "Тема", "theme": "Тема",
"theme_selection": "Выбор темы", "theme_selection": "Выбор темы",
"theme_selection_description": "Автоматически устанавливать светлую или тёмную тему в зависимости от настроек вашего браузера", "theme_selection_description": "Автоматически устанавливать светлую или тёмную тему в зависимости от настроек вашего браузера",
"theme_setting_asset_list_smooth_scrubbing_subtitle": "Прокручивать ленту плавно при перетаскивании полосы прокрутки, без переходов по месяцам",
"theme_setting_asset_list_smooth_scrubbing_title": "Плавная прокрутка ленты",
"theme_setting_asset_list_storage_indicator_title": "Отображать индикатор хранилища на плитках объектов", "theme_setting_asset_list_storage_indicator_title": "Отображать индикатор хранилища на плитках объектов",
"theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({count})", "theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({count})",
"theme_setting_colorful_interface_subtitle": "Добавить оттенок к фону.", "theme_setting_colorful_interface_subtitle": "Добавить оттенок к фону.",
+7 -7
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS builder-cpu FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-cpu
FROM python:3.13-slim-trixie@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b AS builder-openvino FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS builder-openvino
# renovate: datasource=github-releases depName=microsoft/onnxruntime # renovate: datasource=github-releases depName=microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.30.0" ARG ONNXRUNTIME_VERSION="v1.30.0"
@@ -59,7 +59,7 @@ RUN mkdir /opt/armnn && \
cd /opt/ann && \ cd /opt/ann && \
sh build.sh sh build.sh
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS builder-rknn FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image # Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size # TODO: find a way to reduce the image size
@@ -81,11 +81,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy && \ uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy && \
if [ "$DEVICE" = "openvino" ]; then uv pip install --no-deps /opt/onnxruntime_openvino-*.whl; fi if [ "$DEVICE" = "openvino" ]; then uv pip install --no-deps /opt/onnxruntime_openvino-*.whl; fi
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-cpu FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-cpu
ENV MACHINE_LEARNING_MODEL_ARENA=false ENV MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS prod-openvino FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS prod-openvino
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
@@ -141,7 +141,7 @@ COPY --from=builder-armnn \
/opt/ann/build.sh \ /opt/ann/build.sh \
/opt/armnn/ /opt/armnn/
FROM python:3.12-slim-trixie@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea AS prod-rknn FROM python:3.12-slim-trixie@sha256:cb33f7f771ecec2ef1225cf39b159591a040391bd73e4c01f00fdfc4c47611d2 AS prod-rknn
# renovate: datasource=github-tags depName=airockchip/rknn-toolkit2 # renovate: datasource=github-tags depName=airockchip/rknn-toolkit2
ARG RKNN_TOOLKIT_VERSION="v2.3.0" ARG RKNN_TOOLKIT_VERSION="v2.3.0"
@@ -150,7 +150,7 @@ ENV MACHINE_LEARNING_MODEL_ARENA=false
ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/ ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/
FROM python:3.13-slim-trixie@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS mimalloc FROM python:3.13-slim-trixie@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f AS mimalloc
RUN echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/backports.list && \ RUN echo "deb http://deb.debian.org/debian trixie-backports main" > /etc/apt/sources.list.d/backports.list && \
apt-get update && \ apt-get update && \
apt-get install -y --no-install-recommends libmimalloc3/trixie-backports && \ apt-get install -y --no-install-recommends libmimalloc3/trixie-backports && \
+3 -1
View File
@@ -3,6 +3,7 @@ from collections.abc import Hashable
from typing import Any from typing import Any
from immich_ml import allocator from immich_ml import allocator
from immich_ml.config import clean_name
from immich_ml.models.base import InferenceEntry, InferenceModel from immich_ml.models.base import InferenceEntry, InferenceModel
from immich_ml.schemas import Options from immich_ml.schemas import Options
@@ -15,7 +16,8 @@ class ModelCache:
self._expiries: dict[Key, asyncio.TimerHandle] = {} self._expiries: dict[Key, asyncio.TimerHandle] = {}
def get[O: Options](self, entry: InferenceEntry[O], ttl: int | None = None) -> InferenceModel[O]: def get[O: Options](self, entry: InferenceEntry[O], ttl: int | None = None) -> InferenceModel[O]:
key = (entry.model, entry.name, entry.model.graph(entry.options)) # names that differ only in what cleaning drops, like an `immich-app/` prefix, are the same model
key = (entry.model, clean_name(entry.name), entry.model.graph(entry.options))
if key not in self._models: if key not in self._models:
self._models[key] = entry.model.create(entry.name, entry.options) self._models[key] = entry.model.create(entry.name, entry.options)
elif key not in self._expiries: elif key not in self._expiries:
+8
View File
@@ -1856,6 +1856,14 @@ class TestCache:
assert len(model_cache._models) == 1 assert len(model_cache._models) == 1
model.create.assert_called_once() model.create.assert_called_once()
async def test_shares_an_instance_across_names_of_the_same_model(self) -> None:
model_cache = ModelCache()
bare = model_cache.get(InferenceEntry(OpenClipTextualEncoder, "ViT-B-32__openai", TextualOptions()))
prefixed = model_cache.get(
InferenceEntry(OpenClipTextualEncoder, "immich-app/ViT-B-32__openai", TextualOptions(language="en-US"))
)
assert bare is prefixed
async def test_separate_instances_per_graph_option(self) -> None: async def test_separate_instances_per_graph_option(self) -> None:
# the real TextDetector, so that keying it on anything but its resolution fails this # the real TextDetector, so that keying it on anything but its resolution fails this
model_cache = ModelCache() model_cache = ModelCache()
+3 -3
View File
@@ -175,11 +175,11 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.11.12" version = "2026.10.19"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
] ]
[[package]] [[package]]
+49 -52
View File
@@ -212,36 +212,37 @@ checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip" url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]] [[tools.node]]
version = "24.21.0" version = "24.22.1"
backend = "core:node" backend = "core:node"
specifiers = ["24.22.1"]
[tools.node."platforms.linux-arm64"] [tools.node."platforms.linux-arm64"]
checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5" checksum = "sha256:33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz" url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"] [tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4" checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4"
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz" url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz"
[tools.node."platforms.linux-x64"] [tools.node."platforms.linux-x64"]
checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" checksum = "sha256:eb6728e77c576a35162c122c45ccadf224d89ff00abc8dae46cc2897c6002d7c"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz" url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"] [tools.node."platforms.linux-x64-musl"]
checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a" checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a"
url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz" url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz"
[tools.node."platforms.macos-arm64"] [tools.node."platforms.macos-arm64"]
checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057" checksum = "sha256:89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz" url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz"
[tools.node."platforms.macos-x64"] [tools.node."platforms.macos-x64"]
checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" checksum = "sha256:d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz" url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz"
[tools.node."platforms.windows-x64"] [tools.node."platforms.windows-x64"]
checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541" checksum = "sha256:d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5"
url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip" url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip"
[[tools."npm:@openapitools/openapi-generator-cli"]] [[tools."npm:@openapitools/openapi-generator-cli"]]
version = "2.41.0" version = "2.41.0"
@@ -252,13 +253,14 @@ version = "7.5.0"
backend = "npm:oazapfts" backend = "npm:oazapfts"
[[tools.opentofu]] [[tools.opentofu]]
version = "1.12.6" version = "1.13.1"
backend = "aqua:opentofu/opentofu" backend = "aqua:opentofu/opentofu"
specifiers = ["1.13.1"]
[tools.opentofu."platforms.linux-arm64"] [tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e" checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055"
[tools.opentofu."platforms.linux-arm64-musl"] [tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e" checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
@@ -266,9 +268,9 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
[tools.opentofu."platforms.linux-x64"] [tools.opentofu."platforms.linux-x64"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec" checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017"
[tools.opentofu."platforms.linux-x64-musl"] [tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec" checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
@@ -276,58 +278,53 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
[tools.opentofu."platforms.macos-arm64"] [tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:f958ec5e511063be9feb180ca015a4cb7977566a9cf6a8550bba8c2a9b5aba74" checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762710" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059"
[tools.opentofu."platforms.macos-x64"] [tools.opentofu."platforms.macos-x64"]
checksum = "sha256:44bb1855f372f17f365fb94517906e78da5001da10f4c98de57a39bf982f3a92" checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_darwin_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762655" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142"
[tools.opentofu."platforms.windows-x64"] [tools.opentofu."platforms.windows-x64"]
checksum = "sha256:bcbcd9ca744af6c4053e2d5e75dae79ff33ad7dc38d45e1233e693d096284538" checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_windows_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762708" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102"
[[tools.pnpm]] [[tools.pnpm]]
version = "11.27.1" version = "12.14.0"
backend = "aqua:pnpm/pnpm" backend = "aqua:pnpm/pnpm"
specifiers = ["12.14.0"]
[tools.pnpm."platforms.linux-arm64"] [tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:dbf8f84263d2bc696306d4219be68256d881d9af409345374d878676a7edcf0c" checksum = "sha256:cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64.tar.gz" url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602580" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957409"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:7a7854e90bdb7d339430d9943ea482b34d7bde817cfc1c749d3c04ddd464142d"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602583"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"] [tools.pnpm."platforms.linux-x64"]
checksum = "sha256:6be1426540406747b7731564a46c5b30d3023ea6c19e4ce99f994c4ec122f3d2" checksum = "sha256:3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64.tar.gz" url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602576" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957410"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:cc66c1558a1a03b320ff5b4022f755386fa1de54db3f97ff624112015be1ccf1"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602579"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"] [tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:352f67ceaaa5aba294a53f483da93c376005b4c7e785fe5411e0ce720fe74741" checksum = "sha256:50f48e0e17508952790a3fbf58fcf14f3b82efa5f594ca04ad904e29aaf854b0"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-darwin-arm64.tar.gz" url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602575" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957413"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:2c412e07382049b4edb04fa6da78f3d4ef7810b75853c80544dcfb7c0ed79880"
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957411"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"] [tools.pnpm."platforms.windows-x64"]
checksum = "sha256:4b56d1184c3390b08146060fa4b414b8ceec9164aa41bf996e87a0f0bc135851" checksum = "sha256:6f0f38e917c8ce76ef31db528c357e2bfa354a1bcb16464e3cdec39e6530d784"
url = "https://github.com/pnpm/pnpm/releases/download/v11.27.1/pnpm-win32-x64.zip" url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/577602578" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957404"
provenance = "github-attestations" provenance = "github-attestations"
[[tools.terragrunt]] [[tools.terragrunt]]
+3 -3
View File
@@ -15,10 +15,10 @@ config_roots = [
] ]
[tools] [tools]
node = "24.21.0" node = "24.22.1"
pnpm = "11.27.1" pnpm = "12.14.0"
terragrunt = "1.1.1" terragrunt = "1.1.1"
opentofu = "1.12.6" opentofu = "1.13.1"
"npm:@openapitools/openapi-generator-cli" = "2.41.0" "npm:@openapitools/openapi-generator-cli" = "2.41.0"
"npm:oazapfts" = "7.5.0" "npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3" "github:extism/cli" = "1.6.3"
@@ -75,6 +75,7 @@ abstract class AppConfig with _$AppConfig {
.timelineTilesPerRow => timeline.tilesPerRow, .timelineTilesPerRow => timeline.tilesPerRow,
.timelineGroupAssetsBy => timeline.groupAssetsBy, .timelineGroupAssetsBy => timeline.groupAssetsBy,
.timelineStorageIndicator => timeline.storageIndicator, .timelineStorageIndicator => timeline.storageIndicator,
.timelineSmoothScrubbing => timeline.smoothScrubbing,
.mapShowFavoriteOnly => map.favoritesOnly, .mapShowFavoriteOnly => map.favoritesOnly,
.mapRelativeDate => map.relativeDays, .mapRelativeDate => map.relativeDays,
.mapCustomFrom => map.customFrom, .mapCustomFrom => map.customFrom,
@@ -131,6 +132,7 @@ abstract class AppConfig with _$AppConfig {
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)), .timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)), .timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)), .timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
.timelineSmoothScrubbing => copyWith(timeline: timeline.copyWith(smoothScrubbing: value as bool)),
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)), .mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)), .mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))), .mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
@@ -10,5 +10,6 @@ abstract class TimelineConfig with _$TimelineConfig {
@Default(4) int tilesPerRow, @Default(4) int tilesPerRow,
@Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy, @Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy,
@Default(true) bool storageIndicator, @Default(true) bool storageIndicator,
@Default(false) bool smoothScrubbing,
}) = _TimelineConfig; }) = _TimelineConfig;
} }
@@ -48,6 +48,7 @@ enum SettingsKey<T> {
timelineTilesPerRow<int>(), timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)), timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(), timelineStorageIndicator<bool>(),
timelineSmoothScrubbing<bool>(),
// Log // Log
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)), logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
+77 -5
View File
@@ -1,12 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'package:http/http.dart' show ClientException;
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart' show ApiException;
/// Service responsible for handling application logging. /// Service responsible for handling application logging.
/// ///
@@ -14,8 +18,15 @@ import 'package:logging/logging.dart';
/// writes them to a persistent [LogRepository], and manages log levels via /// writes them to a persistent [LogRepository], and manages log levels via
/// [SettingsRepository]. /// [SettingsRepository].
class LogService { class LogService {
static final _schemeAndHost = RegExp(r'''https?://[^\s/?#'"<>(),]*''');
static final _ipAddress = RegExp(
r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|'
r'\[?\b(?:(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:)+(?::[0-9a-f]{1,4})+)(?:%\w+)?\]?',
);
final LogRepository _logRepository; final LogRepository _logRepository;
final SettingsRepository _settingsRepository; final SettingsRepository _settingsRepository;
final StoreService _storeService;
final List<LogMessage> _msgBuffer = []; final List<LogMessage> _msgBuffer = [];
@@ -39,11 +50,13 @@ class LogService {
static Future<LogService> init({ static Future<LogService> init({
required LogRepository logRepository, required LogRepository logRepository,
required SettingsRepository settingsRepository, required SettingsRepository settingsRepository,
required StoreService storeService,
bool shouldBuffer = true, bool shouldBuffer = true,
}) async { }) async {
_instance ??= await create( _instance ??= await create(
logRepository: logRepository, logRepository: logRepository,
settingsRepository: settingsRepository, settingsRepository: settingsRepository,
storeService: storeService,
shouldBuffer: shouldBuffer, shouldBuffer: shouldBuffer,
); );
return _instance!; return _instance!;
@@ -52,16 +65,17 @@ class LogService {
static Future<LogService> create({ static Future<LogService> create({
required LogRepository logRepository, required LogRepository logRepository,
required SettingsRepository settingsRepository, required SettingsRepository settingsRepository,
required StoreService storeService,
bool shouldBuffer = true, bool shouldBuffer = true,
}) async { }) async {
final instance = LogService._(logRepository, settingsRepository, shouldBuffer); final instance = LogService._(logRepository, settingsRepository, storeService, shouldBuffer);
await logRepository.truncate(limit: kLogTruncateLimit); await logRepository.truncate(limit: kLogTruncateLimit);
final level = instance._settingsRepository.appConfig.logLevel; final level = instance._settingsRepository.appConfig.logLevel;
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO; Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
return instance; return instance;
} }
LogService._(this._logRepository, this._settingsRepository, this._shouldBuffer) { LogService._(this._logRepository, this._settingsRepository, this._storeService, this._shouldBuffer) {
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord); _logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
} }
@@ -74,12 +88,12 @@ class LogService {
); );
final record = LogMessage( final record = LogMessage(
message: r.message, message: _redact(r.message),
level: r.level.toLogLevel(), level: r.level.toLogLevel(),
createdAt: r.time, createdAt: r.time,
logger: r.loggerName, logger: r.loggerName,
error: r.error?.toString(), error: r.error == null ? null : _redactError(r.error!),
stack: r.stackTrace?.toString(), stack: r.stackTrace == null ? null : _redact(r.stackTrace.toString()),
); );
if (_shouldBuffer) { if (_shouldBuffer) {
@@ -100,6 +114,64 @@ class LogService {
return [..._msgBuffer.reversed, ...logsFromDb]; return [..._msgBuffer.reversed, ...logsFromDb];
} }
String _redact(String text) {
var redacted = text.replaceAllMapped(_schemeAndHost, (match) => _urlLabel(Uri.tryParse(match[0]!)));
final accessToken = _storeService.tryGet(StoreKey.accessToken);
if (accessToken != null) {
redacted = redacted.replaceAll(accessToken, '[ACCESS TOKEN]');
}
// as a header map prints it, so a short value like "1" can't match other text
for (final header in _settingsRepository.appConfig.network.customHeaders.entries) {
redacted = redacted.replaceAll(
RegExp('${RegExp.escape(header.key)}:\\s+${RegExp.escape(header.value)}'),
'${header.key}: [HEADER VALUE]',
);
}
return redacted;
}
String _redactError(Object error) {
final redacted = _redact(error.toString());
final httpError = error is ApiException ? error.innerException : error;
if (httpError is! ClientException) {
return redacted;
}
// ok_http also prints the host and its ip on their own: "Failed to connect to host/203.0.113.9:443"
final url = httpError.uri;
final withoutHost = url == null || url.host.isEmpty
? redacted
: redacted.replaceAll(RegExp('\\b${RegExp.escape(url.host)}\\b'), _urlLabel(url));
return withoutHost.replaceAll(_ipAddress, '[IP ADDRESS]');
}
String _urlLabel(Uri? url) {
if (url == null) {
return '[OTHER URL]';
}
// a server url that is also a switching entry gets the entry's label
final network = _settingsRepository.appConfig.network;
for (var i = 0; i < network.externalEndpointList.length; i++) {
if (_isSameAddress(url, network.externalEndpointList[i])) {
return '[EXTERNAL ENDPOINT ${i + 1}]';
}
}
if (_isSameAddress(url, network.localEndpoint)) {
return '[LOCAL ENDPOINT]';
}
if (_isSameAddress(url, _storeService.tryGet(StoreKey.serverUrl)) ||
_isSameAddress(url, _storeService.tryGet(StoreKey.serverEndpoint))) {
return '[SERVER URL]';
}
return '[OTHER URL]';
}
bool _isSameAddress(Uri url, String? savedUrl) {
final saved = savedUrl == null ? null : Uri.tryParse(savedUrl);
return saved != null && saved.scheme == url.scheme && saved.host == url.host && saved.port == url.port;
}
Future<void> clearLogs() async { Future<void> clearLogs() async {
_flushTimer?.cancel(); _flushTimer?.cancel();
_flushTimer = null; _flushTimer = null;
+1
View File
@@ -74,6 +74,7 @@ void main() async {
), ),
); );
} catch (error, stack) { } catch (error, stack) {
Logger('main').severe('Failed to start', error, stack);
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString())); runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
} }
} }
+18 -21
View File
@@ -22,6 +22,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/theme/color_scheme.dart'; import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/fallback_localizations.dart'; import 'package:immich_mobile/utils/fallback_localizations.dart';
@@ -78,9 +79,12 @@ class BootstrapErrorWidget extends StatelessWidget {
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
const SafeArea( SafeArea(
top: false, top: false,
child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()), child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: _BottomPanel(startupError: '$error\n\n$stack'),
),
), ),
], ],
), ),
@@ -93,7 +97,9 @@ class BootstrapErrorWidget extends StatelessWidget {
} }
class _BottomPanel extends StatefulWidget { class _BottomPanel extends StatefulWidget {
const _BottomPanel(); final String startupError;
const _BottomPanel({required this.startupError});
@override @override
State<_BottomPanel> createState() => _BottomPanelState(); State<_BottomPanel> createState() => _BottomPanelState();
@@ -157,8 +163,8 @@ class _BottomPanelState extends State<_BottomPanel> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
_ActionLink( _ActionLink(
icon: Icons.chat_bubble_outline, icon: Icons.chat_bubble_outline,
@@ -173,6 +179,11 @@ class _BottomPanelState extends State<_BottomPanel> {
mode: LaunchMode.externalApplication, mode: LaunchMode.externalApplication,
), ),
), ),
_ActionLink(
icon: Icons.share_outlined,
label: context.t.share_logs,
onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError),
),
if (!_cleared) if (!_cleared)
_ActionLink( _ActionLink(
icon: Icons.delete_outline, icon: Icons.delete_outline,
@@ -288,21 +299,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
unawaited( unawaited(ref.read(authProvider.notifier).setOpenApiServiceEndpoint().whenComplete(() => resumeSession()));
ref
.read(authProvider.notifier)
.setOpenApiServiceEndpoint()
.then(logConnectionInfo)
.whenComplete(() => resumeSession()),
);
}
void logConnectionInfo(String? endpoint) {
if (endpoint == null) {
return;
}
log.info("Resuming session at $endpoint");
} }
Future<void> resumeSession() async { Future<void> resumeSession() async {
@@ -356,7 +353,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
} }
}, },
onError: (exception) { onError: (exception) {
log.severe('Failed to update auth info with access token: $accessToken'); log.severe('Failed to update auth info', exception);
if (!mounted) { if (!mounted) {
return; return;
} }
+54 -68
View File
@@ -417,21 +417,57 @@ final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>(
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith); return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith);
}); });
class _QuickAccessItem {
final Widget leading;
final String title;
final VoidCallback onTap;
final EdgeInsetsGeometry? contentPadding;
const _QuickAccessItem({required this.leading, required this.title, required this.onTap, this.contentPadding});
}
class _QuickAccessButtonList extends ConsumerWidget { class _QuickAccessButtonList extends ConsumerWidget {
const _QuickAccessButtonList(); const _QuickAccessButtonList();
static const _tileRadius = 20.0;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider); final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider);
final partners = partnerSharedWithAsync.valueOrNull ?? []; final partners = partnerSharedWithAsync.valueOrNull ?? [];
final items = [
_QuickAccessItem(
leading: const Icon(Icons.folder_outlined, size: 26),
title: context.t.folders,
onTap: () => context.pushRoute(FolderRoute()),
),
_QuickAccessItem(
leading: const Icon(Icons.lock_outline_rounded, size: 26),
title: context.t.locked_folder,
onTap: () => context.pushRoute(const LockedFolderRoute()),
),
_QuickAccessItem(
leading: const Icon(Icons.group_outlined, size: 26),
title: context.t.partners,
onTap: () => context.pushRoute(const PartnerRoute()),
),
for (final partner in partners)
_QuickAccessItem(
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: context.t.partner_list_user_photos(user: partner.name),
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
),
];
return SliverPadding( return SliverPadding(
padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32), padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1), border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)), borderRadius: const BorderRadius.all(Radius.circular(_tileRadius)),
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
context.colorScheme.primary.withAlpha(10), context.colorScheme.primary.withAlpha(10),
@@ -442,83 +478,33 @@ class _QuickAccessButtonList extends ConsumerWidget {
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
), ),
), ),
child: ListView( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ itemCount: items.length,
ListTile( itemBuilder: (context, index) {
final item = items[index];
final isFirst = index == 0;
final isLast = index == items.length - 1;
return ListTile(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20), topLeft: Radius.circular(isFirst ? _tileRadius : 0),
topRight: const Radius.circular(20), topRight: Radius.circular(isFirst ? _tileRadius : 0),
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0), bottomLeft: Radius.circular(isLast ? _tileRadius : 0),
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0), bottomRight: Radius.circular(isLast ? _tileRadius : 0),
), ),
), ),
leading: const Icon(Icons.folder_outlined, size: 26), contentPadding: item.contentPadding,
title: Text( leading: item.leading,
context.t.folders, title: Text(item.title, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), onTap: item.onTap,
), );
onTap: () => context.pushRoute(FolderRoute()), },
),
ListTile(
leading: const Icon(Icons.lock_outline_rounded, size: 26),
title: Text(
context.t.locked_folder,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const LockedFolderRoute()),
),
ListTile(
leading: const Icon(Icons.group_outlined, size: 26),
title: Text(
context.t.partners,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const PartnerRoute()),
),
_PartnerList(partners: partners.toList()),
],
), ),
), ),
), ),
); );
} }
} }
class _PartnerList extends StatelessWidget {
const _PartnerList({required this.partners});
final List<Partner> partners;
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: partners.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final partner = partners[index];
final isLastItem = index == partners.length - 1;
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
bottomRight: Radius.circular(isLastItem ? 20 : 0),
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: Text(
context.t.partner_list_user_photos(user: partner.name),
style: const TextStyle(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
);
},
);
}
}
@@ -328,6 +328,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled; final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
final isBottomWidgetVisible = final isBottomWidgetVisible =
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar); widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
final smoothScrubbing = ref.watch(appConfigProvider.select((config) => config.timeline.smoothScrubbing));
final snapToMonth = widget.snapToMonth && !smoothScrubbing;
return PopScope( return PopScope(
canPop: !isMultiSelectEnabled, canPop: !isMultiSelectEnabled,
@@ -409,7 +411,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
final Widget timeline; final Widget timeline;
if (widget.withScrubber) { if (widget.withScrubber) {
timeline = Scrubber( timeline = Scrubber(
snapToMonth: widget.snapToMonth, snapToMonth: snapToMonth,
layoutSegments: segments, layoutSegments: segments,
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: topPadding, topPadding: topPadding,
@@ -84,8 +84,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
// Needs to be logged in // Needs to be logged in
if (isAuthenticated) { if (isAuthenticated) {
// switch endpoint if needed // switch endpoint if needed
final endpoint = await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint(); await _ref.read(authProvider.notifier).setOpenApiServiceEndpoint();
_log.info("Using server URL: $endpoint");
await _ref.read(serverInfoProvider.notifier).getServerVersion(); await _ref.read(serverInfoProvider.notifier).getServerVersion();
} }
@@ -6,7 +6,6 @@ import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>( final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
@@ -21,7 +20,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final AppRouter router; final AppRouter router;
final ForegroundUploadService _foregroundUploadService; final ForegroundUploadService _foregroundUploadService;
final ShareIntentService _shareIntentService; final ShareIntentService _shareIntentService;
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]); ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
@@ -74,8 +72,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
onSuccess: (fileId, _) { onSuccess: (fileId, _) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0); _updateStatus(fileId, UploadStatus.complete, progress: 1.0);
}, },
onError: (fileId, errorMessage) { onError: (fileId, _) {
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
_updateStatus(fileId, UploadStatus.failed); _updateStatus(fileId, UploadStatus.failed);
}, },
); );
@@ -221,8 +221,6 @@ class BackupNotifier extends StateNotifier<BackupState> {
} }
void _handleForegroundBackupError(String localAssetId, String errorMessage) { void _handleForegroundBackupError(String localAssetId, String errorMessage) {
_logger.severe("Upload failed for $localAssetId: $errorMessage");
final currentItem = state.uploadItems[localAssetId]; final currentItem = state.uploadItems[localAssetId];
if (currentItem != null) { if (currentItem != null) {
state = state.copyWith( state = state.copyWith(
@@ -88,7 +88,7 @@ class UploadRepository {
} on RequestAbortedException { } on RequestAbortedException {
rethrow; rethrow;
} on ClientException catch (error) { } on ClientException catch (error) {
logger.warning("Upload $logContext failed before a response, resending once: $error"); logger.warning("Upload $logContext failed before a response, resending once", error);
response = await client.send(buildRequest()); response = await client.send(buildRequest());
} }
@@ -124,8 +124,8 @@ class UploadRepository {
logger.warning("Upload $logContext was cancelled"); logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled(); return UploadResult.cancelled();
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.warning("Error uploading $logContext: $error: $stackTrace"); logger.warning("Error uploading $logContext", error, stackTrace);
return UploadResult.error(errorMessage: error.toString()); return UploadResult(isSuccess: false, isCancelled: false, errorMessage: error.toString(), error: error);
} }
} }
} }
@@ -166,6 +166,7 @@ class UploadResult {
final String? remoteAssetId; final String? remoteAssetId;
final String? errorMessage; final String? errorMessage;
final int? statusCode; final int? statusCode;
final Object? error;
const UploadResult({ const UploadResult({
required this.isSuccess, required this.isSuccess,
@@ -173,6 +174,7 @@ class UploadResult {
this.remoteAssetId, this.remoteAssetId,
this.errorMessage, this.errorMessage,
this.statusCode, this.statusCode,
this.error,
}); });
factory UploadResult.success({required String remoteAssetId}) { factory UploadResult.success({required String remoteAssetId}) {
+5 -2
View File
@@ -162,6 +162,7 @@ class AuthService {
final localEndpoint = _authRepository.getLocalEndpoint(); final localEndpoint = _authRepository.getLocalEndpoint();
if (localEndpoint != null) { if (localEndpoint != null) {
await _apiService.resolveAndSetEndpoint(localEndpoint); await _apiService.resolveAndSetEndpoint(localEndpoint);
_log.info("Using local endpoint");
return localEndpoint; return localEndpoint;
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -181,9 +182,11 @@ class AuthService {
return null; return null;
} }
for (final endpoint in endpointList) { for (var i = 0; i < endpointList.length; i++) {
try { try {
return await _apiService.resolveAndSetEndpoint(endpoint.url); final url = await _apiService.resolveAndSetEndpoint(endpointList[i].url);
_log.info("Using external endpoint ${i + 1}");
return url;
} on ApiException catch (error) { } on ApiException catch (error) {
_log.severe("Cannot resolve endpoint", error); _log.severe("Cannot resolve endpoint", error);
continue; continue;
@@ -182,6 +182,7 @@ class ForegroundUploadService {
if (result.isSuccess) { if (result.isSuccess) {
onSuccess?.call(fileId, result.remoteAssetId!); onSuccess?.call(fileId, result.remoteAssetId!);
} else if (!result.isCancelled && result.errorMessage != null) { } else if (!result.isCancelled && result.errorMessage != null) {
_logger.warning("Error(${result.statusCode}) uploading $fileId", result.error ?? result.errorMessage);
onError?.call(fileId, result.errorMessage!); onError?.call(fileId, result.errorMessage!);
} }
}, },
@@ -395,7 +396,8 @@ class ForegroundUploadService {
} else if (result.errorMessage != null) { } else if (result.errorMessage != null) {
_logger.severe( _logger.severe(
() => () =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}", "Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt}",
result.error ?? result.errorMessage,
); );
callbacks.onError?.call(asset.localId!, result.errorMessage!); callbacks.onError?.call(asset.localId!, result.errorMessage!);
+29 -3
View File
@@ -2,7 +2,11 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/data/db/logger/database.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -15,15 +19,37 @@ import 'package:share_plus/share_plus.dart';
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
/// and generate a csv file. /// and generate a csv file.
abstract final class ImmichLogger { abstract final class ImmichLogger {
static Future<void> shareLogs(BuildContext context) async { // the startup error screen can show before the log service starts, and then the startup error was not logged
static Future<List<LogMessage>> _read(String? startupError) async {
try {
return await LogService.I.getMessages();
} on LoggerUnInitializedException {
final connection = await openSqliteConnection(name: 'immich_logs');
final logs = DriftLogger.sqlite(connection);
try {
return [
// if the log service had started, main would have logged this already
if (startupError != null)
LogMessage(message: startupError, level: LogLevel.severe, createdAt: DateTime.now()),
...await LogRepository(logs).getAll(),
];
} finally {
await logs.close();
await connection.close();
}
}
}
static Future<void> shareLogs(BuildContext context, {String? startupError}) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final dateTime = DateTime.now().toIso8601String(); final dateTime = DateTime.now().toIso8601String();
final filePath = '${tempDir.path}/Immich_log_$dateTime.log'; final safeTimestamp = dateTime.replaceAll(':', '-');
final filePath = '${tempDir.path}/Immich_log_$safeTimestamp.log';
final logFile = await File(filePath).create(); final logFile = await File(filePath).create();
final io = logFile.openWrite(); final io = logFile.openWrite();
try { try {
// Write messages // Write messages
for (final m in await LogService.I.getMessages()) { for (final m in await _read(startupError)) {
final created = m.createdAt; final created = m.createdAt;
final level = m.level.name.padRight(8); final level = m.level.name.padRight(8);
final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20); final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
+2 -2
View File
@@ -26,7 +26,7 @@ class OAuthService {
); );
final authUrl = dto?.url; final authUrl = dto?.url;
log.info('Received Authorization URL: $authUrl'); log.info('Received Authorization URL');
return authUrl; return authUrl;
} }
@@ -34,7 +34,7 @@ class OAuthService {
Future<LoginResponseDto?> oAuthLogin(String oauthUrl, String state, String codeVerifier) async { Future<LoginResponseDto?> oAuthLogin(String oauthUrl, String state, String codeVerifier) async {
String result = await FlutterWebAuth2.authenticate(url: oauthUrl, callbackUrlScheme: callbackUrlScheme); String result = await FlutterWebAuth2.authenticate(url: oauthUrl, callbackUrlScheme: callbackUrlScheme);
log.info('Received OAuth callback: $result'); log.info('Received OAuth callback');
if (result.startsWith('app.immich:/oauth-callback')) { if (result.startsWith('app.immich:/oauth-callback')) {
result = result.replaceAll('app.immich:/oauth-callback', 'app.immich:///oauth-callback'); result = result.replaceAll('app.immich:/oauth-callback', 'app.immich:///oauth-callback');
+2
View File
@@ -3,6 +3,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/data/data_controller.dart'; import 'package:immich_mobile/data/data_controller.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
@@ -72,6 +73,7 @@ abstract final class Bootstrap {
await LogService.init( await LogService.init(
logRepository: LogRepository(dataController.logDb), logRepository: LogRepository(dataController.logDb),
settingsRepository: settingsRepo, settingsRepository: settingsRepo,
storeService: StoreService.I,
shouldBuffer: shouldBufferLogs, shouldBuffer: shouldBufferLogs,
); );
+6 -1
View File
@@ -37,6 +37,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'ffmpeg': { 'ffmpeg': {
'realtime': {'enabled': false}, 'realtime': {'enabled': false},
}, },
'passkey': {'enabled': false},
}, },
'ServerConfigDto': { 'ServerConfigDto': {
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json', 'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
@@ -55,12 +56,16 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'PeopleResponse': {'updateStrategy': 'everyone'}, 'PeopleResponse': {'updateStrategy': 'everyone'},
'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []}, 'PersonResponseDto': {'otherPeople': const [], 'sharedBy': const [], 'sharedWith': const []},
'ServerVersionResponseDto': {'featureLevel': 0}, 'ServerVersionResponseDto': {'featureLevel': 0},
'WorkflowResponseDto': {'logging': false},
'AdminConfigDto': {
'passkey': {'enabled': false, 'domain': null, 'additionalDomains': const []},
},
'UserConfigDto': { 'UserConfigDto': {
'notifications': { 'notifications': {
'smtp': {'enabled': false}, 'smtp': {'enabled': false},
}, },
'passkey': {'enabled': false},
}, },
'WorkflowResponseDto': {'logging': false},
}; };
// ignore: unused-code // ignore: unused-code
@@ -36,64 +36,63 @@ class _MesmerizingSliverAppBarState extends ConsumerState<MesmerizingSliverAppBa
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
Widget hideWhenSelecting(Widget child) => IgnorePointer(
ignoring: isMultiSelectEnabled,
child: AnimatedOpacity(duration: Durations.medium1, opacity: isMultiSelectEnabled ? 0 : 1, child: child),
);
return isMultiSelectEnabled return SliverAppBar(
? SliverToBoxAdapter( expandedHeight: 300.0,
child: switch (_scrollProgress) { floating: false,
< 0.8 => const SizedBox(height: 120), pinned: true,
_ => const SizedBox(height: 352), snap: false,
}, elevation: 0,
) leading: hideWhenSelecting(
: SliverAppBar( IconButton(
expandedHeight: 300.0, icon: Icon(
floating: false, Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
pinned: true, color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
snap: false, shadows: [
elevation: 0, _scrollProgress < 0.95
leading: IconButton( ? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
icon: Icon( : const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back, ],
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress), ),
shadows: [ onPressed: () {
_scrollProgress < 0.95 context.pop();
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5)) },
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent), ),
], ),
), flexibleSpace: Builder(
onPressed: () { builder: (context) {
context.pop(); final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
}, final scrollProgress = _calculateScrollProgress(settings);
),
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button // Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) { if (mounted && _scrollProgress != scrollProgress) {
setState(() { setState(() {
_scrollProgress = scrollProgress; _scrollProgress = scrollProgress;
}); });
} }
}); });
return FlexibleSpaceBar( return FlexibleSpaceBar(
centerTitle: true, centerTitle: true,
title: AnimatedSwitcher( title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95 child: scrollProgress > 0.95
? Text( ? Text(
widget.title, widget.title,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18), style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
) )
: null, : null,
),
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
);
},
), ),
background: _ExpandedBackground(scrollProgress: scrollProgress, title: widget.title),
); );
},
),
);
} }
} }
@@ -54,6 +54,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
Widget hideWhenSelecting(Widget child) => IgnorePointer(
ignoring: isMultiSelectEnabled,
child: AnimatedOpacity(duration: Durations.medium1, opacity: isMultiSelectEnabled ? 0 : 1, child: child),
);
final Color? actionIconColor = Color.lerp(Colors.white, context.primaryColor, _scrollProgress); final Color? actionIconColor = Color.lerp(Colors.white, context.primaryColor, _scrollProgress);
final List<Shadow> actionIconShadows = [ final List<Shadow> actionIconShadows = [
if (_scrollProgress < 0.95) if (_scrollProgress < 0.95)
@@ -62,74 +66,74 @@ class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent), const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
]; ];
return isMultiSelectEnabled return SliverAppBar(
? SliverToBoxAdapter( expandedHeight: 300.0,
child: switch (_scrollProgress) { floating: false,
< 0.8 => const SizedBox(height: 120), pinned: true,
_ => const SizedBox(height: 352), snap: false,
}, elevation: 0,
) leading: hideWhenSelecting(
: SliverAppBar( IconButton(
expandedHeight: 300.0, icon: Icon(
floating: false, Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
pinned: true, color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
snap: false, shadows: [
elevation: 0, _scrollProgress < 0.95
leading: IconButton( ? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
icon: Icon( : const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
shadows: [
_scrollProgress < 0.95
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
],
),
onPressed: () {
context.pop();
},
),
actions: [
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
], ],
flexibleSpace: Builder( ),
builder: (context) { onPressed: () {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>(); context.pop();
final scrollProgress = _calculateScrollProgress(settings); },
),
),
actions: [
hideWhenSelecting(
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
),
],
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button // Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) { if (mounted && _scrollProgress != scrollProgress) {
setState(() { setState(() {
_scrollProgress = scrollProgress; _scrollProgress = scrollProgress;
}); });
} }
}); });
return FlexibleSpaceBar( return FlexibleSpaceBar(
centerTitle: true, centerTitle: true,
title: AnimatedSwitcher( title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95 child: scrollProgress > 0.95
? Text( ? Text(
widget.person.name, widget.person.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18), style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
) )
: null, : null,
), ),
background: _ExpandedBackground( background: IgnorePointer(
scrollProgress: scrollProgress, ignoring: isMultiSelectEnabled,
person: widget.person, child: _ExpandedBackground(
onNameTap: widget.onNameTap, scrollProgress: scrollProgress,
onBirthdayTap: widget.onBirthdayTap, person: widget.person,
), onNameTap: widget.onNameTap,
); onBirthdayTap: widget.onBirthdayTap,
}, ),
), ),
); );
},
),
);
} }
} }
@@ -16,6 +16,7 @@ class AssetListSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator))); final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator)));
final smoothScrubbing = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.smoothScrubbing)));
final assetListSetting = [ final assetListSetting = [
SettingsSwitchListTile( SettingsSwitchListTile(
@@ -27,6 +28,16 @@ class AssetListSettings extends HookConsumerWidget {
ref.invalidate(settingsProvider); ref.invalidate(settingsProvider);
}, },
), ),
SettingsSwitchListTile(
valueNotifier: smoothScrubbing,
title: context.t.theme_setting_asset_list_smooth_scrubbing_title,
subtitle: context.t.theme_setting_asset_list_smooth_scrubbing_subtitle,
onChanged: (value) {
unawaited(ref.read(settingsProvider).write(.timelineSmoothScrubbing, value));
ref.invalidate(appSettingsServiceProvider);
ref.invalidate(settingsProvider);
},
),
const LayoutSettings(), const LayoutSettings(),
const GroupSettings(), const GroupSettings(),
]; ];
+6
View File
@@ -139,6 +139,7 @@ depends = ["//:open-api-dart"]
run = [ run = [
# This receives passed `--enforce-lockfile` args # This receives passed `--enforce-lockfile` args
"flutter pub get", "flutter pub get",
{ task = "install:build-runner-patch" },
{ task = "install:ios" }, { task = "install:ios" },
] ]
@@ -198,6 +199,11 @@ fi
cd ios && pod install cd ios && pod install
''' '''
[tasks."install:build-runner-patch"]
description = "Patch build_runner to prevent crashes when sources are deleted"
hide = true
run = "node ./scripts/build_runner_patch.mjs"
[tasks."analyze:dart"] [tasks."analyze:dart"]
description = "Run Dart analysis" description = "Run Dart analysis"
hide = true hide = true
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 3.3.3-local.6+3030404 version: 3.3.5-local.7+3030500
environment: environment:
sdk: '>=3.12.0 <4.0.0' sdk: '>=3.12.0 <4.0.0'
+20
View File
@@ -0,0 +1,20 @@
// Prevents build_runner crashing when a source file is deleted from a path dependency, which commonly occurs to `generated/openapi` on branch switch
//
// Fixed upstream in 2.15.3 (https://github.com/dart-lang/build/pull/5022), but we can't take that dependency due to Dart analyzer being locked
import { readFileSync, writeFileSync } from 'node:fs';
const packageConfig = new URL('../.dart_tool/package_config.json', import.meta.url);
const config = JSON.parse(readFileSync(packageConfig, 'utf8'));
const buildRunner = new URL(config.packages.find(({ name }) => name === 'build_runner').rootUri);
const file = new URL(`${buildRunner}/lib/src/build_plan/build_plan.dart`);
const buggy = 'for (final id in deletedOutputs) {';
const fixed = 'for (final id in deletedOutputs.difference(buildInputs.deletedSources.build().asSet())) {';
const source = readFileSync(file, 'utf8');
if (source.includes(buggy)) {
writeFileSync(file, source.replace(buggy, fixed));
console.log('build_runner patch: stopped deleting removed sources from path dependencies');
}
@@ -1,13 +1,23 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' show ClientException;
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/network_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart' show ApiException;
import '../../infrastructure/repository.mock.dart'; import '../../infrastructure/repository.mock.dart';
import '../../test_utils.dart'; import '../../test_utils.dart';
@@ -30,6 +40,16 @@ void main() {
late LogService sut; late LogService sut;
late LogRepository mockLogRepo; late LogRepository mockLogRepo;
late MockSettingsRepository mockSettingsRepository; late MockSettingsRepository mockSettingsRepository;
late Drift db;
setUpAll(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: StoreRepository(db), listenUpdates: false);
});
tearDownAll(() async {
await db.close();
});
setUp(() async { setUp(() async {
mockLogRepo = MockLogRepository(); mockLogRepo = MockLogRepository();
@@ -45,11 +65,16 @@ void main() {
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
sut = await LogService.create(logRepository: mockLogRepo, settingsRepository: mockSettingsRepository); sut = await LogService.create(
logRepository: mockLogRepo,
settingsRepository: mockSettingsRepository,
storeService: StoreService.I,
);
}); });
tearDown(() async { tearDown(() async {
await sut.dispose(); await sut.dispose();
await StoreService.I.clear();
}); });
group("Log Service Init:", () { group("Log Service Init:", () {
@@ -87,6 +112,7 @@ void main() {
sut = await LogService.create( sut = await LogService.create(
logRepository: mockLogRepo, logRepository: mockLogRepo,
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
storeService: StoreService.I,
shouldBuffer: true, shouldBuffer: true,
); );
@@ -105,6 +131,7 @@ void main() {
sut = await LogService.create( sut = await LogService.create(
logRepository: mockLogRepo, logRepository: mockLogRepo,
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
storeService: StoreService.I,
shouldBuffer: true, shouldBuffer: true,
); );
@@ -126,6 +153,7 @@ void main() {
sut = await LogService.create( sut = await LogService.create(
logRepository: mockLogRepo, logRepository: mockLogRepo,
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
storeService: StoreService.I,
shouldBuffer: false, shouldBuffer: false,
); );
@@ -160,6 +188,7 @@ void main() {
sut = await LogService.create( sut = await LogService.create(
logRepository: mockLogRepo, logRepository: mockLogRepo,
settingsRepository: mockSettingsRepository, settingsRepository: mockSettingsRepository,
storeService: StoreService.I,
shouldBuffer: true, shouldBuffer: true,
); );
@@ -176,4 +205,144 @@ void main() {
}); });
}); });
}); });
group("Log Service Redaction:", () {
const accessToken = 'Kq3Zr8WbN2xYp5aLm7cT9vQ4sD6fH1jE0gU2iO5wA8y';
test('Replaces urls, the access token and header values when a line is logged', () async {
await StoreService.I.put(StoreKey.serverUrl, 'https://immich.example.com');
await StoreService.I.put(StoreKey.serverEndpoint, 'https://api.example.com/api');
await StoreService.I.put(StoreKey.accessToken, accessToken);
when(() => mockSettingsRepository.appConfig).thenReturn(
const AppConfig(
network: NetworkConfig(
localEndpoint: 'http://192.168.1.5:2283',
externalEndpointList: ['https://immich.example.com', 'http://nas.lan:2283'],
customHeaders: {'X-Proxy-Key': 'cf-secret-abcdef0123', 'X-Debug': '1'},
),
),
);
const rows = {
'Resuming session at https://immich.example.com/api': 'Resuming session at [EXTERNAL ENDPOINT 1]/api',
'GET http://nas.lan:2283/api/server/ping': 'GET [EXTERNAL ENDPOINT 2]/api/server/ping',
'GET http://192.168.1.5:2283/api/server/ping': 'GET [LOCAL ENDPOINT]/api/server/ping',
'Using server URL: https://api.example.com/api': 'Using server URL: [SERVER URL]/api',
'Received Authorization URL: https://auth.example.org/authorize':
'Received Authorization URL: [OTHER URL]/authorize',
'Causing statement: INSERT INTO "store_entity", parameters: 11, $accessToken':
'Causing statement: INSERT INTO "store_entity", parameters: 11, [ACCESS TOKEN]',
'Could not enqueue task DownloadTask{headers: {X-Proxy-Key: cf-secret-abcdef0123, X-Debug: 1}, retries: 1}':
'Could not enqueue task DownloadTask{headers: {X-Proxy-Key: [HEADER VALUE], X-Debug: [HEADER VALUE]}, retries: 1}',
'X-Proxy-Key:\tcf-secret-abcdef0123': 'X-Proxy-Key: [HEADER VALUE]',
'Uploaded 1 assets in 1.2s': 'Uploaded 1 assets in 1.2s',
};
for (final row in rows.keys) {
Logger('Test').info(row);
}
await sut.flush();
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
expect(stored.map((m) => m.message), rows.values);
});
test('Hides the host and ip in http client errors', () async {
when(
() => mockSettingsRepository.appConfig,
).thenReturn(const AppConfig(network: NetworkConfig(externalEndpointList: ['http://immich:2283'])));
final connectError = ClientException(
'java.net.ConnectException: Failed to connect to immich/192.168.1.5:2283',
Uri.parse('http://immich:2283/api/server/ping'),
);
final unknownHostError = ClientException(
'java.net.UnknownHostException: Unable to resolve host "typo.example.org": No address associated with hostname',
Uri.parse('https://alice:secret@typo.example.org/api/users/me'),
);
Logger('Test').severe(
'Error while checking server availability',
ApiException.withInner(
400,
'HTTP connection failed: GET /server/ping',
connectError,
StackTrace.fromString('#0 ApiService._isEndpointAvailable (package:immich_mobile/services/api.service.dart)'),
),
);
Logger('Test').severe('Error validating auxiliary endpoint', unknownHostError);
await sut.flush();
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
expect(
stored.first.error,
'ApiException 400: HTTP connection failed: GET /server/ping (Inner exception: ClientException: '
'java.net.ConnectException: Failed to connect to [EXTERNAL ENDPOINT 1]/[IP ADDRESS]:2283, '
'uri=[EXTERNAL ENDPOINT 1]/api/server/ping)\n\n'
'#0 ApiService._isEndpointAvailable (package:immich_mobile/services/api.service.dart)',
);
expect(
stored.last.error,
'ClientException: java.net.UnknownHostException: Unable to resolve host "[OTHER URL]": '
'No address associated with hostname, uri=[OTHER URL]/api/users/me',
);
});
test('Hides ipv6 addresses in http client errors', () async {
const rows = {
'java.net.ConnectException: Failed to connect to immich.example.com/[2001:db8::5]:443':
'java.net.ConnectException: Failed to connect to [OTHER URL]/[IP ADDRESS]:443',
'java.net.ConnectException: Failed to connect to immich.example.com/2001:db8::5:443':
'java.net.ConnectException: Failed to connect to [OTHER URL]/[IP ADDRESS]',
'java.net.SocketTimeoutException: failed to connect to immich.example.com/2001:db8::5 (port 443) '
'from /2001:db8:1234:5678:9abc:def0:1234:5678 (port 41234) after 30000ms':
'java.net.SocketTimeoutException: failed to connect to [OTHER URL]/[IP ADDRESS] (port 443) '
'from /[IP ADDRESS] (port 41234) after 30000ms',
'java.net.ConnectException: Failed to connect to /[fe80::5%wlan0]:2283':
'java.net.ConnectException: Failed to connect to /[IP ADDRESS]:2283',
'at 12:34:56': 'at 12:34:56',
'0:00:01.500000': '0:00:01.500000',
'deadbeef': 'deadbeef',
'#0 ApiClient.invokeAPI (package:openapi/api_client.dart:131:7)':
'#0 ApiClient.invokeAPI (package:openapi/api_client.dart:131:7)',
};
for (final row in rows.keys) {
Logger('Test').severe('Upload error', ClientException(row, Uri.parse('https://immich.example.com/api/assets')));
}
await sut.flush();
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
expect(stored.map((m) => m.error), [
for (final row in rows.values) 'ClientException: $row, uri=[OTHER URL]/api/assets',
]);
});
test('Keeps the labels of a line logged before a logout', () async {
final settings = await SettingsRepository.ensureInitialized(db);
addTearDown(SettingsRepository.reset);
await settings.write(SettingsKey.networkExternalEndpointList, ['https://immich.example.com']);
await StoreService.I.put(StoreKey.accessToken, accessToken);
await sut.dispose();
sut = await LogService.create(
logRepository: mockLogRepo,
settingsRepository: settings,
storeService: StoreService.I,
);
Logger('Test').info('Resuming session at https://immich.example.com/api with $accessToken');
// the store and settings changes of AuthService.clearLocalData
await StoreService.I.delete(StoreKey.accessToken);
await settings.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
.networkLocalEndpoint,
.networkExternalEndpointList,
]);
Logger('Test').info('Resuming session at https://immich.example.com/api');
await sut.flush();
final stored = verify(() => mockLogRepo.insertAll(captureAny())).captured.single as List<LogMessage>;
expect(stored.map((m) => m.message), [
'Resuming session at [EXTERNAL ENDPOINT 1]/api with [ACCESS TOKEN]',
'Resuming session at [OTHER URL]/api',
]);
});
});
} }
@@ -118,6 +118,7 @@ void main() {
logService = await LogService.init( logService = await LogService.init(
logRepository: logRepository, logRepository: logRepository,
settingsRepository: settingsRepository, settingsRepository: settingsRepository,
storeService: MockStoreService(),
shouldBuffer: false, shouldBuffer: false,
); );
}); });
@@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
class _MockHttpClient extends Mock implements http.Client {} class _MockHttpClient extends Mock implements http.Client {}
@@ -124,4 +125,21 @@ void main() {
expect(result.errorMessage, 'boom'); expect(result.errorMessage, 'boom');
verify(() => client.send(any())).called(1); verify(() => client.send(any())).called(1);
}); });
test('a transport failure is logged as the error, not in the message', () async {
final error = http.ClientException('Failed to connect to /203.0.113.9:2283');
stubSend((_) => throw error);
final level = Logger.root.level;
addTearDown(() => Logger.root.level = level);
Logger.root.level = Level.ALL;
final records = <LogRecord>[];
final subscription = sut.logger.onRecord.listen(records.add);
final result = await upload();
await subscription.cancel();
expect(records.map((r) => r.error), [error, error]);
expect(records.map((r) => r.message), everyElement(isNot(contains('203.0.113.9'))));
expect(result.error, error);
});
} }
@@ -0,0 +1,58 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/data/db/logger/database.dart';
import 'package:immich_mobile/data/db/main/database.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
void main() {
testWidgets('Shares the startup error and the stored logs before the log service starts', (tester) async {
final dir = Directory.systemTemp.createTempSync('immich-logs');
addTearDown(() => dir.delete(recursive: true));
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(_) async => dir.path,
);
late String shared;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/share'),
(call) async {
shared = File(((call.arguments as Map)['paths'] as List).single as String).readAsStringSync();
return 'success';
},
);
await tester.pumpWidget(const SizedBox());
await tester.runAsync(() async {
final connection = await openSqliteConnection(name: 'immich_logs');
final logs = DriftLogger.sqlite(connection);
await LogRepository(logs).insertAll([
LogMessage(
message: 'Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api',
level: LogLevel.warning,
createdAt: DateTime(2026, 10, 15),
),
]);
await logs.close();
await connection.close();
await ImmichLogger.shareLogs(
tester.element(find.byType(SizedBox)),
startupError: 'SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com',
);
});
expect(
shared.split('\n').first,
endsWith('| SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com |'),
);
expect(
shared,
contains('| Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api |'),
);
});
}
+603 -6
View File
@@ -9708,6 +9708,415 @@
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
"/passkeys": {
"get": {
"description": "Search the passkeys of the current user.",
"operationId": "searchPasskeys",
"parameters": [
{
"name": "id",
"required": false,
"in": "query",
"description": "Filter by passkey ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PasskeyResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Search passkeys",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.read",
"x-immich-state": "Alpha"
}
},
"/passkeys/authentication/finish": {
"post": {
"description": "Verify the response from the authenticator and receive a session token.",
"operationId": "finishAuthentication",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAuthenticationFinishDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponseDto"
}
}
},
"description": ""
}
},
"summary": "Finish passkey authentication",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/authentication/start": {
"post": {
"description": "Generate the options used by the browser to sign in with a passkey.",
"operationId": "startAuthentication",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAuthenticationOptionsDto"
}
}
},
"description": ""
}
},
"summary": "Start passkey authentication",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/registration/finish": {
"post": {
"description": "Verify the response from the authenticator and save the new passkey for the current user.",
"operationId": "finishRegistration",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyRegistrationFinishDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Finish passkey registration",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/registration/start": {
"post": {
"description": "Generate the options used by the browser to create a new passkey for the current user.",
"operationId": "startRegistration",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyRegistrationOptionsDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Start passkey registration",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/passkeys/{id}": {
"delete": {
"description": "Delete a passkey by its ID. The current user must own this passkey.",
"operationId": "deletePasskey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete a passkey",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.delete",
"x-immich-state": "Alpha"
},
"get": {
"description": "Retrieve a passkey by its ID. The current user must own this passkey.",
"operationId": "getPasskey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve a passkey",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.read",
"x-immich-state": "Alpha"
},
"patch": {
"description": "Update the name of a passkey by its ID. The current user must own this passkey.",
"operationId": "updatePasskey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update a passkey",
"tags": [
"Passkeys"
],
"x-immich-history": [
{
"version": "v4.0.0",
"state": "Added"
},
{
"version": "v4.0.0",
"state": "Alpha"
}
],
"x-immich-permission": "passkey.update",
"x-immich-state": "Alpha"
}
},
"/people": { "/people": {
"delete": { "delete": {
"description": "Bulk delete a list of people at once.", "description": "Bulk delete a list of people at once.",
@@ -18211,7 +18620,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "3.3.3", "version": "3.3.5",
"contact": {} "contact": {}
}, },
"tags": [ "tags": [
@@ -18315,6 +18724,10 @@
"name": "Partners", "name": "Partners",
"description": "A partner is a link with another user that allows sharing of assets between two users." "description": "A partner is a link with another user that allows sharing of assets between two users."
}, },
{
"name": "Passkeys",
"description": "A passkey is a WebAuthn credential that can be used to login without a password."
},
{ {
"name": "People", "name": "People",
"description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job." "description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job."
@@ -18619,6 +19032,9 @@
"oauth": { "oauth": {
"$ref": "#/components/schemas/AdminConfigOAuthDto" "$ref": "#/components/schemas/AdminConfigOAuthDto"
}, },
"passkey": {
"$ref": "#/components/schemas/AdminConfigPasskeyDto"
},
"passwordLogin": { "passwordLogin": {
"$ref": "#/components/schemas/AdminConfigPasswordLoginDto" "$ref": "#/components/schemas/AdminConfigPasswordLoginDto"
}, },
@@ -18659,6 +19075,7 @@
"nightlyTasks", "nightlyTasks",
"notifications", "notifications",
"oauth", "oauth",
"passkey",
"passwordLogin", "passwordLogin",
"reverseGeocoding", "reverseGeocoding",
"server", "server",
@@ -19521,6 +19938,34 @@
], ],
"type": "object" "type": "object"
}, },
"AdminConfigPasskeyDto": {
"properties": {
"additionalDomains": {
"description": "Additional domains",
"items": {
"format": "uri",
"type": "string"
},
"type": "array"
},
"domain": {
"description": "Passkey domain",
"format": "uri",
"nullable": true,
"type": "string"
},
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"additionalDomains",
"domain",
"enabled"
],
"type": "object"
},
"AdminConfigPasswordLoginDto": { "AdminConfigPasswordLoginDto": {
"properties": { "properties": {
"enabled": { "enabled": {
@@ -22790,6 +23235,7 @@
"AssetGenerateThumbnailsQueueAll", "AssetGenerateThumbnailsQueueAll",
"AssetGenerateThumbnails", "AssetGenerateThumbnails",
"AuditTableCleanup", "AuditTableCleanup",
"AuthChallengeCleanup",
"DatabaseBackup", "DatabaseBackup",
"FacialRecognitionQueueAll", "FacialRecognitionQueueAll",
"FacialRecognition", "FacialRecognition",
@@ -24918,6 +25364,127 @@
], ],
"type": "object" "type": "object"
}, },
"PasskeyAuthenticationFinishDto": {
"properties": {
"response": {
"additionalProperties": {},
"description": "Response returned by @simplewebauthn/browser's `startAuthentication()`",
"properties": {
"id": {
"description": "Credential ID",
"format": "base64url",
"pattern": "^[A-Za-z0-9_-]*$",
"type": "string"
},
"response": {
"additionalProperties": {},
"properties": {
"userHandle": {
"description": "User handle",
"format": "base64url",
"pattern": "^[A-Za-z0-9_-]*$",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"id",
"response"
],
"type": "object"
}
},
"required": [
"response"
],
"type": "object"
},
"PasskeyAuthenticationOptionsDto": {
"additionalProperties": {},
"description": "Response returned by @simplewebauthn/server's `generateAuthenticationOptions`",
"type": "object"
},
"PasskeyRegistrationFinishDto": {
"properties": {
"name": {
"description": "Passkey name",
"type": "string"
},
"response": {
"description": "Response returned by @simplewebauthn/browser's `startAuthentication()`"
}
},
"required": [
"response"
],
"type": "object"
},
"PasskeyRegistrationOptionsDto": {
"additionalProperties": {},
"description": "Response returned by @simplewebauthn/server's `generateRegistrationOptions`",
"type": "object"
},
"PasskeyResponseDto": {
"properties": {
"createdAt": {
"description": "Creation date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"id": {
"description": "Passkey ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
"description": "Passkey name",
"nullable": true,
"type": "string"
},
"updatedAt": {
"description": "Last update date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"usedAt": {
"description": "Last used date",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
}
},
"required": [
"createdAt",
"id",
"name",
"updatedAt",
"usedAt"
],
"type": "object"
},
"PasskeyUpdateDto": {
"properties": {
"name": {
"description": "Passkey name",
"minLength": 1,
"nullable": true,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"PeopleDeleteDto": { "PeopleDeleteDto": {
"properties": { "properties": {
"ids": { "ids": {
@@ -25280,6 +25847,9 @@
"partner.read", "partner.read",
"partner.update", "partner.update",
"partner.delete", "partner.delete",
"passkey.read",
"passkey.update",
"passkey.delete",
"person.create", "person.create",
"person.read", "person.read",
"person.update", "person.update",
@@ -25963,6 +26533,9 @@
"oauth": { "oauth": {
"$ref": "#/components/schemas/PublicConfigOAuthDto" "$ref": "#/components/schemas/PublicConfigOAuthDto"
}, },
"passkey": {
"$ref": "#/components/schemas/PublicConfigPasskeyDto"
},
"passwordLogin": { "passwordLogin": {
"$ref": "#/components/schemas/PublicConfigPasswordLoginDto" "$ref": "#/components/schemas/PublicConfigPasswordLoginDto"
}, },
@@ -25974,6 +26547,7 @@
"ffmpeg", "ffmpeg",
"map", "map",
"oauth", "oauth",
"passkey",
"passwordLogin", "passwordLogin",
"server" "server"
], ],
@@ -26048,6 +26622,18 @@
], ],
"type": "object" "type": "object"
}, },
"PublicConfigPasskeyDto": {
"properties": {
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"PublicConfigPasswordLoginDto": { "PublicConfigPasswordLoginDto": {
"properties": { "properties": {
"enabled": { "enabled": {
@@ -33173,6 +33759,9 @@
"oauth": { "oauth": {
"$ref": "#/components/schemas/UserConfigOAuthDto" "$ref": "#/components/schemas/UserConfigOAuthDto"
}, },
"passkey": {
"$ref": "#/components/schemas/UserConfigPasskeyDto"
},
"passwordLogin": { "passwordLogin": {
"$ref": "#/components/schemas/UserConfigPasswordLoginDto" "$ref": "#/components/schemas/UserConfigPasswordLoginDto"
}, },
@@ -33196,6 +33785,7 @@
"map", "map",
"notifications", "notifications",
"oauth", "oauth",
"passkey",
"passwordLogin", "passwordLogin",
"reverseGeocoding", "reverseGeocoding",
"server", "server",
@@ -33416,6 +34006,18 @@
], ],
"type": "object" "type": "object"
}, },
"UserConfigPasskeyDto": {
"properties": {
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"UserConfigPasswordLoginDto": { "UserConfigPasswordLoginDto": {
"properties": { "properties": {
"enabled": { "enabled": {
@@ -33710,11 +34312,6 @@
"name": { "name": {
"description": "User name", "description": "User name",
"type": "string" "type": "string"
},
"password": {
"deprecated": true,
"description": "User password (deprecated, use change password endpoint)",
"type": "string"
} }
}, },
"type": "object" "type": "object"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-monorepo", "name": "immich-monorepo",
"version": "3.3.3", "version": "3.3.5",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -8,7 +8,7 @@
"format": "prettier --cache --check i18n/", "format": "prettier --cache --check i18n/",
"format:fix": "prettier --cache --write --list-different i18n" "format:fix": "prettier --cache --write --list-different i18n"
}, },
"packageManager": "pnpm@11.27.1", "packageManager": "pnpm@12.14.0",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
}, },
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.21.0-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe AS core FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e AS core
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./ COPY package* pnpm* .pnpmfile.cjs ./
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "3.3.3", "version": "3.3.5",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -62,7 +62,7 @@
"node": ">=22.0.0" "node": ">=22.0.0"
}, },
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^5.0.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fastq": "^1.17.1", "fastq": "^1.17.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
+33
View File
@@ -200,6 +200,39 @@ describe('checkForDuplicates', () => {
}); });
}); });
it('ignores accepted files that were not part of the request', async () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{ action: AssetUploadAction.Accept, id: testFilePath },
{ action: AssetUploadAction.Accept, id: '/etc/passwd' },
],
});
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
duplicates: [],
newFiles: [testFilePath],
});
});
it('ignores rejected files that were not part of the request', async () => {
vi.mocked(checkBulkUpload).mockResolvedValue({
results: [
{ action: AssetUploadAction.Accept, id: testFilePath },
{
action: AssetUploadAction.Reject,
id: '/etc/passwd',
assetId: 'fc5621b1-86f6-44a1-9905-403e607df9f5',
reason: AssetRejectReason.Duplicate,
},
],
});
await expect(checkForDuplicates([testFilePath], { concurrency: 1 })).resolves.toEqual({
duplicates: [],
newFiles: [testFilePath],
});
});
it('returns results when check duplicates retry is successful', async () => { it('returns results when check duplicates retry is successful', async () => {
let mocked = vi.mocked(checkBulkUpload); let mocked = vi.mocked(checkBulkUpload);
for (let i = 1; i < retry; i++) { for (let i = 1; i < retry; i++) {
+6
View File
@@ -232,8 +232,14 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } }); const response = await checkBulkUpload({ assetBulkUploadCheckDto: { assets } });
const results = response.results as AssetBulkUploadCheckResults; const results = response.results as AssetBulkUploadCheckResults;
const requested = new Set(assets.map(({ id }) => id));
for (const { id: filepath, assetId, action } of results) { for (const { id: filepath, assetId, action } of results) {
if (!requested.has(filepath)) {
console.warn(`Ignoring unexpected file in bulk upload check response: ${filepath}`);
continue;
}
if (action === AssetUploadAction.Accept) { if (action === AssetUploadAction.Accept) {
newFiles.push(filepath); newFiles.push(filepath);
} else { } else {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.21.0-trixie-slim@sha256:8ec5d7557396cfe32d21c3f9c13072355ceab22b584578ca4bb28af31120cffe FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN corepack enable RUN corepack enable
+1 -1
View File
@@ -13,5 +13,5 @@
"oidc-provider": "^9.0.0", "oidc-provider": "^9.0.0",
"tsx": "^4.20.6" "tsx": "^4.20.6"
}, },
"packageManager": "pnpm@11.27.1" "packageManager": "pnpm@12.14.0"
} }
+1 -1
View File
@@ -27,7 +27,7 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"packageManager": "pnpm@11.27.1", "packageManager": "pnpm@12.14.0",
"devDependencies": { "devDependencies": {
"@extism/js-pdk": "^1.1.1", "@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
+1 -1
View File
@@ -33,5 +33,5 @@
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^5.0.0" "vitest": "^5.0.0"
}, },
"packageManager": "pnpm@11.27.1" "packageManager": "pnpm@12.14.0"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "3.3.3", "version": "3.3.5",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"repository": { "repository": {
"type": "git", "type": "git",
+173 -2
View File
@@ -357,6 +357,14 @@ export type AdminConfigOAuthDto = {
timeout: number; timeout: number;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
}; };
export type AdminConfigPasskeyDto = {
/** Additional domains */
additionalDomains: string[];
/** Passkey domain */
domain: string | null;
/** Enabled */
enabled: boolean;
};
export type AdminConfigPasswordLoginDto = { export type AdminConfigPasswordLoginDto = {
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
@@ -421,6 +429,7 @@ export type AdminConfigDto = {
nightlyTasks: AdminConfigNightlyTasksDto; nightlyTasks: AdminConfigNightlyTasksDto;
notifications: AdminConfigNotificationsDto; notifications: AdminConfigNotificationsDto;
oauth: AdminConfigOAuthDto; oauth: AdminConfigOAuthDto;
passkey: AdminConfigPasskeyDto;
passwordLogin: AdminConfigPasswordLoginDto; passwordLogin: AdminConfigPasswordLoginDto;
reverseGeocoding: AdminConfigReverseGeocodingDto; reverseGeocoding: AdminConfigReverseGeocodingDto;
server: AdminConfigServerDto; server: AdminConfigServerDto;
@@ -1614,6 +1623,10 @@ export type UserConfigOAuthDto = {
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
}; };
export type UserConfigPasskeyDto = {
/** Enabled */
enabled: boolean;
};
export type UserConfigPasswordLoginDto = { export type UserConfigPasswordLoginDto = {
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
@@ -1647,6 +1660,7 @@ export type UserConfigDto = {
map: UserConfigMapDto; map: UserConfigMapDto;
notifications: UserConfigNotificationsDto; notifications: UserConfigNotificationsDto;
oauth: UserConfigOAuthDto; oauth: UserConfigOAuthDto;
passkey: UserConfigPasskeyDto;
passwordLogin: UserConfigPasswordLoginDto; passwordLogin: UserConfigPasswordLoginDto;
reverseGeocoding: UserConfigReverseGeocodingDto; reverseGeocoding: UserConfigReverseGeocodingDto;
server: UserConfigServerDto; server: UserConfigServerDto;
@@ -1997,6 +2011,47 @@ export type PartnerUpdateDto = {
/** Show partner assets in timeline */ /** Show partner assets in timeline */
inTimeline: boolean; inTimeline: boolean;
}; };
export type PasskeyResponseDto = {
/** Creation date */
createdAt: string;
/** Passkey ID */
id: string;
/** Passkey name */
name: string | null;
/** Last update date */
updatedAt: string;
/** Last used date */
usedAt: string | null;
};
export type PasskeyAuthenticationFinishDto = {
/** Response returned by @simplewebauthn/browser's `startAuthentication()` */
response: {
/** Credential ID */
id: string;
response: {
/** User handle */
userHandle?: string;
[key: string]: any;
};
[key: string]: any;
};
};
export type PasskeyAuthenticationOptionsDto = {
[key: string]: any;
};
export type PasskeyRegistrationFinishDto = {
/** Passkey name */
name?: string;
/** Response returned by @simplewebauthn/browser's `startAuthentication()` */
response: any;
};
export type PasskeyRegistrationOptionsDto = {
[key: string]: any;
};
export type PasskeyUpdateDto = {
/** Passkey name */
name: string | null;
};
export type PeopleDeleteDto = { export type PeopleDeleteDto = {
/** IDs to process */ /** IDs to process */
ids: string[]; ids: string[];
@@ -2198,6 +2253,10 @@ export type PublicConfigOAuthDto = {
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
}; };
export type PublicConfigPasskeyDto = {
/** Enabled */
enabled: boolean;
};
export type PublicConfigPasswordLoginDto = { export type PublicConfigPasswordLoginDto = {
/** Enabled */ /** Enabled */
enabled: boolean; enabled: boolean;
@@ -2210,6 +2269,7 @@ export type PublicConfigDto = {
ffmpeg: PublicConfigFFmpegDto; ffmpeg: PublicConfigFFmpegDto;
map: PublicConfigMapDto; map: PublicConfigMapDto;
oauth: PublicConfigOAuthDto; oauth: PublicConfigOAuthDto;
passkey: PublicConfigPasskeyDto;
passwordLogin: PublicConfigPasswordLoginDto; passwordLogin: PublicConfigPasswordLoginDto;
server: PublicConfigServerDto; server: PublicConfigServerDto;
}; };
@@ -3206,8 +3266,6 @@ export type UserUpdateMeDto = {
email?: string; email?: string;
/** User name */ /** User name */
name?: string; name?: string;
/** User password (deprecated, use change password endpoint) */
password?: string;
}; };
export type OnboardingResponseDto = { export type OnboardingResponseDto = {
/** Is user onboarded */ /** Is user onboarded */
@@ -6126,6 +6184,115 @@ export function updatePartner({ id, partnerUpdateDto }: {
body: partnerUpdateDto body: partnerUpdateDto
}))); })));
} }
/**
* Search passkeys
*/
export function searchPasskeys({ id }: {
id?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyResponseDto[];
}>(`/passkeys${QS.query(QS.explode({
id
}))}`, {
...opts
}));
}
/**
* Finish passkey authentication
*/
export function finishAuthentication({ passkeyAuthenticationFinishDto }: {
passkeyAuthenticationFinishDto: PasskeyAuthenticationFinishDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: LoginResponseDto;
}>("/passkeys/authentication/finish", oazapfts.json({
...opts,
method: "POST",
body: passkeyAuthenticationFinishDto
})));
}
/**
* Start passkey authentication
*/
export function startAuthentication(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyAuthenticationOptionsDto;
}>("/passkeys/authentication/start", {
...opts,
method: "POST"
}));
}
/**
* Finish passkey registration
*/
export function finishRegistration({ passkeyRegistrationFinishDto }: {
passkeyRegistrationFinishDto: PasskeyRegistrationFinishDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: PasskeyResponseDto;
}>("/passkeys/registration/finish", oazapfts.json({
...opts,
method: "POST",
body: passkeyRegistrationFinishDto
})));
}
/**
* Start passkey registration
*/
export function startRegistration(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyRegistrationOptionsDto;
}>("/passkeys/registration/start", {
...opts,
method: "POST"
}));
}
/**
* Delete a passkey
*/
export function deletePasskey({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/passkeys/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Retrieve a passkey
*/
export function getPasskey({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyResponseDto;
}>(`/passkeys/${encodeURIComponent(id)}`, {
...opts
}));
}
/**
* Update a passkey
*/
export function updatePasskey({ id, passkeyUpdateDto }: {
id: string;
passkeyUpdateDto: PasskeyUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PasskeyResponseDto;
}>(`/passkeys/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PATCH",
body: passkeyUpdateDto
})));
}
/** /**
* Delete people * Delete people
*/ */
@@ -8221,6 +8388,9 @@ export enum Permission {
PartnerRead = "partner.read", PartnerRead = "partner.read",
PartnerUpdate = "partner.update", PartnerUpdate = "partner.update",
PartnerDelete = "partner.delete", PartnerDelete = "partner.delete",
PasskeyRead = "passkey.read",
PasskeyUpdate = "passkey.update",
PasskeyDelete = "passkey.delete",
PersonCreate = "person.create", PersonCreate = "person.create",
PersonRead = "person.read", PersonRead = "person.read",
PersonUpdate = "person.update", PersonUpdate = "person.update",
@@ -8455,6 +8625,7 @@ export enum JobName {
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll", AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
AssetGenerateThumbnails = "AssetGenerateThumbnails", AssetGenerateThumbnails = "AssetGenerateThumbnails",
AuditTableCleanup = "AuditTableCleanup", AuditTableCleanup = "AuditTableCleanup",
AuthChallengeCleanup = "AuthChallengeCleanup",
DatabaseBackup = "DatabaseBackup", DatabaseBackup = "DatabaseBackup",
FacialRecognitionQueueAll = "FacialRecognitionQueueAll", FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
FacialRecognition = "FacialRecognition", FacialRecognition = "FacialRecognition",
+876 -430
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -73,5 +73,5 @@ shamefullyHoist: false
verifyDepsBeforeRun: install verifyDepsBeforeRun: install
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- '@immich/ui' - '@immich/ui'
- '@immich/sql-tools@0.6.3' - '@immich/sql-tools@0.6.3 || 0.6.6'
- '@nestjs/cli@12.0.1' - '@nestjs/cli@12.0.1'
+3 -3
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202610131110@sha256:082dc5e2611953d63d7b30dc8427da37ff5cd1d932aa9e99ccc4a26298b5f7a5 AS builder FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
COREPACK_HOME=/tmp \ COREPACK_HOME=/tmp \
@@ -68,7 +68,7 @@ FROM plugin-sdk AS plugins
ARG TARGETPLATFORM ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.9.12@sha256:868b004119abb2e7c146618da63371ec65da40bfb0f38a0ec4128ca9e96c057a /usr/local/bin/mise /usr/local/bin/mise COPY --from=ghcr.io/jdx/mise:2026.10.15@sha256:0593528c637b0dae8a0acca66b5d49266cf262a811f06530d7273d3cc002f3a0 /usr/local/bin/mise /usr/local/bin/mise
COPY ./mise.toml ./mise.toml COPY ./mise.toml ./mise.toml
COPY ./mise.lock ./mise.lock COPY ./mise.lock ./mise.lock
@@ -86,7 +86,7 @@ RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202610131110@sha256:9d4ce4bfd72d77edddd3072fbca78b7f4307a32c8cbe75276349944dd6405be3 FROM ghcr.io/immich-app/base-server-prod:202610211607@sha256:e50a1b36e2638dce42981ddfa84c134246c48fb2ad290fab6acfc1bb9b2bd891
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
+2 -2
View File
@@ -1,8 +1,8 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:202610131110@sha256:082dc5e2611953d63d7b30dc8427da37ff5cd1d932aa9e99ccc4a26298b5f7a5 AS dev FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS dev
COPY --from=ghcr.io/jdx/mise:2026.9.12@sha256:868b004119abb2e7c146618da63371ec65da40bfb0f38a0ec4128ca9e96c057a /usr/local/bin/mise /usr/local/bin/mise COPY --from=ghcr.io/jdx/mise:2026.10.15@sha256:0593528c637b0dae8a0acca66b5d49266cf262a811f06530d7273d3cc002f3a0 /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
+19 -17
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "3.3.3", "version": "3.3.5",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -41,7 +41,7 @@
"dependencies": { "dependencies": {
"@extism/extism": "2.0.0-rc13", "@extism/extism": "2.0.0-rc13",
"@immich/plugin-sdk": "workspace:*", "@immich/plugin-sdk": "workspace:*",
"@immich/sql-tools": "^0.6.3", "@immich/sql-tools": "^0.6.6",
"@nestjs/bullmq": "^12.0.0", "@nestjs/bullmq": "^12.0.0",
"@nestjs/common": "^12.0.0", "@nestjs/common": "^12.0.0",
"@nestjs/core": "^12.0.0", "@nestjs/core": "^12.0.0",
@@ -52,27 +52,29 @@
"@nestjs/websockets": "^12.0.0", "@nestjs/websockets": "^12.0.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.222.0", "@opentelemetry/exporter-prometheus": "^0.223.0",
"@opentelemetry/instrumentation-http": "^0.222.0", "@opentelemetry/instrumentation-http": "^0.223.0",
"@opentelemetry/instrumentation-ioredis": "^0.70.0", "@opentelemetry/instrumentation-ioredis": "^0.71.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.68.0", "@opentelemetry/instrumentation-nestjs-core": "^0.70.0",
"@opentelemetry/instrumentation-pg": "^0.74.0", "@opentelemetry/instrumentation-pg": "^0.76.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/sdk-node": "^0.223.0",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@simplewebauthn/server": "14.0.3",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0", "aaguids": "^0.0.71",
"archiver": "^8.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"bullmq": "^6.0.0", "bullmq": "^6.0.0",
"chokidar": "^4.0.3", "chokidar": "^5.0.0",
"compression": "^1.8.0", "compression": "^1.8.0",
"cookie": "^1.0.2", "cookie": "^2.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cron": "4.4.0", "cron": "4.4.0",
"exiftool-vendored": "^35.20.0", "exiftool-vendored": "^39.0.0",
"express": "^5.1.0", "express": "^5.1.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
@@ -80,12 +82,12 @@
"geo-tz": "^8.0.0", "geo-tz": "^8.0.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.8.2", "ioredis": "^6.0.0",
"jose": "^6.0.0", "jose": "^6.0.0",
"js-yaml": "^5.0.0", "js-yaml": "^5.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.29.5", "kysely": "^0.29.5",
"kysely-postgres-js": "^3.0.0", "kysely-postgres-js": "^5.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"mnemonist": "^0.40.3", "mnemonist": "^0.40.3",
@@ -95,7 +97,7 @@
"nestjs-kysely": "3.1.4", "nestjs-kysely": "3.1.4",
"nestjs-otel": "^8.0.0", "nestjs-otel": "^8.0.0",
"nestjs-zod": "^5.5.0", "nestjs-zod": "^5.5.0",
"nodemailer": "^9.0.0", "nodemailer": "^10.0.0",
"openid-client": "^6.3.3", "openid-client": "^6.3.3",
"pg": "^8.11.3", "pg": "^8.11.3",
"picomatch": "^4.0.2", "picomatch": "^4.0.2",
@@ -124,7 +126,7 @@
"@nestjs/schematics": "^12.0.0", "@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.0", "@nestjs/testing": "^12.0.0",
"@swc/core": "^1.4.14", "@swc/core": "^1.4.14",
"@types/archiver": "^7.0.0", "@types/archiver": "^8.0.0",
"@types/async-lock": "^1.4.2", "@types/async-lock": "^1.4.2",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/body-parser": "^1.19.6", "@types/body-parser": "^1.19.6",
@@ -165,7 +167,7 @@
"testcontainers": "^12.0.0", "testcontainers": "^12.0.0",
"typescript": "npm:@typescript/typescript6@^6.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
"unplugin-swc": "^1.4.5", "unplugin-swc": "^2.0.0",
"vitest": "^5.0.0" "vitest": "^5.0.0"
}, },
"overrides": { "overrides": {
+2 -1
View File
@@ -60,7 +60,7 @@ export const reverseGeocodeMaxDistance = 25_000;
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
export const LOGIN_URL = '/auth/login?autoLaunch=0'; export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; export const excludePaths = ['/.well-known/immich', '/.well-known/webauthn', '/custom.css', '/favicon.ico'];
export const FACE_THUMBNAIL_SIZE = 250; export const FACE_THUMBNAIL_SIZE = 250;
@@ -175,6 +175,7 @@ export const endpointTags: Record<ApiTag, string> = {
'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.', 'A notification is a specialized message sent to users to inform them of important events. Currently, these notifications are only shown in the Immich web application.',
[ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.', [ApiTag.NotificationsAdmin]: 'Notification administrative endpoints.',
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.', [ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
[ApiTag.Passkeys]: 'A passkey is a WebAuthn credential that can be used to login without a password.',
[ApiTag.People]: [ApiTag.People]:
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.', 'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
[ApiTag.Plugins]: [ApiTag.Plugins]:
@@ -1,20 +1,24 @@
import request from 'supertest'; import request from 'supertest';
import { AppController } from 'src/controllers/app.controller.js'; import { AppController } from 'src/controllers/app.controller.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { SystemConfigService } from 'src/services/system-config.service.js'; import { SystemConfigService } from 'src/services/system-config.service.js';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
describe(AppController.name, () => { describe(AppController.name, () => {
let ctx: ControllerContext; let ctx: ControllerContext;
const passkeyService = mockBaseService(PasskeyService);
beforeAll(async () => { beforeAll(async () => {
ctx = await controllerSetup(AppController, [ ctx = await controllerSetup(AppController, [
{ provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) }, { provide: SystemConfigService, useValue: mockBaseService(SystemConfigService) },
{ provide: PasskeyService, useValue: passkeyService },
]); ]);
return () => ctx.close(); return () => ctx.close();
}); });
beforeEach(() => { beforeEach(() => {
ctx.reset(); ctx.reset();
passkeyService.resetAllMocks();
}); });
describe('GET /.well-known/immich', () => { describe('GET /.well-known/immich', () => {
@@ -34,6 +38,13 @@ describe(AppController.name, () => {
}); });
}); });
describe('GET /.well-known/webauthn', () => {
it('should not be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/.well-known/webauthn');
expect(ctx.authenticate).not.toHaveBeenCalled();
});
});
describe('GET /custom.css', () => { describe('GET /custom.css', () => {
it('should not be an authenticated route', async () => { it('should not be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/custom.css'); await request(ctx.getHttpServer()).post('/custom.css');
+12 -1
View File
@@ -1,11 +1,15 @@
import { Controller, Get, Header } from '@nestjs/common'; import { Controller, Get, Header } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger'; import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { Authenticated } from 'src/middleware/auth.guard.js'; import { Authenticated } from 'src/middleware/auth.guard.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { SystemConfigService } from 'src/services/system-config.service.js'; import { SystemConfigService } from 'src/services/system-config.service.js';
@Controller() @Controller()
export class AppController { export class AppController {
constructor(private service: SystemConfigService) {} constructor(
private service: SystemConfigService,
private passkeyService: PasskeyService,
) {}
@ApiExcludeEndpoint() @ApiExcludeEndpoint()
@Get('.well-known/immich') @Get('.well-known/immich')
@@ -18,6 +22,13 @@ export class AppController {
}; };
} }
@ApiExcludeEndpoint()
@Get('.well-known/webauthn')
@Authenticated({ public: true })
getWebAuthnWellKnown() {
return this.passkeyService.getWellKnown();
}
@ApiExcludeEndpoint() @ApiExcludeEndpoint()
@Get('custom.css') @Get('custom.css')
@Authenticated({ public: true }) @Authenticated({ public: true })
+2
View File
@@ -25,6 +25,7 @@ import { NotificationAdminController } from 'src/controllers/notification-admin.
import { NotificationController } from 'src/controllers/notification.controller.js'; import { NotificationController } from 'src/controllers/notification.controller.js';
import { OAuthController } from 'src/controllers/oauth.controller.js'; import { OAuthController } from 'src/controllers/oauth.controller.js';
import { PartnerController } from 'src/controllers/partner.controller.js'; import { PartnerController } from 'src/controllers/partner.controller.js';
import { PasskeyController } from 'src/controllers/passkey.controller.js';
import { PersonController } from 'src/controllers/person.controller.js'; import { PersonController } from 'src/controllers/person.controller.js';
import { PluginController } from 'src/controllers/plugin.controller.js'; import { PluginController } from 'src/controllers/plugin.controller.js';
import { QueueController } from 'src/controllers/queue.controller.js'; import { QueueController } from 'src/controllers/queue.controller.js';
@@ -73,6 +74,7 @@ export const controllers = [
NotificationAdminController, NotificationAdminController,
OAuthController, OAuthController,
PartnerController, PartnerController,
PasskeyController,
PersonController, PersonController,
PluginController, PluginController,
QueueController, QueueController,
@@ -30,6 +30,32 @@ describe(OAuthController.name, () => {
); );
expect(service.authorize).not.toHaveBeenCalled(); expect(service.authorize).not.toHaveBeenCalled();
}); });
it('should set the state and code verifier cookies when the server generates them', async () => {
service.authorize.mockResolvedValue({ url: 'http://idp/auth', state: 'server-state', codeVerifier: 'verifier' });
const { status, headers } = await request(ctx.getHttpServer())
.post('/oauth/authorize')
.send({ redirectUri: 'http://immich/auth/login' });
expect(status).toBe(201);
const cookies = (headers['set-cookie'] as unknown as string[]) ?? [];
expect(cookies).toEqual([
expect.stringContaining('immich_oauth_state=server-state'),
expect.stringContaining('immich_oauth_code_verifier=verifier'),
]);
});
it('should not set cookies for client supplied state and code challenge', async () => {
service.authorize.mockResolvedValue({ url: 'http://idp/auth', state: 'client-state', codeVerifier: null });
const { status, headers } = await request(ctx.getHttpServer())
.post('/oauth/authorize')
.send({ redirectUri: 'http://immich/auth/login', state: 'client-state', codeChallenge: 'challenge' });
expect(status).toBe(201);
expect(headers['set-cookie']).toBeUndefined();
});
}); });
describe('POST /oauth/callback', () => { describe('POST /oauth/callback', () => {
+1 -1
View File
@@ -56,7 +56,7 @@ export class OAuthController {
{ {
isSecure: loginDetails.isSecure, isSecure: loginDetails.isSecure,
values: [ values: [
{ key: ImmichCookie.OAuthState, value: state }, { key: ImmichCookie.OAuthState, value: dto.state ? null : state },
{ key: ImmichCookie.OAuthCodeVerifier, value: codeVerifier }, { key: ImmichCookie.OAuthCodeVerifier, value: codeVerifier },
], ],
}, },
@@ -0,0 +1,168 @@
import request from 'supertest';
import { PasskeyController } from 'src/controllers/passkey.controller.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { factory } from 'test/small.factory.js';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
const registrationResponse = {
id: 'Y3JlZGVudGlhbC1pZA',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
attestationObject: 'attestation-object',
},
};
const authenticationResponse = {
id: 'Y3JlZGVudGlhbC1pZA',
rawId: 'credential-id',
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: 'client-data',
authenticatorData: 'authenticator-data',
signature: 'signature',
},
};
describe(PasskeyController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(PasskeyService);
beforeAll(async () => {
ctx = await controllerSetup(PasskeyController, [{ provide: PasskeyService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /passkeys/registration/start', () => {
it('should respond with 200', async () => {
service.startRegistration.mockResolvedValue({ challenge: 'challenge' } as any);
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/registration/start');
expect(status).toEqual(200);
expect(headers['set-cookie']).toBeUndefined();
});
});
describe('POST /passkeys/registration/finish', () => {
it('should require a response', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/passkeys/registration/finish')
.send({ name: 'Passkey' });
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['response'], message: 'Invalid input: expected nonoptional, received undefined' },
]),
);
});
it('should respond with 201', async () => {
service.finishRegistration.mockResolvedValue({} as any);
const { status, headers } = await request(ctx.getHttpServer())
.post('/passkeys/registration/finish')
.send({ name: 'Passkey', response: registrationResponse });
expect(status).toEqual(201);
expect(headers['set-cookie']).toBeUndefined();
});
});
describe('POST /passkeys/authentication/start', () => {
it('should respond with 200', async () => {
service.startAuthentication.mockResolvedValue({ challenge: 'challenge' } as any);
const { status, headers } = await request(ctx.getHttpServer()).post('/passkeys/authentication/start');
expect(status).toEqual(200);
expect(headers['set-cookie']).toBeUndefined();
});
});
describe('POST /passkeys/authentication/finish', () => {
it('should require a response', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/passkeys/authentication/finish').send({});
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['response'], message: 'Invalid input: expected object, received undefined' },
]),
);
});
it('should respond with 201 and set the auth cookies', async () => {
service.finishAuthentication.mockResolvedValue({ accessToken: 'token' } as any);
const { status, headers } = await request(ctx.getHttpServer())
.post('/passkeys/authentication/finish')
.send({ response: authenticationResponse });
expect(status).toEqual(201);
expect(headers['set-cookie']).toEqual([
expect.stringContaining('immich_access_token=token; Max-Age=34560000; Path=/; Expires='),
expect.stringContaining('immich_auth_type=passkey; Max-Age=34560000; Path=/; Expires='),
expect.stringContaining('immich_is_authenticated=true; Max-Age=34560000; Path=/; Expires='),
]);
});
});
describe('GET /passkeys', () => {
it('should reject an invalid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys').query({ id: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
describe('GET /passkeys/:id', () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/passkeys/123');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
describe('PATCH /passkeys/:id', () => {
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).patch(`/passkeys/${factory.uuid()}`).send({});
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['name'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
it('should reject an empty name', async () => {
const { status, body } = await request(ctx.getHttpServer())
.patch(`/passkeys/${factory.uuid()}`)
.send({ name: '' });
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.validationError([
{ path: ['name'], message: 'Too small: expected string to have >=1 characters' },
]),
);
expect(service.update).not.toHaveBeenCalled();
});
it('should allow a null name', async () => {
const auth = factory.auth();
const id = factory.uuid();
ctx.authenticate.mockResolvedValue(auth);
service.update.mockResolvedValue({} as any);
const { status } = await request(ctx.getHttpServer()).patch(`/passkeys/${id}`).send({ name: null });
expect(status).toEqual(200);
expect(service.update).toHaveBeenCalledWith(auth, id, { name: null });
});
});
describe('DELETE /passkeys/:id', () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete('/passkeys/123');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
@@ -0,0 +1,152 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import type { LoginDetails } from 'src/services/auth.service.js';
import { Endpoint, HistoryBuilder } from 'src/decorators.js';
import { type AuthDto, LoginResponseDto } from 'src/dtos/auth.dto.js';
import {
PasskeyAuthenticationFinishDto,
PasskeyAuthenticationOptionsDto,
PasskeyRegistrationFinishDto,
PasskeyRegistrationOptionsDto,
PasskeyResponseDto,
PasskeySearchDto,
PasskeyUpdateDto,
} from 'src/dtos/passkey.dto.js';
import { ApiTag, AuthType, ImmichCookie, Permission } from 'src/enum.js';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard.js';
import { PasskeyService } from 'src/services/passkey.service.js';
import { respondWithCookie } from 'src/utils/response.js';
import { UUIDParamDto } from 'src/validation.js';
@ApiTags(ApiTag.Passkeys)
@Controller('passkeys')
export class PasskeyController {
constructor(private service: PasskeyService) {}
@Post('registration/start')
@Authenticated({ permission: false })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Start passkey registration',
description: 'Generate the options used by the browser to create a new passkey for the current user.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
startRegistration(@Req() request: Request, @Auth() auth: AuthDto): Promise<PasskeyRegistrationOptionsDto> {
return this.service.startRegistration(auth, request.headers);
}
@Post('registration/finish')
@Authenticated({ permission: false })
@Endpoint({
summary: 'Finish passkey registration',
description: 'Verify the response from the authenticator and save the new passkey for the current user.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
finishRegistration(
@Req() request: Request,
@Auth() auth: AuthDto,
@Body() dto: PasskeyRegistrationFinishDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<PasskeyResponseDto> {
return this.service.finishRegistration(auth, dto, request.headers, loginDetails);
}
@Post('authentication/start')
@Authenticated({ public: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Start passkey authentication',
description: 'Generate the options used by the browser to sign in with a passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
startAuthentication(@Req() request: Request): Promise<PasskeyAuthenticationOptionsDto> {
return this.service.startAuthentication(request.headers);
}
@Post('authentication/finish')
@Authenticated({ public: true })
@Endpoint({
summary: 'Finish passkey authentication',
description: 'Verify the response from the authenticator and receive a session token.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
async finishAuthentication(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@Body() dto: PasskeyAuthenticationFinishDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const body = await this.service.finishAuthentication(dto, request.headers, loginDetails);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.Passkey },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
}
@Get()
@Authenticated({ permission: Permission.PasskeyRead })
@Endpoint({
summary: 'Search passkeys',
description: 'Search the passkeys of the current user.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
searchPasskeys(@Auth() auth: AuthDto, @Query() dto: PasskeySearchDto): Promise<PasskeyResponseDto[]> {
return this.service.search(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.PasskeyRead })
@Endpoint({
summary: 'Retrieve a passkey',
description: 'Retrieve a passkey by its ID. The current user must own this passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
getPasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PasskeyResponseDto> {
return this.service.get(auth, id);
}
@Patch(':id')
@Authenticated({ permission: Permission.PasskeyUpdate })
@Endpoint({
summary: 'Update a passkey',
description: 'Update the name of a passkey by its ID. The current user must own this passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
updatePasskey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: PasskeyUpdateDto,
): Promise<PasskeyResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.PasskeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a passkey',
description: 'Delete a passkey by its ID. The current user must own this passkey.',
history: HistoryBuilder.v4().alpha('v4.0.0'),
})
deletePasskey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}
+15
View File
@@ -82,6 +82,17 @@ export type ApiKey = {
permissions: Permission[]; permissions: Permission[];
}; };
export type Passkey = {
id: string;
name: string | null;
userId: string;
credentialId: string;
transports: string[];
createdAt: Date;
updatedAt: Date;
usedAt: Date | null;
};
export type Tag = { export type Tag = {
id: string; id: string;
value: string; value: string;
@@ -277,6 +288,9 @@ export type AssetFace = {
updatedAt: Date; updatedAt: Date;
updateId: string; updateId: string;
isVisible: boolean; isVisible: boolean;
ownerId: string;
assetVisibility: AssetVisibility;
assetDeletedAt: Date | null;
}; };
export type Plugin = Selectable<PluginTable>; export type Plugin = Selectable<PluginTable>;
@@ -406,6 +420,7 @@ export const columns = {
], ],
tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'], tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'],
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
passkey: ['id', 'name', 'userId', 'credentialId', 'transports', 'createdAt', 'updatedAt', 'usedAt'],
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'], notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
pluginMethod: [ pluginMethod: [
'plugin_method.name', 'plugin_method.name',
+3 -2
View File
@@ -6,6 +6,7 @@ import { AlbumUser, AuthSharedLink } from 'src/database.js';
import { HistoryBuilder } from 'src/decorators.js'; import { HistoryBuilder } from 'src/decorators.js';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto.js'; import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto.js';
import { MapAsset } from 'src/dtos/asset-response.dto.js'; import { MapAsset } from 'src/dtos/asset-response.dto.js';
import { AuthDto } from 'src/dtos/auth.dto.js';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js'; import { UserResponseSchema, mapUser } from 'src/dtos/user.dto.js';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum.js'; import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum.js';
import { asDateTimeString } from 'src/utils/date.js'; import { asDateTimeString } from 'src/utils/date.js';
@@ -229,7 +230,7 @@ export type MapAlbumDto = {
order: AssetOrder; order: AssetOrder;
}; };
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => { export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, { auth }: { auth?: AuthDto } = {}): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = [];
if (entity.albumUsers) { if (entity.albumUsers) {
@@ -262,7 +263,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
createdAt: asDateTimeString(entity.createdAt), createdAt: asDateTimeString(entity.createdAt),
updatedAt: asDateTimeString(entity.updatedAt), updatedAt: asDateTimeString(entity.updatedAt),
id: entity.id, id: entity.id,
albumUsers, albumUsers: auth?.sharedLink ? [] : albumUsers,
shared: hasSharedUser || hasSharedLink, shared: hasSharedUser || hasSharedLink,
hasSharedLink, hasSharedLink,
startDate: asDateTimeString(startDate), startDate: asDateTimeString(startDate),
+2
View File
@@ -21,6 +21,8 @@ export type AuthDto = {
export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.sharedLink.showExif; export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.sharedLink.showExif;
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
const LoginCredentialSchema = z const LoginCredentialSchema = z
.object({ .object({
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }), email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
+43
View File
@@ -17,6 +17,7 @@ const PUBLIC_PROPERTIES = [
'oauth.autoLaunch', 'oauth.autoLaunch',
'oauth.buttonText', 'oauth.buttonText',
'oauth.enabled', 'oauth.enabled',
'passkey.enabled',
'passwordLogin.enabled', 'passwordLogin.enabled',
'server.loginPageMessage', 'server.loginPageMessage',
]; ];
@@ -75,3 +76,45 @@ describe('config visibility', () => {
} }
}); });
}); });
const parsePasskey = (passkey: Record<string, unknown>) => AdminConfigDto.schema.safeParse({ ...defaults, passkey });
const passkeyMessages = (passkey: Record<string, unknown>) =>
parsePasskey(passkey).error?.issues.map((issue) => issue.message);
describe('passkey config', () => {
it('should allow enabling without a domain', () => {
expect(parsePasskey({ enabled: true, domain: null, additionalDomains: [] }).success).toBe(true);
});
it('should reject domains that are not https', () => {
expect(passkeyMessages({ enabled: true, domain: 'ftp://immich.example', additionalDomains: [] })).toEqual([
'Invalid URL',
]);
});
it('should reject domains with a path', () => {
expect(
passkeyMessages({ enabled: true, domain: null, additionalDomains: ['https://immich.tailnet.example/photos'] }),
).toEqual(['Additional domain must not include a path, query, or fragment']);
});
it('should reject duplicate additional domains', () => {
expect(
passkeyMessages({
enabled: true,
domain: null,
additionalDomains: ['https://immich.tailnet.example', 'https://immich.tailnet.example/'],
}),
).toEqual(['Duplicate domain']);
});
it('should reject the passkey domain as an additional domain', () => {
expect(
passkeyMessages({
enabled: true,
domain: 'https://immich.example',
additionalDomains: ['https://immich.example/'],
}),
).toEqual(['The passkey domain is always allowed and must not be listed as an additional domain']);
});
});
+47 -2
View File
@@ -58,6 +58,18 @@ const cronExpressionSchema = z
}) })
.describe('Cron expression'); .describe('Cron expression');
const httpsDomain = (label: string) =>
z
.url({ protocol: /^https$/, hostname: z.regexes.domain })
.normalize()
.refine((url) => {
try {
return new URL(url).href === `${new URL(url).origin}/`;
} catch {
return true;
}
}, `${label} must not include a path, query, or fragment`);
const emptyOrUrl = (error: string) => const emptyOrUrl = (error: string) =>
z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error }); z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error });
@@ -322,6 +334,29 @@ const AdminConfigSchemaWithVisibility = z
return value; return value;
}) })
.meta({ id: 'AdminConfigOAuthDto' }), .meta({ id: 'AdminConfigOAuthDto' }),
passkey: z
.object({
enabled: configBool.describe('Enabled').meta({ visibility: Public }),
domain: httpsDomain('Passkey domain').nullable().describe('Passkey domain'),
additionalDomains: z
.array(httpsDomain('Additional domain'))
.refine(
(domains) => domains.length === new Set(domains.map((domain) => new URL(domain).origin)).size,
'Duplicate domain',
)
.describe('Additional domains'),
})
.superRefine((value, ctx) => {
const origin = value.domain && new URL(value.domain).origin;
if (origin && value.additionalDomains.some((domain) => new URL(domain).origin === origin)) {
ctx.addIssue({
code: 'custom',
message: 'The passkey domain is always allowed and must not be listed as an additional domain',
input: value.additionalDomains,
});
}
})
.meta({ id: 'AdminConfigPasskeyDto' }),
passwordLogin: z passwordLogin: z
.object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) }) .object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) })
.meta({ id: 'AdminConfigPasswordLoginDto' }), .meta({ id: 'AdminConfigPasswordLoginDto' }),
@@ -469,7 +504,12 @@ const applyVisibilityRecursive = (
let visible: z.ZodType | undefined; let visible: z.ZodType | undefined;
if (Object.keys(shape).length > 0) { if (Object.keys(shape).length > 0) {
visible = z.object(shape).meta({ const isComplete = Object.keys(shape).length === Object.keys(object.shape).length;
const rebuilt =
schema instanceof z.ZodPipe && isComplete
? z.object(shape).pipe(schema.def.out as z.ZodTransform<unknown, Record<string, unknown>>)
: z.object(shape);
visible = rebuilt.meta({
...(id && { id: `${visibility}${id.slice(Admin.length)}` }), ...(id && { id: `${visibility}${id.slice(Admin.length)}` }),
...((override ?? description) && { description: override ?? description }), ...((override ?? description) && { description: override ?? description }),
}); });
@@ -498,7 +538,7 @@ const stripVisibilityMetadata = <T extends z.ZodType>(schema: T): T => {
return schema; return schema;
}; };
const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType<SystemConfig>; const AdminConfigSchema = AdminConfigSchemaWithVisibility;
const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>; const UserConfigSchema = applyVisibility(User)! as z.ZodType<DeepPartial<SystemConfig>>;
const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>; const PublicConfigSchema = applyVisibility(Public)! as z.ZodType<DeepPartial<SystemConfig>>;
@@ -682,6 +722,11 @@ export const defaults = Object.freeze<SystemConfig>({
timeout: 30_000, timeout: 30_000,
allowInsecureRequests: false, allowInsecureRequests: false,
}, },
passkey: {
enabled: true,
domain: null,
additionalDomains: [],
},
passwordLogin: { passwordLogin: {
enabled: true, enabled: true,
}, },
+70
View File
@@ -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,
});
+1 -1
View File
@@ -114,7 +114,7 @@ export function mapSharedLink(sharedLink: SharedLink, { auth }: { auth: AuthDto
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { auth })), assets: assets.map((asset) => mapAsset(asset, { auth })),
album: sharedLink.album ? mapAlbum(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbum(sharedLink.album, { auth }) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif, showMetadata: sharedLink.showExif,
-5
View File
@@ -11,11 +11,6 @@ import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/
export const UserUpdateMeSchema = z export const UserUpdateMeSchema = z
.object({ .object({
email: toEmail.optional().describe('User email'), email: toEmail.optional().describe('User email'),
password: z
.string()
.optional()
.describe('User password (deprecated, use change password endpoint)')
.meta({ deprecated: true }),
name: z.string().optional().describe('User name'), name: z.string().optional().describe('User name'),
avatarColor: UserAvatarColorSchema.nullish(), avatarColor: UserAvatarColorSchema.nullish(),
}) })
+4 -2
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component.js'; import { ImmichButton } from 'src/emails/components/button.component.js';
import ImmichLayout from 'src/emails/components/immich.layout.js'; import ImmichLayout from 'src/emails/components/immich.layout.js';
import { AlbumInviteEmailProps } from 'src/repositories/email.repository.js'; import { AlbumInviteEmailProps } from 'src/repositories/email.repository.js';
import { replaceTemplateTags } from 'src/utils/replace-template-tags.js'; import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js';
export const AlbumInviteEmail = ({ export const AlbumInviteEmail = ({
baseUrl, baseUrl,
@@ -37,7 +37,9 @@ export const AlbumInviteEmail = ({
); );
return ( return (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'You have been added to a shared album.'}> <ImmichLayout
preview={customTemplate ? templatePreview(customTemplate, variables) : 'You have been added to a shared album.'}
>
{customTemplate && ( {customTemplate && (
<Text className="m-0"> <Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div> <div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
+8 -2
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component.js'; import { ImmichButton } from 'src/emails/components/button.component.js';
import ImmichLayout from 'src/emails/components/immich.layout.js'; import ImmichLayout from 'src/emails/components/immich.layout.js';
import { AlbumUpdateEmailProps } from 'src/repositories/email.repository.js'; import { AlbumUpdateEmailProps } from 'src/repositories/email.repository.js';
import { replaceTemplateTags } from 'src/utils/replace-template-tags.js'; import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js';
export const AlbumUpdateEmail = ({ export const AlbumUpdateEmail = ({
baseUrl, baseUrl,
@@ -36,7 +36,13 @@ export const AlbumUpdateEmail = ({
); );
return ( return (
<ImmichLayout preview={customTemplate ? emailContent.toString() : 'New media has been added to a shared album.'}> <ImmichLayout
preview={
customTemplate
? templatePreview(customTemplate, usableTemplateVariables)
: 'New media has been added to a shared album.'
}
>
{customTemplate && ( {customTemplate && (
<Text className="m-0"> <Text className="m-0">
<div dangerouslySetInnerHTML={{ __html: emailContent }}></div> <div dangerouslySetInnerHTML={{ __html: emailContent }}></div>
+6 -2
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component.js'; import { ImmichButton } from 'src/emails/components/button.component.js';
import ImmichLayout from 'src/emails/components/immich.layout.js'; import ImmichLayout from 'src/emails/components/immich.layout.js';
import { WelcomeEmailProps } from 'src/repositories/email.repository.js'; import { WelcomeEmailProps } from 'src/repositories/email.repository.js';
import { replaceTemplateTags } from 'src/utils/replace-template-tags.js'; import { replaceTemplateTags, templatePreview } from 'src/utils/replace-template-tags.js';
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => { export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {
const usableTemplateVariables = { const usableTemplateVariables = {
@@ -37,7 +37,11 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password, customT
return ( return (
<ImmichLayout <ImmichLayout
preview={customTemplate ? emailContent.toString() : 'You have been invited to a new Immich instance.'} preview={
customTemplate
? templatePreview(customTemplate, usableTemplateVariables)
: 'You have been invited to a new Immich instance.'
}
> >
{customTemplate && ( {customTemplate && (
<Text className="m-0"> <Text className="m-0">
+15
View File
@@ -5,6 +5,12 @@ import { asIntegerEnum } from 'src/utils/helpers.js';
export enum AuthType { export enum AuthType {
Password = 'password', Password = 'password',
OAuth = 'oauth', OAuth = 'oauth',
Passkey = 'passkey',
}
export enum AuthChallengeType {
PasskeyRegistration = 'PasskeyRegistration',
PasskeyAuthentication = 'PasskeyAuthentication',
} }
export enum ImmichCookie { export enum ImmichCookie {
@@ -244,6 +250,10 @@ export enum Permission {
PartnerUpdate = 'partner.update', PartnerUpdate = 'partner.update',
PartnerDelete = 'partner.delete', PartnerDelete = 'partner.delete',
PasskeyRead = 'passkey.read',
PasskeyUpdate = 'passkey.update',
PasskeyDelete = 'passkey.delete',
PersonCreate = 'person.create', PersonCreate = 'person.create',
PersonRead = 'person.read', PersonRead = 'person.read',
PersonUpdate = 'person.update', PersonUpdate = 'person.update',
@@ -892,6 +902,8 @@ export enum JobName {
AuditTableCleanup = 'AuditTableCleanup', AuditTableCleanup = 'AuditTableCleanup',
AuthChallengeCleanup = 'AuthChallengeCleanup',
DatabaseBackup = 'DatabaseBackup', DatabaseBackup = 'DatabaseBackup',
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll', FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
@@ -1018,6 +1030,8 @@ export enum DatabaseLock {
IntegrityCheck = 67, IntegrityCheck = 67,
VersionCheck = 800, VersionCheck = 800,
HlsSessionCleanup = 850, HlsSessionCleanup = 850,
FacialRecognition = 900,
DuplicateDetection = 1000,
} }
export enum MaintenanceAction { export enum MaintenanceAction {
@@ -1269,6 +1283,7 @@ export enum ApiTag {
NotificationsAdmin = 'Notifications (admin)', NotificationsAdmin = 'Notifications (admin)',
ClusterGroups = 'Cluster groups', ClusterGroups = 'Cluster groups',
Partners = 'Partners', Partners = 'Partners',
Passkeys = 'Passkeys',
People = 'People', People = 'People',
Plugins = 'Plugins', Plugins = 'Plugins',
Queues = 'Queues', Queues = 'Queues',
@@ -1,5 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie'; import { parseCookie } from 'cookie';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -240,7 +240,7 @@ export class MaintenanceWorkerService {
} }
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> { async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken]; const jwtToken = parseCookie(headers.cookie || '')[ImmichCookie.MaintenanceToken];
return this.login(jwtToken); return this.login(jwtToken);
} }
+9
View File
@@ -320,6 +320,15 @@ where
"partner"."sharedById" in ($1) "partner"."sharedById" in ($1)
and "partner"."sharedWithId" = $2 and "partner"."sharedWithId" = $2
-- AccessRepository.passkey.checkOwnerAccess
select
"passkey"."id"
from
"passkey"
where
"passkey"."id" in ($1)
and "passkey"."userId" = $2
-- AccessRepository.session.checkOwnerAccess -- AccessRepository.session.checkOwnerAccess
select select
"session"."id" "session"."id"
@@ -0,0 +1,16 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AuthChallengeRepository.consume
delete from "auth_challenge"
where
"challenge" = $1
and "type" = $2
and "expiresAt" > $3
returning
"id",
"userId"
-- AuthChallengeRepository.deleteExpired
delete from "auth_challenge"
where
"expiresAt" <= $1
+46
View File
@@ -0,0 +1,46 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PasskeyRepository.delete
delete from "passkey"
where
"id" = $1::uuid
-- PasskeyRepository.getByCredentialId
select
*
from
"passkey"
where
"credentialId" = $1
-- PasskeyRepository.get
select
"id",
"name",
"userId",
"credentialId",
"transports",
"createdAt",
"updatedAt",
"usedAt"
from
"passkey"
where
"id" = $1::uuid
-- PasskeyRepository.search
select
"id",
"name",
"userId",
"credentialId",
"transports",
"createdAt",
"updatedAt",
"usedAt"
from
"passkey"
where
"userId" = $1
order by
"createdAt" desc
+437 -328
View File
@@ -148,168 +148,418 @@ limit
-- PersonRepository.getAllForUser -- PersonRepository.getAllForUser
with with
"similarity_threshold" as ( "faces" as (
select select
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh" "visible"."personGroupId",
) count(*) as "faceCount"
select
(
select
to_json(obj)
from from
( (
select select
"person".* "asset_face"."personGroupId"
from from
"person" "asset_face"
where where
"person"."personGroupId" = "person_group"."id" "asset_face"."deletedAt" is null
and "person"."ownerId" = $1 and "asset_face"."isVisible" is true
) as obj and "asset_face"."assetVisibility" = 'timeline'
) as "ownedPerson", and "asset_face"."assetDeletedAt" is null
( and "asset_face"."personGroupId" in (
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $2
where
"other"."personGroupId" = "person_group"."id"
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedWithId" = $3
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedById" = $4
order by
"user"."name"
) as agg
) as "sharedWith"
from
"similarity_threshold",
"person_group"
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $5
inner join (
select
"asset_face"."personGroupId",
count("asset"."id") as "faceCount"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
and (
"asset"."ownerId" = any ($6::uuid[])
or exists (
select
1 as "exists"
from
"album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
and "album"."deletedAt" is null
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $7::uuid
where
"album_asset"."assetId" = "asset"."id"
)
)
where
"asset_face"."personGroupId" = any (
array(
(
select select
"person"."personGroupId" "person"."personGroupId"
from from
"person" "person"
where where
"person"."ownerId" = $8 "person"."ownerId" = $1
) )
and "asset_face"."ownerId" = any ($2::uuid[])
union all
select
"asset_face"."personGroupId"
from
"asset_face"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."assetVisibility" = 'timeline'
and "asset_face"."assetDeletedAt" is null
and "asset_face"."personGroupId" in (
select
"person"."personGroupId"
from
"person"
where
"person"."ownerId" = $3
)
and not "asset_face"."ownerId" = any ($4::uuid[])
and exists (
select
1 as "exists"
from
"album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
and "album"."deletedAt" is null
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $5
where
"album_asset"."assetId" = "asset_face"."assetId"
)
) as "visible"
group by
"visible"."personGroupId"
),
"people" as (
select
"person".*,
"faces"."faceCount"
from
"person"
inner join "faces" on "faces"."personGroupId" = "person"."personGroupId"
where
"person"."ownerId" = $6
and (
"person"."name" != $7
or "faces"."faceCount" >= coalesce(
(
select
cast(value -> 'people' ->> 'minimumFaces' as integer) as "value"
from
"user_metadata"
where
"userId" = $8
and "key" = 'preferences'
),
3
) )
) )
and "asset_face"."deletedAt" is null ),
and "asset_face"."isVisible" is true "page" as (
group by select
"asset_face"."personGroupId" "people".*
) as "faces" on "faces"."personGroupId" = "person_group"."id" from
where "people"
( where
"owned"."name" != $9 "people"."isHidden" = $9
or "faces"."faceCount" >= COALESCE( order by
( "people"."isHidden" asc,
SELECT "people"."isFavorite" desc,
value -> 'people' ->> 'minimumFaces' "people"."name" = '',
FROM "people"."faceCount" desc,
user_metadata "people"."name",
WHERE "people"."createdAt",
"userId" = $10 "people"."personGroupId"
AND key = 'preferences' limit
), $10
'3' offset
)::int $11
) )
and "owned"."isHidden" = $11 select
and 1 = 1 "counts"."total",
order by "counts"."hidden",
"owned"."isHidden" asc, (
"owned"."isFavorite" desc, select
NULLIF("owned"."name", '') is null asc, coalesce(json_agg(agg), '[]')
"faces"."faceCount" desc, from
NULLIF("owned"."name", '') asc nulls last, (
"owned"."createdAt" select
limit "person".*,
$12 (
offset select
$13 coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $12
where
"other"."personGroupId" = "person"."personGroupId"
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedWithId" = $13
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = $14
order by
"user"."name"
) as agg
) as "sharedWith"
from
"page" as "person"
) as agg
) as "people"
from
(
select
count(*) as "total",
count(*) filter (
where
"people"."isHidden" = $15
) as "hidden"
from
"people"
) as "counts"
-- PersonRepository.getAllForUser (closest person)
with
"faces" as (
select
"visible"."personGroupId",
count(*) as "faceCount"
from
(
select
"asset_face"."personGroupId"
from
"asset_face"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."assetVisibility" = 'timeline'
and "asset_face"."assetDeletedAt" is null
and "asset_face"."personGroupId" in (
select
"person"."personGroupId"
from
"person"
where
"person"."ownerId" = $1
)
and "asset_face"."ownerId" = any ($2::uuid[])
union all
select
"asset_face"."personGroupId"
from
"asset_face"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."assetVisibility" = 'timeline'
and "asset_face"."assetDeletedAt" is null
and "asset_face"."personGroupId" in (
select
"person"."personGroupId"
from
"person"
where
"person"."ownerId" = $3
)
and not "asset_face"."ownerId" = any ($4::uuid[])
and exists (
select
1 as "exists"
from
"album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
and "album"."deletedAt" is null
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $5
where
"album_asset"."assetId" = "asset_face"."assetId"
)
) as "visible"
group by
"visible"."personGroupId"
),
"people" as (
select
"person".*,
"faces"."faceCount"
from
"person"
inner join "faces" on "faces"."personGroupId" = "person"."personGroupId"
where
"person"."ownerId" = $6
and (
"person"."name" != $7
or "faces"."faceCount" >= coalesce(
(
select
cast(value -> 'people' ->> 'minimumFaces' as integer) as "value"
from
"user_metadata"
where
"userId" = $8
and "key" = 'preferences'
),
3
)
)
),
"page" as (
select
"people".*
from
"people"
where
"people"."isHidden" = $9
order by
"people"."isHidden" asc,
"people"."isFavorite" desc,
(
select
"face_search"."embedding"
from
"face_search"
where
"face_search"."faceId" = "people"."faceAssetId"
) <=> (
select
"face_search"."embedding"
from
"face_search"
inner join "person" as "closest" on "closest"."faceAssetId" = "face_search"."faceId"
where
"closest"."ownerId" = $10
and "closest"."personGroupId" = $11
),
"people"."name" = '',
"people"."faceCount" desc,
"people"."name",
"people"."createdAt",
"people"."personGroupId"
limit
$12
offset
$13
)
select
"counts"."total",
"counts"."hidden",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $14
where
"other"."personGroupId" = "person"."personGroupId"
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedWithId" = $15
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person"."personGroupId"
and "person_user"."sharedById" = $16
order by
"user"."name"
) as agg
) as "sharedWith"
from
"page" as "person"
) as agg
) as "people"
from
(
select
count(*) as "total",
count(*) filter (
where
"people"."isHidden" = $17
) as "hidden"
from
"people"
) as "counts"
-- PersonRepository.getAllWithoutFaces -- PersonRepository.getAllWithoutFaces
select select
@@ -592,103 +842,6 @@ set
where where
"asset_face"."id" = $2 "asset_face"."id" = $2
-- PersonRepository.getForUser
select
(
select
to_json(obj)
from
(
select
"person".*
from
"person"
where
"person"."personGroupId" = "person_group"."id"
and "person"."ownerId" = $1
) as obj
) as "ownedPerson",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"person_user"."sharedById",
"person_user"."role",
"other"."name",
"other"."birthDate"
from
"person" as "other"
inner join "person_user" on "person_user"."personGroupId" = "other"."personGroupId"
and "person_user"."sharedById" = "other"."ownerId"
and "person_user"."sharedWithId" = $2
where
"other"."personGroupId" = "person_group"."id"
) as agg
) as "otherPeople",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedById"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedWithId" = $3
order by
"user"."name"
) as agg
) as "sharedBy",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"person_user"."role"
from
"person_user"
inner join "user" on "user"."id" = "person_user"."sharedWithId"
and "user"."deletedAt" is null
where
"person_user"."personGroupId" = "person_group"."id"
and "person_user"."sharedById" = $4
order by
"user"."name"
) as agg
) as "sharedWith"
from
"person_group"
where
"person_group"."id" = $5
and exists (
select
"person"."ownerId"
from
"person"
where
"person"."personGroupId" = "person_group"."id"
and "person"."ownerId" = $6
)
-- PersonRepository.getByGroupId -- PersonRepository.getByGroupId
select select
"person".*, "person".*,
@@ -796,11 +949,6 @@ where
"person_group"."id" = $5 "person_group"."id" = $5
-- PersonRepository.getByName -- PersonRepository.getByName
with
"similarity_threshold" as (
select
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select select
"person".*, "person".*,
( (
@@ -871,11 +1019,15 @@ select
) as agg ) as agg
) as "sharedWith" ) as "sharedWith"
from from
"similarity_threshold",
"person" "person"
where where
"person"."ownerId" = $4 "person"."ownerId" = $4
and f_unaccent ("person"."name") %> f_unaccent ($5) and f_unaccent ("person"."name") %> (
select
f_unaccent ($5)
where
set_config('pg_trgm.word_similarity_threshold', '0.5', true) is not null
)
order by order by
f_unaccent ("person"."name") <->>> f_unaccent ($6) f_unaccent ("person"."name") <->>> f_unaccent ($6)
limit limit
@@ -893,90 +1045,47 @@ where
and "person"."name" != $2 and "person"."name" != $2
) )
-- PersonRepository.getNumberOfPeople -- PersonRepository.getStatistics
select select
coalesce(count(*), 0) as "total", count(distinct "visible"."assetId") as "assets"
coalesce(
count(*) filter (
where
"people"."isHidden" = $1
),
0
) as "hidden"
from from
( (
with
"similarity_threshold" as (
select
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select select
"owned"."isHidden" "asset_face"."assetId"
from from
"similarity_threshold", "asset_face"
"person_group"
inner join "person" as "owned" on "owned"."personGroupId" = "person_group"."id"
and "owned"."ownerId" = $2
inner join (
select
"asset_face"."personGroupId",
count("asset"."id") as "faceCount"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
and (
"asset"."ownerId" = any ($3::uuid[])
or exists (
select
1 as "exists"
from
"album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
and "album"."deletedAt" is null
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $4::uuid
where
"album_asset"."assetId" = "asset"."id"
)
)
where
"asset_face"."personGroupId" = any (
array(
(
select
"person"."personGroupId"
from
"person"
where
"person"."ownerId" = $5
)
)
)
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
group by
"asset_face"."personGroupId"
) as "faces" on "faces"."personGroupId" = "person_group"."id"
where where
( "asset_face"."deletedAt" is null
"owned"."name" != $6 and "asset_face"."isVisible" is true
or "faces"."faceCount" >= COALESCE( and "asset_face"."assetVisibility" = 'timeline'
( and "asset_face"."assetDeletedAt" is null
SELECT and "asset_face"."personGroupId" = $1
value -> 'people' ->> 'minimumFaces' and "asset_face"."ownerId" = any ($2::uuid[])
FROM union all
user_metadata select
WHERE "asset_face"."assetId"
"userId" = $7 from
AND key = 'preferences' "asset_face"
), where
'3' "asset_face"."deletedAt" is null
)::int and "asset_face"."isVisible" is true
and "asset_face"."assetVisibility" = 'timeline'
and "asset_face"."assetDeletedAt" is null
and "asset_face"."personGroupId" = $3
and not "asset_face"."ownerId" = any ($4::uuid[])
and exists (
select
1 as "exists"
from
"album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
and "album"."deletedAt" is null
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $5
where
"album_asset"."assetId" = "asset_face"."assetId"
) )
and 1 = 1 ) as "visible"
) as "people"
-- PersonRepository.createGroup -- PersonRepository.createGroup
insert into insert into
@@ -528,6 +528,26 @@ class ClusterGroupRequestAccess {
} }
} }
class PasskeyAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, passkeyIds: Set<string>) {
if (passkeyIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('passkey')
.select('passkey.id')
.where('passkey.id', 'in', [...passkeyIds])
.where('passkey.userId', '=', userId)
.execute()
.then((passkeys) => new Set(passkeys.map((passkey) => passkey.id)));
}
}
class PersonAccess { class PersonAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@@ -673,6 +693,7 @@ export class AccessRepository {
clusterGroupRequest: ClusterGroupRequestAccess; clusterGroupRequest: ClusterGroupRequestAccess;
person: PersonAccess; person: PersonAccess;
partner: PartnerAccess; partner: PartnerAccess;
passkey: PasskeyAccess;
session: SessionAccess; session: SessionAccess;
stack: StackAccess; stack: StackAccess;
tag: TagAccess; tag: TagAccess;
@@ -692,6 +713,7 @@ export class AccessRepository {
this.clusterGroupRequest = new ClusterGroupRequestAccess(db); this.clusterGroupRequest = new ClusterGroupRequestAccess(db);
this.person = new PersonAccess(db); this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db); this.partner = new PartnerAccess(db);
this.passkey = new PasskeyAccess(db);
this.session = new SessionAccess(db); this.session = new SessionAccess(db);
this.stack = new StackAccess(db); this.stack = new StackAccess(db);
this.tag = new TagAccess(db); this.tag = new TagAccess(db);
+2 -2
View File
@@ -795,7 +795,7 @@ export class AssetRepository {
qb.where((eb) => { qb.where((eb) => {
// TODO this should become a shared `hasAccess` style helper once implement sharing in more places // TODO this should become a shared `hasAccess` style helper once implement sharing in more places
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!)); const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner; return options.personId ? eb.or([isOwner, inSharedAlbum(auth.user.id, 'asset.id')]) : isOwner;
}), }),
) )
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
@@ -884,7 +884,7 @@ export class AssetRepository {
.$if(!!options.userIds, (qb) => .$if(!!options.userIds, (qb) =>
qb.where((eb) => { qb.where((eb) => {
const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!)); const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!));
return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner; return options.personId ? eb.or([isOwner, inSharedAlbum(auth.user.id, 'asset.id')]) : isOwner;
}), }),
) )
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import type { Insertable, Kysely } from 'kysely';
import { DummyValue, GenerateSql } from 'src/decorators.js';
import { AuthChallengeType } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js';
@Injectable()
export class AuthChallengeRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<AuthChallengeTable>) {
return this.db.insertInto('auth_challenge').values(dto).executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{ type: AuthChallengeType.PasskeyRegistration, challenge: DummyValue.STRING }] })
consume({ type, challenge }: { type: AuthChallengeType; challenge: string }) {
return this.db
.deleteFrom('auth_challenge')
.where('challenge', '=', challenge)
.where('type', '=', type)
.where('expiresAt', '>', new Date())
.returning(['id', 'userId'])
.executeTakeFirst();
}
@GenerateSql()
async deleteExpired() {
const result = await this.db.deleteFrom('auth_challenge').where('expiresAt', '<=', new Date()).executeTakeFirst();
return Number(result.numDeletedRows);
}
}
@@ -56,6 +56,28 @@ describe(EmailRepository.name, () => {
expect(result.text).toContain('Vacation'); expect(result.text).toContain('Vacation');
}); });
it('should escape user controlled values in a custom template', async () => {
const request: EmailRenderRequest = {
template: EmailTemplate.ALBUM_INVITE,
data: {
albumName: '</p><a href="https://example.com">Reset your password</a><p>',
albumId: '123',
senderName: '<script>alert(1)</script>',
recipientName: "Tom & Jerry O'Brien",
baseUrl: 'http://localhost',
},
customTemplate: '<p>Hey {recipientName}, {senderName} added you to {albumName}</p>',
};
const result = await sut.renderEmail(request);
expect(result.html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result.html).toContain('&lt;a href=&quot;https://example.com&quot;&gt;');
expect(result.html).not.toContain('<script>alert(1)</script>');
expect(result.html).not.toContain('<a href="https://example.com">');
expect(result.html).toContain('<p>Hey Tom &amp; Jerry O&#39;Brien');
});
it('should render the email correctly for ALBUM_UPDATE template', async () => { it('should render the email correctly for ALBUM_UPDATE template', async () => {
const request: EmailRenderRequest = { const request: EmailRenderRequest = {
template: EmailTemplate.ALBUM_UPDATE, template: EmailTemplate.ALBUM_UPDATE,

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