This commit is contained in:
Jason Rasmussen 2025-02-11 12:30:21 -05:00
parent bf1f8da884
commit 1f5393d02c
No known key found for this signature in database
GPG Key ID: 2EF24B77EAFA4A41
15 changed files with 1203 additions and 83 deletions

1069
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,10 +35,13 @@
"email:dev": "email dev -p 3050 --dir src/emails"
},
"dependencies": {
"@apollo/server": "^4.11.3",
"@nestjs/apollo": "^13.0.2",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
"@nestjs/event-emitter": "^3.0.0",
"@nestjs/graphql": "^13.0.2",
"@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^5.0.0",
@ -63,6 +66,7 @@
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"graphql": "^16.10.0",
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.3.2",

View File

@ -1,12 +1,15 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { join } from 'node:path';
import postgres from 'postgres';
import { commands } from 'src/commands';
import { IWorker } from 'src/constants';
@ -24,6 +27,7 @@ import { providers, repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { resolvers } from 'src/resolvers';
import { services } from 'src/services';
import { CliService } from 'src/services/cli.service';
import { DatabaseService } from 'src/services/database.service';
@ -104,9 +108,28 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
}
@Module({
imports: [...imports, ScheduleModule.forRoot()],
imports: [
...imports,
ScheduleModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
playground: true,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
debug: true,
buildSchemaOptions: {
numberScalarMode: 'integer',
},
}),
],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }],
providers: [
//
...common,
...middleware,
...resolvers,
{ provide: IWorker, useValue: ImmichWorker.API },
],
})
export class ApiModule extends BaseModule {}

View File

@ -33,7 +33,7 @@ export const citiesFile = 'cities500.txt';
export const MOBILE_REDIRECT = 'app.immich:///oauth-callback';
export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico', '/graphql'];
export const FACE_THUMBNAIL_SIZE = 250;

View File

