2023-10-29 02:35:38 +01:00
|
|
|
import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
2024-03-20 21:20:38 +01:00
|
|
|
import { AccessCore, Permission } from 'src/cores/access.core';
|
2024-03-20 23:53:07 +01:00
|
|
|
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
|
|
|
|
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
|
|
|
import { AuthDto } from 'src/dtos/auth.dto';
|
2024-03-20 19:32:04 +01:00
|
|
|
import {
|
2024-03-20 23:53:07 +01:00
|
|
|
SharedLinkCreateDto,
|
|
|
|
SharedLinkEditDto,
|
|
|
|
SharedLinkPasswordDto,
|
2024-03-20 19:32:04 +01:00
|
|
|
SharedLinkResponseDto,
|
|
|
|
mapSharedLink,
|
|
|
|
mapSharedLinkWithoutMetadata,
|
2024-03-20 23:53:07 +01:00
|
|
|
} from 'src/dtos/shared-link.dto';
|
2024-03-20 22:02:51 +01:00
|
|
|
import { AssetEntity } from 'src/entities/asset.entity';
|
|
|
|
import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entity';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
|
|
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
|
|
|
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
2024-03-21 04:15:09 +01:00
|
|
|
import { OpenGraphTags } from 'src/utils/misc';
|
2023-06-02 04:09:57 +02:00
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class SharedLinkService {
|
2023-06-28 15:56:24 +02:00
|
|
|
private access: AccessCore;
|
|
|
|
|
2023-06-21 03:08:43 +02:00
|
|
|
constructor(
|
2023-06-28 15:56:24 +02:00
|
|
|
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
2023-06-21 03:08:43 +02:00
|
|
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
|
|
|
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
|
2023-06-28 15:56:24 +02:00
|
|
|
) {
|
2023-10-23 14:37:51 +02:00
|
|
|
this.access = AccessCore.create(accessRepository);
|
2023-06-28 15:56:24 +02:00
|
|
|
}
|
2023-06-02 04:09:57 +02:00
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> {
|
2024-02-02 04:18:00 +01:00
|
|
|
return this.repository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link)));
|
2023-06-02 04:09:57 +02:00
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
|
|
|
|
if (!auth.sharedLink) {
|
2023-06-02 04:09:57 +02:00
|
|
|
throw new ForbiddenException();
|
|
|
|
}
|
|
|
|
|
2023-12-11 20:37:47 +01:00
|
|
|
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
|
2024-02-02 04:18:00 +01:00
|
|
|
const response = this.mapToSharedLink(sharedLink, { withExif: sharedLink.showExif });
|
2023-10-29 02:35:38 +01:00
|
|
|
if (sharedLink.password) {
|
2023-12-10 05:34:12 +01:00
|
|
|
response.token = this.validateAndRefreshToken(sharedLink, dto);
|
2023-10-29 02:35:38 +01:00
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
return response;
|
2023-06-02 04:09:57 +02:00
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
|
2023-12-11 20:37:47 +01:00
|
|
|
const sharedLink = await this.findOrFail(auth.user.id, id);
|
2024-02-02 04:18:00 +01:00
|
|
|
return this.mapToSharedLink(sharedLink, { withExif: true });
|
2023-06-02 04:09:57 +02:00
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
|
2023-06-21 03:08:43 +02:00
|
|
|
switch (dto.type) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case SharedLinkType.ALBUM: {
|
2023-06-21 03:08:43 +02:00
|
|
|
if (!dto.albumId) {
|
|
|
|
throw new BadRequestException('Invalid albumId');
|
|
|
|
}
|
2023-12-10 05:34:12 +01:00
|
|
|
await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId);
|
2023-06-21 03:08:43 +02:00
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-06-21 03:08:43 +02:00
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
case SharedLinkType.INDIVIDUAL: {
|
2023-06-21 03:08:43 +02:00
|
|
|
if (!dto.assetIds || dto.assetIds.length === 0) {
|
|
|
|
throw new BadRequestException('Invalid assetIds');
|
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds);
|
2023-06-21 03:08:43 +02:00
|
|
|
|
|
|
|
break;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-06-21 03:08:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
const sharedLink = await this.repository.create({
|
|
|
|
key: this.cryptoRepository.randomBytes(50),
|
2023-12-10 05:34:12 +01:00
|
|
|
userId: auth.user.id,
|
2023-06-21 03:08:43 +02:00
|
|
|
type: dto.type,
|
|
|
|
albumId: dto.albumId || null,
|
2023-08-28 21:41:57 +02:00
|
|
|
assets: (dto.assetIds || []).map((id) => ({ id }) as AssetEntity),
|
2023-06-21 03:08:43 +02:00
|
|
|
description: dto.description || null,
|
2023-10-29 02:35:38 +01:00
|
|
|
password: dto.password,
|
2023-06-21 03:08:43 +02:00
|
|
|
expiresAt: dto.expiresAt || null,
|
|
|
|
allowUpload: dto.allowUpload ?? true,
|
|
|
|
allowDownload: dto.allowDownload ?? true,
|
2023-10-14 03:46:30 +02:00
|
|
|
showExif: dto.showMetadata ?? true,
|
2023-06-21 03:08:43 +02:00
|
|
|
});
|
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
return this.mapToSharedLink(sharedLink, { withExif: true });
|
2023-06-21 03:08:43 +02:00
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) {
|
2023-12-11 20:37:47 +01:00
|
|
|
await this.findOrFail(auth.user.id, id);
|
2023-06-02 04:09:57 +02:00
|
|
|
const sharedLink = await this.repository.update({
|
|
|
|
id,
|
2023-12-10 05:34:12 +01:00
|
|
|
userId: auth.user.id,
|
2023-06-02 04:09:57 +02:00
|
|
|
description: dto.description,
|
2023-10-29 02:35:38 +01:00
|
|
|
password: dto.password,
|
2023-10-22 17:05:10 +02:00
|
|
|
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
|
2023-06-02 04:09:57 +02:00
|
|
|
allowUpload: dto.allowUpload,
|
|
|
|
allowDownload: dto.allowDownload,
|
2023-10-14 03:46:30 +02:00
|
|
|
showExif: dto.showMetadata,
|
2023-06-02 04:09:57 +02:00
|
|
|
});
|
2024-02-02 04:18:00 +01:00
|
|
|
return this.mapToSharedLink(sharedLink, { withExif: true });
|
2023-06-02 04:09:57 +02:00
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
async remove(auth: AuthDto, id: string): Promise<void> {
|
2023-12-11 20:37:47 +01:00
|
|
|
const sharedLink = await this.findOrFail(auth.user.id, id);
|
2023-06-02 04:09:57 +02:00
|
|
|
await this.repository.remove(sharedLink);
|
|
|
|
}
|
|
|
|
|
2023-12-11 20:37:47 +01:00
|
|
|
// TODO: replace `userId` with permissions and access control checks
|
|
|
|
private async findOrFail(userId: string, id: string) {
|
|
|
|
const sharedLink = await this.repository.get(userId, id);
|
2023-06-02 04:09:57 +02:00
|
|
|
if (!sharedLink) {
|
|
|
|
throw new BadRequestException('Shared link not found');
|
|
|
|
}
|
|
|
|
return sharedLink;
|
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
2023-12-11 20:37:47 +01:00
|
|
|
const sharedLink = await this.findOrFail(auth.user.id, id);
|
2023-06-21 03:08:43 +02:00
|
|
|
|
|
|
|
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
|
|
|
|
throw new BadRequestException('Invalid shared link type');
|
|
|
|
}
|
|
|
|
|
2023-11-23 05:04:52 +01:00
|
|
|
const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id));
|
|
|
|
const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId));
|
2023-12-10 05:34:12 +01:00
|
|
|
const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds);
|
2023-11-23 05:04:52 +01:00
|
|
|
|
2023-06-21 03:08:43 +02:00
|
|
|
const results: AssetIdsResponseDto[] = [];
|
|
|
|
for (const assetId of dto.assetIds) {
|
2023-11-23 05:04:52 +01:00
|
|
|
const hasAsset = existingAssetIds.has(assetId);
|
2023-06-21 03:08:43 +02:00
|
|
|
if (hasAsset) {
|
|
|
|
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2023-11-23 05:04:52 +01:00
|
|
|
const hasAccess = allowedAssetIds.has(assetId);
|
2023-06-21 03:08:43 +02:00
|
|
|
if (!hasAccess) {
|
|
|
|
results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION });
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
results.push({ assetId, success: true });
|
|
|
|
sharedLink.assets.push({ id: assetId } as AssetEntity);
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.repository.update(sharedLink);
|
|
|
|
|
|
|
|
return results;
|
|
|
|
}
|
|
|
|
|
2023-12-10 05:34:12 +01:00
|
|
|
async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
2023-12-11 20:37:47 +01:00
|
|
|
const sharedLink = await this.findOrFail(auth.user.id, id);
|
2023-06-21 03:08:43 +02:00
|
|
|
|
|
|
|
if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
|
|
|
|
throw new BadRequestException('Invalid shared link type');
|
|
|
|
}
|
|
|
|
|
|
|
|
const results: AssetIdsResponseDto[] = [];
|
|
|
|
for (const assetId of dto.assetIds) {
|
|
|
|
const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
|
|
|
|
if (!hasAsset) {
|
|
|
|
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
results.push({ assetId, success: true });
|
|
|
|
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId);
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.repository.update(sharedLink);
|
|
|
|
|
|
|
|
return results;
|
|
|
|
}
|
|
|
|
|
2023-12-11 20:37:47 +01:00
|
|
|
async getMetadataTags(auth: AuthDto): Promise<null | OpenGraphTags> {
|
|
|
|
if (!auth.sharedLink || auth.sharedLink.password) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id);
|
|
|
|
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
|
2024-04-27 20:56:13 +02:00
|
|
|
const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets.length || 0;
|
2023-12-11 20:37:47 +01:00
|
|
|
|
|
|
|
return {
|
|
|
|
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
|
|
|
|
description: sharedLink.description || `${assetCount} shared photos & videos`,
|
|
|
|
imageUrl: assetId
|
2024-05-31 19:44:04 +02:00
|
|
|
? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
|
2023-12-11 20:37:47 +01:00
|
|
|
: '/feature-panel.png',
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
private mapToSharedLink(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
|
2023-10-14 03:46:30 +02:00
|
|
|
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
|
2023-06-02 04:09:57 +02:00
|
|
|
}
|
2023-10-29 02:35:38 +01:00
|
|
|
|
|
|
|
private validateAndRefreshToken(sharedLink: SharedLinkEntity, dto: SharedLinkPasswordDto): string {
|
|
|
|
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
|
|
|
const sharedLinkTokens = dto.token?.split(',') || [];
|
|
|
|
if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) {
|
|
|
|
throw new UnauthorizedException('Invalid password');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!sharedLinkTokens.includes(token)) {
|
|
|
|
sharedLinkTokens.push(token);
|
|
|
|
}
|
|
|
|
return sharedLinkTokens.join(',');
|
|
|
|
}
|
2023-06-02 04:09:57 +02:00
|
|
|
}
|