1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server): guards, decorators, and utils (#3060)

This commit is contained in:
Jason Rasmussen 2023-07-01 14:27:34 -04:00 committed by GitHub
parent f55b3add80
commit d69fa3ceae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 243 additions and 255 deletions

View File

@ -1,13 +1,13 @@
import { import {
AlbumResponseDto, AlbumResponseDto,
AuthService, AuthService,
AuthUserDto,
CreateAlbumDto, CreateAlbumDto,
SharedLinkCreateDto, SharedLinkCreateDto,
SharedLinkResponseDto, SharedLinkResponseDto,
UserService, UserService,
} from '@app/domain'; } from '@app/domain';
import { AppModule } from '@app/immich/app.module'; import { AppModule } from '@app/immich/app.module';
import { AuthUserDto } from '@app/immich/decorators/auth-user.decorator';
import { SharedLinkType } from '@app/infra/entities'; import { SharedLinkType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';

View File

@ -1,5 +1,5 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ArrayNotEmpty } from 'class-validator'; import { ArrayNotEmpty } from 'class-validator';
import { ValidateUUID } from '../../domain.util';
export class AddUsersDto { export class AddUsersDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })

View File

@ -1,6 +1,6 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
import { ValidateUUID } from '../../domain.util';
export class CreateAlbumDto { export class CreateAlbumDto {
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,6 +1,6 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from 'class-validator'; import { IsOptional } from 'class-validator';
import { ValidateUUID } from '../../domain.util';
export class UpdateAlbumDto { export class UpdateAlbumDto {
@IsOptional() @IsOptional()

View File

@ -1,8 +1,7 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { toBoolean } from '@app/immich/utils/transform.util';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator'; import { IsBoolean, IsOptional } from 'class-validator';
import { toBoolean, ValidateUUID } from '../../domain.util';
export class GetAlbumsDto { export class GetAlbumsDto {
@IsOptional() @IsOptional()

View File

@ -1,4 +1,4 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator'; import { ValidateUUID } from '../../domain.util';
export class AssetIdsDto { export class AssetIdsDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })

View File

@ -1,6 +1,6 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsOptional, IsPositive } from 'class-validator'; import { IsInt, IsOptional, IsPositive } from 'class-validator';
import { ValidateUUID } from '../../domain.util';
export class DownloadDto { export class DownloadDto {
@ValidateUUID({ each: true, optional: true }) @ValidateUUID({ each: true, optional: true })

View File

@ -1,7 +1,7 @@
import { toBoolean } from '@app/immich/utils/transform.util';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsDate, IsOptional } from 'class-validator'; import { IsBoolean, IsDate, IsOptional } from 'class-validator';
import { toBoolean } from '../../domain.util';
export class MapMarkerDto { export class MapMarkerDto {
@ApiProperty() @ApiProperty()

View File

@ -1,4 +1,39 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { basename, extname } from 'node:path'; import { basename, extname } from 'node:path';
import sanitize from 'sanitize-filename';
export type Options = {
optional?: boolean;
each?: boolean;
};
export function ValidateUUID({ optional, each }: Options = { optional: false, each: false }) {
return applyDecorators(
IsUUID('4', { each }),
ApiProperty({ format: 'uuid' }),
optional ? IsOptional() : IsNotEmpty(),
each ? IsArray() : IsString(),
);
}
interface IValue {
value?: string;
}
export const toBoolean = ({ value }: IValue) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
return false;
}
return value;
};
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));
export function getFileNameWithoutExtension(path: string): string { export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path)); return basename(path, extname(path));

View File

@ -1,7 +1,7 @@
import { toBoolean } from '@app/immich/utils/transform.util';
import { AssetType } from '@app/infra/entities'; import { AssetType } from '@app/infra/entities';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toBoolean } from '../../domain.util';
export class SearchDto { export class SearchDto {
@IsString() @IsString()

View File

@ -2,7 +2,7 @@ import { SharedLinkType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsBoolean, IsDate, IsEnum, IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsDate, IsEnum, IsOptional, IsString } from 'class-validator';
import { ValidateUUID } from '../../immich/decorators/validate-uuid.decorator'; import { ValidateUUID } from '../domain.util';
export class SharedLinkCreateDto { export class SharedLinkCreateDto {
@IsEnum(SharedLinkType) @IsEnum(SharedLinkType)

View File

@ -1,6 +1,6 @@
import { toEmail, toSanitized } from '@app/immich/utils/transform.util';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toEmail, toSanitized } from '../../domain.util';
export class CreateUserDto { export class CreateUserDto {
@IsEmail({ require_tld: false }) @IsEmail({ require_tld: false })

View File

@ -1,7 +1,7 @@
import { toEmail, toSanitized } from '@app/immich/utils/transform.util';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { toEmail, toSanitized } from '../../domain.util';
export class UpdateUserDto { export class UpdateUserDto {
@IsOptional() @IsOptional()

View File

@ -1,10 +1,9 @@
import { AlbumResponseDto } from '@app/domain'; import { AlbumResponseDto, AuthUserDto } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard';
import { UseValidation } from '../../app.utils';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { UseValidation } from '../../decorators/use-validation.decorator';
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';

View File

@ -1,8 +1,7 @@
import { AlbumResponseDto, mapUser } from '@app/domain'; import { AlbumResponseDto, AuthUserDto, mapUser } from '@app/domain';
import { AlbumEntity, UserEntity } from '@app/infra/entities'; import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { userEntityStub } from '@test'; import { userEntityStub } from '@test';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { IAlbumRepository } from './album-repository'; import { IAlbumRepository } from './album-repository';
import { AlbumService } from './album.service'; import { AlbumService } from './album.service';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';

View File

@ -1,7 +1,6 @@
import { AlbumResponseDto, mapAlbum } from '@app/domain'; import { AlbumResponseDto, AuthUserDto, mapAlbum } from '@app/domain';
import { AlbumEntity } from '@app/infra/entities'; import { AlbumEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { IAlbumRepository } from './album-repository'; import { IAlbumRepository } from './album-repository';
import { AddAssetsDto } from './dto/add-assets.dto'; import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto';

View File

@ -1,4 +1,4 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator'; import { ValidateUUID } from '@app/domain';
export class AddAssetsDto { export class AddAssetsDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })

View File

@ -1,4 +1,4 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator'; import { ValidateUUID } from '@app/domain';
export class AddUsersDto { export class AddUsersDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })

View File

@ -1,4 +1,4 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator'; import { ValidateUUID } from '@app/domain';
export class RemoveAssetsDto { export class RemoveAssetsDto {
@ValidateUUID({ each: true }) @ValidateUUID({ each: true })

View File

@ -1,4 +1,4 @@
import { AssetResponseDto } from '@app/domain'; import { AssetResponseDto, AuthUserDto } from '@app/domain';
import { import {
Body, Body,
Controller, Controller,
@ -21,10 +21,9 @@ import {
import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import FileNotEmptyValidator from '../validation/file-not-empty-validator'; import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';

View File

@ -1,7 +1,7 @@
import { toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator'; import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export class AssetSearchDto { export class AssetSearchDto {
@IsOptional() @IsOptional()

View File

@ -1,9 +1,9 @@
import { toBoolean, toSanitized } from '@app/domain';
import { AssetType } from '@app/infra/entities'; import { AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ImmichFile } from '../../../config/asset-upload.config'; import { ImmichFile } from '../../../config/asset-upload.config';
import { toBoolean, toSanitized } from '../../../utils/transform.util';
export class CreateAssetBase { export class CreateAssetBase {
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,7 +1,7 @@
import { toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export class GetAssetByTimeBucketDto { export class GetAssetByTimeBucketDto {
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,7 +1,7 @@
import { toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export enum TimeGroupEnum { export enum TimeGroupEnum {
Day = 'day', Day = 'day',

View File

@ -1,7 +1,7 @@
import { toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator'; import { IsBoolean, IsOptional } from 'class-validator';
import { toBoolean } from '../../../utils/transform.util';
export class ServeFileDto { export class ServeFileDto {
@IsOptional() @IsOptional()

View File

@ -0,0 +1,118 @@
import { AuthService, AuthUserDto, IMMICH_API_KEY_NAME, LoginDetails } from '@app/domain';
import {
applyDecorators,
CanActivate,
createParamDecorator,
ExecutionContext,
Injectable,
Logger,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { UAParser } from 'ua-parser-js';
export enum Metadata {
AUTH_ROUTE = 'auth_route',
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
PUBLIC_SECURITY = 'public_security',
}
const adminDecorator = SetMetadata(Metadata.ADMIN_ROUTE, true);
const sharedLinkDecorators = [
SetMetadata(Metadata.SHARED_ROUTE, true),
ApiQuery({ name: 'key', type: String, required: false }),
];
export interface AuthenticatedOptions {
admin?: boolean;
isShared?: boolean;
}
export const Authenticated = (options: AuthenticatedOptions = {}) => {
const decorators: MethodDecorator[] = [
ApiBearerAuth(),
ApiCookieAuth(),
ApiSecurity(IMMICH_API_KEY_NAME),
SetMetadata(Metadata.AUTH_ROUTE, true),
];
if (options.admin) {
decorators.push(adminDecorator);
}
if (options.isShared) {
decorators.push(...sharedLinkDecorators);
}
return applyDecorators(...decorators);
};
export const PublicRoute = () =>
applyDecorators(SetMetadata(Metadata.AUTH_ROUTE, false), ApiSecurity(Metadata.PUBLIC_SECURITY));
export const SharedLinkRoute = () => applyDecorators(...sharedLinkDecorators);
export const AdminRoute = () => adminDecorator;
export const AuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
});
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
const req = ctx.switchToHttp().getRequest();
const userAgent = UAParser(req.headers['user-agent']);
return {
clientIp: req.clientIp,
isSecure: req.secure,
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
deviceOS: userAgent.os.name || req.headers.devicetype || '',
};
});
export interface AuthRequest extends Request {
user?: AuthUserDto;
}
@Injectable()
export class AppGuard implements CanActivate {
private logger = new Logger(AppGuard.name);
constructor(private reflector: Reflector, private authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
if (!isAuthRoute) {
return true;
}
const req = context.switchToHttp().getRequest<AuthRequest>();
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
if (!authDto) {
this.logger.warn(`Denied access to authenticated route: ${req.path}`);
return false;
}
if (authDto.isPublicUser && !isSharedRoute) {
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
return false;
}
if (isAdminRoute && !authDto.isAdmin) {
this.logger.warn(`Denied access to admin only route: ${req.path}`);
return false;
}
req.user = authDto;
return true;
}
}

View File

@ -5,6 +5,7 @@ import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { AlbumModule } from './api-v1/album/album.module'; import { AlbumModule } from './api-v1/album/album.module';
import { AssetModule } from './api-v1/asset/asset.module'; import { AssetModule } from './api-v1/asset/asset.module';
import { AppGuard } from './app.guard';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { import {
AlbumController, AlbumController,
@ -23,7 +24,6 @@ import {
TagController, TagController,
UserController, UserController,
} from './controllers'; } from './controllers';
import { AuthGuard } from './middlewares/auth.guard';
@Module({ @Module({
imports: [ imports: [
@ -52,8 +52,8 @@ import { AuthGuard } from './middlewares/auth.guard';
], ],
providers: [ providers: [
// //
{ provide: APP_GUARD, useExisting: AuthGuard }, { provide: APP_GUARD, useExisting: AppGuard },
AuthGuard, AppGuard,
AppService, AppService,
], ],
}) })

View File

@ -15,12 +15,29 @@ import {
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import path from 'path'; import path from 'path';
import { Metadata } from './decorators/authenticated.decorator';
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
import { Metadata } from './app.guard';
export function UseValidation() {
return applyDecorators(
UsePipes(
new ValidationPipe({
transform: true,
whitelist: true,
}),
),
);
}
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length }); return new StreamableFile(stream, { type, length });
}; };
export function patchFormData(latin1: string) {
return Buffer.from(latin1, 'latin1').toString('utf8');
}
function sortKeys<T extends object>(obj: T): T { function sortKeys<T extends object>(obj: T): T {
if (!obj) { if (!obj) {
return obj; return obj;

View File

@ -1,6 +1,6 @@
import { Request } from 'express'; import { Request } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import { AuthRequest } from '../decorators/auth-user.decorator'; import { AuthRequest } from '../app.guard';
import { multerUtils } from './asset-upload.config'; import { multerUtils } from './asset-upload.config';
const { fileFilter, destination, filename } = multerUtils; const { fileFilter, destination, filename } = multerUtils;

View File

@ -1,4 +1,4 @@
import { isSidecarFileType, isSupportedFileType } from '@app/domain'; import { AuthUserDto, isSidecarFileType, isSupportedFileType } from '@app/domain';
import { StorageCore, StorageFolder } from '@app/domain/storage'; import { StorageCore, StorageFolder } from '@app/domain/storage';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
@ -7,8 +7,8 @@ import { existsSync, mkdirSync } from 'fs';
import { diskStorage, StorageEngine } from 'multer'; import { diskStorage, StorageEngine } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { AuthRequest, AuthUserDto } from '../decorators/auth-user.decorator'; import { AuthRequest } from '../app.guard';
import { patchFormData } from '../utils/path-form-data.util'; import { patchFormData } from '../app.utils';
export interface ImmichFile extends Express.Multer.File { export interface ImmichFile extends Express.Multer.File {
/** sha1 hash of file */ /** sha1 hash of file */

View File

@ -1,6 +1,6 @@
import { Request } from 'express'; import { Request } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import { AuthRequest } from '../decorators/auth-user.decorator'; import { AuthRequest } from '../app.guard';
import { multerUtils } from './profile-image-upload.config'; import { multerUtils } from './profile-image-upload.config';
const { fileFilter, destination, filename } = multerUtils; const { fileFilter, destination, filename } = multerUtils;

View File

@ -1,12 +1,12 @@
import { StorageCore, StorageFolder } from '@app/domain/storage'; import { AuthUserDto, StorageCore, StorageFolder } from '@app/domain';
import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { AuthRequest, AuthUserDto } from '../decorators/auth-user.decorator'; import { AuthRequest } from '../app.guard';
import { patchFormData } from '../utils/path-form-data.util'; import { patchFormData } from '../app.utils';
export const profileImageUploadOption: MulterOptions = { export const profileImageUploadOption: MulterOptions = {
fileFilter, fileFilter,

View File

@ -10,9 +10,8 @@ import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe'; import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
import { AuthUser } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser } from '../app.guard';
import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Album') @ApiTags('Album')

View File

@ -8,9 +8,8 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthUser } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser } from '../app.guard';
import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('API Key') @ApiTags('API Key')

View File

@ -11,10 +11,8 @@ import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto'; import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { asStreamableFile } from '../app.utils'; import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
import { AuthUser } from '../decorators/auth-user.decorator'; import { asStreamableFile, UseValidation } from '../app.utils';
import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Asset') @ApiTags('Asset')

View File

@ -18,9 +18,8 @@ import {
import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common';
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger'; import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AuthUser, GetLoginDetails } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser, GetLoginDetails, PublicRoute } from '../app.guard';
import { Authenticated, PublicRoute } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Authentication') @ApiTags('Authentication')

View File

@ -1,8 +1,8 @@
import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobService, JobStatusDto } from '@app/domain'; import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobService, JobStatusDto } from '@app/domain';
import { Body, Controller, Get, Param, Put } from '@nestjs/common'; import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator'; import { Authenticated } from '../app.guard';
import { UseValidation } from '../decorators/use-validation.decorator'; import { UseValidation } from '../app.utils';
@ApiTags('Job') @ApiTags('Job')
@Controller('jobs') @Controller('jobs')

View File

@ -11,9 +11,8 @@ import {
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AuthUser, GetLoginDetails } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser, GetLoginDetails, PublicRoute } from '../app.guard';
import { Authenticated, PublicRoute } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('OAuth') @ApiTags('OAuth')
@Controller('oauth') @Controller('oauth')

View File

@ -1,9 +1,8 @@
import { PartnerDirection, PartnerService, UserResponseDto } from '@app/domain'; import { AuthUserDto, PartnerDirection, PartnerService, UserResponseDto } from '@app/domain';
import { Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuthUser, AuthUserDto } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser } from '../app.guard';
import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Partner') @ApiTags('Partner')

View File

@ -8,9 +8,8 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common'; import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthUser } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser } from '../app.guard';
import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
function asStreamableFile({ stream, type, length }: ImmichReadStream) { function asStreamableFile({ stream, type, length }: ImmichReadStream) {

View File

@ -8,9 +8,8 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthUser } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser } from '../app.guard';
import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('Search') @ApiTags('Search')
@Controller('search') @Controller('search')

View File

@ -7,8 +7,8 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AdminRoute, Authenticated, PublicRoute } from '../decorators/authenticated.decorator'; import { AdminRoute, Authenticated, PublicRoute } from '../app.guard';
import { UseValidation } from '../decorators/use-validation.decorator'; import { UseValidation } from '../app.utils';
@ApiTags('Server Info') @ApiTags('Server Info')
@Controller('server-info') @Controller('server-info')

View File

@ -9,9 +9,8 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthUser } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Shared Link') @ApiTags('Shared Link')

View File

@ -1,8 +1,8 @@
import { SystemConfigDto, SystemConfigService, SystemConfigTemplateStorageOptionDto } from '@app/domain'; import { SystemConfigDto, SystemConfigService, SystemConfigTemplateStorageOptionDto } from '@app/domain';
import { Body, Controller, Get, Put } from '@nestjs/common'; import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator'; import { Authenticated } from '../app.guard';
import { UseValidation } from '../decorators/use-validation.decorator'; import { UseValidation } from '../app.utils';
@ApiTags('System Config') @ApiTags('System Config')
@Controller('system-config') @Controller('system-config')

View File

@ -2,6 +2,7 @@ import {
AssetIdsDto, AssetIdsDto,
AssetIdsResponseDto, AssetIdsResponseDto,
AssetResponseDto, AssetResponseDto,
AuthUserDto,
CreateTagDto, CreateTagDto,
TagResponseDto, TagResponseDto,
TagService, TagService,
@ -9,9 +10,8 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthUser, AuthUserDto } from '../decorators/auth-user.decorator'; import { Authenticated, AuthUser } from '../app.guard';
import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../app.utils';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Tag') @ApiTags('Tag')

View File

@ -1,4 +1,5 @@
import { import {
AuthUserDto,
CreateProfileImageDto, CreateProfileImageDto,
CreateProfileImageResponseDto, CreateProfileImageResponseDto,
CreateUserDto, CreateUserDto,
@ -27,10 +28,9 @@ import {
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { AdminRoute, Authenticated, AuthUser, PublicRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
import { profileImageUploadOption } from '../config/profile-image-upload.config'; import { profileImageUploadOption } from '../config/profile-image-upload.config';
import { AuthUser, AuthUserDto } from '../decorators/auth-user.decorator';
import { AdminRoute, Authenticated, PublicRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ApiTags('User') @ApiTags('User')
@Controller('user') @Controller('user')

View File

@ -1,25 +0,0 @@
export { AuthUserDto } from '@app/domain';
import { AuthUserDto, LoginDetails } from '@app/domain';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { UAParser } from 'ua-parser-js';
export interface AuthRequest extends Request {
user?: AuthUserDto;
}
export const AuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
});
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
const req = ctx.switchToHttp().getRequest();
const userAgent = UAParser(req.headers['user-agent']);
return {
clientIp: req.clientIp,
isSecure: req.secure,
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
deviceOS: userAgent.os.name || req.headers.devicetype || '',
};
});

View File

@ -1,46 +0,0 @@
import { IMMICH_API_KEY_NAME } from '@app/domain';
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiCookieAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger';
interface AuthenticatedOptions {
admin?: boolean;
isShared?: boolean;
}
export enum Metadata {
AUTH_ROUTE = 'auth_route',
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
PUBLIC_SECURITY = 'public_security',
}
const adminDecorator = SetMetadata(Metadata.ADMIN_ROUTE, true);
const sharedLinkDecorators = [
SetMetadata(Metadata.SHARED_ROUTE, true),
ApiQuery({ name: 'key', type: String, required: false }),
];
export const Authenticated = (options: AuthenticatedOptions = {}) => {
const decorators: MethodDecorator[] = [
ApiBearerAuth(),
ApiCookieAuth(),
ApiSecurity(IMMICH_API_KEY_NAME),
SetMetadata(Metadata.AUTH_ROUTE, true),
];
if (options.admin) {
decorators.push(adminDecorator);
}
if (options.isShared) {
decorators.push(...sharedLinkDecorators);
}
return applyDecorators(...decorators);
};
export const PublicRoute = () =>
applyDecorators(SetMetadata(Metadata.AUTH_ROUTE, false), ApiSecurity(Metadata.PUBLIC_SECURITY));
export const SharedLinkRoute = () => applyDecorators(...sharedLinkDecorators);
export const AdminRoute = () => adminDecorator;

View File

@ -1,12 +0,0 @@
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
export function UseValidation() {
return applyDecorators(
UsePipes(
new ValidationPipe({
transform: true,
whitelist: true,
}),
),
);
}

View File

@ -1,17 +0,0 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export type Options = {
optional?: boolean;
each?: boolean;
};
export function ValidateUUID({ optional, each }: Options = { optional: false, each: false }) {
return applyDecorators(
IsUUID('4', { each }),
ApiProperty({ format: 'uuid' }),
optional ? IsOptional() : IsNotEmpty(),
each ? IsArray() : IsString(),
);
}

View File

@ -1,46 +0,0 @@
import { AuthService } from '@app/domain';
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthRequest } from '../decorators/auth-user.decorator';
import { Metadata } from '../decorators/authenticated.decorator';
@Injectable()
export class AuthGuard implements CanActivate {
private logger = new Logger(AuthGuard.name);
constructor(private reflector: Reflector, private authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
if (!isAuthRoute) {
return true;
}
const req = context.switchToHttp().getRequest<AuthRequest>();
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
if (!authDto) {
this.logger.warn(`Denied access to authenticated route: ${req.path}`);
return false;
}
if (authDto.isPublicUser && !isSharedRoute) {
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
return false;
}
if (isAdminRoute && !authDto.isAdmin) {
this.logger.warn(`Denied access to admin only route: ${req.path}`);
return false;
}
req.user = authDto;
return true;
}
}

View File

@ -1,3 +0,0 @@
export function patchFormData(latin1: string) {
return Buffer.from(latin1, 'latin1').toString('utf8');
}

View File

@ -1,18 +0,0 @@
import sanitize from 'sanitize-filename';
interface IValue {
value?: string;
}
export const toBoolean = ({ value }: IValue) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
return false;
}
return value;
};
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));

View File

@ -1,5 +1,5 @@
import { AuthUserDto } from '@app/immich/decorators/auth-user.decorator'; import { AuthUserDto } from '@app/domain';
import { AuthGuard } from '@app/immich/middlewares/auth.guard'; import { AppGuard } from '@app/immich/app.guard';
import { CanActivate, ExecutionContext } from '@nestjs/common'; import { CanActivate, ExecutionContext } from '@nestjs/common';
import { TestingModuleBuilder } from '@nestjs/testing'; import { TestingModuleBuilder } from '@nestjs/testing';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@ -34,5 +34,5 @@ export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCa
return true; return true;
}, },
}; };
return builder.overrideProvider(AuthGuard).useValue(canActivate); return builder.overrideProvider(AppGuard).useValue(canActivate);
} }