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

View File

@ -1,12 +1,15 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; 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 { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely'; import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel'; import { OpenTelemetryModule } from 'nestjs-otel';
import { join } from 'node:path';
import postgres from 'postgres'; import postgres from 'postgres';
import { commands } from 'src/commands'; import { commands } from 'src/commands';
import { IWorker } from 'src/constants'; import { IWorker } from 'src/constants';
@ -24,6 +27,7 @@ import { providers, repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository'; import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { resolvers } from 'src/resolvers';
import { services } from 'src/services'; import { services } from 'src/services';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
import { DatabaseService } from 'src/services/database.service'; import { DatabaseService } from 'src/services/database.service';
@ -104,9 +108,28 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
} }
@Module({ @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], controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }], providers: [
//
...common,
...middleware,
...resolvers,
{ provide: IWorker, useValue: ImmichWorker.API },
],
}) })
export class ApiModule extends BaseModule {} 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 MOBILE_REDIRECT = 'app.immich:///oauth-callback';
export const LOGIN_URL = '/auth/login?autoLaunch=0'; 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; export const FACE_THUMBNAIL_SIZE = 250;

View File

@ -9,6 +9,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Req,
Res, Res,
UploadedFile, UploadedFile,
UseInterceptors, UseInterceptors,
@ -38,8 +39,21 @@ export class UserController {
@Get() @Get()
@Authenticated() @Authenticated()
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> { async searchUsers(@Req() req: Request): Promise<UserResponseDto[]> {
return this.service.search(auth); 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') @Get('me')

View File

@ -5,19 +5,18 @@ import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-re
import { ImmichHeader } from 'src/enum'; import { ImmichHeader } from 'src/enum';
import { AuthenticatedRequest } from 'src/middleware/auth.guard'; import { AuthenticatedRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { fromMaybeArray } from 'src/utils/request'; import { fromMaybeArray, getReqRes } from 'src/utils/request';
@Injectable() @Injectable()
export class AssetUploadInterceptor implements NestInterceptor { export class AssetUploadInterceptor implements NestInterceptor {
constructor(private service: AssetMediaService) {} constructor(private service: AssetMediaService) {}
async intercept(context: ExecutionContext, next: CallHandler<any>) { async intercept(context: ExecutionContext, next: CallHandler<any>) {
const req = context.switchToHttp().getRequest<AuthenticatedRequest>(); const { type, req, res } = getReqRes<AuthenticatedRequest, Response<AssetMediaResponseDto>>(context);
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]); const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum); const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
if (response) { if (response && type === 'http') {
res.status(200); res.status(200);
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id }); 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 { ImmichQuery, MetadataKey, Permission } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { getReqRes } from 'src/utils/request';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
type AdminRoute = { admin?: true }; type AdminRoute = { admin?: true };
@ -35,7 +36,8 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator =
}; };
export const Auth = createParamDecorator((data, context: ExecutionContext): AuthDto => { 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 = () => export const FileResponse = () =>
@ -86,12 +88,12 @@ export class AuthGuard implements CanActivate {
sharedLink: sharedLinkRoute, sharedLink: sharedLinkRoute,
permission, permission,
} = { sharedLink: false, admin: false, ...options }; } = { sharedLink: false, admin: false, ...options };
const request = context.switchToHttp().getRequest<AuthRequest>(); const { req } = getReqRes<AuthenticatedRequest>(context);
request.user = await this.authService.authenticate({ req.user = await this.authService.authenticate({
headers: request.headers, headers: req.headers,
queryParams: request.query as Record<string, string>, queryParams: req.query as Record<string, string>,
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path }, metadata: { adminRoute, sharedLinkRoute, permission, uri: req.path },
}); });
return true; return true;

View File

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

View File

@ -1,7 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable, finalize } from 'rxjs'; import { Observable, finalize } from 'rxjs';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { getReqRes } from 'src/utils/request';
const maxArrayLength = 100; const maxArrayLength = 100;
const replacer = (key: string, value: unknown) => { const replacer = (key: string, value: unknown) => {
@ -23,10 +23,7 @@ export class LoggingInterceptor implements NestInterceptor {
} }
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> { intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const handler = context.switchToHttp(); const { req, res } = getReqRes(context);
const req = handler.getRequest<Request>();
const res = handler.getResponse<Response>();
const { method, ip, url } = req; const { method, ip, url } = req;
const start = performance.now(); const start = performance.now();
@ -35,9 +32,7 @@ export class LoggingInterceptor implements NestInterceptor {
finalize(() => { finalize(() => {
const finish = performance.now(); const finish = performance.now();
const duration = (finish - start).toFixed(2); const duration = (finish - start).toFixed(2);
const { statusCode } = res; this.logger.debug(`${method} ${url} ${res?.statusCode || ''} ${duration}ms ${ip}`);
this.logger.debug(`${method} ${url} ${statusCode} ${duration}ms ${ip}`);
if (req.body && Object.keys(req.body).length > 0) { if (req.body && Object.keys(req.body).length > 0) {
this.logger.verbose(JSON.stringify(req.body, replacer)); 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 => { export const fromChecksum = (checksum: string): Buffer => {
return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
}; };
export const fromMaybeArray = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param); 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 // connect to a remote backend during web-only development
proxy: { proxy: {
'/api': upstream, '/api': upstream,
'/graphql': upstream,
'/.well-known/immich': upstream, '/.well-known/immich': upstream,
'/custom.css': upstream, '/custom.css': upstream,
}, },