refactor(server): shared links (#2632)

* refactor: rename share => shared-link

* refactor: shared link crud methods

* chore: open api
This commit is contained in:
Jason Rasmussen
2023-06-01 22:09:57 -04:00
committed by GitHub
parent 038e064e60
commit 3ea2fe1c48
30 changed files with 507 additions and 536 deletions

View File

@@ -10,13 +10,19 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { AddAssetsDto } from './dto/add-assets.dto';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { ShareCore, ISharedLinkRepository, mapSharedLink, SharedLinkResponseDto, ICryptoRepository } from '@app/domain';
import {
SharedLinkCore,
ISharedLinkRepository,
mapSharedLink,
SharedLinkResponseDto,
ICryptoRepository,
} from '@app/domain';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
@Injectable()
export class AlbumService {
readonly logger = new Logger(AlbumService.name);
private shareCore: ShareCore;
private shareCore: SharedLinkCore;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@@ -25,7 +31,7 @@ export class AlbumService {
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
}
private async _getAlbum({

View File

@@ -229,7 +229,7 @@ describe('AssetService', () => {
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.save).not.toHaveBeenCalled();
expect(sharedLinkRepositoryMock.update).not.toHaveBeenCalled();
});
it('should add assets to a shared link', async () => {
@@ -241,13 +241,13 @@ describe('AssetService', () => {
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssetsToSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.save).toHaveBeenCalled();
expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
});
it('should remove assets from a shared link', async () => {
@@ -259,13 +259,13 @@ describe('AssetService', () => {
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssetsFromSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.save).toHaveBeenCalled();
expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
});
});

View File

@@ -54,7 +54,7 @@ import { ICryptoRepository, IJobRepository } from '@app/domain';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository';
import { ShareCore } from '@app/domain';
import { SharedLinkCore } from '@app/domain';
import { IPartnerRepository } from '@app/domain';
import { ISharedLinkRepository } from '@app/domain';
import { DownloadFilesDto } from './dto/download-files.dto';
@@ -80,7 +80,7 @@ interface ServableFile {
@Injectable()
export class AssetService {
readonly logger = new Logger(AssetService.name);
private shareCore: ShareCore;
private shareCore: SharedLinkCore;
private assetCore: AssetCore;
private partnerCore: PartnerCore;
@@ -97,7 +97,7 @@ export class AssetService {
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
this.partnerCore = new PartnerCore(partnerRepository);
}

View File

@@ -1,4 +1,4 @@
import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, ShareService } from '@app/domain';
import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, SharedLinkService } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
@@ -11,7 +11,7 @@ import { UUIDParamDto } from './dto/uuid-param.dto';
@Authenticated()
@UseValidation()
export class SharedLinkController {
constructor(private readonly service: ShareService) {}
constructor(private readonly service: SharedLinkService) {}
@Get()
getAllSharedLinks(@GetAuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
@@ -29,20 +29,20 @@ export class SharedLinkController {
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
): Promise<SharedLinkResponseDto> {
return this.service.getById(authUser, id, true);
return this.service.get(authUser, id);
}
@Patch(':id')
updateSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: EditSharedLinkDto,
): Promise<SharedLinkResponseDto> {
return this.service.update(authUser, id, dto);
}
@Delete(':id')
removeSharedLink(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(authUser, id);
}
@Patch(':id')
editSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: EditSharedLinkDto,
): Promise<SharedLinkResponseDto> {
return this.service.edit(authUser, id, dto);
}
}

View File

@@ -1565,41 +1565,8 @@
}
]
},
"delete": {
"operationId": "removeSharedLink",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"share"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
},
"patch": {
"operationId": "editSharedLink",
"operationId": "updateSharedLink",
"parameters": [
{
"name": "id",
@@ -1647,6 +1614,39 @@
"api_key": []
}
]
},
"delete": {
"operationId": "removeSharedLink",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"share"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/system-config": {

View File

@@ -20,7 +20,7 @@ import {
} from '../../test';
import { IKeyRepository } from '../api-key';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { ISharedLinkRepository } from '../share';
import { ISharedLinkRepository } from '../shared-link';
import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
import { IUserTokenRepository } from '../user-token';

View File

@@ -18,7 +18,7 @@ import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from '.
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import cookieParser from 'cookie';
import { ISharedLinkRepository, ShareCore } from '../share';
import { ISharedLinkRepository, SharedLinkCore } from '../shared-link';
import { APIKeyCore } from '../api-key/api-key.core';
import { IKeyRepository } from '../api-key';
import { AuthDeviceResponseDto, mapUserToken } from './response-dto';
@@ -29,7 +29,7 @@ export class AuthService {
private authCore: AuthCore;
private oauthCore: OAuthCore;
private userCore: UserCore;
private shareCore: ShareCore;
private shareCore: SharedLinkCore;
private keyCore: APIKeyCore;
private logger = new Logger(AuthService.name);
@@ -48,7 +48,7 @@ export class AuthService {
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
this.oauthCore = new OAuthCore(configRepository, initialConfig);
this.userCore = new UserCore(userRepository, cryptoRepository);
this.shareCore = new ShareCore(shareRepository, cryptoRepository);
this.shareCore = new SharedLinkCore(shareRepository, cryptoRepository);
this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
}

View File

@@ -12,7 +12,7 @@ import { PartnerService } from './partner';
import { PersonService } from './person';
import { SearchService } from './search';
import { ServerInfoService } from './server-info';
import { ShareService } from './share';
import { SharedLinkService } from './shared-link';
import { SmartInfoService } from './smart-info';
import { StorageService } from './storage';
import { StorageTemplateService } from './storage-template';
@@ -34,7 +34,7 @@ const providers: Provider[] = [
PartnerService,
SearchService,
ServerInfoService,
ShareService,
SharedLinkService,
SmartInfoService,
StorageService,
StorageTemplateService,

View File

@@ -17,7 +17,7 @@ export * from './person';
export * from './search';
export * from './server-info';
export * from './partner';
export * from './share';
export * from './shared-link';
export * from './smart-info';
export * from './storage';
export * from './storage-template';

View File

@@ -1,127 +0,0 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import {
authStub,
newCryptoRepositoryMock,
newSharedLinkRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '../../test';
import { ICryptoRepository } from '../crypto';
import { ShareService } from './share.service';
import { ISharedLinkRepository } from './shared-link.repository';
describe(ShareService.name, () => {
let sut: ShareService;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let shareMock: jest.Mocked<ISharedLinkRepository>;
beforeEach(async () => {
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
sut = new ShareService(cryptoMock, shareMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
it('should return all keys for a user', async () => {
shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid,
]);
expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
});
describe('getMine', () => {
it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin)).rejects.toBeInstanceOf(ForbiddenException);
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return the key for the public user (auth dto)', async () => {
const authDto = authStub.adminSharedLink;
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
});
describe('get', () => {
it('should not work on a missing key', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, true)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).not.toHaveBeenCalled();
});
it('should get a key by id', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, false)).resolves.toEqual(
sharedLinkResponseStub.valid,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
});
it('should include exif', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, true)).resolves.toEqual(
sharedLinkResponseStub.readonly,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
});
it('should exclude exif', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, false)).resolves.toEqual(
sharedLinkResponseStub.readonlyNoExif,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
});
});
describe('remove', () => {
it('should not work on a missing key', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.user1, sharedLinkStub.valid.id)).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).not.toHaveBeenCalled();
});
it('should remove a key', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
});
});
describe('edit', () => {
it('should not work on a missing key', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, {})).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.save).not.toHaveBeenCalled();
});
it('should edit a key', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.save.mockResolvedValue(sharedLinkStub.valid);
const dto = { allowDownload: false };
await sut.edit(authStub.user1, sharedLinkStub.valid.id, dto);
// await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.save).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id,
userId: authStub.user1.id,
allowDownload: false,
});
});
});
});

