diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index d136a52b04..d955f1dc6c 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -14,6 +14,7 @@ import { MemoryController } from 'src/controllers/memory.controller'; import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; +import { PluginController } from 'src/controllers/plugin.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerInfoController } from 'src/controllers/server-info.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; @@ -41,6 +42,7 @@ export const controllers = [ MemoryController, OAuthController, PartnerController, + PluginController, SearchController, ServerInfoController, SharedLinkController, diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts new file mode 100644 index 0000000000..7a1066e376 --- /dev/null +++ b/server/src/controllers/plugin.controller.ts @@ -0,0 +1,49 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { PluginImportDto, PluginResponseDto, PluginUpdateDto, SearchPluginDto } from 'src/dtos/plugin.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { PluginService } from 'src/services/plugin.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Plugin') +@Controller('plugins') +@Authenticated() +export class PluginController { + constructor(private service: PluginService) {} + + @Get() + searchPlugins(@Auth() auth: AuthDto, @Query() dto: SearchPluginDto): Promise { + return this.service.search(auth, dto); + } + + @Post() + importPlugin(@Auth() auth: AuthDto, @Body() dto: PluginImportDto): Promise { + return this.service.import(auth, dto); + } + + @Put(':id') + updatePlugin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: PluginUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Post(':id/install') + installPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.install(auth, id); + } + + @Post(':id/uninstall') + uninstallPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.uninstall(auth, id); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deletePlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index 72644870d3..9ab6f889ff 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -43,6 +43,13 @@ export enum Permission { PERSON_CREATE = 'person.create', PERSON_REASSIGN = 'person.reassign', + PLUGIN_READ = 'plugin.read', + PLUGIN_WRITE = 'plugin.write', + PLUGIN_DELETE = 'plugin.delete', + PLUGIN_ADMIN = 'plugin.admin', + PLUGIN_INSTALL = 'plugin.install', + PLUGIN_UNINSTALL = 'plugin.uninstall', + PARTNER_UPDATE = 'partner.update', } diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts new file mode 100644 index 0000000000..5c58ec54b7 --- /dev/null +++ b/server/src/dtos/plugin.dto.ts @@ -0,0 +1,58 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsString } from 'class-validator'; +import { PluginEntity } from 'src/entities/plugin.entity'; +import { Optional, ValidateBoolean } from 'src/validation'; + +export class SearchPluginDto { + @ValidateBoolean({ optional: true }) + isEnabled?: boolean; + + @ValidateBoolean({ optional: true }) + isOfficial?: boolean; + + @ValidateBoolean({ optional: true }) + isInstalled?: boolean; + + @IsString() + @Optional() + name?: string; +} + +export class PluginImportDto { + url!: string; + install!: boolean; + isEnabled!: boolean; + isOfficial!: boolean; +} + +export class PluginUpdateDto { + @IsBoolean() + isEnabled!: boolean; +} + +export class PluginResponseDto { + id!: string; + createdAt!: Date; + updatedAt!: Date; + packageId!: string; + @ApiProperty({ type: 'integer' }) + version!: number; + name!: string; + description!: string; + isEnabled!: boolean; + isInstalled!: boolean; + isTrusted!: boolean; +} + +export const mapPlugin = (plugin: PluginEntity): PluginResponseDto => ({ + id: plugin.id, + createdAt: plugin.createdAt, + updatedAt: plugin.updatedAt, + packageId: plugin.packageId, + version: plugin.version, + name: plugin.name, + description: plugin.description, + isEnabled: plugin.isEnabled, + isInstalled: plugin.isInstalled, + isTrusted: plugin.isTrusted, +}); diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 761b476930..1aca73dc82 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -13,6 +13,7 @@ import { MemoryEntity } from 'src/entities/memory.entity'; import { MoveEntity } from 'src/entities/move.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { PluginEntity } from 'src/entities/plugin.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; @@ -37,6 +38,7 @@ export const entities = [ MoveEntity, PartnerEntity, PersonEntity, + PluginEntity, SharedLinkEntity, SmartInfoEntity, SmartSearchEntity, diff --git a/server/src/entities/plugin.entity.ts b/server/src/entities/plugin.entity.ts new file mode 100644 index 0000000000..8c7125e1ea --- /dev/null +++ b/server/src/entities/plugin.entity.ts @@ -0,0 +1,40 @@ +import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('plugins') +export class PluginEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @DeleteDateColumn({ type: 'timestamptz', nullable: true }) + deletedAt!: Date | null; + + @Column({ unique: true }) + packageId!: string; + + @Column() + version!: number; + + @Column() + name!: string; + + @Column() + description!: string; + + @Column({ type: 'boolean', default: true }) + isEnabled!: boolean; + + @Column({ type: 'boolean', default: false }) + isInstalled!: boolean; + + @Column({ type: 'boolean', default: false }) + isTrusted!: boolean; + + @Column({ nullable: true }) + installPath!: string | null; +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index eddaefcf38..e602c5f77e 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -89,6 +89,9 @@ export enum JobName { SIDECAR_DISCOVERY = 'sidecar-discovery', SIDECAR_SYNC = 'sidecar-sync', SIDECAR_WRITE = 'sidecar-write', + + // workflows + WORKFLOW_TRIGGER = 'workflow-trigger', } export const JOBS_ASSET_PAGINATION_SIZE = 1000; @@ -135,6 +138,17 @@ export interface IDeferrableJob extends IEntityJob { deferred?: boolean; } +export enum WorkflowTriggerType { + ASSET_UPLOAD = 'asset.upload', + ASSET_UPDATE = 'asset.update', + ASSET_TRASH = 'asset.trash', + ASSET_DELETE = 'asset.delete', +} + +export type IWorkflowTriggerJob = + | { type: WorkflowTriggerType.ASSET_UPLOAD; data: { assetId: string } } + | { type: WorkflowTriggerType.ASSET_UPDATE; data: { asset2Id: string } }; + export interface JobCounts { active: number; completed: number; @@ -216,7 +230,8 @@ export type JobItem = | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } - | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }; + | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } + | { name: JobName.WORKFLOW_TRIGGER; data: IWorkflowTriggerJob }; export enum JobStatus { SUCCESS = 'success', diff --git a/server/src/interfaces/plugin.interface.ts b/server/src/interfaces/plugin.interface.ts new file mode 100644 index 0000000000..ef577d7f21 --- /dev/null +++ b/server/src/interfaces/plugin.interface.ts @@ -0,0 +1,116 @@ +import { PluginEntity } from 'src/entities/plugin.entity'; + +export const IPluginRepository = 'IPluginRepository'; + +export interface PluginSearchOptions { + id?: string; + namespace?: string; + version?: number; + name?: string; + isEnabled?: boolean; + isInstalled?: boolean; + isOfficial?: boolean; +} + +export interface IPluginRepository { + search(options: PluginSearchOptions): Promise; + create(dto: Partial): Promise; + get(id: string): Promise; + update(dto: Partial): Promise; + delete(id: string): Promise; + + download(url: string, downloadPath: string): Promise; + load(pluginPath: string): Promise; +} + +export type PluginFactory = { + register: () => MaybePromise; +}; + +export type PluginLike = Plugin | PluginFactory | { default: Plugin } | { plugin: Plugin }; + +export interface Plugin { + version: 1; + id: string; + name: string; + description: string; + actions: PluginAction[]; +} + +export type PluginAction = { + id: string; + name: string; + description: string; + events?: EventType[]; + config?: T; +} & ( + | { type: ActionType.ASSET; onAction: OnAction } + | { type: ActionType.ALBUM; onAction: OnAction } + | { type: ActionType.ALBUM_ASSET; onAction: OnAction } +); + +export type OnAction = T extends undefined + ? (ctx: PluginContext, data: D) => MaybePromise + : (ctx: PluginContext, data: D, config: InferConfig) => MaybePromise; + +export interface PluginContext { + updateAsset: (asset: { id: string; isArchived: boolean }) => Promise; +} + +export type PluginActionData = { data: { asset?: AssetDto; album?: AlbumDto } } & ( + | { type: EventType.ASSET_UPLOAD; data: { asset: AssetDto } } + | { type: EventType.ASSET_UPDATE; data: { asset: AssetDto } } + | { type: EventType.ASSET_TRASH; data: { asset: AssetDto } } + | { type: EventType.ASSET_DELETE; data: { asset: AssetDto } } + | { type: EventType.ALBUM_CREATE; data: { album: AlbumDto } } + | { type: EventType.ALBUM_UPDATE; data: { album: AlbumDto } } +); + +export type PluginConfig = Record; + +export type ConfigItem = { + name: string; + description: string; + required?: boolean; +} & { [K in Types]: { type: K; default?: InferType } }[Types]; + +export type InferType = T extends 'string' + ? string + : T extends 'date' + ? Date + : T extends 'number' + ? number + : T extends 'boolean' + ? boolean + : never; + +type Types = 'string' | 'boolean' | 'number' | 'date'; +type MaybePromise = Promise | T; +type IfRequired = T['required'] extends true ? Type : Type | undefined; +type InferConfig = T extends PluginConfig + ? { + [K in keyof T]: IfRequired>; + } + : never; + +export enum ActionType { + ASSET = 'asset', + ALBUM = 'album', + ALBUM_ASSET = 'album-asset', +} + +export enum EventType { + ASSET_UPLOAD = 'asset.upload', + ASSET_UPDATE = 'asset.update', + ASSET_TRASH = 'asset.trash', + ASSET_DELETE = 'asset.delete', + ASSET_ARCHIVE = 'asset.archvie', + ASSET_UNARCHIVE = 'asset.unarchive', + + ALBUM_CREATE = 'album.create', + ALBUM_UPDATE = 'album.update', + ALBUM_DELETE = 'album.delete', +} + +export type AssetDto = { id: string; type: 'asset' }; +export type AlbumDto = { id: string; type: 'album' }; diff --git a/server/src/plugins/asset.plugin.ts b/server/src/plugins/asset.plugin.ts new file mode 100644 index 0000000000..04b1c57d9e --- /dev/null +++ b/server/src/plugins/asset.plugin.ts @@ -0,0 +1,55 @@ +import { ActionType, AssetDto, Plugin, PluginContext } from 'src/interfaces/plugin.interface'; + +const onAsset = async (ctx: PluginContext, asset: AssetDto) => { + await ctx.updateAsset({ id: asset.id, isArchived: true }); +}; + +export const plugin: Plugin = { + version: 1, + id: 'immich-plugin-asset', + name: 'Asset Plugin', + description: 'Immich asset plugin', + actions: [ + { + id: 'asset.favorite', + name: '', + type: ActionType.ASSET, + description: '', + onAction: async (ctx, asset) => { + await ctx.updateAsset({ id: asset.id, isArchived: false }); + }, + }, + { + id: 'asset.unfavorite', + name: '', + type: ActionType.ASSET, + description: '', + onAction: () => { + console.log('Unfavorite'); + }, + }, + { + id: 'asset.action', + name: '', + type: ActionType.ASSET, + description: '', + onAction: (ctx, asset) => onAsset(ctx, asset), + }, + { + id: 'album-asset.action', + name: '', + type: ActionType.ALBUM_ASSET, + description: '', + onAction: (ctx, { asset }) => onAsset(ctx, asset), + }, + { + id: 'asset.unarchive', + name: '', + type: ActionType.ASSET, + description: '', + onAction: () => { + console.log('Unarchive'); + }, + }, + ], +}; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index e6466ee6b5..5a8cc29ee2 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -20,6 +20,7 @@ import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; +import { IPluginRepository } from 'src/interfaces/plugin.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -51,6 +52,7 @@ import { MetricRepository } from 'src/repositories/metric.repository'; import { MoveRepository } from 'src/repositories/move.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; @@ -83,6 +85,7 @@ export const repositories = [ { provide: IMoveRepository, useClass: MoveRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, + { provide: IPluginRepository, useClass: PluginRepository }, { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISearchRepository, useClass: SearchRepository }, diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 858798b88d..6883841694 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -77,6 +77,9 @@ export const JOBS_TO_QUEUE: Record = { [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, + + // workflows + [JobName.WORKFLOW_TRIGGER]: QueueName.BACKGROUND_TASK, }; @Instrumentation() diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts new file mode 100644 index 0000000000..ddf4cc413d --- /dev/null +++ b/server/src/repositories/plugin.repository.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { writeFile } from 'node:fs/promises'; +import { PluginEntity } from 'src/entities/plugin.entity'; +import { IPluginRepository, PluginLike, PluginSearchOptions } from 'src/interfaces/plugin.interface'; +import { ImmichLogger } from 'src/utils/logger'; +import { Repository } from 'typeorm'; + +@Injectable() +export class PluginRepository implements IPluginRepository { + private logger = new ImmichLogger(PluginRepository.name); + constructor(@InjectRepository(PluginEntity) private repository: Repository) {} + + search(options: PluginSearchOptions): Promise { + return this.repository.find({ + where: { + id: options.id, + packageId: options.namespace, + version: options.version, + name: options.name, + isEnabled: options.isEnabled, + isInstalled: options.isInstalled, + isTrusted: options.isOfficial, + }, + }); + } + + create(dto: Partial): Promise { + return this.repository.save(dto); + } + + get(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + update(dto: Partial): Promise { + return this.repository.save(dto); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } + + async download(url: string, downloadPath: string): Promise { + try { + const { json } = await fetch(url); + await writeFile(downloadPath, await json()); + } catch (error) { + this.logger.error(`Error downloading the plugin from ${url}. ${error}`); + } + } + + load(pluginPath: string): Promise { + return import(pluginPath); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6c40f8420a..0be676e04b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -16,6 +16,7 @@ import { MetadataService } from 'src/services/metadata.service'; import { MicroservicesService } from 'src/services/microservices.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; +import { PluginService } from 'src/services/plugin.service'; import { SearchService } from 'src/services/search.service'; import { ServerInfoService } from 'src/services/server-info.service'; import { SharedLinkService } from 'src/services/shared-link.service'; @@ -28,6 +29,7 @@ import { TagService } from 'src/services/tag.service'; import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; import { UserService } from 'src/services/user.service'; +import { WorkflowService } from 'src/services/workflow.service'; export const services = [ ApiService, @@ -48,6 +50,7 @@ export const services = [ MetadataService, PartnerService, PersonService, + PluginService, SearchService, ServerInfoService, SharedLinkService, @@ -60,4 +63,5 @@ export const services = [ TimelineService, TrashService, UserService, + WorkflowService, ]; diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 13d367994b..81f5bb078a 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -16,6 +16,7 @@ import { JobStatus, QueueCleanType, QueueName, + WorkflowTriggerType, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; @@ -294,6 +295,13 @@ export class JobService { if (asset && asset.isVisible) { this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); } + + if (asset) { + await this.jobRepository.queue({ + name: JobName.WORKFLOW_TRIGGER, + data: { type: WorkflowTriggerType.ASSET_UPLOAD, data: { assetId: asset.id } }, + }); + } break; } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 7bea8c3770..3aa60aabb0 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -13,6 +13,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SystemConfigService } from 'src/services/system-config.service'; import { UserService } from 'src/services/user.service'; +import { WorkflowService } from 'src/services/workflow.service'; import { otelSDK } from 'src/utils/instrumentation'; @Injectable() @@ -31,6 +32,7 @@ export class MicroservicesService { private storageService: StorageService, private userService: UserService, private databaseService: DatabaseService, + private workflowService: WorkflowService, ) {} async init() { @@ -77,6 +79,7 @@ export class MicroservicesService { [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), + [JobName.WORKFLOW_TRIGGER]: (data) => this.workflowService.handleTrigger(data), }); await this.metadataService.init(); diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts new file mode 100644 index 0000000000..4e05141dfd --- /dev/null +++ b/server/src/services/plugin.service.ts @@ -0,0 +1,108 @@ +import { BadRequestException, Inject } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { PluginImportDto, PluginUpdateDto, SearchPluginDto, mapPlugin } from 'src/dtos/plugin.dto'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IPluginRepository, Plugin, PluginFactory } from 'src/interfaces/plugin.interface'; + +export class PluginService { + private access: AccessCore; + constructor( + @Inject(IPluginRepository) private pluginRepository: IPluginRepository, + @Inject(IAccessRepository) accessRepository: IAccessRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + search(auth: AuthDto, dto: SearchPluginDto) { + // await this.access.requirePermission(authUser, Permission.PLUGIN_READ); + return this.pluginRepository.search(dto); + } + + async import(auth: AuthDto, dto: PluginImportDto) { + // await this.access.requirePermission(authUser, Permission.PLUGIN_ADMIN); + if (dto.url.startsWith('http')) { + // + } + + const pluginPath = '/path/to/plugin'; + await this.pluginRepository.download(dto.url, pluginPath); + const { version, id, name, description } = await this.load(pluginPath); + + const response = await this.pluginRepository.create({ + version, + packageId: id, + name, + description, + isInstalled: false, + isEnabled: dto.isEnabled, + isTrusted: false, + installPath: pluginPath, + }); + + return mapPlugin(response); + } + + async install(auth: AuthDto, id: string) { + await this.access.requirePermission(auth, Permission.PLUGIN_INSTALL, id); + // TODO + return this.pluginRepository.update({ id, isInstalled: true }); + } + + async uninstall(auth: AuthDto, id: string) { + await this.access.requirePermission(auth, Permission.PLUGIN_UNINSTALL, id); + // TODO + return this.pluginRepository.update({ id, isInstalled: false, installPath: null }); + } + + async update(auth: AuthDto, id: string, dto: PluginUpdateDto) { + await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id); + return this.pluginRepository.update({ + id, + isEnabled: dto.isEnabled, + }); + } + + async delete(auth: AuthDto, id: string) { + await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id); + await this.findOrFail(id); + await this.pluginRepository.delete(id); + } + + private async findOrFail(id: string) { + const plugin = await this.pluginRepository.get(id); + if (!plugin) { + throw new BadRequestException('Plugin not found'); + } + return plugin; + } + + // TODO security implications + private async load(pluginPath: string): Promise { + const pluginLike = await this.pluginRepository.load(pluginPath); + let plugin: Plugin | undefined; + + const pluginModule = pluginLike as { default: Plugin }; + if (pluginModule.default) { + plugin = pluginModule.default; + } + + const pluginExport = pluginLike as { plugin: Plugin }; + if (pluginExport.plugin) { + plugin = pluginExport.plugin; + } + + const pluginFactory = pluginLike as PluginFactory; + if (pluginFactory) { + plugin = await pluginFactory.register(); + } + + // TODO use class-validator + const isPlugin = plugin && !!plugin.version && Array.isArray(plugin.actions); + if (isPlugin) { + return plugin as Plugin; + } + + throw new Error('Unable to load plugin'); + } +} diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts new file mode 100644 index 0000000000..1756c0b923 --- /dev/null +++ b/server/src/services/workflow.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IWorkflowTriggerJob, JobStatus, WorkflowTriggerType } from 'src/interfaces/job.interface'; +import { IPluginRepository, PluginLike } from 'src/interfaces/plugin.interface'; + +@Injectable() +export class WorkflowService { + private plugins?: PluginLike[]; + constructor(@Inject(IPluginRepository) private pluginRepository: IPluginRepository) {} + + async init(): Promise { + const activePlugins = await this.pluginRepository.search({ isEnabled: true }); + this.plugins = await Promise.all(activePlugins.map(({ installPath }) => this.pluginRepository.load(installPath))); + } + + // async register() { + // const plugins = ['/src/abc']; + // for (const pluginModule of plugins) { + // // eslint-disable-next-line @typescript-eslint/no-var-requires + // try { + // const plugin: Plugin = ; + // const actions = await plugin.register(); + // for (const action of actions) { + // this.actions[action.id] = action; + // } + // } catch (error) { + // console.error(`Unable to load module: ${pluginModule}`, error); + // continue; + // } + // } + // } + + async handleTrigger(data: IWorkflowTriggerJob): Promise { + if (data.type === WorkflowTriggerType.ASSET_UPLOAD) { + // const stuff = data.data; + return JobStatus.SUCCESS; + } + + await Promise.resolve(); + + // const workflows = this.getWorkflows(); + // const plugins = await this.getPlugins(); + + // for (const plugin of plugins) { + // if (workflow.trigger === data.type) { + // } + // } + + return JobStatus.SKIPPED; + } +} diff --git a/server/src/utils/plugin.ts b/server/src/utils/plugin.ts new file mode 100644 index 0000000000..8a9a412f51 --- /dev/null +++ b/server/src/utils/plugin.ts @@ -0,0 +1,12 @@ +import { AssetDto, EventType, OnAction, PluginConfig } from 'src/interfaces/plugin.interface'; + +export const createPluginAction = (options: { + id: string; + name: string; + description: string; + events?: EventType[]; + config?: T; +}) => ({ + addHandler: (onAction: OnAction) => ({ ...options, onAction }), + onAsset: (onAction: OnAction) => ({ ...options, onAction }), +}); diff --git a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte index 658c029fd8..84a738e6b2 100644 --- a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte @@ -3,7 +3,7 @@ import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import StatusBox from '$lib/components/shared-components/status-box.svelte'; import { AppRoute } from '$lib/constants'; - import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js'; + import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiPuzzle, mdiServer, mdiSync, mdiTools } from '@mdi/js'; @@ -14,6 +14,7 @@ +
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index af5558c261..b153a0a414 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -16,6 +16,7 @@ export enum AppRoute { ADMIN_STATS = '/admin/server-status', ADMIN_JOBS = '/admin/jobs-status', ADMIN_REPAIR = '/admin/repair', + ADMIN_PLUGINS = '/admin/plugins', ALBUMS = '/albums', LIBRARIES = '/libraries', diff --git a/web/src/routes/admin/plugins/+page.svelte b/web/src/routes/admin/plugins/+page.svelte new file mode 100644 index 0000000000..03651c17cc --- /dev/null +++ b/web/src/routes/admin/plugins/+page.svelte @@ -0,0 +1,54 @@ + + + + +
+ console.log('clicked')}> +
+ + Test +
+
+
+
+
+ {#each plugins as plugin} +
+
+

+ {plugin.name} + {#if plugin.isOfficial} + + {/if} +
Version {plugin.version}
+

+ +

{plugin.description}

+
+
Is {plugin.isInstalled ? '' : 'not '}installed
+ +
+ {/each} +
+
+
diff --git a/web/src/routes/admin/plugins/+page.ts b/web/src/routes/admin/plugins/+page.ts new file mode 100644 index 0000000000..d9d44f449e --- /dev/null +++ b/web/src/routes/admin/plugins/+page.ts @@ -0,0 +1,16 @@ +import { authenticate } from '$lib/utils/auth'; +import { getAuditFiles } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async () => { + await authenticate({ admin: true }); + const { orphans, extras } = await getAuditFiles(); + + return { + orphans, + extras, + meta: { + title: 'Plugins', + }, + }; +}) satisfies PageLoad;