From f2726606e08f869c5003b974f737e3f64cb15c46 Mon Sep 17 00:00:00 2001 From: Daniil Suvorov Date: Mon, 9 Mar 2026 21:47:54 +0300 Subject: [PATCH 01/49] fix(web): context menu overflow (#26760) --- .../context-menu/context-menu.svelte | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte index dbe32f2701..58ae508320 100644 --- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte @@ -2,8 +2,6 @@ import { clickOutside } from '$lib/actions/click-outside'; import { languageManager } from '$lib/managers/language-manager.svelte'; import type { Snippet } from 'svelte'; - import { quintOut } from 'svelte/easing'; - import { slide } from 'svelte/transition'; interface Props { isVisible?: boolean; @@ -14,6 +12,7 @@ ariaLabel?: string | undefined; ariaLabelledBy?: string | undefined; ariaActiveDescendant?: string | undefined; + menuScrollView?: HTMLDivElement | undefined; menuElement?: HTMLUListElement | undefined; onClose?: (() => void) | undefined; children?: Snippet; @@ -28,6 +27,7 @@ ariaLabel = undefined, ariaLabelledBy = undefined, ariaActiveDescendant = undefined, + menuScrollView = $bindable(), menuElement = $bindable(), onClose = undefined, children, @@ -37,33 +37,43 @@ const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction); const position = $derived.by(() => { - if (!menuElement) { + if (!menuScrollView || !menuElement) { return { left: 0, top: 0 }; } - const rect = menuElement.getBoundingClientRect(); + const rect = menuScrollView.getBoundingClientRect(); const directionWidth = layoutDirection === 'left' ? rect.width : 0; - const menuHeight = Math.min(menuElement.clientHeight, height) || 0; - const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth)); - const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y)); - const maxHeight = window.innerHeight - top - 8; + const margin = 8; - return { left, top, maxHeight }; + const left = Math.max(margin, Math.min(windowInnerWidth - rect.width - margin, x - directionWidth)); + const top = Math.max(margin, Math.min(windowInnerHeight - menuElement.clientHeight, y)); + const maxHeight = windowInnerHeight - top - margin; + + const needScrollBar = menuElement.clientHeight > maxHeight; + + return { left, top, maxHeight, needScrollBar }; }); - // We need to bind clientHeight since the bounding box may return a height - // of zero when starting the 'slide' animation. - let height: number = $state(0); + let windowInnerHeight: number = $state(0); + let windowInnerWidth: number = $state(0); + +
-
+
+ {#if assetViewerManager.isImageLoading} + + {#snippet child({ props })} +
+ +
+ {/snippet} +
+ {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8520e69a3d..3f7b048c8f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,6 +5,7 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; @@ -12,9 +13,9 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { imageManager } from '$lib/managers/ImageManager.svelte'; import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -37,6 +38,7 @@ } from '@immich/sdk'; import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; @@ -93,20 +95,19 @@ stopProgress: stopSlideshowProgress, slideshowNavigation, slideshowState, - slideshowTransition, slideshowRepeat, } = slideshowStore; const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - const asset = $derived(cursor.current); + let previewStackedAsset: AssetResponseDto | undefined = $state(); + let stack: StackResponseDto | null = $state(null); + + const asset = $derived(previewStackedAsset ?? cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); let sharedLink = getSharedLink(); - let previewStackedAsset: AssetResponseDto | undefined = $state(); let fullscreenElement = $state(); - let unsubscribes: (() => void)[] = []; - let stack: StackResponseDto | null = $state(null); let playOriginalVideo = $state($alwaysLoadOriginalVideo); let slideshowStartAssetId = $state(); @@ -116,7 +117,7 @@ }; const refreshStack = async () => { - if (authManager.isSharedLink) { + if (authManager.isSharedLink || !withStacked) { return; } @@ -127,19 +128,17 @@ if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = null; } - - untrack(() => { - imageManager.preload(stack?.assets[1]); - }); }; const handleFavorite = async () => { - if (album && album.isActivityEnabled) { - try { - await activityManager.toggleLike(); - } catch (error) { - handleError(error, $t('errors.unable_to_change_favorite')); - } + if (!album || !album.isActivityEnabled) { + return; + } + + try { + await activityManager.toggleLike(); + } catch (error) { + handleError(error, $t('errors.unable_to_change_favorite')); } }; @@ -151,33 +150,34 @@ onMount(() => { syncAssetViewerOpenClass(true); - unsubscribes.push( - slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }), - slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }), - ); + const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }); + + const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }); + + return () => { + slideshowStateUnsubscribe(); + slideshowNavigationUnsubscribe(); + }; }); onDestroy(() => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - activityManager.reset(); assetViewerManager.closeEditor(); syncAssetViewerOpenClass(false); + preloadManager.destroy(); }); const closeViewer = () => { @@ -194,8 +194,7 @@ }; const tracker = new InvocationTracker(); - - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -204,16 +203,19 @@ } } - e?.stopPropagation(); - imageManager.cancel(asset); + preloadManager.cancelBeforeNavigation(order); + if (tracker.isActive()) { return; } void tracker.invoke(async () => { + const isShuffle = + $slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle; + let hasNext: boolean; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + if (isShuffle) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { const asset = await onRandom?.(); @@ -227,17 +229,22 @@ order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); } - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else if ($slideshowRepeat && slideshowStartAssetId) { - // Loop back to starting asset - await setAssetId(slideshowStartAssetId); - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; } + + if (hasNext) { + $restartSlideshowProgress = true; + return; + } + + if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + return; + } + + await handleStopSlideshow(); }, $t('error_while_navigating')); }; @@ -281,12 +288,14 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? asset : undefined; + const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { + previewStackedAsset = isMouseOver ? stackedAsset : undefined; }; + const handlePreAction = (action: Action) => { preAction?.(action); }; + const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.DELETE: @@ -359,17 +368,31 @@ await ocrManager.getAssetOcr(asset.id); } }; + $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset; untrack(() => handlePromiseError(refresh())); - imageManager.preload(cursor.nextAsset); - imageManager.preload(cursor.previousAsset); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor.current.id === lastCursor?.current.id) { + return; + } + if (lastCursor) { + preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink); + } + if (!lastCursor) { + preloadManager.initializePreloads(cursor, sharedLink); + } + lastCursor = cursor; }); const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; @@ -410,6 +433,24 @@ assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor, ); + + const onSwipe = (event: SwipeCustomEvent) => { + if (assetViewerManager.zoom > 1) { + return; + } + + if (ocrManager.showOverlay) { + return; + } + + if (event.detail.direction === 'left') { + navigateAsset('next'); + } + + if (event.detail.direction === 'right') { + navigateAsset('previous'); + } + }; @@ -456,23 +497,15 @@
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
navigateAsset('previous')} />
{/if} -
- {#if viewerKind === 'StackPhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} - {sharedLink} - /> - {:else if viewerKind === 'StackVideoViewer'} +
+ {#if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'PhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - /> + {:else if viewerKind === 'VideoViewer'} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
navigateAsset('next')} />
diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 39088b23de..e84bc9fa0c 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -3,7 +3,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils'; + import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; @@ -81,15 +81,20 @@ await getPeople(); }); - $effect(() => { - const metrics = getContentMetrics(htmlElement); - - const imageBoundingBox = { - top: metrics.offsetY, - left: metrics.offsetX, - width: metrics.contentWidth, - height: metrics.contentHeight, + const imageContentMetrics = $derived.by(() => { + const natural = getNaturalSize(htmlElement); + const container = { width: containerWidth, height: containerHeight }; + const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container); + return { + contentWidth, + contentHeight, + offsetX: (containerWidth - contentWidth) / 2, + offsetY: (containerHeight - contentHeight) / 2, }; + }); + + $effect(() => { + const { offsetX, offsetY } = imageContentMetrics; if (!canvas) { return; @@ -105,8 +110,8 @@ } faceRect.set({ - top: imageBoundingBox.top + 200, - left: imageBoundingBox.left + 200, + top: offsetY + 200, + left: offsetX + 200, }); faceRect.setCoords(); @@ -214,13 +219,13 @@ } const { left, top, width, height } = faceRect.getBoundingRect(); - const metrics = getContentMetrics(htmlElement); + const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const natural = getNaturalSize(htmlElement); - const scaleX = natural.width / metrics.contentWidth; - const scaleY = natural.height / metrics.contentHeight; - const imageX = (left - metrics.offsetX) * scaleX; - const imageY = (top - metrics.offsetY) * scaleY; + const scaleX = natural.width / contentWidth; + const scaleY = natural.height / contentHeight; + const imageX = (left - offsetX) * scaleX; + const imageY = (top - offsetY) * scaleY; return { imageWidth: natural.width, diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 411c9f3ee3..55c765ce22 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,66 +1,56 @@ -
- +
+ +
transformManager.handleMouseDownOn(e, ResizeBoundary.None)} + >
+ + {#each edges as edge (edge)} + {@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)} + + {/each} + + {#each corners as corner (corner)} + {@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)} + + {/each} +
+
diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 77290d3e6d..652cd0bee9 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -1,9 +1,10 @@ -import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; +import { type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; import { getAssetMediaUrl } from '$lib/utils'; import { getDimensions } from '$lib/utils/asset-utils'; import { normalizeTransformEdits } from '$lib/utils/editor'; import { handleError } from '$lib/utils/handle-error'; import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk'; +import { clamp } from 'lodash-es'; import { tick } from 'svelte'; export type CropAspectRatio = @@ -37,17 +38,27 @@ type RegionConvertParams = { to: ImageDimensions; }; +export enum ResizeBoundary { + None = 'none', + TopLeft = 'top-left', + TopRight = 'top-right', + BottomLeft = 'bottom-left', + BottomRight = 'bottom-right', + Left = 'left', + Right = 'right', + Top = 'top', + Bottom = 'bottom', +} + class TransformManager implements EditToolManager { canReset: boolean = $derived.by(() => this.checkEdits()); hasChanges: boolean = $state(false); - darkenLevel = $state(0.65); isInteracting = $state(false); isDragging = $state(false); animationFrame = $state | null>(null); - canvasCursor = $state('default'); - dragOffset = $state({ x: 0, y: 0 }); - resizeSide = $state(''); + dragAnchor = $state({ x: 0, y: 0 }); + resizeSide = $state(ResizeBoundary.None); imgElement = $state(null); cropAreaEl = $state(null); overlayEl = $state(null); @@ -69,7 +80,6 @@ class TransformManager implements EditToolManager { const newAngle = this.imageRotation % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); - orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0); edits = $derived.by(() => this.getEdits()); @@ -81,9 +91,9 @@ class TransformManager implements EditToolManager { return; } - const newCrop = transformManager.recalculateCrop(aspectRatio); + const newCrop = this.recalculateCrop(aspectRatio); if (newCrop) { - transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop); + this.animateCropChange(newCrop); this.region = newCrop; } } @@ -216,17 +226,11 @@ class TransformManager implements EditToolManager { } reset() { - this.darkenLevel = 0.65; this.isInteracting = false; this.animationFrame = null; - this.canvasCursor = 'default'; - this.dragOffset = { x: 0, y: 0 }; - this.resizeSide = ''; + this.dragAnchor = { x: 0, y: 0 }; + this.resizeSide = ResizeBoundary.None; this.imgElement = null; - if (this.cropAreaEl) { - this.cropAreaEl.style.cursor = ''; - } - document.body.style.cursor = ''; this.cropAreaEl = null; this.isDragging = false; this.overlayEl = null; @@ -295,12 +299,12 @@ class TransformManager implements EditToolManager { }; } - animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) { - const cropFrame = element.querySelector('.crop-frame') as HTMLElement; - if (!cropFrame) { + animateCropChange(to: Region, duration = 100) { + if (!this.cropFrame) { return; } + const from = this.region; const startTime = performance.now(); const initialCrop = { ...from }; @@ -334,28 +338,6 @@ class TransformManager implements EditToolManager { return { newWidth, newHeight }; } - // Calculate constrained dimensions based on aspect ratio and limits - getConstrainedDimensions( - desiredWidth: number, - desiredHeight: number, - maxWidth: number, - maxHeight: number, - minSize = 50, - ) { - const { newWidth, newHeight } = this.adjustDimensions( - desiredWidth, - desiredHeight, - this.cropAspectRatio, - maxWidth, - maxHeight, - minSize, - ); - return { - width: Math.max(minSize, Math.min(newWidth, maxWidth)), - height: Math.max(minSize, Math.min(newHeight, maxHeight)), - }; - } - adjustDimensions( newWidth: number, newHeight: number, @@ -364,49 +346,45 @@ class TransformManager implements EditToolManager { yLimit: number, minSize: number, ) { + if (aspectRatio === 'free') { + return { + newWidth: clamp(newWidth, minSize, xLimit), + newHeight: clamp(newHeight, minSize, yLimit), + }; + } + let w = newWidth; let h = newHeight; - let aspectMultiplier: number; + const [ratioWidth, ratioHeight] = aspectRatio.split(':').map(Number); + const aspectMultiplier = ratioWidth && ratioHeight ? ratioWidth / ratioHeight : w / h; - if (aspectRatio === 'free') { - aspectMultiplier = newWidth / newHeight; - } else { - const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); - aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; - } - - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; + h = w / aspectMultiplier; + // When dragging a corner, use the biggest region that fits 'inside' the mouse location. + if (h < newHeight) { + h = newHeight; + w = h * aspectMultiplier; } if (w > xLimit) { w = xLimit; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h > yLimit) { h = yLimit; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } if (w < minSize) { w = minSize; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h < minSize) { h = minSize; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } - if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w / h !== aspectMultiplier) { if (w < minSize) { h = w / aspectMultiplier; } @@ -428,10 +406,6 @@ class TransformManager implements EditToolManager { this.cropFrame.style.width = `${crop.width}px`; this.cropFrame.style.height = `${crop.height}px`; - this.drawOverlay(crop); - } - - drawOverlay(crop: Region) { if (!this.overlayEl) { return; } @@ -465,7 +439,6 @@ class TransformManager implements EditToolManager { const cropFrameEl = this.cropFrame; cropFrameEl?.classList.add('transition'); this.region = this.normalizeCropArea(scale); - cropFrameEl?.classList.add('transition'); cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { passive: true, }); @@ -540,7 +513,7 @@ class TransformManager implements EditToolManager { normalizeCropArea(scale: number) { const img = this.imgElement; if (!img) { - return { ...this.region }; + return this.region; } const scaleRatio = scale / this.cropImageScale; @@ -576,38 +549,17 @@ class TransformManager implements EditToolManager { this.draw(); } - handleMouseDown(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + handleMouseDownOn(e: MouseEvent, resizeBoundary: ResizeBoundary) { + if (e.button !== 0) { return; } - const { mouseX, mouseY } = this.getMousePosition(e); - - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if ( - onTopLeftCorner || - onTopRightCorner || - onBottomLeftCorner || - onBottomRightCorner || - onLeftBoundary || - onRightBoundary || - onTopBoundary || - onBottomBoundary - ) { - this.setResizeSide(mouseX, mouseY); - } else if (this.isInCropArea(mouseX, mouseY)) { - this.startDragging(mouseX, mouseY); + this.isInteracting = true; + this.resizeSide = resizeBoundary; + if (resizeBoundary === ResizeBoundary.None) { + this.isDragging = true; + const { mouseX, mouseY } = this.getMousePosition(e); + this.dragAnchor = { x: mouseX - this.region.x, y: mouseY - this.region.y }; } document.body.style.userSelect = 'none'; @@ -615,20 +567,16 @@ class TransformManager implements EditToolManager { } handleMouseMove(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + if (!this.cropAreaEl) { return; } - const resizeSideValue = this.resizeSide; const { mouseX, mouseY } = this.getMousePosition(e); if (this.isDragging) { this.moveCrop(mouseX, mouseY); - } else if (resizeSideValue) { + } else if (this.resizeSide !== ResizeBoundary.None) { this.resizeCrop(mouseX, mouseY); - } else { - this.updateCursor(mouseX, mouseY); } } @@ -638,131 +586,42 @@ class TransformManager implements EditToolManager { this.isInteracting = false; this.isDragging = false; - this.resizeSide = ''; - this.fadeOverlay(true); // Darken the background + this.resizeSide = ResizeBoundary.None; } getMousePosition(e: MouseEvent) { - let offsetX = e.clientX; - let offsetY = e.clientY; - const clienRect = this.cropAreaEl?.getBoundingClientRect(); - const rotateDeg = this.normalizedRotation; - - if (rotateDeg == 90) { - offsetX = e.clientY - (clienRect?.top ?? 0); - offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - } else if (rotateDeg == 180) { - offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - } else if (rotateDeg == 270) { - offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - offsetY = e.clientX - (clienRect?.left ?? 0); - } else if (rotateDeg == 0) { - offsetX -= clienRect?.left ?? 0; - offsetY -= clienRect?.top ?? 0; + if (!this.cropAreaEl) { + throw new Error('Crop area is undefined'); } - return { mouseX: offsetX, mouseY: offsetY }; - } + const clientRect = this.cropAreaEl.getBoundingClientRect(); - // Boundary detection helpers - private isInRange(value: number, target: number, sensitivity: number): boolean { - return value >= target - sensitivity && value <= target + sensitivity; - } - - private isWithinBounds(value: number, min: number, max: number): boolean { - return value >= min && value <= max; - } - - isOnCropBoundary(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - const sensitivity = 10; - const cornerSensitivity = 15; - const { width: imgWidth, height: imgHeight } = this.previewImageSize; - - const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0; - if (outOfBound) { - return { - onLeftBoundary: false, - onRightBoundary: false, - onTopBoundary: false, - onBottomBoundary: false, - onTopLeftCorner: false, - onTopRightCorner: false, - onBottomLeftCorner: false, - onBottomRightCorner: false, - }; + switch (this.normalizedRotation) { + case 90: { + return { + mouseX: e.clientY - clientRect.top, + mouseY: -e.clientX + clientRect.right, + }; + } + case 180: { + return { + mouseX: -e.clientX + clientRect.right, + mouseY: -e.clientY + clientRect.bottom, + }; + } + case 270: { + return { + mouseX: -e.clientY + clientRect.bottom, + mouseY: e.clientX - clientRect.left, + }; + } + // also case 0: + default: { + return { + mouseX: e.clientX - clientRect.left, + mouseY: e.clientY - clientRect.top, + }; + } } - - const onLeftBoundary = this.isInRange(mouseX, x, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onRightBoundary = - this.isInRange(mouseX, x + width, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onTopBoundary = this.isInRange(mouseY, y, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - const onBottomBoundary = - this.isInRange(mouseY, y + height, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - - const onTopLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onTopRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onBottomLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - const onBottomRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - - return { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - }; - } - - isInCropArea(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - } - - setResizeSide(mouseX: number, mouseY: number) { - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (onTopLeftCorner) { - this.resizeSide = 'top-left'; - } else if (onTopRightCorner) { - this.resizeSide = 'top-right'; - } else if (onBottomLeftCorner) { - this.resizeSide = 'bottom-left'; - } else if (onBottomRightCorner) { - this.resizeSide = 'bottom-right'; - } else if (onLeftBoundary) { - this.resizeSide = 'left'; - } else if (onRightBoundary) { - this.resizeSide = 'right'; - } else if (onTopBoundary) { - this.resizeSide = 'top'; - } else if (onBottomBoundary) { - this.resizeSide = 'bottom'; - } - } - - startDragging(mouseX: number, mouseY: number) { - this.isDragging = true; - const crop = this.region; - this.isInteracting = true; - this.dragOffset = { x: mouseX - crop.x, y: mouseY - crop.y }; - this.fadeOverlay(false); } moveCrop(mouseX: number, mouseY: number) { @@ -772,102 +631,116 @@ class TransformManager implements EditToolManager { } this.hasChanges = true; - const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width)); - const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height)); - - this.region = { - ...this.region, - x: newX, - y: newY, - }; + this.region.x = clamp(mouseX - this.dragAnchor.x, 0, cropArea.clientWidth - this.region.width); + this.region.y = clamp(mouseY - this.dragAnchor.y, 0, cropArea.clientHeight - this.region.height); this.draw(); } resizeCrop(mouseX: number, mouseY: number) { const canvas = this.cropAreaEl; - const crop = this.region; - const resizeSideValue = this.resizeSide; - if (!canvas || !resizeSideValue) { + const currentCrop = this.region; + if (!canvas) { return; } - this.fadeOverlay(false); + this.isInteracting = true; this.hasChanges = true; - const { x, y, width, height } = crop; + const { x, y, width, height } = currentCrop; const minSize = 50; - let newRegion = { ...crop }; + let newRegion = { ...currentCrop }; - switch (resizeSideValue) { - case 'left': { - const desiredWidth = width + (x - mouseX); - if (desiredWidth >= minSize && mouseX >= 0) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth)); - const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight)); - newRegion = { - x: Math.max(0, x + width - finalWidth), - y, - width: finalWidth, - height: finalHeight, - }; - } + let desiredWidth = width; + let desiredHeight = height; + + // Width + switch (this.resizeSide) { + case ResizeBoundary.Left: + case ResizeBoundary.TopLeft: + case ResizeBoundary.BottomLeft: { + desiredWidth = Math.max(minSize, width + (x - Math.max(mouseX, 0))); break; } - case 'right': { - const desiredWidth = mouseX - x; - if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - newRegion = { - ...newRegion, - width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)), - height: Math.max(minSize, Math.min(h, canvas.clientHeight)), - }; - } + case ResizeBoundary.Right: + case ResizeBoundary.TopRight: + case ResizeBoundary.BottomRight: { + desiredWidth = Math.max(minSize, Math.max(mouseX, 0) - x); break; } - case 'top': { - const desiredHeight = height + (y - mouseY); - if (desiredHeight >= minSize && mouseY >= 0) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight, - minSize, - ); - newRegion = { - x, - y: Math.max(0, y + height - h), - width: w, - height: h, - }; - } + } + + // Height + switch (this.resizeSide) { + case ResizeBoundary.Top: + case ResizeBoundary.TopLeft: + case ResizeBoundary.TopRight: { + desiredHeight = Math.max(minSize, height + (y - Math.max(mouseY, 0))); break; } - case 'bottom': { - const desiredHeight = mouseY - y; - if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight - y, - minSize, - ); - newRegion = { - ...newRegion, - width: w, - height: h, - }; - } + case ResizeBoundary.Bottom: + case ResizeBoundary.BottomLeft: + case ResizeBoundary.BottomRight: { + desiredHeight = Math.max(minSize, Math.max(mouseY, 0) - y); break; } - case 'top-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = height + (y - Math.max(mouseY, 0)); + } + + // Old + switch (this.resizeSide) { + case ResizeBoundary.Left: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + const finalWidth = clamp(w, minSize, canvas.clientWidth); + newRegion = { + x: Math.max(0, x + width - finalWidth), + y, + width: finalWidth, + height: clamp(h, minSize, canvas.clientHeight), + }; + break; + } + case ResizeBoundary.Right: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + newRegion = { + ...newRegion, + width: clamp(w, minSize, canvas.clientWidth - x), + height: clamp(h, minSize, canvas.clientHeight), + }; + break; + } + case ResizeBoundary.Top: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + newRegion = { + x, + y: Math.max(0, y + height - h), + width: w, + height: h, + }; + break; + } + case ResizeBoundary.Bottom: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + newRegion = { + ...newRegion, + width: w, + height: h, + }; + break; + } + case ResizeBoundary.TopLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -884,9 +757,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'top-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = height + (y - Math.max(mouseY, 0)); + case ResizeBoundary.TopRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -903,9 +774,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -922,9 +791,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -952,95 +819,6 @@ class TransformManager implements EditToolManager { this.draw(); } - updateCursor(mouseX: number, mouseY: number) { - if (!this.cropAreaEl) { - return; - } - - let { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (this.normalizedRotation == 90) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onLeftBoundary, - onTopBoundary, - onRightBoundary, - onBottomBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onBottomLeftCorner, - onTopLeftCorner, - onTopRightCorner, - onBottomRightCorner, - ]; - } else if (this.normalizedRotation == 180) { - [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; - [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; - - [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; - [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; - } else if (this.normalizedRotation == 270) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onRightBoundary, - onBottomBoundary, - onLeftBoundary, - onTopBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onTopRightCorner, - onBottomRightCorner, - onBottomLeftCorner, - onTopLeftCorner, - ]; - } - - let cursorName: string; - if (onTopLeftCorner || onBottomRightCorner) { - cursorName = 'nwse-resize'; - } else if (onTopRightCorner || onBottomLeftCorner) { - cursorName = 'nesw-resize'; - } else if (onLeftBoundary || onRightBoundary) { - cursorName = 'ew-resize'; - } else if (onTopBoundary || onBottomBoundary) { - cursorName = 'ns-resize'; - } else if (this.isInCropArea(mouseX, mouseY)) { - cursorName = 'move'; - } else { - cursorName = 'default'; - } - - if (this.canvasCursor != cursorName && this.cropAreaEl && !editManager.isShowingConfirmDialog) { - this.canvasCursor = cursorName; - document.body.style.cursor = cursorName; - this.cropAreaEl.style.cursor = cursorName; - } - } - - fadeOverlay(toDark: boolean) { - const overlay = this.overlayEl; - const cropFrame = document.querySelector('.crop-frame'); - - if (toDark) { - overlay?.classList.remove('light'); - cropFrame?.classList.remove('resizing'); - } else { - overlay?.classList.add('light'); - cropFrame?.classList.add('resizing'); - } - - this.isInteracting = !toDark; - } - resetCrop() { this.cropAspectRatio = 'free'; this.region = { From 0ac3d6a83a633ed4832de88f923b95644b825479 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Wed, 11 Mar 2026 13:38:08 -0500 Subject: [PATCH 25/49] fix(web): face selection box position resetting on browser resize (#26766) --- .../face-editor/face-editor.svelte | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index e84bc9fa0c..8b3d672bfe 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -74,6 +74,7 @@ canvas.add(faceRect); canvas.setActiveObject(faceRect); + setDefaultFaceRectanglePosition(faceRect); }; onMount(async () => { @@ -93,9 +94,19 @@ }; }); - $effect(() => { + const setDefaultFaceRectanglePosition = (faceRect: Rect) => { const { offsetX, offsetY } = imageContentMetrics; + faceRect.set({ + top: offsetY + 200, + left: offsetX + 200, + }); + + faceRect.setCoords(); + positionFaceSelector(); + }; + + $effect(() => { if (!canvas) { return; } @@ -109,15 +120,21 @@ return; } - faceRect.set({ - top: offsetY + 200, - left: offsetX + 200, - }); - - faceRect.setCoords(); - positionFaceSelector(); + if (!isFaceRectIntersectingCanvas(faceRect, canvas)) { + setDefaultFaceRectanglePosition(faceRect); + } }); + const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => { + const faceBox = faceRect.getBoundingRect(); + return !( + 0 > faceBox.left + faceBox.width || + 0 > faceBox.top + faceBox.height || + canvas.width < faceBox.left || + canvas.height < faceBox.top + ); + }; + const cancel = () => { isFaceEditMode.value = false; }; From d49d9956112a52a6a7f3b142fb1837a084590c04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:03:19 +0100 Subject: [PATCH 26/49] chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7853a3000b..69e2da45f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,7 +248,7 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 globals: specifier: ^17.0.0 version: 17.4.0 @@ -456,7 +456,7 @@ importers: version: 4.4.0 exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 express: specifier: ^5.1.0 version: 5.2.1 @@ -3919,8 +3919,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.1 - '@photostructure/tz-lookup@11.4.0': - resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==} + '@photostructure/tz-lookup@11.5.0': + resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -7210,17 +7210,17 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.51.0: - resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==} + exiftool-vendored.exe@13.52.0: + resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==} os: [win32] - exiftool-vendored.pl@13.51.0: - resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==} + exiftool-vendored.pl@13.52.0: + resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==} os: ['!win32'] hasBin: true - exiftool-vendored@35.10.1: - resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} + exiftool-vendored@35.13.1: + resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==} engines: {node: '>=20.0.0'} expect-type@1.3.0: @@ -15989,7 +15989,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.1 three: 0.182.0 - '@photostructure/tz-lookup@11.4.0': {} + '@photostructure/tz-lookup@11.5.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -19617,21 +19617,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.51.0: + exiftool-vendored.exe@13.52.0: optional: true - exiftool-vendored.pl@13.51.0: {} + exiftool-vendored.pl@13.52.0: {} - exiftool-vendored@35.10.1: + exiftool-vendored@35.13.1: dependencies: - '@photostructure/tz-lookup': 11.4.0 + '@photostructure/tz-lookup': 11.5.0 '@types/luxon': 3.7.1 batch-cluster: 17.3.1 - exiftool-vendored.pl: 13.51.0 + exiftool-vendored.pl: 13.52.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.51.0 + exiftool-vendored.exe: 13.52.0 expect-type@1.3.0: {} From 4773788a8833ba45058c06094de20451c11e6b6c Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 11 Mar 2026 20:04:26 +0100 Subject: [PATCH 27/49] chore: more unused release workflow cleanup (#26817) --- .github/workflows/release.yml | 149 ---------------------------------- 1 file changed, 149 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 30e9c1c7ca..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: release.yml -on: - pull_request: - types: [closed] - paths: - - CHANGELOG.md - -jobs: - # Maybe double check PR source branch? - - merge_translations: - uses: ./.github/workflows/merge-translations.yml - permissions: - pull-requests: write - secrets: - PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }} - PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} - - build_mobile: - uses: ./.github/workflows/build-mobile.yml - needs: merge_translations - permissions: - contents: read - secrets: - KEY_JKS: ${{ secrets.KEY_JKS }} - ALIAS: ${{ secrets.ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} - # iOS secrets - APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} - IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} - with: - ref: main - environment: production - - prepare_release: - runs-on: ubuntu-latest - needs: build_mobile - permissions: - actions: read # To download the app artifact - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} - private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - persist-credentials: false - ref: main - - - name: Extract changelog - id: changelog - run: | - CHANGELOG_PATH=$RUNNER_TEMP/changelog.md - sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH - echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT - VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download APK - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: release-apk-signed - github-token: ${{ steps.generate-token.outputs.token }} - - - name: Create draft release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 - with: - tag_name: ${{ steps.version.outputs.result }} - token: ${{ steps.generate-token.outputs.token }} - body_path: ${{ steps.changelog.outputs.path }} - draft: true - files: | - docker/docker-compose.yml - docker/docker-compose.rootless.yml - docker/example.env - docker/hwaccel.ml.yml - docker/hwaccel.transcoding.yml - docker/prometheus.yml - *.apk - - - name: Rename Outline document - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - continue-on-error: true - env: - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - VERSION: ${{ steps.changelog.outputs.version }} - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const outlineKey = process.env.OUTLINE_API_KEY; - const version = process.env.VERSION; - const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'; - const baseUrl = 'https://outline.immich.cloud'; - - const listResponse = await fetch(`${baseUrl}/api/documents.list`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ parentDocumentId }) - }); - - if (!listResponse.ok) { - throw new Error(`Outline list failed: ${listResponse.statusText}`); - } - - const listData = await listResponse.json(); - const allDocuments = listData.data || []; - const document = allDocuments.find(doc => doc.title === 'next'); - - if (document) { - console.log(`Found document 'next', renaming to '${version}'...`); - - const updateResponse = await fetch(`${baseUrl}/api/documents.update`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: document.id, - title: version - }) - }); - - if (!updateResponse.ok) { - throw new Error(`Failed to rename document: ${updateResponse.statusText}`); - } - } else { - console.log('No document titled "next" found to rename'); - } From 471c27cd33adf01ef40145de59a0e6e46a3ff231 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:15:18 +0000 Subject: [PATCH 28/49] chore(mobile): remove background from asset viewer back button (#26851) We recently changed the asset viewer to use a gradient. The circle button looks out of place now. --- .../widgets/asset_viewer/viewer_top_app_bar.widget.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 397cd98ace..ae7dd85396 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -113,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; - return Padding( padding: const EdgeInsets.only(left: 12.0), child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: backgroundColor, + backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent, shape: const CircleBorder(), iconSize: 22, - iconColor: foregroundColor, + iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white, padding: EdgeInsets.zero, elevation: showingDetails ? 4 : 0, ), From 6c531e0a5a34af52b676bf0c5d1e2be8bee8f985 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 11 Mar 2026 14:15:31 -0500 Subject: [PATCH 29/49] chore: add shadow to video play/pause icon shadow (#26836) --- .../asset_viewer/animated_play_pause.dart | 35 +++++++++++++++---- .../widgets/asset_viewer/video_controls.dart | 19 +++++----- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart index e7ceac6105..4be7f49b5a 100644 --- a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart +++ b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart @@ -1,12 +1,15 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; /// A widget that animates implicitly between a play and a pause icon. class AnimatedPlayPause extends StatefulWidget { - const AnimatedPlayPause({super.key, required this.playing, this.size, this.color}); + const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows}); final double? size; final bool playing; final Color? color; + final List? shadows; @override State createState() => AnimatedPlayPauseState(); @@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State with SingleTickerP @override Widget build(BuildContext context) { + final icon = AnimatedIcon( + color: widget.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ); + return Center( - child: AnimatedIcon( - color: widget.color, - size: widget.size, - icon: AnimatedIcons.play_pause, - progress: animationController, + child: Stack( + alignment: Alignment.center, + children: [ + for (final shadow in widget.shadows ?? const []) + Transform.translate( + offset: shadow.offset, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2), + child: AnimatedIcon( + color: shadow.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ), + ), + ), + icon, + ], ), ); } diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 4eed3903c9..85707c82ea 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -72,17 +72,14 @@ class VideoControls extends HookConsumerWidget { children: [ Row( children: [ - IconTheme( - data: const IconThemeData(shadows: _controlShadows), - child: IconButton( - iconSize: 32, - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(), - icon: isFinished - ? const Icon(Icons.replay, color: Colors.white, size: 32) - : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), - onPressed: () => _toggle(ref, isCasting), - ), + IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + icon: isFinished + ? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows), + onPressed: () => _toggle(ref, isCasting), ), const Spacer(), Text( From 5c3777ab467cfc634e66abefdc58a68121efb0cf Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 12 Mar 2026 10:37:29 -0400 Subject: [PATCH 30/49] fix(web): fix zoom touch event handling (#26866) fix(web): fix zoom touch event handling and add clarifying comments - Suppress Safari's synthetic dblclick on double-tap which conflicts with zoom-image's touchstart-based zoom - Add comment explaining pointer-events-none on zoom transform wrapper - Add comments for touchAction and overflow style overrides --- web/src/lib/actions/zoom-image.ts | 20 ++++++++++++++++++++ web/src/lib/components/AdaptiveImage.svelte | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 66659997d2..35c3d3a106 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -23,7 +23,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea node.addEventListener('wheel', onInteractionStart, { capture: true }); node.addEventListener('pointerdown', onInteractionStart, { capture: true }); + // Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart + // handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's + // handler which conflicts. Chrome does not fire synthetic dblclick on touch. + let lastPointerWasTouch = false; + const trackPointerType = (event: PointerEvent) => { + lastPointerWasTouch = event.pointerType === 'touch'; + }; + const suppressTouchDblClick = (event: MouseEvent) => { + if (lastPointerWasTouch) { + event.stopImmediatePropagation(); + } + }; + node.addEventListener('pointerdown', trackPointerType, { capture: true }); + node.addEventListener('dblclick', suppressTouchDblClick, { capture: true }); + + // Allow zoomed content to render outside the container bounds node.style.overflow = 'visible'; + // Prevent browser handling of touch gestures so zoom-image can manage them + node.style.touchAction = 'none'; return { update(newOptions?: { disabled?: boolean }) { options = newOptions; @@ -34,6 +52,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea } node.removeEventListener('wheel', onInteractionStart, { capture: true }); node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', trackPointerType, { capture: true }); + node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 92e3fad2d3..fad4d49d1b 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -162,8 +162,9 @@
{@render backdrop?.()} +
From 3bd37ebbfbf4dfacbe98ca3f20a79b5cb1c6efb3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 10:53:46 -0400 Subject: [PATCH 31/49] refactor: clean class (#26879) --- pnpm-lock.yaml | 20 +++++++---- web/package.json | 2 ++ web/src/lib/components/AlphaBackground.svelte | 5 +-- web/src/lib/components/LoadingDots.svelte | 3 +- web/src/lib/components/QueueCard.svelte | 11 +++++-- web/src/lib/components/QueueCardBadge.svelte | 26 ++++++++------- web/src/lib/components/QueueCardButton.svelte | 33 +++++++++---------- web/src/lib/components/QueueGraph.svelte | 5 +-- web/src/lib/index.spec.ts | 15 +++++++++ web/src/lib/index.ts | 16 +++++++++ 10 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 web/src/lib/index.spec.ts create mode 100644 web/src/lib/index.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e2da45f7..9d47ba73f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,6 +845,12 @@ importers: tabbable: specifier: ^6.2.0 version: 6.4.0 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -11252,8 +11258,8 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} tailwind-variants@3.2.2: resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} @@ -14959,8 +14965,8 @@ snapshots: simple-icons: 16.9.0 svelte: 5.53.7 svelte-highlight: 7.9.0 - tailwind-merge: 3.4.0 - tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1) + tailwind-merge: 3.5.0 + tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) tailwindcss: 4.2.1 transitivePeerDependencies: - '@sveltejs/kit' @@ -24554,13 +24560,13 @@ snapshots: tabbable@6.4.0: {} - tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1): + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): dependencies: tailwindcss: 4.2.1 optionalDependencies: - tailwind-merge: 3.4.0 + tailwind-merge: 3.5.0 tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: diff --git a/web/package.json b/web/package.json index 67460e87e5..9c63b2e5f5 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,8 @@ "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", "thumbhash": "^0.1.1", "transformation-matrix": "^3.1.0", "uplot": "^1.6.32" diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte index c0d8536a2f..5c3869d587 100644 --- a/web/src/lib/components/AlphaBackground.svelte +++ b/web/src/lib/components/AlphaBackground.svelte @@ -1,11 +1,12 @@ -
+
diff --git a/web/src/lib/components/LoadingDots.svelte b/web/src/lib/components/LoadingDots.svelte index 3dcfcb8122..7e6692021f 100644 --- a/web/src/lib/components/LoadingDots.svelte +++ b/web/src/lib/components/LoadingDots.svelte @@ -1,4 +1,5 @@ -
+
{#each [0, 1, 2] as i (i)} diff --git a/web/src/lib/components/QueueCard.svelte b/web/src/lib/components/QueueCard.svelte index b7cde7b8f1..448558ed9f 100644 --- a/web/src/lib/components/QueueCard.svelte +++ b/web/src/lib/components/QueueCard.svelte @@ -1,4 +1,5 @@ - -
+
{@render children?.()}
diff --git a/web/src/lib/components/QueueCardButton.svelte b/web/src/lib/components/QueueCardButton.svelte index f71d8a3e44..9964b8fd1a 100644 --- a/web/src/lib/components/QueueCardButton.svelte +++ b/web/src/lib/components/QueueCardButton.svelte @@ -4,6 +4,7 @@ - diff --git a/web/src/lib/components/QueueGraph.svelte b/web/src/lib/components/QueueGraph.svelte index f2a23216df..01327643a1 100644 --- a/web/src/lib/components/QueueGraph.svelte +++ b/web/src/lib/components/QueueGraph.svelte @@ -1,4 +1,5 @@ -
+
{#if data[0].length === 0} {/if} diff --git a/web/src/lib/index.spec.ts b/web/src/lib/index.spec.ts new file mode 100644 index 0000000000..bda5a9e722 --- /dev/null +++ b/web/src/lib/index.spec.ts @@ -0,0 +1,15 @@ +import { cleanClass } from '$lib'; + +describe('cleanClass', () => { + it('should return a string of class names', () => { + expect(cleanClass('class1', 'class2', 'class3')).toBe('class1 class2 class3'); + }); + + it('should filter out undefined, null, and false values', () => { + expect(cleanClass('class1', undefined, 'class2', null, 'class3', false)).toBe('class1 class2 class3'); + }); + + it('should unnest arrays', () => { + expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3'); + }); +}); diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000000..b4fc195626 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1,16 @@ +import { twMerge } from 'tailwind-merge'; + +export const cleanClass = (...classNames: unknown[]) => { + return twMerge( + classNames + .flatMap((className) => (Array.isArray(className) ? className : [className])) + .filter((className) => { + if (!className || typeof className === 'boolean') { + return false; + } + + return typeof className === 'string'; + }) + .join(' '), + ); +}; From d4605b21d99fa7f9bc21689e932d26ef55870874 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 10:55:33 -0400 Subject: [PATCH 32/49] refactor: external links (#26880) --- .../admin-settings/AuthSettings.svelte | 11 ++------- .../admin-settings/BackupSettings.svelte | 10 +++----- .../admin-settings/FFmpegSettings.svelte | 14 ++++------- .../admin-settings/LibrarySettings.svelte | 8 +++---- .../admin-settings/MapSettings.svelte | 10 ++------ .../StorageTemplateSettings.svelte | 20 ++++------------ .../onboarding-page/onboarding-backup.svelte | 24 +++++-------------- .../AuthDisableLoginConfirmModal.svelte | 11 ++------- 8 files changed, 26 insertions(+), 82 deletions(-) diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index aec1761998..25af7bf2c1 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -11,7 +11,7 @@ import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk'; - import { Button, modalManager, Text, toastManager } from '@immich/ui'; + import { Button, Link, modalManager, Text, toastManager } from '@immich/ui'; import { mdiRestart } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -75,14 +75,7 @@ {#snippet children({ message })} - - {message} - + {message} {/snippet} diff --git a/web/src/lib/components/admin-settings/BackupSettings.svelte b/web/src/lib/components/admin-settings/BackupSettings.svelte index fc374ddd6f..7fd22a2b6d 100644 --- a/web/src/lib/components/admin-settings/BackupSettings.svelte +++ b/web/src/lib/components/admin-settings/BackupSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -52,15 +53,10 @@

{#snippet children({ message })} - + {message}
-
+ {/snippet}

diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index e062b616b3..95aa9d74f2 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -18,7 +18,7 @@ VideoCodec, VideoContainer, } from '@immich/sdk'; - import { Icon } from '@immich/ui'; + import { Icon, Link } from '@immich/ui'; import { mdiHelpCircleOutline } from '@mdi/js'; import { isEqual, sortBy } from 'lodash-es'; import { t } from 'svelte-i18n'; @@ -38,17 +38,11 @@ {#snippet children({ tag, message })} {#if tag === 'h264-link'} - - {message} - + {message} {:else if tag === 'hevc-link'} - - {message} - + {message} {:else if tag === 'vp9-link'} - - {message} - + {message} {/if} {/snippet} diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index a91a5eb97a..52c2eb8d4f 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -8,6 +8,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -73,14 +74,11 @@

{#snippet children({ message })} - {message} - + {/snippet}

diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 692a5cfcf5..5888c82611 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -54,14 +55,7 @@

{#snippet children({ message })} - - {message} - + {message} {/snippet}

diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index 7018bc5d04..8ccb3f7781 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -12,7 +12,7 @@ import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { user } from '$lib/stores/user.store'; import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; - import { Heading, LoadingSpinner, Text } from '@immich/ui'; + import { Heading, Link, LoadingSpinner, Text } from '@immich/ui'; import handlebar from 'handlebars'; import * as luxon from 'luxon'; import { onDestroy } from 'svelte'; @@ -112,23 +112,11 @@ {#snippet children({ tag, message })} {#if tag === 'template-link'} - - {message} - + {message} {:else if tag === 'implications-link'} - + {message} - + {/if} {/snippet} diff --git a/web/src/lib/components/onboarding-page/onboarding-backup.svelte b/web/src/lib/components/onboarding-page/onboarding-backup.svelte index 146661884b..7d7f51c392 100644 --- a/web/src/lib/components/onboarding-page/onboarding-backup.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-backup.svelte @@ -1,6 +1,6 @@
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index b4772cc1c4..76956fbb26 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -1,4 +1,5 @@ @@ -19,7 +20,7 @@ (isBroken = true)} - class="size-full rounded-xl object-cover aspect-square {className}" + class={cleanClass('size-full rounded-xl object-cover aspect-square', className)} data-testid="album-image" draggable="false" loading={preload ? 'eager' : 'lazy'} diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte index 1e09c6bcfa..319a5e7f9e 100644 --- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte @@ -1,16 +1,18 @@ Date: Thu, 12 Mar 2026 19:48:00 +0100 Subject: [PATCH 34/49] fix(server): restrict individual shared link asset removal to owners (#26868) * fix(server): restrict individual shared link asset removal to owners * make open-api --- .../specs/server/api/shared-link.e2e-spec.ts | 10 ++++++++ e2e/src/specs/web/shared-link.e2e-spec.ts | 24 +++++++++++++++++++ mobile/openapi/lib/api/shared_links_api.dart | 21 +++------------- open-api/immich-openapi-specs.json | 17 +------------ open-api/typescript-sdk/src/fetch-client.ts | 9 ++----- .../shared-link.controller.spec.ts | 15 +++++++++++- .../src/controllers/shared-link.controller.ts | 2 +- web/src/lib/services/shared-link.service.ts | 2 -- 8 files changed, 55 insertions(+), 45 deletions(-) diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 80232beb75..00c455d6cb 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -438,6 +438,16 @@ describe('/shared-links', () => { expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); }); + it('should reject guests removing assets from an individual shared link', async () => { + const { status, body } = await request(app) + .delete(`/shared-links/${linkWithAssets.id}/assets`) + .query({ key: linkWithAssets.key }) + .send({ assetIds: [asset1.id] }); + + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) .delete(`/shared-links/${linkWithAssets.id}/assets`) diff --git a/e2e/src/specs/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts index f6d1ec98d4..8380840935 100644 --- a/e2e/src/specs/web/shared-link.e2e-spec.ts +++ b/e2e/src/specs/web/shared-link.e2e-spec.ts @@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils'; test.describe('Shared Links', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let asset2: AssetMediaResponseDto; let album: AlbumResponseDto; let sharedLink: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto; + let individualSharedLink: SharedLinkResponseDto; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + asset2 = await utils.createAsset(admin.accessToken); album = await createAlbum( { createAlbumDto: { @@ -39,6 +42,10 @@ test.describe('Shared Links', () => { albumId: album.id, password: 'test-password', }); + individualSharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id, asset2.id], + }); }); test('download from a shared link', async ({ page }) => { @@ -109,4 +116,21 @@ test.describe('Shared Links', () => { await page.waitForURL('/photos'); await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); }); + + test('owner can remove assets from an individual shared link', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto(`/share/${individualSharedLink.key}`); + await page.locator(`[data-asset="${asset.id}"]`).waitFor(); + await expect(page.locator(`[data-asset]`)).toHaveCount(2); + + await page.locator(`[data-asset="${asset.id}"]`).hover(); + await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click(); + + await page.getByRole('button', { name: 'Remove from shared link' }).click(); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + + await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0); + await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1); + }); }); diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 37eeffcf46..084662ace8 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -427,11 +427,7 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { // ignore: prefer_const_declarations final apiPath = r'/shared-links/{id}/assets' .replaceAll('{id}', id); @@ -443,13 +439,6 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - if (slug != null) { - queryParams.addAll(_queryParams('', 'slug', slug)); - } - const contentTypes = ['application/json']; @@ -473,12 +462,8 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, ); + Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async { + final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d2eb322009..2227273535 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11605,22 +11605,6 @@ "format": "uuid", "type": "string" } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "slug", - "required": false, - "in": "query", - "schema": { - "type": "string" - } } ], "requestBody": { @@ -11677,6 +11661,7 @@ "state": "Stable" } ], + "x-immich-permission": "sharedLink.update", "x-immich-state": "Stable" }, "put": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5c8ac6dbc1..5a47cf2707 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5987,19 +5987,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { /** * Remove assets from a shared link */ -export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { +export function removeSharedLinkAssets({ id, assetIdsDto }: { id: string; - key?: string; - slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key, - slug - }))}`, oazapfts.json({ + }>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", body: assetIdsDto diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index 96c84040ca..d8b89d0029 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -1,7 +1,8 @@ import { SharedLinkController } from 'src/controllers/shared-link.controller'; -import { SharedLinkType } from 'src/enum'; +import { Permission, SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import request from 'supertest'; +import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(SharedLinkController.name, () => { @@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => { expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); }); + + describe('DELETE /shared-links/:id/assets', () => { + it('should require shared link update permission', async () => { + await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] }); + + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }), + }), + ); + }); + }); }); diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 1f91409e80..c7ba589a9f 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -180,7 +180,7 @@ export class SharedLinkController { } @Delete(':id/assets') - @Authenticated({ sharedLink: true }) + @Authenticated({ permission: Permission.SharedLinkUpdate }) @Endpoint({ summary: 'Remove assets from a shared link', description: diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index fc4bbe11c0..135c67b95a 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -1,5 +1,4 @@ import { goto } from '$app/navigation'; -import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; @@ -138,7 +137,6 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons try { const results = await removeSharedLinkAssets({ - ...authManager.params, id: sharedLink.id, assetIdsDto: { assetIds }, }); From 001d7d083f0d7d541a54a9495961746992e212a8 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:48:49 +0100 Subject: [PATCH 35/49] refactor: small test factories (#26862) --- server/src/services/activity.service.spec.ts | 45 +++-- server/src/services/api-key.service.spec.ts | 77 ++++---- server/src/services/asset.service.spec.ts | 9 +- server/src/services/auth.service.spec.ts | 184 +++++++++--------- server/src/services/cli.service.spec.ts | 10 +- server/src/services/map.service.spec.ts | 4 +- server/src/services/partner.service.spec.ts | 53 ++--- server/src/services/session.service.spec.ts | 13 +- server/src/services/sync.service.spec.ts | 3 +- .../src/services/user-admin.service.spec.ts | 7 +- server/src/services/user.service.spec.ts | 21 +- server/test/factories/activity.factory.ts | 42 ++++ server/test/factories/api-key.factory.ts | 42 ++++ server/test/factories/auth.factory.ts | 17 +- server/test/factories/partner.factory.ts | 50 +++++ server/test/factories/session.factory.ts | 35 ++++ server/test/factories/types.ts | 8 + server/test/small.factory.ts | 183 +---------------- 18 files changed, 414 insertions(+), 389 deletions(-) create mode 100644 server/test/factories/activity.factory.ts create mode 100644 server/test/factories/api-key.factory.ts create mode 100644 server/test/factories/partner.factory.ts create mode 100644 server/test/factories/session.factory.ts diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index d1a9f53a20..03cd0132c1 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,8 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; +import { ActivityFactory } from 'test/factories/activity.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { getForActivity } from 'test/mappers'; -import { factory, newUuid, newUuids } from 'test/small.factory'; +import { newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { @@ -24,7 +26,7 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); }); @@ -36,7 +38,7 @@ describe(ActivityService.name, () => { mocks.activity.search.mockResolvedValue([]); await expect( - sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }), + sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), ).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); @@ -48,7 +50,9 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual( + [], + ); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false }); }); @@ -61,7 +65,10 @@ describe(ActivityService.name, () => { mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); - await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 }); + await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({ + comments: 1, + likes: 3, + }); }); }); @@ -70,18 +77,18 @@ describe(ActivityService.name, () => { const [albumId, assetId] = newUuids(); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a comment', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ albumId, assetId, userId }); + const activity = ActivityFactory.create({ albumId, assetId, userId }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); - await sut.create(factory.auth({ user: { id: userId } }), { + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.COMMENT, @@ -99,38 +106,38 @@ describe(ActivityService.name, () => { it('should fail because activity is disabled for the album', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId }); + const activity = ActivityFactory.create({ albumId, assetId }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a like', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ userId, albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); mocks.activity.search.mockResolvedValue([]); - await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); }); it('should skip if like exists', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ albumId, assetId, isLiked: true }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([getForActivity(activity)]); - await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).not.toHaveBeenCalled(); }); @@ -138,29 +145,29 @@ describe(ActivityService.name, () => { describe('delete', () => { it('should require access', async () => { - await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException); expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); it('should let the album owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 3a31dbbea1..68165d642f 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; -import { factory, newUuid } from 'test/small.factory'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ApiKeyService.name, () => { @@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => { }); it('should not require a name', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the api key does not have sufficient permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.AssetRead] }) + .build(); await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf( BadRequestException, @@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => { describe('update', () => { it('should throw an error if the key is not found', async () => { const id = newUuid(); - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => { }); it('should update a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newName = 'New name'; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => { }); it('should update permissions', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate]; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => { describe('api key auth', () => { it('should prevent adding Permission.all', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => { it('should prevent adding a new permission', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => { }); it('should allow removing permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } }); - const apiKey = factory.apiKey({ + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead, Permission.AssetDelete], }); @@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => { }); it('should allow adding new permissions', async () => { - const auth = factory.auth({ - apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }, - }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); @@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => { describe('delete', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => { }); it('should delete a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.delete.mockResolvedValue(); @@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => { describe('getMine', () => { it('should not work with a session token', async () => { - const session = factory.session(); - const auth = factory.auth({ session }); + const session = SessionFactory.create(); + const auth = AuthFactory.from().session(session).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the key is not found', async () => { - const apiKey = factory.authApiKey(); - const auth = factory.auth({ apiKey }); + const apiKey = ApiKeyFactory.create(); + const auth = AuthFactory.from().apiKey(apiKey).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => { describe('getById', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => { describe('getAll', () => { it('should return all the keys for a user', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getByUserId.mockResolvedValue([apiKey]); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 7da0452d45..718ec00f1d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -7,6 +7,7 @@ import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; @@ -80,8 +81,8 @@ describe(AssetService.name, () => { }); it('should not include partner assets if not in timeline', async () => { - const partner = factory.partner({ inTimeline: false }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: false }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); @@ -92,8 +93,8 @@ describe(AssetService.name, () => { }); it('should include partner assets if in timeline', async () => { - const partner = factory.partner({ inTimeline: true }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: true }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 81f601da0a..f2cc3ada95 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; import { UserMetadataItem } from 'src/types'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory, newUuid } from 'test/small.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const oauthResponse = ({ @@ -91,8 +95,8 @@ describe(AuthService.name, () => { }); it('should successfully log the user in', async () => { - const user = { ...(factory.user() as UserAdmin), password: 'immich_password' }; - const session = factory.session(); + const user = UserFactory.create({ password: 'immich_password' }); + const session = SessionFactory.create(); mocks.user.getByEmail.mockResolvedValue(user); mocks.session.create.mockResolvedValue(session); @@ -113,8 +117,8 @@ describe(AuthService.name, () => { describe('changePassword', () => { it('should change the password', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -132,8 +136,8 @@ describe(AuthService.name, () => { }); it('should throw when password does not match existing password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.crypto.compareBcrypt.mockReturnValue(false); @@ -144,8 +148,8 @@ describe(AuthService.name, () => { }); it('should throw when user does not have a password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' }); @@ -154,8 +158,8 @@ describe(AuthService.name, () => { }); it('should change the password and logout other sessions', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -175,7 +179,7 @@ describe(AuthService.name, () => { describe('logout', () => { it('should return the end session endpoint', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -186,7 +190,7 @@ describe(AuthService.name, () => { }); it('should return the default redirect', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, @@ -262,11 +266,11 @@ describe(AuthService.name, () => { }); it('should validate using authorization header', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -340,7 +344,7 @@ describe(AuthService.name, () => { }); it('should accept a base64url key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -361,7 +365,7 @@ describe(AuthService.name, () => { }); it('should accept a hex key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -396,7 +400,7 @@ describe(AuthService.name, () => { }); it('should accept a valid slug', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any; mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink); @@ -428,11 +432,11 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -455,11 +459,11 @@ describe(AuthService.name, () => { }); it('should throw if admin route and not an admin', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -477,11 +481,11 @@ describe(AuthService.name, () => { }); it('should update when access time exceeds an hour', async () => { - const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); + const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -517,8 +521,8 @@ describe(AuthService.name, () => { }); it('should throw an error if api key has insufficient permissions', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -533,8 +537,8 @@ describe(AuthService.name, () => { }); it('should default to requiring the all permission when omitted', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -548,10 +552,12 @@ describe(AuthService.name, () => { }); it('should not require any permission when metadata is set to `false`', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); const result = sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -562,10 +568,12 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); await expect( sut.authenticate({ @@ -629,12 +637,12 @@ describe(AuthService.name, () => { }); it('should link an existing user', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.user.getByEmail.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -649,7 +657,7 @@ describe(AuthService.name, () => { }); it('should not link to a user with a different oauth sub', async () => { - const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' }); + const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.user.getByEmail.mockResolvedValueOnce(user); @@ -669,13 +677,13 @@ describe(AuthService.name, () => { }); it('should allow auto registering by default', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -690,13 +698,13 @@ describe(AuthService.name, () => { }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - const user = factory.userAdmin({ isAdmin: true }); + const user = UserFactory.create({ isAdmin: true }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect( @@ -717,11 +725,11 @@ describe(AuthService.name, () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.user.getByOAuthId.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails); @@ -735,13 +743,13 @@ describe(AuthService.name, () => { } it('should use the default quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -755,14 +763,14 @@ describe(AuthService.name, () => { }); it('should ignore an invalid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -776,14 +784,14 @@ describe(AuthService.name, () => { }); it('should ignore a negative quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 }); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -797,14 +805,14 @@ describe(AuthService.name, () => { }); it('should set quota for 0 quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -825,15 +833,15 @@ describe(AuthService.name, () => { }); it('should use a valid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -855,7 +863,7 @@ describe(AuthService.name, () => { it('should sync the profile picture', async () => { const fileId = newUuid(); - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg'; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); @@ -871,7 +879,7 @@ describe(AuthService.name, () => { data: new Uint8Array([1, 2, 3, 4, 5]).buffer, }); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -889,7 +897,7 @@ describe(AuthService.name, () => { }); it('should not sync the profile picture if the user already has one', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); + const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.oauth.getProfile.mockResolvedValue({ @@ -899,7 +907,7 @@ describe(AuthService.name, () => { }); mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -914,15 +922,15 @@ describe(AuthService.name, () => { }); it('should only allow "admin" and "user" for the role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -943,14 +951,14 @@ describe(AuthService.name, () => { }); it('should create an admin user if the role claim is set to admin', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' }); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -971,7 +979,7 @@ describe(AuthService.name, () => { }); it('should accept a custom role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue({ oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' }, @@ -980,7 +988,7 @@ describe(AuthService.name, () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -1003,8 +1011,8 @@ describe(AuthService.name, () => { describe('link', () => { it('should link an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ apiKey: { permissions: [] }, user }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1019,8 +1027,8 @@ describe(AuthService.name, () => { }); it('should not link an already linked oauth.sub', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); const auth = { user: authUser, apiKey: authApiKey }; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -1036,8 +1044,8 @@ describe(AuthService.name, () => { describe('unlink', () => { it('should unlink an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user, apiKey: { permissions: [] } }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1050,8 +1058,8 @@ describe(AuthService.name, () => { describe('setupPinCode', () => { it('should setup a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' }); @@ -1065,8 +1073,8 @@ describe(AuthService.name, () => { }); it('should fail if the user already has a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1076,8 +1084,8 @@ describe(AuthService.name, () => { describe('changePinCode', () => { it('should change the PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456', newPinCode: '012345' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1091,37 +1099,37 @@ describe(AuthService.name, () => { }); it('should fail if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); await expect( - sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }), + sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }), ).rejects.toThrow('Wrong PIN code'); }); }); describe('resetPinCode', () => { it('should reset the PIN code', async () => { - const currentSession = factory.session(); - const user = factory.userAdmin(); + const currentSession = SessionFactory.create(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); - await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); + await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); + await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); }); }); }); diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 36a3d2eb2c..347d9eef00 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,7 +1,7 @@ import { jwtVerify } from 'jose'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { CliService } from 'src/services/cli.service'; -import { factory } from 'test/small.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe, it } from 'vitest'; @@ -15,7 +15,7 @@ describe(CliService.name, () => { describe('listUsers', () => { it('should list users', async () => { - mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]); + mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); @@ -32,10 +32,10 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); - mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); const ask = vitest.fn().mockImplementation(() => {}); @@ -50,7 +50,7 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index 287c5c7c63..fdf7aee68b 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -2,9 +2,9 @@ import { MapService } from 'src/services/map.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { userStub } from 'test/fixtures/user.stub'; import { getForAlbum, getForPartner } from 'test/mappers'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { @@ -40,7 +40,7 @@ describe(MapService.name, () => { it('should include partner assets', async () => { const auth = AuthFactory.create(); - const partner = factory.partner({ sharedWithId: auth.user.id }); + const partner = PartnerFactory.create({ sharedWithId: auth.user.id }); const asset = AssetFactory.from() .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 0f80ca84f1..029462a865 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,9 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerService } from 'src/services/partner.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { UserFactory } from 'test/factories/user.factory'; -import { getDehydrated, getForPartner } from 'test/mappers'; -import { factory } from 'test/small.factory'; +import { getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { @@ -22,15 +23,9 @@ describe(PartnerService.name, () => { it("should return a list of partners with whom I've shared my library", async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const sharedWithUser2 = factory.partner({ - sharedBy: getDehydrated(user1), - sharedWith: getDehydrated(user2), - }); - const sharedWithUser1 = factory.partner({ - sharedBy: getDehydrated(user2), - sharedWith: getDehydrated(user1), - }); - const auth = factory.auth({ user: { id: user1.id } }); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); @@ -41,15 +36,9 @@ describe(PartnerService.name, () => { it('should return a list of partners who have shared their libraries with me', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const sharedWithUser2 = factory.partner({ - sharedBy: getDehydrated(user1), - sharedWith: getDehydrated(user2), - }); - const sharedWithUser1 = factory.partner({ - sharedBy: getDehydrated(user2), - sharedWith: getDehydrated(user1), - }); - const auth = factory.auth({ user: { id: user1.id } }); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); @@ -61,8 +50,8 @@ describe(PartnerService.name, () => { it('should create a new partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(void 0); mocks.partner.create.mockResolvedValue(getForPartner(partner)); @@ -78,8 +67,8 @@ describe(PartnerService.name, () => { it('should throw an error when the partner already exists', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(getForPartner(partner)); @@ -93,8 +82,8 @@ describe(PartnerService.name, () => { it('should remove a partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(getForPartner(partner)); @@ -104,8 +93,8 @@ describe(PartnerService.name, () => { }); it('should throw an error when the partner does not exist', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); mocks.partner.get.mockResolvedValue(void 0); @@ -117,8 +106,8 @@ describe(PartnerService.name, () => { describe('update', () => { it('should require access', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException); }); @@ -126,8 +115,8 @@ describe(PartnerService.name, () => { it('should update partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id])); mocks.partner.update.mockResolvedValue(getForPartner(partner)); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 7eacd148ad..8f4409a508 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,7 +1,8 @@ import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { @@ -25,9 +26,9 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - const currentSession = factory.session(); - const otherSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const otherSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); @@ -42,8 +43,8 @@ describe('SessionService', () => { describe('logoutDevices', () => { it('should logout all devices', async () => { - const currentSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.invalidate.mockResolvedValue(); diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 3b7fbfcd95..234e3ac223 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,6 +1,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SyncService } from 'src/services/sync.service'; import { AssetFactory } from 'test/factories/asset.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { getForAsset, getForPartner } from 'test/mappers'; import { factory } from 'test/small.factory'; @@ -42,7 +43,7 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - const partner = factory.partner(); + const partner = PartnerFactory.create(); const auth = factory.auth({ user: { id: partner.sharedWithId } }); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index d8e13fcfbd..49aefaa870 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; import { JobName, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe } from 'vitest'; @@ -126,8 +127,8 @@ describe(UserAdminService.name, () => { }); it('should not allow deleting own account', async () => { - const user = factory.userAdmin({ isAdmin: false }); - const auth = factory.auth({ user }); + const user = UserFactory.create({ isAdmin: false }); + const auth = AuthFactory.create(user); mocks.user.get.mockResolvedValue(user); await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index bd896ffc24..0dc83928fc 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -3,10 +3,11 @@ import { UserAdmin } from 'src/database'; import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const makeDeletedAt = (daysAgo: number) => { @@ -28,8 +29,8 @@ describe(UserService.name, () => { describe('getAll', () => { it('admin should get all users', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -39,8 +40,8 @@ describe(UserService.name, () => { }); it('non-admin should get all users when publicUsers enabled', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -105,7 +106,7 @@ describe(UserService.name, () => { it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); @@ -113,7 +114,7 @@ describe(UserService.name, () => { }); it('should delete the previous profile image', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const file = { path: '/profile/path' } as Express.Multer.File; const files = [user.profileImagePath]; @@ -149,7 +150,7 @@ describe(UserService.name, () => { }); it('should delete the profile image if user has one', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const files = [user.profileImagePath]; mocks.user.get.mockResolvedValue(user); @@ -178,7 +179,7 @@ describe(UserService.name, () => { }); it('should return the profile picture', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); await expect(sut.getProfileImage(user.id)).resolves.toEqual( @@ -205,7 +206,7 @@ describe(UserService.name, () => { }); it('should queue user ready for deletion', async () => { - const user = factory.user(); + const user = UserFactory.create(); mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]); await sut.handleUserDeleteCheck(); diff --git a/server/test/factories/activity.factory.ts b/server/test/factories/activity.factory.ts new file mode 100644 index 0000000000..861b115158 --- /dev/null +++ b/server/test/factories/activity.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; +import { build } from 'test/factories/builder.factory'; +import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ActivityFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ActivityLike = {}) { + return ActivityFactory.from(dto).build(); + } + + static from(dto: ActivityLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ActivityFactory({ + albumId: newUuid(), + assetId: null, + comment: null, + createdAt: newDate(), + id: newUuid(), + isLiked: false, + userId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/api-key.factory.ts b/server/test/factories/api-key.factory.ts new file mode 100644 index 0000000000..d16b50ba57 --- /dev/null +++ b/server/test/factories/api-key.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { Permission } from 'src/enum'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; +import { build } from 'test/factories/builder.factory'; +import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ApiKeyFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ApiKeyLike = {}) { + return ApiKeyFactory.from(dto).build(); + } + + static from(dto: ApiKeyLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ApiKeyFactory({ + createdAt: newDate(), + id: newUuid(), + key: Buffer.from('api-key-buffer'), + name: 'API Key', + permissions: [Permission.All], + updatedAt: newDate(), + updateId: newUuidV7(), + userId, + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts index 9c738aabac..fd38c42649 100644 --- a/server/test/factories/auth.factory.ts +++ b/server/test/factories/auth.factory.ts @@ -1,12 +1,16 @@ import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; import { build } from 'test/factories/builder.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; -import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; +import { newUuid } from 'test/small.factory'; export class AuthFactory { #user: UserFactory; #sharedLink?: SharedLinkFactory; + #apiKey?: ApiKeyFactory; + #session?: AuthDto['session']; private constructor(user: UserFactory) { this.#user = user; @@ -20,8 +24,8 @@ export class AuthFactory { return new AuthFactory(UserFactory.from(dto)); } - apiKey() { - // TODO + apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder) { + this.#apiKey = build(ApiKeyFactory.from(dto), builder); return this; } @@ -30,6 +34,11 @@ export class AuthFactory { return this; } + session(dto: Partial = {}) { + this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto }; + return this; + } + build(): AuthDto { const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); @@ -43,6 +52,8 @@ export class AuthFactory { quotaSizeInBytes, }, sharedLink: this.#sharedLink?.build(), + apiKey: this.#apiKey?.build(), + session: this.#session, }; } } diff --git a/server/test/factories/partner.factory.ts b/server/test/factories/partner.factory.ts new file mode 100644 index 0000000000..f631db1eb5 --- /dev/null +++ b/server/test/factories/partner.factory.ts @@ -0,0 +1,50 @@ +import { Selectable } from 'kysely'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { build } from 'test/factories/builder.factory'; +import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PartnerFactory { + #sharedWith!: UserFactory; + #sharedBy!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: PartnerLike = {}) { + return PartnerFactory.from(dto).build(); + } + + static from(dto: PartnerLike = {}) { + const sharedById = dto.sharedById ?? newUuid(); + const sharedWithId = dto.sharedWithId ?? newUuid(); + return new PartnerFactory({ + createdAt: newDate(), + createId: newUuidV7(), + inTimeline: true, + sharedById, + sharedWithId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }) + .sharedBy({ id: sharedById }) + .sharedWith({ id: sharedWithId }); + } + + sharedWith(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedWith = build(UserFactory.from(dto), builder); + this.value.sharedWithId = this.#sharedWith.build().id; + return this; + } + + sharedBy(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedBy = build(UserFactory.from(dto), builder); + this.value.sharedById = this.#sharedBy.build().id; + return this; + } + + build() { + return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() }; + } +} diff --git a/server/test/factories/session.factory.ts b/server/test/factories/session.factory.ts new file mode 100644 index 0000000000..8d4cb28727 --- /dev/null +++ b/server/test/factories/session.factory.ts @@ -0,0 +1,35 @@ +import { Selectable } from 'kysely'; +import { SessionTable } from 'src/schema/tables/session.table'; +import { SessionLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class SessionFactory { + private constructor(private value: Selectable) {} + + static create(dto: SessionLike = {}) { + return SessionFactory.from(dto).build(); + } + + static from(dto: SessionLike = {}) { + return new SessionFactory({ + appVersion: null, + createdAt: newDate(), + deviceOS: 'android', + deviceType: 'mobile', + expiresAt: null, + id: newUuid(), + isPendingSyncReset: false, + parentId: null, + pinExpiresAt: null, + token: Buffer.from('abc123'), + updateId: newUuidV7(), + updatedAt: newDate(), + userId: newUuid(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 0e070c1bcc..e2d9e4e1c3 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,13 +1,17 @@ import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; +import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -26,3 +30,7 @@ export type AssetFaceLike = Partial>; export type PersonLike = Partial>; export type StackLike = Partial>; export type MemoryLike = Partial>; +export type PartnerLike = Partial>; +export type ActivityLike = Partial>; +export type ApiKeyLike = Partial>; +export type SessionLike = Partial>; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index c734fdcb2d..57098e01ee 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,26 +1,7 @@ -import { ShallowDehydrateObject } from 'kysely'; -import { - Activity, - Album, - ApiKey, - AuthApiKey, - AuthSharedLink, - AuthUser, - Exif, - Library, - Partner, - Person, - Session, - Tag, - User, - UserAdmin, -} from 'src/database'; +import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; -import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum'; -import { UserMetadataItem } from 'src/types'; -import { UserFactory } from 'test/factories/user.factory'; +import { AssetFileType, Permission, UserStatus } from 'src/enum'; import { v4, v7 } from 'uuid'; export const newUuid = () => v4(); @@ -109,49 +90,6 @@ const authUserFactory = (authUser: Partial = {}) => { return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; }; -const partnerFactory = ({ - sharedBy: sharedByProvided, - sharedWith: sharedWithProvided, - ...partner -}: Partial = {}) => { - const hydrateUser = (user: Partial>) => ({ - ...user, - profileChangedAt: user.profileChangedAt ? new Date(user.profileChangedAt) : undefined, - }); - const sharedBy = UserFactory.create(sharedByProvided ? hydrateUser(sharedByProvided) : {}); - const sharedWith = UserFactory.create(sharedWithProvided ? hydrateUser(sharedWithProvided) : {}); - - return { - sharedById: sharedBy.id, - sharedBy, - sharedWithId: sharedWith.id, - sharedWith, - createId: newUuidV7(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - inTimeline: true, - ...partner, - }; -}; - -const sessionFactory = (session: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - deviceOS: 'android', - deviceType: 'mobile', - token: Buffer.from('abc123'), - parentId: null, - expiresAt: null, - userId: newUuid(), - pinExpiresAt: newDate(), - isPendingSyncReset: false, - appVersion: session.appVersion ?? null, - ...session, -}); - const queueStatisticsFactory = (dto?: Partial) => ({ active: 0, completed: 0, @@ -162,22 +100,6 @@ const queueStatisticsFactory = (dto?: Partial) => ({ ...dto, }); -const userFactory = (user: Partial = {}) => ({ - id: newUuid(), - name: 'Test User', - email: 'test@immich.cloud', - avatarColor: null, - profileImagePath: '', - profileChangedAt: newDate(), - metadata: [ - { - key: UserMetadataKey.Onboarding, - value: 'true', - }, - ] as UserMetadataItem[], - ...user, -}); - const userAdminFactory = (user: Partial = {}) => { const { id = newUuid(), @@ -219,34 +141,6 @@ const userAdminFactory = (user: Partial = {}) => { }; }; -const activityFactory = (activity: Omit, 'user'> = {}) => { - const userId = activity.userId || newUuid(); - return { - id: newUuid(), - comment: null, - isLiked: false, - userId, - user: UserFactory.create({ id: userId }), - assetId: newUuid(), - albumId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - ...activity, - }; -}; - -const apiKeyFactory = (apiKey: Partial = {}) => ({ - id: newUuid(), - userId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - name: 'Api Key', - permissions: [Permission.All], - ...apiKey, -}); - const libraryFactory = (library: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), @@ -328,88 +222,15 @@ const assetOcrFactory = ( ...ocr, }); -const tagFactory = (tag: Partial): Tag => ({ - id: newUuid(), - color: null, - createdAt: newDate(), - parentId: null, - updatedAt: newDate(), - value: `tag-${newUuid()}`, - ...tag, -}); - -const assetEditFactory = (edit?: Partial): AssetEditActionItem => { - switch (edit?.action) { - case AssetEditAction.Crop: { - return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit }; - } - case AssetEditAction.Mirror: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit }; - } - case AssetEditAction.Rotate: { - return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit }; - } - default: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }; - } - } -}; - -const personFactory = (person?: Partial): Person => ({ - birthDate: newDate(), - color: null, - createdAt: newDate(), - faceAssetId: null, - id: newUuid(), - isFavorite: false, - isHidden: false, - name: 'person', - ownerId: newUuid(), - thumbnailPath: '/path/to/person/thumbnail.jpg', - updatedAt: newDate(), - updateId: newUuidV7(), - ...person, -}); - -const albumFactory = (album?: Partial>) => ({ - albumName: 'My Album', - albumThumbnailAssetId: null, - albumUsers: [], - assets: [], - createdAt: newDate(), - deletedAt: null, - description: 'Album description', - id: newUuid(), - isActivityEnabled: false, - order: AssetOrder.Desc, - ownerId: newUuid(), - sharedLinks: [], - updatedAt: newDate(), - updateId: newUuidV7(), - ...album, -}); - export const factory = { - activity: activityFactory, - apiKey: apiKeyFactory, assetOcr: assetOcrFactory, auth: authFactory, - authApiKey: authApiKeyFactory, - authUser: authUserFactory, library: libraryFactory, - partner: partnerFactory, queueStatistics: queueStatisticsFactory, - session: sessionFactory, - user: userFactory, - userAdmin: userAdminFactory, versionHistory: versionHistoryFactory, jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, - person: personFactory, - assetEdit: assetEditFactory, - tag: tagFactory, - album: albumFactory, uuid: newUuid, buffer: () => Buffer.from('this is a fake buffer'), date: newDate, From 990aff441bb52642dd6c1e66d652d993c5f3845e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 16:10:55 -0400 Subject: [PATCH 36/49] fix: add to shared link (#26886) --- .../src/repositories/shared-link.repository.ts | 16 +++++++++++++++- server/src/services/asset-media.service.ts | 9 +++++++++ server/src/services/shared-link.service.ts | 6 ++++++ .../share-page/individual-shared-viewer.svelte | 16 +++------------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 48afcf7d92..bc81e75c81 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -4,7 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Album, columns } from 'src/database'; -import { DummyValue, GenerateSql } from 'src/decorators'; +import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkType } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -249,6 +249,20 @@ export class SharedLinkRepository { await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute(); } + @ChunkedArray({ paramIndex: 1 }) + async addAssets(id: string, assetIds: string[]) { + if (assetIds.length === 0) { + return []; + } + + return await this.db + .insertInto('shared_link_asset') + .values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id }))) + .onConflict((oc) => oc.doNothing()) + .returning(['shared_link_asset.assetId']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID] }) private getSharedLinks(id: string) { return this.db diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 020bda4df7..3c981ea61e 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -151,6 +151,10 @@ export class AssetMediaService extends BaseService { } const asset = await this.create(auth.user.id, dto, file, sidecarFile); + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]); + } + await this.userRepository.updateUsage(auth.user.id, file.size); return { id: asset.id, status: AssetMediaStatus.CREATED }; @@ -341,6 +345,11 @@ export class AssetMediaService extends BaseService { this.logger.error(`Error locating duplicate for checksum constraint`); throw new InternalServerErrorException(); } + + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); + } + return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index b942c32326..26b15031ee 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { + if (auth.sharedLink) { + this.logger.deprecate( + 'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication', + ); + } + const sharedLink = await this.findOrFail(auth.user.id, id); if (sharedLink.type !== SharedLinkType.Individual) { diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 94e00500fb..64eb98bec0 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -16,7 +16,7 @@ import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; + import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; import { IconButton, Logo, toastManager } from '@immich/ui'; import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -48,21 +48,11 @@ const handleUploadAssets = async (files: File[] = []) => { try { - let results: (string | undefined)[] = []; - results = await (!files || files.length === 0 || !Array.isArray(files) + await (!files || files.length === 0 || !Array.isArray(files) ? openFileUploadDialog() : fileUploadHandler({ files })); - const data = await addSharedLinkAssets({ - ...authManager.params, - id: sharedLink.id, - assetIdsDto: { - assetIds: results.filter((id) => !!id) as string[], - }, - }); - const added = data.filter((item) => item.success).length; - - toastManager.success($t('assets_added_count', { values: { count: added } })); + toastManager.success(); } catch (error) { handleError(error, $t('errors.unable_to_add_assets_to_shared_link')); } From f3b7cd6198365a7fc38e6b46e18dcb599bfbc448 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 12 Mar 2026 15:15:21 -0500 Subject: [PATCH 37/49] refactor: move encoded video to asset files table (#26863) * refactor: move encoded video to asset files table * chore: update --- server/src/cores/storage.core.ts | 23 +++++------- server/src/database.ts | 1 - server/src/dtos/asset-response.dto.ts | 1 - server/src/enum.ts | 1 + server/src/queries/asset.job.repository.sql | 36 ++++++++++++++----- server/src/queries/asset.repository.sql | 16 ++++++--- .../src/repositories/asset-job.repository.ts | 24 +++++++++---- server/src/repositories/asset.repository.ts | 21 +++++++++-- .../src/repositories/database.repository.ts | 1 - .../1773242919341-EncodedVideoAssetFiles.ts | 25 +++++++++++++ server/src/schema/tables/asset.table.ts | 3 -- .../src/services/asset-media.service.spec.ts | 17 ++++++--- server/src/services/asset.service.ts | 2 +- server/src/services/media.service.spec.ts | 6 ++-- server/src/services/media.service.ts | 16 ++++++--- server/src/utils/asset.util.ts | 2 ++ server/src/utils/database.ts | 21 +++++++++-- server/test/factories/asset.factory.ts | 1 - server/test/mappers.ts | 1 - 19 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index c6821404dc..3345f6e129 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -154,10 +154,11 @@ export class StorageCore { } async moveAssetVideo(asset: StorageAsset) { + const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); return this.moveFile({ entityId: asset.id, pathType: AssetPathType.EncodedVideo, - oldPath: asset.encodedVideoPath, + oldPath: encodedVideoFile?.path || null, newPath: StorageCore.getEncodedVideoPath(asset), }); } @@ -303,21 +304,15 @@ export class StorageCore { case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); } - case AssetFileType.FullSize: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); - } - case AssetFileType.Preview: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); - } - case AssetFileType.Thumbnail: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); - } - case AssetPathType.EncodedVideo: { - return this.assetRepository.update({ id, encodedVideoPath: newPath }); - } + + case AssetFileType.FullSize: + case AssetFileType.EncodedVideo: + case AssetFileType.Thumbnail: + case AssetFileType.Preview: case AssetFileType.Sidecar: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath }); } + case PersonPathType.Face: { return this.personRepository.update({ id, thumbnailPath: newPath }); } diff --git a/server/src/database.ts b/server/src/database.ts index fc790259d1..3e3192c21a 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -154,7 +154,6 @@ export type StorageAsset = { id: string; ownerId: string; files: AssetFile[]; - encodedVideoPath: string | null; }; export type Stack = { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 644c9caeb8..8b38b2e124 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -153,7 +153,6 @@ export type MapAsset = { duplicateId: string | null; duration: string | null; edits?: ShallowDehydrateObject[]; - encodedVideoPath: string | null; exifInfo?: ShallowDehydrateObject> | null; faces?: ShallowDehydrateObject[]; fileCreatedAt: Date; diff --git a/server/src/enum.ts b/server/src/enum.ts index 887c8fa93c..60f45efd6e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,7 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + EncodedVideo = 'encoded_video', } export enum AlbumUserRole { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index a9c407782b..cebb9fe95e 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -175,7 +175,6 @@ where select "asset"."id", "asset"."ownerId", - "asset"."encodedVideoPath", ( select coalesce(json_agg(agg), '[]') @@ -463,7 +462,6 @@ select "asset"."libraryId", "asset"."ownerId", "asset"."livePhotoVideoId", - "asset"."encodedVideoPath", "asset"."originalPath", "asset"."isOffline", to_json("asset_exif") as "exifInfo", @@ -521,12 +519,17 @@ select from "asset" where - "asset"."type" = $1 - and ( - "asset"."encodedVideoPath" is null - or "asset"."encodedVideoPath" = $2 + "asset"."type" = 'VIDEO' + and not exists ( + select + "asset_file"."id" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = 'encoded_video' ) - and "asset"."visibility" != $3 + and "asset"."visibility" != 'hidden' and "asset"."deletedAt" is null -- AssetJobRepository.getForVideoConversion @@ -534,12 +537,27 @@ select "asset"."id", "asset"."ownerId", "asset"."originalPath", - "asset"."encodedVideoPath" + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type", + "asset_file"."isEdited" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + ) as agg + ) as "files" from "asset" where "asset"."id" = $1 - and "asset"."type" = $2 + and "asset"."type" = 'VIDEO' -- AssetJobRepository.streamForMetadataExtraction select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index a74a05f466..a2525c3b17 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -629,13 +629,21 @@ order by -- AssetRepository.getForVideo select - "asset"."encodedVideoPath", - "asset"."originalPath" + "asset"."originalPath", + ( + select + "asset_file"."path" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as "encodedVideoPath" from "asset" where - "asset"."id" = $1 - and "asset"."type" = $2 + "asset"."id" = $2 + and "asset"."type" = $3 -- AssetRepository.getForOcr select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index a8067473e4..3765cad7ed 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -104,7 +104,7 @@ export class AssetJobRepository { getForMigrationJob(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId']) .select(withFiles) .where('asset.id', '=', id) .executeTakeFirst(); @@ -268,7 +268,6 @@ export class AssetJobRepository { 'asset.libraryId', 'asset.ownerId', 'asset.livePhotoVideoId', - 'asset.encodedVideoPath', 'asset.originalPath', 'asset.isOffline', ]) @@ -310,11 +309,21 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .select(['asset.id']) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .$if(!force, (qb) => qb - .where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')])) - .where('asset.visibility', '!=', AssetVisibility.Hidden), + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.id') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)), + ), + ), + ) + .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), ) .where('asset.deletedAt', 'is', null) .stream(); @@ -324,9 +333,10 @@ export class AssetJobRepository { getForVideoConversion(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId', 'asset.originalPath']) + .select(withFiles) .where('asset.id', '=', id) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .executeTakeFirst(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 200137a137..2e1d02ef28 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -36,6 +36,7 @@ import { withExif, withFaces, withFacesAndPeople, + withFilePath, withFiles, withLibrary, withOwner, @@ -1019,8 +1020,21 @@ export class AssetRepository { .execute(); } - async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise { - await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute(); + async deleteFile({ + assetId, + type, + edited, + }: { + assetId: string; + type: AssetFileType; + edited?: boolean; + }): Promise { + await this.db + .deleteFrom('asset_file') + .where('assetId', '=', asUuid(assetId)) + .where('type', '=', type) + .$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!)) + .execute(); } async deleteFiles(files: Pick, 'id'>[]): Promise { @@ -1139,7 +1153,8 @@ export class AssetRepository { async getForVideo(id: string) { return this.db .selectFrom('asset') - .select(['asset.encodedVideoPath', 'asset.originalPath']) + .select(['asset.originalPath']) + .select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath')) .where('asset.id', '=', id) .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 4ffb37c79c..7ae1119bbc 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -431,7 +431,6 @@ export class DatabaseRepository { .updateTable('asset') .set((eb) => ({ originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]), - encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]), })) .execute(); diff --git a/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts new file mode 100644 index 0000000000..4a62a7e842 --- /dev/null +++ b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts @@ -0,0 +1,25 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + INSERT INTO "asset_file" ("assetId", "type", "path") + SELECT "id", 'encoded_video', "encodedVideoPath" + FROM "asset" + WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != ''; + `.execute(db); + + await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db); + + await sql` + UPDATE "asset" + SET "encodedVideoPath" = af."path" + FROM "asset_file" af + WHERE "asset"."id" = af."assetId" + AND af."type" = 'encoded_video' + AND af."isEdited" = false; + `.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 12e9c36125..8bdaa59bc6 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -92,9 +92,6 @@ export class AssetTable { @Column({ type: 'character varying', nullable: true }) duration!: string | null; - @Column({ type: 'character varying', nullable: true, default: '' }) - encodedVideoPath!: string | null; - @Column({ type: 'bytea', index: true }) checksum!: Buffer; // sha1 checksum diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index f49dd3cb50..1bf8bafdf7 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -163,7 +163,6 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - encodedVideoPath: '', duration: '0:00:00.000000', files: [] as AssetFile[], exifInfo: { @@ -711,13 +710,18 @@ describe(AssetMediaService.name, () => { }); it('should return the encoded video path if available', async () => { - const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' }); + const asset = AssetFactory.from() + .file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' }) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: asset.files[0].path, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: asset.encodedVideoPath!, + path: '/path/to/encoded/video.mp4', cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), @@ -727,7 +731,10 @@ describe(AssetMediaService.name, () => { it('should fall back to the original path', async () => { const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: null, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 387b700f01..1e5d23a98d 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -370,7 +370,7 @@ export class AssetService extends BaseService { assetFiles.editedFullsizeFile?.path, assetFiles.editedPreviewFile?.path, assetFiles.editedThumbnailFile?.path, - asset.encodedVideoPath, + assetFiles.encodedVideoFile?.path, ]; if (deleteOnDisk && !asset.isOffline) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 279d57becd..51a10a39c2 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2254,7 +2254,9 @@ describe(MediaService.name, () => { }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' }); + const asset = AssetFactory.from({ type: AssetType.Video }) + .file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' }) + .build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); @@ -2264,7 +2266,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, - data: { files: [asset.encodedVideoPath] }, + data: { files: ['/encoded/video/path.mp4'] }, }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 8158ade192..ea0b1e9142 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -39,7 +39,7 @@ import { VideoInterfaces, VideoStreamInfo, } from 'src/types'; -import { getDimensions } from 'src/utils/asset.util'; +import { getAssetFile, getDimensions } from 'src/utils/asset.util'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; @@ -605,10 +605,11 @@ export class MediaService extends BaseService { let { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream); if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) { - if (asset.encodedVideoPath) { + const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); + if (encodedVideo) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); - await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } }); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } }); + await this.assetRepository.deleteFiles([encodedVideo]); } else { this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } @@ -656,7 +657,12 @@ export class MediaService extends BaseService { this.logger.log(`Successfully encoded ${asset.id}`); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: output }); + await this.assetRepository.upsertFile({ + assetId: asset.id, + type: AssetFileType.EncodedVideo, + path: output, + isEdited: false, + }); return JobStatus.Success; } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index d6ab825028..5420e60361 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -26,6 +26,8 @@ export const getAssetFiles = (files: AssetFile[]) => ({ editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }), + + encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }), }); export const addAssets = async ( diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 2e22a9f479..03998d9462 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -355,7 +355,16 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) - .$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!)) + .$if(!!options.encodedVideoPath, (qb) => + qb + .innerJoin('asset_file', (join) => + join + .onRef('asset.id', '=', 'asset_file.assetId') + .on('asset_file.type', '=', AssetFileType.EncodedVideo) + .on('asset_file.isEdited', '=', false), + ) + .where('asset_file.path', '=', options.encodedVideoPath!), + ) .$if(!!options.originalPath, (qb) => qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), ) @@ -380,7 +389,15 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!)) .$if(options.isEncoded !== undefined, (qb) => - qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), + qb.where((eb) => { + const exists = eb.exists((eb) => + eb + .selectFrom('asset_file') + .whereRef('assetId', '=', 'asset.id') + .where('type', '=', AssetFileType.EncodedVideo), + ); + return options.isEncoded ? exists : eb.not(exists); + }), ) .$if(options.isMotion !== undefined, (qb) => qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 4d54ba820b..ec596dc86e 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -55,7 +55,6 @@ export class AssetFactory { deviceId: '', duplicateId: null, duration: null, - encodedVideoPath: null, fileCreatedAt: newDate(), fileModifiedAt: newDate(), isExternal: false, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 73c1bcd6d7..7f324663be 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -183,7 +183,6 @@ export const getForAssetDeletion = (asset: ReturnType) => libraryId: asset.libraryId, ownerId: asset.ownerId, livePhotoVideoId: asset.livePhotoVideoId, - encodedVideoPath: asset.encodedVideoPath, originalPath: asset.originalPath, isOffline: asset.isOffline, exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null, From c91d8745b460736fa2bb878857e26cf4d3891457 Mon Sep 17 00:00:00 2001 From: luis15pt <100942871+luis15pt@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:27:44 +0000 Subject: [PATCH 38/49] fix: use correct original URL for 360 video panorama playback (#26831) Co-authored-by: Claude Opus 4.6 --- web/src/lib/utils.spec.ts | 26 ++++++++++++++++++++++++++ web/src/lib/utils.ts | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 3bc8665279..221fc38568 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -74,6 +74,32 @@ describe('utils', () => { expect(url).toContain(asset.id); }); + it('should return original URL for video assets with forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset, forceOriginal: true }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for video assets without forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => { const asset = assetFactory.build({ originalPath: 'image.gif', diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 8b6665bf94..9d0c32ae94 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -200,7 +200,9 @@ const forceUseOriginal = (asset: AssetResponseDto) => { export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) { - return isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize; + return asset.type === AssetTypeEnum.Video || isWebCompatibleImage(asset) + ? AssetMediaSize.Original + : AssetMediaSize.Fullsize; } return AssetMediaSize.Preview; }; From 754f072ef9bb1fcebe4676bc5bb6e3b92ba1880d Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:37:51 +0100 Subject: [PATCH 39/49] fix(web): disable drag and drop for internal items (#26897) --- .../drag-and-drop-upload-overlay.svelte | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index 77178aa992..b37b8c0739 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -13,6 +13,7 @@ let isInLockedFolder = $derived(isLockedFolderRoute(page.route.id)); let dragStartTarget: EventTarget | null = $state(null); + let isInternalDrag = false; const onDragEnter = (e: DragEvent) => { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { @@ -133,7 +134,19 @@ } }; + const ondragstart = () => { + isInternalDrag = true; + }; + + const ondragend = () => { + isInternalDrag = false; + }; + const ondragenter = (e: DragEvent) => { + if (isInternalDrag) { + return; + } + e.preventDefault(); e.stopPropagation(); onDragEnter(e); @@ -146,6 +159,10 @@ }; const ondrop = async (e: DragEvent) => { + if (isInternalDrag) { + return; + } + e.preventDefault(); e.stopPropagation(); await onDrop(e); @@ -159,7 +176,7 @@ - + {#if dragStartTarget} From 226b9390dbf810d3c0c2961b672383fdb803e7af Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:38:21 -0500 Subject: [PATCH 40/49] fix(mobile): video auth (#26887) * fix video auth * update commit --- mobile/android/app/build.gradle | 2 + .../app/alextran/immich/MainActivity.kt | 2 + .../alextran/immich/core/HttpClientManager.kt | 66 +++++++++++++++++++ .../immich/images/RemoteImagesImpl.kt | 57 ++++------------ mobile/ios/Runner/AppDelegate.swift | 2 + mobile/pubspec.lock | 12 ++-- mobile/pubspec.yaml | 2 +- 7 files changed, 90 insertions(+), 53 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index bd90986f60..103cf79e4e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -113,6 +113,8 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation 'org.chromium.net:cronet-embedded:143.7445.0' + implementation("androidx.media3:media3-datasource-okhttp:1.9.2") + implementation("androidx.media3:media3-datasource-cronet:1.9.2") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index a85929a0e9..06649de8f0 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.NetworkApiPlugin +import me.albemala.native_video_player.NativeVideoPlayerPlugin import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi @@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() { companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { HttpClientManager.initialize(ctx) + NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory flutterEngine.plugins.add(NetworkApiPlugin()) val messenger = flutterEngine.dartExecutor.binaryMessenger diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 180ae4735d..5b53b2a49a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -3,7 +3,13 @@ package app.alextran.immich.core import android.content.Context import android.content.SharedPreferences import android.security.KeyChain +import androidx.annotation.OptIn import androidx.core.content.edit +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cronet.CronetDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import app.alextran.immich.BuildConfig import app.alextran.immich.NativeBuffer import okhttp3.Cache @@ -16,6 +22,7 @@ import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient +import org.chromium.net.CronetEngine import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream @@ -25,6 +32,8 @@ import java.security.KeyStore import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext @@ -56,6 +65,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) { */ object HttpClientManager { private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB + const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB private const val KEEP_ALIVE_CONNECTIONS = 10 private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val MAX_REQUESTS_PER_HOST = 64 @@ -67,6 +77,11 @@ object HttpClientManager { private lateinit var appContext: Context private lateinit var prefs: SharedPreferences + var cronetEngine: CronetEngine? = null + private set + private lateinit var cronetStorageDir: File + val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4) + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } var keyChainAlias: String? = null @@ -107,6 +122,10 @@ object HttpClientManager { val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) + + cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() } + cronetEngine = buildCronetEngine() + initialized = true } } @@ -223,6 +242,53 @@ object HttpClientManager { ?.joinToString("; ") { "${it.name}=${it.value}" } } + fun getAuthHeaders(url: String): Map { + val result = mutableMapOf() + headers.forEach { (key, value) -> result[key] = value } + loadCookieHeader(url)?.let { result["Cookie"] = it } + url.toHttpUrlOrNull()?.let { httpUrl -> + if (httpUrl.username.isNotEmpty()) { + result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password) + } + } + return result + } + + fun rebuildCronetEngine(): CronetEngine { + val old = cronetEngine!! + cronetEngine = buildCronetEngine() + return old + } + + val cronetStoragePath: File get() = cronetStorageDir + + @OptIn(UnstableApi::class) + fun createDataSourceFactory(headers: Map): DataSource.Factory { + return if (isMtls) { + OkHttpDataSource.Factory(client.newBuilder().cache(null).build()) + } else { + ResolvingDataSource.Factory( + CronetDataSource.Factory(cronetEngine!!, cronetExecutor) + ) { dataSpec -> + val newHeaders = dataSpec.httpRequestHeaders.toMutableMap() + newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString())) + newHeaders["Cache-Control"] = "no-store" + dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build() + } + } + } + + private fun buildCronetEngine(): CronetEngine { + return CronetEngine.Builder(appContext) + .enableHttp2(true) + .enableQuic(true) + .enableBrotli(true) + .setStoragePath(cronetStorageDir.absolutePath) + .setUserAgent(USER_AGENT) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES) + .build() + } + private fun build(cacheDir: File): OkHttpClient { val connectionPool = ConnectionPool( maxIdleConnections = KEEP_ALIVE_CONNECTIONS, diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index b820b45425..8e9fc3f6d5 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeByteBuffer import app.alextran.immich.core.HttpClientManager -import app.alextran.immich.core.USER_AGENT import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Call @@ -15,9 +14,6 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.Credentials -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest import org.chromium.net.UrlResponseInfo @@ -31,10 +27,6 @@ import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors - - -private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 private class RemoteRequest(val cancellationSignal: CancellationSignal) @@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { } private object ImageFetcherManager { - private lateinit var appContext: Context private lateinit var cacheDir: File private lateinit var fetcher: ImageFetcher private var initialized = false @@ -110,7 +101,6 @@ private object ImageFetcherManager { if (initialized) return synchronized(this) { if (initialized) return - appContext = context.applicationContext cacheDir = context.cacheDir fetcher = build() HttpClientManager.addClientChangedListener(::invalidate) @@ -143,7 +133,7 @@ private object ImageFetcherManager { return if (HttpClientManager.isMtls) { OkHttpImageFetcher.create(cacheDir) } else { - CronetImageFetcher(appContext, cacheDir) + CronetImageFetcher() } } } @@ -161,19 +151,11 @@ private sealed interface ImageFetcher { fun clearCache(onCleared: (Result) -> Unit) } -private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { - private val ctx = context - private var engine: CronetEngine - private val executor = Executors.newFixedThreadPool(4) +private class CronetImageFetcher : ImageFetcher { private val stateLock = Any() private var activeCount = 0 private var draining = false private var onCacheCleared: ((Result) -> Unit)? = null - private val storageDir = File(cacheDir, "cronet").apply { mkdirs() } - - init { - engine = build(context) - } override fun fetch( url: String, @@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } val callback = FetchCallback(onSuccess, onFailure, ::onComplete) - val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) - HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } - HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) } - url.toHttpUrlOrNull()?.let { httpUrl -> - if (httpUrl.username.isNotEmpty()) { - requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) - } + val requestBuilder = HttpClientManager.cronetEngine!! + .newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor) + HttpClientManager.getAuthHeaders(url).forEach { (key, value) -> + requestBuilder.addHeader(key, value) } val request = requestBuilder.build() signal.setOnCancelListener(request::cancel) request.start() } - private fun build(ctx: Context): CronetEngine { - return CronetEngine.Builder(ctx) - .enableHttp2(true) - .enableQuic(true) - .enableBrotli(true) - .setStoragePath(storageDir.absolutePath) - .setUserAgent(USER_AGENT) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES) - .build() - } - private fun onComplete() { val didDrain = synchronized(stateLock) { activeCount-- @@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } private fun onDrained() { - engine.shutdown() val onCacheCleared = synchronized(stateLock) { val onCacheCleared = onCacheCleared this.onCacheCleared = null onCacheCleared } - if (onCacheCleared == null) { - executor.shutdown() - } else { + if (onCacheCleared != null) { + val oldEngine = HttpClientManager.rebuildCronetEngine() + oldEngine.shutdown() CoroutineScope(Dispatchers.IO).launch { - val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) } - // Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result - engine = build(ctx) + val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) } synchronized(stateLock) { draining = false } onCacheCleared(result) } @@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor( val dir = File(cacheDir, "okhttp") val client = HttpClientManager.getClient().newBuilder() - .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + .cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES)) .build() return OkHttpImageFetcher(client) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index f842285b23..8487db7b48 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import BackgroundTasks import Flutter +import native_video_player import network_info_plus import path_provider_foundation import permission_handler_apple @@ -18,6 +19,7 @@ import UIKit UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } + SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index de116abb7e..89a43f328b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1218,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" - resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" + ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 + resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" @@ -1897,10 +1897,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3a075d67ff..77955c06ab 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,7 +56,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2' + ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: From c2a279e49ea585065f5a4e21450544862dc668c4 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:40:04 +0100 Subject: [PATCH 41/49] fix(web): keep header fixed on individual shared links (#26892) --- .../individual-shared-viewer.svelte | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 64eb98bec0..0bf1a2f7f2 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -74,8 +74,12 @@ }; -
- {#if sharedLink?.allowUpload || assets.length > 1} +{#if sharedLink?.allowUpload || assets.length > 1} +
+ +
+ +
{#if assetInteraction.selectionActive} {/if} -
- -
- {:else if assets.length === 1} - {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} - {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} - - {/await} +
+{:else if assets.length === 1} + {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} + {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + {/await} - {/if} -
+ {/await} +{/if} From e322d44f9553e51da46ed85568aaa3dc9951b7d2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hourt Date: Fri, 13 Mar 2026 09:41:50 -0500 Subject: [PATCH 42/49] fix: SMTP over TLS (#26893) Final step on #22833 PReq #22833 is about adding support for SMTP-over-TLS rather than just STARTTLS when sending emails. That PReq adds almost everything; it just forgot to actually pass the flag to Nodemailer at the end. This adds that last line of code and makes it work correctly (for me, anyways!). Co-authored-by: Nathaniel --- server/src/repositories/email.repository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/repositories/email.repository.ts b/server/src/repositories/email.repository.ts index 1bc4f0981a..a0cc23661a 100644 --- a/server/src/repositories/email.repository.ts +++ b/server/src/repositories/email.repository.ts @@ -162,6 +162,7 @@ export class EmailRepository { host: options.host, port: options.port, tls: { rejectUnauthorized: !options.ignoreCert }, + secure: options.secure, auth: options.username || options.password ? { From 10fa928abeee45e994ab29f9b36178982fb971a0 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 13 Mar 2026 15:43:00 +0100 Subject: [PATCH 43/49] feat: require pull requests to follow template (#26902) * feat: require pull requests to follow template * fix: persist-credentials: false --- .github/workflows/check-pr-template.yml | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/check-pr-template.yml diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml new file mode 100644 index 0000000000..f60498d269 --- /dev/null +++ b/.github/workflows/check-pr-template.yml @@ -0,0 +1,80 @@ +name: Check PR Template + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, edited] + +permissions: {} + +jobs: + parse: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.head.repo.fork == true }} + permissions: + contents: read + outputs: + uses_template: ${{ steps.check.outputs.uses_template }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .github/pull_request_template.md + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Check required sections + id: check + env: + BODY: ${{ github.event.pull_request.body }} + run: | + OK=true + while IFS= read -r header; do + printf '%s\n' "$BODY" | grep -qF "$header" || OK=false + done < <(grep "^## " .github/pull_request_template.md) + echo "uses_template=$OK" >> "$GITHUB_OUTPUT" + + act: + runs-on: ubuntu-latest + needs: parse + permissions: + pull-requests: write + steps: + - name: Close PR + if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \ + -f query=' + mutation CommentAndClosePR($prId: ID!, $body: String!) { + addComment(input: { + subjectId: $prId, + body: $body + }) { + __typename + } + closePullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' + + - name: Reopen PR (sections now present, PR closed) + if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f query=' + mutation ReopenPR($prId: ID!) { + reopenPullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' From 55513cd59f74a48aecce7ac73c252009d6ab81da Mon Sep 17 00:00:00 2001 From: Belnadifia Date: Fri, 13 Mar 2026 22:14:45 +0100 Subject: [PATCH 44/49] feat(server): support IDPs that only send the userinfo in the ID token (#26717) Co-authored-by: irouply Co-authored-by: Daniel Dietzler --- e2e-auth-server/auth-server.ts | 29 ++++++++++++++++++--- e2e/src/specs/server/api/oauth.e2e-spec.ts | 19 ++++++++++++++ server/src/repositories/oauth.repository.ts | 11 +++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/e2e-auth-server/auth-server.ts b/e2e-auth-server/auth-server.ts index a190ecd023..9aef56510d 100644 --- a/e2e-auth-server/auth-server.ts +++ b/e2e-auth-server/auth-server.ts @@ -10,6 +10,7 @@ export enum OAuthClient { export enum OAuthUser { NO_EMAIL = 'no-email', NO_NAME = 'no-name', + ID_TOKEN_CLAIMS = 'id-token-claims', WITH_QUOTA = 'with-quota', WITH_USERNAME = 'with-username', WITH_ROLE = 'with-role', @@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({ email_verified: true, }); -const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +const getClaims = (sub: string, use?: string) => { + if (sub === OAuthUser.ID_TOKEN_CLAIMS) { + return { + sub, + email: `oauth-${sub}@immich.app`, + email_verified: true, + name: use === 'id_token' ? 'ID Token User' : 'Userinfo User', + }; + } + return claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +}; const setup = async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); - const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect']; + const redirectUris = [ + 'http://127.0.0.1:2285/auth/login', + 'https://photos.immich.app/oauth/mobile-redirect', + ]; const port = 2286; const host = '0.0.0.0'; const oidc = new Provider(`http://${host}:${port}`, { @@ -66,7 +80,10 @@ const setup = async () => { console.error(error); ctx.body = 'Internal Server Error'; }, - findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }), + findAccount: (ctx, sub) => ({ + accountId: sub, + claims: (use) => getClaims(sub, use), + }), scopes: ['openid', 'email', 'profile'], claims: { openid: ['sub'], @@ -94,6 +111,7 @@ const setup = async () => { state: 'oidc.state', }, }, + conformIdTokenClaims: false, pkce: { required: () => false, }, @@ -125,7 +143,10 @@ const setup = async () => { ], }); - const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`); + const onStart = () => + console.log( + `[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`, + ); const app = oidc.listen(port, host, onStart); return () => app.close(); }; diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index cbd68c003a..ae9064375f 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -380,4 +380,23 @@ describe(`/oauth`, () => { }); }); }); + + describe('idTokenClaims', () => { + it('should use claims from the ID token if IDP includes them', async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + }); + const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS); + const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); + expect(status).toBe(201); + expect(body).toMatchObject({ + accessToken: expect.any(String), + name: 'ID Token User', + userEmail: 'oauth-id-token-claims@immich.app', + userId: expect.any(String), + }); + }); + }); }); diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index a42955ba10..5af5163f8f 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -70,7 +70,16 @@ export class OAuthRepository { try { const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier }); - const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + + let profile: OAuthProfile; + const tokenClaims = tokens.claims(); + if (tokenClaims && 'email' in tokenClaims) { + this.logger.debug('Using ID token claims instead of userinfo endpoint'); + profile = tokenClaims as OAuthProfile; + } else { + profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + } + if (!profile.sub) { throw new Error('Unexpected profile response, no `sub`'); } From 2c6d4f3fe1df8b1acba1de90cda5312f61fd4323 Mon Sep 17 00:00:00 2001 From: rthrth-svg <267244824+rthrth-svg@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:27:08 +0000 Subject: [PATCH 45/49] fix(web): copy yearMonth in MonthGroup to avoid shared object reference with asset (#26890) Co-authored-by: Min Idzelis --- .../timeline-manager/month-group.svelte.ts | 2 +- .../timeline-manager.svelte.spec.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 3b3860eb9c..b41deb5785 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -60,7 +60,7 @@ export class MonthGroup { this.#initialCount = initialCount; this.#sortOrder = order; - this.yearMonth = yearMonth; + this.yearMonth = { year: yearMonth.year, month: yearMonth.month }; this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth)); this.loader = new CancellableTask( diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 8addc173c4..943b5d12a8 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -355,6 +355,29 @@ describe('TimelineManager', () => { expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); }); + + it('yearMonth is not a shared reference with asset.localDateTime (reference bug)', () => { + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + + timelineManager.upsertAssets([asset]); + const januaryMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; + const monthYearMonth = januaryMonth.yearMonth; + + const originalMonth = monthYearMonth.month; + expect(originalMonth).toEqual(1); + + // Simulating updateObject + asset.localDateTime.month = 3; + asset.localDateTime.day = 20; + + expect(monthYearMonth.month).toEqual(originalMonth); + expect(monthYearMonth.month).toEqual(1); + }); + it('asset is removed during upsert when TimelineManager if visibility changes', async () => { await timelineManager.updateOptions({ visibility: AssetVisibility.Archive, From 0581b497509d0ffe8296be194bdcf1b30b1ca313 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 13 Mar 2026 23:55:00 +0100 Subject: [PATCH 46/49] fix: ignore optional headers in pr template check (#26910) --- .github/workflows/check-pr-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml index f60498d269..4dcdd20f72 100644 --- a/.github/workflows/check-pr-template.yml +++ b/.github/workflows/check-pr-template.yml @@ -29,7 +29,7 @@ jobs: OK=true while IFS= read -r header; do printf '%s\n' "$BODY" | grep -qF "$header" || OK=false - done < <(grep "^## " .github/pull_request_template.md) + done < <(sed '//d' .github/pull_request_template.md | grep "^## ") echo "uses_template=$OK" >> "$GITHUB_OUTPUT" act: From 48fe111daaf93d3a80346be05f57a6ebb91aa6d1 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 13 Mar 2026 23:04:55 -0400 Subject: [PATCH 47/49] feat(web): improve OCR overlay text fitting, reactivity, and accessibility (#26678) - Precise font sizing using canvas measureText instead of character-count heuristic - Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads - Fix animation delay on resize by using transition-colors instead of transition-all - Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort - Show text on focus (same styling as hover) with proper ARIA attributes --- .../asset-viewer/ocr-bounding-box.svelte | 29 +++- .../asset-viewer/photo-viewer.svelte | 3 +- web/src/lib/utils/ocr-utils.ts | 125 +++++++++++++++++- 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index 6f6caad0fc..d5551b9cc5 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -1,6 +1,6 @@
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 55c765ce22..4a6a02cb4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -73,7 +73,8 @@ } const natural = getNaturalSize(assetViewerManager.imgRef); - const scaled = scaleToFit(natural, container); + const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight }); + return { contentWidth: scaled.width, contentHeight: scaled.height, diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 3da36cf57a..c483eb9551 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -1,18 +1,38 @@ import type { OcrBoundingBox } from '$lib/stores/ocr.svelte'; import type { ContentMetrics } from '$lib/utils/container-utils'; +import { clamp } from 'lodash-es'; export type Point = { x: number; y: number; }; +const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + +export type VerticalMode = 'none' | 'cjk' | 'rotated'; + export interface OcrBox { id: string; points: Point[]; text: string; confidence: number; + verticalMode: VerticalMode; } +const CJK_PATTERN = + /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/; + +const VERTICAL_ASPECT_RATIO = 1.5; + +const containsCjk = (text: string): boolean => CJK_PATTERN.test(text); + +const getVerticalMode = (width: number, height: number, text: string): VerticalMode => { + if (height / width < VERTICAL_ASPECT_RATIO) { + return 'none'; + } + return containsCjk(text) ? 'cjk' : 'rotated'; +}; + /** * Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d. * @param points - Array of 4 corner points of the bounding box @@ -21,8 +41,6 @@ export interface OcrBox { export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => { const [topLeft, topRight, bottomRight, bottomLeft] = points; - // Approximate width and height to prevent text distortion as much as possible - const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight)); const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight)); @@ -55,6 +73,96 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; return { matrix, width, height }; }; +const BORDER_SIZE = 4; +const HORIZONTAL_PADDING = 16 + BORDER_SIZE; +const VERTICAL_PADDING = 8 + BORDER_SIZE; +const REFERENCE_FONT_SIZE = 100; +const MIN_FONT_SIZE = 8; +const MAX_FONT_SIZE = 96; +const FALLBACK_FONT = `${REFERENCE_FONT_SIZE}px sans-serif`; + +let sharedCanvasContext: CanvasRenderingContext2D | null = null; +let resolvedFont: string | undefined; + +const getCanvasContext = (): CanvasRenderingContext2D | null => { + if (sharedCanvasContext !== null) { + return sharedCanvasContext; + } + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + sharedCanvasContext = context; + return sharedCanvasContext; +}; + +const getReferenceFont = (): string => { + if (resolvedFont !== undefined) { + return resolvedFont; + } + const fontFamily = globalThis.getComputedStyle?.(document.documentElement).getPropertyValue('--font-sans').trim(); + resolvedFont = fontFamily ? `${REFERENCE_FONT_SIZE}px ${fontFamily}` : FALLBACK_FONT; + return resolvedFont; +}; + +export const calculateFittedFontSize = ( + text: string, + boxWidth: number, + boxHeight: number, + verticalMode: VerticalMode, +): number => { + const isVertical = verticalMode === 'cjk' || verticalMode === 'rotated'; + const availableWidth = boxWidth - (isVertical ? VERTICAL_PADDING : HORIZONTAL_PADDING); + const availableHeight = boxHeight - (isVertical ? HORIZONTAL_PADDING : VERTICAL_PADDING); + + const context = getCanvasContext(); + + if (verticalMode === 'cjk') { + if (!context) { + const fontSize = Math.min(availableWidth, availableHeight / text.length); + return clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + let maxCharWidth = 0; + let totalCharHeight = 0; + for (const character of text) { + const metrics = context.measureText(character); + const charWidth = metrics.width; + const charHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + maxCharWidth = Math.max(maxCharWidth, charWidth); + totalCharHeight += Math.max(charWidth, charHeight); + } + + const scaleFromWidth = (availableWidth / maxCharWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (availableHeight / totalCharHeight) * REFERENCE_FONT_SIZE; + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + const fitWidth = verticalMode === 'rotated' ? availableHeight : availableWidth; + const fitHeight = verticalMode === 'rotated' ? availableWidth : availableHeight; + + if (!context) { + return clamp((1.4 * fitWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // Unsupported in Safari iOS <16.6; falls back to default canvas font, giving less accurate but functional sizing + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + const metrics = context.measureText(text); + const measuredWidth = metrics.width; + const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + + const scaleFromWidth = (fitWidth / measuredWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (fitHeight / measuredHeight) * REFERENCE_FONT_SIZE; + + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); +}; + export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => { const boxes: OcrBox[] = []; for (const ocr of ocrData) { @@ -68,13 +176,26 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM y: point.y * metrics.contentHeight + metrics.offsetY, })); + const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2])); + const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2])); + boxes.push({ id: ocr.id, points, text: ocr.text, confidence: ocr.textScore, + verticalMode: getVerticalMode(boxWidth, boxHeight, ocr.text), }); } + const rowThreshold = metrics.contentHeight * 0.02; + boxes.sort((a, b) => { + const yDifference = a.points[0].y - b.points[0].y; + if (Math.abs(yDifference) < rowThreshold) { + return a.points[0].x - b.points[0].x; + } + return yDifference; + }); + return boxes; }; From ff936f901d0f3c0ea058a25b93a17659105d6be2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:09:42 -0500 Subject: [PATCH 48/49] fix(mobile): duplicate server urls returned (#26864) remove server url Co-authored-by: Alex --- mobile/lib/services/api.service.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index bc5e46f769..e296ac522d 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -176,10 +176,6 @@ class ApiService { if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } - final serverUrl = Store.tryGet(StoreKey.serverUrl); - if (serverUrl != null && serverUrl.isNotEmpty) { - urls.add(serverUrl); - } final localEndpoint = Store.tryGet(StoreKey.localEndpoint); if (localEndpoint != null && localEndpoint.isNotEmpty) { urls.add(localEndpoint); From b66c97b785169b3d40fb8b4d8d8027a72a2a5f4f Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:23:07 -0500 Subject: [PATCH 49/49] fix(mobile): use shared auth for background_downloader (#26911) shared client for background_downloader on ios --- .../alextran/immich/core/HttpClientManager.kt | 23 ++++++++ mobile/ios/Runner/AppDelegate.swift | 1 + .../ios/Runner/Core/URLSessionManager.swift | 55 +++++++++++++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 5b53b2a49a..e7268396e8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -27,7 +27,11 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.File +import java.net.Authenticator +import java.net.CookieHandler +import java.net.PasswordAuthentication import java.net.Socket +import java.net.URI import java.security.KeyStore import java.security.Principal import java.security.PrivateKey @@ -104,6 +108,25 @@ object HttpClientManager { keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) cookieJar.init(prefs) + System.setProperty("http.agent", USER_AGENT) + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + val url = requestingURL ?: return null + if (url.userInfo.isNullOrEmpty()) return null + val parts = url.userInfo.split(":", limit = 2) + return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray()) + } + }) + CookieHandler.setDefault(object : CookieHandler() { + override fun get(uri: URI, requestHeaders: Map>): Map> { + val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap() + val cookies = cookieJar.loadForRequest(httpUrl) + if (cookies.isEmpty()) return emptyMap() + return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" })) + } + + override fun put(uri: URI, responseHeaders: Map>) {} + }) val savedHeaders = prefs.getString(PREFS_HEADERS, null) if (savedHeaders != null) { diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 8487db7b48..81af41ab08 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -20,6 +20,7 @@ import UIKit } SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage + URLSessionManager.patchBackgroundDownloader() GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 9868d4eb59..0b73ed71a6 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -51,7 +51,7 @@ class URLSessionManager: NSObject { diskCapacity: 1024 * 1024 * 1024, directory: cacheDir ) - private static let userAgent: String = { + static let userAgent: String = { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" return "Immich_iOS_\(version)" }() @@ -158,6 +158,49 @@ class URLSessionManager: NSObject { return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } + + /// Patches background_downloader's URLSession to use shared auth configuration. + /// Must be called before background_downloader creates its session (i.e. early in app startup). + static func patchBackgroundDownloader() { + // Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config + let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:") + let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:)) + if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel), + let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) { + method_exchangeImplementations(original, swizzled) + } + + // Add auth challenge handling to background_downloader's UrlSessionDelegate + guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return } + + let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(sessionBlock), "v@:@@@?") + + let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, task, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(taskBlock), "v@:@@@@?") + } +} + +private extension URLSessionConfiguration { + @objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration { + // After swizzle, this calls the original implementation + let config = immich_background(withIdentifier: id) + config.httpCookieStorage = URLSessionManager.cookieStorage + config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent] + return config + } } class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { @@ -168,7 +211,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler) } - + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -177,7 +220,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler, task: task) } - + func handleChallenge( _ session: URLSession, _ challenge: URLAuthenticationChallenge, @@ -190,7 +233,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb default: completionHandler(.performDefaultHandling, nil) } } - + private func handleClientCertificate( _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void @@ -200,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb kSecAttrLabel as String: CLIENT_CERT_LABEL, kSecReturnRef as String: true, ] - + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecSuccess, let identity = item { @@ -214,7 +257,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb } completion(.performDefaultHandling, nil) } - + private func handleBasicAuth( _ session: URLSession, task: URLSessionTask?,