View File

@@ -1,60 +0,0 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { EditSharedLinkDto } from './dto';
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
import { ShareCore } from './share.core';
import { ISharedLinkRepository } from './shared-link.repository';
@Injectable()
export class ShareService {
readonly logger = new Logger(ShareService.name);
private shareCore: ShareCore;
constructor(
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
}
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
const links = await this.shareCore.getAll(authUser.id);
return links.map(mapSharedLink);
}
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
if (!authUser.isPublicUser || !authUser.sharedLinkId) {
throw new ForbiddenException();
}
let allowExif = true;
if (authUser.isShowExif != undefined) {
allowExif = authUser.isShowExif;
}
return this.getById(authUser, authUser.sharedLinkId, allowExif);
}
async getById(authUser: AuthUserDto, id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.get(authUser.id, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
if (allowExif) {
return mapSharedLink(link);
} else {
return mapSharedLinkWithNoExif(link);
}
}
async remove(authUser: AuthUserDto, id: string): Promise<void> {
await this.shareCore.remove(authUser.id, id);
}
async edit(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
const link = await this.shareCore.save(authUser.id, id, dto);
return mapSharedLink(link);
}
}

View File

@@ -1,5 +1,5 @@
export * from './dto';
export * from './response-dto';
export * from './share.core';
export * from './share.service';
export * from './shared-link.core';
export * from './shared-link.service';
export * from './shared-link.repository';

