mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 20:29:05 +00:00
feat(web/server) public album sharing (#1266)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
46
server/apps/immich/src/api-v1/share/share.controller.ts
Normal file
46
server/apps/immich/src/api-v1/share/share.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
99
server/apps/immich/src/api-v1/share/share.core.ts
Normal file
99
server/apps/immich/src/api-v1/share/share.core.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
server/apps/immich/src/api-v1/share/share.module.ts
Normal file
19
server/apps/immich/src/api-v1/share/share.module.ts
Normal 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 {}
|
||||
54
server/apps/immich/src/api-v1/share/share.service.ts
Normal file
54
server/apps/immich/src/api-v1/share/share.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
123
server/apps/immich/src/api-v1/share/shared-link.repository.ts
Normal file
123
server/apps/immich/src/api-v1/share/shared-link.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user