mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
WIP
This commit is contained in:
parent
bf1f8da884
commit
1f5393d02c
1069
server/package-lock.json
generated
1069
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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 {}
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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));
|
||||
}
|
||||
|
27
server/src/models/user.model.ts
Normal file
27
server/src/models/user.model.ts
Normal 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;
|
||||
}
|
3
server/src/resolvers/index.ts
Normal file
3
server/src/resolvers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { UsersResolver } from 'src/resolvers/user.resolver';
|
||||
|
||||
export const resolvers = [UsersResolver];
|
22
server/src/resolvers/user.resolver.ts
Normal file
22
server/src/resolvers/user.resolver.ts
Normal 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
35
server/src/schema.gql
Normal 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
|
||||
}
|
@ -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() };
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user