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

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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],

View File

@@ -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 () => {

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -226,7 +226,7 @@ export class AssetRepository implements IAssetRepository {
where: {
id: assetId,
},
relations: ['exifInfo', 'tags'],
relations: ['exifInfo', 'tags', 'sharedLinks'],
});
}

View File

@@ -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(

View File

@@ -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],

View File

@@ -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,
);
});

View File

@@ -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();
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

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: [],

View File

@@ -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);

View File

@@ -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;
});

View File

@@ -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);
};

View File

@@ -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;
}
}

View File

@@ -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]) {}

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}