@ -9,6 +9,7 @@ import {
Param,
Post,
Put,
Req,
Res,
UploadedFile,
UseInterceptors,
@ -38,8 +39,21 @@ export class UserController {
@Get()
@Authenticated()
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
return this.service.search(auth);
async searchUsers(@Req() req: Request): Promise<UserResponseDto[]> {
const response = await fetch(`http://localhost:2283/graphql`, {
method: 'POST',
body: JSON.stringify({
operationName: null,
query: '{ users { id name email } }',
}),
headers: {
...req.headers,
'Content-Type': 'application/json',
},
});
const { data } = await response.json();
return data.users;
}
@Get('me')

View File

@ -5,19 +5,18 @@ import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-re
import { ImmichHeader } from 'src/enum';
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service';
import { fromMaybeArray } from 'src/utils/request';
import { fromMaybeArray, getReqRes } from 'src/utils/request';
@Injectable()
export class AssetUploadInterceptor implements NestInterceptor {
constructor(private service: AssetMediaService) {}
async intercept(context: ExecutionContext, next: CallHandler<any>) {
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
const { type, req, res } = getReqRes<AuthenticatedRequest, Response<AssetMediaResponseDto>>(context);
const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
if (response) {
if (response && type === 'http') {
res.status(200);
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id });
}

View File

@ -13,6 +13,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichQuery, MetadataKey, Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { getReqRes } from 'src/utils/request';
import { UAParser } from 'ua-parser-js';
type AdminRoute = { admin?: true };
@ -35,7 +36,8 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator =
};
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => {
return context.switchToHttp().getRequest<AuthenticatedRequest>().user;
const { req } = getReqRes<AuthenticatedRequest>(context);
return req.user;
});
export const FileResponse = () =>
@ -86,12 +88,12 @@ export class AuthGuard implements CanActivate {
sharedLink: sharedLinkRoute,
permission,
} = { sharedLink: false, admin: false, ...options };
const request = context.switchToHttp().getRequest<AuthRequest>();
const { req } = getReqRes<AuthenticatedRequest>(context);
request.user = await this.authService.authenticate({
headers: request.headers,
queryParams: request.query as Record<string, string>,
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
req.user = await this.authService.authenticate({
headers: req.headers,
queryParams: req.query as Record<string, string>,
metadata: { adminRoute, sharedLinkRoute, permission, uri: req.path },
});
return true;

View File

@ -1,8 +1,18 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Response } from 'express';
import { GqlContextType } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { ClsService } from 'nestjs-cls';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { getReqRes } from 'src/utils/request';
type StructuredError = {
status: number;
body: {
[key: string]: unknown;
message?: string;
};
};
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter<Error> {
@ -14,15 +24,20 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
}
catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { res } = getReqRes(host);
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
const message = { ...body, statusCode: status, correlationId: this.cls.getId() };
if (host.getType<GqlContextType>() === 'graphql') {
throw new GraphQLError(body?.message || 'Error', { extensions: message });
}
if (!res.headersSent) {
res.status(status).json(message);
}
}
private fromError(error: Error) {
private fromError(error: Error): StructuredError {
logGlobalError(this.logger, error);
if (error instanceof HttpException) {
@ -34,7 +49,7 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
body = { message: body };
}
return { status, body };
return { status, body } as StructuredError;
}
return {

View File

@ -1,7 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable, finalize } from 'rxjs';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { getReqRes } from 'src/utils/request';
const maxArrayLength = 100;
const replacer = (key: string, value: unknown) => {
@ -23,10 +23,7 @@ export class LoggingInterceptor implements NestInterceptor {
}
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const handler = context.switchToHttp();
const req = handler.getRequest<Request>();
const res = handler.getResponse<Response>();
const { req, res } = getReqRes(context);
const { method, ip, url } = req;
const start = performance.now();
@ -35,9 +32,7 @@ export class LoggingInterceptor implements NestInterceptor {
finalize(() => {
const finish = performance.now();
const duration = (finish - start).toFixed(2);
const { statusCode } = res;
this.logger.debug(`${method} ${url} ${statusCode} ${duration}ms ${ip}`);
this.logger.debug(`${method} ${url} ${res?.statusCode || ''} ${duration}ms ${ip}`);
if (req.body && Object.keys(req.body).length > 0) {
this.logger.verbose(JSON.stringify(req.body, replacer));
}

View File

@ -0,0 +1,27 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { UserAvatarColor } from 'src/enum';
registerEnumType(UserAvatarColor, {
name: 'UserAvatarColor',
});
@ObjectType()
export class User {
@Field()
id!: string;
@Field()
name!: string;
@Field()
email!: string;
@Field(() => UserAvatarColor)
avatarColor!: UserAvatarColor;
@Field()
profileImagePath!: string;
@Field({ nullable: true })
profileChangedAt!: Date;
}

View File

@ -0,0 +1,3 @@
import { UsersResolver } from 'src/resolvers/user.resolver';
export const resolvers = [UsersResolver];

View File

@ -0,0 +1,22 @@
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
import { AuthDto } from 'src/dtos/auth.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { User } from 'src/models/user.model';
import { UserService } from 'src/services/user.service';
@Resolver(() => User)
export class UsersResolver {
constructor(private service: UserService) {}
@Authenticated()
@Query(() => User)
async user(@Args('id', { type: () => Int }) id: string) {
return this.service.get(id);
}
@Authenticated()
@Query(() => [User])
async users(@Auth() auth: AuthDto) {
return this.service.search(auth);
}
}

35
server/src/schema.gql Normal file
View File

@ -0,0 +1,35 @@
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime
type Query {
user(id: Int!): User!
users: [User!]!
}
type User {
avatarColor: UserAvatarColor!
email: String!
id: String!
name: String!
profileChangedAt: DateTime
profileImagePath: String!
}
enum UserAvatarColor {
AMBER
BLUE
GRAY
GREEN
ORANGE
PINK
PRIMARY
PURPLE
RED
YELLOW
}

View File

@ -1,5 +1,22 @@
import { ArgumentsHost } from '@nestjs/common';
import { GqlArgumentsHost, GqlContextType } from '@nestjs/graphql';
import { Request, Response } from 'express';
export const fromChecksum = (checksum: string): Buffer => {
return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
};
export const fromMaybeArray = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param);
export const getReqRes = <Req extends Request = Request, Res extends Response = Response>(
context: ArgumentsHost,
): { type: GqlContextType; req: Req; res: Res } => {
const type = context.getType<GqlContextType>();
if (type === 'graphql') {
const ctx = GqlArgumentsHost.create(context).getContext();
return { type, req: ctx.req, res: ctx.req.res };
}
const http = context.switchToHttp();
return { type, req: http.getRequest(), res: http.getResponse() };
};

View File

@ -26,6 +26,7 @@ export default defineConfig({
// connect to a remote backend during web-only development
proxy: {
'/api': upstream,
'/graphql': upstream,
'/.well-known/immich': upstream,
'/custom.css': upstream,
},