mirror of
https://github.com/immich-app/immich.git
synced 2026-02-04 08:49:01 +03:00
fix: shared-link-mapper (#24794)
This commit is contained in:
@@ -20,7 +20,6 @@ describe('/shared-links', () => {
|
|||||||
let user1: LoginResponseDto;
|
let user1: LoginResponseDto;
|
||||||
let user2: LoginResponseDto;
|
let user2: LoginResponseDto;
|
||||||
let album: AlbumResponseDto;
|
let album: AlbumResponseDto;
|
||||||
let metadataAlbum: AlbumResponseDto;
|
|
||||||
let deletedAlbum: AlbumResponseDto;
|
let deletedAlbum: AlbumResponseDto;
|
||||||
let linkWithDeletedAlbum: SharedLinkResponseDto;
|
let linkWithDeletedAlbum: SharedLinkResponseDto;
|
||||||
let linkWithPassword: SharedLinkResponseDto;
|
let linkWithPassword: SharedLinkResponseDto;
|
||||||
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
|
|||||||
|
|
||||||
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
|
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
|
||||||
|
|
||||||
[album, deletedAlbum, metadataAlbum] = await Promise.all([
|
[album, deletedAlbum] = await Promise.all([
|
||||||
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
||||||
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
||||||
createAlbum(
|
|
||||||
{
|
|
||||||
createAlbumDto: {
|
|
||||||
albumName: 'metadata album',
|
|
||||||
assetIds: [asset1.id],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ headers: asBearerAuth(user1.accessToken) },
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
||||||
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
|
|||||||
password: 'foo',
|
password: 'foo',
|
||||||
}),
|
}),
|
||||||
utils.createSharedLink(user1.accessToken, {
|
utils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Album,
|
type: SharedLinkType.Individual,
|
||||||
albumId: metadataAlbum.id,
|
assetIds: [asset1.id],
|
||||||
showMetadata: true,
|
showMetadata: true,
|
||||||
slug: 'metadata-album',
|
slug: 'metadata-slug',
|
||||||
}),
|
}),
|
||||||
utils.createSharedLink(user1.accessToken, {
|
utils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Album,
|
type: SharedLinkType.Individual,
|
||||||
albumId: metadataAlbum.id,
|
assetIds: [asset1.id],
|
||||||
showMetadata: false,
|
showMetadata: false,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
|
|||||||
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
||||||
expect(resp.status).toBe(200);
|
expect(resp.status).toBe(200);
|
||||||
expect(resp.header['content-type']).toContain('text/html');
|
expect(resp.header['content-type']).toContain('text/html');
|
||||||
expect(resp.text).toContain(
|
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct asset count in meta tag for empty album', async () => {
|
it('should have correct asset count in meta tag for empty album', async () => {
|
||||||
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
|
|||||||
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
|
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
|
||||||
expect(resp.status).toBe(200);
|
expect(resp.status).toBe(200);
|
||||||
expect(resp.header['content-type']).toContain('text/html');
|
expect(resp.header['content-type']).toContain('text/html');
|
||||||
expect(resp.text).toContain(
|
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return metadata for album shared link', async () => {
|
it('should return metadata for individual shared link', async () => {
|
||||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
|
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.assets).toHaveLength(0);
|
expect(body.assets).toHaveLength(1);
|
||||||
expect(body.album).toBeDefined();
|
expect(body.album).not.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not return metadata for album shared link without metadata', async () => {
|
it('should not return metadata for album shared link without metadata', async () => {
|
||||||
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
|
|||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.assets).toHaveLength(1);
|
expect(body.assets).toHaveLength(1);
|
||||||
expect(body.album).toBeDefined();
|
expect(body.album).not.toBeDefined();
|
||||||
|
|
||||||
const asset = body.assets[0];
|
const asset = body.assets[0];
|
||||||
expect(asset).not.toHaveProperty('exifInfo');
|
expect(asset).not.toHaveProperty('exifInfo');
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
import _ from 'lodash';
|
|
||||||
import { SharedLink } from 'src/database';
|
import { SharedLink } from 'src/database';
|
||||||
import { HistoryBuilder, Property } from 'src/decorators';
|
import { HistoryBuilder, Property } from 'src/decorators';
|
||||||
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
|
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
|
||||||
@@ -118,10 +117,10 @@ export class SharedLinkResponseDto {
|
|||||||
slug!: string | null;
|
slug!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
|
export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto {
|
||||||
const linkAssets = sharedLink.assets || [];
|
const assets = sharedLink.assets || [];
|
||||||
|
|
||||||
return {
|
const response = {
|
||||||
id: sharedLink.id,
|
id: sharedLink.id,
|
||||||
description: sharedLink.description,
|
description: sharedLink.description,
|
||||||
password: sharedLink.password,
|
password: sharedLink.password,
|
||||||
@@ -130,35 +129,19 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
|
|||||||
type: sharedLink.type,
|
type: sharedLink.type,
|
||||||
createdAt: sharedLink.createdAt,
|
createdAt: sharedLink.createdAt,
|
||||||
expiresAt: sharedLink.expiresAt,
|
expiresAt: sharedLink.expiresAt,
|
||||||
assets: linkAssets.map((asset) => mapAsset(asset)),
|
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
|
||||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
|
||||||
allowUpload: sharedLink.allowUpload,
|
|
||||||
allowDownload: sharedLink.allowDownload,
|
|
||||||
showMetadata: sharedLink.showExif,
|
|
||||||
slug: sharedLink.slug,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLinkResponseDto {
|
|
||||||
const linkAssets = sharedLink.assets || [];
|
|
||||||
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
|
|
||||||
|
|
||||||
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: sharedLink.id,
|
|
||||||
description: sharedLink.description,
|
|
||||||
password: sharedLink.password,
|
|
||||||
userId: sharedLink.userId,
|
|
||||||
key: sharedLink.key.toString('base64url'),
|
|
||||||
type: sharedLink.type,
|
|
||||||
createdAt: sharedLink.createdAt,
|
|
||||||
expiresAt: sharedLink.expiresAt,
|
|
||||||
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })),
|
|
||||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||||
allowUpload: sharedLink.allowUpload,
|
allowUpload: sharedLink.allowUpload,
|
||||||
allowDownload: sharedLink.allowDownload,
|
allowDownload: sharedLink.allowDownload,
|
||||||
showMetadata: sharedLink.showExif,
|
showMetadata: sharedLink.showExif,
|
||||||
slug: sharedLink.slug,
|
slug: sharedLink.slug,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// unless we select sharedLink.album.sharedLinks this will be wrong
|
||||||
|
if (response.album) {
|
||||||
|
response.album.hasSharedLink = true;
|
||||||
|
response.album.shared = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ describe(SharedLinkService.name, () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
||||||
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
|
const response = await sut.getMine(authDto, {});
|
||||||
|
expect(response.assets[0]).toMatchObject({ hasMetadata: false });
|
||||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { AssetIdsDto } from 'src/dtos/asset.dto';
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
mapSharedLink,
|
mapSharedLink,
|
||||||
mapSharedLinkWithoutMetadata,
|
|
||||||
SharedLinkCreateDto,
|
SharedLinkCreateDto,
|
||||||
SharedLinkEditDto,
|
SharedLinkEditDto,
|
||||||
SharedLinkPasswordDto,
|
SharedLinkPasswordDto,
|
||||||
@@ -22,7 +21,7 @@ export class SharedLinkService extends BaseService {
|
|||||||
async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
|
async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
|
||||||
return this.sharedLinkRepository
|
return this.sharedLinkRepository
|
||||||
.getAll({ userId: auth.user.id, id, albumId })
|
.getAll({ userId: auth.user.id, id, albumId })
|
||||||
.then((links) => links.map((link) => mapSharedLink(link)));
|
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
|
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
|
||||||
@@ -31,7 +30,7 @@ export class SharedLinkService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
|
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
|
||||||
const response = this.mapToSharedLink(sharedLink, { withExif: sharedLink.showExif });
|
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
|
||||||
if (sharedLink.password) {
|
if (sharedLink.password) {
|
||||||
response.token = this.validateAndRefreshToken(sharedLink, dto);
|
response.token = this.validateAndRefreshToken(sharedLink, dto);
|
||||||
}
|
}
|
||||||
@@ -41,7 +40,7 @@ export class SharedLinkService extends BaseService {
|
|||||||
|
|
||||||
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
|
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
|
||||||
const sharedLink = await this.findOrFail(auth.user.id, id);
|
const sharedLink = await this.findOrFail(auth.user.id, id);
|
||||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
|
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
|
||||||
@@ -81,7 +80,7 @@ export class SharedLinkService extends BaseService {
|
|||||||
slug: dto.slug || null,
|
slug: dto.slug || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleError(error);
|
this.handleError(error);
|
||||||
}
|
}
|
||||||
@@ -108,7 +107,7 @@ export class SharedLinkService extends BaseService {
|
|||||||
showExif: dto.showMetadata,
|
showExif: dto.showMetadata,
|
||||||
slug: dto.slug || null,
|
slug: dto.slug || null,
|
||||||
});
|
});
|
||||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleError(error);
|
this.handleError(error);
|
||||||
}
|
}
|
||||||
@@ -214,10 +213,6 @@ export class SharedLinkService extends BaseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapToSharedLink(sharedLink: SharedLink, { withExif }: { withExif: boolean }) {
|
|
||||||
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
|
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
|
||||||
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
||||||
const sharedLinkTokens = dto.token?.split(',') || [];
|
const sharedLinkTokens = dto.token?.split(',') || [];
|
||||||
|
|||||||
267
server/test/fixtures/shared-link.stub.ts
vendored
267
server/test/fixtures/shared-link.stub.ts
vendored
@@ -1,10 +1,7 @@
|
|||||||
import { UserAdmin } from 'src/database';
|
import { UserAdmin } from 'src/database';
|
||||||
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetResponseDto, MapAsset } from 'src/dtos/asset-response.dto';
|
|
||||||
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
|
||||||
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
|
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
|
||||||
import { mapUser } from 'src/dtos/user.dto';
|
import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
|
||||||
import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
|
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
@@ -20,89 +17,6 @@ const sharedLinkBytes = Buffer.from(
|
|||||||
'hex',
|
'hex',
|
||||||
);
|
);
|
||||||
|
|
||||||
const assetInfo: ExifResponseDto = {
|
|
||||||
make: 'camera-make',
|
|
||||||
model: 'camera-model',
|
|
||||||
exifImageWidth: 500,
|
|
||||||
exifImageHeight: 500,
|
|
||||||
fileSizeInByte: 100,
|
|
||||||
orientation: 'orientation',
|
|
||||||
dateTimeOriginal: today,
|
|
||||||
modifyDate: today,
|
|
||||||
timeZone: 'America/Los_Angeles',
|
|
||||||
lensModel: 'fancy',
|
|
||||||
fNumber: 100,
|
|
||||||
focalLength: 100,
|
|
||||||
iso: 100,
|
|
||||||
exposureTime: '1/16',
|
|
||||||
latitude: 100,
|
|
||||||
longitude: 100,
|
|
||||||
city: 'city',
|
|
||||||
state: 'state',
|
|
||||||
country: 'country',
|
|
||||||
description: 'description',
|
|
||||||
projectionType: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const assetResponse: AssetResponseDto = {
|
|
||||||
id: 'id_1',
|
|
||||||
createdAt: today,
|
|
||||||
deviceAssetId: 'device_asset_id_1',
|
|
||||||
ownerId: 'user_id_1',
|
|
||||||
deviceId: 'device_id_1',
|
|
||||||
type: AssetType.Video,
|
|
||||||
originalMimeType: 'image/jpeg',
|
|
||||||
originalPath: 'fake_path/jpeg',
|
|
||||||
originalFileName: 'asset_1.jpeg',
|
|
||||||
thumbhash: null,
|
|
||||||
fileModifiedAt: today,
|
|
||||||
isOffline: false,
|
|
||||||
fileCreatedAt: today,
|
|
||||||
localDateTime: today,
|
|
||||||
updatedAt: today,
|
|
||||||
isFavorite: false,
|
|
||||||
isArchived: false,
|
|
||||||
duration: '0:00:00.00000',
|
|
||||||
exifInfo: assetInfo,
|
|
||||||
livePhotoVideoId: null,
|
|
||||||
tags: [],
|
|
||||||
people: [],
|
|
||||||
checksum: 'ZmlsZSBoYXNo',
|
|
||||||
isTrashed: false,
|
|
||||||
libraryId: 'library-id',
|
|
||||||
hasMetadata: true,
|
|
||||||
visibility: AssetVisibility.Timeline,
|
|
||||||
};
|
|
||||||
|
|
||||||
const assetResponseWithoutMetadata = {
|
|
||||||
id: 'id_1',
|
|
||||||
type: AssetType.Video,
|
|
||||||
originalMimeType: 'image/jpeg',
|
|
||||||
thumbhash: null,
|
|
||||||
localDateTime: today,
|
|
||||||
duration: '0:00:00.00000',
|
|
||||||
livePhotoVideoId: null,
|
|
||||||
hasMetadata: false,
|
|
||||||
} as AssetResponseDto;
|
|
||||||
|
|
||||||
const albumResponse: AlbumResponseDto = {
|
|
||||||
albumName: 'Test Album',
|
|
||||||
description: '',
|
|
||||||
albumThumbnailAssetId: null,
|
|
||||||
createdAt: today,
|
|
||||||
updatedAt: today,
|
|
||||||
id: 'album-123',
|
|
||||||
ownerId: 'admin_id',
|
|
||||||
owner: mapUser(userStub.admin),
|
|
||||||
albumUsers: [],
|
|
||||||
shared: false,
|
|
||||||
hasSharedLink: false,
|
|
||||||
assets: [],
|
|
||||||
assetCount: 1,
|
|
||||||
isActivityEnabled: true,
|
|
||||||
order: AssetOrder.Desc,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sharedLinkStub = {
|
export const sharedLinkStub = {
|
||||||
individual: Object.freeze({
|
individual: Object.freeze({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -161,7 +75,7 @@ export const sharedLinkStub = {
|
|||||||
id: '123',
|
id: '123',
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
key: sharedLinkBytes,
|
key: sharedLinkBytes,
|
||||||
type: SharedLinkType.Album,
|
type: SharedLinkType.Individual,
|
||||||
createdAt: today,
|
createdAt: today,
|
||||||
expiresAt: tomorrow,
|
expiresAt: tomorrow,
|
||||||
allowUpload: false,
|
allowUpload: false,
|
||||||
@@ -169,97 +83,80 @@ export const sharedLinkStub = {
|
|||||||
showExif: false,
|
showExif: false,
|
||||||
description: null,
|
description: null,
|
||||||
password: null,
|
password: null,
|
||||||
assets: [],
|
assets: [
|
||||||
slug: null,
|
{
|
||||||
albumId: 'album-123',
|
id: 'id_1',
|
||||||
album: {
|
status: AssetStatus.Active,
|
||||||
id: 'album-123',
|
owner: undefined as unknown as UserAdmin,
|
||||||
updateId: '42',
|
ownerId: 'user_id_1',
|
||||||
ownerId: authStub.admin.user.id,
|
deviceAssetId: 'device_asset_id_1',
|
||||||
owner: userStub.admin,
|
deviceId: 'device_id_1',
|
||||||
albumName: 'Test Album',
|
type: AssetType.Video,
|
||||||
description: '',
|
originalPath: 'fake_path/jpeg',
|
||||||
createdAt: today,
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
updatedAt: today,
|
fileModifiedAt: today,
|
||||||
deletedAt: null,
|
fileCreatedAt: today,
|
||||||
albumThumbnailAsset: null,
|
localDateTime: today,
|
||||||
albumThumbnailAssetId: null,
|
createdAt: today,
|
||||||
albumUsers: [],
|
updatedAt: today,
|
||||||
sharedLinks: [],
|
isFavorite: false,
|
||||||
isActivityEnabled: true,
|
isArchived: false,
|
||||||
order: AssetOrder.Desc,
|
isExternal: false,
|
||||||
assets: [
|
isOffline: false,
|
||||||
{
|
files: [],
|
||||||
id: 'id_1',
|
thumbhash: null,
|
||||||
status: AssetStatus.Active,
|
encodedVideoPath: '',
|
||||||
owner: undefined as unknown as UserAdmin,
|
duration: null,
|
||||||
ownerId: 'user_id_1',
|
livePhotoVideo: null,
|
||||||
deviceAssetId: 'device_asset_id_1',
|
livePhotoVideoId: null,
|
||||||
deviceId: 'device_id_1',
|
originalFileName: 'asset_1.jpeg',
|
||||||
type: AssetType.Video,
|
exifInfo: {
|
||||||
originalPath: 'fake_path/jpeg',
|
projectionType: null,
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
livePhotoCID: null,
|
||||||
fileModifiedAt: today,
|
assetId: 'id_1',
|
||||||
fileCreatedAt: today,
|
description: 'description',
|
||||||
localDateTime: today,
|
exifImageWidth: 500,
|
||||||
createdAt: today,
|
exifImageHeight: 500,
|
||||||
|
fileSizeInByte: 100,
|
||||||
|
orientation: 'orientation',
|
||||||
|
dateTimeOriginal: today,
|
||||||
|
modifyDate: today,
|
||||||
|
timeZone: 'America/Los_Angeles',
|
||||||
|
latitude: 100,
|
||||||
|
longitude: 100,
|
||||||
|
city: 'city',
|
||||||
|
state: 'state',
|
||||||
|
country: 'country',
|
||||||
|
make: 'camera-make',
|
||||||
|
model: 'camera-model',
|
||||||
|
lensModel: 'fancy',
|
||||||
|
fNumber: 100,
|
||||||
|
focalLength: 100,
|
||||||
|
iso: 100,
|
||||||
|
exposureTime: '1/16',
|
||||||
|
fps: 100,
|
||||||
|
profileDescription: 'sRGB',
|
||||||
|
bitsPerSample: 8,
|
||||||
|
colorspace: 'sRGB',
|
||||||
|
autoStackId: null,
|
||||||
|
rating: 3,
|
||||||
updatedAt: today,
|
updatedAt: today,
|
||||||
isFavorite: false,
|
|
||||||
isArchived: false,
|
|
||||||
isExternal: false,
|
|
||||||
isOffline: false,
|
|
||||||
files: [],
|
|
||||||
thumbhash: null,
|
|
||||||
encodedVideoPath: '',
|
|
||||||
duration: null,
|
|
||||||
livePhotoVideo: null,
|
|
||||||
livePhotoVideoId: null,
|
|
||||||
originalFileName: 'asset_1.jpeg',
|
|
||||||
exifInfo: {
|
|
||||||
projectionType: null,
|
|
||||||
livePhotoCID: null,
|
|
||||||
assetId: 'id_1',
|
|
||||||
description: 'description',
|
|
||||||
exifImageWidth: 500,
|
|
||||||
exifImageHeight: 500,
|
|
||||||
fileSizeInByte: 100,
|
|
||||||
orientation: 'orientation',
|
|
||||||
dateTimeOriginal: today,
|
|
||||||
modifyDate: today,
|
|
||||||
timeZone: 'America/Los_Angeles',
|
|
||||||
latitude: 100,
|
|
||||||
longitude: 100,
|
|
||||||
city: 'city',
|
|
||||||
state: 'state',
|
|
||||||
country: 'country',
|
|
||||||
make: 'camera-make',
|
|
||||||
model: 'camera-model',
|
|
||||||
lensModel: 'fancy',
|
|
||||||
fNumber: 100,
|
|
||||||
focalLength: 100,
|
|
||||||
iso: 100,
|
|
||||||
exposureTime: '1/16',
|
|
||||||
fps: 100,
|
|
||||||
profileDescription: 'sRGB',
|
|
||||||
bitsPerSample: 8,
|
|
||||||
colorspace: 'sRGB',
|
|
||||||
autoStackId: null,
|
|
||||||
rating: 3,
|
|
||||||
updatedAt: today,
|
|
||||||
updateId: '42',
|
|
||||||
},
|
|
||||||
sharedLinks: [],
|
|
||||||
faces: [],
|
|
||||||
sidecarPath: null,
|
|
||||||
deletedAt: null,
|
|
||||||
duplicateId: null,
|
|
||||||
updateId: '42',
|
updateId: '42',
|
||||||
libraryId: null,
|
|
||||||
stackId: null,
|
|
||||||
visibility: AssetVisibility.Timeline,
|
|
||||||
},
|
},
|
||||||
],
|
sharedLinks: [],
|
||||||
},
|
faces: [],
|
||||||
|
sidecarPath: null,
|
||||||
|
deletedAt: null,
|
||||||
|
duplicateId: null,
|
||||||
|
updateId: '42',
|
||||||
|
libraryId: null,
|
||||||
|
stackId: null,
|
||||||
|
visibility: AssetVisibility.Timeline,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
albumId: null,
|
||||||
|
album: null,
|
||||||
|
slug: null,
|
||||||
}),
|
}),
|
||||||
passwordRequired: Object.freeze({
|
passwordRequired: Object.freeze({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -312,20 +209,4 @@ export const sharedLinkResponseStub = {
|
|||||||
userId: 'admin_id',
|
userId: 'admin_id',
|
||||||
slug: null,
|
slug: null,
|
||||||
}),
|
}),
|
||||||
readonlyNoMetadata: Object.freeze<SharedLinkResponseDto>({
|
|
||||||
id: '123',
|
|
||||||
userId: 'admin_id',
|
|
||||||
key: sharedLinkBytes.toString('base64url'),
|
|
||||||
type: SharedLinkType.Album,
|
|
||||||
createdAt: today,
|
|
||||||
expiresAt: tomorrow,
|
|
||||||
description: null,
|
|
||||||
password: null,
|
|
||||||
allowUpload: false,
|
|
||||||
allowDownload: false,
|
|
||||||
showMetadata: false,
|
|
||||||
slug: null,
|
|
||||||
album: { ...albumResponse, startDate: assetResponse.localDateTime, endDate: assetResponse.localDateTime },
|
|
||||||
assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }],
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
AlbumGroupBy,
|
AlbumGroupBy,
|
||||||
AlbumSortBy,
|
AlbumSortBy,
|
||||||
AlbumViewMode,
|
AlbumViewMode,
|
||||||
SortOrder,
|
|
||||||
locale,
|
locale,
|
||||||
|
SortOrder,
|
||||||
type AlbumViewSettings,
|
type AlbumViewSettings,
|
||||||
} from '$lib/stores/preferences.store';
|
} from '$lib/stores/preferences.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
@@ -23,7 +23,12 @@
|
|||||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||||
import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
|
import {
|
||||||
|
addUsersToAlbum,
|
||||||
|
type AlbumResponseDto,
|
||||||
|
type AlbumUserAddDto,
|
||||||
|
type SharedLinkResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
|
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
|
||||||
import { groupBy } from 'lodash-es';
|
import { groupBy } from 'lodash-es';
|
||||||
@@ -208,12 +213,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'sharedLink': {
|
case 'sharedLink': {
|
||||||
const success = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
|
await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
|
||||||
if (success) {
|
|
||||||
selectedAlbum.shared = true;
|
|
||||||
selectedAlbum.hasSharedLink = true;
|
|
||||||
onUpdate(selectedAlbum);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,9 +274,15 @@
|
|||||||
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
|
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
|
||||||
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
|
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
|
||||||
|
if (sharedLink.album) {
|
||||||
|
onUpdate(sharedLink.album);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<OnEvents {onAlbumUpdate} {onAlbumDelete} />
|
<OnEvents {onAlbumUpdate} {onAlbumDelete} {onSharedLinkCreate} />
|
||||||
|
|
||||||
{#if albums.length > 0}
|
{#if albums.length > 0}
|
||||||
{#if userSettings.view === AlbumViewMode.Cover}
|
{#if userSettings.view === AlbumViewMode.Cover}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
|
||||||
import { IconButton, modalManager } from '@immich/ui';
|
|
||||||
import { mdiShareVariantOutline } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
asset: AssetResponseDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { asset }: Props = $props();
|
|
||||||
|
|
||||||
const handleClick = async () => {
|
|
||||||
await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
color="secondary"
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
icon={mdiShareVariantOutline}
|
|
||||||
onclick={handleClick}
|
|
||||||
aria-label={$t('share')}
|
|
||||||
/>
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import CastButton from '$lib/cast/cast-button.svelte';
|
import CastButton from '$lib/cast/cast-button.svelte';
|
||||||
|
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
||||||
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
||||||
@@ -18,14 +19,13 @@
|
|||||||
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
|
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
|
||||||
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
|
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
|
||||||
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
|
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
|
||||||
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
|
|
||||||
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
|
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
|
||||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
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 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 MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { handleReplaceAsset } from '$lib/services/asset.service';
|
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
@@ -113,6 +113,8 @@
|
|||||||
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
|
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
|
||||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||||
|
|
||||||
|
const { Share } = $derived(getAssetActions($t, asset));
|
||||||
|
|
||||||
// $: showEditorButton =
|
// $: showEditorButton =
|
||||||
// isOwner &&
|
// isOwner &&
|
||||||
// asset.type === AssetTypeEnum.Image &&
|
// asset.type === AssetTypeEnum.Image &&
|
||||||
@@ -135,9 +137,7 @@
|
|||||||
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||||
<CastButton />
|
<CastButton />
|
||||||
|
|
||||||
{#if !asset.isTrashed && $user && !isLocked}
|
<ActionButton action={Share} />
|
||||||
<ShareAction {asset} />
|
|
||||||
{/if}
|
|
||||||
{#if asset.isOffline}
|
{#if asset.isOffline}
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: (success?: boolean) => void;
|
onClose: () => void;
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
assetIds?: string[];
|
assetIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onClose, albumId = $bindable(), assetIds = $bindable([]) }: Props = $props();
|
let { onClose, albumId, assetIds }: Props = $props();
|
||||||
|
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
let allowDownload = $state(true);
|
let allowDownload = $state(true);
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
slug,
|
slug,
|
||||||
});
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
onClose(true);
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||||
|
import { user as authUser } from '$lib/stores/user.store';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { copyAsset, deleteAssets } from '@immich/sdk';
|
import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { modalManager, type ActionItem } from '@immich/ui';
|
||||||
|
import { mdiShareVariantOutline } from '@mdi/js';
|
||||||
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
|
||||||
|
const Share: ActionItem = {
|
||||||
|
title: $t('share'),
|
||||||
|
icon: mdiShareVariantOutline,
|
||||||
|
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
|
||||||
|
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { Share };
|
||||||
|
};
|
||||||
|
|
||||||
export const handleReplaceAsset = async (oldAssetId: string) => {
|
export const handleReplaceAsset = async (oldAssetId: string) => {
|
||||||
const [newAssetId] = await openFileUploadDialog({ multiple: false });
|
const [newAssetId] = await openFileUploadDialog({ multiple: false });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { handleError } from '$lib/utils/handle-error';
|
|||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import {
|
import {
|
||||||
createSharedLink,
|
createSharedLink,
|
||||||
|
getSharedLinkById,
|
||||||
removeSharedLink,
|
removeSharedLink,
|
||||||
removeSharedLinkAssets,
|
removeSharedLinkAssets,
|
||||||
updateSharedLink,
|
updateSharedLink,
|
||||||
@@ -58,7 +59,11 @@ export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => {
|
|||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
|
let sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
|
||||||
|
if (dto.albumId) {
|
||||||
|
// fetch album details, for event
|
||||||
|
sharedLink = await getSharedLinkById({ id: sharedLink.id });
|
||||||
|
}
|
||||||
|
|
||||||
eventManager.emit('SharedLinkCreate', sharedLink);
|
eventManager.emit('SharedLinkCreate', sharedLink);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user