View File

@@ -5,19 +5,12 @@ import { ICryptoRepository } from '../crypto';
import { CreateSharedLinkDto } from './dto';
import { ISharedLinkRepository } from './shared-link.repository';
export class ShareCore {
readonly logger = new Logger(ShareCore.name);
export class SharedLinkCore {
readonly logger = new Logger(SharedLinkCore.name);
constructor(private repository: ISharedLinkRepository, private cryptoRepository: ICryptoRepository) {}
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.getAll(userId);
}
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.get(userId, id);
}
// TODO: move to SharedLinkController/SharedLinkService
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
return this.repository.create({
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
@@ -34,42 +27,24 @@ export class ShareCore {
});
}
async save(userId: string, id: string, entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
const link = await this.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.save({ ...entity, userId, id });
}
async remove(userId: string, id: string): Promise<void> {
const link = await this.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
await this.repository.remove(link);
}
async addAssets(userId: string, id: string, assets: AssetEntity[]) {
const link = await this.get(userId, id);
const link = await this.repository.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.save({ ...link, assets: [...link.assets, ...assets] });
return this.repository.update({ ...link, assets: [...link.assets, ...assets] });
}
async removeAssets(userId: string, id: string, assets: AssetEntity[]) {
const link = await this.get(userId, id);
const link = await this.repository.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
const newAssets = link.assets.filter((asset) => assets.find((a) => a.id === asset.id));
return this.repository.save({ ...link, assets: newAssets });
return this.repository.update({ ...link, assets: newAssets });
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {

View File

@@ -7,7 +7,7 @@ export interface ISharedLinkRepository {
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
getByKey(key: Buffer): Promise<SharedLinkEntity | null>;
create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<void>;
save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,103 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { authStub, newSharedLinkRepositoryMock, sharedLinkResponseStub, sharedLinkStub } from '../../test';
import { SharedLinkService } from './shared-link.service';
import { ISharedLinkRepository } from './shared-link.repository';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;
let shareMock: jest.Mocked<ISharedLinkRepository>;
beforeEach(async () => {
shareMock = newSharedLinkRepositoryMock();
sut = new SharedLinkService(shareMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
it('should return all shared links for a user', async () => {
shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid,
]);
expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
});
describe('getMine', () => {
it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin)).rejects.toBeInstanceOf(ForbiddenException);
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink;
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
it('should return not return exif', async () => {
const authDto = authStub.adminSharedLinkNoExif;
shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoExif);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
});
describe('get', () => {
it('should throw an error for an invalid shared link', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should get a shared link by id', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
});
});
describe('update', () => {
it('should throw an error for an invalid shared link', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should update a shared link', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.update.mockResolvedValue(sharedLinkStub.valid);
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.update).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id,
userId: authStub.user1.id,
allowDownload: false,
});
});
});
describe('remove', () => {
it('should throw an error for an invalid shared link', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should remove a key', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
});
});
});

