feat(web/server) public album sharing (#1266)

This commit is contained in:
Alex
2023-01-09 14:16:08 -06:00
committed by GitHub
parent fd15cdbf40
commit 10789503c1
101 changed files with 4879 additions and 347 deletions
@@ -1,7 +1,7 @@
import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/database';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository, SelectQueryBuilder, DataSource, Brackets } from 'typeorm';
import { In, Repository, SelectQueryBuilder, DataSource, Brackets, Not, IsNull } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto';
import { AddUsersDto } from './dto/add-users.dto';
import { CreateAlbumDto } from './dto/create-album.dto';
@@ -14,6 +14,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
getPublicSharingList(ownerId: string): Promise<AlbumEntity[]>;
get(albumId: string): Promise<AlbumEntity | undefined>;
delete(album: AlbumEntity): Promise<void>;
addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
@@ -43,6 +44,21 @@ export class AlbumRepository implements IAlbumRepository {
private dataSource: DataSource,
) {}
async getPublicSharingList(ownerId: string): Promise<AlbumEntity[]> {
return this.albumRepository.find({
relations: {
sharedLinks: true,
assets: true,
},
where: {
ownerId,
sharedLinks: {
id: Not(IsNull()),
},
},
});
}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
@@ -161,6 +177,9 @@ export class AlbumRepository implements IAlbumRepository {
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC');
// Get information of shared links in albums
query = query.leftJoinAndSelect('album.sharedLinks', 'sharedLink');
const albums = await query.getMany();
albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf());
@@ -203,6 +222,7 @@ export class AlbumRepository implements IAlbumRepository {
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.leftJoinAndSelect('assetInfo.exifInfo', 'exifInfo')
.leftJoinAndSelect('album.sharedLinks', 'sharedLinks')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
.getOne();
@@ -33,25 +33,29 @@ import {
IMMICH_CONTENT_LENGTH_HINT,
} from '../../constants/download.constant';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
@Authenticated()
@ApiBearerAuth()
@ApiTags('Album')
@Controller('album')
export class AlbumController {
constructor(private readonly albumService: AlbumService) {}
@Authenticated()
@Get('count-by-user-id')
async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.albumService.getAlbumCountByUserId(authUser);
}
@Authenticated()
@Post()
async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
return this.albumService.create(authUser, createAlbumDto);
}
@Authenticated()
@Put('/:albumId/users')
async addUsersToAlbum(
@GetAuthUser() authUser: AuthUserDto,
@@ -61,6 +65,7 @@ export class AlbumController {
return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId);
}
@Authenticated({ isShared: true })
@Put('/:albumId/assets')
async addAssetsToAlbum(
@GetAuthUser() authUser: AuthUserDto,
@@ -70,6 +75,7 @@ export class AlbumController {
return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
}
@Authenticated()
@Get()
async getAllAlbums(
@GetAuthUser() authUser: AuthUserDto,
@@ -78,6 +84,7 @@ export class AlbumController {
return this.albumService.getAllAlbums(authUser, query);
}
@Authenticated({ isShared: true })
@Get('/:albumId')
async getAlbumInfo(
@GetAuthUser() authUser: AuthUserDto,
@@ -86,6 +93,7 @@ export class AlbumController {
return this.albumService.getAlbumInfo(authUser, albumId);
}
@Authenticated()
@Delete('/:albumId/assets')
async removeAssetFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@@ -95,6 +103,7 @@ export class AlbumController {
return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
}
@Authenticated()
@Delete('/:albumId')
async deleteAlbum(
@GetAuthUser() authUser: AuthUserDto,
@@ -103,6 +112,7 @@ export class AlbumController {
return this.albumService.deleteAlbum(authUser, albumId);
}
@Authenticated()
@Delete('/:albumId/user/:userId')
async removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@@ -112,6 +122,7 @@ export class AlbumController {
return this.albumService.removeUserFromAlbum(authUser, albumId, userId);
}
@Authenticated()
@Patch('/:albumId')
async updateAlbumInfo(
@GetAuthUser() authUser: AuthUserDto,
@@ -121,6 +132,7 @@ export class AlbumController {
return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId);
}
@Authenticated({ isShared: true })
@Get('/:albumId/download')
async downloadArchive(
@GetAuthUser() authUser: AuthUserDto,
@@ -139,4 +151,13 @@ export class AlbumController {
res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`);
return stream;
}
@Authenticated()
@Post('/create-shared-link')
async createAlbumSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) createAlbumShareLinkDto: CreateAlbumSharedLinkDto,
) {
return this.albumService.createAlbumSharedLink(authUser, createAlbumShareLinkDto);
}
}
@@ -7,6 +7,7 @@ import { AlbumRepository, IAlbumRepository } from './album-repository';
import { DownloadModule } from '../../modules/download/download.module';
import { AssetModule } from '../asset/asset.module';
import { UserModule } from '../user/user.module';
import { ShareModule } from '../share/share.module';
const ALBUM_REPOSITORY_PROVIDER = {
provide: IAlbumRepository,
@@ -19,6 +20,7 @@ const ALBUM_REPOSITORY_PROVIDER = {
DownloadModule,
UserModule,
forwardRef(() => AssetModule),
ShareModule,
],
controllers: [AlbumController],
providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],
@@ -3,15 +3,15 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service';
import { ISharedLinkRepository } from '../share/shared-link.repository';
describe('Album service', () => {
let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
const authUser: AuthUserDto = Object.freeze({
@@ -33,7 +33,7 @@ describe('Album service', () => {
albumEntity.sharedUsers = [];
albumEntity.assets = [];
albumEntity.albumThumbnailAssetId = null;
albumEntity.sharedLinks = [];
return albumEntity;
};
@@ -94,6 +94,7 @@ describe('Album service', () => {
},
},
];
albumEntity.sharedLinks = [];
return albumEntity;
};
@@ -113,6 +114,7 @@ describe('Album service', () => {
beforeAll(() => {
albumRepositoryMock = {
getPublicSharingList: jest.fn(),
addAssets: jest.fn(),
addSharedUsers: jest.fn(),
create: jest.fn(),
@@ -127,31 +129,20 @@ describe('Album service', () => {
getSharedWithUserAlbumCount: jest.fn(),
};
assetRepositoryMock = {
sharedLinkRepositoryMock = {
create: jest.fn(),
update: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
remove: jest.fn(),
get: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
getByKey: jest.fn(),
save: jest.fn(),
};
downloadServiceMock = {
downloadArchive: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock, downloadServiceMock as DownloadService);
sut = new AlbumService(albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService);
});
it('creates album', async () => {
@@ -175,10 +166,8 @@ describe('Album service', () => {
albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
const result = await sut.getAllAlbums(authUser, {});
expect(result).toHaveLength(3);
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(ownedAlbum.id);
expect(result[1].id).toEqual(ownedSharedAlbum.id);
expect(result[2].id).toEqual(sharedWithMeAlbum.id);
});
it('gets an owned album', async () => {
@@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAlbumDto } from './dto/create-album.dto';
import { AlbumEntity } from '@app/database';
import { AlbumEntity, SharedLinkType } from '@app/database';
import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
@@ -9,19 +9,28 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
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 } from '../share/share.core';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import _ from 'lodash';
@Injectable()
export class AlbumService {
readonly logger = new Logger(AlbumService.name);
private shareCore: ShareCore;
constructor(
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
private downloadService: DownloadService,
) {}
) {
this.shareCore = new ShareCore(sharedLinkRepository);
}
private async _getAlbum({
authUser,
@@ -63,8 +72,14 @@ export class AlbumService {
albums = await this._albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId);
} else {
albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
if (getAlbumsDto.shared) {
const publicSharingAlbums = await this._albumRepository.getPublicSharingList(authUser.id);
albums = [...albums, ...publicSharingAlbums];
}
}
albums = _.uniqBy(albums, (album) => album.id);
for (const album of albums) {
await this._checkValidThumbnail(album);
}
@@ -85,6 +100,11 @@ export class AlbumService {
async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise<void> {
const album = await this._getAlbum({ authUser, albumId });
for (const sharedLink of album.sharedLinks) {
await this.shareCore.removeSharedLink(sharedLink.id, authUser.id);
}
await this._albumRepository.delete(album);
}
@@ -125,6 +145,11 @@ export class AlbumService {
addAssetsDto: AddAssetsDto,
albumId: string,
): Promise<AddAssetsResponseDto> {
if (authUser.isPublicUser && !authUser.isAllowUpload) {
this.logger.warn('Deny public user attempt to add asset to album');
throw new ForbiddenException('Public user is not allowed to upload');
}
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const result = await this._albumRepository.addAssets(album, addAssetsDto);
const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
@@ -174,4 +199,19 @@ export class AlbumService {
album.albumThumbnailAssetId = dto.albumThumbnailAssetId || null;
}
}
async createAlbumSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise<SharedLinkResponseDto> {
const album = await this._getAlbum({ authUser, albumId: dto.albumId });
const sharedLink = await this.shareCore.createSharedLink(authUser.id, {
sharedType: SharedLinkType.ALBUM,
expiredAt: dto.expiredAt,
allowUpload: dto.allowUpload,
album: album,
assets: [],
description: dto.description,
});
return mapSharedLinkToResponseDto(sharedLink);
}
}
@@ -0,0 +1,19 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAlbumShareLinkDto {
@IsString()
@IsNotEmpty()
albumId!: string;
@IsString()
@IsOptional()
expiredAt?: string;
@IsBoolean()
@IsOptional()
allowUpload?: boolean;
@IsString()
@IsOptional()
description?: string;
}
@@ -33,7 +33,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
id: entity.id,
ownerId: entity.ownerId,
sharedUsers,
shared: sharedUsers.length > 0,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
assetCount: entity.assets?.length || 0,
};
@@ -55,7 +55,7 @@ export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto
id: entity.id,
ownerId: entity.ownerId,
sharedUsers,
shared: sharedUsers.length > 0,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: [],
assetCount: entity.assets?.length || 0,
};
@@ -226,7 +226,7 @@ export class AssetRepository implements IAssetRepository {
where: {
id: assetId,
},
relations: ['exifInfo', 'tags'],
relations: ['exifInfo', 'tags', 'sharedLinks'],
});
}
@@ -49,14 +49,15 @@ import {
IMMICH_ARCHIVE_FILE_COUNT,
IMMICH_CONTENT_LENGTH_HINT,
} from '../../constants/download.constant';
import { DownloadFilesDto } from './dto/download-files.dto';
@Authenticated()
@ApiBearerAuth()
@ApiTags('Asset')
@Controller('asset')
export class AssetController {
constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
@Authenticated({ isShared: true })
@Post('upload')
@UseInterceptors(
FileFieldsInterceptor(
@@ -84,6 +85,7 @@ export class AssetController {
return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
}
@Authenticated({ isShared: true })
@Get('/download/:assetId')
async downloadFile(
@GetAuthUser() authUser: AuthUserDto,
@@ -95,6 +97,23 @@ export class AssetController {
return this.assetService.downloadFile(query, assetId, res);
}
@Authenticated({ isShared: true })
@Post('/download-files')
async downloadFiles(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Body(new ValidationPipe()) dto: DownloadFilesDto,
): Promise<any> {
await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]);
const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto);
res.attachment(fileName);
res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize);
res.setHeader(IMMICH_ARCHIVE_FILE_COUNT, fileCount);
res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`);
return stream;
}
@Authenticated({ isShared: true })
@Get('/download-library')
async downloadLibrary(
@GetAuthUser() authUser: AuthUserDto,
@@ -109,6 +128,7 @@ export class AssetController {
return stream;
}
@Authenticated({ isShared: true })
@Get('/file/:assetId')
@Header('Cache-Control', 'max-age=31536000')
async serveFile(
@@ -122,6 +142,7 @@ export class AssetController {
return this.assetService.serveFile(assetId, query, res, headers);
}
@Authenticated({ isShared: true })
@Get('/thumbnail/:assetId')
@Header('Cache-Control', 'max-age=31536000')
async getAssetThumbnail(
@@ -135,21 +156,25 @@ export class AssetController {
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
}
@Authenticated()
@Get('/curated-objects')
async getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
return this.assetService.getCuratedObject(authUser);
}
@Authenticated()
@Get('/curated-locations')
async getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
return this.assetService.getCuratedLocation(authUser);
}
@Authenticated()
@Get('/search-terms')
async getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise<string[]> {
return this.assetService.getAssetSearchTerm(authUser);
}
@Authenticated()
@Post('/search')
async searchAsset(
@GetAuthUser() authUser: AuthUserDto,
@@ -158,6 +183,7 @@ export class AssetController {
return this.assetService.searchAsset(authUser, searchAssetDto);
}
@Authenticated()
@Post('/count-by-time-bucket')
async getAssetCountByTimeBucket(
@GetAuthUser() authUser: AuthUserDto,
@@ -166,6 +192,7 @@ export class AssetController {
return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto);
}
@Authenticated()
@Get('/count-by-user-id')
async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getAssetCountByUserId(authUser);
@@ -174,6 +201,7 @@ export class AssetController {
/**
* Get all AssetEntity belong to the user
*/
@Authenticated()
@Get('/')
@ApiHeader({
name: 'if-none-match',
@@ -186,6 +214,7 @@ export class AssetController {
return assets;
}
@Authenticated()
@Post('/time-bucket')
async getAssetByTimeBucket(
@GetAuthUser() authUser: AuthUserDto,
@@ -193,9 +222,11 @@ export class AssetController {
): Promise<AssetResponseDto[]> {
return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
}
/**
* Get all asset of a device that are in the database, ID only.
*/
@Authenticated()
@Get('/:deviceId')
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
@@ -204,6 +235,7 @@ export class AssetController {
/**
* Get a single asset's information
*/
@Authenticated({ isShared: true })
@Get('/assetById/:assetId')
async getAssetById(
@GetAuthUser() authUser: AuthUserDto,
@@ -216,6 +248,7 @@ export class AssetController {
/**
* Update an asset
*/
@Authenticated()
@Put('/:assetId')
async updateAsset(
@GetAuthUser() authUser: AuthUserDto,
@@ -226,6 +259,7 @@ export class AssetController {
return await this.assetService.updateAsset(authUser, assetId, dto);
}
@Authenticated()
@Delete('/')
async deleteAsset(
@GetAuthUser() authUser: AuthUserDto,
@@ -265,6 +299,7 @@ export class AssetController {
/**
* Check duplicated asset before uploading - for Web upload used
*/
@Authenticated({ isShared: true })
@Post('/check')
@HttpCode(200)
async checkDuplicateAsset(
@@ -277,6 +312,7 @@ export class AssetController {
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Authenticated()
@Post('/exist')
@HttpCode(200)
async checkExistingAssets(
@@ -14,6 +14,7 @@ import { AlbumModule } from '../album/album.module';
import { UserModule } from '../user/user.module';
import { StorageModule } from '@app/storage';
import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant';
import { ShareModule } from '../share/share.module';
const ASSET_REPOSITORY_PROVIDER = {
provide: IAssetRepository,
@@ -32,6 +33,7 @@ const ASSET_REPOSITORY_PROVIDER = {
StorageModule,
forwardRef(() => AlbumModule),
BullModule.registerQueue(...immichSharedQueues),
ShareModule,
],
controllers: [AssetController],
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],
@@ -13,6 +13,7 @@ import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
import { Queue } from 'bull';
import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
import { ISharedLinkRepository } from '../share/shared-link.repository';
describe('AssetService', () => {
let sui: AssetService;
@@ -24,6 +25,7 @@ describe('AssetService', () => {
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
let storageSeriveMock: jest.Mocked<StorageService>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
const authUser: AuthUserDto = Object.freeze({
id: 'user_id_1',
email: 'auth@test.com',
@@ -128,12 +130,22 @@ describe('AssetService', () => {
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
getSharePermission: jest.fn(),
};
downloadServiceMock = {
downloadArchive: jest.fn(),
};
sharedLinkRepositoryMock = {
create: jest.fn(),
get: jest.fn(),
getById: jest.fn(),
getByKey: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
};
sui = new AssetService(
assetRepositoryMock,
albumRepositoryMock,
@@ -143,6 +155,7 @@ describe('AssetService', () => {
videoConversionQueueMock,
downloadServiceMock as DownloadService,
storageSeriveMock,
sharedLinkRepositoryMock,
);
});
@@ -56,11 +56,17 @@ import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
import { ShareCore } from '../share/share.core';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { DownloadFilesDto } from './dto/download-files.dto';
const fileInfo = promisify(stat);
@Injectable()
export class AssetService {
readonly logger = new Logger(AssetService.name);
private shareCore: ShareCore;
constructor(
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@@ -80,7 +86,10 @@ export class AssetService {
private downloadService: DownloadService,
private storageService: StorageService,
) {}
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
}
public async handleUploadedAsset(
authUser: AuthUserDto,
@@ -253,6 +262,24 @@ export class AssetService {
return this.downloadService.downloadArchive(dto.name || `library`, assets);
}
public async downloadFiles(dto: DownloadFilesDto) {
const assetToDownload = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assetToDownload.push(asset);
// Get live photo asset
if (asset.livePhotoVideoId) {
const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId);
assetToDownload.push(livePhotoAsset);
}
}
const now = new Date().toISOString();
return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload);
}
public async downloadFile(query: ServeFileDto, assetId: string, res: Res) {
try {
let fileReadStream = null;
@@ -649,7 +676,15 @@ export class AssetService {
async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
for (const assetId of assetIds) {
// Step 1: Check if user owns asset
// Step 1: Check if asset is part of a public shared
if (authUser.sharedLinkId) {
const canAccess = await this.shareCore.hasAssetAccess(authUser.sharedLinkId, assetId);
if (!canAccess) {
throw new ForbiddenException();
}
}
// Step 2: Check if user owns asset
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
continue;
}
@@ -660,8 +695,6 @@ export class AssetService {
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
continue;
}
//TODO: Step 3: Check if asset is part of a public album
}
throw new ForbiddenException();
}
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class DownloadFilesDto {
@IsNotEmpty()
@ApiProperty({
isArray: true,
type: String,
title: 'Array of asset ids to be downloaded',
})
assetIds!: string[];
}
@@ -0,0 +1,11 @@
import { AlbumEntity, AssetEntity } from '@app/database';
import { SharedLinkType } from '@app/database/entities/shared-link.entity';
export class CreateSharedLinkDto {
description?: string;
expiredAt?: string;
sharedType!: SharedLinkType;
assets!: AssetEntity[];
album?: AlbumEntity;
allowUpload?: boolean;
}
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class EditSharedLinkDto {
@IsOptional()
description?: string;
@IsOptional()
expiredAt?: string;
@IsOptional()
allowUpload?: boolean;
@IsNotEmpty()
isEditExpireTime?: boolean;
}
@@ -0,0 +1,40 @@
import { SharedLinkEntity, SharedLinkType } from '@app/database';
import { ApiProperty } from '@nestjs/swagger';
import _ from 'lodash';
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto';
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
export class SharedLinkResponseDto {
id!: string;
description?: string;
userId!: string;
key!: string;
@ApiProperty({ enumName: 'SharedLinkType', enum: SharedLinkType })
type!: SharedLinkType;
createdAt!: string;
expiresAt!: string | null;
assets!: AssetResponseDto[];
album?: AlbumResponseDto;
allowUpload!: boolean;
}
export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return {
id: sharedLink.id,
description: sharedLink.description,
userId: sharedLink.userId,
key: sharedLink.key.toString('hex'),
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map(mapAsset),
album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
};
}
@@ -0,0 +1,46 @@
import { Body, Controller, Delete, Get, Param, Patch, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
import { ShareService } from './share.service';
@ApiTags('share')
@Controller('share')
export class ShareController {
constructor(private readonly shareService: ShareService) {}
@Authenticated()
@Get()
getAllSharedLinks(@GetAuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
return this.shareService.getAll(authUser);
}
@Authenticated({ isShared: true })
@Get('me')
getMySharedLink(@GetAuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
return this.shareService.getMine(authUser);
}
@Authenticated()
@Get(':id')
getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> {
return this.shareService.getById(id);
}
@Authenticated()
@Delete(':id')
removeSharedLink(@Param('id') id: string, @GetAuthUser() authUser: AuthUserDto): Promise<string> {
return this.shareService.remove(id, authUser.id);
}
@Authenticated()
@Patch(':id')
editSharedLink(
@Param('id') id: string,
@GetAuthUser() authUser: AuthUserDto,
@Body(new ValidationPipe()) dto: EditSharedLinkDto,
): Promise<SharedLinkResponseDto> {
return this.shareService.edit(id, authUser, dto);
}
}
@@ -0,0 +1,99 @@
import { SharedLinkEntity } from '@app/database/entities/shared-link.entity';
import { CreateSharedLinkDto } from './dto/create-shared-link.dto';
import { ISharedLinkRepository } from './shared-link.repository';
import crypto from 'node:crypto';
import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common';
import { AssetEntity } from '@app/database';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
export class ShareCore {
readonly logger = new Logger(ShareCore.name);
constructor(private sharedLinkRepository: ISharedLinkRepository) {}
async createSharedLink(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
try {
const sharedLink = new SharedLinkEntity();
sharedLink.key = Buffer.from(crypto.randomBytes(50));
sharedLink.description = dto.description;
sharedLink.userId = userId;
sharedLink.createdAt = new Date().toISOString();
sharedLink.expiresAt = dto.expiredAt ?? null;
sharedLink.type = dto.sharedType;
sharedLink.assets = dto.assets;
sharedLink.album = dto.album;
sharedLink.allowUpload = dto.allowUpload ?? false;
return this.sharedLinkRepository.create(sharedLink);
} catch (error: any) {
this.logger.error(error, error.stack);
throw new InternalServerErrorException('failed to create shared link');
}
}
async getSharedLinks(userId: string): Promise<SharedLinkEntity[]> {
return this.sharedLinkRepository.get(userId);
}
async removeSharedLink(id: string, userId: string): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return await this.sharedLinkRepository.remove(link);
}
async getSharedLinkById(id: string): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getById(id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return link;
}
async getSharedLinkByKey(key: string): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByKey(key);
if (!link) {
throw new BadRequestException();
}
return link;
}
async updateAssetsInSharedLink(sharedLinkId: string, assets: AssetEntity[]) {
const link = await this.getSharedLinkById(sharedLinkId);
link.assets = assets;
return await this.sharedLinkRepository.save(link);
}
async updateSharedLink(id: string, userId: string, dto: EditSharedLinkDto): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
if (!link) {
throw new BadRequestException('Shared link not found');
}
link.description = dto.description ?? link.description;
link.allowUpload = dto.allowUpload ?? link.allowUpload;
if (dto.isEditExpireTime && dto.expiredAt) {
link.expiresAt = dto.expiredAt;
} else if (dto.isEditExpireTime && !dto.expiredAt) {
link.expiresAt = null;
}
return await this.sharedLinkRepository.save(link);
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
return this.sharedLinkRepository.hasAssetAccess(id, assetId);
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ShareService } from './share.service';
import { ShareController } from './share.controller';
import { SharedLinkEntity } from '@app/database/entities/shared-link.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SharedLinkRepository, ISharedLinkRepository } from './shared-link.repository';
const SHARED_LINK_REPOSITORY_PROVIDER = {
provide: ISharedLinkRepository,
useClass: SharedLinkRepository,
};
@Module({
imports: [TypeOrmModule.forFeature([SharedLinkEntity])],
controllers: [ShareController],
providers: [ShareService, SHARED_LINK_REPOSITORY_PROVIDER],
exports: [SHARED_LINK_REPOSITORY_PROVIDER, ShareService],
})
export class ShareModule {}
@@ -0,0 +1,54 @@
import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from './response-dto/shared-link-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(ISharedLinkRepository)
sharedLinkRepository: ISharedLinkRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
}
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
const links = await this.shareCore.getSharedLinks(authUser.id);
return links.map(mapSharedLinkToResponseDto);
}
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
if (!authUser.isPublicUser || !authUser.sharedLinkId) {
throw new ForbiddenException();
}
const link = await this.shareCore.getSharedLinkById(authUser.sharedLinkId);
return mapSharedLinkToResponseDto(link);
}
async getById(id: string): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkById(id);
return mapSharedLinkToResponseDto(link);
}
async remove(id: string, userId: string): Promise<string> {
await this.shareCore.removeSharedLink(id, userId);
return id;
}
async getByKey(key: string): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkByKey(key);
return mapSharedLinkToResponseDto(link);
}
async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) {
const link = await this.shareCore.updateSharedLink(id, authUser.id, dto);
return mapSharedLinkToResponseDto(link);
}
}
@@ -0,0 +1,123 @@
import { SharedLinkEntity } from '@app/database/entities/shared-link.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Logger } from '@nestjs/common';
export interface ISharedLinkRepository {
get(userId: string): Promise<SharedLinkEntity[]>;
getById(id: string): Promise<SharedLinkEntity | null>;
getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null>;
getByKey(key: string): Promise<SharedLinkEntity | null>;
create(payload: SharedLinkEntity): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
save(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
}
export const ISharedLinkRepository = 'ISharedLinkRepository';
export class SharedLinkRepository implements ISharedLinkRepository {
readonly logger = new Logger(SharedLinkRepository.name);
constructor(
@InjectRepository(SharedLinkEntity)
private readonly sharedLinkRepository: Repository<SharedLinkEntity>,
) {}
async getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
userId: userId,
id: id,
},
order: {
createdAt: 'DESC',
},
});
}
async get(userId: string): Promise<SharedLinkEntity[]> {
return await this.sharedLinkRepository.find({
where: {
userId: userId,
},
relations: ['assets', 'album'],
order: {
createdAt: 'DESC',
},
});
}
async create(payload: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.save(payload);
}
async getById(id: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
id: id,
},
relations: {
assets: true,
album: {
assets: {
assetInfo: true,
},
},
},
order: {
createdAt: 'DESC',
},
});
}
async getByKey(key: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
key: Buffer.from(key, 'hex'),
},
relations: {
assets: true,
album: {
assets: {
assetInfo: true,
},
},
},
order: {
createdAt: 'DESC',
},
});
}
async remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.save(entity);
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
const count1 = await this.sharedLinkRepository.count({
where: {
id,
assets: {
id: assetId,
},
},
});
const count2 = await this.sharedLinkRepository.count({
where: {
id,
album: {
assets: {
assetId,
},
},
},
});
return Boolean(count1 + count2);
}
}
+3
View File
@@ -19,6 +19,7 @@ import { JobModule } from './api-v1/job/job.module';
import { SystemConfigModule } from './api-v1/system-config/system-config.module';
import { OAuthModule } from './api-v1/oauth/oauth.module';
import { TagModule } from './api-v1/tag/tag.module';
import { ShareModule } from './api-v1/share/share.module';
import { APIKeyModule } from './api-v1/api-key/api-key.module';
@Module({
@@ -58,6 +59,8 @@ import { APIKeyModule } from './api-v1/api-key/api-key.module';
SystemConfigModule,
TagModule,
ShareModule,
],
controllers: [AppController],
providers: [],
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname, join } from 'path';
import sanitize from 'sanitize-filename';
import { AuthUserDto } from '../decorators/auth-user.decorator';
import { patchFormData } from '../utils/path-form-data.util';
const logger = new Logger('AssetUploadConfig');
@@ -42,6 +43,12 @@ function destination(req: Request, file: Express.Multer.File, cb: any) {
return cb(new UnauthorizedException());
}
const user = req.user as AuthUserDto;
if (user.isPublicUser && !user.isAllowUpload) {
return cb(new UnauthorizedException());
}
const basePath = APP_UPLOAD_LOCATION;
const sanitizedDeviceId = sanitize(String(req.body['deviceId']));
const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId);
@@ -1,23 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { UserEntity } from '@app/database';
// import { AuthUserDto } from './dto/auth-user.dto';
export class AuthUserDto {
id!: string;
email!: string;
isAdmin!: boolean;
isPublicUser?: boolean;
sharedLinkId?: string;
isAllowUpload?: boolean;
}
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
const req = ctx.switchToHttp().getRequest<{ user: UserEntity }>();
const { id, email, isAdmin } = req.user;
const authUser: AuthUserDto = {
id: id.toString(),
email,
isAdmin,
};
return authUser;
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
});
@@ -1,16 +1,25 @@
import { UseGuards } from '@nestjs/common';
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard';
interface AuthenticatedOptions {
admin?: boolean;
isShared?: boolean;
}
export const Authenticated = (options?: AuthenticatedOptions) => {
const guards: Parameters<typeof UseGuards> = [AuthGuard];
options = options || {};
if (options.admin) {
guards.push(AdminRolesGuard);
}
if (!options.isShared) {
guards.push(RouteNotSharedGuard);
}
return UseGuards(...guards);
};
@@ -0,0 +1,21 @@
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Request } from 'express';
import { AuthUserDto } from '../decorators/auth-user.decorator';
@Injectable()
export class RouteNotSharedGuard implements CanActivate {
logger = new Logger(RouteNotSharedGuard.name);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as AuthUserDto;
// Inverse logic - I know it is weird
if (user.isPublicUser) {
this.logger.warn(`Denied attempt to access non-shared route: ${request.path}`);
return false;
}
return true;
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
import { JWT_STRATEGY } from '../strategies/jwt.strategy';
import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
@Injectable()
export class AuthGuard extends PassportAuthGuard([JWT_STRATEGY, API_KEY_STRATEGY]) {}
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {}
@@ -7,10 +7,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '@app/database';
import { APIKeyModule } from '../../api-v1/api-key/api-key.module';
import { APIKeyStrategy } from './strategies/api-key.strategy';
import { ShareModule } from '../../api-v1/share/share.module';
import { PublicShareStrategy } from './strategies/public-share.strategy';
@Module({
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule],
providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy],
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule, ShareModule],
providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy],
exports: [ImmichJwtService],
})
export class ImmichJwtModule {}
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { APIKeyService } from '../../../api-v1/api-key/api-key.service';
@@ -15,7 +16,16 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY)
super(options);
}
async validate(token: string) {
return this.apiKeyService.validate(token);
async validate(token: string): Promise<AuthUserDto> {
const user = await this.apiKeyService.validate(token);
const authUser = new AuthUserDto();
authUser.id = user.id;
authUser.email = user.email;
authUser.isAdmin = user.isAdmin;
authUser.isPublicUser = false;
authUser.isAllowUpload = true;
return authUser;
}
}
@@ -7,6 +7,7 @@ import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
import { UserEntity } from '@app/database';
import { jwtSecret } from '../../../constants/jwt.constant';
import { ImmichJwtService } from '../immich-jwt.service';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
export const JWT_STRATEGY = 'jwt';
@@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
} as StrategyOptions);
}
async validate(payload: JwtPayloadDto) {
async validate(payload: JwtPayloadDto): Promise<AuthUserDto> {
const { userId } = payload;
const user = await this.usersRepository.findOne({ where: { id: userId } });
@@ -35,6 +36,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
throw new UnauthorizedException('Failure to validate JWT payload');
}
return user;
const authUser = new AuthUserDto();
authUser.id = user.id;
authUser.email = user.email;
authUser.isAdmin = user.isAdmin;
authUser.isPublicUser = false;
authUser.isAllowUpload = true;
return authUser;
}
}
@@ -0,0 +1,53 @@
import { UserEntity } from '@app/database';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ShareService } from '../../../api-v1/share/share.service';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { Repository } from 'typeorm';
import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator';
export const PUBLIC_SHARE_STRATEGY = 'public-share';
const options: IStrategyOptions = {
header: 'x-immich-share-key',
param: 'key',
};
@Injectable()
export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE_STRATEGY) {
constructor(
private shareService: ShareService,
@InjectRepository(UserEntity)
private usersRepository: Repository<UserEntity>,
) {
super(options);
}
async validate(key: string): Promise<AuthUserDto> {
const validatedLink = await this.shareService.getByKey(key);
if (validatedLink.expiresAt) {
const now = new Date().getTime();
const expiresAt = new Date(validatedLink.expiresAt).getTime();
if (now > expiresAt) {
throw new UnauthorizedException('Expired link');
}
}
const user = await this.usersRepository.findOne({ where: { id: validatedLink.userId } });
if (!user) {
throw new UnauthorizedException('Failure to validate public share payload');
}
let publicUser = new AuthUserDto();
publicUser = user;
publicUser.isPublicUser = true;
publicUser.sharedLinkId = validatedLink.id;
publicUser.isAllowUpload = validatedLink.allowUpload;
return publicUser;
}
}
+462 -141
View File
@@ -473,6 +473,147 @@
]
}
},
"/share": {
"get": {
"operationId": "getAllSharedLinks",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
}
},
"tags": [
"share"
]
}
},
"/share/me": {
"get": {
"operationId": "getMySharedLink",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"share"
]
}
},
"/share/{id}": {
"get": {
"operationId": "getSharedLinkById",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"share"
]
},
"delete": {
"operationId": "removeSharedLink",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
},
"tags": [
"share"
]
},
"patch": {
"operationId": "editSharedLink",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditSharedLinkDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"share"
]
}
},
"/asset/upload": {
"post": {
"operationId": "uploadFile",
@@ -563,6 +704,42 @@
]
}
},
"/asset/download-files": {
"post": {
"operationId": "downloadFiles",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadFilesDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
}
]
}
},
"/asset/download-library": {
"get": {
"operationId": "downloadLibrary",
@@ -1616,6 +1793,42 @@
]
}
},
"/album/create-shared-link": {
"post": {
"operationId": "createAlbumSharedLink",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateAlbumShareLinkDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
}
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
}
]
}
},
"/tag": {
"post": {
"operationId": "create",
@@ -2666,99 +2879,11 @@
"name"
]
},
"AssetFileUploadDto": {
"type": "object",
"properties": {
"assetData": {
"type": "string",
"format": "binary"
}
},
"required": [
"assetData"
]
},
"AssetFileUploadResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
]
},
"ThumbnailFormat": {
"SharedLinkType": {
"type": "string",
"enum": [
"JPEG",
"WEBP"
]
},
"CuratedObjectsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"object": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"object",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"CuratedLocationsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"city": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"city",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"SearchAssetDto": {
"type": "object",
"properties": {
"searchTerm": {
"type": "string"
}
},
"required": [
"searchTerm"
"ALBUM",
"INDIVIDUAL"
]
},
"AssetTypeEnum": {
@@ -3019,6 +3144,232 @@
"tags"
]
},
"AlbumResponseDto": {
"type": "object",
"properties": {
"assetCount": {
"type": "integer"
},
"id": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"albumName": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"albumThumbnailAssetId": {
"type": "string",
"nullable": true
},
"shared": {
"type": "boolean"
},
"sharedUsers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserResponseDto"
}
},
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
}
},
"required": [
"assetCount",
"id",
"ownerId",
"albumName",
"createdAt",
"albumThumbnailAssetId",
"shared",
"sharedUsers",
"assets"
]
},
"SharedLinkResponseDto": {
"type": "object",
"properties": {
"type": {
"$ref": "#/components/schemas/SharedLinkType"
},
"id": {
"type": "string"
},
"description": {
"type": "string"
},
"userId": {
"type": "string"
},
"key": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"expiresAt": {
"type": "string",
"nullable": true
},
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
},
"album": {
"$ref": "#/components/schemas/AlbumResponseDto"
},
"allowUpload": {
"type": "boolean"
}
},
"required": [
"type",
"id",
"userId",
"key",
"createdAt",
"expiresAt",
"assets",
"allowUpload"
]
},
"EditSharedLinkDto": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"expiredAt": {
"type": "string"
},
"allowUpload": {
"type": "boolean"
},
"isEditExpireTime": {
"type": "boolean"
}
}
},
"AssetFileUploadDto": {
"type": "object",
"properties": {
"assetData": {
"type": "string",
"format": "binary"
}
},
"required": [
"assetData"
]
},
"AssetFileUploadResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
]
},
"DownloadFilesDto": {
"type": "object",
"properties": {
"assetIds": {
"title": "Array of asset ids to be downloaded",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"assetIds"
]
},
"ThumbnailFormat": {
"type": "string",
"enum": [
"JPEG",
"WEBP"
]
},
"CuratedObjectsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"object": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"object",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"CuratedLocationsResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"city": {
"type": "string"
},
"resizePath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
}
},
"required": [
"id",
"city",
"resizePath",
"deviceAssetId",
"deviceId"
]
},
"SearchAssetDto": {
"type": "object",
"properties": {
"searchTerm": {
"type": "string"
}
},
"required": [
"searchTerm"
]
},
"TimeGroupEnum": {
"type": "string",
"enum": [
@@ -3287,56 +3638,6 @@
"albumName"
]
},
"AlbumResponseDto": {
"type": "object",
"properties": {
"assetCount": {
"type": "integer"
},
"id": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"albumName": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"albumThumbnailAssetId": {
"type": "string",
"nullable": true
},
"shared": {
"type": "boolean"
},
"sharedUsers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserResponseDto"
}
},
"assets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
}
},
"required": [
"assetCount",
"id",
"ownerId",
"albumName",
"createdAt",
"albumThumbnailAssetId",
"shared",
"sharedUsers",
"assets"
]
},
"AddUsersDto": {
"type": "object",
"properties": {
@@ -3411,6 +3712,26 @@
}
}
},
"CreateAlbumShareLinkDto": {
"type": "object",
"properties": {
"albumId": {
"type": "string"
},
"expiredAt": {
"type": "string"
},
"allowUpload": {
"type": "boolean"
},
"description": {
"type": "string"
}
},
"required": [
"albumId"
]
},
"CreateTagDto": {
"type": "object",
"properties": {
@@ -1,5 +1,6 @@
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { AssetAlbumEntity } from './asset-album.entity';
import { SharedLinkEntity } from './shared-link.entity';
import { UserAlbumEntity } from './user-album.entity';
@Entity('albums')
@@ -24,4 +25,7 @@ export class AlbumEntity {
@OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo)
assets?: AssetAlbumEntity[];
@OneToMany(() => SharedLinkEntity, (link) => link.album)
sharedLinks!: SharedLinkEntity[];
}
@@ -1,5 +1,6 @@
import { Column, Entity, Index, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity';
import { SharedLinkEntity } from './shared-link.entity';
import { SmartInfoEntity } from './smart-info.entity';
import { TagEntity } from './tag.entity';
@@ -68,6 +69,10 @@ export class AssetEntity {
@ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
@JoinTable({ name: 'tag_asset' })
tags!: TagEntity[];
@ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
@JoinTable({ name: 'shared_link__asset' })
sharedLinks!: SharedLinkEntity[];
}
export enum AssetType {
@@ -9,3 +9,4 @@ export * from './system-config.entity';
export * from './tag.entity';
export * from './user-album.entity';
export * from './user.entity';
export * from './shared-link.entity';
@@ -0,0 +1,50 @@
import { Column, Entity, Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { AlbumEntity } from './album.entity';
import { AssetEntity } from './asset.entity';
@Entity('shared_links')
@Unique('UQ_sharedlink_key', ['key'])
export class SharedLinkEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ nullable: true })
description?: string;
@Column()
userId!: string;
@Index('IDX_sharedlink_key')
@Column({ type: 'bytea' })
key!: Buffer; // use to access the inidividual asset
@Column()
type!: SharedLinkType;
@Column({ type: 'timestamptz' })
createdAt!: string;
@Column({ type: 'timestamptz', nullable: true })
expiresAt!: string | null;
@Column({ type: 'boolean', default: false })
allowUpload!: boolean;
@ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks)
assets!: AssetEntity[];
@ManyToOne(() => AlbumEntity, (album) => album.sharedLinks)
album?: AlbumEntity;
}
export enum SharedLinkType {
ALBUM = 'ALBUM',
/**
* Individual asset
* or group of assets that are not in an album
*/
INDIVIDUAL = 'INDIVIDUAL',
}
// npm run typeorm -- migration:generate ./libs/database/src/AddSharedLinkTable -d libs/database/src/config/database.config.ts
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSharedLinkTable1673150490490 implements MigrationInterface {
name = 'AddSharedLinkTable1673150490490'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "shared_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "description" character varying, "userId" character varying NOT NULL, "key" bytea NOT NULL, "type" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, "allowUpload" boolean NOT NULL DEFAULT false, "albumId" uuid, CONSTRAINT "UQ_sharedlink_key" UNIQUE ("key"), CONSTRAINT "PK_642e2b0f619e4876e5f90a43465" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_sharedlink_key" ON "shared_links" ("key") `);
await queryRunner.query(`CREATE TABLE "shared_link__asset" ("assetsId" uuid NOT NULL, "sharedLinksId" uuid NOT NULL, CONSTRAINT "PK_9b4f3687f9b31d1e311336b05e3" PRIMARY KEY ("assetsId", "sharedLinksId"))`);
await queryRunner.query(`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId") `);
await queryRunner.query(`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId") `);
await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab" FOREIGN KEY ("sharedLinksId") REFERENCES "shared_links"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab"`);
await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66"`);
await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`);
await queryRunner.query(`DROP INDEX "public"."IDX_c9fab4aa97ffd1b034f3d6581a"`);
await queryRunner.query(`DROP INDEX "public"."IDX_5b7decce6c8d3db9593d6111a6"`);
await queryRunner.query(`DROP TABLE "shared_link__asset"`);
await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_key"`);
await queryRunner.query(`DROP TABLE "shared_links"`);
}
}
+20
View File
@@ -47,6 +47,7 @@
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
"passport-custom": "^1.1.1",
"passport-http-header-strategy": "^1.1.0",
"passport-jwt": "^4.0.0",
"pg": "^8.7.1",
@@ -8619,6 +8620,17 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-custom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
"dependencies": {
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/passport-http-header-strategy": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",
@@ -17927,6 +17939,14 @@
"utils-merge": "^1.0.1"
}
},
"passport-custom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
"requires": {
"passport-strategy": "1.x.x"
}
},
"passport-http-header-strategy": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",
+1
View File
@@ -70,6 +70,7 @@
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
"passport-custom": "^1.1.1",
"passport-http-header-strategy": "^1.1.0",
"passport-jwt": "^4.0.0",
"pg": "^8.7.1",