From 1071396a4a7a4b42db2599e5e97922e94f4f099a Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Wed, 17 Apr 2024 09:15:37 +0000 Subject: [PATCH 01/46] fix(web,a11y): remove autofocus from input fields (#8857) * fix(web,a11y): remove autofocus from input field The autofocus attribute can cause the keyboard to unexpectedly appear for mobile users, and override any other focus management that the application is doing programatically. * fix: always include people filter --- web/src/lib/components/elements/search-bar.svelte | 2 -- .../lib/components/faces-page/edit-name-input.svelte | 11 ++++++++--- .../search-bar/search-people-section.svelte | 11 +++++------ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/web/src/lib/components/elements/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte index fb301b65fe..9d8c8854be 100644 --- a/web/src/lib/components/elements/search-bar.svelte +++ b/web/src/lib/components/elements/search-bar.svelte @@ -28,9 +28,7 @@ <Icon path={mdiMagnify} size="24" /> </div> </button> - <!-- svelte-ignore a11y-autofocus --> <input - autofocus class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white" type="text" {placeholder} diff --git a/web/src/lib/components/faces-page/edit-name-input.svelte b/web/src/lib/components/faces-page/edit-name-input.svelte index fa968e6914..f01ecd67e7 100644 --- a/web/src/lib/components/faces-page/edit-name-input.svelte +++ b/web/src/lib/components/faces-page/edit-name-input.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import { type PersonResponseDto } from '@immich/sdk'; - import { createEventDispatcher } from 'svelte'; + import { createEventDispatcher, onMount } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import Button from '../elements/buttons/button.svelte'; @@ -9,11 +9,17 @@ export let suggestedPeople = false; export let thumbnailData: string; + let inputElement: HTMLInputElement; + const dispatch = createEventDispatcher<{ change: string; cancel: void; input: void; }>(); + + onMount(() => { + inputElement.focus(); + }); </script> <div @@ -27,13 +33,12 @@ autocomplete="off" on:submit|preventDefault={() => dispatch('change', name)} > - <!-- svelte-ignore a11y-autofocus --> <input - autofocus class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white" type="text" placeholder="New name or nickname" bind:value={name} + bind:this={inputElement} on:input={() => dispatch('input')} /> <Button size="sm" type="submit">Done</Button> diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index a8f0008450..1b7c8c2fad 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -43,21 +43,20 @@ const filterPeople = (list: PersonResponseDto[], name: string) => { const nameLower = name.toLowerCase(); - return name ? list.filter((p) => p.name.toLowerCase().startsWith(nameLower)) : list; + return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list; }; </script> {#await peoplePromise then people} {#if people && people.length > 0} - {@const peopleList = showAllPeople ? filterPeople(people, name) : people.slice(0, numberOfPeople)} + {@const peopleList = showAllPeople + ? filterPeople(people, name) + : filterPeople(people, name).slice(0, numberOfPeople)} <div id="people-selection" class="-mb-4"> <div class="flex items-center w-full justify-between gap-6"> <p class="immich-form-label py-3">PEOPLE</p> - - {#if showAllPeople} - <SearchBar bind:name placeholder="Filter people" isSearching={false} /> - {/if} + <SearchBar bind:name placeholder="Filter people" isSearching={false} /> </div> <div class="flex -mx-1 max-h-64 gap-1 mt-2 flex-wrap overflow-y-auto immich-scrollbar"> From b21566c2fc9ba82da2b6a8ec863086a72c47da1f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 07:52:29 -0400 Subject: [PATCH 02/46] chore(deps): update node.js to d328c7b (#8829) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 2 +- web/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 8ed5344395..553084ed77 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build -FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e as web +FROM node:iron-alpine3.18@sha256:d328c7bc3305e1ab26491817936c8151a47a8861ad617c16c1eeaa9c8075c8f6 as web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/web/Dockerfile b/web/Dockerfile index 8659c64277..a25ac2bfac 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e +FROM node:iron-alpine3.18@sha256:d328c7bc3305e1ab26491817936c8151a47a8861ad617c16c1eeaa9c8075c8f6 RUN apk add --no-cache tini USER node From a3feca2580c72492d39199763a9b845c73e96660 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 07:53:00 -0400 Subject: [PATCH 03/46] chore(deps): update node.js to ec0c413 (#8833) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/Dockerfile b/cli/Dockerfile index 18f38fb4ab..17799b8506 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine3.19@sha256:7e227295e96f5b00aa79555ae166f50610940d888fc2e321cf36304cbd17d7d6 as core +FROM node:20-alpine3.19@sha256:ec0c413b1d84f3f7f67ec986ba885930c57b5318d2eb3abc6960ee05d4f2eb28 as core WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ From c227f9893e74be22deb3eaaae5561d8689bee844 Mon Sep 17 00:00:00 2001 From: Ethan Margaillan <ethan.margaillan@gmail.com> Date: Wed, 17 Apr 2024 13:55:07 +0200 Subject: [PATCH 04/46] feat(web): un-stack from the photos page ; fix stack count (#8419) * feat(web): un-stack from the photos page ; fix stack count * move stuff outside of try-catch block * small optim --- .../asset-viewer/asset-viewer.svelte | 22 ++--- .../photos-page/actions/stack-action.svelte | 39 ++++++-- .../components/photos-page/asset-grid.svelte | 9 +- web/src/lib/utils/actions.ts | 3 +- web/src/lib/utils/asset-utils.ts | 99 +++++++++++++------ web/src/routes/(user)/photos/+page.svelte | 17 +++- 6 files changed, 129 insertions(+), 60 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 4fa7d72a2f..46c95636d0 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -12,7 +12,7 @@ import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; import { user } from '$lib/stores/user.store'; import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils'; - import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile, unstackAssets } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { shortcuts } from '$lib/utils/shortcut'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; @@ -28,7 +28,6 @@ getAllAlbums, runAssetJobs, updateAsset, - updateAssets, updateAlbumInfo, type ActivityResponseDto, type AlbumResponseDto, @@ -481,20 +480,15 @@ }; const handleUnstack = async () => { - try { - const ids = $stackAssetsStore.map(({ id }) => id); - await updateAssets({ assetBulkUpdateDto: { ids, removeParent: true } }); - for (const child of $stackAssetsStore) { - child.stackParentId = null; - child.stackCount = 0; - child.stack = []; - dispatch('action', { type: AssetAction.ADD, asset: child }); + const unstackedAssets = await unstackAssets($stackAssetsStore); + if (unstackedAssets) { + for (const asset of unstackedAssets) { + dispatch('action', { + type: AssetAction.ADD, + asset, + }); } - dispatch('close'); - notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 }); - } catch (error) { - handleError(error, `Unable to unstack`); } }; diff --git a/web/src/lib/components/photos-page/actions/stack-action.svelte b/web/src/lib/components/photos-page/actions/stack-action.svelte index a857da1dd3..b6a034672b 100644 --- a/web/src/lib/components/photos-page/actions/stack-action.svelte +++ b/web/src/lib/components/photos-page/actions/stack-action.svelte @@ -1,20 +1,45 @@ <script lang="ts"> import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; - import { getAssetControlContext } from '../asset-select-control-bar.svelte'; - import type { OnStack } from '$lib/utils/actions'; - import { stackAssets } from '$lib/utils/asset-utils'; - import { mdiImageMultipleOutline } from '@mdi/js'; + import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; + import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; + import { stackAssets, unstackAssets } from '$lib/utils/asset-utils'; + import type { OnStack, OnUnstack } from '$lib/utils/actions'; + export let unstack = false; export let onStack: OnStack | undefined; + export let onUnstack: OnUnstack | undefined; const { clearSelect, getOwnedAssets } = getAssetControlContext(); const handleStack = async () => { - await stackAssets([...getOwnedAssets()], (ids) => { + const selectedAssets = [...getOwnedAssets()]; + const ids = await stackAssets(selectedAssets); + if (ids) { onStack?.(ids); clearSelect(); - }); + } + }; + + const handleUnstack = async () => { + const selectedAssets = [...getOwnedAssets()]; + if (selectedAssets.length !== 1) { + return; + } + const { stack } = selectedAssets[0]; + if (!stack) { + return; + } + const assets = [selectedAssets[0], ...stack]; + const unstackedAssets = await unstackAssets(assets); + if (unstackedAssets) { + onUnstack?.(unstackedAssets); + } + clearSelect(); }; </script> -<MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} /> +{#if unstack} + <MenuOption text="Un-stack" icon={mdiImageMinusOutline} on:click={handleUnstack} /> +{:else} + <MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} /> +{/if} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 06b5627d1b..a84b9d4d73 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -89,11 +89,10 @@ }; const onStackAssets = async () => { - if ($selectedAssets.size > 1) { - await stackAssets(Array.from($selectedAssets), (ids) => { - assetStore.removeAssets(ids); - dispatch('escape'); - }); + const ids = await stackAssets(Array.from($selectedAssets)); + if (ids) { + assetStore.removeAssets(ids); + dispatch('escape'); } }; diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index b6718e63a1..ecfd29a8fc 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -1,5 +1,5 @@ import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; -import { deleteAssets as deleteBulk } from '@immich/sdk'; +import { deleteAssets as deleteBulk, type AssetResponseDto } from '@immich/sdk'; import { handleError } from './handle-error'; export type OnDelete = (assetIds: string[]) => void; @@ -7,6 +7,7 @@ export type OnRestore = (ids: string[]) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (ids: string[]) => void; +export type OnUnstack = (assets: AssetResponseDto[]) => void; export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { try { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 72102d2634..50337fb06b 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; +import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { downloadRequest, getKey } from '$lib/utils'; @@ -269,43 +270,81 @@ export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserRespo return ids; }; -export async function stackAssets(assets: Array<AssetResponseDto>, onStack: (ds: string[]) => void) { +export const stackAssets = async (assets: AssetResponseDto[]) => { + if (assets.length < 2) { + return false; + } + + const parent = assets[0]; + const children = assets.slice(1); + const ids = children.map(({ id }) => id); + try { - const parent = assets.at(0); - if (!parent) { - return; - } + await updateAssets({ + assetBulkUpdateDto: { + ids, + stackParentId: parent.id, + }, + }); + } catch (error) { + handleError(error, 'Failed to stack assets'); + return false; + } - const children = assets.slice(1); - const ids = children.map(({ id }) => id); - - if (children.length > 0) { - await updateAssets({ assetBulkUpdateDto: { ids, stackParentId: parent.id } }); - } - - let childrenCount = parent.stackCount || 1; - for (const asset of children) { - asset.stackParentId = parent.id; - // Add grand-children's count to new parent - childrenCount += asset.stackCount || 1; + let grandChildren: AssetResponseDto[] = []; + for (const asset of children) { + asset.stackParentId = parent.id; + if (asset.stack) { + // Add grand-children to new parent + grandChildren = grandChildren.concat(asset.stack); // Reset children stack info asset.stackCount = null; asset.stack = []; } - - parent.stackCount = childrenCount; - - notificationController.show({ - message: `Stacked ${ids.length + 1} assets`, - type: NotificationType.Info, - timeout: 1500, - }); - - onStack(ids); - } catch (error) { - handleError(error, `Unable to stack`); } -} + + parent.stack ??= []; + parent.stack = parent.stack.concat(children, grandChildren); + parent.stackCount = parent.stack.length + 1; + + notificationController.show({ + message: `Stacked ${parent.stackCount} assets`, + type: NotificationType.Info, + button: { + text: 'View Stack', + onClick() { + return assetViewingStore.setAssetId(parent.id); + }, + }, + }); + + return ids; +}; + +export const unstackAssets = async (assets: AssetResponseDto[]) => { + const ids = assets.map(({ id }) => id); + try { + await updateAssets({ + assetBulkUpdateDto: { + ids, + removeParent: true, + }, + }); + } catch (error) { + handleError(error, 'Failed to un-stack assets'); + return; + } + for (const asset of assets) { + asset.stackParentId = null; + asset.stackCount = null; + asset.stack = []; + } + notificationController.show({ + type: NotificationType.Info, + message: `Un-stacked ${assets.length} assets`, + }); + return assets; +}; export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { if (get(isSelectingAllAssets)) { diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 00409cdc1b..f711b081d6 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -30,7 +30,14 @@ const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); + let isAllFavorite: boolean; + let isAssetStackSelected: boolean; + + $: { + const selection = [...$selectedAssets]; + isAllFavorite = selection.every((asset) => asset.isFavorite); + isAssetStackSelected = selection.length === 1 && !!selection[0].stack; + } const handleEscape = () => { if ($showAssetViewer) { @@ -62,8 +69,12 @@ <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu"> <DownloadAction menuItem /> - {#if $selectedAssets.size > 1} - <StackAction onStack={(assetIds) => assetStore.removeAssets(assetIds)} /> + {#if $selectedAssets.size > 1 || isAssetStackSelected} + <StackAction + unstack={isAssetStackSelected} + onStack={(assetIds) => assetStore.removeAssets(assetIds)} + onUnstack={(assets) => assetStore.addAssets(assets)} + /> {/if} <ChangeDate menuItem /> <ChangeLocation menuItem /> From 3a9df6dae8b1493cb79ea17a0938ef13e3dfc42f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Wed, 17 Apr 2024 08:27:04 -0400 Subject: [PATCH 05/46] refactor(server): immich-admin list-users (#8862) --- .../specs/immich-admin.e2e-spec.ts | 19 +++++++++++++++++++ e2e/src/utils.ts | 18 ++++++++++-------- e2e/vitest.config.ts | 2 +- server/src/commands/list-users.command.ts | 12 +----------- server/src/services/user.service.ts | 5 +++++ 5 files changed, 36 insertions(+), 20 deletions(-) create mode 100644 e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts diff --git a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts new file mode 100644 index 0000000000..707093ab25 --- /dev/null +++ b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts @@ -0,0 +1,19 @@ +import { immichAdmin, utils } from 'src/utils'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe(`immich-admin`, () => { + beforeAll(async () => { + await utils.resetDatabase(); + await utils.adminSetup(); + }); + + describe('list-users', () => { + it('should list the admin user', async () => { + const { stdout, stderr, exitCode } = await immichAdmin(['list-users']); + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + expect(stdout).toContain("email: 'admin@immich.cloud'"); + expect(stdout).toContain("name: 'Immich Admin'"); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index a46653eb11..617f2d62cc 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -43,7 +43,7 @@ import { loginDto, signupDto } from 'src/fixtures'; import { makeRandomImage } from 'src/generators'; import request from 'supertest'; -type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; +type CommandResponse = { stdout: string; stderr: string; exitCode: number | null }; type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete'; type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number }; type AdminSetupOptions = { onboarding?: boolean }; @@ -59,13 +59,15 @@ export const testAssetDirInternal = '/data/assets'; export const tempDir = tmpdir(); export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); -export const immichCli = async (args: string[]) => { - let _resolve: (value: CliResponse) => void; - const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve)); - const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]; - const child = spawn('node', _args, { - stdio: 'pipe', - }); +export const immichCli = (args: string[]) => + executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]); +export const immichAdmin = (args: string[]) => + executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); + +const executeCommand = (command: string, args: string[]) => { + let _resolve: (value: CommandResponse) => void; + const deferred = new Promise<CommandResponse>((resolve) => (_resolve = resolve)); + const child = spawn(command, args, { stdio: 'pipe' }); let stdout = ''; let stderr = ''; diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 9b9670c042..6b1db353c5 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -10,7 +10,7 @@ try { export default defineConfig({ test: { - include: ['src/{api,cli}/specs/*.e2e-spec.ts'], + include: ['src/{api,cli,immich-admin}/specs/*.e2e-spec.ts'], globalSetup, testTimeout: 15_000, poolOptions: { diff --git a/server/src/commands/list-users.command.ts b/server/src/commands/list-users.command.ts index 32bcc35d95..ea3e745463 100644 --- a/server/src/commands/list-users.command.ts +++ b/server/src/commands/list-users.command.ts @@ -1,5 +1,4 @@ import { Command, CommandRunner } from 'nest-commander'; -import { UserEntity } from 'src/entities/user.entity'; import { UserService } from 'src/services/user.service'; @Command({ @@ -13,16 +12,7 @@ export class ListUsersCommand extends CommandRunner { async run(): Promise<void> { try { - const users = await this.userService.getAll( - { - user: { - id: 'cli', - email: 'cli@immich.app', - isAdmin: true, - } as UserEntity, - }, - true, - ); + const users = await this.userService.listUsers(); console.dir(users); } catch (error) { console.error(error); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index cb9012d641..9d40a14e5a 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -37,6 +37,11 @@ export class UserService { this.configCore = SystemConfigCore.create(configRepository, this.logger); } + async listUsers(): Promise<UserResponseDto[]> { + const users = await this.userRepository.getList({ withDeleted: true }); + return users.map((user) => mapUser(user)); + } + async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> { const users = await this.userRepository.getList({ withDeleted: !isAll }); return users.map((user) => mapUser(user)); From 7db07bbe615af186b55949cd4a58bc72cb8decba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 11:23:24 -0400 Subject: [PATCH 06/46] fix(deps): update dependency gunicorn to v22 [security] (#8863) --- machine-learning/poetry.lock | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 6b98b8f521..ce3c8a180a 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1150,22 +1150,23 @@ test = ["objgraph", "psutil"] [[package]] name = "gunicorn" -version = "21.2.0" +version = "22.0.0" description = "WSGI HTTP Server for UNIX" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" files = [ - {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, - {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, + {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, + {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, ] [package.dependencies] packaging = "*" [package.extras] -eventlet = ["eventlet (>=0.24.1)"] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] gevent = ["gevent (>=1.4.0)"] setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] [[package]] From a4f805e99ba86a8b3e196473888d593d3ae1acee Mon Sep 17 00:00:00 2001 From: Alessandro Vitali <98644809+alvitali@users.noreply.github.com> Date: Wed, 17 Apr 2024 20:59:09 +0200 Subject: [PATCH 07/46] Update oauth.md (#8794) Removed closing brackets from oauth redirect URIs. --- docs/docs/administration/oauth.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index b273f27712..6fcc47d6a4 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -52,8 +52,8 @@ Before enabling OAuth in Immich, a new client application needs to be configured Hostname - - `https://immich.example.com/auth/login`) - - `https://immich.example.com/user-settings`) + - `https://immich.example.com/auth/login` + - `https://immich.example.com/user-settings` ## Enable OAuth From 8573c846056ed56a804848c5ddcf37bdfca54531 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 17 Apr 2024 23:47:24 -0400 Subject: [PATCH 08/46] fix(server): include archived images in face detection (#8892) --- server/src/services/person.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 77b7e552cc..2cd3cd88a9 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -292,7 +292,7 @@ export class PersonService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true }) + ? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true, withArchived: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); }); @@ -424,7 +424,7 @@ export class PersonService { this.logger.debug(`Face ${id} has ${matches.length} matches`); - const isCore = matches.length >= machineLearning.facialRecognition.minFaces; + const isCore = matches.length >= machineLearning.facialRecognition.minFaces && !face.asset.isArchived; if (!isCore && !deferred) { this.logger.debug(`Deferring non-core face ${id} for later processing`); await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } }); From b74f8273c2ad0745e18decb95aa59721b17253d3 Mon Sep 17 00:00:00 2001 From: martyfuhry <martyfuhry@gmail.com> Date: Thu, 18 Apr 2024 15:11:00 -0400 Subject: [PATCH 09/46] fix:(mobile): Updates old IMMICH text from the mobile settings modal (#8906) * fix: Removes old IMMICH text from the mobile settings modal Removed old Snowburst One font from the pubspec Removes SnowburstOne.ttf file * Uses immich text now --- mobile/fonts/SnowburstOne.ttf | Bin 66404 -> 0 bytes .../ui/app_bar_dialog/app_bar_dialog.dart | 33 ++++++++---------- mobile/pubspec.yaml | 3 -- 3 files changed, 15 insertions(+), 21 deletions(-) delete mode 100644 mobile/fonts/SnowburstOne.ttf diff --git a/mobile/fonts/SnowburstOne.ttf b/mobile/fonts/SnowburstOne.ttf deleted file mode 100644 index e29832085c1cd29dc552841c22c203d7a5e96b34..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66404 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 Fe*@=XKZ^hW diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart index 24ee7e693f..89f73c0c64 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart @@ -39,27 +39,24 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); buildTopRow() { - return Row( + return Stack( children: [ - InkWell( - onTap: () => context.pop(), - child: const Icon( - Icons.close, - size: 20, + Align( + alignment: Alignment.topLeft, + child: InkWell( + onTap: () => context.pop(), + child: const Icon( + Icons.close, + size: 20, + ), ), ), - Expanded( - child: Align( - alignment: Alignment.center, - child: Text( - 'IMMICH', - style: TextStyle( - fontFamily: 'SnowburstOne', - fontWeight: FontWeight.bold, - color: context.primaryColor, - fontSize: 16, - ), - ), + Center( + child: Image.asset( + context.isDarkTheme + ? 'assets/immich-text-dark.png' + : 'assets/immich-text-light.png', + height: 16, ), ), ], diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b0dd2f6b61..8dcc892a06 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -105,9 +105,6 @@ flutter: - assets/ - assets/i18n/ fonts: - - family: SnowburstOne - fonts: - - asset: fonts/SnowburstOne.ttf - family: Inconsolata fonts: - asset: fonts/Inconsolata-Regular.ttf From c50241369ac3b16549f8f31a2f83a3c58e903e0a Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Thu, 18 Apr 2024 18:08:09 -0700 Subject: [PATCH 10/46] docs: link to storage label docs from storage template docs (#8911) * docs: link to storage label docs from storage template docs * docusaurus sucks --- docs/docs/partials/_storage-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/partials/_storage-template.md b/docs/docs/partials/_storage-template.md index b52fd4649a..6b733faa55 100644 --- a/docs/docs/partials/_storage-template.md +++ b/docs/docs/partials/_storage-template.md @@ -1,4 +1,4 @@ -Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level. +Immich allows the admin user to set the uploaded filename pattern at the directory and filename level as well as the [storage label for a user](/docs/administration/user-management/#set-storage-label-for-user). :::note new version On new machines running version 1.92.0 storage template engine is off by default, for [more info](https://github.com/immich-app/immich/releases/tag/v1.92.0#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template,-We%20have%20further). From 112d6d60ec1aee1d6bf9ed7228cf18cf80fcbd6a Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Fri, 19 Apr 2024 03:11:54 +0200 Subject: [PATCH 11/46] feat(web): add page up and page down shortcuts (#8910) feat: add page up and page down shortcuts --- web/src/lib/components/photos-page/asset-grid.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index a84b9d4d73..a3f4c51563 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -106,6 +106,8 @@ { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) }, + { shortcut: { key: 'PageUp' }, onShortcut: () => (element.scrollTop = 0) }, + { shortcut: { key: 'PageDown' }, onShortcut: () => (element.scrollTop = viewport.height) }, ]; if ($isMultiSelectState) { From 596c35dc00200d98ef71c9c6c05d1b7b9806eb3a Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 18 Apr 2024 21:37:55 -0400 Subject: [PATCH 12/46] fix(server): skip invisible assets for thumbnail generation and ml (#8891) * skip invisible assets for thumbnail generation and ml * no need to update job status * fix thumbhash check order * linting --- server/src/services/media.service.spec.ts | 27 ++++++++++++++++ server/src/services/media.service.ts | 20 ++++++++++-- server/src/services/person.service.ts | 11 ++++++- .../src/services/smart-info.service.spec.ts | 32 ++++++++++--------- server/src/services/smart-info.service.ts | 6 +++- 5 files changed, 77 insertions(+), 19 deletions(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 1b1adcd573..c6301c7c33 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -225,6 +225,15 @@ describe(MediaService.name, () => { expect(assetMock.update).not.toHaveBeenCalledWith(); }); + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + }); + it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]); assetMock.getByIds.mockResolvedValue([assetStub.image]); @@ -353,6 +362,15 @@ describe(MediaService.name, () => { expect(assetMock.update).not.toHaveBeenCalledWith(); }); + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + }); + it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { @@ -410,6 +428,15 @@ describe(MediaService.name, () => { expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); }); + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + }); + it('should generate a thumbhash', async () => { const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); assetMock.getByIds.mockResolvedValue([assetStub.image]); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 47fa31abcc..ca72b6cbdd 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -77,7 +77,7 @@ export class MediaService { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination) + ? this.assetRepository.getAll(pagination, { isVisible: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); }); @@ -178,6 +178,10 @@ export class MediaService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); await this.assetRepository.update({ id: asset.id, previewPath }); return JobStatus.SUCCESS; @@ -230,6 +234,10 @@ export class MediaService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); await this.assetRepository.update({ id: asset.id, thumbnailPath }); return JobStatus.SUCCESS; @@ -237,7 +245,15 @@ export class MediaService { async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> { const [asset] = await this.assetRepository.getByIds([id]); - if (!asset?.previewPath) { + if (!asset) { + return JobStatus.FAILED; + } + + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + + if (!asset.previewPath) { return JobStatus.FAILED; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 2cd3cd88a9..721f2586ee 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -292,7 +292,12 @@ export class PersonService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true, withArchived: true }) + ? this.assetRepository.getAll(pagination, { + orderDirection: 'DESC', + withFaces: true, + withArchived: true, + isVisible: true, + }) : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); }); @@ -322,6 +327,10 @@ export class PersonService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + const faces = await this.machineLearningRepository.detectFaces( machineLearning.url, { imagePath: asset.previewPath }, diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 4d85c00253..8dedcb5c5f 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,8 +1,7 @@ -import { AssetEntity } from 'src/entities/asset.entity'; import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; @@ -19,11 +18,6 @@ import { newSearchRepositoryMock } from 'test/repositories/search.repository.moc import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { Mocked } from 'vitest'; -const asset = { - id: 'asset-1', - previewPath: 'path/to/resize.ext', -} as AssetEntity; - describe(SmartInfoService.name, () => { let sut: SmartInfoService; let assetMock: Mocked<IAssetRepository>; @@ -44,7 +38,7 @@ describe(SmartInfoService.name, () => { loggerMock = newLoggerRepositoryMock(); sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock, loggerMock); - assetMock.getByIds.mockResolvedValue([asset]); + assetMock.getByIds.mockResolvedValue([assetStub.image]); }); it('should work', () => { @@ -92,17 +86,16 @@ describe(SmartInfoService.name, () => { it('should do nothing if machine learning is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); - await sut.handleEncodeClip({ id: '123' }); + expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled(); }); it('should skip assets without a resize path', async () => { - const asset = { previewPath: '' } as AssetEntity; - assetMock.getByIds.mockResolvedValue([asset]); + assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleEncodeClip({ id: asset.id }); + expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); expect(searchMock.upsert).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled(); @@ -111,14 +104,23 @@ describe(SmartInfoService.name, () => { it('should save the returned objects', async () => { machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); - await sut.handleEncodeClip({ id: asset.id }); + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(machineMock.encodeImage).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - { imagePath: 'path/to/resize.ext' }, + { imagePath: assetStub.image.previewPath }, { enabled: true, modelName: 'ViT-B-32__openai' }, ); - expect(searchMock.upsert).toHaveBeenCalledWith('asset-1', [0.01, 0.02, 0.03]); + expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); + }); + + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(searchMock.upsert).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 9de5edbd88..929d15beca 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -60,7 +60,7 @@ export class SmartInfoService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination) + ? this.assetRepository.getAll(pagination, { isVisible: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.SMART_SEARCH); }); @@ -84,6 +84,10 @@ export class SmartInfoService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + if (!asset.previewPath) { return JobStatus.FAILED; } From d2b5cc6a4abd2a8dde899e76294f38b473bbdef1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Apr 2024 22:44:37 -0400 Subject: [PATCH 13/46] chore(deps): update registry.hub.docker.com/library/redis:6.2-alpine docker digest to 84882e8 (#8913) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9516ea9ca7..731bd4c90a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -58,7 +58,7 @@ services: redis: container_name: immich_redis - image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 + image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 restart: always database: From efd8f0d6487082cb75ae94f04112155bf702462d Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Fri, 19 Apr 2024 04:55:11 +0200 Subject: [PATCH 14/46] fix(web): notification number of people when editing faces (#7352) * fix: notification number of people when editing faces * fix: lint * fix: use id instead of index * rename --- .../faces-page/assign-face-side-panel.svelte | 8 +- .../faces-page/person-side-panel.svelte | 147 ++++++++++-------- 2 files changed, 88 insertions(+), 67 deletions(-) diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index ce7212f950..7f48a9eae8 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -21,7 +21,7 @@ export let peopleWithFaces: AssetFaceResponseDto[]; export let allPeople: PersonResponseDto[]; - export let editedPersonIndex: number; + export let editedPerson: PersonResponseDto; export let assetType: AssetTypeEnum; export let assetId: string; @@ -106,7 +106,7 @@ const handleCreatePerson = async () => { const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); - const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id); + const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id); const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null; @@ -229,7 +229,7 @@ <div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto"> {#if searchName == ''} {#each allPeople as person (person.id)} - {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} + {#if person.id !== editedPerson.id} <div class="w-fit"> <button class="w-[90px]" on:click={() => dispatch('reassign', person)}> <div class="relative"> @@ -255,7 +255,7 @@ {/each} {:else} {#each searchedPeople as person (person.id)} - {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} + {#if person.id !== editedPerson.id} <div class="w-fit"> <button class="w-[90px]" on:click={() => dispatch('reassign', person)}> <div class="relative"> diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 3cb705a1f3..489cfeb170 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -28,14 +28,14 @@ export let assetType: AssetTypeEnum; // keep track of the changes - let numberOfPersonToCreate: string[] = []; - let numberOfAssetFaceGenerated: string[] = []; + let peopleToCreate: string[] = []; + let assetFaceGenerated: string[] = []; // faces let peopleWithFaces: AssetFaceResponseDto[] = []; - let selectedPersonToReassign: (PersonResponseDto | null)[]; - let selectedPersonToCreate: (string | null)[]; - let editedPersonIndex: number; + let selectedPersonToReassign: Record<string, PersonResponseDto> = {}; + let selectedPersonToCreate: Record<string, string> = {}; + let editedPerson: PersonResponseDto; // loading spinners let isShowLoadingDone = false; @@ -49,6 +49,8 @@ let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>; let automaticRefreshTimeout: ReturnType<typeof setTimeout>; + const thumbnailWidth = '90px'; + const dispatch = createEventDispatcher<{ close: void; refresh: void; @@ -60,8 +62,6 @@ const { people } = await getAllPeople({ withHidden: true }); allPeople = people; peopleWithFaces = await getFaces({ id: assetId }); - selectedPersonToCreate = Array.from({ length: peopleWithFaces.length }); - selectedPersonToReassign = Array.from({ length: peopleWithFaces.length }); } catch (error) { handleError(error, "Can't get faces"); } finally { @@ -71,12 +71,12 @@ } const onPersonThumbnail = (personId: string) => { - numberOfAssetFaceGenerated.push(personId); + assetFaceGenerated.push(personId); if ( - isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) && + isEqual(assetFaceGenerated, peopleToCreate) && loaderLoadingDoneTimeout && automaticRefreshTimeout && - selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length + Object.keys(selectedPersonToCreate).length === peopleToCreate.length ) { clearTimeout(loaderLoadingDoneTimeout); clearTimeout(automaticRefreshTimeout); @@ -97,36 +97,41 @@ dispatch('close'); }; - const handleReset = (index: number) => { - if (selectedPersonToReassign[index]) { - selectedPersonToReassign[index] = null; + const handleReset = (id: string) => { + if (selectedPersonToReassign[id]) { + delete selectedPersonToReassign[id]; + + // trigger reactivity + selectedPersonToReassign = selectedPersonToReassign; } - if (selectedPersonToCreate[index]) { - selectedPersonToCreate[index] = null; + if (selectedPersonToCreate[id]) { + delete selectedPersonToCreate[id]; + + // trigger reactivity + selectedPersonToCreate = selectedPersonToCreate; } }; const handleEditFaces = async () => { loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner); - const numberOfChanges = - selectedPersonToCreate.filter((person) => person !== null).length + - selectedPersonToReassign.filter((person) => person !== null).length; + const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length; + if (numberOfChanges > 0) { try { - for (const [index, peopleWithFace] of peopleWithFaces.entries()) { - const personId = selectedPersonToReassign[index]?.id; + for (const personWithFace of peopleWithFaces) { + const personId = selectedPersonToReassign[personWithFace.id]?.id; if (personId) { await reassignFacesById({ id: personId, - faceDto: { id: peopleWithFace.id }, + faceDto: { id: personWithFace.id }, }); - } else if (selectedPersonToCreate[index]) { + } else if (selectedPersonToCreate[personWithFace.id]) { const data = await createPerson({ personCreateDto: {} }); - numberOfPersonToCreate.push(data.id); + peopleToCreate.push(data.id); await reassignFacesById({ id: data.id, - faceDto: { id: peopleWithFace.id }, + faceDto: { id: personWithFace.id }, }); } } @@ -141,7 +146,7 @@ } isShowLoadingDone = false; - if (numberOfPersonToCreate.length === 0) { + if (peopleToCreate.length === 0) { clearTimeout(loaderLoadingDoneTimeout); dispatch('refresh'); } else { @@ -150,23 +155,26 @@ }; const handleCreatePerson = (newFeaturePhoto: string | null) => { - const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id); + const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id); if (newFeaturePhoto && personToUpdate) { - selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto; + selectedPersonToCreate[personToUpdate.id] = newFeaturePhoto; } showSeletecFaces = false; }; const handleReassignFace = (person: PersonResponseDto | null) => { - if (person) { - selectedPersonToReassign[editedPersonIndex] = person; + const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id); + if (person && personToUpdate) { + selectedPersonToReassign[personToUpdate.id] = person; showSeletecFaces = false; } }; - const handlePersonPicker = (index: number) => { - editedPersonIndex = index; - showSeletecFaces = true; + const handlePersonPicker = (person: PersonResponseDto | null) => { + if (person) { + editedPerson = person; + showSeletecFaces = true; + } }; </script> @@ -217,35 +225,48 @@ on:mouseleave={() => ($boundingBoxesArray = [])} > <div class="relative"> - <ImageThumbnail - curve - shadow - url={selectedPersonToCreate[index] || - getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)} - altText={selectedPersonToReassign[index] - ? selectedPersonToReassign[index]?.name - : selectedPersonToCreate[index] - ? 'New person' - : getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)} - title={selectedPersonToReassign[index] - ? selectedPersonToReassign[index]?.name - : selectedPersonToCreate[index] - ? 'New person' - : getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)} - widthStyle="90px" - heightStyle="90px" - thumbhash={null} - hidden={selectedPersonToReassign[index] - ? selectedPersonToReassign[index]?.isHidden - : selectedPersonToCreate[index] - ? false - : face.person?.isHidden} - /> + {#if selectedPersonToCreate[face.id]} + <ImageThumbnail + curve + shadow + url={selectedPersonToCreate[face.id]} + altText={selectedPersonToCreate[face.id]} + title={'New person'} + widthStyle={thumbnailWidth} + heightStyle={thumbnailWidth} + /> + {:else if selectedPersonToReassign[face.id]} + <ImageThumbnail + curve + shadow + url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)} + altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id} + title={getPersonNameWithHiddenValue( + selectedPersonToReassign[face.id].name, + face.person?.isHidden, + )} + widthStyle={thumbnailWidth} + heightStyle={thumbnailWidth} + hidden={selectedPersonToReassign[face.id].isHidden} + /> + {:else} + <ImageThumbnail + curve + shadow + url={getPeopleThumbnailUrl(face.person.id)} + altText={face.person.name || face.person.id} + title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)} + widthStyle={thumbnailWidth} + heightStyle={thumbnailWidth} + hidden={face.person.isHidden} + /> + {/if} </div> - {#if !selectedPersonToCreate[index]} + + {#if !selectedPersonToCreate[face.id]} <p class="relative mt-1 truncate font-medium" title={face.person?.name}> - {#if selectedPersonToReassign[index]?.id} - {selectedPersonToReassign[index]?.name} + {#if selectedPersonToReassign[face.id]?.id} + {selectedPersonToReassign[face.id]?.name} {:else} {face.person?.name} {/if} @@ -253,8 +274,8 @@ {/if} <div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700"> - {#if selectedPersonToCreate[index] || selectedPersonToReassign[index]} - <button on:click={() => handleReset(index)} class="flex h-full w-full"> + {#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} + <button on:click={() => handleReset(face.id)} class="flex h-full w-full"> <div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"> <div> <Icon path={mdiRestart} size={18} /> @@ -262,7 +283,7 @@ </div> </button> {:else} - <button on:click={() => handlePersonPicker(index)} class="flex h-full w-full"> + <button on:click={() => handlePersonPicker(face.person)} class="flex h-full w-full"> <div class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white" /> @@ -282,7 +303,7 @@ <AssignFaceSidePanel {peopleWithFaces} {allPeople} - {editedPersonIndex} + {editedPerson} {assetType} {assetId} on:close={() => (showSeletecFaces = false)} From e72e41a7aa5374ce763b7b779aed65f2ce761e01 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Apr 2024 12:46:59 +0200 Subject: [PATCH 15/46] chore(deps): update redis:6.2-alpine docker digest to 84882e8 (#8912) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- e2e/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8b85b00284..1b56637baf 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -97,7 +97,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 + image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 database: container_name: immich_postgres diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 7f1f0f1a41..49b375b5f6 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -54,7 +54,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 + image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 restart: always database: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index e10f22156f..85b201f98c 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -36,7 +36,7 @@ services: <<: *server-common redis: - image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 + image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 From 4478e524f8d20fd7ebd78b15a0cdc7dc4ec4829d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Fri, 19 Apr 2024 06:47:29 -0400 Subject: [PATCH 16/46] refactor(server): sessions (#8915) * refactor: auth device => sessions * chore: open api --- e2e/src/api/specs/auth.e2e-spec.ts | 69 +---- e2e/src/api/specs/session.e2e-spec.ts | 75 ++++++ e2e/src/utils.ts | 2 +- mobile/openapi/.openapi-generator/FILES | 9 +- mobile/openapi/README.md | Bin 26434 -> 26368 bytes mobile/openapi/doc/AuthenticationApi.md | Bin 13955 -> 8231 bytes ...ceResponseDto.md => SessionResponseDto.md} | Bin 574 -> 571 bytes mobile/openapi/doc/SessionsApi.md | Bin 0 -> 5829 bytes mobile/openapi/lib/api.dart | Bin 9055 -> 9081 bytes .../openapi/lib/api/authentication_api.dart | Bin 11601 -> 8141 bytes mobile/openapi/lib/api/sessions_api.dart | Bin 0 -> 3834 bytes mobile/openapi/lib/api_client.dart | Bin 25027 -> 25021 bytes ...nse_dto.dart => session_response_dto.dart} | Bin 4098 -> 4044 bytes .../openapi/test/authentication_api_test.dart | Bin 1507 -> 1153 bytes ...st.dart => session_response_dto_test.dart} | Bin 1084 -> 1075 bytes mobile/openapi/test/sessions_api_test.dart | Bin 0 -> 800 bytes open-api/immich-openapi-specs.json | 248 +++++++++--------- open-api/typescript-sdk/src/fetch-client.ts | 60 ++--- server/src/controllers/auth.controller.ts | 21 +- server/src/controllers/index.ts | 2 + server/src/controllers/session.controller.ts | 31 +++ server/src/dtos/auth.dto.ts | 22 +- server/src/dtos/session.dto.ts | 19 ++ server/src/entities/index.ts | 4 +- ...user-token.entity.ts => session.entity.ts} | 4 +- server/src/interfaces/session.interface.ts | 11 + server/src/interfaces/user-token.interface.ts | 11 - .../1713490844785-RenameSessionsTable.ts | 15 ++ server/src/queries/access.repository.sql | 8 +- server/src/queries/session.repository.sql | 48 ++++ server/src/queries/user.token.repository.sql | 48 ---- server/src/repositories/access.repository.ts | 10 +- server/src/repositories/index.ts | 6 +- ...en.repository.ts => session.repository.ts} | 20 +- server/src/services/auth.service.spec.ts | 103 ++------ server/src/services/auth.service.ts | 55 ++-- server/src/services/index.ts | 2 + server/src/services/session.service.spec.ts | 77 ++++++ server/src/services/session.service.ts | 41 +++ server/test/fixtures/auth.stub.ts | 14 +- .../{user-token.stub.ts => session.stub.ts} | 8 +- .../repositories/session.repository.mock.ts | 12 + .../user-token.repository.mock.ts | 12 - .../user-settings-page/device-card.svelte | 4 +- .../user-settings-page/device-list.svelte | 14 +- .../user-settings-list.svelte | 8 +- .../routes/(user)/user-settings/+page.svelte | 2 +- web/src/routes/(user)/user-settings/+page.ts | 6 +- 48 files changed, 595 insertions(+), 506 deletions(-) create mode 100644 e2e/src/api/specs/session.e2e-spec.ts rename mobile/openapi/doc/{AuthDeviceResponseDto.md => SessionResponseDto.md} (93%) create mode 100644 mobile/openapi/doc/SessionsApi.md create mode 100644 mobile/openapi/lib/api/sessions_api.dart rename mobile/openapi/lib/model/{auth_device_response_dto.dart => session_response_dto.dart} (70%) rename mobile/openapi/test/{auth_device_response_dto_test.dart => session_response_dto_test.dart} (88%) create mode 100644 mobile/openapi/test/sessions_api_test.dart create mode 100644 server/src/controllers/session.controller.ts create mode 100644 server/src/dtos/session.dto.ts rename server/src/entities/{user-token.entity.ts => session.entity.ts} (92%) create mode 100644 server/src/interfaces/session.interface.ts delete mode 100644 server/src/interfaces/user-token.interface.ts create mode 100644 server/src/migrations/1713490844785-RenameSessionsTable.ts create mode 100644 server/src/queries/session.repository.sql delete mode 100644 server/src/queries/user.token.repository.sql rename server/src/repositories/{user-token.repository.ts => session.repository.ts} (54%) create mode 100644 server/src/services/session.service.spec.ts create mode 100644 server/src/services/session.service.ts rename server/test/fixtures/{user-token.stub.ts => session.stub.ts} (72%) create mode 100644 server/test/repositories/session.repository.mock.ts delete mode 100644 server/test/repositories/user-token.repository.mock.ts diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts index 28445f79d9..4a6e1a773a 100644 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ b/e2e/src/api/specs/auth.e2e-spec.ts @@ -1,7 +1,7 @@ -import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk'; -import { loginDto, signupDto, uuidDto } from 'src/fixtures'; -import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; +import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk'; +import { loginDto, signupDto } from 'src/fixtures'; +import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses'; +import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -118,67 +118,6 @@ describe('/auth/*', () => { }); }); - describe('GET /auth/devices', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/auth/devices'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should get a list of authorized devices', async () => { - const { status, body } = await request(app) - .get('/auth/devices') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual([deviceDto.current]); - }); - }); - - describe('DELETE /auth/devices', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/auth/devices`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should logout all devices (except the current one)', async () => { - for (let i = 0; i < 5; i++) { - await login({ loginCredentialDto: loginDto.admin }); - } - - await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6); - - const { status } = await request(app).delete(`/auth/devices`).set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); - - await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1); - }); - - it('should throw an error for a non-existent device id', async () => { - const { status, body } = await request(app) - .delete(`/auth/devices/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access')); - }); - - it('should logout a device', async () => { - const [device] = await getAuthDevices({ - headers: asBearerAuth(admin.accessToken), - }); - const { status } = await request(app) - .delete(`/auth/devices/${device.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); - - const response = await request(app) - .post('/auth/validateToken') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(response.body).toEqual(errorDto.invalidToken); - expect(response.status).toBe(401); - }); - }); - describe('POST /auth/validateToken', () => { it('should reject an invalid token', async () => { const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123'); diff --git a/e2e/src/api/specs/session.e2e-spec.ts b/e2e/src/api/specs/session.e2e-spec.ts new file mode 100644 index 0000000000..0b632f78ba --- /dev/null +++ b/e2e/src/api/specs/session.e2e-spec.ts @@ -0,0 +1,75 @@ +import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk'; +import { loginDto, signupDto, uuidDto } from 'src/fixtures'; +import { deviceDto, errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('/sessions', () => { + let admin: LoginResponseDto; + + beforeEach(async () => { + await utils.resetDatabase(); + await signUpAdmin({ signUpDto: signupDto.admin }); + admin = await login({ loginCredentialDto: loginDto.admin }); + }); + + describe('GET /sessions', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/sessions'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get a list of authorized devices', async () => { + const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([deviceDto.current]); + }); + }); + + describe('DELETE /sessions', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/sessions`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should logout all devices (except the current one)', async () => { + for (let i = 0; i < 5; i++) { + await login({ loginCredentialDto: loginDto.admin }); + } + + await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6); + + const { status } = await request(app).delete(`/sessions`).set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1); + }); + + it('should throw an error for a non-existent device id', async () => { + const { status, body } = await request(app) + .delete(`/sessions/${uuidDto.notFound}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access')); + }); + + it('should logout a device', async () => { + const [device] = await getSessions({ + headers: asBearerAuth(admin.accessToken), + }); + const { status } = await request(app) + .delete(`/sessions/${device.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + const response = await request(app) + .post('/auth/validateToken') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(response.body).toEqual(errorDto.invalidToken); + expect(response.status).toBe(401); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 617f2d62cc..0047502023 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -140,7 +140,7 @@ export const utils = { 'asset_faces', 'activity', 'api_keys', - 'user_token', + 'sessions', 'users', 'system_metadata', 'system_config', diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index b296bbcb55..2181476b3a 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -41,7 +41,6 @@ doc/AssetTypeEnum.md doc/AudioCodec.md doc/AuditApi.md doc/AuditDeletesResponseDto.md -doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/BulkIdResponseDto.md doc/BulkIdsDto.md @@ -142,6 +141,8 @@ doc/ServerPingResponse.md doc/ServerStatsResponseDto.md doc/ServerThemeDto.md doc/ServerVersionResponseDto.md +doc/SessionResponseDto.md +doc/SessionsApi.md doc/SharedLinkApi.md doc/SharedLinkCreateDto.md doc/SharedLinkEditDto.md @@ -219,6 +220,7 @@ lib/api/partner_api.dart lib/api/person_api.dart lib/api/search_api.dart lib/api/server_info_api.dart +lib/api/sessions_api.dart lib/api/shared_link_api.dart lib/api/sync_api.dart lib/api/system_config_api.dart @@ -267,7 +269,6 @@ lib/model/asset_stats_response_dto.dart lib/model/asset_type_enum.dart lib/model/audio_codec.dart lib/model/audit_deletes_response_dto.dart -lib/model/auth_device_response_dto.dart lib/model/bulk_id_response_dto.dart lib/model/bulk_ids_dto.dart lib/model/change_password_dto.dart @@ -357,6 +358,7 @@ lib/model/server_ping_response.dart lib/model/server_stats_response_dto.dart lib/model/server_theme_dto.dart lib/model/server_version_response_dto.dart +lib/model/session_response_dto.dart lib/model/shared_link_create_dto.dart lib/model/shared_link_edit_dto.dart lib/model/shared_link_response_dto.dart @@ -448,7 +450,6 @@ test/asset_type_enum_test.dart test/audio_codec_test.dart test/audit_api_test.dart test/audit_deletes_response_dto_test.dart -test/auth_device_response_dto_test.dart test/authentication_api_test.dart test/bulk_id_response_dto_test.dart test/bulk_ids_dto_test.dart @@ -549,6 +550,8 @@ test/server_ping_response_test.dart test/server_stats_response_dto_test.dart test/server_theme_dto_test.dart test/server_version_response_dto_test.dart +test/session_response_dto_test.dart +test/sessions_api_test.dart test/shared_link_api_test.dart test/shared_link_create_dto_test.dart test/shared_link_edit_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 730307b9bf97178c38f81192e317866f372babdd..7fb4681f792960b00efc67e22aa2dedc934a6c4a 100644 GIT binary patch delta 344 zcmX?fj<Mk!;|6A_$;DE8H>=8=5wA}zF3!x)D|Rf%)KaKXh}P0dNzF+uNp;N035H5( zX~k-!<R|Ov!vuh8^>R~`Aqo?7a*Ck}HGu|bX}P%ixQ4iDX({N##DT(GT1c9aEviq2 zO5nEzY7{OT^s6&dYH?VTo>~I48;c#PV6OnxKy4_7SmEv(g6xCIj;4~ESxokdZvO50 VUVsha-^qayB8<hGFGdye0008bc?19e delta 290 zcmZoT$9U)*;|69azVy@*$I_Avm(;S%<kaHHr={f7fkKHup%jRarb3N^mX^C~h?bUu zK1f_2Dls`%Qg$-4)ZWPnQfyoZGeAZd=H#d6mm--0F~r5y$2G(i-5mYu%#>QNVL*ep z5e9)Bql<1*F+QU}rffbSbw+%0xvB8x6UKW*)ez<dr4|?D=M|^Al;p>1q~s^->m%eg W7kV-aPQDk#Ir(%H=jL5eZae@)H)q`d diff --git a/mobile/openapi/doc/AuthenticationApi.md b/mobile/openapi/doc/AuthenticationApi.md index 9521568e9d303f2d70977240ea93829b4f030777..02fb94a092a48d27ba3348ff2b2cdf3d89fd44be 100644 GIT binary patch delta 26 icmZq9UGA{KmT~eV#`euOn6`3k-XL_8Yx6Aams|jvGz)A1 delta 794 zcmb7?%}WA77{*zUl98lC9XzCO2(=E_t{Ksn(T5U&7*r4x5$cRKV7uas!J?g_YXozT zZXJvE(xK?oxj&#&r%wG3?P_E$A~eTmhTr=<&pWp(=O_0mC0M3nwJwg+^pq7Tmv2gF z(vk9xMA}jent<^}W*1}7TwVLQkh99=aK$W}^*;m`Ag`5y^$wv8NN2K{-Hex^H5fT_ z2A~6sy{MKMmo?d8@qRG-S$XoOx&vX1mLbcySX%u}xlMW1G&oHQa~Zmy+gd2+^LBDI zw(pfb20@O6S7Lq07$2&gneyHLAooT-Z4133)f=_t(krTr*m`Kf$4bn|SAY>nL2_<J zcTaq-SkmK(M1jN$(@7dn=!N-NIzvmkP8LDaU`N!LQFfa}qGmw@fphXzcEk)W@<x$P z^%y7b0-HnK>--qJSYyOloBIjcP>h?<0!O1!*ruXhGeB5XiqNU)m{jRWRaj8_DH#eT zvE)toC6F@(5bA`XM4FLDxfMj?oq~AJ4!E<BX<wwTajv?)gI&J4Y;PzP`j;nrVe|`q E0}nM8g8%>k diff --git a/mobile/openapi/doc/AuthDeviceResponseDto.md b/mobile/openapi/doc/SessionResponseDto.md similarity index 93% rename from mobile/openapi/doc/AuthDeviceResponseDto.md rename to mobile/openapi/doc/SessionResponseDto.md index 4433e33385aa8bef132d232242df04a913f2e52a..9d1a11cbce6d5dc75c9a7d75823394925c7116a4 100644 GIT binary patch delta 32 ncmdnTvYSO*Ss}k5H7~IsQ!h6^B{fGcIJLMqGe2*mlnN67w;c-# delta 35 qcmdnZvX4bdSs}k5H7~IsQ!h6^B{fISv9u(^CABOwId!AB3KIa@y9@{b diff --git a/mobile/openapi/doc/SessionsApi.md b/mobile/openapi/doc/SessionsApi.md new file mode 100644 index 0000000000000000000000000000000000000000..d082a8cfed186e66617b1aeb6807cd62a1a71104 GIT binary patch literal 5829 zcmeHLYfsxq6#YKG;!3F$$EqD@t5p|Tq!heu#ZwSIR4f9=zDZ_rJgz-y$x{A%&v+&f zQrOa^m6la3A>#3!`<`=r-5^zP9v3n+e+OJuQsq^rkU}&Xw58&d^pL5u_nwM4Ig1A@ zPESwMxYR<93RP-qy7d*OsBIJ(f##|a6{3@6bh!V%qPS!#Ig7RY#H1DZM${K#oApqo z^o8DayL*uMIOa;@c7v;=l!b;zBAC7KZ1y&ozZXZopK`{UVS5^r?;m^9--s_9r41_+ zXIbUqZb6gpZ$w*BH}ZXIPuH=0t<x#FQ=i*(m#+4OOfQ|n0c)qNx<wtHP93ny-bUSd zuTUm<d`qxmjlNm+S^`%Cg^kYRQIY*nE!%DC?r!YTy2|@<Fh(s+Q=IXH;xX!1%D+)q zm9J2u)R|<_Zl~Oj$C*a5*E7kv4iQiXviK>JTKE;7ZSuq{u6a$nvDq@Mh*f$`%@4bW z`zNq-vKf6qKf#A_o~Y3XDyYYq`fU2BVm&UXD7i1s$>VSk(i=vzl`ZP4a_Z(&l6}gV zQ%)c5+nzNB53MEZ+x_x`=uVKff8m7N`QOaYymR+TQGMcV3g_FXSC`O~d_At1mP0zk zzXM&$d_Y}w#`*m+_)hD>T5xS<6(fo0n@*D8=-1jj*KF(S(ZRt<cXu<|X}0Q?tL;Ct z_WwZtnKq}-LzxU|z&V$(HgE=qLgTITaH7U#t@o`JQRQ^2*P;sw!;mh2asYRmL>!_o z^Ee|@C7D+`&J)G~o1#yrke(6J<%BK-;hGf;Z$Xk(aY;DD$brWQsNPecHri`O=Ly%S zjU;gnsVp-!o3RzFeWP)Op75W!$@rzE4FgkqaT$*Q<7FkfLs=2$X`x_)QpFYJO4kRP z;(P*ja-yL{xP5>^nUkIrEW{@zQ}{V5^aT7;6`(k#o=PVGL}SNv#ub7Iab%I`*bBq< ze$?66jtpDa#nw|ga;-l1+^re`+hTLi?V_`IgX)GHrzS>00l*vptATGp7q0vM3hf|F zK&i|DT(y8{+CtAi5=TdGz|aN|wJMH}y+(_!mkd^CveH<;5mc&@u|d-sjsw7DfG~n- zo~v0E+X_Xyr;?G8y|LToan8I+#ua_J{_y=P>T)ZdOc%tpAg*6U?e5%OvFd`j9v^X8 zas$Y}rspqTESW2Kne>-}<>g))^qxKEV0o$c`)~Y$`%6pdDqRybUhV&x_GVD%r;wi0 z_dUWBJD@(y35EvnZ)O-G+=3Li`mw19yKYQP0>~><3>0SOv2QKD|HUX@Fz91r&>Mp! z_;U+X|H?JmzI)->&}t1YtXC7~Vj5gbgTHKEUXbelY8tfsiqQ$*bHK5ty3;AreARJC zQ<)jk43BmYwBlx>d&s0XqyBr8`JfpQ-wf|U&6#g=Ghm9D@R5&?aZzOMOI-V}3SU_t H=?wW5=2(+f literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e7320d5bb2d7087f33807022a34137d77e80262e..b484d38b688e705f5bd013784b819cbb49784f8c 100644 GIT binary patch delta 37 tcmccb_S0>{Z5H;_;^NHwyyD3Zn8h~VVzFV{?8KeI%amHYxk2JS7XUSB4&49% delta 37 tcmezAcHeEoZI;bHS*+Q(5=%=m;!{$~GLt74iYaY2;ZEV*d_ZC+7XUWM4)_27 diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index d1f04d600ed8c91ce309aa15e3afe2ff0f6cee14..62f8be353a69ed9cdf77b6ab4c83cd08af8586f3 100644 GIT binary patch delta 21 dcmcZ@b=H1EG{<IrzAfC7_4&9r&r{sY4FF-#2n7HD delta 705 zcmX?We=%x9G>4$OYlwn=VrfZ+eoAUtW^!ur<ZKSD$;|wMeCeqrjzDo2sIU}&Bs)~e zW?pGYMkaUH$^I;pP25ULN{do$d@_qmY!K>$Qi}`n^NLelO7iXO?G#YW(wJN*u8L;< zW&_#Rj3Q~7d5Jj+MNrL7`6-nOwv!bk6a}og6u_V;6=++Yg2v>zl8W`n>U0#;@i;+U zQz5Zf0i-|^YLJ40UUDK%Cu<-nE6E4x(?~DMFHcO$Nwrc)OUx-w)wJfCyk1^r@_Ih? z&6ym_xFua&eOyCaQNvQdIy0pf7@(e9!6ikRdFinVnJJU!af?m<z`?7WUy2mI;h7~F z9wj9Oo_T5c8W6=GwHujywV{CyHcK7qdcC65f}F(URL7hg4Rw$u>N-H>nw$BgB^cQt zE}0y_vJuT%g|ht26g!2S{B#t%k?boFSDTzK$R>%1NEC+wU38Ik>f|rN(jwTr3r#xW m+z9Q(lhM-+!g-VAp=k%Zvx>pV1mqkP^9*DcZvG;ClN$gmIPb^+ diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..bc0fed71e1352fb12ea98ddb46f71daa9f24e509 GIT binary patch literal 3834 zcmeHKZExE)5dQ98aqWlN9yGfP`eASrdv2n%0kSwtTogkPxQunSxyhtTQgOW?|9y9) zB+HxA1z3S)Km$Y;U+(=m@=>?j=yqZFemwgAZG0AA49D>i9G{%V5gd)+Y&3?SM@J{e zzdoaFyz{3-7<aJO`C+fYLpfL4;AE!FWF}MWL9TL($plkeqH<9=mdi{Vw^Kr-E3vkd zEO!%wpK@tPT!|$;Ghyi0p|Pag*xlk&t*jHuO=Lm_<xHaS;&!L8R4TNSRI52{a|@v+ zKfaty7sA*^gUmV5Gte0-k;xAIziu=d(^OasmuRgdQ^QQcT?6Xl))h@2rjpDLfP}*9 zbsKt{5ggRs2LniOCi2u(f@BC_CY4B`n%hYD)h?y`91W#<X+bD>Hy)ou^C&)z$1!wm z;a`*8H^Pyxv?&3DE;q`8TOeFrpv@=-yzY>-<D6D-zntrnPbnpPn5Jc{AEaBnb1pkk zGu>)~uxmB-!2?vK??Gm8M#=Pbm?TK)3g#`@C}YZmTTlv2bK|FvX_q3O1uD^0q(Q~# z<~N*DYBO!!8=b5R$&js2Ihyr31I3bo{a>nK|LXd6&4UGs1kGmSr882^BM86!yZicP z&hZzwdc{6spr@nCkJlM`4+@2Boofn31vbU(NOh}!$Km-&%dZ1)cB10FC}^`B*WxRu z$~=_gdT8a`CW3ON?q^5Zc$v913_#^+N<iIO_01OK+MJeq4y1s~t(M6SsQd*Up*E&~ z8-sr(r28j?g%Zdav`WbiwYJOcp-vF`2f$#w^fR3skzCMef!4JPNCIKp=oR-*{CSEQ zx4<q%QQ-_@mGm;A-mD$kYH!;I&0!azKd$q-cQUzueEWH&)dANdTQn1%@Eu$Q7+r%* zI2++uZ1b)M+P3J~t(FZsNJaFOn>U0?&hTHyV3vw09~`Zw&p|{cDTjXj?+3-V{-y)_ z>Gk)&#AE$jD6rUi2!CHZ9d-ek5&Zu;9Nxy`uW~2P(S5s(|HIpu>iG0pwYUOKrFHuS zI!YFI<aB?~hZ<}icQe-nwyuz;*mj;Yepnz8Tq0LX?GP-QTQn8fRT^&ST89-8SlSdw z1XKIHm#-pN(f)-X+M_g65+(s49aCbjdFVV@c5;a<vNF;UQ=~a*BlyUlKRsegWX^pg z1EeO<4r1ib-z)r_5m&_okhnKVOGV!`&r8+Egk<3=b^<F7!kM+#uS;J@6MA-LJx2|W zZLuN_jyT+zc7evdctN2DYF}qe&$bwo(uaYnZV1!%LCj33qG}Jbqi_&cytWmnUG?45 zNsy``MJoTmA%8Jcyo&Wl(0mBCO(rS8tL=hW5Vx3$-577x_BhQ#tvQiDal_xBGx~DC Rl7KLatDI%F8yM|<<4=qc<)Z)q literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4bbae892858cb0cd554b692d818ca004239c4950..0a0cd80088bbdd96df0fef2da6db126d89dea032 100644 GIT binary patch delta 46 xcmX?nm~ro6#tlMhn+w#6<k(V+i!<}{CLh$0XAg$5Cn{)9e(%b)`L)Xp834Bx68!)G delta 49 ucmdmcnDOvo#tlMhT#ltB87`@1naQb>CpyTZa22#S3#k>!ZNB63PzC_p0}}=S diff --git a/mobile/openapi/lib/model/auth_device_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart similarity index 70% rename from mobile/openapi/lib/model/auth_device_response_dto.dart rename to mobile/openapi/lib/model/session_response_dto.dart index f1425a221f9ccc77623a0f01b17b2116c6619a45..6a44fc24bbae862111b91bcf5331ac46d5174f30 100644 GIT binary patch delta 228 zcmZotI3vG7osm5_wYWGlKX0-jqcWTw$0!M7Z*F94fGgU}&3p^NdC&3$&Y66dZ9YuP z<Tmzt7<)56#~Os9qnr^iiOK3*g>d#FuB~u(BDWKqy_WkJoL$Sa4bHaZb%nEgc+=qQ OKfD)U?9JEsbQl2@s8rDa delta 310 zcmX>j-=wfXosrA2v?RkNwJb9^b+R6#vMz!flv-SnpI4mfQj#C5keOFpl9-pAs>h|E zfKalzg0TVFgw6k%ZlUlWuslKLPd?2yAKAh}_If1l=I`ulP*kttj6jl}%*j=V%x&b_ yip;g;c0%U1aUVnGHgbth-pR*3xtM1fvakTJD>65RHw~G4lJ^1<ck=>19Yz4tsBugH diff --git a/mobile/openapi/test/authentication_api_test.dart b/mobile/openapi/test/authentication_api_test.dart index aa2f1879d55d067d4baded369f1e3c399962cdc0..dea20ec9b1d8dda2f922863c298d90427048f17e 100644 GIT binary patch delta 16 XcmaFN-N?D2o@p`@^QX<1Sp*mXHYEkm delta 190 zcmZqVe9XO}o+%)+xWvY>v?RkNwJb9^H7K>XAV04-)uklg&Q2jcwFDtmtf8rpSX`Nx z%%uPXC8@<F8tNbtP1R%vW~0e<OrNC{a`MyjOOXuF2reng%u83uOqsl!Ns1Rm4yeme U7(+*~21q3zipt{64$O{>0I|zNTmS$7 diff --git a/mobile/openapi/test/auth_device_response_dto_test.dart b/mobile/openapi/test/session_response_dto_test.dart similarity index 88% rename from mobile/openapi/test/auth_device_response_dto_test.dart rename to mobile/openapi/test/session_response_dto_test.dart index c0cccf8d65c3c1dca8bda88eb55836e53178ab08..d704b2e5eba3d6379c1c5821b886d0a62b39517b 100644 GIT binary patch delta 44 mcmdnPv6*8-EF*hxYH@L9e%|DCMgur|A)_3Oz4;_#F%tk^Y!ANx delta 53 pcmdnYv4>+rEF+g=X-S4lYFTD->f{tg17z-OMmZ$z<^znyOaSzw60iUO diff --git a/mobile/openapi/test/sessions_api_test.dart b/mobile/openapi/test/sessions_api_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..9fc6093c1941696b29b461336c33326826519b61 GIT binary patch literal 800 zcmb7=O-sW-5Qgvh72~NDYNI`gwZ%YMDbxmRJ$PD{WEuz4-F0`P6zP9=Hfcd<@#ByU zyFBm5OdLmX49U|fySSe$CXdN#lEQR0pY$NjV3DohAxme|=QG9vc~?m5M}z*=APQG1 zm9{9>+KM$*cmuVn9ToyB+@SKk!<{xpTYpj^s}H$#!qi?^e5=W_T*(ceMq2)Rw2sZK zJ1x((a$YJgsNg_a6WT`ZPKH&a(1}W`k}>}xmAJkX`HQqJirS9QF*1LZCEjR*N*ap! z>xZd3!z09_^Km#1Kv+r);l%+j1(xU>al^z=^scFZjU?4QhtCLr!8Iva0W}lk2&3<O z_k0*JRa#w}Zs(+Brw4nl;5My&Z4nCIy~m`g4%v)Iw^cctK~t@DI|f_3hHVN4o=dK> zG;6fGw$V}6KB&9&mQ)E;{1~{W{~dNaC+BbX>PK{j*NUmv<1v)ze;f82^2g*b?JoKP D$0-2! literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8f53f838b0..bfe3ec32c9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2530,99 +2530,6 @@ ] } }, - "/auth/devices": { - "delete": { - "operationId": "logoutAuthDevices", - "parameters": [], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Authentication" - ] - }, - "get": { - "operationId": "getAuthDevices", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AuthDeviceResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Authentication" - ] - } - }, - "/auth/devices/{id}": { - "delete": { - "operationId": "logoutAuthDevice", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Authentication" - ] - } - }, "/auth/login": { "post": { "operationId": "login", @@ -5184,6 +5091,99 @@ ] } }, + "/sessions": { + "delete": { + "operationId": "deleteAllSessions", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + }, + "get": { + "operationId": "getSessions", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SessionResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + } + }, + "/sessions/{id}": { + "delete": { + "operationId": "deleteSession", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + } + }, "/shared-link": { "get": { "operationId": "getAllSharedLinks", @@ -7892,37 +7892,6 @@ ], "type": "object" }, - "AuthDeviceResponseDto": { - "properties": { - "createdAt": { - "type": "string" - }, - "current": { - "type": "boolean" - }, - "deviceOS": { - "type": "string" - }, - "deviceType": { - "type": "string" - }, - "id": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - }, - "required": [ - "createdAt", - "current", - "deviceOS", - "deviceType", - "id", - "updatedAt" - ], - "type": "object" - }, "BulkIdResponseDto": { "properties": { "error": { @@ -10049,6 +10018,37 @@ ], "type": "object" }, + "SessionResponseDto": { + "properties": { + "createdAt": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "deviceOS": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "createdAt", + "current", + "deviceOS", + "deviceType", + "id", + "updatedAt" + ], + "type": "object" + }, "SharedLinkCreateDto": { "properties": { "albumId": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 96b071f1f9..560295c94c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -346,14 +346,6 @@ export type ChangePasswordDto = { newPassword: string; password: string; }; -export type AuthDeviceResponseDto = { - createdAt: string; - current: boolean; - deviceOS: string; - deviceType: string; - id: string; - updatedAt: string; -}; export type LoginCredentialDto = { email: string; password: string; @@ -791,6 +783,14 @@ export type ServerVersionResponseDto = { minor: number; patch: number; }; +export type SessionResponseDto = { + createdAt: string; + current: boolean; + deviceOS: string; + deviceType: string; + id: string; + updatedAt: string; +}; export type SharedLinkResponseDto = { album?: AlbumResponseDto; allowDownload: boolean; @@ -1703,28 +1703,6 @@ export function changePassword({ changePasswordDto }: { body: changePasswordDto }))); } -export function logoutAuthDevices(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/auth/devices", { - ...opts, - method: "DELETE" - })); -} -export function getAuthDevices(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AuthDeviceResponseDto[]; - }>("/auth/devices", { - ...opts - })); -} -export function logoutAuthDevice({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/auth/devices/${encodeURIComponent(id)}`, { - ...opts, - method: "DELETE" - })); -} export function login({ loginCredentialDto }: { loginCredentialDto: LoginCredentialDto; }, opts?: Oazapfts.RequestOpts) { @@ -2413,6 +2391,28 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sessions", { + ...opts, + method: "DELETE" + })); +} +export function getSessions(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SessionResponseDto[]; + }>("/sessions", { + ...opts + })); +} +export function deleteSession({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 9b4e7a3bc7..f4e7666207 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,9 +1,8 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants'; import { - AuthDeviceResponseDto, AuthDto, ChangePasswordDto, LoginCredentialDto, @@ -15,7 +14,6 @@ import { import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; -import { UUIDParamDto } from 'src/validation'; @ApiTags('Authentication') @Controller('auth') @@ -41,23 +39,6 @@ export class AuthController { return this.service.adminSignUp(dto); } - @Get('devices') - getAuthDevices(@Auth() auth: AuthDto): Promise<AuthDeviceResponseDto[]> { - return this.service.getDevices(auth); - } - - @Delete('devices') - @HttpCode(HttpStatus.NO_CONTENT) - logoutAuthDevices(@Auth() auth: AuthDto): Promise<void> { - return this.service.logoutDevices(auth); - } - - @Delete('devices/:id') - @HttpCode(HttpStatus.NO_CONTENT) - logoutAuthDevice(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { - return this.service.logoutDevice(auth, id); - } - @Post('validateToken') @HttpCode(HttpStatus.OK) validateAccessToken(): ValidateAccessTokenResponseDto { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index d136a52b04..5e109f1eb3 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -16,6 +16,7 @@ import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerInfoController } from 'src/controllers/server-info.controller'; +import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { SyncController } from 'src/controllers/sync.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; @@ -43,6 +44,7 @@ export const controllers = [ PartnerController, SearchController, ServerInfoController, + SessionController, SharedLinkController, SyncController, SystemConfigController, diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts new file mode 100644 index 0000000000..552afcdf5a --- /dev/null +++ b/server/src/controllers/session.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto } from 'src/dtos/session.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { SessionService } from 'src/services/session.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Sessions') +@Controller('sessions') +@Authenticated() +export class SessionController { + constructor(private service: SessionService) {} + + @Get() + getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> { + return this.service.getAll(auth); + } + + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + deleteAllSessions(@Auth() auth: AuthDto): Promise<void> { + return this.service.deleteAll(auth); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { + return this.service.delete(auth, id); + } +} diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index f3f2270d02..4651c010b9 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { APIKeyEntity } from 'src/entities/api-key.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { UserEntity } from 'src/entities/user.entity'; export class AuthDto { @@ -11,7 +11,7 @@ export class AuthDto { apiKey?: APIKeyEntity; sharedLink?: SharedLinkEntity; - userToken?: UserTokenEntity; + session?: SessionEntity; } export class LoginCredentialDto { @@ -78,24 +78,6 @@ export class ValidateAccessTokenResponseDto { authStatus!: boolean; } -export class AuthDeviceResponseDto { - id!: string; - createdAt!: string; - updatedAt!: string; - current!: boolean; - deviceType!: string; - deviceOS!: string; -} - -export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({ - id: entity.id, - createdAt: entity.createdAt.toISOString(), - updatedAt: entity.updatedAt.toISOString(), - current: currentId === entity.id, - deviceOS: entity.deviceOS, - deviceType: entity.deviceType, -}); - export class OAuthCallbackDto { @IsNotEmpty() @IsString() diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts new file mode 100644 index 0000000000..d96d7819ad --- /dev/null +++ b/server/src/dtos/session.dto.ts @@ -0,0 +1,19 @@ +import { SessionEntity } from 'src/entities/session.entity'; + +export class SessionResponseDto { + id!: string; + createdAt!: string; + updatedAt!: string; + current!: boolean; + deviceType!: string; + deviceOS!: string; +} + +export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({ + id: entity.id, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + current: currentId === entity.id, + deviceOS: entity.deviceOS, + deviceType: entity.deviceType, +}); diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 761b476930..59aa907199 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -13,13 +13,13 @@ import { MemoryEntity } from 'src/entities/memory.entity'; import { MoveEntity } from 'src/entities/move.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { SystemConfigEntity } from 'src/entities/system-config.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { TagEntity } from 'src/entities/tag.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { UserEntity } from 'src/entities/user.entity'; export const entities = [ @@ -44,6 +44,6 @@ export const entities = [ SystemMetadataEntity, TagEntity, UserEntity, - UserTokenEntity, + SessionEntity, LibraryEntity, ]; diff --git a/server/src/entities/user-token.entity.ts b/server/src/entities/session.entity.ts similarity index 92% rename from server/src/entities/user-token.entity.ts rename to server/src/entities/session.entity.ts index 3c2cf2cf6c..1cc9ad9857 100644 --- a/server/src/entities/user-token.entity.ts +++ b/server/src/entities/session.entity.ts @@ -1,8 +1,8 @@ import { UserEntity } from 'src/entities/user.entity'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; -@Entity('user_token') -export class UserTokenEntity { +@Entity('sessions') +export class SessionEntity { @PrimaryGeneratedColumn('uuid') id!: string; diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts new file mode 100644 index 0000000000..3e2c9574a4 --- /dev/null +++ b/server/src/interfaces/session.interface.ts @@ -0,0 +1,11 @@ +import { SessionEntity } from 'src/entities/session.entity'; + +export const ISessionRepository = 'ISessionRepository'; + +export interface ISessionRepository { + create(dto: Partial<SessionEntity>): Promise<SessionEntity>; + update(dto: Partial<SessionEntity>): Promise<SessionEntity>; + delete(id: string): Promise<void>; + getByToken(token: string): Promise<SessionEntity | null>; + getByUserId(userId: string): Promise<SessionEntity[]>; +} diff --git a/server/src/interfaces/user-token.interface.ts b/server/src/interfaces/user-token.interface.ts deleted file mode 100644 index 0fcec39fdc..0000000000 --- a/server/src/interfaces/user-token.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { UserTokenEntity } from 'src/entities/user-token.entity'; - -export const IUserTokenRepository = 'IUserTokenRepository'; - -export interface IUserTokenRepository { - create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>; - save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>; - delete(id: string): Promise<void>; - getByToken(token: string): Promise<UserTokenEntity | null>; - getAll(userId: string): Promise<UserTokenEntity[]>; -} diff --git a/server/src/migrations/1713490844785-RenameSessionsTable.ts b/server/src/migrations/1713490844785-RenameSessionsTable.ts new file mode 100644 index 0000000000..b1b35e8ae6 --- /dev/null +++ b/server/src/migrations/1713490844785-RenameSessionsTable.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameSessionsTable1713490844785 implements MigrationInterface { + name = 'RenameSessionsTable1713490844785'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "user_token" RENAME TO "sessions"`); + await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" to "FK_57de40bc620f456c7311aa3a1e6"`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" to "FK_d37db50eecdf9b8ce4eedd2f918"`); + await queryRunner.query(`ALTER TABLE "sessions" RENAME TO "user_token"`); + } +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 0e1cab6d0b..3c6eca7270 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -173,13 +173,13 @@ WHERE -- AccessRepository.authDevice.checkOwnerAccess SELECT - "UserTokenEntity"."id" AS "UserTokenEntity_id" + "SessionEntity"."id" AS "SessionEntity_id" FROM - "user_token" "UserTokenEntity" + "sessions" "SessionEntity" WHERE ( - ("UserTokenEntity"."userId" = $1) - AND ("UserTokenEntity"."id" IN ($2)) + ("SessionEntity"."userId" = $1) + AND ("SessionEntity"."id" IN ($2)) ) -- AccessRepository.library.checkOwnerAccess diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql new file mode 100644 index 0000000000..e712c8a160 --- /dev/null +++ b/server/src/queries/session.repository.sql @@ -0,0 +1,48 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- SessionRepository.getByToken +SELECT DISTINCT + "distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id" +FROM + ( + SELECT + "SessionEntity"."id" AS "SessionEntity_id", + "SessionEntity"."userId" AS "SessionEntity_userId", + "SessionEntity"."createdAt" AS "SessionEntity_createdAt", + "SessionEntity"."updatedAt" AS "SessionEntity_updatedAt", + "SessionEntity"."deviceType" AS "SessionEntity_deviceType", + "SessionEntity"."deviceOS" AS "SessionEntity_deviceOS", + "SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id", + "SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name", + "SessionEntity__SessionEntity_user"."avatarColor" AS "SessionEntity__SessionEntity_user_avatarColor", + "SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin", + "SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email", + "SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel", + "SessionEntity__SessionEntity_user"."oauthId" AS "SessionEntity__SessionEntity_user_oauthId", + "SessionEntity__SessionEntity_user"."profileImagePath" AS "SessionEntity__SessionEntity_user_profileImagePath", + "SessionEntity__SessionEntity_user"."shouldChangePassword" AS "SessionEntity__SessionEntity_user_shouldChangePassword", + "SessionEntity__SessionEntity_user"."createdAt" AS "SessionEntity__SessionEntity_user_createdAt", + "SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt", + "SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status", + "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", + "SessionEntity__SessionEntity_user"."memoriesEnabled" AS "SessionEntity__SessionEntity_user_memoriesEnabled", + "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", + "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes" + FROM + "sessions" "SessionEntity" + LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId" + AND ( + "SessionEntity__SessionEntity_user"."deletedAt" IS NULL + ) + WHERE + (("SessionEntity"."token" = $1)) + ) "distinctAlias" +ORDER BY + "SessionEntity_id" ASC +LIMIT + 1 + +-- SessionRepository.delete +DELETE FROM "sessions" +WHERE + "id" = $1 diff --git a/server/src/queries/user.token.repository.sql b/server/src/queries/user.token.repository.sql deleted file mode 100644 index f09238e137..0000000000 --- a/server/src/queries/user.token.repository.sql +++ /dev/null @@ -1,48 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- UserTokenRepository.getByToken -SELECT DISTINCT - "distinctAlias"."UserTokenEntity_id" AS "ids_UserTokenEntity_id" -FROM - ( - SELECT - "UserTokenEntity"."id" AS "UserTokenEntity_id", - "UserTokenEntity"."userId" AS "UserTokenEntity_userId", - "UserTokenEntity"."createdAt" AS "UserTokenEntity_createdAt", - "UserTokenEntity"."updatedAt" AS "UserTokenEntity_updatedAt", - "UserTokenEntity"."deviceType" AS "UserTokenEntity_deviceType", - "UserTokenEntity"."deviceOS" AS "UserTokenEntity_deviceOS", - "UserTokenEntity__UserTokenEntity_user"."id" AS "UserTokenEntity__UserTokenEntity_user_id", - "UserTokenEntity__UserTokenEntity_user"."name" AS "UserTokenEntity__UserTokenEntity_user_name", - "UserTokenEntity__UserTokenEntity_user"."avatarColor" AS "UserTokenEntity__UserTokenEntity_user_avatarColor", - "UserTokenEntity__UserTokenEntity_user"."isAdmin" AS "UserTokenEntity__UserTokenEntity_user_isAdmin", - "UserTokenEntity__UserTokenEntity_user"."email" AS "UserTokenEntity__UserTokenEntity_user_email", - "UserTokenEntity__UserTokenEntity_user"."storageLabel" AS "UserTokenEntity__UserTokenEntity_user_storageLabel", - "UserTokenEntity__UserTokenEntity_user"."oauthId" AS "UserTokenEntity__UserTokenEntity_user_oauthId", - "UserTokenEntity__UserTokenEntity_user"."profileImagePath" AS "UserTokenEntity__UserTokenEntity_user_profileImagePath", - "UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword", - "UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt", - "UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt", - "UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status", - "UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt", - "UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled", - "UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes", - "UserTokenEntity__UserTokenEntity_user"."quotaUsageInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaUsageInBytes" - FROM - "user_token" "UserTokenEntity" - LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId" - AND ( - "UserTokenEntity__UserTokenEntity_user"."deletedAt" IS NULL - ) - WHERE - (("UserTokenEntity"."token" = $1)) - ) "distinctAlias" -ORDER BY - "UserTokenEntity_id" ASC -LIMIT - 1 - --- UserTokenRepository.delete -DELETE FROM "user_token" -WHERE - "id" = $1 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 469de11be6..a624e8bfdc 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -9,8 +9,8 @@ import { LibraryEntity } from 'src/entities/library.entity'; import { MemoryEntity } from 'src/entities/memory.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Brackets, In, Repository } from 'typeorm'; @@ -286,7 +286,7 @@ class AssetAccess implements IAssetAccess { } class AuthDeviceAccess implements IAuthDeviceAccess { - constructor(private tokenRepository: Repository<UserTokenEntity>) {} + constructor(private sessionRepository: Repository<SessionEntity>) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) @@ -295,7 +295,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess { return new Set(); } - return this.tokenRepository + return this.sessionRepository .find({ select: { id: true }, where: { @@ -457,12 +457,12 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>, @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>, - @InjectRepository(UserTokenEntity) tokenRepository: Repository<UserTokenEntity>, + @InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>, ) { this.activity = new ActivityAccess(activityRepository, albumRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); - this.authDevice = new AuthDeviceAccess(tokenRepository); + this.authDevice = new AuthDeviceAccess(sessionRepository); this.library = new LibraryAccess(libraryRepository); this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index e6466ee6b5..6ab09ac746 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -22,12 +22,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; @@ -53,12 +53,12 @@ import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemConfigRepository } from 'src/repositories/system-config.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; -import { UserTokenRepository } from 'src/repositories/user-token.repository'; import { UserRepository } from 'src/repositories/user.repository'; export const repositories = [ @@ -86,11 +86,11 @@ export const repositories = [ { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISearchRepository, useClass: SearchRepository }, + { provide: ISessionRepository, useClass: SessionRepository }, { provide: IStorageRepository, useClass: StorageRepository }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: IMediaRepository, useClass: MediaRepository }, { provide: IUserRepository, useClass: UserRepository }, - { provide: IUserTokenRepository, useClass: UserTokenRepository }, ]; diff --git a/server/src/repositories/user-token.repository.ts b/server/src/repositories/session.repository.ts similarity index 54% rename from server/src/repositories/user-token.repository.ts rename to server/src/repositories/session.repository.ts index cbf3a3e3b0..5e42039bc6 100644 --- a/server/src/repositories/user-token.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,22 +1,22 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; +import { SessionEntity } from 'src/entities/session.entity'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; @Instrumentation() @Injectable() -export class UserTokenRepository implements IUserTokenRepository { - constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {} +export class SessionRepository implements ISessionRepository { + constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {} @GenerateSql({ params: [DummyValue.STRING] }) - getByToken(token: string): Promise<UserTokenEntity | null> { + getByToken(token: string): Promise<SessionEntity | null> { return this.repository.findOne({ where: { token }, relations: { user: true } }); } - getAll(userId: string): Promise<UserTokenEntity[]> { + getByUserId(userId: string): Promise<SessionEntity[]> { return this.repository.find({ where: { userId, @@ -31,12 +31,12 @@ export class UserTokenRepository implements IUserTokenRepository { }); } - create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { - return this.repository.save(userToken); + create(session: Partial<SessionEntity>): Promise<SessionEntity> { + return this.repository.save(session); } - save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { - return this.repository.save(userToken); + update(session: Partial<SessionEntity>): Promise<SessionEntity> { + return this.repository.save(session); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index d53f319661..9d83d5261f 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -9,25 +9,25 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub, loginResponseStub } from 'test/fixtures/auth.stub'; +import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { userTokenStub } from 'test/fixtures/user-token.stub'; import { userStub } from 'test/fixtures/user.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; -import { newUserTokenRepositoryMock } from 'test/repositories/user-token.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mock, Mocked, vitest } from 'vitest'; @@ -65,7 +65,7 @@ describe('AuthService', () => { let libraryMock: Mocked<ILibraryRepository>; let loggerMock: Mocked<ILoggerRepository>; let configMock: Mocked<ISystemConfigRepository>; - let userTokenMock: Mocked<IUserTokenRepository>; + let sessionMock: Mocked<ISessionRepository>; let shareMock: Mocked<ISharedLinkRepository>; let keyMock: Mocked<IKeyRepository>; @@ -98,7 +98,7 @@ describe('AuthService', () => { libraryMock = newLibraryRepositoryMock(); loggerMock = newLoggerRepositoryMock(); configMock = newSystemConfigRepositoryMock(); - userTokenMock = newUserTokenRepositoryMock(); + sessionMock = newSessionRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); @@ -109,7 +109,7 @@ describe('AuthService', () => { libraryMock, loggerMock, userMock, - userTokenMock, + sessionMock, shareMock, keyMock, ); @@ -139,14 +139,14 @@ describe('AuthService', () => { it('should successfully log the user in', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); it('should generate the cookie headers (insecure)', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await expect( sut.login(fixtures.login, { clientIp: '127.0.0.1', @@ -231,14 +231,14 @@ describe('AuthService', () => { }); it('should delete the access token', async () => { - const auth = { user: { id: '123' }, userToken: { id: 'token123' } } as AuthDto; + const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto; await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); - expect(userTokenMock.delete).toHaveBeenCalledWith('token123'); + expect(sessionMock.delete).toHaveBeenCalledWith('token123'); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { @@ -282,11 +282,11 @@ describe('AuthService', () => { it('should validate using authorization header', async () => { userMock.get.mockResolvedValue(userStub.user1); - userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); + sessionMock.getByToken.mockResolvedValue(sessionStub.valid); const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({ user: userStub.user1, - userToken: userTokenStub.userToken, + session: sessionStub.valid, }); }); }); @@ -336,37 +336,29 @@ describe('AuthService', () => { describe('validate - user token', () => { it('should throw if no token is found', async () => { - userTokenMock.getByToken.mockResolvedValue(null); + sessionMock.getByToken.mockResolvedValue(null); const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' }; await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); }); it('should return an auth dto', async () => { - userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); + sessionMock.getByToken.mockResolvedValue(sessionStub.valid); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.user1, - userToken: userTokenStub.userToken, + session: sessionStub.valid, }); }); it('should update when access time exceeds an hour', async () => { - userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken); - userTokenMock.save.mockResolvedValue(userTokenStub.userToken); + sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); + sessionMock.update.mockResolvedValue(sessionStub.valid); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.user1, - userToken: userTokenStub.userToken, - }); - expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({ - id: 'not_active', - token: 'auth_token', - userId: 'user-id', - createdAt: new Date('2021-01-01'), - updatedAt: expect.any(Date), - deviceOS: 'Android', - deviceType: 'Mobile', + session: sessionStub.valid, }); + expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); @@ -386,55 +378,6 @@ describe('AuthService', () => { }); }); - describe('getDevices', () => { - it('should get the devices', async () => { - userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.inactiveToken]); - await expect(sut.getDevices(authStub.user1)).resolves.toEqual([ - { - createdAt: '2021-01-01T00:00:00.000Z', - current: true, - deviceOS: '', - deviceType: '', - id: 'token-id', - updatedAt: expect.any(String), - }, - { - createdAt: '2021-01-01T00:00:00.000Z', - current: false, - deviceOS: 'Android', - deviceType: 'Mobile', - id: 'not_active', - updatedAt: expect.any(String), - }, - ]); - - expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); - }); - }); - - describe('logoutDevices', () => { - it('should logout all devices', async () => { - userTokenMock.getAll.mockResolvedValue([userTokenStub.inactiveToken, userTokenStub.userToken]); - - await sut.logoutDevices(authStub.user1); - - expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); - expect(userTokenMock.delete).toHaveBeenCalledWith('not_active'); - expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id'); - }); - }); - - describe('logoutDevice', () => { - it('should logout the device', async () => { - accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); - - await sut.logoutDevice(authStub.user1, 'token-1'); - - expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); - expect(userTokenMock.delete).toHaveBeenCalledWith('token-1'); - }); - }); - describe('getMobileRedirect', () => { it('should pass along the query params', () => { expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456'); @@ -463,7 +406,7 @@ describe('AuthService', () => { configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); userMock.getByEmail.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, @@ -478,7 +421,7 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, @@ -491,7 +434,7 @@ describe('AuthService', () => { it('should use the mobile redirect override', async () => { configMock.load.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails); @@ -501,7 +444,7 @@ describe('AuthService', () => { it('should use the mobile redirect override for ios urls with multiple slashes', async () => { configMock.load.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7bebca5989..7e81d15ce5 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -19,11 +19,10 @@ import { LOGIN_URL, MOBILE_REDIRECT, } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserCore } from 'src/cores/user.core'; import { - AuthDeviceResponseDto, AuthDto, ChangePasswordDto, LoginCredentialDto, @@ -34,7 +33,6 @@ import { OAuthConfigDto, SignUpDto, mapLoginResponse, - mapUserToken, } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { SystemConfig } from 'src/entities/system-config.entity'; @@ -44,9 +42,9 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -85,7 +83,7 @@ export class AuthService { @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository, + @Inject(ISessionRepository) private sessionRepository: ISessionRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository, ) { @@ -120,8 +118,8 @@ export class AuthService { } async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> { - if (auth.userToken) { - await this.userTokenRepository.delete(auth.userToken.id); + if (auth.session) { + await this.sessionRepository.delete(auth.session.id); } return { @@ -164,8 +162,9 @@ export class AuthService { async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> { const shareKey = (headers['x-immich-share-key'] || params.key) as string; - const userToken = (headers['x-immich-user-token'] || - params.userToken || + const session = (headers['x-immich-user-token'] || + headers['x-immich-session-token'] || + params.sessionKey || this.getBearerToken(headers) || this.getCookieToken(headers)) as string; const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string; @@ -174,8 +173,8 @@ export class AuthService { return this.validateSharedLink(shareKey); } - if (userToken) { - return this.validateUserToken(userToken); + if (session) { + return this.validateSession(session); } if (apiKey) { @@ -185,26 +184,6 @@ export class AuthService { throw new UnauthorizedException('Authentication required'); } - async getDevices(auth: AuthDto): Promise<AuthDeviceResponseDto[]> { - const userTokens = await this.userTokenRepository.getAll(auth.user.id); - return userTokens.map((userToken) => mapUserToken(userToken, auth.userToken?.id)); - } - - async logoutDevice(auth: AuthDto, id: string): Promise<void> { - await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); - await this.userTokenRepository.delete(id); - } - - async logoutDevices(auth: AuthDto): Promise<void> { - const devices = await this.userTokenRepository.getAll(auth.user.id); - for (const device of devices) { - if (device.id === auth.userToken?.id) { - continue; - } - await this.userTokenRepository.delete(device.id); - } - } - getMobileRedirect(url: string) { return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`; } @@ -408,19 +387,19 @@ export class AuthService { return this.cryptoRepository.compareBcrypt(inputPassword, user.password); } - private async validateUserToken(tokenValue: string): Promise<AuthDto> { + private async validateSession(tokenValue: string): Promise<AuthDto> { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); - let userToken = await this.userTokenRepository.getByToken(hashedToken); + let session = await this.sessionRepository.getByToken(hashedToken); - if (userToken?.user) { + if (session?.user) { const now = DateTime.now(); - const updatedAt = DateTime.fromJSDate(userToken.updatedAt); + const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); if (diff.hours > 1) { - userToken = await this.userTokenRepository.save({ ...userToken, updatedAt: new Date() }); + session = await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); } - return { user: userToken.user, userToken }; + return { user: session.user, session: session }; } throw new UnauthorizedException('Invalid user token'); @@ -430,7 +409,7 @@ export class AuthService { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); - await this.userTokenRepository.create({ + await this.sessionRepository.create({ token, user, deviceOS: loginDetails.deviceOS, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6c40f8420a..db3d6083e9 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -18,6 +18,7 @@ import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; import { SearchService } from 'src/services/search.service'; import { ServerInfoService } from 'src/services/server-info.service'; +import { SessionService } from 'src/services/session.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; @@ -50,6 +51,7 @@ export const services = [ PersonService, SearchService, ServerInfoService, + SessionService, SharedLinkService, SmartInfoService, StorageService, diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts new file mode 100644 index 0000000000..0b54564da6 --- /dev/null +++ b/server/src/services/session.service.spec.ts @@ -0,0 +1,77 @@ +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; +import { SessionService } from 'src/services/session.service'; +import { authStub } from 'test/fixtures/auth.stub'; +import { sessionStub } from 'test/fixtures/session.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; +import { Mocked } from 'vitest'; + +describe('SessionService', () => { + let sut: SessionService; + let accessMock: Mocked<IAccessRepositoryMock>; + let loggerMock: Mocked<ILoggerRepository>; + let sessionMock: Mocked<ISessionRepository>; + + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + sessionMock = newSessionRepositoryMock(); + + sut = new SessionService(accessMock, loggerMock, sessionMock); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('getAll', () => { + it('should get the devices', async () => { + sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]); + await expect(sut.getAll(authStub.user1)).resolves.toEqual([ + { + createdAt: '2021-01-01T00:00:00.000Z', + current: true, + deviceOS: '', + deviceType: '', + id: 'token-id', + updatedAt: expect.any(String), + }, + { + createdAt: '2021-01-01T00:00:00.000Z', + current: false, + deviceOS: 'Android', + deviceType: 'Mobile', + id: 'not_active', + updatedAt: expect.any(String), + }, + ]); + + expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + }); + }); + + describe('logoutDevices', () => { + it('should logout all devices', async () => { + sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]); + + await sut.deleteAll(authStub.user1); + + expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(sessionMock.delete).toHaveBeenCalledWith('not_active'); + expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id'); + }); + }); + + describe('logoutDevice', () => { + it('should logout the device', async () => { + accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); + + await sut.delete(authStub.user1, 'token-1'); + + expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); + expect(sessionMock.delete).toHaveBeenCalledWith('token-1'); + }); + }); +}); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts new file mode 100644 index 0000000000..7ee454d7b4 --- /dev/null +++ b/server/src/services/session.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; + +@Injectable() +export class SessionService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ISessionRepository) private sessionRepository: ISessionRepository, + ) { + this.logger.setContext(SessionService.name); + this.access = AccessCore.create(accessRepository); + } + + async getAll(auth: AuthDto): Promise<SessionResponseDto[]> { + const sessions = await this.sessionRepository.getByUserId(auth.user.id); + return sessions.map((session) => mapSession(session, auth.session?.id)); + } + + async delete(auth: AuthDto, id: string): Promise<void> { + await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); + await this.sessionRepository.delete(id); + } + + async deleteAll(auth: AuthDto): Promise<void> { + const sessions = await this.sessionRepository.getByUserId(auth.user.id); + for (const session of sessions) { + if (session.id === auth.session?.id) { + continue; + } + await this.sessionRepository.delete(session.id); + } + } +} diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 2e56d0001a..a4753a02e7 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,6 +1,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { UserEntity } from 'src/entities/user.entity'; export const adminSignupStub = { @@ -35,9 +35,9 @@ export const authStub = { email: 'immich@test.com', isAdmin: false, } as UserEntity, - userToken: { + session: { id: 'token-id', - } as UserTokenEntity, + } as SessionEntity, }), user2: Object.freeze<AuthDto>({ user: { @@ -45,9 +45,9 @@ export const authStub = { email: 'user2@immich.app', isAdmin: false, } as UserEntity, - userToken: { + session: { id: 'token-id', - } as UserTokenEntity, + } as SessionEntity, }), external1: Object.freeze<AuthDto>({ user: { @@ -55,9 +55,9 @@ export const authStub = { email: 'immich@test.com', isAdmin: false, } as UserEntity, - userToken: { + session: { id: 'token-id', - } as UserTokenEntity, + } as SessionEntity, }), adminSharedLink: Object.freeze<AuthDto>({ user: { diff --git a/server/test/fixtures/user-token.stub.ts b/server/test/fixtures/session.stub.ts similarity index 72% rename from server/test/fixtures/user-token.stub.ts rename to server/test/fixtures/session.stub.ts index 2f6fcc0cd5..cdf499c8d1 100644 --- a/server/test/fixtures/user-token.stub.ts +++ b/server/test/fixtures/session.stub.ts @@ -1,8 +1,8 @@ -import { UserTokenEntity } from 'src/entities/user-token.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { userStub } from 'test/fixtures/user.stub'; -export const userTokenStub = { - userToken: Object.freeze<UserTokenEntity>({ +export const sessionStub = { + valid: Object.freeze<SessionEntity>({ id: 'token-id', token: 'auth_token', userId: userStub.user1.id, @@ -12,7 +12,7 @@ export const userTokenStub = { deviceType: '', deviceOS: '', }), - inactiveToken: Object.freeze<UserTokenEntity>({ + inactive: Object.freeze<SessionEntity>({ id: 'not_active', token: 'auth_token', userId: userStub.user1.id, diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts new file mode 100644 index 0000000000..1a034e79f0 --- /dev/null +++ b/server/test/repositories/session.repository.mock.ts @@ -0,0 +1,12 @@ +import { ISessionRepository } from 'src/interfaces/session.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => { + return { + create: vitest.fn(), + update: vitest.fn(), + delete: vitest.fn(), + getByToken: vitest.fn(), + getByUserId: vitest.fn(), + }; +}; diff --git a/server/test/repositories/user-token.repository.mock.ts b/server/test/repositories/user-token.repository.mock.ts deleted file mode 100644 index f34e65b7f3..0000000000 --- a/server/test/repositories/user-token.repository.mock.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newUserTokenRepositoryMock = (): Mocked<IUserTokenRepository> => { - return { - create: vitest.fn(), - save: vitest.fn(), - delete: vitest.fn(), - getByToken: vitest.fn(), - getAll: vitest.fn(), - }; -}; diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 64f17ad9e5..8821ed970a 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; import { locale } from '$lib/stores/preferences.store'; - import type { AuthDeviceResponseDto } from '@immich/sdk'; + import type { SessionResponseDto } from '@immich/sdk'; import { mdiAndroid, mdiApple, @@ -15,7 +15,7 @@ import { DateTime, type ToRelativeCalendarOptions } from 'luxon'; import { createEventDispatcher } from 'svelte'; - export let device: AuthDeviceResponseDto; + export let device: SessionResponseDto; const dispatcher = createEventDispatcher<{ delete: void; diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 3bec6cf170..b7bb05657f 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -1,16 +1,16 @@ <script lang="ts"> - import { getAuthDevices, logoutAuthDevice, logoutAuthDevices, type AuthDeviceResponseDto } from '@immich/sdk'; + import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk'; import { handleError } from '../../utils/handle-error'; import Button from '../elements/buttons/button.svelte'; import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; - import { NotificationType, notificationController } from '../shared-components/notification/notification'; + import { notificationController, NotificationType } from '../shared-components/notification/notification'; import DeviceCard from './device-card.svelte'; - export let devices: AuthDeviceResponseDto[]; - let deleteDevice: AuthDeviceResponseDto | null = null; + export let devices: SessionResponseDto[]; + let deleteDevice: SessionResponseDto | null = null; let deleteAll = false; - const refresh = () => getAuthDevices().then((_devices) => (devices = _devices)); + const refresh = () => getSessions().then((_devices) => (devices = _devices)); $: currentDevice = devices.find((device) => device.current); $: otherDevices = devices.filter((device) => !device.current); @@ -21,7 +21,7 @@ } try { - await logoutAuthDevice({ id: deleteDevice.id }); + await deleteSession({ id: deleteDevice.id }); notificationController.show({ message: `Logged out device`, type: NotificationType.Info }); } catch (error) { handleError(error, 'Unable to log out device'); @@ -33,7 +33,7 @@ const handleDeleteAll = async () => { try { - await logoutAuthDevices(); + await deleteAllSessions(); notificationController.show({ message: `Logged out all devices`, type: NotificationType.Info, diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 7a3be6fb5c..d239886ed9 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -4,7 +4,8 @@ import { featureFlags } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { oauth } from '$lib/utils'; - import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk'; + import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk'; + import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; import SettingAccordion from '../shared-components/settings/setting-accordion.svelte'; import AppSettings from './app-settings.svelte'; import ChangePasswordSettings from './change-password-settings.svelte'; @@ -14,10 +15,9 @@ import PartnerSettings from './partner-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; - import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; export let keys: ApiKeyResponseDto[] = []; - export let devices: AuthDeviceResponseDto[] = []; + export let sessions: SessionResponseDto[] = []; let oauthOpen = oauth.isCallback(window.location) || @@ -38,7 +38,7 @@ </SettingAccordion> <SettingAccordion key="authorized-devices" title="Authorized Devices" subtitle="Manage your logged-in devices"> - <DeviceList bind:devices /> + <DeviceList bind:devices={sessions} /> </SettingAccordion> <SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories"> diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index 359ce33d2c..d23ecda446 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -19,7 +19,7 @@ </svelte:fragment> <section class="mx-4 flex place-content-center"> <div class="w-full max-w-3xl"> - <UserSettingsList keys={data.keys} devices={data.devices} /> + <UserSettingsList keys={data.keys} sessions={data.sessions} /> </div> </section> </UserPageLayout> diff --git a/web/src/routes/(user)/user-settings/+page.ts b/web/src/routes/(user)/user-settings/+page.ts index 84aed0db00..eba40243e9 100644 --- a/web/src/routes/(user)/user-settings/+page.ts +++ b/web/src/routes/(user)/user-settings/+page.ts @@ -1,16 +1,16 @@ import { authenticate } from '$lib/utils/auth'; -import { getApiKeys, getAuthDevices } from '@immich/sdk'; +import { getApiKeys, getSessions } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async () => { await authenticate(); const keys = await getApiKeys(); - const devices = await getAuthDevices(); + const sessions = await getSessions(); return { keys, - devices, + sessions, meta: { title: 'Settings', }, From eaf9e5e477619172e484ac384d74579d28673a8f Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Fri, 19 Apr 2024 12:49:29 +0200 Subject: [PATCH 17/46] feat(web): add an option to fill the screen with the slideshow view (#8909) * feat: add an option to fill the screen with the slideshow view * fix: rename var --- .../asset-viewer/photo-viewer.svelte | 7 +++- .../lib/components/slideshow-settings.svelte | 39 +++++++++++++------ web/src/lib/stores/slideshow.store.ts | 12 ++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 14bbb6b1fa..6285a63006 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -14,6 +14,9 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { getAltText } from '$lib/utils/thumbnail-util'; + import { slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; + + const { slideshowState, slideshowLook } = slideshowStore; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] | null = null; @@ -158,7 +161,9 @@ transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} src={assetData} alt={getAltText(asset)} - class="h-full w-full object-contain" + class="h-full w-full {$slideshowState === SlideshowState.None + ? 'object-contain' + : slideshowLookCssMapping[$slideshowLook]}" draggable="false" /> {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index 7a0da7b836..d0ac5eab93 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -4,27 +4,34 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; - import { mdiArrowDownThin, mdiArrowUpThin, mdiShuffle } from '@mdi/js'; - import { SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; + import { mdiArrowDownThin, mdiArrowUpThin, mdiFitToPageOutline, mdiFitToScreenOutline, mdiShuffle } from '@mdi/js'; + import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; import Button from './elements/buttons/button.svelte'; import type { RenderedOption } from './elements/dropdown.svelte'; import SettingDropdown from './shared-components/settings/setting-dropdown.svelte'; - const { slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore; + const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore; export let onClose = () => {}; - const options: Record<SlideshowNavigation, RenderedOption> = { + const navigationOptions: Record<SlideshowNavigation, RenderedOption> = { [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: 'Shuffle' }, [SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: 'Backward' }, [SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' }, }; - const handleToggle = (selectedOption: RenderedOption) => { + const lookOptions: Record<SlideshowLook, RenderedOption> = { + [SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: 'Contain' }, + [SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: 'Cover' }, + }; + + const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>( + record: RenderedOption, + options: Record<Type, RenderedOption>, + ): undefined | Type => { for (const [key, option] of Object.entries(options)) { - if (option === selectedOption) { - $slideshowNavigation = key as SlideshowNavigation; - break; + if (option === record) { + return key as Type; } } }; @@ -34,9 +41,19 @@ <div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"> <SettingDropdown title="Direction" - options={Object.values(options)} - selectedOption={options[$slideshowNavigation]} - onToggle={(option) => handleToggle(option)} + options={Object.values(navigationOptions)} + selectedOption={navigationOptions[$slideshowNavigation]} + onToggle={(option) => { + $slideshowNavigation = handleToggle(option, navigationOptions) || $slideshowNavigation; + }} + /> + <SettingDropdown + title="Look" + options={Object.values(lookOptions)} + selectedOption={lookOptions[$slideshowLook]} + onToggle={(option) => { + $slideshowLook = handleToggle(option, lookOptions) || $slideshowLook; + }} /> <SettingSwitch id="show-progress-bar" title="Show Progress Bar" bind:checked={$showProgressBar} /> <SettingInputField diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts index acb78d0b0a..453f216912 100644 --- a/web/src/lib/stores/slideshow.store.ts +++ b/web/src/lib/stores/slideshow.store.ts @@ -13,6 +13,16 @@ export enum SlideshowNavigation { DescendingOrder = 'descending-order', } +export enum SlideshowLook { + Contain = 'contain', + Cover = 'cover', +} + +export const slideshowLookCssMapping: Record<SlideshowLook, string> = { + [SlideshowLook.Contain]: 'object-contain', + [SlideshowLook.Cover]: 'object-cover', +}; + function createSlideshowStore() { const restartState = writable<boolean>(false); const stopState = writable<boolean>(false); @@ -21,6 +31,7 @@ function createSlideshowStore() { 'slideshow-navigation', SlideshowNavigation.DescendingOrder, ); + const slideshowLook = persisted<SlideshowLook>('slideshow-look', SlideshowLook.Contain); const slideshowState = writable<SlideshowState>(SlideshowState.None); const showProgressBar = persisted<boolean>('slideshow-show-progressbar', true); @@ -50,6 +61,7 @@ function createSlideshowStore() { }, }, slideshowNavigation, + slideshowLook, slideshowState, slideshowDelay, showProgressBar, From 74c921148bd2db5cd74065fa0ec18228346fa1d7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Fri, 19 Apr 2024 11:19:23 -0400 Subject: [PATCH 18/46] refactor(server): cookies (#8920) --- e2e/src/api/specs/auth.e2e-spec.ts | 26 +++++++- server/src/constants.ts | 7 +- server/src/controllers/auth.controller.ts | 29 ++++++--- server/src/controllers/oauth.controller.ts | 15 ++++- .../src/controllers/shared-link.controller.ts | 24 ++++--- server/src/dtos/auth.dto.ts | 21 +++++- server/src/middleware/auth.guard.ts | 4 +- server/src/services/auth.service.spec.ts | 14 ---- server/src/services/auth.service.ts | 64 ++++--------------- server/src/utils/misc.ts | 15 ++--- server/src/utils/response.ts | 36 +++++++++++ server/test/fixtures/auth.stub.ts | 58 ++++------------- 12 files changed, 158 insertions(+), 155 deletions(-) create mode 100644 server/src/utils/response.ts diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts index 4a6e1a773a..9174128bb8 100644 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ b/e2e/src/api/specs/auth.e2e-spec.ts @@ -112,9 +112,29 @@ describe('/auth/*', () => { const cookies = headers['set-cookie']; expect(cookies).toHaveLength(3); - expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`); - expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'); - expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'); + expect(cookies[0].split(';').map((item) => item.trim())).toEqual([ + `immich_access_token=${token}`, + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'HttpOnly', + 'SameSite=Lax', + ]); + expect(cookies[1].split(';').map((item) => item.trim())).toEqual([ + 'immich_auth_type=password', + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'HttpOnly', + 'SameSite=Lax', + ]); + expect(cookies[2].split(';').map((item) => item.trim())).toEqual([ + 'immich_is_authenticated=true', + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'SameSite=Lax', + ]); }); }); diff --git a/server/src/constants.ts b/server/src/constants.ts index 1289701dd8..d9d4232396 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -26,12 +26,7 @@ export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile); export const MOBILE_REDIRECT = 'app.immich:/'; export const LOGIN_URL = '/auth/login?autoLaunch=0'; -export const IMMICH_ACCESS_COOKIE = 'immich_access_token'; -export const IMMICH_IS_AUTHENTICATED = 'immich_is_authenticated'; -export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type'; -export const IMMICH_API_KEY_NAME = 'api_key'; -export const IMMICH_API_KEY_HEADER = 'x-api-key'; -export const IMMICH_SHARED_LINK_ACCESS_COOKIE = 'immich_shared_link_token'; + export enum AuthType { PASSWORD = 'password', OAUTH = 'oauth', diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index f4e7666207..a4c7494f2b 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,10 +1,11 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants'; +import { AuthType } from 'src/constants'; import { AuthDto, ChangePasswordDto, + ImmichCookie, LoginCredentialDto, LoginResponseDto, LogoutResponseDto, @@ -14,6 +15,7 @@ import { import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; +import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; @ApiTags('Authentication') @Controller('auth') @@ -28,9 +30,15 @@ export class AuthController { @Res({ passthrough: true }) res: Response, @GetLoginDetails() loginDetails: LoginDetails, ): Promise<LoginResponseDto> { - const { response, cookie } = await this.service.login(loginCredential, loginDetails); - res.header('Set-Cookie', cookie); - return response; + const body = await this.service.login(loginCredential, loginDetails); + return respondWithCookie(res, body, { + isSecure: loginDetails.isSecure, + values: [ + { key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken }, + { key: ImmichCookie.AUTH_TYPE, value: AuthType.PASSWORD }, + { key: ImmichCookie.IS_AUTHENTICATED, value: 'true' }, + ], + }); } @PublicRoute() @@ -53,15 +61,18 @@ export class AuthController { @Post('logout') @HttpCode(HttpStatus.OK) - logout( + async logout( @Req() request: Request, @Res({ passthrough: true }) res: Response, @Auth() auth: AuthDto, ): Promise<LogoutResponseDto> { - res.clearCookie(IMMICH_ACCESS_COOKIE); - res.clearCookie(IMMICH_AUTH_TYPE_COOKIE); - res.clearCookie(IMMICH_IS_AUTHENTICATED); + const authType = (request.cookies || {})[ImmichCookie.AUTH_TYPE]; - return this.service.logout(auth, (request.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]); + const body = await this.service.logout(auth, authType); + return respondWithoutCookie(res, body, [ + ImmichCookie.ACCESS_TOKEN, + ImmichCookie.AUTH_TYPE, + ImmichCookie.IS_AUTHENTICATED, + ]); } } diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index debbd4e676..d87fb11d88 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,8 +1,10 @@ import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { AuthType } from 'src/constants'; import { AuthDto, + ImmichCookie, LoginResponseDto, OAuthAuthorizeResponseDto, OAuthCallbackDto, @@ -11,6 +13,7 @@ import { import { UserResponseDto } from 'src/dtos/user.dto'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; +import { respondWithCookie } from 'src/utils/response'; @ApiTags('OAuth') @Controller('oauth') @@ -41,9 +44,15 @@ export class OAuthController { @Body() dto: OAuthCallbackDto, @GetLoginDetails() loginDetails: LoginDetails, ): Promise<LoginResponseDto> { - const { response, cookie } = await this.service.callback(dto, loginDetails); - res.header('Set-Cookie', cookie); - return response; + const body = await this.service.callback(dto, loginDetails); + return respondWithCookie(res, body, { + isSecure: loginDetails.isSecure, + values: [ + { key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken }, + { key: ImmichCookie.AUTH_TYPE, value: AuthType.OAUTH }, + { key: ImmichCookie.IS_AUTHENTICATED, value: 'true' }, + ], + }); } @Post('link') diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index a7a8e3a1c6..58f2939b93 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -1,18 +1,19 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { IMMICH_SHARED_LINK_ACCESS_COOKIE } from 'src/constants'; import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; -import { AuthDto } from 'src/dtos/auth.dto'; +import { AuthDto, ImmichCookie } from 'src/dtos/auth.dto'; import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, } from 'src/dtos/shared-link.dto'; -import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; +import { Auth, Authenticated, GetLoginDetails, SharedLinkRoute } from 'src/middleware/auth.guard'; +import { LoginDetails } from 'src/services/auth.service'; import { SharedLinkService } from 'src/services/shared-link.service'; +import { respondWithCookie } from 'src/utils/response'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Shared Link') @@ -33,20 +34,17 @@ export class SharedLinkController { @Query() dto: SharedLinkPasswordDto, @Req() request: Request, @Res({ passthrough: true }) res: Response, + @GetLoginDetails() loginDetails: LoginDetails, ): Promise<SharedLinkResponseDto> { - const sharedLinkToken = request.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE]; + const sharedLinkToken = request.cookies?.[ImmichCookie.SHARED_LINK_TOKEN]; if (sharedLinkToken) { dto.token = sharedLinkToken; } - const response = await this.service.getMine(auth, dto); - if (response.token) { - res.cookie(IMMICH_SHARED_LINK_ACCESS_COOKIE, response.token, { - expires: new Date(Date.now() + 1000 * 60 * 60 * 24), - httpOnly: true, - sameSite: 'lax', - }); - } - return response; + const body = await this.service.getMine(auth, dto); + return respondWithCookie(res, body, { + isSecure: loginDetails.isSecure, + values: body.token ? [{ key: ImmichCookie.SHARED_LINK_TOKEN, value: body.token }] : [], + }); } @Get(':id') diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 4651c010b9..5c1e01b818 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -6,6 +6,25 @@ import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; +export enum ImmichCookie { + ACCESS_TOKEN = 'immich_access_token', + AUTH_TYPE = 'immich_auth_type', + IS_AUTHENTICATED = 'immich_is_authenticated', + SHARED_LINK_TOKEN = 'immich_shared_link_token', +} + +export enum ImmichHeader { + API_KEY = 'x-api-key', + USER_TOKEN = 'x-immich-user-token', + SESSION_TOKEN = 'x-immich-session-token', + SHARED_LINK_TOKEN = 'x-immich-share-key', +} + +export type CookieResponse = { + isSecure: boolean; + values: Array<{ key: ImmichCookie; value: string }>; +}; + export class AuthDto { user!: UserEntity; @@ -39,7 +58,7 @@ export class LoginResponseDto { export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { return { - accessToken: accessToken, + accessToken, userId: entity.id, userEmail: entity.email, name: entity.name, diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 8b3abe6693..1253e99bbb 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -10,7 +10,6 @@ import { import { Reflector } from '@nestjs/core'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { Request } from 'express'; -import { IMMICH_API_KEY_NAME } from 'src/constants'; import { AuthDto } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService, LoginDetails } from 'src/services/auth.service'; @@ -21,6 +20,7 @@ export enum Metadata { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', PUBLIC_SECURITY = 'public_security', + API_KEY_SECURITY = 'api_key', } export interface AuthenticatedOptions { @@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}) => { const decorators: MethodDecorator[] = [ ApiBearerAuth(), ApiCookieAuth(), - ApiSecurity(IMMICH_API_KEY_NAME), + ApiSecurity(Metadata.API_KEY_SECURITY), SetMetadata(Metadata.AUTH_ROUTE, true), ]; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 9d83d5261f..cbee9faddf 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -143,20 +143,6 @@ describe('AuthService', () => { await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); - - it('should generate the cookie headers (insecure)', async () => { - userMock.getByEmail.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); - await expect( - sut.login(fixtures.login, { - clientIp: '127.0.0.1', - isSecure: false, - deviceOS: '', - deviceType: '', - }), - ).resolves.toEqual(loginResponseStub.user1insecure); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - }); }); describe('changePassword', () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7e81d15ce5..bea7366555 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -10,23 +10,16 @@ import cookieParser from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; -import { - AuthType, - IMMICH_ACCESS_COOKIE, - IMMICH_API_KEY_HEADER, - IMMICH_AUTH_TYPE_COOKIE, - IMMICH_IS_AUTHENTICATED, - LOGIN_URL, - MOBILE_REDIRECT, -} from 'src/constants'; +import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants'; import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserCore } from 'src/cores/user.core'; import { AuthDto, ChangePasswordDto, + ImmichCookie, + ImmichHeader, LoginCredentialDto, - LoginResponseDto, LogoutResponseDto, OAuthAuthorizeResponseDto, OAuthCallbackDto, @@ -55,11 +48,6 @@ export interface LoginDetails { deviceOS: string; } -interface LoginResponse { - response: LoginResponseDto; - cookie: string[]; -} - interface OAuthProfile extends UserinfoResponse { email: string; } @@ -95,7 +83,7 @@ export class AuthService { custom.setHttpOptionsDefaults({ timeout: 30_000 }); } - async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> { + async login(dto: LoginCredentialDto, details: LoginDetails) { const config = await this.configCore.getConfig(); if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); @@ -114,7 +102,7 @@ export class AuthService { throw new UnauthorizedException('Incorrect email or password'); } - return this.createLoginResponse(user, AuthType.PASSWORD, details); + return this.createLoginResponse(user, details); } async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> { @@ -161,13 +149,13 @@ export class AuthService { } async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> { - const shareKey = (headers['x-immich-share-key'] || params.key) as string; - const session = (headers['x-immich-user-token'] || - headers['x-immich-session-token'] || + const shareKey = (headers[ImmichHeader.SHARED_LINK_TOKEN] || params.key) as string; + const session = (headers[ImmichHeader.USER_TOKEN] || + headers[ImmichHeader.SESSION_TOKEN] || params.sessionKey || this.getBearerToken(headers) || this.getCookieToken(headers)) as string; - const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string; + const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string; if (shareKey) { return this.validateSharedLink(shareKey); @@ -204,10 +192,7 @@ export class AuthService { return { url }; } - async callback( - dto: OAuthCallbackDto, - loginDetails: LoginDetails, - ): Promise<{ response: LoginResponseDto; cookie: string[] }> { + async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { const config = await this.configCore.getConfig(); const profile = await this.getOAuthProfile(config, dto.url); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); @@ -256,7 +241,7 @@ export class AuthService { }); } - return this.createLoginResponse(user, AuthType.OAUTH, loginDetails); + return this.createLoginResponse(user, loginDetails); } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> { @@ -353,7 +338,7 @@ export class AuthService { private getCookieToken(headers: IncomingHttpHeaders): string | null { const cookies = cookieParser.parse(headers.cookie || ''); - return cookies[IMMICH_ACCESS_COOKIE] || null; + return cookies[ImmichCookie.ACCESS_TOKEN] || null; } async validateSharedLink(key: string | string[]): Promise<AuthDto> { @@ -405,7 +390,7 @@ export class AuthService { throw new UnauthorizedException('Invalid user token'); } - private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { + private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); @@ -416,28 +401,7 @@ export class AuthService { deviceType: loginDetails.deviceType, }); - const response = mapLoginResponse(user, key); - const cookie = this.getCookies(response, authType, loginDetails); - return { response, cookie }; - } - - private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) { - const maxAge = 400 * 24 * 3600; // 400 days - - let authTypeCookie = ''; - let accessTokenCookie = ''; - let isAuthenticatedCookie = ''; - - if (isSecure) { - accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - } else { - accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - } - return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie]; + return mapLoginResponse(user, key); } private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T { diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index c11c936a1a..8262b6024b 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -10,13 +10,8 @@ import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.inte import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; -import { - CLIP_MODEL_INFO, - IMMICH_ACCESS_COOKIE, - IMMICH_API_KEY_HEADER, - IMMICH_API_KEY_NAME, - serverVersion, -} from 'src/constants'; +import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Metadata } from 'src/middleware/auth.guard'; @@ -143,14 +138,14 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => { scheme: 'Bearer', in: 'header', }) - .addCookieAuth(IMMICH_ACCESS_COOKIE) + .addCookieAuth(ImmichCookie.ACCESS_TOKEN) .addApiKey( { type: 'apiKey', in: 'header', - name: IMMICH_API_KEY_HEADER, + name: ImmichHeader.API_KEY, }, - IMMICH_API_KEY_NAME, + Metadata.API_KEY_SECURITY, ) .addServer('/api') .build(); diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts new file mode 100644 index 0000000000..f318ca3300 --- /dev/null +++ b/server/src/utils/response.ts @@ -0,0 +1,36 @@ +import { CookieOptions, Response } from 'express'; +import { Duration } from 'luxon'; +import { CookieResponse, ImmichCookie } from 'src/dtos/auth.dto'; + +export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values }: CookieResponse) => { + const defaults: CookieOptions = { + path: '/', + sameSite: 'lax', + httpOnly: true, + secure: isSecure, + maxAge: Duration.fromObject({ days: 400 }).toMillis(), + }; + + const cookieOptions: Record<ImmichCookie, CookieOptions> = { + [ImmichCookie.AUTH_TYPE]: defaults, + [ImmichCookie.ACCESS_TOKEN]: defaults, + // no httpOnly so that the client can know the auth state + [ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false }, + [ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() }, + }; + + for (const { key, value } of values) { + const options = cookieOptions[key]; + res.cookie(key, value, options); + } + + return body; +}; + +export const respondWithoutCookie = <T>(res: Response, body: T, cookies: ImmichCookie[]) => { + for (const cookie of cookies) { + res.clearCookie(cookie); + } + + return body; +}; diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index a4753a02e7..96a0bc0141 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -129,51 +129,21 @@ export const loginResponseStub = { }, }, user1oauth: { - response: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - cookie: [ - 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - ], + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, }, user1password: { - response: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - cookie: [ - 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - ], - }, - user1insecure: { - response: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - cookie: [ - 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;', - ], + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, }, }; From 431ffebddda0a08d7ad327a37f392e01c2400c52 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 19 Apr 2024 11:50:13 -0400 Subject: [PATCH 19/46] feat(server): use embedded preview from raw images (#8773) * extract embedded * update api * add tests * move temp file logic outside of media repo * formatting * revert `toSorted` * disable by default * clarify setting description * wording * wording * update docs * check extracted image dimensions * test that it unlinks * formatting --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> --- docs/docs/install/config-file.md | 3 +- mobile/openapi/doc/SystemConfigImageDto.md | Bin 657 -> 694 bytes .../lib/model/system_config_image_dto.dart | Bin 4361 -> 4688 bytes .../test/system_config_image_dto_test.dart | Bin 1136 -> 1249 bytes open-api/immich-openapi-specs.json | 4 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/cores/storage.core.ts | 5 + server/src/cores/system-config.core.ts | 1 + server/src/dtos/system-config.dto.ts | 3 + server/src/entities/system-config.entity.ts | 2 + server/src/interfaces/media.interface.ts | 7 ++ server/src/repositories/media.repository.ts | 29 +++++- server/src/services/media.service.spec.ts | 97 +++++++++++++++++- server/src/services/media.service.ts | 29 +++++- .../services/system-config.service.spec.ts | 1 + server/src/utils/mime-types.spec.ts | 30 +++--- server/src/utils/mime-types.ts | 39 ++++--- server/test/fixtures/asset.stub.ts | 41 ++++++++ .../repositories/media.repository.mock.ts | 2 + .../settings/image/image-settings.svelte | 10 ++ 20 files changed, 259 insertions(+), 45 deletions(-) diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index a890d674bc..256f3619f1 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -120,7 +120,8 @@ The default configuration looks like this: "previewFormat": "jpeg", "previewSize": 1440, "quality": 80, - "colorspace": "p3" + "colorspace": "p3", + "extractEmbedded": false }, "newVersionCheck": { "enabled": true diff --git a/mobile/openapi/doc/SystemConfigImageDto.md b/mobile/openapi/doc/SystemConfigImageDto.md index 1b9bbe726dbe04b9c5dc7eb4bf7dd33aaed7659a..81e88045d51f65d36a05c4098d8de21e5d12a1e9 100644 GIT binary patch delta 41 wcmbQpx{Y;%7^6&TMM+U&a*1nhQff*{YKoSYLXCo!R#JX`j+WMDUd9$i03IX^<NyEw delta 12 TcmdnSI+1mQ7~^JZ#%e|Y8YcsU diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 1c830861aff0739ecba7e1eb112453b1bb5d5a44..7072e11270ab3e9d09f06d7ca531a805723bd77f 100644 GIT binary patch delta 297 zcmeBFx}dUQ4<mnSMM+U&a*1nhQff*{YRcr3jM9@2Gp-a#%FoYHK$F~@&LqaDgeIt9 zYm1>SpV=1E<XJ4A(G}XNpc%Hgn^lle15HppR>4-GBqOs}4^3k70XDJ8Ih-7uSF`mq z*`Y~VDdZ*=geB&bruwIaR2HP#fP7@9kyV_Zr=w7$j;30DvmR$DJ4R^C7RUntpcifp delta 51 zcmV-30L=f;B#9!hyaAE|2b1Ojsk5j85dpL511|xSIR)XfJO&Q|la&e&vylgn0<&rg JX$O<)5oYkh5@-Mb diff --git a/mobile/openapi/test/system_config_image_dto_test.dart b/mobile/openapi/test/system_config_image_dto_test.dart index aef907bbe694d08ab9ab8854175f187f37e79ca9..b46340455ba9fabb6d04396c73cb0d6169c4deb3 100644 GIT binary patch delta 53 vcmeys@sM+aFOx`8etwQZYDGy=VseRVZc=JWN@~jF3rvdqXkwccm;#spCA<@e delta 12 TcmaFJ`GI4DFVp6IOv+3EBM$_6 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bfe3ec32c9..de3456e519 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10531,6 +10531,9 @@ "colorspace": { "$ref": "#/components/schemas/Colorspace" }, + "extractEmbedded": { + "type": "boolean" + }, "previewFormat": { "$ref": "#/components/schemas/ImageFormat" }, @@ -10549,6 +10552,7 @@ }, "required": [ "colorspace", + "extractEmbedded", "previewFormat", "previewSize", "quality", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 560295c94c..cfa60e9249 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -864,6 +864,7 @@ export type SystemConfigFFmpegDto = { }; export type SystemConfigImageDto = { colorspace: Colorspace; + extractEmbedded: boolean; previewFormat: ImageFormat; previewSize: number; quality: number; diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index f1c16e5698..4e5f4742a4 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { APP_MEDIA_LOCATION } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; @@ -308,4 +309,8 @@ export class StorageCore { static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string { return join(this.getNestedFolder(folder, ownerId, filename), filename); } + + static getTempPathInDir(dir: string): string { + return join(dir, `${randomUUID()}.tmp`); + } } diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 9cbe3b8414..2520840173 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -120,6 +120,7 @@ export const defaults = Object.freeze<SystemConfig>({ previewSize: 1440, quality: 80, colorspace: Colorspace.P3, + extractEmbedded: false, }, newVersionCheck: { enabled: true, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 9f80e8d6a3..d23eef4994 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -417,6 +417,9 @@ class SystemConfigImageDto { @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) colorspace!: Colorspace; + + @ValidateBoolean() + extractEmbedded!: boolean; } class SystemConfigTrashDto { diff --git a/server/src/entities/system-config.entity.ts b/server/src/entities/system-config.entity.ts index a8a550fd6d..7126297ce3 100644 --- a/server/src/entities/system-config.entity.ts +++ b/server/src/entities/system-config.entity.ts @@ -114,6 +114,7 @@ export const SystemConfigKey = { IMAGE_PREVIEW_SIZE: 'image.previewSize', IMAGE_QUALITY: 'image.quality', IMAGE_COLORSPACE: 'image.colorspace', + IMAGE_EXTRACT_EMBEDDED: 'image.extractEmbedded', TRASH_ENABLED: 'trash.enabled', TRASH_DAYS: 'trash.days', @@ -284,6 +285,7 @@ export interface SystemConfig { previewSize: number; quality: number; colorspace: Colorspace; + extractEmbedded: boolean; }; newVersionCheck: { enabled: boolean; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 5e51e94a52..a82b38b6de 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -34,6 +34,11 @@ export interface VideoFormat { bitrate: number; } +export interface ImageDimensions { + width: number; + height: number; +} + export interface VideoInfo { format: VideoFormat; videoStreams: VideoStreamInfo[]; @@ -70,9 +75,11 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { export interface IMediaRepository { // image + extract(input: string, output: string): Promise<boolean>; resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; crop(input: string, options: CropOptions): Promise<Buffer>; generateThumbhash(imagePath: string): Promise<Buffer>; + getImageDimensions(input: string): Promise<ImageDimensions>; // video probe(input: string): Promise<VideoInfo>; diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 3936ad7e42..434fb585f8 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; @@ -9,6 +10,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { CropOptions, IMediaRepository, + ImageDimensions, ResizeOptions, TranscodeOptions, VideoInfo, @@ -26,6 +28,23 @@ export class MediaRepository implements IMediaRepository { constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { this.logger.setContext(MediaRepository.name); } + + async extract(input: string, output: string): Promise<boolean> { + try { + await exiftool.extractJpgFromRaw(input, output); + } catch (error: any) { + this.logger.debug('Could not extract JPEG from image, trying preview', error.message); + try { + await exiftool.extractPreview(input, output); + } catch (error: any) { + this.logger.debug('Could not extract preview from image', error.message); + return false; + } + } + + return true; + } + crop(input: string | Buffer, options: CropOptions): Promise<Buffer> { return sharp(input, { failOn: 'none' }) .pipelineColorspace('rgb16') @@ -133,6 +152,11 @@ export class MediaRepository implements IMediaRepository { return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); } + async getImageDimensions(input: string): Promise<ImageDimensions> { + const { width = 0, height = 0 } = await sharp(input).metadata(); + return { width, height }; + } + private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) { return ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) @@ -140,9 +164,4 @@ export class MediaRepository implements IMediaRepository { .output(output) .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); } - - private chainPath(existing: string, path: string) { - const separator = existing.endsWith(':') ? '' : ':'; - return `${existing}${separator}${path}`; - } } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index c6301c7c33..6f02e72253 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -393,14 +393,12 @@ describe(MediaService.name, () => { }); it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.resize).toHaveBeenCalledWith( - '/original/path.jpg', + assetStub.imageDng.originalPath, 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', { format: ImageFormat.WEBP, @@ -415,7 +413,96 @@ describe(MediaService.name, () => { }); }); - describe('handleGenerateThumbhashThumbnail', () => { + it('should extract embedded image if enabled and available', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.resize.mock.calls).toEqual([ + [ + extractedPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ], + ]); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); + + it('should resize original image if embedded image is too small', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + expect(mediaMock.resize.mock.calls).toEqual([ + [ + assetStub.imageDng.originalPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ], + ]); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); + + it('should resize original image if embedded image not found', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + expect(mediaMock.resize).toHaveBeenCalledWith( + assetStub.imageDng.originalPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should resize original image if embedded image extraction is not enabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: false }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.resize).toHaveBeenCalledWith( + assetStub.imageDng.originalPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + describe('handleGenerateThumbhash', () => { it('should skip thumbhash generation if asset not found', async () => { assetMock.getByIds.mockResolvedValue([]); await sut.handleGenerateThumbhash({ id: assetStub.image.id }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index ca72b6cbdd..1795db86d0 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { dirname } from 'node:path'; import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; @@ -42,6 +43,7 @@ import { VAAPIConfig, VP9Config, } from 'src/utils/media'; +import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @Injectable() @@ -195,9 +197,21 @@ export class MediaService { switch (asset.type) { case AssetType.IMAGE: { - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { format, size, colorspace, quality: image.quality }; - await this.mediaRepository.resize(asset.originalPath, path, imageOptions); + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(path)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const imageOptions = { format, size, colorspace, quality: image.quality }; + + await this.mediaRepository.resize(useExtracted ? extractedPath : asset.originalPath, path, imageOptions); + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } + } break; } @@ -527,7 +541,7 @@ export class MediaService { } } - parseBitrateToBps(bitrateString: string) { + private parseBitrateToBps(bitrateString: string) { const bitrateValue = Number.parseInt(bitrateString); if (Number.isNaN(bitrateValue)) { @@ -542,4 +556,11 @@ export class MediaService { return bitrateValue; } } + + private async shouldUseExtractedImage(extractedPath: string, targetSize: number) { + const { width, height } = await this.mediaRepository.getImageDimensions(extractedPath); + const extractedSize = Math.min(width, height); + + return extractedSize >= targetSize; + } } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 49bf8d6544..5f55effcac 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -129,6 +129,7 @@ const updatedConfig = Object.freeze<SystemConfig>({ previewSize: 1440, quality: 80, colorspace: Colorspace.P3, + extractEmbedded: false, }, newVersionCheck: { enabled: true, diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index bce75e1e10..cbbf751bc5 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -106,12 +106,6 @@ describe('mimeTypes', () => { expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); }); - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.profile); - // TODO: use toSorted in NodeJS 20. - expect(keys).toEqual([...keys].sort()); - }); - for (const [extension, v] of Object.entries(mimeTypes.profile)) { it(`should lookup ${extension}`, () => { expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]); @@ -128,12 +122,6 @@ describe('mimeTypes', () => { expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); }); - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.image); - // TODO: use toSorted in NodeJS 20. - expect(keys).toEqual([...keys].sort()); - }); - it('should contain only image mime types', () => { const values = Object.values(mimeTypes.image).flat(); expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/'))); @@ -157,7 +145,6 @@ describe('mimeTypes', () => { it('should be a sorted list', () => { const keys = Object.keys(mimeTypes.video); - // TODO: use toSorted in NodeJS 20. expect(keys).toEqual([...keys].sort()); }); @@ -184,7 +171,6 @@ describe('mimeTypes', () => { it('should be a sorted list', () => { const keys = Object.keys(mimeTypes.sidecar); - // TODO: use toSorted in NodeJS 20. expect(keys).toEqual([...keys].sort()); }); @@ -198,4 +184,20 @@ describe('mimeTypes', () => { }); } }); + + describe('raw', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.raw); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + + const values = Object.values(mimeTypes.raw).flat(); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + for (const [extension, v] of Object.entries(mimeTypes.video)) { + it(`should lookup ${extension}`, () => { + expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]); + }); + } + }); }); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index a888e4f423..495efc9ebc 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -1,12 +1,10 @@ import { extname } from 'node:path'; import { AssetType } from 'src/entities/asset.entity'; -const image: Record<string, string[]> = { +const raw: Record<string, string[]> = { '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], '.ari': ['image/ari', 'image/x-arriflex-ari'], '.arw': ['image/arw', 'image/x-sony-arw'], - '.avif': ['image/avif'], - '.bmp': ['image/bmp'], '.cap': ['image/cap', 'image/x-phaseone-cap'], '.cin': ['image/cin', 'image/x-phantom-cin'], '.cr2': ['image/cr2', 'image/x-canon-cr2'], @@ -16,16 +14,7 @@ const image: Record<string, string[]> = { '.dng': ['image/dng', 'image/x-adobe-dng'], '.erf': ['image/erf', 'image/x-epson-erf'], '.fff': ['image/fff', 'image/x-hasselblad-fff'], - '.gif': ['image/gif'], - '.heic': ['image/heic'], - '.heif': ['image/heif'], - '.hif': ['image/hif'], '.iiq': ['image/iiq', 'image/x-phaseone-iiq'], - '.insp': ['image/jpeg'], - '.jpe': ['image/jpeg'], - '.jpeg': ['image/jpeg'], - '.jpg': ['image/jpeg'], - '.jxl': ['image/jxl'], '.k25': ['image/k25', 'image/x-kodak-k25'], '.kdc': ['image/kdc', 'image/x-kodak-kdc'], '.mrw': ['image/mrw', 'image/x-minolta-mrw'], @@ -33,7 +22,6 @@ const image: Record<string, string[]> = { '.orf': ['image/orf', 'image/x-olympus-orf'], '.ori': ['image/ori', 'image/x-olympus-ori'], '.pef': ['image/pef', 'image/x-pentax-pef'], - '.png': ['image/png'], '.psd': ['image/psd', 'image/vnd.adobe.photoshop'], '.raf': ['image/raf', 'image/x-fuji-raf'], '.raw': ['image/raw', 'image/x-panasonic-raw'], @@ -42,11 +30,27 @@ const image: Record<string, string[]> = { '.sr2': ['image/sr2', 'image/x-sony-sr2'], '.srf': ['image/srf', 'image/x-sony-srf'], '.srw': ['image/srw', 'image/x-samsung-srw'], + '.x3f': ['image/x3f', 'image/x-sigma-x3f'], +}; + +const image: Record<string, string[]> = { + ...raw, + '.avif': ['image/avif'], + '.bmp': ['image/bmp'], + '.gif': ['image/gif'], + '.heic': ['image/heic'], + '.heif': ['image/heif'], + '.hif': ['image/hif'], + '.insp': ['image/jpeg'], + '.jpe': ['image/jpeg'], + '.jpeg': ['image/jpeg'], + '.jpg': ['image/jpeg'], + '.jxl': ['image/jxl'], + '.png': ['image/png'], '.svg': ['image/svg'], '.tif': ['image/tiff'], '.tiff': ['image/tiff'], '.webp': ['image/webp'], - '.x3f': ['image/x3f', 'image/x-sigma-x3f'], }; const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); @@ -77,22 +81,25 @@ const sidecar: Record<string, string[]> = { '.xmp': ['application/xml', 'text/xml'], }; +const types = { ...image, ...video, ...sidecar }; + const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r; -const lookup = (filename: string) => - ({ ...image, ...video, ...sidecar })[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; +const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; export const mimeTypes = { image, profile, sidecar, video, + raw, isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isImage: (filename: string) => isType(filename, image), isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), + isRaw: (filename: string) => isType(filename, raw), lookup, assetType: (filename: string) => { const contentType = lookup(filename); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 7aa49866d0..ce2b070672 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -757,4 +757,45 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, }), + imageDng: Object.freeze<AssetEntity>({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.dng', + previewPath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + thumbnailPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, + isReadOnly: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + } as ExifEntity, + }), }; diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 2eea47b6ac..da3e05fe81 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -4,9 +4,11 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => { return { generateThumbhash: vitest.fn(), + extract: vitest.fn().mockResolvedValue(false), resize: vitest.fn(), crop: vitest.fn(), probe: vitest.fn(), transcode: vitest.fn(), + getImageDimensions: vitest.fn(), }; }; diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index 5b984e2305..2a1853f904 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -101,6 +101,16 @@ isEdited={config.image.colorspace !== savedConfig.image.colorspace} {disabled} /> + + <SettingSwitch + id="prefer-embedded" + title="PREFER EMBEDDED PREVIEW" + subtitle="Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts." + checked={config.image.extractEmbedded} + on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} + isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} + {disabled} + /> </div> <div class="ml-4"> From 886e07604e559fd263ec2b7609df7506ddc5a495 Mon Sep 17 00:00:00 2001 From: Alex The Bot <alex.tran1502@gmail.com> Date: Fri, 19 Apr 2024 20:08:02 +0000 Subject: [PATCH 20/46] Version v1.102.0 --- cli/package-lock.json | 6 +++--- e2e/package-lock.json | 10 +++++----- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | Bin 26368 -> 26368 bytes mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 16 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 11154fec52..899c1cd487 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,15 +47,15 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.12.7", - "typescript": "^5.4.5" + "@types/node": "^20.11.0", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6cd8dd90ec..bdafbadd4f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.101.0", + "version": "1.102.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.101.0", + "version": "1.102.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -81,15 +81,15 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.12.7", - "typescript": "^5.4.5" + "@types/node": "^20.11.0", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/e2e/package.json b/e2e/package.json index 9023de8162..9e5ad85fb4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.101.0", + "version": "1.102.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index e5d8e06d43..5610f8438d 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.101.0" +version = "1.102.0" description = "" authors = ["Hau Tran <alex.tran1502@gmail.com>"] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 65307abde8..50a22b6451 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 131, - "android.injected.version.name" => "1.101.0", + "android.injected.version.code" => 132, + "android.injected.version.name" => "1.102.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 6cf9173c1c..74ffcf4237 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.101.0" + version_number: "1.102.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7fb4681f792960b00efc67e22aa2dedc934a6c4a..7bd651ad102c11fb2fa745527182aeed1d36db10 100644 GIT binary patch delta 14 VcmZoT$JlU=al&jyqm6S?(f}@~1{44Q delta 14 VcmZoT$JlU=al&jy!;N!N(f}@_1`_}P diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8dcc892a06..70c443d832 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.101.0+131 +version: 1.102.0+132 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index de3456e519..6e616febbc 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7006,7 +7006,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.101.0", + "version": "1.102.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 8def6adffd..e698fb97ff 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 887fece059..eab26bc4e1 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index cfa60e9249..dc121117b3 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.101.0 + * 1.102.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 286f1006b9..c3f8e8cf79 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.101.0", + "version": "1.102.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.101.0", + "version": "1.102.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index d5828822cd..bdecc1362a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.101.0", + "version": "1.102.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index b5e3a6c2f9..d36059ca05 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.101.0", + "version": "1.102.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.101.0", + "version": "1.102.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 8418af55aa..5e2ec844a1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.101.0", + "version": "1.102.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 57be9182d4506d886f87061e6bbbf0b61e5f50ff Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Fri, 19 Apr 2024 15:32:45 -0500 Subject: [PATCH 21/46] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 +++--- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/report.xml | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index d39c4a373f..9ff4624d67 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000219"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256"> </testcase> - <testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.515419"> + <testcase classname="fastlane.lanes" name="1: bundleRelease" time="73.93743"> </testcase> - <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="35.431743"> + <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="34.73691"> </testcase> diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 1894e39798..26153c05cd 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -525,7 +525,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -553,7 +553,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 64b4ea5474..3e7f6a874a 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.101.0</string> + <string>1.102.0</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>147</string> + <string>148</string> <key>FLTEnableImpeller</key> <true /> <key>ITSAppUsesNonExemptEncryption</key> diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 1d6f7ff460..85320ab12a 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000242"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000399"> </testcase> - <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.761829"> + <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.247535"> </testcase> - <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.47461"> + <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="8.325258"> </testcase> - <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.179512"> + <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.180002"> </testcase> - <testcase classname="fastlane.lanes" name="4: build_app" time="165.636347"> + <testcase classname="fastlane.lanes" name="4: build_app" time="199.335284"> </testcase> - <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="77.651963"> + <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="90.564254"> </testcase> From 78c7ff855de0fe43d8aa956a90c4fa0f87d6f0f7 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 20 Apr 2024 02:35:54 +0200 Subject: [PATCH 22/46] refactor(server): move file file report endpoints to their own controller (#8925) * move file report to its own controller * chore: open api --- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 26368 -> 26365 bytes mobile/openapi/doc/AuditApi.md | Bin 8494 -> 2481 bytes mobile/openapi/doc/FileReportApi.md | Bin 0 -> 6243 bytes mobile/openapi/lib/api.dart | Bin 9081 -> 9114 bytes mobile/openapi/lib/api/audit_api.dart | Bin 6836 -> 2478 bytes mobile/openapi/lib/api/file_report_api.dart | Bin 0 -> 4721 bytes mobile/openapi/test/audit_api_test.dart | Bin 1053 -> 637 bytes mobile/openapi/test/file_report_api_test.dart | Bin 0 -> 883 bytes open-api/immich-openapi-specs.json | 224 +++++++++--------- open-api/typescript-sdk/src/fetch-client.ts | 128 +++++----- server/src/controllers/audit.controller.ts | 31 +-- .../src/controllers/file-report.controller.ts | 30 +++ server/src/controllers/index.ts | 16 +- 14 files changed, 221 insertions(+), 211 deletions(-) create mode 100644 mobile/openapi/doc/FileReportApi.md create mode 100644 mobile/openapi/lib/api/file_report_api.dart create mode 100644 mobile/openapi/test/file_report_api_test.dart create mode 100644 server/src/controllers/file-report.controller.ts diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 2181476b3a..42f1034dce 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -69,6 +69,7 @@ doc/FaceApi.md doc/FaceDto.md doc/FileChecksumDto.md doc/FileChecksumResponseDto.md +doc/FileReportApi.md doc/FileReportDto.md doc/FileReportFixDto.md doc/FileReportItemDto.md @@ -212,6 +213,7 @@ lib/api/audit_api.dart lib/api/authentication_api.dart lib/api/download_api.dart lib/api/face_api.dart +lib/api/file_report_api.dart lib/api/job_api.dart lib/api/library_api.dart lib/api/memory_api.dart @@ -478,6 +480,7 @@ test/face_api_test.dart test/face_dto_test.dart test/file_checksum_dto_test.dart test/file_checksum_response_dto_test.dart +test/file_report_api_test.dart test/file_report_dto_test.dart test/file_report_fix_dto_test.dart test/file_report_item_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7bd651ad102c11fb2fa745527182aeed1d36db10..3ebd65025b606cd030899cfffaad5099acb6832b 100644 GIT binary patch delta 153 zcmZoT$N2Xw<AxXFlh;ddOb(Oq*<2#Y!NTd5nUflnT998f`L4Jk5|2qjS)vFeUZS6t yS)ov)z@>#yx<mq5=}8GC5r|SWWvr4aNG9q^s_{cjOwLG6&Mq#^-CQo)=?MUr-!^Li delta 162 zcmex+ma*X+<AxXFlOtrrSR6}JGAEbID2pZn872B@nK`MtMX3e(MId&?<SXJC5<qds zf=n%i8ii;rt@P9qkd_j+%$&(z#pNfzkr9%@&<a$_r3E%wNLB@6g}tmAraJxPjMU`p W;?ms7>m+112T6Ql-dri$;t2p2jyppD diff --git a/mobile/openapi/doc/AuditApi.md b/mobile/openapi/doc/AuditApi.md index 8514cdec7376841e8525ae099fb31043b4368805..2c768c40d190b52b7744eb50cee37b5a19f03308 100644 GIT binary patch delta 15 XcmZ4Iv{87%waHqHKAX33_HzILHY^4% delta 1162 zcmah{&x_MQ6sEXjL-ZgBdeDW3*+V+g2KJz`s|)qVdJ!$%1qBg_$<&T+W`@jETI{kn z1z|xM@Ne)WXfOU99z-vC)PKR7Z_+JJQ>{5A?|u2+_rCYNKk@F$tIzE&8?^cv(_3tp zDnjl@<yT9xW}b!EOl-4jn{^lgA?@4SJA^=7)??df6jX{r<NE*+uRpc-?-BBd>VMN> zvr2p8`rlcRF__R<fmeGpeWLpLoKR%6Qqr<d5%52;6J$TP{6P@nVihBTO_-@fmcLVO zy2S?9v>5Nz!lnIc6)uAn>H?$s2E_4Pxty$vAv_BJpG2|;j4Pe+lr|Snu5Sc;kS03q zfqCn#9d*#=hxcMm6P{}XXkt~Ey>_*RSFewt)E0&zbg1qZ9Q07qAb2WR1`*Ar9zsEt z6kJhwD6(N49Yk=euY00k=A!@RTq5NzLyb)E_^}c^fChTpN>eH|A?t8UXvk=nahjQ5 z{>SlZ?Z98NXQ%`R&+tbgx(+k_2#>I`WY)ddvnUmrm)_kY_=-N9B|AN)fbvWV#&MdU z3OJ7ClxC3dA;_dia;j<3G~bs`o}QUA>&<x8Kd9X~lxJ~H5K-Yxrt~GZ+&Ody1#eo# zZcmj-ni5QdbfN38+C3*Za$ru3oT@qRHCGnWqb1wNT!i`QJ#SUUxe;;Lr!F|9X+X@k X<x`D?i&uX(XZ#PxtGO^={V%@(h*y`H diff --git a/mobile/openapi/doc/FileReportApi.md b/mobile/openapi/doc/FileReportApi.md new file mode 100644 index 0000000000000000000000000000000000000000..b722c860414a3c83e71147e6171db5a25a092b13 GIT binary patch literal 6243 zcmeH~QE%He5Xay7DGu-e%R((<drZ3kuH&>t+9YtChaw9SMWSskGO0;)8@I`K-~U7@ zmYgg}*L7G18wea9kH_QP@qT2BOe%Gls(}A?RU$_+HJR<FN{CjA-kUHcJCoEud`D>* zU4~OB&d<-|Ftb9<S(VzQe5*9oV^gfqe#xpr^pk{+NAGe9GfA0DLaRPWvW9$C^@aE# z?aah<O>f8JLumZ5kh#Ur9hpa&O06<lbn73C)qi%^e~S~}pQx+;B370+mHYmw*VtV! zk6X|PS!2Q)9oX>w!-J!-?^7=;qR@i|#B6OU?IYHpv2N|nux4y!h#eWsWOSJ?=J$IB zTkKR+Dc$Q_HOhvOEO@`RWwA{SiN`iwes|T?jx`<3LzY`Kyb9-O@=clT^=N#sb3g;5 zCu+JtX1PQz<&we$vYDwbNVCyf6d7}=r0DfxISCgDm)0OrQrn=2DT+&56&1>_Ft#U` zeHhsG+HPXI!=(u+{YveR2gjo`taG+E{D|s;4+|Zc`5Y$bLMG-jS5+?UBBeBwoIOtl z(|}$}lwQ`MiODKAUyz!RmNJ&{_xpC}7K4XwCFk4I;)C6tA#8uciE`$DGeYamJt;)R z`F9~)FQbbhg`Dz1m`nOOpksU;*-Ysvjm@RhPmADNYg4yES!T{Kf{425N0H3)!kSkW z+h8y}IyxI4><#zZoucH5^?z~e@1p*=%<1z?MKhX8Ewj*abAv+xds}+AG>fdT`^Kr5 zteWa|=qm+5KsW#J2L5_U>_beH4in-mDV^I;M-m5&JJT%yJg7OX^}f;2bgcx+gx#(T zIC0UNY__DYg7B#?7vOlVTI${2^itdq+5)2LNrb4FpCa4VYY(p?Sx^CCml|40!d0+Z zyq~J>cEkP_{Um?WoWUChC2o6%VHVD%m02#tKK_HZ<7LWUHS!RC?i(xfZ$l7fH4r6= z{#=FEMF*t0CctIocqOmX%E<t%&NchuI`#-nF4JV;t`MJ$iV>xGYL}pAo*Ip#abe;m z8a(flEB&@33vuE)>!}w6z0t70^I^y|bi&RHIw_Ct+IT5t1$SiD#+|TSu>ta#4C5lk z)I|^g42yo<<w*Pf7HT3*%KqQ`JqPIvEk22<<#EKGfDWb6sKv?2Yg|IO%UhG4o_ehg ztwao<MCBIp#?E3BEf^66W4d5K7klYS9j6B61oE#;d@!+0o@ivwx{cW`m}|>(M6m6( z3k2A_$~sVt)=|4)cpEZr$o!-3F6BYKy*6yf{2a+FR#bJ6M&rlr8ffPHR!nzy?CKT7 zL}v^;jJgR-#P8#5qjNpR+3`%}RJC4;jm$}-!Sh9&LcPQrvqW-m59b=FMAK}(>L6f9 zJ_F>{k+w#=J2ZUW$YaLUp1PpkdjR@XT|6o;EJ)urQ?Ndk7uUkd05mpS+;DNj#o{Tv zd9gn7Vy(4}^1)h@)ZVK#Y4N;zGFTt(R6hu{jYS`$?Z5V_t;MkZsBMVdo3<u;=t&FI YKS+uS{gt^IMV`qe;122kr#FE=0Ypz8&;S4c literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b484d38b688e705f5bd013784b819cbb49784f8c..8520bab3056ca6340d4b5fabdb3bdfadb00de704 100644 GIT binary patch delta 24 gcmezAHp_j(H)ig%%$(HtqSS)?qLR%Im}klZ0E{mRz5oCK delta 12 TcmbQ`{?l#4H|EXaEY0!&C)ou$ diff --git a/mobile/openapi/lib/api/audit_api.dart b/mobile/openapi/lib/api/audit_api.dart index 871c8e1905b1c3e07ecc39875f812a365d9d4beb..83dde34da7e5fcf2b4bc1ef92dba5d16dbebd791 100644 GIT binary patch delta 15 WcmdmDx=wh*F2>CfY@gVfYPkS390jQW delta 1195 zcmb7CO=}ZT6lIdOX{w;6Em(wb3znH_C(TNbBu(&xU_nvZf-4u}<fVOfoHx$AsjVV) zA?Q+g{Q>$1M64^fF5LJ7T)A=CKjD4Tp}Es&(A~^^_ndprx#z?5=PMt-7?<wdUEhGZ zoAkJ>_qoq%5er46zjxJlW3|@B+FDxb^4*(KJOJHAD_}+JNx~!6v(?MOYzvXsG@VAq z_qp7<C1to8^o4brYlzBGM}<4*^Z>d>r`Vi^m7!+!toTO#8mm@JHw=C5xsr>Z{wNlK zsf_WfMO3Dm<Dd3VPaUbCF$K^~WD>E~j^MpEP9<voL4gf!ycc-z9EkwYf%-8qvy@}W zd7{NqHy!1a4vZIv?~JpP>b-IK3~>VwvZ!7a<|<lJ-db&3uC-JAisl?GWMT#;;0-%% z*kp2WBujmpsQrQT&?(u4Y7S~TS|AK+>UDVm_nd>j1<BiD-gcZk)@fSOxEopFBuu1` z&(g&P?avFQFUXXpgAk=jn(31yo$P^es$_8@fd5GofgOj56o>+nrQ1A~ExPg=i$f8_ z3|DU-pM$=}Y0xOER-*qWwUr2R@MP9v4l@)hf_LzgKMnLFaD9lf+1EvH4_4GcY5sgO zO)KCNMn3>cT`14*(>@2xks8c27P`gGr~sBdcZ6z-G)ke7)@CH0x(|KUfWGU;W6W+& Iski0oZ|_;GIRF3v diff --git a/mobile/openapi/lib/api/file_report_api.dart b/mobile/openapi/lib/api/file_report_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..df307e12c714a9ba22408f202b64d3174a206467 GIT binary patch literal 4721 zcmeHL-*4MC5PtVxaqUBG51!oxeHhZjnVZ;cfG*Aw7sU_+E+d^{wlb-bRBW%v|GpzB z$x{5IS&#uK(1+M2b$7fw-hJOGoldROf!>GF@YlEflm1z6)IWm3@%w%Yj)rhD9KoN% zqvOHf&q&Nl{t+@|_IKL9?bPg&Ukj;l97#2fc#OM{id17b#u%q4Oe<gWX(E+btHI(K zTj+6;nz6#KDOaQ}*pxmKrs&(C(qvre_3D8X+Av|pJfs78#8EkQz4ow}3)JISifa-x z6DG#LzZ?e>rgW`F#~F|j$OHvTc$@xS*J`yOW?I95qB}zyL@(j+tp?Sd#szI3#++O@ z08;cnFPpHtT)}?n_rU>#7_l_A_PjJ51`u(<Vkq{O8t!R_Qhthx(mmC{1iTxKPN9A} zJRd<vIqy0VpVw^&iWx2i35B50q)KQo6NHO1)Cr{wFWYpQvsYw!*Y9Nezre*Z=mDP} z8F@+W<!SIc(8%wsX|)F_orHV&8C{Z}_tKCXYe|3PX7bLM<XA+qao6^W=~fe%UWmXY z+q#+++b$#uBf36!^WzZdf_$)=JjgScFf*Zqt9m&vE{E$(xvW!J1T6MOhF704psP%z zHg9COAX9fk&y4vC`<kL!oZ3uk65ac&SavTiUzgmPAPZ3~1<#G*;<^R?yZi07ENriN ztEe;V(g5^nsQ5;U1f2(&!HYUel9&gs7&@P|6m{~)78+z_v8=E*7dP??_D+u*?m2)> zVk`UYs;r)`O7Z4fd8+btIn;b_8BxAd_Pfn>f0~#D9DqpUn0lx3)M0?vYuYs3+A#j7 z3cis%yi>jZxng}~sI1>?_s<wNTUz37g|%Fm06em7=G(SSU}sW&C_{wqKG=3TcRTGX z7M>A)qBhN}Wq>+f$(coy{yf0M0>U~&t;{pqOJvv9`<sP9UC1^wM{X-wU%{cb{Q`#W z4?!!CagAmJWIQS$)E^E8KSt+2w9n}lpI)11Rk9=vVxxmDRBEMBt&a`uS5c`j(o^g< zM^+yvNJLI}J(UJQqq;#=kh+j?WeI9V<Vr#w9_$#i^YT>-W+Xphgr!0n8aQlw12>lX zo#mc!LxJH_v??>j4bc(paovJXcGTM?2&UuACpv)4ctD}a<M{`H^Mp7OLqM7_NSh0q z;hyKF2Si@X6u1L38-!5{XJ6*NkR^1GuN@J3$0~SdgCk-%k;Zewix(97Di%JgV4;wQ zo)4E$=!c*T>52)h9U|qEvzY&bJv(hQJV<Yb!WPskRakd(N@*IT%Jd}eGGQUJpjyjn zRpwg3(8??qOfdMO)9L-u^d5A0m>DhRxzfh>#yM&p8+@*@I??4%$haGrO6y0S=Yl*p z56AIrs4MySV<7v_nXah7`xo(huFYP#c=MMOGST%^Qhuu4xK}ET@09aqwy9XH5KdHT zCGz4)j(a}|=&=Z>S|N7GE?x;XvyEqxf<Q8M+CpDBH(L8dRa>jRZU-i?B2C9qmZ{nS h+tMeElAw`RvvEyt80^aa>nEa@XQUx&8}HrL{sjfb8i@b^ literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/audit_api_test.dart b/mobile/openapi/test/audit_api_test.dart index 8161d4e4dbfd49d00f70e97518002ed37aae89bb..8114283a1a339405796696eb51ff9b127a98e965 100644 GIT binary patch delta 18 ZcmbQs@t0-83C793OzYWdHLba7xd22|1}Ojl delta 263 zcmey%GM8h+3C19Ww9E>}(v-{+x6GW>Vhs=(lv<Eq1mst^l;i`YQ3W&=5{oPIlDQOs zpd_`pL_-}!BAYVVk4ba#KE|CI3JUuAZlxuqMX5FjV}WMcDWs>CAlW%ti%Fgtr~;%h z3Q3bsW^oBvr*lSXa&~cPZcu7*L4ICwD##45As}O6k`TAUcrd3U@qyMz@gVD%?9ZeJ E0Ka%$-v9sr diff --git a/mobile/openapi/test/file_report_api_test.dart b/mobile/openapi/test/file_report_api_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..255c787002b022c803d609cdf43b82fb7d6c7c52 GIT binary patch literal 883 zcmb7?O>5gg5Qgvi71NV#aHAxr;(QPjStZ~`VBAAa%OZ{CA=2(<KU#<4|K3@t!ZEd> zJ!r+O-g)0=2Bm3|rZE4$E<S%<JTF%B^&*4(X}Op}R={(yhHpjol)rppSyVn$()zpW zi>vD-mQ*WkQEapo8)|R{K?R4Ezy@1Xemd0D8g2c_4%z&a?@pNDg~hi(mfMwV`83k< z+oN@CZr$njQY+`B@`5T3qz$3%+TF>ph7>x{XjQY!Z>17nE=9SO)+I^L2qPoQS6Sms z8&uLz%FjiddUW%b#G~_RJO)8rN(%AD0WSq|UdjsN>~llO4^0)clGJztzY+kJZb-=n zsGFfgxI69`Pi_*{)m8^H9-XlrP2rDUbj*Vf7TI?<4;A?c;Km1xoOYSl{CVK&FD4+} zyDB*gI{H5B*rm_ubRB)v@&xN5E4sdY4|{;PJ;ZY6J=EwwFrA4!68(SVlAP~yAGcV( ly3nrB8U95u5_AX=YWUc1W~he6e5lVu9@+dOXWY;}`3;^7AC>?B literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6e616febbc..f49df7baea 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2345,118 +2345,6 @@ ] } }, - "/audit/file-report": { - "get": { - "operationId": "getAuditFiles", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileReportDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Audit" - ] - } - }, - "/audit/file-report/checksum": { - "post": { - "operationId": "getFileChecksums", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileChecksumDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/FileChecksumResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Audit" - ] - } - }, - "/audit/file-report/fix": { - "post": { - "operationId": "fixAuditFiles", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileReportFixDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Audit" - ] - } - }, "/auth/admin-sign-up": { "post": { "operationId": "signUpAdmin", @@ -4429,6 +4317,118 @@ ] } }, + "/report": { + "get": { + "operationId": "getAuditFiles", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReportDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "File Report" + ] + } + }, + "/report/checksum": { + "post": { + "operationId": "getFileChecksums", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileChecksumDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FileChecksumResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "File Report" + ] + } + }, + "/report/fix": { + "post": { + "operationId": "fixAuditFiles", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReportFixDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "File Report" + ] + } + }, "/search": { "get": { "deprecated": true, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index dc121117b3..9148b4d3b1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -316,27 +316,6 @@ export type AuditDeletesResponseDto = { ids: string[]; needsFullSync: boolean; }; -export type FileReportItemDto = { - checksum?: string; - entityId: string; - entityType: PathEntityType; - pathType: PathType; - pathValue: string; -}; -export type FileReportDto = { - extras: string[]; - orphans: FileReportItemDto[]; -}; -export type FileChecksumDto = { - filenames: string[]; -}; -export type FileChecksumResponseDto = { - checksum: string; - filename: string; -}; -export type FileReportFixDto = { - items: FileReportItemDto[]; -}; export type SignUpDto = { email: string; name: string; @@ -599,6 +578,27 @@ export type AssetFaceUpdateDto = { export type PersonStatisticsResponseDto = { assets: number; }; +export type FileReportItemDto = { + checksum?: string; + entityId: string; + entityType: PathEntityType; + pathType: PathType; + pathValue: string; +}; +export type FileReportDto = { + extras: string[]; + orphans: FileReportItemDto[]; +}; +export type FileChecksumDto = { + filenames: string[]; +}; +export type FileChecksumResponseDto = { + checksum: string; + filename: string; +}; +export type FileReportFixDto = { + items: FileReportItemDto[]; +}; export type SearchFacetCountResponseDto = { count: number; value: string; @@ -1651,35 +1651,6 @@ export function getAuditDeletes({ after, entityType, userId }: { ...opts })); } -export function getAuditFiles(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: FileReportDto; - }>("/audit/file-report", { - ...opts - })); -} -export function getFileChecksums({ fileChecksumDto }: { - fileChecksumDto: FileChecksumDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; - data: FileChecksumResponseDto[]; - }>("/audit/file-report/checksum", oazapfts.json({ - ...opts, - method: "POST", - body: fileChecksumDto - }))); -} -export function fixAuditFiles({ fileReportFixDto }: { - fileReportFixDto: FileReportFixDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/audit/file-report/fix", oazapfts.json({ - ...opts, - method: "POST", - body: fileReportFixDto - }))); -} export function signUpAdmin({ signUpDto }: { signUpDto: SignUpDto; }, opts?: Oazapfts.RequestOpts) { @@ -2206,6 +2177,35 @@ export function getPersonThumbnail({ id }: { ...opts })); } +export function getAuditFiles(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: FileReportDto; + }>("/report", { + ...opts + })); +} +export function getFileChecksums({ fileChecksumDto }: { + fileChecksumDto: FileChecksumDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: FileChecksumResponseDto[]; + }>("/report/checksum", oazapfts.json({ + ...opts, + method: "POST", + body: fileChecksumDto + }))); +} +export function fixAuditFiles({ fileReportFixDto }: { + fileReportFixDto: FileReportFixDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/report/fix", oazapfts.json({ + ...opts, + method: "POST", + body: fileReportFixDto + }))); +} export function search({ clip, motion, page, q, query, recent, size, smart, $type, withArchived }: { clip?: boolean; motion?: boolean; @@ -2948,20 +2948,6 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } -export enum PathEntityType { - Asset = "asset", - Person = "person", - User = "user" -} -export enum PathType { - Original = "original", - Preview = "preview", - Thumbnail = "thumbnail", - EncodedVideo = "encoded_video", - Sidecar = "sidecar", - Face = "face", - Profile = "profile" -} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", @@ -2993,6 +2979,20 @@ export enum Type2 { export enum MemoryType { OnThisDay = "on_this_day" } +export enum PathEntityType { + Asset = "asset", + Person = "person", + User = "user" +} +export enum PathType { + Original = "original", + Preview = "preview", + Thumbnail = "thumbnail", + EncodedVideo = "encoded_video", + Sidecar = "sidecar", + Face = "face", + Profile = "profile" +} export enum SearchSuggestionType { Country = "country", State = "state", diff --git a/server/src/controllers/audit.controller.ts b/server/src/controllers/audit.controller.ts index 1487e78d47..8eea6a6e3e 100644 --- a/server/src/controllers/audit.controller.ts +++ b/server/src/controllers/audit.controller.ts @@ -1,15 +1,8 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { - AuditDeletesDto, - AuditDeletesResponseDto, - FileChecksumDto, - FileChecksumResponseDto, - FileReportDto, - FileReportFixDto, -} from 'src/dtos/audit.dto'; +import { AuditDeletesDto, AuditDeletesResponseDto } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AdminRoute, Auth, Authenticated } from 'src/middleware/auth.guard'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AuditService } from 'src/services/audit.service'; @ApiTags('Audit') @@ -22,22 +15,4 @@ export class AuditController { getAuditDeletes(@Auth() auth: AuthDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> { return this.service.getDeletes(auth, dto); } - - @AdminRoute() - @Get('file-report') - getAuditFiles(): Promise<FileReportDto> { - return this.service.getFileReport(); - } - - @AdminRoute() - @Post('file-report/checksum') - getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> { - return this.service.getChecksums(dto); - } - - @AdminRoute() - @Post('file-report/fix') - fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> { - return this.service.fixItems(dto.items); - } } diff --git a/server/src/controllers/file-report.controller.ts b/server/src/controllers/file-report.controller.ts new file mode 100644 index 0000000000..6bdf726073 --- /dev/null +++ b/server/src/controllers/file-report.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { FileChecksumDto, FileChecksumResponseDto, FileReportDto, FileReportFixDto } from 'src/dtos/audit.dto'; +import { AdminRoute, Authenticated } from 'src/middleware/auth.guard'; +import { AuditService } from 'src/services/audit.service'; + +@ApiTags('File Report') +@Controller('report') +@Authenticated() +export class ReportController { + constructor(private service: AuditService) {} + + @AdminRoute() + @Get() + getAuditFiles(): Promise<FileReportDto> { + return this.service.getFileReport(); + } + + @AdminRoute() + @Post('/checksum') + getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> { + return this.service.getChecksums(dto); + } + + @AdminRoute() + @Post('/fix') + fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> { + return this.service.fixItems(dto.items); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 5e109f1eb3..ad2f6e8de1 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -8,6 +8,7 @@ import { AuditController } from 'src/controllers/audit.controller'; import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { FaceController } from 'src/controllers/face.controller'; +import { ReportController } from 'src/controllers/file-report.controller'; import { JobController } from 'src/controllers/job.controller'; import { LibraryController } from 'src/controllers/library.controller'; import { MemoryController } from 'src/controllers/memory.controller'; @@ -26,13 +27,13 @@ import { TrashController } from 'src/controllers/trash.controller'; import { UserController } from 'src/controllers/user.controller'; export const controllers = [ - ActivityController, - AssetsController, - AssetControllerV1, - AssetController, - AppController, - AlbumController, APIKeyController, + ActivityController, + AlbumController, + AppController, + AssetController, + AssetControllerV1, + AssetsController, AuditController, AuthController, DownloadController, @@ -42,6 +43,8 @@ export const controllers = [ MemoryController, OAuthController, PartnerController, + PersonController, + ReportController, SearchController, ServerInfoController, SessionController, @@ -52,5 +55,4 @@ export const controllers = [ TimelineController, TrashController, UserController, - PersonController, ]; From 171b6bb0a6571262420c0bd3789d66267bc097e1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Fri, 19 Apr 2024 20:36:15 -0400 Subject: [PATCH 23/46] refactor: system metadata (#8923) refactor(server): system metadata --- e2e/src/api/specs/server-info.e2e-spec.ts | 17 +- e2e/src/api/specs/system-metadata.e2e-spec.ts | 76 +++++++++ e2e/src/utils.ts | 7 +- mobile/openapi/.openapi-generator/FILES | 9 ++ mobile/openapi/README.md | Bin 26365 -> 26826 bytes .../openapi/doc/AdminOnboardingUpdateDto.md | Bin 0 -> 423 bytes .../doc/ReverseGeocodingStateResponseDto.md | Bin 0 -> 474 bytes mobile/openapi/doc/ServerInfoApi.md | Bin 11069 -> 9228 bytes mobile/openapi/doc/SystemMetadataApi.md | Bin 0 -> 6367 bytes mobile/openapi/lib/api.dart | Bin 9114 -> 9254 bytes mobile/openapi/lib/api/server_info_api.dart | Bin 12548 -> 11619 bytes .../openapi/lib/api/system_metadata_api.dart | Bin 0 -> 4697 bytes mobile/openapi/lib/api_client.dart | Bin 25021 -> 25237 bytes .../model/admin_onboarding_update_dto.dart | Bin 0 -> 2989 bytes .../reverse_geocoding_state_response_dto.dart | Bin 0 -> 3748 bytes .../admin_onboarding_update_dto_test.dart | Bin 0 -> 600 bytes ...rse_geocoding_state_response_dto_test.dart | Bin 0 -> 745 bytes mobile/openapi/test/server_info_api_test.dart | Bin 1583 -> 1473 bytes .../test/system_metadata_api_test.dart | Bin 0 -> 936 bytes open-api/immich-openapi-specs.json | 150 +++++++++++++++--- open-api/typescript-sdk/src/fetch-client.ts | 38 ++++- server/src/controllers/index.ts | 2 + .../src/controllers/server-info.controller.ts | 9 +- .../controllers/system-metadata.controller.ts | 28 ++++ server/src/dtos/system-metadata.dto.ts | 15 ++ .../src/repositories/metadata.repository.ts | 2 +- server/src/services/index.ts | 2 + .../src/services/server-info.service.spec.ts | 8 - server/src/services/server-info.service.ts | 8 +- .../services/system-metadata.service.spec.ts | 31 ++++ .../src/services/system-metadata.service.ts | 29 ++++ web/src/routes/auth/onboarding/+page.svelte | 4 +- 32 files changed, 362 insertions(+), 73 deletions(-) create mode 100644 e2e/src/api/specs/system-metadata.e2e-spec.ts create mode 100644 mobile/openapi/doc/AdminOnboardingUpdateDto.md create mode 100644 mobile/openapi/doc/ReverseGeocodingStateResponseDto.md create mode 100644 mobile/openapi/doc/SystemMetadataApi.md create mode 100644 mobile/openapi/lib/api/system_metadata_api.dart create mode 100644 mobile/openapi/lib/model/admin_onboarding_update_dto.dart create mode 100644 mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart create mode 100644 mobile/openapi/test/admin_onboarding_update_dto_test.dart create mode 100644 mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart create mode 100644 mobile/openapi/test/system_metadata_api_test.dart create mode 100644 server/src/controllers/system-metadata.controller.ts create mode 100644 server/src/dtos/system-metadata.dto.ts create mode 100644 server/src/services/system-metadata.service.spec.ts create mode 100644 server/src/services/system-metadata.service.ts diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 5cfd6a8b98..690bfae744 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, getServerConfig } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; @@ -162,19 +162,4 @@ describe('/server-info', () => { }); }); }); - - describe('POST /server-info/admin-onboarding', () => { - it('should set admin onboarding', async () => { - const config = await getServerConfig({}); - expect(config.isOnboarded).toBe(false); - - const { status } = await request(app) - .post('/server-info/admin-onboarding') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); - - const newConfig = await getServerConfig({}); - expect(newConfig.isOnboarded).toBe(true); - }); - }); }); diff --git a/e2e/src/api/specs/system-metadata.e2e-spec.ts b/e2e/src/api/specs/system-metadata.e2e-spec.ts new file mode 100644 index 0000000000..bd17bf2524 --- /dev/null +++ b/e2e/src/api/specs/system-metadata.e2e-spec.ts @@ -0,0 +1,76 @@ +import { LoginResponseDto, getServerConfig } from '@immich/sdk'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/server-info', () => { + let admin: LoginResponseDto; + let nonAdmin: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + }); + + describe('POST /system-metadata/admin-onboarding', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/system-metadata/admin-onboarding').send({ isOnboarded: true }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .post('/system-metadata/admin-onboarding') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`) + .send({ isOnboarded: true }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should set admin onboarding', async () => { + const config = await getServerConfig({}); + expect(config.isOnboarded).toBe(false); + + const { status } = await request(app) + .post('/system-metadata/admin-onboarding') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ isOnboarded: true }); + expect(status).toBe(204); + + const newConfig = await getServerConfig({}); + expect(newConfig.isOnboarded).toBe(true); + }); + }); + + describe('GET /system-metadata/reverse-geocoding-state', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/system-metadata/reverse-geocoding-state'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .get('/system-metadata/reverse-geocoding-state') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should get the reverse geocoding state', async () => { + const { status, body } = await request(app) + .get('/system-metadata/reverse-geocoding-state') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + lastUpdate: expect.any(String), + lastImportFileName: 'cities500.txt', + }); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 0047502023..96994c7f0a 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -24,8 +24,8 @@ import { getConfigDefaults, login, searchMetadata, - setAdminOnboarding, signUpAdmin, + updateAdminOnboarding, updateConfig, validate, } from '@immich/sdk'; @@ -264,7 +264,10 @@ export const utils = { await signUpAdmin({ signUpDto: signupDto.admin }); const response = await login({ loginCredentialDto: loginDto.admin }); if (options.onboarding) { - await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) }); + await updateAdminOnboarding( + { adminOnboardingUpdateDto: { isOnboarded: true } }, + { headers: asBearerAuth(response.accessToken) }, + ); } return response; }, diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 42f1034dce..64229329aa 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -13,6 +13,7 @@ doc/ActivityCreateDto.md doc/ActivityResponseDto.md doc/ActivityStatisticsResponseDto.md doc/AddUsersDto.md +doc/AdminOnboardingUpdateDto.md doc/AlbumApi.md doc/AlbumCountResponseDto.md doc/AlbumResponseDto.md @@ -123,6 +124,7 @@ doc/QueueStatusDto.md doc/ReactionLevel.md doc/ReactionType.md doc/RecognitionConfig.md +doc/ReverseGeocodingStateResponseDto.md doc/ScanLibraryDto.md doc/SearchAlbumResponseDto.md doc/SearchApi.md @@ -174,6 +176,7 @@ doc/SystemConfigTemplateStorageOptionDto.md doc/SystemConfigThemeDto.md doc/SystemConfigTrashDto.md doc/SystemConfigUserDto.md +doc/SystemMetadataApi.md doc/TagApi.md doc/TagResponseDto.md doc/TagTypeEnum.md @@ -226,6 +229,7 @@ lib/api/sessions_api.dart lib/api/shared_link_api.dart lib/api/sync_api.dart lib/api/system_config_api.dart +lib/api/system_metadata_api.dart lib/api/tag_api.dart lib/api/timeline_api.dart lib/api/trash_api.dart @@ -242,6 +246,7 @@ lib/model/activity_create_dto.dart lib/model/activity_response_dto.dart lib/model/activity_statistics_response_dto.dart lib/model/add_users_dto.dart +lib/model/admin_onboarding_update_dto.dart lib/model/album_count_response_dto.dart lib/model/album_response_dto.dart lib/model/all_job_status_response_dto.dart @@ -343,6 +348,7 @@ lib/model/queue_status_dto.dart lib/model/reaction_level.dart lib/model/reaction_type.dart lib/model/recognition_config.dart +lib/model/reverse_geocoding_state_response_dto.dart lib/model/scan_library_dto.dart lib/model/search_album_response_dto.dart lib/model/search_asset_response_dto.dart @@ -419,6 +425,7 @@ test/activity_create_dto_test.dart test/activity_response_dto_test.dart test/activity_statistics_response_dto_test.dart test/add_users_dto_test.dart +test/admin_onboarding_update_dto_test.dart test/album_api_test.dart test/album_count_response_dto_test.dart test/album_response_dto_test.dart @@ -534,6 +541,7 @@ test/queue_status_dto_test.dart test/reaction_level_test.dart test/reaction_type_test.dart test/recognition_config_test.dart +test/reverse_geocoding_state_response_dto_test.dart test/scan_library_dto_test.dart test/search_album_response_dto_test.dart test/search_api_test.dart @@ -585,6 +593,7 @@ test/system_config_template_storage_option_dto_test.dart test/system_config_theme_dto_test.dart test/system_config_trash_dto_test.dart test/system_config_user_dto_test.dart +test/system_metadata_api_test.dart test/tag_api_test.dart test/tag_response_dto_test.dart test/tag_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3ebd65025b606cd030899cfffaad5099acb6832b..27d631e4fd03f971074a7eb7a3764485ec64ad20 100644 GIT binary patch delta 491 zcmex+mhsd@#tocCo4bsjvr7h77MG;v`lgm7rX-dmIu>MVDby%LYiXrVmNAl6LzmRc zO;Jt<sm;yI%g;;7Pb^Bw%uAnq!BV6?7ixq)SW*`u3p9^Q3#T=yB|)iWsYS)9?y337 z`5@DROA<>`wX|Y2Qu34a^)WO;olpePl@8OzS`5+#a)~qCB~UfGa7DUcMR*-lS^x}( z)X8#2l2X__50&;Zl9K~V>%uKV^Y-R!s~w!ipm6eshf^rnY?qRJsNWp1NpBW&nImL_ cHBf?5iwpAeic=9LU{yA`AyRbn<}fb-0JILcP5=M^ delta 76 zcmV-S0JHzf(E<I<0k8=&lZP%60dtcHIvbPAF%ScDWptBlJ02Kia&~2MEop9MZ!ckF iZE0>TZ*F35VRB@%|1t3gv#vX`3A3O@j}o(gSW6IS@g4^N diff --git a/mobile/openapi/doc/AdminOnboardingUpdateDto.md b/mobile/openapi/doc/AdminOnboardingUpdateDto.md new file mode 100644 index 0000000000000000000000000000000000000000..b25084301943eb5495f27b1e781338f4732efdd4 GIT binary patch literal 423 zcma)2!A`?4488j+EcGxJDcv1TMco03jS0bNn$)cEMnn@wsW~9=@g$`ZgA3&1Jp1|C zeid@0V4|x%TN>)ddKVtTkxjBLoS&mLLai`BRpC7wi}FG^aWR_y)tm{suCpcyI3GC) zF6R98th_4fg|N6O)JbWVaxsac5w^w?e&Ex4ETN^bPcDk%kkOVUGxWvF$qS_QUl^2f z<9rWUr7~ZsBl9qQGXmJ}Z}TB2jGJNs4sMjg;i|4zkL#x0tZQ#8%l(_DAf_y)Pn`5* e!MMcl?q+xQ&)#f^reGtxAU`C&2L2kp1b|PEh=TtB literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/ReverseGeocodingStateResponseDto.md b/mobile/openapi/doc/ReverseGeocodingStateResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..87f8aa8ab7cfc8c3ee7b938638b9538caa64edfc GIT binary patch literal 474 zcma)2!Ab)$5WVLs2KG?9knLShm90{+rEK+73L7?K4en+_G75qp?_^!DilDiKH+gSf z-Yb9tdJ|kVkj1`g@D*diTcnPx^QS!<#s*U$pRi_9swm)&j0Ju|7&Q<~_iRq6+P1YO zvT{S9$S|Acmy^RHyJW)bDpSWzyC{#59VWFk1HwDpzlD6&n7Zf!JMx0I#KO>LM}}ue z>%Xun%ibhjk6*iSJ!ZSI6j@pBIZDzI2%eY^!AkPq8R~GjmdwBeo7@`oF7ferQB{j( ywX8R*YOsZ+?qxtP{YeJ>NKub%r8#`5m%H^pwbGqNZv#2c-&TAY{xF{_r9J=#W|SBJ literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/ServerInfoApi.md b/mobile/openapi/doc/ServerInfoApi.md index cb5cf0fd3ec98fc4cd06b4afb039859add422bee..e8121a8001e11a58ed44ca847a54b7d8ff49eff2 100644 GIT binary patch delta 12 TcmdlR*5k2Z5%XpqmMjGTBS{2` delta 261 zcmeD2*c-NC5wmEtmR50UiDOD`W}bguQhs7lN@iaA<OPhPmO$Y|kZ?X+SW}@!K}##Z zKR857OF_RlwWut$NH;StEngq3P#3Cl^K9lq1zlyJQcRPzw7BdPFoh>8O4x3;5&X!k zkXVozpP5%&l9-pAs)wmiV{(D0lMtr(<b`~(l4bdsDGC~?xdkPa3Pq{K1^Ic!sR~K? fDV3AsS;Z#jiLK=J%P&z#NlnYlOHG-~D6j(nRP|o4 diff --git a/mobile/openapi/doc/SystemMetadataApi.md b/mobile/openapi/doc/SystemMetadataApi.md new file mode 100644 index 0000000000000000000000000000000000000000..f8c2347afed6701d0a62ff44945123b26b3fe1f9 GIT binary patch literal 6367 zcmeHL?{C{S5dE&d;s6h@EL1YKkJ%Q$O}w<o8V9!Xp~!-Sk<K<3nbagYjo0LV-=nBF z{@FTf1GI$=1dho&-tl;RPfs<{87q;=$o}=Fh2il#HX;#5v@+>=wHp1YMM7r8)H;1j znTT)1kiDy`t3>3+ljBV1hU(S$UbS4)E@utuzgknzYo#fjb>9{wa;BV9VdO27(G*zI z!1EAwq!YTQH~s!8EdH5rVes>ii#V5=ky?4-{0DRMKf~qU-gyuV*|d_eR2_A#MV?4C z41x=PWt-7BslilOQ#~_nV8tLfYWIVHnnhWfa9q`<S=xqA#;~mdcKMd`f{$3o+D7ad zVfa&FbLTL8bSSLDCxcfW;mjp0eX1NL8BUM4-M6z|yY<sfZ|l9IQ&6mR_4f8@DVMV{ z!9yJWs=eRv@TsBg+IM1{rO)cLW|R7z!wwy2HITyzI?cB14Npl-&|SIw7j3829>rSU zNcNh|ga=}h8npUBDp{GxW&~UOE927mH_RRL)DG_Zb>~=b*z1B(`km_UJ7?WX#JN0f zzel$thlz^yc#I|Jnp6G3c6q^Ol2MlPK;Dth(J-RdjH{40XrS}QG+&V%kYY|a`SRRO zT`*+mBDvhQs}FW}iL(8VBr4BuvqH<sZB?QYk6a0t+vvJHLVM%~qG0+tqBDH;Opa?$ zeSO1fy9&NBCUX(0HggN3i0GSE9CJ~WtNB&49UQcKy~}>*xP4M@lr2}r|HZ}s1N~>) zoIZ?XJi;AQoC6ThhDa!~*;W}&^&~IXJ#r>Ge{}U5^pT<{qKB`%fz2VY4{soqNXed( zR6sNpGcMQ)TKm@2<Wu_S5kAf-=K}W_=JVd=ZC=EG>ukL;oRRUJ7ls}Pw;jdu0laQm zXHak7#axmPs>upgX=)F5C8=<K70dSMC;m&>6MAqYXkqPC<N_=Litzd)SrDrv)5wJq z%>^lKN&q0Crr_GjG;)a81&HK|%rs-qlFcSqwPdqt4paw(wTakPn?WMhbI^9-M^Ure zZXLdBTUK;~jaPJD-JPZRYSbFIo?V)EgH^-@W*@OgY!N&PK^#DA1HcUX*jo_n(Fy7R zY?ca0SQ^+{kQqBAvMjBzx%qd2rvmT%{576REX*36U0nFJ20aZ~21sRLFut&}M8^}0 z(Iv=-0O1gO2_H|Rm|x<ZPB`6ik+HR1@OH&q3U4do_;SPJ0$6kHOqYvHtAZ=`Tx+xA z&>e@q*i%Mz<7WiF<Io?ULpPwz3o_~2h%e2jYlF@KuI1Iw+3fhWdUE(C_;rI62Dz@C z@k<%}^s-<kRCl6uCrV#Rw(kh_M-io!d|ecrGYo+?-6YB&N(r)j)3klBHz#;)!#wKa z=$Sb7Rg~`9n4cqO?*y_JJFyB6UvsZ@2|uCtxOm?CU3e|L|Mj4!HsLqk;NH_0DCT>c n;~Eiut7QUQ7-wdRSJqCM(X~#d--E_p5z<RSdtQToryBb|U>aw- literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8520bab3056ca6340d4b5fabdb3bdfadb00de704..44bd35a6838a99692a4c97328b6ed3feef902bc6 100644 GIT binary patch delta 86 zcmbQ`zRY7oIV*p0WpPPrZhUTPNn%Q3N#f>2)^m*fDY=<>@%ed4`H4j-nR)4x4LB7x o>$7j<6E8|FOD!r+jZaU_PtFHP#TS<(mZVND<W=1KKy()`0IAR+M*si- delta 26 icmZ4HG0S~JIqT**tUDMduV-i7+`+zvZ*z{=E?xkWs|rs5 diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index 77840acd19247702f1fb83175465b4e491d35b9f..b67045add13f3825be1657f7bd05c6d52f845159 100644 GIT binary patch delta 10 RcmZokdK|SOQJ1Nf3ji7Q1M2_) delta 156 zcmaDH)snO!QFn4TkEDEne{hI`esOA1S!$7PW?ovpequ^)W}a?-UQ&KyQOaaJ4LhOY z)DlOKpg%+`GcSGeMD_#$6oo*+&3A<)8JU3wPHvE#C0>@FnPR7ap>=Wsi#j#~6%-b- J)N<8w0RXDpIMo0E diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..f3952fda8a565f4974b78534c216d7bf97c17544 GIT binary patch literal 4697 zcmeHKU2oeq6n)pPxa~u253=0_dl=lto|`ynfHn3K2gMKsDq~&S>||0UskmN||GrC7 zlI>Jpx@G8EAP@c!$@{@`&pjgB?V#O;{>6CMJRY15&idoQAsn6jItbx#2&cm_ycr&z z9R2=;mf4lR#7vp}z1EL=fqmpNAr(ebsiG-QumhRMG{zAoxIkgT;+Zc}sZ6B>OO|Y< zqck&-!Vei&WG>i(zEh^?*PznmT<L1_i4@u}VIm$=KtAQDoVl(HmT-YON~D<4GBal) z`uTY@nKPw>fWjG&Q;;bNmhu*TUIalfNto7fzS0I4Z_u!q8P-oZd<x+6(fTD#9VDDm z=>a+Lf3F(QSwF%4#{XUqVw|!pF{L2|1(<Td5-7*k7H+mp(2r0N><bM{!0YjN1hwPA z7~0w;+9cdurfn84xM)goEt!hBnAPn56f;~3vW!8Y$&}Dw<_MQ(s8hlduUeGCQARWP zvKZ^a3{Bs98<x)Aax;HzOnM@wvfcouS7PFT0Vv7QfmGp?;C7XYVkAI)d`&@$ou<sp z2^dwoe*+GIssIi%D(-|O-u3YMJx-_)snq6`j90m($M!>pYBjPH(-ipSZ{=h6^6JG# zW^-gQs`bKiqX@<j{BQTu?dD=zk-uH)96Ka{z76qYyh_n!kUO-|EdwAg&ARk0E^g!> z*dLwL-8_IrEzJMh^4E%ojm;H91qj9S=Dv!tb^67~W|A$g2aD9Kpa&vL65{mMtm{#4 z*RYt}+Fj%%y2G1`l)4BHjqFvi>JF29xl~R~+dWIh&An{&W#Ln(7p5%|3X|yr86$M} z!4mh}jkK;=d`8-X+B9<d0yTt^OWT2ij}uI7zf?I2^K2}y$**PEtCc}ru5CChEE=|? z)D>^%NS#N;T}XqVQQe>_yHH5DCKs@z#-c?&VJk{|&wmPGN$cl~kQdUBY}kOnjqO$E zxo=z-8@@nmvQ*p<Q)!Ir5Z+ng?oh!foOwq9$c^73>BrLxfge-q6^sE%6v&zjQsk$F z>m%YdGbIwRw3{$$<^0Pc7xILTAlmhaPi(6Kc5_6dQ)xVto;{<jd(GLb&XcX`2zlUJ zWnCPvS~>Qm&bDGCJUL7FpV+rrRQFWbU7QeVyQo<Ut)RiKX-_YQk1uVWlI@I={r^Cx z6;fd|HfJcQu7qpT79Srb*Js}8apBX_9lwTX@7m3yc7K1`T}h(iR8VSo<*F}M@=EJ% zqCV>Rqn>|>o>x+7gRZ%*-?!`m`aT+-|EC9W_ZBJdLLGYN(1`zrAK-G=8|n(!_G!I5 zb=f14ziBnBx6cmxnLD1{@w?THcjnffcZGB>({KBBU{97i0Q){J4mFmh37@#TUHiS3 zqV{jE5|+W=-d*A=^}&tgaTh9kp#D(a@3_lU2HR@5UCa#g>&4b7+g}_0zkAMYa2xyu Dpg1Ye literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0a0cd80088bbdd96df0fef2da6db126d89dea032..a92f1df7a7bed8c052cc85c84d8993ab98f23266 100644 GIT binary patch delta 108 zcmdmcm~rY+#tolk_)>B+^ZfIY@)L_vGV{_WD=NyN3nfZxZT>71uP7RnT9#T=oa&yM fpPUa;99)uEk~(>!gS-efnaLmQbvFmwWhnyy6;vu) delta 19 bcmbPwlyUE2#tolkHiyf`D{j`Z&sGKiS`i2B diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..50c4ae090ede8409ad0d2003b51e6b03e8eaf215 GIT binary patch literal 2989 zcmbVOZExE)5dQ98aRG)}0aSVGry;4m28%PaYh$2w2Mk6a&=O^_lSz%FY8a{i`|e0d zp;s#jY9O&i-5a0hxg#f&(PRQwpO=f5f6Q;^AFr14Yq)v)ejda10&W*e_^`Nsd-L}h znvvz3oM}6KmHhH*M2})4mF8)obXtgtUqB-p!}F9^e9NVcyJxYjm9{56ShZt&lh%z* zHUGB}8r>z^;{Tdy{I*;h46e;-_e@D+nY5|MF`*~~*UsIXOjZfWO<F0rK{H!2ng057 znw3nO(FoI7Pz9)(OIC{n|1L+PtYXH%RlXH+A=irOT*%GmI%k$&TLt$cfC&Qek=sT~ z0}LeJ!D`6<4umT=;|V$rSw$2Tpo0T!DNG_vo1f<~zCN5|aR6(jDhOV=#W(6J*BSxo z3~)=DS*2k*jTY-KJj0FJlIwGr&cFkgu_~fGarfriH~$Nx4;Bh9UbsqKu&_c*^k`K= zTG((4B{SvkD(6^-3)D-Blq7k^jbVCEN<eZ;ZiI1WQLy8ri{SwY_x{OhBnzzaKO(o? z@et~(An5d`;NfoUQ2oH_*FI1T`5q-IvKhvHXOzUqp0YwnR>2osf+>7weSf%g<%6?W z>2Z)`e1{(YC)%59r!D703kTx;hcpLF=HOe=fUpL>vZ~<*3az$n%#~Y0nHFd;COxlk z-*+*{rmDOkcEULI23*ye7uZm)7j=`N(o9MkOtCes$WrX$Qsu;*Ul<hBZ>|;xX$!Hh zvBMY3id==HN-V5f2RK6BnIsuA_B#$DFB9Ki)U$)NL|Cr>1T<a1meqd|Y>Q&KulZ|g zz*$6$$8b#O`5E;oeJ~|`9o+wxO~SKhvqj~-z=DV5IfDvc-yl^-@c7L4ZVqg;+Oc(o z-2?@z437^TJ{}#-O;cG?=3tW5ov)RErqF~lm4%(|1c*(3tL%JR+kF=svB$|0wJFpN zN1QW#c;B((nVSwGo{$jT?r<Y5Xaw8?>t|CZ#4t3?N!nJz^4x19@br*(&>s=ddRax= zi#q5op!?;bxL#}73HyH#Ka$5uj=GXPOlTDjh{15(#0crYIv!=i(KhvL4YdHykM``o zfc}_G@ub58oepk4W{;S~RlV=HP3TE*)9s2_@;iGz$1Y&v>;uN0;2MEjdB=bFo<^qg z_nM_59w8iE`-a2q<0FkXhnDhZ;!fBSm#EL^A~3|tOK*<e?F7YB0Nv<jL{YKJPbh{| zHr#eIawL<J+KJ(L{f#j~RXvgNN3``!**r&r?Q9HY$CC=f{T-7(#85&M_D}{wi~II| zX!N*OpBK;3l)#jMXeO{Md9RQDeuh#scy=?Szd`A&IpBe4FfFwUgLj`6w{+cWRpCy_ a{&M#x`Sy2(*}a41=wje*dzT{|MgIXv!PQ^@ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..71e1d3ad99b5b3584620c8b322926fda965ddadd GIT binary patch literal 3748 zcmbtXU2oeq6n*!vxB-U7!Bn~H(~!(gizO-2HZhR60|vto7>SD6$)ZNmHH_5%efRQ8 zwp=NZw*^RIQ}_EE9#VrrZ!m!OUnb+%A1-e%@7_-?FW~C>^JM@RW4Ikp;AVVref8%N znvvzZL>M=G8~*aPM_<)ru8oXRZK70V@)VZ&(#j-~nOw-+1=V-8D70~{9wJ+bwT+6U zi;VnxsSLX3Vu61PVer3`#$s?|+udWGTPJcCsRR?MR7vCAZELbj<<dr(&L7dt%|#yl z`X-9!!q{F9(>c&7=tAbAP$B-F^?Gq8tc5%I4a<-pq>go>^2d88oV=5^!0htEX?W@Z z3~(TK(k)GHK|n57Fzxc2f%4_YGQ<E~)_uwXP>l)PT-gxobJu>nSBStZPAquF>PwMe z)%0|8f}H`}JA=KPLo-C<G5h-EcN%A5aNHQ-LF<eLW`xrQhQt2&;j4^sjBYN?2@FTT zu*4$kQ?)_$<okF21nPj{zIx?5@<Chr#k?&oK*NiBsH~sqNaYS5rGvS!^WSwMu?L@g zOLpRTE@Nq}Fl*u{(rzxTvfixUoFyg9mstkq@CHs>Dm@CTWGC?RF3PUlAH4ZN&8q<m z0}D>`hrUPRxQkxXX6?hDnrne?fs5eh{CRfl>$nlcZ=5!OWQ{vn#Urfbqt!Xq!3<7S zE;9IvOE83n_0#6ePeG;NNz(@T5zn*=2tL@<95>57o?(t>b_(a&fXu8cOGBJHVi6jp zW3$W>w3o_bll`)It-G9>?~i2|uROkXinE~&{=XCnM4kW(w*}=ad=uGHT1bsvl(DCN z2}LYLgJV*?DZ)PsnD2hSu9WrKR@*;?ktxcrS8lV6Q8{cxni8RqI~7N$`E#Ane&U5r zT|D<Sd4`pY<60|r5RrVPq&8S!wGOaHL~jztsHY7|_E|7leM#XvYwBb9{L`f5DJ(?s z2TgOFPS%Ai(~bJHBLZk;@d>Lsg`|K?go6T=oY@AzRw&BG|1DWlk;${Bocd>lgXq$X z5oBm~g_PJEKR5E^kB3L2SK=W<C52SrMhiC8{CWCD+GXZQ2s)EZXIwi0N+}3WDigKd z6F*e;R=dkZ;nr0S#28Peewj+I@WAz^yXO@Pm?6VXWJB5jzFTap93~BP$Fj!mr^no8 zD3XM^So35kDoFN#DZQMgHv82mPoetcqtsEW%WvmAiHEJ&Rny_Ip;cX{JHyrmyn+eq zdJ!dWH)Ir_+k&WP<&E<cwwJ>Q?`L=orRydi!F!0~s##Z<hxAQJTXjdUaEJdMdmlLT zel;XJIDmtkvg2QJP3MyLXHHWwFAxD;YqCzjk=**JX})u<l$+xc?MJ%CcA@l|-7re2 z?086{JKgUo`c8dsY6m*8uzQ=1C;*wZaw~wtbOB@3S$AOS|H8ckX>|xiOVzJwPA3Dg z&wWyKAr}(F+tJpD7;I%>ClJAwolLGhb-~m%QVn1-ms|d9-!hP@7B5f&X|Y6VJxIU< y++teVRsufWk8kPar93tKslI(h`9!|kE8ghIkEiXD#;2QqQl3}-J>{&2+V&4Yf7_Y> literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/admin_onboarding_update_dto_test.dart b/mobile/openapi/test/admin_onboarding_update_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..09cc73e977c0748ac9d4babb2828f7e65a246e9d GIT binary patch literal 600 zcmZ`#&1=Fi6u<Yccso_W+0D~XgbA%f*es0huv3ifYYl6YEJ-(H_`mNZDh#HFybt(& zy(CGLB(QiZ^4q6uoxLuKEQRIjIU7Nm!#XeEB~Mq&_ZxzF<YUE+n@`5~lgO{4k;)*e zm0`7LaSFZcEmjO$>`=PVc^92l#$9Ca_M7i5)4gK`KYL+_E_p{)b3@&su_SKnWqPTk zbzC|oDl!zcKojWpqFE~>TGlGr5X|kkWDj?&+;L;0=$H{=&C3to;8bapT#JO>*k2vb z{y`jCm-uo9cvHVypf7<%)d`u)t>UH<vU$@LcbGaAeJN2v$AugW;U@whRxKoNL4;#U zg!xtSU^t72(iqk2LF~=0LgNvf41&d0DRQu<Kv<CqgtR21!T@fEpbcGa+#WW6u$!Y6 Yw|iNJ$ev5Fi#*MP&cVl`DfC113;U7BF#rGn literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart b/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..91fdfcfea4030dafc64cd9cb685a3d2efbaad4ad GIT binary patch literal 745 zcma)3&1=Fi6u<Yccsr@!?B;1G!i1^_n*~1(JH^mGYgkRP<kca=|9!7<UZ$Xjrg`xD ze2L>Ij$yc3rjL`+Y&0J(M+uClZ=*IODa_I(yr;=@{PDoB8u^+_M}uzXxf|7^DztGZ zHpYpKDzOKZt~}-fOWdKR_IXx2YaCr=$nskrys#AshhLR)OxJS9r<IQXgwC_L^VjK_ z(VnCxp>j4<8-*^={Z+G4Y4oBrx?mV>r4}zwBHK#mqv(_o8dheXvcR6PsHIggf1SE@ zqkjP*^c2^}8Bm+nvj<)Y%yG}*ae^i@xzfdgB;g#r<;<9niS|b2u#-wRTksPBFngo4 zEJ1}*8N%SENVC<Cm|8ef*=D0Qze#Dd;m<HwF352>D5a-qc&9Nx<&0O)vJmR9t_c^Q zErNBt8b=3MUsBd5ZRrm>3zcdKuC+{)G{_xKasL~yY|gv6%kTuf&xi$s;W7FJA!qh$ literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/server_info_api_test.dart b/mobile/openapi/test/server_info_api_test.dart index 68cd1c348bcf9c43aacbf924cc90ca0391480ca8..dac465116eb42c185c525ec1eee5ca4a399c6078 100644 GIT binary patch delta 14 VcmZ3_bC7$(ZdSHhO>3@NE&wM_1aJTV delta 65 zcmX@ey`E>oZq~^T%v{0>#i=EZDY=<>{&`9HiA5=ydFhk?GD`?y5njlw&Q_~w%~i_< E01$~64gdfE diff --git a/mobile/openapi/test/system_metadata_api_test.dart b/mobile/openapi/test/system_metadata_api_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..bc1ce6f6f33849d10723585ed976cf7f29255adc GIT binary patch literal 936 zcmb7>PfNo<5XJBL6yvECs!>nkKP;pbK@HS;@U)D{G%hB)>+VD;;&*p8RgoG+57{uu z@6Eh<o2E&c!uV#MZ=Fr9CbRK;lEL)+Vlsj(hpT)Jmw9$Rz1<KjlBXqlKHl5j-AiJr zD`P#Wh4pHoYdV0|w1G-RH8o^-^ik`^S<k%+te<filxtaedTg~9xxq$GhhE-HK1g#P z`sIZ+fzhzir3};yO}>+d-eI*i6jW_ZB^ckMQTscpxJMt7WaSYC4vPn@=)gKM=yWR2 zcHDY!_zhtSoW|n=h@qqqzXXs_;CdODnoD9Vk#X#F@?>=h4QeyogqH+B@P#&5gYNby z2#&w2hMR|pu$8y18xDSHKN!J#-`IKDa_i}6TsGR|=FXxowW)5Lc%h8#2~@;uKShmU zX$qMJ-5#rPJ>+Us2X~7^fh;r6v%C|(t~`J7F{3BBOFAQ4*l6UM#m9_-lPmo6(+mCL hs+a$_H)y+1*27q<Ka&IeA<`ex>+SwWlQGBF<PA}THTnPm literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f49df7baea..4f666b303c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4908,31 +4908,6 @@ ] } }, - "/server-info/admin-onboarding": { - "post": { - "operationId": "setAdminOnboarding", - "parameters": [], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Server Info" - ] - } - }, "/server-info/config": { "get": { "operationId": "getServerConfig", @@ -5885,6 +5860,103 @@ ] } }, + "/system-metadata/admin-onboarding": { + "get": { + "operationId": "getAdminOnboarding", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminOnboardingUpdateDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + }, + "post": { + "operationId": "updateAdminOnboarding", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminOnboardingUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + } + }, + "/system-metadata/reverse-geocoding-state": { + "get": { + "operationId": "getReverseGeocodingState", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReverseGeocodingStateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + } + }, "/tag": { "get": { "operationId": "getAllTags", @@ -7180,6 +7252,17 @@ ], "type": "object" }, + "AdminOnboardingUpdateDto": { + "properties": { + "isOnboarded": { + "type": "boolean" + } + }, + "required": [ + "isOnboarded" + ], + "type": "object" + }, "AlbumCountResponseDto": { "properties": { "notShared": { @@ -9618,6 +9701,23 @@ ], "type": "object" }, + "ReverseGeocodingStateResponseDto": { + "properties": { + "lastImportFileName": { + "nullable": true, + "type": "string" + }, + "lastUpdate": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "lastImportFileName", + "lastUpdate" + ], + "type": "object" + }, "ScanLibraryDto": { "properties": { "refreshAllFiles": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9148b4d3b1..1bf219162d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -998,6 +998,13 @@ export type SystemConfigTemplateStorageOptionDto = { weekOptions: string[]; yearOptions: string[]; }; +export type AdminOnboardingUpdateDto = { + isOnboarded: boolean; +}; +export type ReverseGeocodingStateResponseDto = { + lastImportFileName: string | null; + lastUpdate: string | null; +}; export type CreateTagDto = { name: string; "type": TagTypeEnum; @@ -2330,12 +2337,6 @@ export function getServerInfo(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function setAdminOnboarding(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/server-info/admin-onboarding", { - ...opts, - method: "POST" - })); -} export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2597,6 +2598,31 @@ export function getStorageTemplateOptions(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function getAdminOnboarding(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminOnboardingUpdateDto; + }>("/system-metadata/admin-onboarding", { + ...opts + })); +} +export function updateAdminOnboarding({ adminOnboardingUpdateDto }: { + adminOnboardingUpdateDto: AdminOnboardingUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/system-metadata/admin-onboarding", oazapfts.json({ + ...opts, + method: "POST", + body: adminOnboardingUpdateDto + }))); +} +export function getReverseGeocodingState(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ReverseGeocodingStateResponseDto; + }>("/system-metadata/reverse-geocoding-state", { + ...opts + })); +} export function getAllTags(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index ad2f6e8de1..bd10c41a43 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -21,6 +21,7 @@ import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { SyncController } from 'src/controllers/sync.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; +import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; import { TagController } from 'src/controllers/tag.controller'; import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; @@ -51,6 +52,7 @@ export const controllers = [ SharedLinkController, SyncController, SystemConfigController, + SystemMetadataController, TagController, TimelineController, TrashController, diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index e32b0d191c..35e5e17594 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ServerConfigDto, @@ -65,11 +65,4 @@ export class ServerInfoController { getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } - - @AdminRoute() - @Post('admin-onboarding') - @HttpCode(HttpStatus.NO_CONTENT) - setAdminOnboarding(): Promise<void> { - return this.service.setAdminOnboarding(); - } } diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts new file mode 100644 index 0000000000..7f186fec03 --- /dev/null +++ b/server/src/controllers/system-metadata.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; + +@ApiTags('System Metadata') +@Controller('system-metadata') +@Authenticated({ admin: true }) +export class SystemMetadataController { + constructor(private service: SystemMetadataService) {} + + @Get('admin-onboarding') + getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> { + return this.service.getAdminOnboarding(); + } + + @Post('admin-onboarding') + @HttpCode(HttpStatus.NO_CONTENT) + updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> { + return this.service.updateAdminOnboarding(dto); + } + + @Get('reverse-geocoding-state') + getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> { + return this.service.getReverseGeocodingState(); + } +} diff --git a/server/src/dtos/system-metadata.dto.ts b/server/src/dtos/system-metadata.dto.ts new file mode 100644 index 0000000000..1c04435341 --- /dev/null +++ b/server/src/dtos/system-metadata.dto.ts @@ -0,0 +1,15 @@ +import { IsBoolean } from 'class-validator'; + +export class AdminOnboardingUpdateDto { + @IsBoolean() + isOnboarded!: boolean; +} + +export class AdminOnboardingResponseDto { + isOnboarded!: boolean; +} + +export class ReverseGeocodingStateResponseDto { + lastUpdate!: string | null; + lastImportFileName!: string | null; +} diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 8eeb0064ac..e7d37407d9 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -36,8 +36,8 @@ export class MetadataRepository implements IMetadataRepository { this.logger.log('Initializing metadata repository'); const geodataDate = await readFile(geodataDatePath, 'utf8'); + // TODO move to metadata service init const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); - if (geocodingMetadata?.lastUpdate === geodataDate) { return; } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index db3d6083e9..2305708caa 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -25,6 +25,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SyncService } from 'src/services/sync.service'; import { SystemConfigService } from 'src/services/system-config.service'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; import { TagService } from 'src/services/tag.service'; import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; @@ -58,6 +59,7 @@ export const services = [ StorageTemplateService, SyncService, SystemConfigService, + SystemMetadataService, TagService, TimelineService, TrashService, diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index 836909b74f..115ab4b6a1 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -1,5 +1,4 @@ import { serverVersion } from 'src/constants'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -207,13 +206,6 @@ describe(ServerInfoService.name, () => { }); }); - describe('setAdminOnboarding', () => { - it('should set admin onboarding to true', async () => { - await sut.setAdminOnboarding(); - expect(systemMetadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); - }); - }); - describe('getStats', () => { it('should total up usage by user', async () => { userMock.getUserStats.mockResolvedValue([ diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index bb092896bf..52bf8bd1d3 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -51,7 +51,9 @@ export class ServerInfoService { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { - await this.setAdminOnboarding(); + await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + isOnboarded: true, + }); } } @@ -105,10 +107,6 @@ export class ServerInfoService { }; } - setAdminOnboarding(): Promise<void> { - return this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); - } - async getStatistics(): Promise<ServerStatsResponseDto> { const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats(); const serverStats = new ServerStatsResponseDto(); diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts new file mode 100644 index 0000000000..9d11c1c72a --- /dev/null +++ b/server/src/services/system-metadata.service.spec.ts @@ -0,0 +1,31 @@ +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { Mocked } from 'vitest'; + +describe(SystemMetadataService.name, () => { + let sut: SystemMetadataService; + let metadataMock: Mocked<ISystemMetadataRepository>; + + beforeEach(() => { + metadataMock = newSystemMetadataRepositoryMock(); + sut = new SystemMetadataService(metadataMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('updateAdminOnboarding', () => { + it('should update isOnboarded to true', async () => { + await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); + expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + }); + + it('should update isOnboarded to false', async () => { + await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); + expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + }); + }); +}); diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts new file mode 100644 index 0000000000..e8fddfc13c --- /dev/null +++ b/server/src/services/system-metadata.service.ts @@ -0,0 +1,29 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + AdminOnboardingResponseDto, + AdminOnboardingUpdateDto, + ReverseGeocodingStateResponseDto, +} from 'src/dtos/system-metadata.dto'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; + +@Injectable() +export class SystemMetadataService { + constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {} + + async getAdminOnboarding(): Promise<AdminOnboardingResponseDto> { + const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING); + return { isOnboarded: false, ...value }; + } + + async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise<void> { + await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + isOnboarded: dto.isOnboarded, + }); + } + + async getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> { + const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + return { lastUpdate: null, lastImportFileName: null, ...value }; + } +} diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 09139a7f7e..4647ad8bde 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -5,7 +5,7 @@ import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte'; import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; - import { setAdminOnboarding } from '@immich/sdk'; + import { updateAdminOnboarding } from '@immich/sdk'; let index = 0; @@ -28,7 +28,7 @@ const handleDoneClicked = async () => { if (index >= onboardingSteps.length - 1) { - await setAdminOnboarding(); + await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); await goto(AppRoute.PHOTOS); } else { index++; From 3abfe3c99e26fa59427c5710802f7a9ba798dc5f Mon Sep 17 00:00:00 2001 From: Conner <46903591+ConnerWithAnE@users.noreply.github.com> Date: Fri, 19 Apr 2024 19:19:50 -0600 Subject: [PATCH 24/46] fix(web): restore button in asset viewer (#8935) * fix(web): restore button added to trashed asset-view to restore single item * fixed the asset-viewer menu to update upon restoration * prettier formatting complete, testing passed * chore: clean up --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> --- .../asset-viewer/asset-viewer-nav-bar.svelte | 19 +++++++++++++------ .../asset-viewer/asset-viewer.svelte | 18 ++++++++++++++++++ .../components/photos-page/asset-grid.svelte | 1 + web/src/lib/constants.ts | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index abcb248f1c..6772ff5db0 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -20,6 +20,7 @@ mdiFolderDownloadOutline, mdiHeart, mdiHeartOutline, + mdiHistory, mdiImageAlbum, mdiImageMinusOutline, mdiImageOutline, @@ -52,6 +53,7 @@ type MenuItemEvent = | 'addToAlbum' + | 'restoreAsset' | 'addToSharedAlbum' | 'asProfileImage' | 'setAsAlbumCover' @@ -70,6 +72,7 @@ delete: void; toggleArchive: void; addToAlbum: void; + restoreAsset: void; addToSharedAlbum: void; asProfileImage: void; setAsAlbumCover: void; @@ -208,12 +211,16 @@ {#if showDownloadButton} <MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text="Download" /> {/if} - <MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" /> - <MenuOption - icon={mdiShareVariantOutline} - on:click={() => onMenuClick('addToSharedAlbum')} - text="Add to shared album" - /> + {#if asset.isTrashed} + <MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text="Restore" /> + {:else} + <MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" /> + <MenuOption + icon={mdiShareVariantOutline} + on:click={() => onMenuClick('addToSharedAlbum')} + text="Add to shared album" + /> + {/if} {#if isOwner} {#if hasStackChildren} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 46c95636d0..40309e511f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -27,6 +27,7 @@ getActivityStatistics, getAllAlbums, runAssetJobs, + restoreAssets, updateAsset, updateAlbumInfo, type ActivityResponseDto, @@ -403,6 +404,22 @@ await handleGetAllAlbums(); }; + const handleRestoreAsset = async () => { + try { + await restoreAssets({ bulkIdsDto: { ids: [asset.id] } }); + asset.isTrashed = false; + + dispatch('action', { type: AssetAction.RESTORE, asset }); + + notificationController.show({ + type: NotificationType.Info, + message: `Restored asset`, + }); + } catch (error) { + handleError(error, 'Error restoring asset'); + } + }; + const toggleArchive = async () => { try { const data = await updateAsset({ @@ -556,6 +573,7 @@ on:delete={() => trashOrDelete()} on:favorite={toggleFavorite} on:addToAlbum={() => openAlbumPicker(false)} + on:restoreAsset={() => handleRestoreAsset()} on:addToSharedAlbum={() => openAlbumPicker(true)} on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)} on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index a3f4c51563..8cfb0b8b16 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -169,6 +169,7 @@ switch (action) { case removeAction: case AssetAction.TRASH: + case AssetAction.RESTORE: case AssetAction.DELETE: { // find the next asset to show or close the viewer (await handleNext()) || (await handlePrevious()) || handleClose(); diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index af5558c261..98d6d742d2 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -5,7 +5,7 @@ export enum AssetAction { UNFAVORITE = 'unfavorite', TRASH = 'trash', DELETE = 'delete', - // RESTORE = 'restore', + RESTORE = 'restore', ADD = 'add', } From 71b6d8b569038e1676316f61845c91cbac58c19c Mon Sep 17 00:00:00 2001 From: devjn <devjn@jfayz.dev> Date: Sat, 20 Apr 2024 16:39:04 +0300 Subject: [PATCH 25/46] feat(android) Check server is reachable before starting background backup (#8594) * Bump androidx work version to 2.9.0 * Check that server is reachable before starting backup work * Dart format * Cleanup debug logs * Fix analysis --- mobile/android/app/build.gradle | 2 +- .../example/mobile/BackgroundServicePlugin.kt | 1 + .../kotlin/com/example/mobile/BackupWorker.kt | 136 ++++++++++++++---- mobile/android/build.gradle | 4 +- .../background.service.dart | 7 +- 5 files changed, 116 insertions(+), 34 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 96d2db23f5..a6f86b8537 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -90,7 +90,7 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" - implementation "androidx.work:work-runtime-ktx:$work_version" + implementation "androidx.work:work-runtime:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" implementation "com.google.guava:guava:$guava_version" implementation "com.github.bumptech.glide:glide:$glide_version" diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt index 6541ad5755..1d23c5665c 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt @@ -52,6 +52,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) + .putString(BackupWorker.SHARED_PREF_SERVER_URL, args.get(3) as String) .apply() ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) result.success(true) diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index 660e1d55ba..dc7c4a9c37 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -11,8 +11,8 @@ import android.os.PowerManager import android.os.SystemClock import android.util.Log import androidx.annotation.RequiresApi +import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.core.app.NotificationCompat -import androidx.concurrent.futures.ResolvableFuture import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ForegroundInfo @@ -30,6 +30,16 @@ import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.view.FlutterCallbackInformation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.IOException +import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.URL import java.util.concurrent.TimeUnit /** @@ -42,7 +52,6 @@ import java.util.concurrent.TimeUnit */ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { - private val resolvableFuture = ResolvableFuture.create<Result>() private var engine: FlutterEngine? = null private lateinit var backgroundChannel: MethodChannel private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -52,37 +61,82 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private var notificationDetailBuilder: NotificationCompat.Builder? = null private var fgFuture: ListenableFuture<Void>? = null - override fun startWork(): ListenableFuture<ListenableWorker.Result> { + private val job = Job() + private lateinit var completer: CallbackToFutureAdapter.Completer<Result> + private val resolvableFuture = CallbackToFutureAdapter.getFuture { completer -> + this.completer = completer + null + } + init { + resolvableFuture.addListener( + Runnable { + if (resolvableFuture.isCancelled) { + job.cancel() + } + }, + taskExecutor.serialTaskExecutor + ) + } + + override fun startWork(): ListenableFuture<ListenableWorker.Result> { Log.d(TAG, "startWork") val ctx = applicationContext + val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - if (!flutterLoader.initialized()) { - flutterLoader.startInitialization(ctx) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a Notification channel if necessary - createChannel() - } - if (isIgnoringBatteryOptimizations) { - // normal background services can only up to 10 minutes - // foreground services are allowed to run indefinitely - // requires battery optimizations to be disabled (either manually by the user - // or by the system learning that immich is important to the user) - val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! - showInfo(getInfoBuilder(title, indeterminate=true).build()) - } - engine = FlutterEngine(ctx) - - flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { - runDart() - } - + prefs.getString(SHARED_PREF_SERVER_URL, null) + ?.takeIf { it.isNotEmpty() } + ?.let { serverUrl -> doCoroutineWork(serverUrl) } + ?: doWork() return resolvableFuture } + /** + * This function is used to check if server URL is reachable before starting the backup work. + * Check must be done in a background to avoid blocking the main thread. + */ + private fun doCoroutineWork(serverUrl : String) { + CoroutineScope(Dispatchers.Default + job).launch { + val isReachable = isUrlReachableHttp(serverUrl) + withContext(Dispatchers.Main) { + if (isReachable) { + doWork() + } else { + // Fail when the URL is not reachable + completer.set(Result.failure()) + } + } + } + } + + private fun doWork() { + Log.d(TAG, "doWork") + val ctx = applicationContext + + if (!flutterLoader.initialized()) { + flutterLoader.startInitialization(ctx) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Create a Notification channel if necessary + createChannel() + } + if (isIgnoringBatteryOptimizations) { + // normal background services can only up to 10 minutes + // foreground services are allowed to run indefinitely + // requires battery optimizations to be disabled (either manually by the user + // or by the system learning that immich is important to the user) + val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! + showInfo(getInfoBuilder(title, indeterminate=true).build()) + } + engine = FlutterEngine(ctx) + + flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + runDart() + } + } + /** * Starts the Dart runtime/engine and calls `_nativeEntry` function in * `background.service.dart` to run the actual backup logic. @@ -139,7 +193,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct engine = null if (result != null) { Log.d(TAG, "stopEngine result=${result}") - resolvableFuture.set(result) + this.completer.set(result) } waitOnSetForegroundAsync() } @@ -270,13 +324,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" const val SHARED_PREF_LAST_CHANGE = "lastChange" + const val SHARED_PREF_SERVER_URL = "serverUrl" private const val TASK_NAME_BACKUP = "immich/BackupWorker" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_ID = 1 - private const val NOTIFICATION_ERROR_ID = 2 + private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_DETAIL_ID = 3 private const val ONE_MINUTE = 60000L @@ -304,7 +359,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) if (workInfoList != null) { for (workInfo in workInfoList) { - if (workInfo.getState() == WorkInfo.State.ENQUEUED) { + if (workInfo.state == WorkInfo.State.ENQUEUED) { val workRequest = buildWorkRequest(requireWifi, requireCharging) wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") @@ -359,4 +414,27 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } } -private const val TAG = "BackupWorker" \ No newline at end of file +private const val TAG = "BackupWorker" + +/** + * Check if the given URL is reachable via HTTP + */ +suspend fun isUrlReachableHttp(url: String, timeoutMillis: Long = 5000L): Boolean { + return withTimeoutOrNull(timeoutMillis) { + var httpURLConnection: HttpURLConnection? = null + try { + httpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "HEAD" + connectTimeout = timeoutMillis.toInt() + readTimeout = timeoutMillis.toInt() + } + httpURLConnection.connect() + httpURLConnection.responseCode == HttpURLConnection.HTTP_OK + } catch (e: Exception) { + Log.e(TAG, "Failed to reach server URL: $e") + false + } finally { + httpURLConnection?.disconnect() + } + } == true +} diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 4dacde5a9d..e9c271b2c5 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -1,7 +1,7 @@ buildscript { - ext.kotlin_version = '1.8.20' + ext.kotlin_version = '1.8.22' ext.kotlin_coroutines_version = '1.7.1' - ext.work_version = '2.7.1' + ext.work_version = '2.9.0' ext.concurrent_version = '1.1.0' ext.guava_version = '33.0.0-android' ext.glide_version = '4.14.2' diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index cbee121105..8358043894 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -68,8 +69,10 @@ class BackgroundService { final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final String title = "backup_background_service_default_notification".tr(); - final bool ok = await _foregroundChannel - .invokeMethod('enable', [callback.toRawHandle(), title, immediate]); + final bool ok = await _foregroundChannel.invokeMethod( + 'enable', + [callback.toRawHandle(), title, immediate, getServerUrl()], + ); return ok; } catch (error) { return false; From 6eb1b825412207729b9c60a8af6d29f6ff41ae8b Mon Sep 17 00:00:00 2001 From: Alex The Bot <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 13:43:46 +0000 Subject: [PATCH 26/46] Version v1.102.1 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 6 +++--- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | Bin 26826 -> 26826 bytes mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 16 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 899c1cd487..e17392c9d8 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,7 +47,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.0", + "version": "1.102.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index bdafbadd4f..b58faf4424 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.102.0", + "version": "1.102.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.102.0", + "version": "1.102.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -81,7 +81,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.0", + "version": "1.102.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 9e5ad85fb4..ae3aa015d7 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.102.0", + "version": "1.102.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 5610f8438d..9a9026fdff 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.102.0" +version = "1.102.1" description = "" authors = ["Hau Tran <alex.tran1502@gmail.com>"] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 50a22b6451..5690591d50 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 132, - "android.injected.version.name" => "1.102.0", + "android.injected.version.code" => 133, + "android.injected.version.name" => "1.102.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 74ffcf4237..548fac81f7 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.102.0" + version_number: "1.102.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 27d631e4fd03f971074a7eb7a3764485ec64ad20..b5a92b6e6fce2746c6f57aec9d13a0847d424ab5 100644 GIT binary patch delta 14 WcmX?gk@3_;#tCy74L8oy$p8Q~$Od}= delta 14 WcmX?gk@3_;#tCy74K~ix$p8Q~!v=Z) diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 70c443d832..9f367a76de 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.102.0+132 +version: 1.102.1+133 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4f666b303c..184400b140 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7078,7 +7078,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.102.0", + "version": "1.102.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index e698fb97ff..acef699734 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.102.0", + "version": "1.102.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.102.0", + "version": "1.102.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index eab26bc4e1..a323a5a459 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.102.0", + "version": "1.102.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1bf219162d..9fe74fde7b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.102.0 + * 1.102.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index c3f8e8cf79..f2362c2717 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.102.0", + "version": "1.102.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.102.0", + "version": "1.102.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index bdecc1362a..bb2cd8a24e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.102.0", + "version": "1.102.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index d36059ca05..b6e3aea30d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.102.0", + "version": "1.102.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.102.0", + "version": "1.102.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.0", + "version": "1.102.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 5e2ec844a1..11a1ce65ae 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.102.0", + "version": "1.102.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From c858b43717ccbef31d55fe8446901f9bd1f48cec Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 09:12:11 -0500 Subject: [PATCH 27/46] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 +++--- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/report.xml | 15 +++++---------- 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 9ff4624d67..9b1b5df910 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239"> </testcase> - <testcase classname="fastlane.lanes" name="1: bundleRelease" time="73.93743"> + <testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.670134"> </testcase> - <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="34.73691"> + <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.759668"> </testcase> diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 26153c05cd..9eb77da4d4 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 150; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -525,7 +525,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 150; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -553,7 +553,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 148; + CURRENT_PROJECT_VERSION = 150; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 3e7f6a874a..70338b5725 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.102.0</string> + <string>1.102.1</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>148</string> + <string>150</string> <key>FLTEnableImpeller</key> <true /> <key>ITSAppUsesNonExemptEncryption</key> diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 85320ab12a..b1538e60af 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,27 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000399"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000226"> </testcase> - <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.247535"> + <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.160617"> </testcase> - <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="8.325258"> + <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="2.966255"> </testcase> - <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.180002"> + <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.184278"> </testcase> - <testcase classname="fastlane.lanes" name="4: build_app" time="199.335284"> - - </testcase> - - - <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="90.564254"> + <testcase classname="fastlane.lanes" name="4: build_app" time="160.892368"> </testcase> From 6778653825e8deaa4db5e49f7f65bc0d57802975 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Sat, 20 Apr 2024 16:18:31 +0200 Subject: [PATCH 28/46] fix(web): keep focus when searching people (#8950) fix: keep focus when searching people Co-authored-by: Alex <alex.tran1502@gmail.com> --- web/src/routes/(user)/people/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index ba9db04c74..09d36890f5 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -89,7 +89,7 @@ const handleSearch = async (force: boolean) => { $page.url.searchParams.set(QueryParameter.SEARCHED_PEOPLE, searchName); - await goto($page.url); + await goto($page.url, { keepFocus: true }); await handleSearchPeople(force); }; From caf76f07130d8e9c125bb8a9329fe5b623602722 Mon Sep 17 00:00:00 2001 From: Jaryl Chng <github@jarylchng.com> Date: Sat, 20 Apr 2024 22:36:00 +0800 Subject: [PATCH 29/46] feat(server): enable AV1 encoding for QSV (#8942) --- server/src/utils/media.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index d0a6d4d740..7750e48873 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -566,7 +566,7 @@ export class QSVConfig extends BaseHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9]; + return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9, VideoCodec.AV1]; } // recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md From 7ec62f12b5e6c70629764f043a663dec5a59c674 Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 10:53:52 -0500 Subject: [PATCH 30/46] Revert "fix(mobile): random logout (#8739)" (#8954) This reverts commit 97c099e26dd35dc89a7a28b84e032e7aa11cba55. --- mobile/lib/shared/views/splash_screen.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index 64bc1ec081..47b550f9d0 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -25,7 +25,6 @@ class SplashScreenPage extends HookConsumerWidget { void performLoggingIn() async { bool isSuccess = false; bool deviceIsOffline = false; - if (accessToken != null && serverUrl != null) { try { // Resolve API server endpoint from user provided serverUrl @@ -51,11 +50,15 @@ class SplashScreenPage extends HookConsumerWidget { offlineLogin: deviceIsOffline, ); } catch (error, stackTrace) { + ref.read(authenticationProvider.notifier).logout(); + log.severe( 'Cannot set success login info', error, stackTrace, ); + + context.pushRoute(const LoginRoute()); } } @@ -73,11 +76,6 @@ class SplashScreenPage extends HookConsumerWidget { } context.replaceRoute(const TabControllerRoute()); } else { - log.severe( - 'Unable to login through offline or online methods - logging out completely', - ); - - ref.read(authenticationProvider.notifier).logout(); // User was unable to login through either offline or online methods context.replaceRoute(const LoginRoute()); } From 25549b87c924f65a43e8fa5d612cd6cf014c38f3 Mon Sep 17 00:00:00 2001 From: Alex The Bot <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 15:55:32 +0000 Subject: [PATCH 31/46] Version v1.102.2 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 6 +++--- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | Bin 26826 -> 26826 bytes mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 16 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index e17392c9d8..68a7a29280 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,7 +47,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.1", + "version": "1.102.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b58faf4424..b9933aee96 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.102.1", + "version": "1.102.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.102.1", + "version": "1.102.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -81,7 +81,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.1", + "version": "1.102.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index ae3aa015d7..b71950fd18 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.102.1", + "version": "1.102.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 9a9026fdff..4d06858b78 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.102.1" +version = "1.102.2" description = "" authors = ["Hau Tran <alex.tran1502@gmail.com>"] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 5690591d50..4dd9c9c60f 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 133, - "android.injected.version.name" => "1.102.1", + "android.injected.version.code" => 134, + "android.injected.version.name" => "1.102.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 548fac81f7..2d1c1129f7 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.102.1" + version_number: "1.102.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b5a92b6e6fce2746c6f57aec9d13a0847d424ab5..24e4e993b6fe54b6fe6b9ddab7f81f38a34afd13 100644 GIT binary patch delta 14 WcmX?gk@3_;#tCy7jW*8H$p8Q~%?5k` delta 14 WcmX?gk@3_;#tCy74L8oy$p8Q~$Od}= diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9f367a76de..f3d821c415 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.102.1+133 +version: 1.102.2+134 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 184400b140..c21b16e93f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7078,7 +7078,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.102.1", + "version": "1.102.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index acef699734..a3056fe71d 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.102.1", + "version": "1.102.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.102.1", + "version": "1.102.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index a323a5a459..3c2a0e1f7c 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.102.1", + "version": "1.102.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9fe74fde7b..d384986e0c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.102.1 + * 1.102.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index f2362c2717..a70fc65659 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.102.1", + "version": "1.102.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.102.1", + "version": "1.102.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index bb2cd8a24e..8ac141c24d 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.102.1", + "version": "1.102.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index b6e3aea30d..441ed46442 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.102.1", + "version": "1.102.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.102.1", + "version": "1.102.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.1", + "version": "1.102.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 11a1ce65ae..a891cfaa33 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.102.1", + "version": "1.102.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 40931b5668be18cfd34bb0520513b58f3445d271 Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 11:15:41 -0500 Subject: [PATCH 32/46] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 +++--- mobile/ios/Runner/Info.plist | 2 +- mobile/ios/fastlane/report.xml | 15 ++++++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 9b1b5df910..94f1ff73e9 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000425"> </testcase> - <testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.670134"> + <testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.658719"> </testcase> - <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.759668"> + <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.312519"> </testcase> diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 70338b5725..517c5b29ef 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,7 +58,7 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.102.1</string> + <string>1.102.2</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index b1538e60af..c3a94c1c23 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,27 +5,32 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000226"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000231"> </testcase> - <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.160617"> + <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.155919"> </testcase> - <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="2.966255"> + <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.252784"> </testcase> - <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.184278"> + <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.210502"> </testcase> - <testcase classname="fastlane.lanes" name="4: build_app" time="160.892368"> + <testcase classname="fastlane.lanes" name="4: build_app" time="175.813647"> + + </testcase> + + + <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="73.512517"> </testcase> From 2dd7c13b8871e5cc3e546e3f9244f0298c425643 Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 12:15:26 -0500 Subject: [PATCH 33/46] Revert "feat(android) Check server is reachable before starting background backup (#8594)" (#8958) This reverts commit 71b6d8b569038e1676316f61845c91cbac58c19c. --- mobile/android/app/build.gradle | 2 +- .../example/mobile/BackgroundServicePlugin.kt | 1 - .../kotlin/com/example/mobile/BackupWorker.kt | 132 ++++-------------- mobile/android/build.gradle | 4 +- .../background.service.dart | 7 +- 5 files changed, 32 insertions(+), 114 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index a6f86b8537..96d2db23f5 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -90,7 +90,7 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" - implementation "androidx.work:work-runtime:$work_version" + implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" implementation "com.google.guava:guava:$guava_version" implementation "com.github.bumptech.glide:glide:$glide_version" diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt index 1d23c5665c..6541ad5755 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt @@ -52,7 +52,6 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { .putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true) .putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long) .putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String) - .putString(BackupWorker.SHARED_PREF_SERVER_URL, args.get(3) as String) .apply() ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean) result.success(true) diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index dc7c4a9c37..660e1d55ba 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -11,8 +11,8 @@ import android.os.PowerManager import android.os.SystemClock import android.util.Log import androidx.annotation.RequiresApi -import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.core.app.NotificationCompat +import androidx.concurrent.futures.ResolvableFuture import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ForegroundInfo @@ -30,16 +30,6 @@ import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.view.FlutterCallbackInformation -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import java.io.IOException -import java.net.HttpURLConnection -import java.net.InetAddress -import java.net.URL import java.util.concurrent.TimeUnit /** @@ -52,6 +42,7 @@ import java.util.concurrent.TimeUnit */ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler { + private val resolvableFuture = ResolvableFuture.create<Result>() private var engine: FlutterEngine? = null private lateinit var backgroundChannel: MethodChannel private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -61,80 +52,35 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private var notificationDetailBuilder: NotificationCompat.Builder? = null private var fgFuture: ListenableFuture<Void>? = null - private val job = Job() - private lateinit var completer: CallbackToFutureAdapter.Completer<Result> - private val resolvableFuture = CallbackToFutureAdapter.getFuture { completer -> - this.completer = completer - null - } - - init { - resolvableFuture.addListener( - Runnable { - if (resolvableFuture.isCancelled) { - job.cancel() - } - }, - taskExecutor.serialTaskExecutor - ) - } - override fun startWork(): ListenableFuture<ListenableWorker.Result> { + Log.d(TAG, "startWork") val ctx = applicationContext - val prefs = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - prefs.getString(SHARED_PREF_SERVER_URL, null) - ?.takeIf { it.isNotEmpty() } - ?.let { serverUrl -> doCoroutineWork(serverUrl) } - ?: doWork() - return resolvableFuture - } - - /** - * This function is used to check if server URL is reachable before starting the backup work. - * Check must be done in a background to avoid blocking the main thread. - */ - private fun doCoroutineWork(serverUrl : String) { - CoroutineScope(Dispatchers.Default + job).launch { - val isReachable = isUrlReachableHttp(serverUrl) - withContext(Dispatchers.Main) { - if (isReachable) { - doWork() - } else { - // Fail when the URL is not reachable - completer.set(Result.failure()) - } - } + if (!flutterLoader.initialized()) { + flutterLoader.startInitialization(ctx) } - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Create a Notification channel if necessary + createChannel() + } + if (isIgnoringBatteryOptimizations) { + // normal background services can only up to 10 minutes + // foreground services are allowed to run indefinitely + // requires battery optimizations to be disabled (either manually by the user + // or by the system learning that immich is important to the user) + val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! + showInfo(getInfoBuilder(title, indeterminate=true).build()) + } + engine = FlutterEngine(ctx) - private fun doWork() { - Log.d(TAG, "doWork") - val ctx = applicationContext + flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + runDart() + } - if (!flutterLoader.initialized()) { - flutterLoader.startInitialization(ctx) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Create a Notification channel if necessary - createChannel() - } - if (isIgnoringBatteryOptimizations) { - // normal background services can only up to 10 minutes - // foreground services are allowed to run indefinitely - // requires battery optimizations to be disabled (either manually by the user - // or by the system learning that immich is important to the user) - val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) - .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!! - showInfo(getInfoBuilder(title, indeterminate=true).build()) - } - engine = FlutterEngine(ctx) - - flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { - runDart() - } + return resolvableFuture } /** @@ -193,7 +139,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct engine = null if (result != null) { Log.d(TAG, "stopEngine result=${result}") - this.completer.set(result) + resolvableFuture.set(result) } waitOnSetForegroundAsync() } @@ -324,14 +270,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle" const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle" const val SHARED_PREF_LAST_CHANGE = "lastChange" - const val SHARED_PREF_SERVER_URL = "serverUrl" private const val TASK_NAME_BACKUP = "immich/BackupWorker" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_ID = 1 - private const val NOTIFICATION_ERROR_ID = 2 + private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_DETAIL_ID = 3 private const val ONE_MINUTE = 60000L @@ -359,7 +304,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) if (workInfoList != null) { for (workInfo in workInfoList) { - if (workInfo.state == WorkInfo.State.ENQUEUED) { + if (workInfo.getState() == WorkInfo.State.ENQUEUED) { val workRequest = buildWorkRequest(requireWifi, requireCharging) wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") @@ -414,27 +359,4 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } } -private const val TAG = "BackupWorker" - -/** - * Check if the given URL is reachable via HTTP - */ -suspend fun isUrlReachableHttp(url: String, timeoutMillis: Long = 5000L): Boolean { - return withTimeoutOrNull(timeoutMillis) { - var httpURLConnection: HttpURLConnection? = null - try { - httpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply { - requestMethod = "HEAD" - connectTimeout = timeoutMillis.toInt() - readTimeout = timeoutMillis.toInt() - } - httpURLConnection.connect() - httpURLConnection.responseCode == HttpURLConnection.HTTP_OK - } catch (e: Exception) { - Log.e(TAG, "Failed to reach server URL: $e") - false - } finally { - httpURLConnection?.disconnect() - } - } == true -} +private const val TAG = "BackupWorker" \ No newline at end of file diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index e9c271b2c5..4dacde5a9d 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -1,7 +1,7 @@ buildscript { - ext.kotlin_version = '1.8.22' + ext.kotlin_version = '1.8.20' ext.kotlin_coroutines_version = '1.7.1' - ext.work_version = '2.9.0' + ext.work_version = '2.7.1' ext.concurrent_version = '1.1.0' ext.guava_version = '33.0.0-android' ext.glide_version = '4.14.2' diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 8358043894..cbee121105 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:immich_mobile/utils/url_helper.dart'; import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -69,10 +68,8 @@ class BackgroundService { final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!; final String title = "backup_background_service_default_notification".tr(); - final bool ok = await _foregroundChannel.invokeMethod( - 'enable', - [callback.toRawHandle(), title, immediate, getServerUrl()], - ); + final bool ok = await _foregroundChannel + .invokeMethod('enable', [callback.toRawHandle(), title, immediate]); return ok; } catch (error) { return false; From fd4514711f03a236b508f84b7aedec70de27bd4c Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 20 Apr 2024 14:52:50 -0400 Subject: [PATCH 34/46] feat(server): enable AV1 encoding for NVENC (#8959) allow av1 for nvenc --- server/src/utils/media.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 7750e48873..ff38ded631 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -436,7 +436,7 @@ export class AV1Config extends BaseConfig { export class NVENCConfig extends BaseHWConfig { getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC]; + return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1]; } getBaseInputOptions() { From 1e3dceea4d0ce4a1be4ea27385c8fb8ef4ca16a7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Sat, 20 Apr 2024 16:15:25 -0400 Subject: [PATCH 35/46] fix(server): session refresh (#8974) --- server/src/services/auth.service.spec.ts | 5 +---- server/src/services/auth.service.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index cbee9faddf..f00e10b13c 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -340,10 +340,7 @@ describe('AuthService', () => { sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); sessionMock.update.mockResolvedValue(sessionStub.valid); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; - await expect(sut.validate(headers, {})).resolves.toEqual({ - user: userStub.user1, - session: sessionStub.valid, - }); + await expect(sut.validate(headers, {})).resolves.toBeDefined(); expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index bea7366555..72fee12f45 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -374,14 +374,14 @@ export class AuthService { private async validateSession(tokenValue: string): Promise<AuthDto> { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); - let session = await this.sessionRepository.getByToken(hashedToken); + const session = await this.sessionRepository.getByToken(hashedToken); if (session?.user) { const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); if (diff.hours > 1) { - session = await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); + await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); } return { user: session.user, session: session }; From a2180a467d09c01ec41c1481e51c7485d5a82046 Mon Sep 17 00:00:00 2001 From: Alex The Bot <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 20:17:39 +0000 Subject: [PATCH 36/46] Version v1.102.3 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 6 +++--- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 2 +- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | Bin 26826 -> 26826 bytes mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 16 files changed, 21 insertions(+), 21 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 68a7a29280..5075514973 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,7 +47,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.2", + "version": "1.102.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b9933aee96..4643b8b01f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.102.2", + "version": "1.102.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.102.2", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -81,7 +81,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.2", + "version": "1.102.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index b71950fd18..34ef229a2a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.102.2", + "version": "1.102.3", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 4d06858b78..76f51d964e 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.102.2" +version = "1.102.3" description = "" authors = ["Hau Tran <alex.tran1502@gmail.com>"] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 4dd9c9c60f..024cd6436f 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -36,7 +36,7 @@ platform :android do build_type: 'Release', properties: { "android.injected.version.code" => 134, - "android.injected.version.name" => "1.102.2", + "android.injected.version.name" => "1.102.3", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 2d1c1129f7..dd62415ff5 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.102.2" + version_number: "1.102.3" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 24e4e993b6fe54b6fe6b9ddab7f81f38a34afd13..5439d48208e7b6b1614d6d56e32c2839582eaa21 100644 GIT binary patch delta 14 WcmX?gk@3_;#tCy7jW^EI$p8Q~(guA1 delta 14 WcmX?gk@3_;#tCy7jW*8H$p8Q~%?5k` diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f3d821c415..16fd92d821 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.102.2+134 +version: 1.102.3+134 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c21b16e93f..b5bf2c9f4d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7078,7 +7078,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.102.2", + "version": "1.102.3", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index a3056fe71d..a6a751ccfa 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.102.2", + "version": "1.102.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.102.2", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 3c2a0e1f7c..dd2360cede 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.102.2", + "version": "1.102.3", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d384986e0c..41603bc0e8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.102.2 + * 1.102.3 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index a70fc65659..eb062059f3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.102.2", + "version": "1.102.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.102.2", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 8ac141c24d..274eddd304 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.102.2", + "version": "1.102.3", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 441ed46442..40f1d937c2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.102.2", + "version": "1.102.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.102.2", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.102.2", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index a891cfaa33..b2bdd5afba 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.102.2", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From cef84f6cedef1c7f83027327474681d92d685845 Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Sat, 20 Apr 2024 19:56:03 -0500 Subject: [PATCH 37/46] chore(mobile): override appbundle on PlayStore before getting released (#8960) --- mobile/android/fastlane/Fastfile | 2 +- mobile/android/fastlane/report.xml | 6 +++--- mobile/pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 024cd6436f..94a9a7e0bd 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,7 +35,7 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 134, + "android.injected.version.code" => 136, "android.injected.version.name" => "1.102.3", } ) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 94f1ff73e9..358fb9618c 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000425"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000261"> </testcase> - <testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.658719"> + <testcase classname="fastlane.lanes" name="1: bundleRelease" time="32.48099"> </testcase> - <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.312519"> + <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.236974"> </testcase> diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 16fd92d821..828c9a63b7 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.102.3+134 +version: 1.102.3+136 environment: sdk: '>=3.0.0 <4.0.0' From a93534fc3c123abf756bcd22fd11b17e68c0f08d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen <jrasm91@gmail.com> Date: Sat, 20 Apr 2024 23:45:55 -0400 Subject: [PATCH 38/46] refactor(server): session interface types (#8977) --- server/src/interfaces/session.interface.ts | 10 ++++++---- server/src/repositories/session.repository.ts | 8 ++++---- server/test/repositories/session.repository.mock.ts | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts index 3e2c9574a4..62c2ecec7b 100644 --- a/server/src/interfaces/session.interface.ts +++ b/server/src/interfaces/session.interface.ts @@ -2,10 +2,12 @@ import { SessionEntity } from 'src/entities/session.entity'; export const ISessionRepository = 'ISessionRepository'; +type E = SessionEntity; + export interface ISessionRepository { - create(dto: Partial<SessionEntity>): Promise<SessionEntity>; - update(dto: Partial<SessionEntity>): Promise<SessionEntity>; + create<T extends Partial<E>>(dto: T): Promise<T>; + update<T extends Partial<E>>(dto: T): Promise<T>; delete(id: string): Promise<void>; - getByToken(token: string): Promise<SessionEntity | null>; - getByUserId(userId: string): Promise<SessionEntity[]>; + getByToken(token: string): Promise<E | null>; + getByUserId(userId: string): Promise<E[]>; } diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 5e42039bc6..ed2da7a05f 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -31,12 +31,12 @@ export class SessionRepository implements ISessionRepository { }); } - create(session: Partial<SessionEntity>): Promise<SessionEntity> { - return this.repository.save(session); + create<T extends Partial<SessionEntity>>(dto: T): Promise<T & { id: string }> { + return this.repository.save(dto); } - update(session: Partial<SessionEntity>): Promise<SessionEntity> { - return this.repository.save(session); + update<T extends Partial<SessionEntity>>(dto: T): Promise<T> { + return this.repository.save(dto); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts index 1a034e79f0..d510eb53f7 100644 --- a/server/test/repositories/session.repository.mock.ts +++ b/server/test/repositories/session.repository.mock.ts @@ -3,8 +3,8 @@ import { Mocked, vitest } from 'vitest'; export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => { return { - create: vitest.fn(), - update: vitest.fn(), + create: vitest.fn() as any, + update: vitest.fn() as any, delete: vitest.fn(), getByToken: vitest.fn(), getByUserId: vitest.fn(), From 7d4187962ad0b4ebc131390d39422b219a8e85d1 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Sun, 21 Apr 2024 06:06:49 +0200 Subject: [PATCH 39/46] feat(web): new look option for slideshow (#8924) feat: new look option for slideshow --- .../asset-viewer/photo-viewer.svelte | 19 ++++++++++++++----- .../lib/components/slideshow-settings.svelte | 10 +++++++++- web/src/lib/stores/slideshow.store.ts | 2 ++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 6285a63006..2bf4ba4801 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -14,7 +14,7 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; + import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; const { slideshowState, slideshowLook } = slideshowStore; @@ -150,15 +150,24 @@ <div bind:this={element} transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} - class="flex h-full select-none place-content-center place-items-center" + class="relative h-full select-none" > {#if !imageLoaded} - <LoadingSpinner /> + <div class="flex h-full items-center justify-center"> + <LoadingSpinner /> + </div> {:else} - <div bind:this={imgElement} class="h-full w-full"> + <div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}> + {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} + <img + src={assetData} + alt={getAltText(asset)} + class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg" + draggable="false" + /> + {/if} <img bind:this={$photoViewer} - transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} src={assetData} alt={getAltText(asset)} class="h-full w-full {$slideshowState === SlideshowState.None diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index d0ac5eab93..64480dfdbf 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -4,7 +4,14 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; - import { mdiArrowDownThin, mdiArrowUpThin, mdiFitToPageOutline, mdiFitToScreenOutline, mdiShuffle } from '@mdi/js'; + import { + mdiArrowDownThin, + mdiArrowUpThin, + mdiFitToPageOutline, + mdiFitToScreenOutline, + mdiPanorama, + mdiShuffle, + } from '@mdi/js'; import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; import Button from './elements/buttons/button.svelte'; import type { RenderedOption } from './elements/dropdown.svelte'; @@ -23,6 +30,7 @@ const lookOptions: Record<SlideshowLook, RenderedOption> = { [SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: 'Contain' }, [SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: 'Cover' }, + [SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: 'Blurred background' }, }; const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>( diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts index 453f216912..daa4e98791 100644 --- a/web/src/lib/stores/slideshow.store.ts +++ b/web/src/lib/stores/slideshow.store.ts @@ -16,11 +16,13 @@ export enum SlideshowNavigation { export enum SlideshowLook { Contain = 'contain', Cover = 'cover', + BlurredBackground = 'blurred-background', } export const slideshowLookCssMapping: Record<SlideshowLook, string> = { [SlideshowLook.Contain]: 'object-contain', [SlideshowLook.Cover]: 'object-cover', + [SlideshowLook.BlurredBackground]: 'object-contain', }; function createSlideshowStore() { From 776023b149b204033af17a6aeb302f9cb1577bed Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Sun, 21 Apr 2024 04:07:32 +0000 Subject: [PATCH 40/46] dep(mobile): upgrade gradle (#8409) * dep(mobile): upgrade gradle * chore(deps): update kotlin & guava * build: change java version and flutter test version --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/build-mobile.yml | 10 +++--- .github/workflows/test.yml | 2 +- mobile/android/app/build.gradle | 32 +++++++++---------- .../kotlin/com/example/mobile/BackupWorker.kt | 8 ++--- mobile/android/build.gradle | 22 +++---------- .../gradle/wrapper/gradle-wrapper.properties | 5 +-- mobile/android/settings.gradle | 31 +++++++++++++----- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 2 +- 9 files changed, 58 insertions(+), 56 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 3da7cbe107..e522854059 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -37,15 +37,15 @@ jobs: - uses: actions/setup-java@v4 with: - distribution: "zulu" - java-version: "11.0.21+9" - cache: "gradle" + distribution: 'zulu' + java-version: '17' + cache: 'gradle' - name: Setup Flutter SDK uses: subosito/flutter-action@v2 with: - channel: "stable" - flutter-version: "3.19.3" + channel: 'stable' + flutter-version: '3.19.3' cache: true - name: Create the Keystore diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d74c961393..51b947418e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -208,7 +208,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.16.9' + flutter-version: '3.19.3' - name: Run tests working-directory: ./mobile run: flutter test -j 1 diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 96d2db23f5..85d6206e1f 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -1,14 +1,14 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" + id "kotlin-kapt" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") + localPropertiesFile.withInputStream { localProperties.load(it) } } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') @@ -21,18 +21,12 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + keystorePropertiesFile.withInputStream { keystoreProperties.load(it) } } - android { compileSdkVersion 34 @@ -50,7 +44,6 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "app.alextran.immich" minSdkVersion 26 targetSdkVersion 33 @@ -88,6 +81,13 @@ flutter { } dependencies { + def kotlin_version = '1.9.23' + def kotlin_coroutines_version = '1.8.0' + def work_version = '2.9.0' + def concurrent_version = '1.1.0' + def guava_version = '33.1.0-android' + def glide_version = '4.16.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "androidx.work:work-runtime-ktx:$work_version" diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index 660e1d55ba..b6b78c2cba 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -276,7 +276,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_ID = 1 - private const val NOTIFICATION_ERROR_ID = 2 + private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_DETAIL_ID = 3 private const val ONE_MINUTE = 60000L @@ -304,7 +304,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) if (workInfoList != null) { for (workInfo in workInfoList) { - if (workInfo.getState() == WorkInfo.State.ENQUEUED) { + if (workInfo.state == WorkInfo.State.ENQUEUED) { val workRequest = buildWorkRequest(requireWifi, requireCharging) wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") @@ -346,7 +346,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct .setRequiresBatteryNotLow(true) .setRequiresCharging(requireCharging) .build(); - + val work = OneTimeWorkRequest.Builder(BackupWorker::class.java) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS) @@ -359,4 +359,4 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } } -private const val TAG = "BackupWorker" \ No newline at end of file +private const val TAG = "BackupWorker" diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 4dacde5a9d..5e374c9f64 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -1,21 +1,3 @@ -buildscript { - ext.kotlin_version = '1.8.20' - ext.kotlin_coroutines_version = '1.7.1' - ext.work_version = '2.7.1' - ext.concurrent_version = '1.1.0' - ext.guava_version = '33.0.0-android' - ext.glide_version = '4.14.2' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -34,3 +16,7 @@ subprojects { tasks.register("clean", Delete) { delete rootProject.buildDir } + +tasks.named('wrapper') { + distributionType = Wrapper.DistributionType.ALL +} \ No newline at end of file diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties index 7787882b74..6357330c9e 100644 --- a/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip -distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80 \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip +distributionSha256Sum=fe696c020f241a5f69c30f763c5a7f38eec54b490db19cd2b0962dda420d7d12 \ No newline at end of file diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 44e62bcf06..7ea6533b65 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -1,11 +1,26 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.4.2" apply false + id "org.jetbrains.kotlin.android" version "1.9.23" apply false + id "org.jetbrains.kotlin.kapt" version "1.9.23" apply false +} + +include ":app" diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 6bf2b09026..54648fd20b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1804,5 +1804,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0 <4.0.0" + dart: ">=3.3.0 <4.0.0" flutter: ">=3.16.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 828c9a63b7..8ae3a21318 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -5,7 +5,7 @@ publish_to: 'none' version: 1.102.3+136 environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.3.0 <4.0.0' dependencies: flutter: From a99862120d5e3f6bf9b4fe19b571e5fa5b58ad80 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sun, 21 Apr 2024 20:11:03 +0200 Subject: [PATCH 41/46] feat: mobile label for renovate pull requests (#8991) mobile lable for renovate pull requests --- renovate.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index afa68011d0..3e2c50d7f1 100644 --- a/renovate.json +++ b/renovate.json @@ -27,7 +27,8 @@ "matchFileNames": ["mobile/**"], "groupName": "mobile", "matchUpdateTypes": ["minor", "patch"], - "schedule": "on tuesday" + "schedule": "on tuesday", + "addLabels": ["📱mobile"] }, { "groupName": "exiftool", From 21231d53a589f10acea804620c3219d47e6c063a Mon Sep 17 00:00:00 2001 From: clementdelestre <56797425+clementdelestre@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:26:19 +0300 Subject: [PATCH 42/46] feat(mobile): add i18n in multiselect-grid and update translation (en and fr) (#8993) * add i18n in multiselect grid (en-fr) * add FR translations from (haptic feedback) * revert settings --- mobile/assets/i18n/en-US.json | 1 + mobile/assets/i18n/fr-FR.json | 5 ++++- mobile/lib/shared/ui/asset_grid/multiselect_grid.dart | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 1a6ca76757..46155d0c53 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -296,6 +296,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "no_assets_to_show" : "No assets to show", "notification_permission_dialog_cancel": "Cancel", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_dialog_settings": "Settings", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index eaa4fc06ed..f9ce46c4d5 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -296,6 +296,7 @@ "motion_photos_page_title": "Photos avec mouvement", "multiselect_grid_edit_date_time_err_read_only": "Impossible de modifier la date d'un élément d'actif en lecture seule.", "multiselect_grid_edit_gps_err_read_only": "Impossible de modifier l'emplacement d'un élément en lecture seule.", + "no_assets_to_show" : "Aucun élément à afficher", "notification_permission_dialog_cancel": "Annuler", "notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sélectionnez Autoriser.", "notification_permission_dialog_settings": "Paramètres", @@ -509,5 +510,7 @@ "version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89", "viewer_remove_from_stack": "Retirer de la pile", "viewer_stack_use_as_main_asset": "Utiliser comme élément principal", - "viewer_unstack": "Désempiler" + "viewer_unstack": "Désempiler", + "haptic_feedback_title": "Retour haptique", + "haptic_feedback_switch": "Activer le retour haptique" } \ No newline at end of file diff --git a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart index c9cc6c04a9..482f1efc4f 100644 --- a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart +++ b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart @@ -63,7 +63,7 @@ class MultiselectGrid extends HookConsumerWidget { const Center(child: ImmichLoadingIndicator()); Widget buildEmptyIndicator() => - emptyIndicator ?? const Center(child: Text("No assets to show")); + emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr()); @override Widget build(BuildContext context, WidgetRef ref) { From f004487be0d68d7c14891ee21d941607ee574d92 Mon Sep 17 00:00:00 2001 From: Conner Hnatiuk <46903591+ConnerWithAnE@users.noreply.github.com> Date: Sun, 21 Apr 2024 13:07:17 -0600 Subject: [PATCH 43/46] fix(web): trash page now auto refreshes (#8978) * fix(web): the trash page now auto refreshes when restore all or empty trash is clicked. Also shows number of assets affected. * formatting --- web/src/routes/(user)/trash/+page.svelte | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/routes/(user)/trash/+page.svelte b/web/src/routes/(user)/trash/+page.svelte index 5be97f2821..2728f25b02 100644 --- a/web/src/routes/(user)/trash/+page.svelte +++ b/web/src/routes/(user)/trash/+page.svelte @@ -39,8 +39,12 @@ try { await emptyTrash(); + const deletedAssetIds = assetStore.assets.map((a) => a.id); + const numberOfAssets = deletedAssetIds.length; + assetStore.removeAssets(deletedAssetIds); + notificationController.show({ - message: `Empty trash initiated. Refresh the page to see the changes`, + message: `Permanently deleted ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`, type: NotificationType.Info, }); } catch (error) { @@ -52,8 +56,12 @@ try { await restoreTrash(); + const restoredAssetIds = assetStore.assets.map((a) => a.id); + const numberOfAssets = restoredAssetIds.length; + assetStore.removeAssets(restoredAssetIds); + notificationController.show({ - message: `Restore trash initiated. Refresh the page to see the changes`, + message: `Restored ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`, type: NotificationType.Info, }); } catch (error) { From 0d3cc28f45607ebf62069fe898f8e1df6b7a586c Mon Sep 17 00:00:00 2001 From: TruongSinh Tran-Nguyen <i@truongsinh.pro> Date: Sun, 21 Apr 2024 12:14:54 -0700 Subject: [PATCH 44/46] feat(web): support 360 video (equirectangular) (#8762) * [web]: support 360 video * lint * lint * fix typing --------- Co-authored-by: Alex <alex.tran1502@gmail.com> --- web/package-lock.json | 18 ++++++++++++ web/package.json | 2 ++ .../asset-viewer/asset-viewer.svelte | 5 +++- .../asset-viewer/panorama-viewer.svelte | 29 +++++++++++++++---- .../photo-sphere-viewer-adapter.svelte | 21 ++++++++++++-- ...ewer.svelte => video-native-viewer.svelte} | 0 .../asset-viewer/video-wrapper-viewer.svelte | 15 ++++++++++ 7 files changed, 80 insertions(+), 10 deletions(-) rename web/src/lib/components/asset-viewer/{video-viewer.svelte => video-native-viewer.svelte} (100%) create mode 100644 web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte diff --git a/web/package-lock.json b/web/package-lock.json index 40f1d937c2..89b02cc4cd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,8 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", + "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", + "@photo-sphere-viewer/video-plugin": "^5.7.2", "@zoom-image/svelte": "^0.2.6", "buffer": "^6.0.3", "copy-image-clipboard": "^2.1.2", @@ -1590,6 +1592,22 @@ "three": "^0.161.0" } }, + "node_modules/@photo-sphere-viewer/equirectangular-video-adapter": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.7.2.tgz", + "integrity": "sha512-cAaot52nPqa2p77Xp1humRvuxRIa8cqbZ/XRhA8kBToFLT1Ugh9YBcDD7pM/358JtAjicUbLpT7Ioap9iEigxQ==", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.7.2" + } + }, + "node_modules/@photo-sphere-viewer/video-plugin": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.7.2.tgz", + "integrity": "sha512-vrPV9RCr4HsYiORkto1unDPeUkbN2kbyogvNUoLiQ78M4xkPOqoKxtfxCxTYoM+7gECwNL9VTF81+okck498qA==", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.7.2" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.24", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", diff --git a/web/package.json b/web/package.json index b2bdd5afba..34c2ee83a3 100644 --- a/web/package.json +++ b/web/package.json @@ -61,6 +61,8 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", + "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", + "@photo-sphere-viewer/video-plugin": "^5.7.2", "@zoom-image/svelte": "^0.2.6", "buffer": "^6.0.3", "copy-image-clipboard": "^2.1.2", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 40309e511f..28899a7525 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -50,7 +50,7 @@ import PanoramaViewer from './panorama-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte'; import SlideshowBar from './slideshow-bar.svelte'; - import VideoViewer from './video-viewer.svelte'; + import VideoViewer from './video-wrapper-viewer.svelte'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; @@ -622,6 +622,7 @@ {:else} <VideoViewer assetId={previewStackedAsset.id} + projectionType={previewStackedAsset.exifInfo?.projectionType} on:close={closeViewer} on:onVideoEnded={() => navigateAsset()} on:onVideoStarted={handleVideoStarted} @@ -642,6 +643,7 @@ {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} <VideoViewer assetId={asset.livePhotoVideoId} + projectionType={asset.exifInfo?.projectionType} on:close={closeViewer} on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} /> @@ -655,6 +657,7 @@ {:else} <VideoViewer assetId={asset.id} + projectionType={asset.exifInfo?.projectionType} on:close={closeViewer} on:onVideoEnded={() => navigateAsset()} on:onVideoStarted={handleVideoStarted} diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index 66d8f63099..592053e5b8 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -1,22 +1,39 @@ <script lang="ts"> - import { serveFile, type AssetResponseDto } from '@immich/sdk'; + import { serveFile, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { getKey } from '$lib/utils'; - export let asset: AssetResponseDto; + import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core'; + export let asset: Pick<AssetResponseDto, 'id' | 'type'>; + + const photoSphereConfigs = + asset.type === AssetTypeEnum.Video + ? ([ + import('@photo-sphere-viewer/equirectangular-video-adapter').then( + ({ EquirectangularVideoAdapter }) => EquirectangularVideoAdapter, + ), + import('@photo-sphere-viewer/video-plugin').then(({ VideoPlugin }) => [VideoPlugin]), + true, + import('@photo-sphere-viewer/video-plugin/index.css'), + ] as [PromiseLike<AdapterConstructor>, Promise<PluginConstructor[]>, true, unknown]) + : ([undefined, [], false] as [undefined, [], false]); const loadAssetData = async () => { const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() }); - return URL.createObjectURL(data); + const url = URL.createObjectURL(data); + if (asset.type === AssetTypeEnum.Video) { + return { source: url }; + } + return url; }; </script> <div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <!-- the photo sphere viewer is quite large, so lazy load it in parallel with loading the data --> - {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte')])} + {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])} <LoadingSpinner /> - {:then [data, module]} - <svelte:component this={module.default} panorama={data} /> + {:then [data, module, adapter, plugins, navbar]} + <svelte:component this={module.default} panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} /> {:catch} Failed to load asset {/await} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 796622e7fe..0c0e707693 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -1,17 +1,32 @@ <script lang="ts"> - import { Viewer } from '@photo-sphere-viewer/core'; + import { + Viewer, + EquirectangularAdapter, + type PluginConstructor, + type AdapterConstructor, + } from '@photo-sphere-viewer/core'; import '@photo-sphere-viewer/core/index.css'; import { onDestroy, onMount } from 'svelte'; - export let panorama: string; + export let panorama: string | { source: string }; + export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter; + export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = []; + export let navbar = false; + let container: HTMLDivElement; let viewer: Viewer; onMount(() => { viewer = new Viewer({ + adapter, + plugins, container, panorama, - navbar: false, + touchmoveTwoFingers: true, + mousewheelCtrlKey: false, + navbar, + maxFov: 180, + fisheye: true, }); }); diff --git a/web/src/lib/components/asset-viewer/video-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte similarity index 100% rename from web/src/lib/components/asset-viewer/video-viewer.svelte rename to web/src/lib/components/asset-viewer/video-native-viewer.svelte diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte new file mode 100644 index 0000000000..59809caa25 --- /dev/null +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import { AssetTypeEnum } from '@immich/sdk'; + import { ProjectionType } from '$lib/constants'; + import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte'; + import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte'; + + export let assetId: string; + export let projectionType: string | null | undefined; +</script> + +{#if projectionType === ProjectionType.EQUIRECTANGULAR} + <PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} /> +{:else} + <VideoNativeViewer {assetId} on:onVideoEnded on:onVideoStarted /> +{/if} From c30cd3b3786ea8582239ac57b13d2b141c721080 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 22 Apr 2024 01:35:27 -0400 Subject: [PATCH 45/46] chore: test more formats in e2e (#9001) --- e2e/src/api/specs/asset.e2e-spec.ts | 106 ++++++++++++++++++++++++++++ server/test/assets | 2 +- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 53c4c43468..d18dc2532c 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -572,6 +572,22 @@ describe('/asset', () => { } const tests = [ + { + input: 'formats/avif/8bit-sRGB.avif', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '8bit-sRGB.avif', + resized: true, + exifInfo: { + description: '', + exifImageHeight: 1080, + exifImageWidth: 1617, + fileSizeInByte: 862_424, + latitude: null, + longitude: null, + }, + }, + }, { input: 'formats/jpg/el_torcal_rocks.jpg', expected: { @@ -596,6 +612,22 @@ describe('/asset', () => { }, }, }, + { + input: 'formats/jxl/8bit-sRGB.jxl', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '8bit-sRGB.jxl', + resized: true, + exifInfo: { + description: '', + exifImageHeight: 1080, + exifImageWidth: 1440, + fileSizeInByte: 1_780_777, + latitude: null, + longitude: null, + }, + }, + }, { input: 'formats/heic/IMG_2682.heic', expected: { @@ -681,6 +713,80 @@ describe('/asset', () => { }, }, }, + { + input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '4_3.rw2', + resized: true, + fileCreatedAt: '2018-05-10T08:42:37.842Z', + exifInfo: { + make: 'Panasonic', + model: 'DMC-GH4', + exifImageHeight: 3456, + exifImageWidth: 4608, + exposureTime: '1/100', + fNumber: 3.2, + focalLength: 35, + iso: 400, + fileSizeInByte: 19_587_072, + dateTimeOriginal: '2018-05-10T08:42:37.842Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '12bit-compressed-(3_2).arw', + resized: true, + fileCreatedAt: '2016-09-27T10:51:44.000Z', + exifInfo: { + make: 'SONY', + model: 'ILCE-6300', + exifImageHeight: 4024, + exifImageWidth: 6048, + exposureTime: '1/320', + fNumber: 8, + focalLength: 97, + iso: 100, + lensModel: 'E PZ 18-105mm F4 G OSS', + fileSizeInByte: 25_001_984, + dateTimeOriginal: '2016-09-27T10:51:44.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '14bit-uncompressed-(3_2).arw', + resized: true, + fileCreatedAt: '2016-01-08T15:08:01.000Z', + exifInfo: { + make: 'SONY', + model: 'ILCE-7M2', + exifImageHeight: 4024, + exifImageWidth: 6048, + exposureTime: '1.3', + fNumber: 22, + focalLength: 25, + iso: 100, + lensModel: 'E 25mm F2', + fileSizeInByte: 49_512_448, + dateTimeOriginal: '2016-01-08T15:08:01.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, ]; for (const { input, expected } of tests) { diff --git a/server/test/assets b/server/test/assets index 61131e84ec..625ec3a5e9 160000 --- a/server/test/assets +++ b/server/test/assets @@ -1 +1 @@ -Subproject commit 61131e84ec91d316265aebe375b3155308baaa89 +Subproject commit 625ec3a5e9aa9b087ad986e0c2e6a24edb4ea81e From be4a783845cd4461b7df504938c083be6800e66f Mon Sep 17 00:00:00 2001 From: Alex <alex.tran1502@gmail.com> Date: Mon, 22 Apr 2024 06:22:59 -0500 Subject: [PATCH 46/46] fix(web): wrong month on timeline scrollbar cursor (#8996) * fix(web): wrong month on timeline scrollbar cursor * revert unnesessary change --- .../lib/components/shared-components/scrollbar/scrollbar.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte index 2573a7730c..16e53dd562 100644 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte @@ -83,6 +83,7 @@ hoverLabel = new Date(attr).toLocaleString($locale, { month: 'short', year: 'numeric', + timeZone: 'UTC', }); };