View File

@@ -0,0 +1,63 @@
import { SharedLinkEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { EditSharedLinkDto } from './dto';
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
import { ISharedLinkRepository } from './shared-link.repository';
@Injectable()
export class SharedLinkService {
constructor(@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository) {}
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
}
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
const { sharedLinkId: id, isPublicUser, isShowExif } = authUser;
if (!isPublicUser || !id) {
throw new ForbiddenException();
}
const sharedLink = await this.findOrFail(authUser, id);
return this.map(sharedLink, { withExif: isShowExif ?? true });
}
async get(authUser: AuthUserDto, id: string): Promise<SharedLinkResponseDto> {
const sharedLink = await this.findOrFail(authUser, id);
return this.map(sharedLink, { withExif: true });
}
async update(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
await this.findOrFail(authUser, id);
const sharedLink = await this.repository.update({
id,
userId: authUser.id,
description: dto.description,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
});
return this.map(sharedLink, { withExif: true });
}
async remove(authUser: AuthUserDto, id: string): Promise<void> {
const sharedLink = await this.findOrFail(authUser, id);
await this.repository.remove(sharedLink);
}
private async findOrFail(authUser: AuthUserDto, id: string) {
const sharedLink = await this.repository.get(authUser.id, id);
if (!sharedLink) {
throw new BadRequestException('Shared link not found');
}
return sharedLink;
}
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
}
}

View File

@@ -71,6 +71,16 @@ export const authStub = {
isShowExif: true,
sharedLinkId: '123',
}),
adminSharedLinkNoExif: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
isAllowUpload: true,
isAllowDownload: true,
isPublicUser: true,
isShowExif: false,
sharedLinkId: '123',
}),
readonlySharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
@@ -690,7 +700,7 @@ export const sharedLinkStub = {
showExif: true,
assets: [],
} as SharedLinkEntity),
readonly: Object.freeze<SharedLinkEntity>({
readonlyNoExif: Object.freeze<SharedLinkEntity>({
id: '123',
userId: authStub.admin.id,
user: userEntityStub.admin,
@@ -700,7 +710,7 @@ export const sharedLinkStub = {
expiresAt: tomorrow,
allowUpload: false,
allowDownload: false,
showExif: true,
showExif: false,
assets: [],
album: {
id: 'album-123',
@@ -834,7 +844,7 @@ export const sharedLinkResponseStub = {
description: undefined,
allowUpload: false,
allowDownload: false,
showExif: true,
showExif: false,
album: albumResponse,
assets: [{ ...assetResponse, exifInfo: undefined }],
}),

View File

@@ -7,7 +7,7 @@ export const newSharedLinkRepositoryMock = (): jest.Mocked<ISharedLinkRepository
getByKey: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
update: jest.fn(),
hasAssetAccess: jest.fn(),
};
};

View File

@@ -1,16 +1,12 @@
import { ISharedLinkRepository } from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SharedLinkEntity } from '../entities';
@Injectable()
export class SharedLinkRepository implements ISharedLinkRepository {
readonly logger = new Logger(SharedLinkRepository.name);
constructor(
@InjectRepository(SharedLinkEntity)
private readonly repository: Repository<SharedLinkEntity>,
) {}
constructor(@InjectRepository(SharedLinkEntity) private repository: Repository<SharedLinkEntity>) {}
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.findOne({
@@ -78,40 +74,45 @@ export class SharedLinkRepository implements ISharedLinkRepository {
});
}
create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity> {
return this.repository.save(entity);
create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
}
update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
return this.save(entity);
}
async remove(entity: SharedLinkEntity): Promise<void> {
await this.repository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
const count1 = await this.repository.count({
where: {
id,
assets: {
id: assetId,
return (
// album asset
(await this.repository.exist({
where: {
id,
album: {
assets: {
id: assetId,
},
},
},
},
});
const count2 = await this.repository.count({
where: {
id,
album: {
})) ||
// individual asset
(await this.repository.exist({
where: {
id,
assets: {
id: assetId,
},
},
},
});
}))
);
}
return Boolean(count1 + count2);
private async save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
}
}