From 813d684aaa64cc658d04d47f8492b04215ec2e64 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 20 Mar 2026 14:14:07 -0400 Subject: [PATCH] fix: shared link add to album (#27063) --- server/package.json | 2 +- server/src/database.ts | 10 +- server/src/queries/shared.link.repository.sql | 2 + server/src/repositories/album.repository.ts | 1 + .../repositories/shared-link.repository.ts | 9 +- ...1773956345315-DuplicateSharedLinkAssets.ts | 13 ++ server/src/services/album.service.ts | 12 ++ server/src/services/asset-media.service.ts | 12 +- server/test/fixtures/auth.stub.ts | 1 + server/test/medium.factory.ts | 4 +- .../services/asset-media.service.spec.ts | 172 +++++++++++++++++- server/test/small.factory.ts | 12 +- web/src/lib/utils/file-uploader.ts | 2 +- 13 files changed, 230 insertions(+), 22 deletions(-) create mode 100644 server/src/schema/migrations/1773956345315-DuplicateSharedLinkAssets.ts diff --git a/server/package.json b/server/package.json index 0af90c4ae5..eb3bdfebfc 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,7 @@ "typeorm": "typeorm", "migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug", "migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", - "migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", + "migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create", "migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run", "migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert", "schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'", diff --git a/server/src/database.ts b/server/src/database.ts index 3e3192c21a..eb558c6d28 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -169,6 +169,7 @@ export type AuthSharedLink = { id: string; expiresAt: Date | null; userId: string; + albumId: string | null; showExif: boolean; allowUpload: boolean; allowDownload: boolean; @@ -357,15 +358,6 @@ export const columns = { authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authApiKey: ['api_key.id', 'api_key.permissions'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], - authSharedLink: [ - 'shared_link.id', - 'shared_link.userId', - 'shared_link.expiresAt', - 'shared_link.showExif', - 'shared_link.allowUpload', - 'shared_link.allowDownload', - 'shared_link.password', - ], user: userColumns, userWithPrefix: userWithPrefixColumns, userAdmin: [ diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 2630e384fc..f002110735 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -173,6 +173,7 @@ order by select "shared_link"."id", "shared_link"."userId", + "shared_link"."albumId", "shared_link"."expiresAt", "shared_link"."showExif", "shared_link"."allowUpload", @@ -211,6 +212,7 @@ where select "shared_link"."id", "shared_link"."userId", + "shared_link"."albumId", "shared_link"."expiresAt", "shared_link"."showExif", "shared_link"."allowUpload", diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 9a76b379ed..f74356c924 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -330,6 +330,7 @@ export class AlbumRepository { await db .insertInto('album_asset') .values(assetIds.map((assetId) => ({ albumId, assetId }))) + .onConflict((oc) => oc.doNothing()) .execute(); } diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index bc81e75c81..1ad5d7bd77 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -202,7 +202,14 @@ export class SharedLinkRepository { .leftJoin('album', 'album.id', 'shared_link.albumId') .where('album.deletedAt', 'is', null) .select((eb) => [ - ...columns.authSharedLink, + 'shared_link.id', + 'shared_link.userId', + 'shared_link.albumId', + 'shared_link.expiresAt', + 'shared_link.showExif', + 'shared_link.allowUpload', + 'shared_link.allowDownload', + 'shared_link.password', jsonObjectFrom( eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'), ).as('user'), diff --git a/server/src/schema/migrations/1773956345315-DuplicateSharedLinkAssets.ts b/server/src/schema/migrations/1773956345315-DuplicateSharedLinkAssets.ts new file mode 100644 index 0000000000..d3a83d53dc --- /dev/null +++ b/server/src/schema/migrations/1773956345315-DuplicateSharedLinkAssets.ts @@ -0,0 +1,13 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + DELETE FROM "shared_link_asset" + USING "shared_link" + WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM'; +`.execute(db); +} + +export async function down(): Promise { + // noop +} diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 24b9b165c9..547ec63bf8 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -165,6 +165,12 @@ export class AlbumService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + if (auth.sharedLink) { + this.logger.deprecate( + 'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.', + ); + } + const album = await this.findOrFail(id, { withAssets: false }); await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] }); @@ -195,6 +201,12 @@ export class AlbumService extends BaseService { } async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise { + if (auth.sharedLink) { + this.logger.deprecate( + 'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.', + ); + } + const results: AlbumsAddAssetsResponseDto = { success: false, error: BulkIdErrorReason.DUPLICATE, diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 3c981ea61e..10132bbb07 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; -import { Asset } from 'src/database'; +import { Asset, AuthSharedLink } from 'src/database'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, @@ -152,7 +152,7 @@ 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.addToSharedLink(auth.sharedLink, asset.id); } await this.userRepository.updateUsage(auth.user.id, file.size); @@ -326,6 +326,12 @@ export class AssetMediaService extends BaseService { }; } + private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) { + await (sharedLink.albumId + ? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId]) + : this.sharedLinkRepository.addAssets(sharedLink.id, [assetId])); + } + private async handleUploadError( error: any, auth: AuthDto, @@ -347,7 +353,7 @@ export class AssetMediaService extends BaseService { } if (auth.sharedLink) { - await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); + await this.addToSharedLink(auth.sharedLink, duplicateId); } return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 3e5825c0cc..85d52f14a1 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -48,6 +48,7 @@ export const authStub = { showExif: true, allowDownload: true, allowUpload: true, + albumId: null, expiresAt: null, password: null, userId: '42', diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 53bf78b5b8..a8aa00c2a3 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -220,9 +220,9 @@ export class MediumTestContext { return { result }; } - async newAlbum(dto: Insertable) { + async newAlbum(dto: Insertable, assetIds?: string[]) { const album = mediumFactory.albumInsert(dto); - const result = await this.get(AlbumRepository).create(album, [], []); + const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []); return { album, result }; } diff --git a/server/test/medium/specs/services/asset-media.service.spec.ts b/server/test/medium/specs/services/asset-media.service.spec.ts index cdd47e3dc4..f10844ca4a 100644 --- a/server/test/medium/specs/services/asset-media.service.spec.ts +++ b/server/test/medium/specs/services/asset-media.service.spec.ts @@ -1,12 +1,15 @@ import { Kysely } from 'kysely'; +import { randomBytes } from 'node:crypto'; import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto'; import { AssetMediaSize } from 'src/dtos/asset-media.dto'; -import { AssetFileType } from 'src/enum'; +import { AssetFileType, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; @@ -22,7 +25,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(AssetMediaService, { database: db || defaultDatabase, - real: [AccessRepository, AssetRepository, UserRepository], + real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository], mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], }); }; @@ -44,7 +47,6 @@ describe(AssetService.name, () => { const { asset } = await ctx.newAsset({ ownerId: user.id }); await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); const auth = factory.auth({ user: { id: user.id } }); - const file = mediumFactory.uploadFile(); await expect( sut.uploadAsset( @@ -56,7 +58,7 @@ describe(AssetService.name, () => { fileCreatedAt: new Date(), assetData: Buffer.from('some data'), }, - file, + mediumFactory.uploadFile(), ), ).resolves.toEqual({ id: expect.any(String), @@ -99,6 +101,168 @@ describe(AssetService.name, () => { status: AssetMediaStatus.CREATED, }); }); + + it('should add to a shared link', async () => { + const { sut, ctx } = setup(); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(50), + type: SharedLinkType.Individual, + description: 'Shared link description', + userId: user.id, + allowDownload: true, + allowUpload: true, + }); + + const auth = factory.auth({ user: { id: user.id }, sharedLink }); + const file = mediumFactory.uploadFile(); + const uploadDto = { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + }; + + const response = await sut.uploadAsset(auth, uploadDto, file); + expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED }); + + const update = await sharedLinkRepo.get(user.id, sharedLink.id); + const assets = update!.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ id: response.id }); + }); + + it('should handle adding a duplicate asset to a shared link', async () => { + const { sut, ctx } = setup(); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(50), + type: SharedLinkType.Individual, + description: 'Shared link description', + userId: user.id, + allowDownload: true, + allowUpload: true, + assetIds: [asset.id], + }); + + const auth = factory.auth({ user: { id: user.id }, sharedLink }); + const uploadDto = { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + }; + + const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum })); + expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE }); + + const update = await sharedLinkRepo.get(user.id, sharedLink.id); + const assets = update!.assets; + expect(assets).toHaveLength(1); + expect(assets[0]).toMatchObject({ id: response.id }); + }); + + it('should add to an album shared link', async () => { + const { sut, ctx } = setup(); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(50), + type: SharedLinkType.Album, + albumId: album.id, + description: 'Shared link description', + userId: user.id, + allowDownload: true, + allowUpload: true, + }); + + const auth = factory.auth({ user: { id: user.id }, sharedLink }); + const uploadDto = { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + }; + + const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile()); + expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED }); + + const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]); + const assets = [...result]; + expect(assets).toHaveLength(1); + expect(assets[0]).toEqual(response.id); + }); + + it('should handle adding a duplicate asset to an album shared link', async () => { + const { sut, ctx } = setup(); + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]); + // await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(50), + type: SharedLinkType.Album, + albumId: album.id, + description: 'Shared link description', + userId: user.id, + allowDownload: true, + allowUpload: true, + }); + + const auth = factory.auth({ user: { id: user.id }, sharedLink }); + const uploadDto = { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + }; + + const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum })); + expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE }); + + const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]); + const assets = [...result]; + expect(assets).toHaveLength(1); + expect(assets[0]).toEqual(response.id); + }); }); describe('viewThumbnail', () => { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 57098e01ee..e4001d18ab 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial = {}) => { expiresAt = null, userId = newUuid(), showExif = true, + albumId = null, allowUpload = false, allowDownload = true, password = null, } = sharedLink; - return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password }; + return { + id, + albumId, + expiresAt, + userId, + showExif, + allowUpload, + allowDownload, + password, + }; }; const authApiKeyFactory = (apiKey: Partial = {}) => ({ diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index f2a4cdec4f..2950563a85 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -216,7 +216,7 @@ async function fileUploader({ uploadAssetsStore.track('success'); } - if (albumId) { + if (albumId && !authManager.isSharedLink) { uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') }); await addAssetsToAlbums([albumId], [responseData.id], { notify: false }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });