mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: yucca integration
This commit is contained in:
+4
-1
@@ -7,7 +7,8 @@ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
|
||||
RUN npm install --global corepack@latest && \
|
||||
corepack enable pnpm && \
|
||||
pnpm config set store-dir "$PNPM_HOME"
|
||||
pnpm config set store-dir "$PNPM_HOME" && \
|
||||
pnpm config set registry https://npm.raccoon.sh
|
||||
|
||||
FROM builder AS server
|
||||
|
||||
@@ -104,6 +105,8 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
|
||||
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
|
||||
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
|
||||
|
||||
RUN apt-get update && apt-get install -y restic
|
||||
|
||||
VOLUME /data
|
||||
EXPOSE 2283
|
||||
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
|
||||
|
||||
@@ -28,7 +28,7 @@ FROM dev AS dev-container-server
|
||||
|
||||
RUN apt-get update --allow-releaseinfo-change && \
|
||||
apt-get install inetutils-ping openjdk-21-jre-headless \
|
||||
vim nano curl \
|
||||
vim nano curl restic \
|
||||
-y --no-install-recommends --fix-missing
|
||||
|
||||
RUN mkdir -p /workspaces && \
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"nodemailer": "^8.0.0",
|
||||
"nestjs-zod": "^5.3.0",
|
||||
"openid-client": "^6.3.3",
|
||||
"orchestration-api": "0.1.54",
|
||||
"pg": "^8.11.3",
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"picomatch": "^4.0.2",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ClsModule } from 'nestjs-cls';
|
||||
import { KyselyModule } from 'nestjs-kysely';
|
||||
import { OpenTelemetryModule } from 'nestjs-otel';
|
||||
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
|
||||
import { OrchestrationApiModule } from 'orchestration-api/dist';
|
||||
import { commandsAndQuestions } from 'src/commands';
|
||||
import { IWorker } from 'src/constants';
|
||||
import { controllers } from 'src/controllers';
|
||||
@@ -20,6 +21,7 @@ import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
|
||||
import { YuccaAdminGuard } from 'src/middleware/yucca-admin.guard';
|
||||
import { repositories } from 'src/repositories';
|
||||
import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
@@ -50,7 +52,12 @@ const commonMiddleware = [
|
||||
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
|
||||
];
|
||||
|
||||
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
|
||||
const apiMiddleware = [
|
||||
FileUploadInterceptor,
|
||||
...commonMiddleware,
|
||||
{ provide: APP_GUARD, useClass: YuccaAdminGuard },
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
];
|
||||
|
||||
const configRepository = new ConfigRepository();
|
||||
const { bull, cls, database, otel } = configRepository.getEnv();
|
||||
@@ -102,14 +109,37 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
|
||||
imports: [
|
||||
...bullImports,
|
||||
...commonImports,
|
||||
ScheduleModule.forRoot(),
|
||||
OrchestrationApiModule.forRoot({
|
||||
// TODO: db init must happen elsewhere...
|
||||
|
||||
yuccaProductionApi: 'https://staging.fubar.computer',
|
||||
// yuccaProductionApi: 'http://100.64.0.6:5173', // TODO
|
||||
statePath: '/yucca', // TODO
|
||||
requireWsAuth: true,
|
||||
requireLock: true,
|
||||
}),
|
||||
],
|
||||
controllers: [...controllers],
|
||||
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
|
||||
})
|
||||
export class ApiModule extends BaseModule {}
|
||||
|
||||
@Module({
|
||||
imports: [...commonImports],
|
||||
imports: [
|
||||
...commonImports,
|
||||
OrchestrationApiModule.forRoot({
|
||||
yuccaProductionApi: 'https://staging.fubar.computer',
|
||||
// yuccaProductionApi: 'http://100.64.0.6:5173', // TODO
|
||||
statePath: '/yucca', // TODO
|
||||
externalBaseUrl: 'https://my.immich.app',
|
||||
requireWsAuth: true,
|
||||
requireLock: true,
|
||||
}),
|
||||
],
|
||||
controllers: [MaintenanceWorkerController],
|
||||
providers: [
|
||||
ConfigRepository,
|
||||
|
||||
@@ -879,6 +879,7 @@ export enum DatabaseLock {
|
||||
MaintenanceOperation = 621,
|
||||
MemoryCreation = 777,
|
||||
VersionCheck = 800,
|
||||
YuccaModuleConfig = 900,
|
||||
}
|
||||
|
||||
export const DatabaseLockSchema = z.enum(DatabaseLock).describe('Database lock').meta({ id: 'DatabaseLock' });
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ class Workers {
|
||||
}
|
||||
|
||||
onError(name: ImmichWorker, error: Error) {
|
||||
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
|
||||
console.error(`${name} worker error: ${JSON.stringify(error)}, stack: ${error.stack}`);
|
||||
}
|
||||
|
||||
onExit(name: ImmichWorker, exitCode: number | null) {
|
||||
|
||||
@@ -41,16 +41,18 @@ export class MaintenanceAuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
|
||||
|
||||
const targets = [context.getHandler()];
|
||||
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
|
||||
MetadataKey.AuthRoute,
|
||||
targets,
|
||||
);
|
||||
if (!options) {
|
||||
|
||||
if (!options && !request.path.startsWith('/api/yucca')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
|
||||
request.auth = await this.service.authenticate(request.headers);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -39,6 +39,10 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const eventsGatewayMock = {
|
||||
setAuthFn: vitest.fn(),
|
||||
};
|
||||
|
||||
sut = new MaintenanceWorkerService(
|
||||
mocks.logger as never,
|
||||
mocks.app,
|
||||
@@ -50,6 +54,7 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
mocks.process,
|
||||
mocks.database as never,
|
||||
databaseBackupServiceMock,
|
||||
eventsGatewayMock as never,
|
||||
);
|
||||
|
||||
sut.mock({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextFunction, Request, Response } from 'express';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { EventsGateway } from 'orchestration-api/dist';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ export class MaintenanceWorkerService {
|
||||
private processRepository: ProcessRepository,
|
||||
private databaseRepository: DatabaseRepository,
|
||||
private databaseBackupService: DatabaseBackupService,
|
||||
private readonly eventsGateway: EventsGateway,
|
||||
) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
@@ -77,6 +79,14 @@ export class MaintenanceWorkerService {
|
||||
|
||||
StorageCore.setMediaLocation(this.detectMediaLocation());
|
||||
|
||||
this.eventsGateway.setAuthFn(async (client) => {
|
||||
await this.authenticate(client.request.headers);
|
||||
|
||||
return {
|
||||
user: { isAdmin: true },
|
||||
};
|
||||
});
|
||||
|
||||
this.maintenanceWebsocketRepository.setAuthFn(async (client) => this.authenticate(client.request.headers));
|
||||
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class YuccaAdminGuard implements CanActivate {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthRequest>();
|
||||
if (!request.path.startsWith('/api/yucca')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
request.user = await this.authService.authenticate({
|
||||
headers: request.headers,
|
||||
queryParams: request.query as Record<string, string>,
|
||||
metadata: { adminRoute: true, sharedLinkRoute: false, uri: request.path },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import _ from 'lodash';
|
||||
import { GatewayEvent as YuccaGatewayEvent } from 'orchestration-api/dist/events/events.gateway';
|
||||
import { Socket } from 'socket.io';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Asset } from 'src/database';
|
||||
@@ -67,6 +68,10 @@ type EventMap = {
|
||||
/** job finishes with error */
|
||||
JobError: [JobErrorEvent];
|
||||
|
||||
LibraryCreate: [];
|
||||
LibraryUpdate: [];
|
||||
LibraryDelete: [];
|
||||
|
||||
// queue events
|
||||
QueueStart: [QueueStartEvent];
|
||||
|
||||
@@ -94,6 +99,8 @@ type EventMap = {
|
||||
|
||||
// websocket events
|
||||
WebsocketConnect: [{ userId: string }];
|
||||
|
||||
YuccaEvent: [YuccaGatewayEvent];
|
||||
};
|
||||
|
||||
export type AppRestartEvent = {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
|
||||
export const serverEvents = ['ConfigUpdate', 'AppRestart', 'YuccaEvent'] as const;
|
||||
export type ServerEvents = (typeof serverEvents)[number];
|
||||
|
||||
export interface ClientEventMap {
|
||||
|
||||
@@ -46,6 +46,7 @@ import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
import { YuccaService } from 'src/services/yucca.service';
|
||||
|
||||
export const services = [
|
||||
ApiKeyService,
|
||||
@@ -96,4 +97,5 @@ export const services = [
|
||||
VersionService,
|
||||
ViewService,
|
||||
WorkflowService,
|
||||
YuccaService,
|
||||
];
|
||||
|
||||
@@ -242,6 +242,8 @@ export class LibraryService extends BaseService {
|
||||
'**/.stfolder/**',
|
||||
],
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('LibraryCreate');
|
||||
return mapLibrary(library);
|
||||
}
|
||||
|
||||
@@ -343,6 +345,7 @@ export class LibraryService extends BaseService {
|
||||
}
|
||||
|
||||
const library = await this.libraryRepository.update(id, dto);
|
||||
await this.eventRepository.emit('LibraryUpdate');
|
||||
return mapLibrary(library);
|
||||
}
|
||||
|
||||
@@ -355,6 +358,8 @@ export class LibraryService extends BaseService {
|
||||
|
||||
await this.libraryRepository.softDelete(id);
|
||||
await this.jobRepository.queue({ name: JobName.LibraryDelete, data: { id } });
|
||||
|
||||
await this.eventRepository.emit('LibraryDelete');
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.LibraryDelete, queue: QueueName.Library })
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { EventsGateway, ModuleConfigRepository } from 'orchestration-api/dist';
|
||||
import { GatewayEvent } from 'orchestration-api/dist/events/events.gateway';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { DatabaseLock, ImmichWorker, StorageFolder } from 'src/enum';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { ArgOf, EventRepository } from 'src/repositories/event.repository';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class YuccaService implements OnModuleInit, OnModuleDestroy {
|
||||
private lock = false;
|
||||
|
||||
constructor(
|
||||
private readonly logger: LoggingRepository,
|
||||
private readonly databaseRepository: DatabaseRepository,
|
||||
private readonly libraryRepository: LibraryRepository,
|
||||
private readonly authService: AuthService,
|
||||
private readonly eventRepository: EventRepository,
|
||||
private readonly websocketRepository: WebsocketRepository,
|
||||
@Optional() private readonly moduleConfig: ModuleConfigRepository,
|
||||
@Optional() private readonly eventsGateway: EventsGateway,
|
||||
) {
|
||||
this.onInternalEvent = this.onInternalEvent.bind(this);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (this.eventsGateway) {
|
||||
this.eventsGateway.setAuthFn(async (client) =>
|
||||
this.authService.authenticate({
|
||||
headers: client.request.headers,
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: true, sharedLinkRoute: false, uri: '/api/yucca/socket.io' },
|
||||
}),
|
||||
);
|
||||
|
||||
this.eventsGateway.on(this.onInternalEvent);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.eventsGateway) {
|
||||
this.eventsGateway.off(this.onInternalEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private updateSystemConfig({ server }: SystemConfig) {
|
||||
this.moduleConfig.update({
|
||||
externalBaseUrl: getExternalDomain(server),
|
||||
});
|
||||
}
|
||||
|
||||
private async updateLibraryConfig() {
|
||||
const libraries = await this.libraryRepository.getAll();
|
||||
|
||||
this.moduleConfig.update({
|
||||
immichIntegration: {
|
||||
dataPath: StorageCore.getMediaLocation(),
|
||||
dataFolders: Object.values(StorageFolder),
|
||||
libraries: libraries
|
||||
.filter((library) => !library.deletedAt)
|
||||
.map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Api] })
|
||||
async onConfigInit({ newConfig }: ArgOf<'ConfigInit'>) {
|
||||
this.updateSystemConfig(newConfig);
|
||||
void this.updateLibraryConfig();
|
||||
|
||||
this.lock = await this.databaseRepository.tryLock(DatabaseLock.YuccaModuleConfig);
|
||||
|
||||
if (this.lock) {
|
||||
this.moduleConfig.acquireLock();
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.Api], server: true })
|
||||
onConfigUpdate({ newConfig }: ArgOf<'ConfigUpdate'>) {
|
||||
void this.updateSystemConfig(newConfig);
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'LibraryCreate', workers: [ImmichWorker.Api], server: true })
|
||||
onLibraryCreate() {
|
||||
void this.updateLibraryConfig();
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'LibraryUpdate', workers: [ImmichWorker.Api], server: true })
|
||||
onLibraryUpdate() {
|
||||
void this.updateLibraryConfig();
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'LibraryDelete', workers: [ImmichWorker.Api], server: true })
|
||||
onLibraryDelete() {
|
||||
void this.updateLibraryConfig();
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'YuccaEvent', workers: [ImmichWorker.Api], server: true })
|
||||
onYuccaEvent(event: GatewayEvent) {
|
||||
this.eventsGateway.emit(event);
|
||||
}
|
||||
|
||||
onInternalEvent(event: GatewayEvent) {
|
||||
this.websocketRepository.serverSend('YuccaEvent', event);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user