Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
53b2078371 fix(server): prevent duplicate metadata items from being sent 2026-01-14 16:44:13 -05:00
5 changed files with 82 additions and 24 deletions

View File

@@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -777,4 +777,40 @@ describe(AssetService.name, () => {
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
});
describe('upsertMetadata', () => {
it('should throw a bad request exception if duplicate keys are sent', async () => {
const asset = factory.asset();
const items = [
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
];
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
await expect(sut.upsertMetadata(authStub.admin, asset.id, { items })).rejects.toThrowError(
'Duplicate items are not allowed:',
);
expect(mocks.asset.upsertBulkMetadata).not.toHaveBeenCalled();
});
});
describe('upsertBulkMetadata', () => {
it('should throw a bad request exception if duplicate keys are sent', async () => {
const asset = factory.asset();
const items = [
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
];
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
await expect(sut.upsertBulkMetadata(authStub.admin, { items })).rejects.toThrowError(
'Duplicate items are not allowed:',
);
expect(mocks.asset.upsertBulkMetadata).not.toHaveBeenCalled();
});
});
});

View File

@@ -414,11 +414,32 @@ export class AssetService extends BaseService {
async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise<AssetMetadataBulkResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) });
const uniqueKeys = new Set<string>();
for (const item of dto.items) {
const key = `(${item.assetId}, ${item.key})`;
if (uniqueKeys.has(key)) {
throw new BadRequestException(`Duplicate items are not allowed: "${key}"`);
}
uniqueKeys.add(key);
}
return this.assetRepository.upsertBulkMetadata(dto.items);
}
async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise<AssetMetadataResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
const uniqueKeys = new Set<string>();
for (const { key } of dto.items) {
if (uniqueKeys.has(key)) {
throw new BadRequestException(`Duplicate items are not allowed: "${key}"`);
}
uniqueKeys.add(key);
}
return this.assetRepository.upsertMetadata(id, dto.items);
}

View File

@@ -8,7 +8,6 @@
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import EditAction from '$lib/components/asset-viewer/actions/edit-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
@@ -21,7 +20,7 @@
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute, ProjectionType } from '$lib/constants';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
@@ -73,7 +72,7 @@
onUndoDelete?: OnUndoDelete;
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
onEdit: () => void;
// onEdit: () => void;
onClose?: () => void;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
@@ -93,7 +92,7 @@
onRunJob,
onPlaySlideshow,
onClose,
onEdit,
// onEdit,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
@@ -117,15 +116,18 @@
$derived(getAssetActions($t, asset));
const sharedLink = getSharedLink();
const editorDisabled = $derived(
!isOwner ||
asset.type !== AssetTypeEnum.Image ||
asset.livePhotoVideoId ||
(asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR &&
asset.originalPath.toLowerCase().endsWith('.insp')) ||
asset.originalPath.toLowerCase().endsWith('.gif') ||
asset.originalPath.toLowerCase().endsWith('.svg'),
);
// TODO: Enable when edits are ready for release
// let showEditorButton = $derived(
// isOwner &&
// asset.type === AssetTypeEnum.Image &&
// !(
// asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
// (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
// ) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) &&
// !asset.livePhotoVideoId,
// );
</script>
<CommandPaletteDefaultProvider
@@ -178,9 +180,9 @@
<RatingAction {asset} {onAction} />
{/if}
{#if !editorDisabled}
<!-- {#if showEditorButton}
<EditAction onAction={onEdit} />
{/if}
{/if} -->
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />

View File

@@ -255,12 +255,12 @@
});
};
const showEditor = () => {
if (assetViewerManager.isShowActivityPanel) {
assetViewerManager.isShowActivityPanel = false;
}
isShowEditor = !isShowEditor;
};
// const showEditor = () => {
// if (assetViewerManager.isShowActivityPanel) {
// assetViewerManager.isShowActivityPanel = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
@@ -431,7 +431,6 @@
onCopyImage={copyImage}
preAction={handlePreAction}
onAction={handleAction}
onEdit={showEditor}
{onUndoDelete}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}

View File

@@ -62,7 +62,7 @@
/>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
</HStack>
<Button shape="round" size="small" onclick={applyEdits} loading={editManager.isApplyingEdits}>{$t('save')}</Button>
<Button shape="round" size="small" onclick={applyEdits}>{$t('save')}</Button>
</HStack>
<section>