Merge remote-tracking branch 'origin/main' into dev/asset-access-control

This commit is contained in:
Matthias Rupp
2022-12-01 17:12:12 +01:00
122 changed files with 2012 additions and 940 deletions

View File

@@ -18,7 +18,14 @@ export class AlbumResponseDto {
}
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((userAlbum) => {
if (userAlbum.userInfo) {
const user = mapUser(userAlbum.userInfo);
sharedUsers.push(user);
}
});
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
@@ -33,7 +40,14 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
}
export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((userAlbum) => {
if (userAlbum.userInfo) {
const user = mapUser(userAlbum.userInfo);
sharedUsers.push(user);
}
});
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,

View File

@@ -26,7 +26,7 @@ import { BackgroundTaskService } from '../../modules/background-task/background-
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto';
@@ -110,7 +110,7 @@ export class AssetController {
}
@Get('/file/:assetId')
@Header('Cache-Control', 'max-age=300')
@Header('Cache-Control', 'max-age=3600')
async serveFile(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@@ -123,15 +123,16 @@ export class AssetController {
}
@Get('/thumbnail/:assetId')
@Header('Cache-Control', 'max-age=300')
@Header('Cache-Control', 'max-age=3600')
async getAssetThumbnail(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Param('assetId') assetId: string,
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
): Promise<any> {
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.getAssetThumbnail(assetId, query, res);
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
}
@Get('/curated-objects')
@@ -174,8 +175,15 @@ export class AssetController {
* Get all AssetEntity belong to the user
*/
@Get('/')
@ApiHeader({
name: 'if-none-match',
description: 'ETag of data already cached on the client',
required: false,
schema: { type: 'string' },
})
async getAllAssets(@GetAuthUser() authUser: AuthUserDto): Promise<AssetResponseDto[]> {
return await this.assetService.getAllAssets(authUser);
const assets = await this.assetService.getAllAssets(authUser);
return assets;
}
@Post('/time-bucket')

View File

@@ -306,7 +306,12 @@ export class AssetService {
}
}
public async getAssetThumbnail(assetId: string, query: GetAssetThumbnailDto, res: Res) {
public async getAssetThumbnail(
assetId: string,
query: GetAssetThumbnailDto,
res: Res,
headers: Record<string, string>,
) {
let fileReadStream: ReadStream;
const asset = await this.assetRepository.findOne({ where: { id: assetId } });
@@ -316,28 +321,22 @@ export class AssetService {
}
try {
if (query.format == GetAssetThumbnailFormatEnum.JPEG) {
if (query.format == GetAssetThumbnailFormatEnum.WEBP && asset.webpPath && asset.webpPath.length > 0) {
if (await processETag(asset.webpPath, res, headers)) {
return;
}
await fs.access(asset.webpPath, constants.R_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
} else {
if (asset.webpPath && asset.webpPath.length > 0) {
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK);
fileReadStream = createReadStream(asset.resizePath);
}
res.header('Cache-Control', 'max-age=300');
return new StreamableFile(fileReadStream);
} catch (e) {
res.header('Cache-Control', 'none');
@@ -349,7 +348,7 @@ export class AssetService {
}
}
public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: any) {
public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: Record<string, string>) {
let fileReadStream: ReadStream;
const asset = await this._assetRepository.getById(assetId);
@@ -371,6 +370,9 @@ export class AssetService {
Logger.error('Error serving IMAGE asset for web', 'ServeFile');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
}
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
@@ -384,7 +386,9 @@ export class AssetService {
res.set({
'Content-Type': asset.mimeType,
});
if (await processETag(asset.originalPath, res, headers)) {
return;
}
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
@@ -392,7 +396,9 @@ export class AssetService {
res.set({
'Content-Type': 'image/webp',
});
if (await processETag(asset.webpPath, res, headers)) {
return;
}
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
@@ -403,6 +409,9 @@ export class AssetService {
if (!asset.resizePath) {
throw new Error('resizePath not set');
}
if (await processETag(asset.resizePath, res, headers)) {
return;
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
@@ -436,9 +445,9 @@ export class AssetService {
if (range) {
/** Extracting Start and End value from Range Header */
let [start, end] = range.replace(/bytes=/, '').split('-');
start = parseInt(start, 10);
end = end ? parseInt(end, 10) : size - 1;
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
let start = parseInt(startStr, 10);
let end = endStr ? parseInt(endStr, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
@@ -475,7 +484,9 @@ export class AssetService {
res.set({
'Content-Type': mimeType,
});
if (await processETag(asset.originalPath, res, headers)) {
return;
}
return new StreamableFile(createReadStream(videoPath));
}
} catch (e) {
@@ -651,3 +662,14 @@ export class AssetService {
}
}
}
async function processETag(path: string, res: Res, headers: Record<string, string>): Promise<boolean> {
const { size, mtimeNs } = await fs.stat(path, { bigint: true });
const etag = `W/"${size}-${mtimeNs}"`;
res.setHeader('ETag', etag);
if (etag === headers['if-none-match']) {
res.status(304);
return true;
}
return false;
}

View File

@@ -19,10 +19,10 @@ export class AssetResponseDto {
mimeType!: string | null;
duration!: string;
webpPath!: string | null;
encodedVideoPath!: string | null;
encodedVideoPath?: string | null;
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
livePhotoVideoId!: string | null;
livePhotoVideoId?: string | null;
}
export function mapAsset(entity: AssetEntity): AssetResponseDto {

View File

@@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { AuthType } from '../../constants/jwt.constant';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
import { OAuthConfigDto } from './dto/oauth-config.dto';
import { OAuthService } from './oauth.service';
@@ -19,7 +20,10 @@ export class OAuthController {
}
@Post('/callback')
public async callback(@Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto) {
public async callback(
@Res({ passthrough: true }) response: Response,
@Body(ValidationPipe) dto: OAuthCallbackDto,
): Promise<LoginResponseDto> {
const loginResponse = await this.oauthService.callback(dto);
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
return loginResponse;

View File

@@ -5,7 +5,6 @@ export class ServerStatsResponseDto {
constructor() {
this.photos = 0;
this.videos = 0;
this.objects = 0;
this.usageByUser = [];
this.usageRaw = 0;
this.usage = '';
@@ -34,7 +33,6 @@ export class ServerStatsResponseDto {
{
photos: 1,
videos: 1,
objects: 1,
diskUsageRaw: 1,
},
],

View File

@@ -3,16 +3,15 @@ import { ApiProperty } from '@nestjs/swagger';
export class UsageByUserDto {
constructor(userId: string) {
this.userId = userId;
this.objects = 0;
this.videos = 0;
this.photos = 0;
this.usageRaw = 0;
this.usage = '0B';
}
@ApiProperty({ type: 'string' })
userId: string;
@ApiProperty({ type: 'integer' })
objects: number;
@ApiProperty({ type: 'integer' })
videos: number;
@ApiProperty({ type: 'integer' })
photos: number;

View File

@@ -7,8 +7,6 @@ import { UsageByUserDto } from './response-dto/usage-by-user-response.dto';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import path from 'path';
import { readdirSync, statSync } from 'fs';
import { asHumanReadable } from '../../utils/human-readable.util';
@Injectable()
@@ -35,59 +33,46 @@ export class ServerInfoService {
}
async getStats(): Promise<ServerStatsResponseDto> {
const res = await this.assetRepository
.createQueryBuilder('asset')
.select(`COUNT(asset.id)`, 'count')
.addSelect(`asset.type`, 'type')
.addSelect(`asset.userId`, 'userId')
.groupBy('asset.type, asset.userId')
.addGroupBy('asset.type')
const serverStats = new ServerStatsResponseDto();
type UserStatsQueryResponse = {
assetType: string;
assetCount: string;
totalSizeInBytes: string;
userId: string;
};
const userStatsQueryResponse: UserStatsQueryResponse[] = await this.assetRepository
.createQueryBuilder('a')
.select('COUNT(a.id)', 'assetCount')
.addSelect('SUM(ei.fileSizeInByte)', 'totalSizeInBytes')
.addSelect('a."userId"')
.addSelect('a.type', 'assetType')
.where('a.isVisible = true')
.leftJoin('a.exifInfo', 'ei')
.groupBy('a."userId"')
.addGroupBy('a.type')
.getRawMany();
const serverStats = new ServerStatsResponseDto();
const tmpMap = new Map<string, UsageByUserDto>();
const getUsageByUser = (id: string) => tmpMap.get(id) || new UsageByUserDto(id);
res.map((item) => {
const usage: UsageByUserDto = getUsageByUser(item.userId);
if (item.type === 'IMAGE') {
usage.photos = parseInt(item.count);
serverStats.photos += usage.photos;
} else if (item.type === 'VIDEO') {
usage.videos = parseInt(item.count);
serverStats.videos += usage.videos;
}
tmpMap.set(item.userId, usage);
userStatsQueryResponse.forEach((r) => {
const usageByUser = getUsageByUser(r.userId);
usageByUser.photos += r.assetType === 'IMAGE' ? parseInt(r.assetCount) : 0;
usageByUser.videos += r.assetType === 'VIDEO' ? parseInt(r.assetCount) : 0;
usageByUser.usageRaw += parseInt(r.totalSizeInBytes);
usageByUser.usage = asHumanReadable(usageByUser.usageRaw);
serverStats.photos += r.assetType === 'IMAGE' ? parseInt(r.assetCount) : 0;
serverStats.videos += r.assetType === 'VIDEO' ? parseInt(r.assetCount) : 0;
serverStats.usageRaw += parseInt(r.totalSizeInBytes);
serverStats.usage = asHumanReadable(serverStats.usageRaw);
tmpMap.set(r.userId, usageByUser);
});
for (const userId of tmpMap.keys()) {
const usage = getUsageByUser(userId);
const userDiskUsage = await ServerInfoService.getDirectoryStats(path.join(APP_UPLOAD_LOCATION, userId));
usage.usageRaw = userDiskUsage.size;
usage.objects = userDiskUsage.fileCount;
usage.usage = asHumanReadable(usage.usageRaw);
serverStats.usageRaw += usage.usageRaw;
serverStats.objects += usage.objects;
}
serverStats.usage = asHumanReadable(serverStats.usageRaw);
serverStats.usageByUser = Array.from(tmpMap.values());
return serverStats;
}
private static async getDirectoryStats(dirPath: string) {
let size = 0;
let fileCount = 0;
for (const filename of readdirSync(dirPath)) {
const absFilename = path.join(dirPath, filename);
const fileStat = statSync(absFilename);
if (fileStat.isFile()) {
size += fileStat.size;
fileCount += 1;
} else if (fileStat.isDirectory()) {
const subDirStat = await ServerInfoService.getDirectoryStats(absFilename);
size += subDirStat.size;
fileCount += subDirStat.fileCount;
}
}
return { size, fileCount };
}
}

View File

@@ -9,7 +9,7 @@ export class UserResponseDto {
profileImagePath!: string;
shouldChangePassword!: boolean;
isAdmin!: boolean;
deletedAt!: Date | null;
deletedAt?: Date;
}
export function mapUser(entity: UserEntity): UserResponseDto {
@@ -22,6 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt || null,
deletedAt: entity.deletedAt,
};
}

View File

@@ -59,7 +59,6 @@ export class UserRepository implements IUserRepository {
user.salt = await bcrypt.genSalt();
user.password = await this.hashPassword(user.password, user.salt);
}
user.isAdmin = false;
return this.userRepository.save(user);
}

View File

@@ -127,5 +127,16 @@ describe('UserService', () => {
});
expect(result).rejects.toBeInstanceOf(NotFoundException);
});
it('cannot delete admin user', () => {
const requestor = adminAuthUser;
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
const result = sui.deleteUser(requestor, adminAuthUser.id);
expect(result).rejects.toBeInstanceOf(BadRequestException);
});
});
});

View File

@@ -119,6 +119,11 @@ export class UserService {
if (!user) {
throw new BadRequestException('User not found');
}
if (user.isAdmin) {
throw new BadRequestException('Cannot delete admin user');
}
try {
const deletedUser = await this.userRepository.delete(user);
return mapUser(deletedUser);

View File

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = {
major: 1,
minor: 35,
minor: 37,
patch: 0,
build: 54,
build: 58,
};

View File

@@ -14,6 +14,7 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy');
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (process.env.NODE_ENV === 'development') {

View File

@@ -35,7 +35,7 @@ export class DownloadService {
fileCount++;
// for easier testing, can be changed before merging.
if (totalSize > HumanReadableSize.GB * 20) {
if (totalSize > HumanReadableSize.GiB * 20) {
complete = false;
this.logger.log(
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(

View File

@@ -1,31 +1,25 @@
const KB = 1000;
const MB = KB * 1000;
const GB = MB * 1000;
const TB = GB * 1000;
const PB = TB * 1000;
const KiB = Math.pow(1024, 1);
const MiB = Math.pow(1024, 2);
const GiB = Math.pow(1024, 3);
const TiB = Math.pow(1024, 4);
const PiB = Math.pow(1024, 5);
export const HumanReadableSize = { KB, MB, GB, TB, PB };
export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB };
export function asHumanReadable(bytes: number, precision = 1) {
if (bytes >= PB) {
return `${(bytes / PB).toFixed(precision)}PB`;
}
export function asHumanReadable(bytes: number, precision = 1): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
if (bytes >= TB) {
return `${(bytes / TB).toFixed(precision)}TB`;
}
let magnitude = 0;
let remainder = bytes;
while (remainder >= 1024) {
if (magnitude + 1 < units.length) {
magnitude++;
remainder /= 1024;
}
else {
break;
}
}
if (bytes >= GB) {
return `${(bytes / GB).toFixed(precision)}GB`;
}
if (bytes >= MB) {
return `${(bytes / MB).toFixed(precision)}MB`;
}
if (bytes >= KB) {
return `${(bytes / KB).toFixed(precision)}KB`;
}
return `${bytes}B`;
return `${remainder.toFixed( magnitude == 0 ? 0 : precision )} ${units[magnitude]}`;
}

File diff suppressed because one or more lines are too long