mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 01:11:36 +03:00
chore: move controllers and middleware (#8119)
This commit is contained in:
117
server/src/middleware/auth.guard.ts
Normal file
117
server/src/middleware/auth.guard.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
applyDecorators,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { IMMICH_API_KEY_NAME } from 'src/domain/auth/auth.constant';
|
||||
import { AuthDto } from 'src/domain/auth/auth.dto';
|
||||
import { AuthService, LoginDetails } from 'src/domain/auth/auth.service';
|
||||
import { ImmichLogger } from 'src/infra/logger';
|
||||
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',
|
||||
}
|
||||
|
||||
export interface AuthenticatedOptions {
|
||||
admin?: true;
|
||||
isShared?: true;
|
||||
}
|
||||
|
||||
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(AdminRoute());
|
||||
}
|
||||
|
||||
if (options.isShared) {
|
||||
decorators.push(SharedLinkRoute());
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
export const PublicRoute = () =>
|
||||
applyDecorators(SetMetadata(Metadata.AUTH_ROUTE, false), ApiSecurity(Metadata.PUBLIC_SECURITY));
|
||||
export const SharedLinkRoute = () =>
|
||||
applyDecorators(SetMetadata(Metadata.SHARED_ROUTE, true), ApiQuery({ name: 'key', type: String, required: false }));
|
||||
export const AdminRoute = (value = true) => SetMetadata(Metadata.ADMIN_ROUTE, value);
|
||||
|
||||
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
|
||||
return context.switchToHttp().getRequest<{ user: AuthDto }>().user;
|
||||
});
|
||||
|
||||
export const FileResponse = () =>
|
||||
ApiOkResponse({
|
||||
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
|
||||
});
|
||||
|
||||
export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userAgent = UAParser(request.headers['user-agent']);
|
||||
|
||||
return {
|
||||
clientIp: request.ip,
|
||||
isSecure: request.secure,
|
||||
deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '',
|
||||
deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '',
|
||||
};
|
||||
});
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: AuthDto;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private logger = new ImmichLogger(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 request = context.switchToHttp().getRequest<AuthRequest>();
|
||||
|
||||
const authDto = await this.authService.validate(request.headers, request.query as Record<string, string>);
|
||||
if (authDto.sharedLink && !isSharedRoute) {
|
||||
this.logger.warn(`Denied access to non-shared route: ${request.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAdminRoute && !authDto.user.isAdmin) {
|
||||
this.logger.warn(`Denied access to admin only route: ${request.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
request.user = authDto;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
35
server/src/middleware/error.interceptor.ts
Normal file
35
server/src/middleware/error.interceptor.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, catchError, throwError } from 'rxjs';
|
||||
import { routeToErrorMessage } from 'src/immich/app.utils';
|
||||
import { ImmichLogger } from 'src/infra/logger';
|
||||
import { isConnectionAborted } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements NestInterceptor {
|
||||
private logger = new ImmichLogger(ErrorInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
catchError((error) =>
|
||||
throwError(() => {
|
||||
if (error instanceof HttpException === false) {
|
||||
const errorMessage = routeToErrorMessage(context.getHandler().name);
|
||||
if (!isConnectionAborted(error)) {
|
||||
this.logger.error(errorMessage, error, error?.errors, error?.stack);
|
||||
}
|
||||
return new InternalServerErrorException(errorMessage);
|
||||
} else {
|
||||
return error;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
177
server/src/middleware/file-upload.interceptor.ts
Normal file
177
server/src/middleware/file-upload.interceptor.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
||||
import { NextFunction, RequestHandler } from 'express';
|
||||
import multer, { StorageEngine, diskStorage } from 'multer';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AssetService, UploadFieldName, UploadFile } from 'src/domain/asset/asset.service';
|
||||
import { ImmichLogger } from 'src/infra/logger';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
|
||||
export enum Route {
|
||||
ASSET = 'asset',
|
||||
USER = 'user',
|
||||
}
|
||||
|
||||
export interface ImmichFile extends Express.Multer.File {
|
||||
/** sha1 hash of file */
|
||||
uuid: string;
|
||||
checksum: Buffer;
|
||||
}
|
||||
|
||||
export function mapToUploadFile(file: ImmichFile): UploadFile {
|
||||
return {
|
||||
uuid: file.uuid,
|
||||
checksum: file.checksum,
|
||||
originalPath: file.path,
|
||||
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
type DiskStorageCallback = (error: Error | null, result: string) => void;
|
||||
|
||||
type ImmichMulterFile = Express.Multer.File & { uuid: string };
|
||||
|
||||
interface Callback<T> {
|
||||
(error: Error): void;
|
||||
(error: null, result: T): void;
|
||||
}
|
||||
|
||||
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
|
||||
try {
|
||||
return callback(null, target());
|
||||
} catch (error: Error | any) {
|
||||
return callback(error);
|
||||
}
|
||||
};
|
||||
|
||||
const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
|
||||
return {
|
||||
auth: request.user || null,
|
||||
fieldName: file.fieldname as UploadFieldName,
|
||||
file: mapToUploadFile(file as ImmichFile),
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadInterceptor implements NestInterceptor {
|
||||
private logger = new ImmichLogger(FileUploadInterceptor.name);
|
||||
|
||||
private handlers: {
|
||||
userProfile: RequestHandler;
|
||||
assetUpload: RequestHandler;
|
||||
};
|
||||
private defaultStorage: StorageEngine;
|
||||
|
||||
constructor(
|
||||
private reflect: Reflector,
|
||||
private assetService: AssetService,
|
||||
) {
|
||||
this.defaultStorage = diskStorage({
|
||||
filename: this.filename.bind(this),
|
||||
destination: this.destination.bind(this),
|
||||
});
|
||||
|
||||
const instance = multer({
|
||||
fileFilter: this.fileFilter.bind(this),
|
||||
storage: {
|
||||
_handleFile: this.handleFile.bind(this),
|
||||
_removeFile: this.removeFile.bind(this),
|
||||
},
|
||||
});
|
||||
|
||||
this.handlers = {
|
||||
userProfile: instance.single(UploadFieldName.PROFILE_DATA),
|
||||
assetUpload: instance.fields([
|
||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.LIVE_PHOTO_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
||||
const context_ = context.switchToHttp();
|
||||
const route = this.reflect.get<string>(PATH_METADATA, context.getClass());
|
||||
|
||||
const handler: RequestHandler | null = this.getHandler(route as Route);
|
||||
if (handler) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
|
||||
handler(context_.getRequest(), context_.getResponse(), next);
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`Skipping invalid file upload route: ${route}`);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
|
||||
}
|
||||
|
||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFilename(asRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>);
|
||||
}
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
(file as ImmichMulterFile).uuid = randomUUID();
|
||||
if (!this.isAssetUploadFile(file)) {
|
||||
this.defaultStorage._handleFile(request, file, callback);
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = createHash('sha1');
|
||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
||||
this.defaultStorage._handleFile(request, file, (error, info) => {
|
||||
if (error) {
|
||||
hash.destroy();
|
||||
callback(error);
|
||||
} else {
|
||||
callback(null, { ...info, checksum: hash.digest() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
||||
this.defaultStorage._removeFile(request, file, callback);
|
||||
}
|
||||
|
||||
private isAssetUploadFile(file: Express.Multer.File) {
|
||||
switch (file.fieldname as UploadFieldName) {
|
||||
case UploadFieldName.ASSET_DATA:
|
||||
case UploadFieldName.LIVE_PHOTO_DATA: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private getHandler(route: Route) {
|
||||
switch (route) {
|
||||
case Route.ASSET: {
|
||||
return this.handlers.assetUpload;
|
||||
}
|
||||
|
||||
case Route.USER: {
|
||||
return this.handlers.userProfile;
|
||||
}
|
||||
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user