From a6b0f0c76a0495192eb3e9ea0682ad63bf18a2eb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Oct 2025 13:29:32 -0400 Subject: [PATCH] feat: plugins --- i18n/en.json | 1 + open-api/immich-openapi-specs.json | 211 +++++++++++++++++++ server/src/controllers/index.ts | 2 + server/src/controllers/plugin.controller.ts | 36 ++++ server/src/database.ts | 13 ++ server/src/dtos/plugin.dto.ts | 58 +++++ server/src/enum.ts | 4 + server/src/interfaces/plugin.interface.ts | 91 ++++++++ server/src/plugin_types.ts | 92 ++++++++ server/src/plugins/asset.plugin.ts | 55 +++++ server/src/repositories/index.ts | 3 +- server/src/repositories/plugin.repository.ts | 83 ++++++++ server/src/schema/index.ts | 4 + server/src/schema/tables/plugin.table.ts | 61 ++++++ server/src/services/base.service.ts | 2 + server/src/services/index.ts | 4 + server/src/services/plugin.service.ts | 42 ++++ server/src/services/workflow.service.ts | 31 +++ server/src/utils/plugin.ts | 12 ++ web/src/lib/constants.ts | 1 + web/src/lib/sidebars/AdminSidebar.svelte | 3 +- web/src/routes/admin/plugins/+page.svelte | 54 +++++ web/src/routes/admin/plugins/+page.ts | 16 ++ 23 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 server/src/controllers/plugin.controller.ts create mode 100644 server/src/dtos/plugin.dto.ts create mode 100644 server/src/interfaces/plugin.interface.ts create mode 100644 server/src/plugin_types.ts create mode 100644 server/src/plugins/asset.plugin.ts create mode 100644 server/src/repositories/plugin.repository.ts create mode 100644 server/src/schema/tables/plugin.table.ts create mode 100644 server/src/services/plugin.service.ts create mode 100644 server/src/services/workflow.service.ts create mode 100644 server/src/utils/plugin.ts create mode 100644 web/src/routes/admin/plugins/+page.svelte create mode 100644 web/src/routes/admin/plugins/+page.ts diff --git a/i18n/en.json b/i18n/en.json index 8669a048b8..a49dd3092e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1604,6 +1604,7 @@ "read_changelog": "Read Changelog", "readonly_mode_disabled": "Read-only mode disabled", "readonly_mode_enabled": "Read-only mode enabled", + "plugins": "Plugins", "ready_for_upload": "Ready for upload", "reassign": "Reassign", "reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fdfc40eb6c..af521b9c4f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5717,6 +5717,154 @@ "description": "This endpoint requires the `person.read` permission." } }, + "/plugins": { + "get": { + "operationId": "searchPlugins", + "parameters": [ + { + "name": "isEnabled", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isInstalled", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrusted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PluginResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Plugin" + ], + "x-immich-admin-only": true, + "x-immich-permission": "plugin.read", + "description": "This endpoint is an admin-only route, and requires the `plugin.read` permission." + } + }, + "/plugins/{id}": { + "delete": { + "operationId": "deletePlugin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": [ + "Plugin" + ] + }, + "put": { + "operationId": "updatePlugin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Plugin" + ], + "x-immich-admin-only": true, + "x-immich-permission": "plugin.update", + "description": "This endpoint is an admin-only route, and requires the `plugin.update` permission." + } + }, "/search/cities": { "get": { "operationId": "getAssetsByCity", @@ -13211,6 +13359,9 @@ "person.statistics", "person.merge", "person.reassign", + "plugin.read", + "plugin.update", + "plugin.delete", "pinCode.create", "pinCode.update", "pinCode.delete", @@ -13498,6 +13649,66 @@ ], "type": "object" }, + "PluginResponseDto": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isEnabled": { + "type": "boolean" + }, + "isInstalled": { + "type": "boolean" + }, + "isTrusted": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "packageId": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "version": { + "type": "integer" + } + }, + "required": [ + "createdAt", + "description", + "id", + "isEnabled", + "isInstalled", + "isTrusted", + "name", + "packageId", + "updatedAt", + "version" + ], + "type": "object" + }, + "PluginUpdateDto": { + "properties": { + "isEnabled": { + "type": "boolean" + } + }, + "required": [ + "isEnabled" + ], + "type": "object" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e3661ec794..973b623f7e 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -18,6 +18,7 @@ import { NotificationController } from 'src/controllers/notification.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 { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; @@ -54,6 +55,7 @@ export const controllers = [ OAuthController, PartnerController, PersonController, + PluginController, SearchController, ServerController, SessionController, diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts new file mode 100644 index 0000000000..f48a111bc7 --- /dev/null +++ b/server/src/controllers/plugin.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { PluginResponseDto, PluginSearchDto, PluginUpdateDto } from 'src/dtos/plugin.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { PluginService } from 'src/services/plugin.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Plugin') +@Controller('plugins') +export class PluginController { + constructor(private service: PluginService) {} + + @Get() + @Authenticated({ admin: true, permission: Permission.PluginRead }) + searchPlugins(@Auth() auth: AuthDto, @Query() dto: PluginSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Put(':id') + @Authenticated({ admin: true, permission: Permission.PluginUpdate }) + updatePlugin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: PluginUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @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/database.ts b/server/src/database.ts index f472c643ee..8cb5ae8f2e 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -144,6 +144,19 @@ export type UserAdmin = User & { metadata: UserMetadataItem[]; }; +export type Plugin = { + id: string; + createdAt: Date; + updatedAt: Date; + packageId: string; + version: number; + name: string; + description: string; + isEnabled: boolean; + isInstalled: boolean; + isTrusted: boolean; +}; + export type StorageAsset = { id: string; ownerId: string; diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts new file mode 100644 index 0000000000..9dec65cde4 --- /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 { Plugin } from 'src/database'; +import { Optional, ValidateBoolean } from 'src/validation'; + +export class PluginSearchDto { + @ValidateBoolean({ optional: true }) + isEnabled?: boolean; + + @ValidateBoolean({ optional: true }) + isTrusted?: boolean; + + @ValidateBoolean({ optional: true }) + isInstalled?: boolean; + + @IsString() + @Optional() + name?: string; +} + +export class PluginImportDto { + url!: string; + install!: boolean; + isEnabled!: boolean; + isTrusted!: 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: Plugin): 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/enum.ts b/server/src/enum.ts index b8e6e5209f..4a60481127 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -164,6 +164,10 @@ export enum Permission { PersonMerge = 'person.merge', PersonReassign = 'person.reassign', + PluginRead = 'plugin.read', + PluginUpdate = 'plugin.update', + PluginDelete = 'plugin.delete', + PinCodeCreate = 'pinCode.create', PinCodeUpdate = 'pinCode.update', PinCodeDelete = 'pinCode.delete', diff --git a/server/src/interfaces/plugin.interface.ts b/server/src/interfaces/plugin.interface.ts new file mode 100644 index 0000000000..0aa03d8a98 --- /dev/null +++ b/server/src/interfaces/plugin.interface.ts @@ -0,0 +1,91 @@ +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.archive', + 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/plugin_types.ts b/server/src/plugin_types.ts new file mode 100644 index 0000000000..ca4231e476 --- /dev/null +++ b/server/src/plugin_types.ts @@ -0,0 +1,92 @@ +import sdk from '../../open-api/typescript-sdk'; + +export type Plugin = { + id: string; + name: string; + description: string; + filters: Filter[]; + // actions: Action[]; +}; + +export enum EntityType { + Asset = 'asset', + Album = 'album', +} + +type PluginItem = { + id: string; + name: string; + description?: string; + type: EntityType; + configuration?: Config[]; +}; + +type FilterContext, D = any> = { + api: { + getAssetAlbums: (assetId: string) => Promise; + }; + sdk: typeof sdk; + config: C; +}; + +type AssetFilter = { + type: EntityType.Asset; + filter: (ctx: FilterContext, input: { asset: { id: string } }) => Promise; +}; + +type AlbumFilter = { + type: EntityType.Album; + filter: (ctx: FilterContext, input: { album: { id: string; name: string } }) => Promise; +}; + +export type Filter = PluginItem & (AssetFilter | AlbumFilter); + +export type Config = { + key: string; + type: PluginConfigType; + required?: boolean; +}; + +export type PluginConfigType = 'string' | 'number' | 'boolean' | 'date' | 'albumId' | 'assetId'; + +const authenticate = (ctx: FilterContext) => { +const + sdk.init() + +} + +export const corePlugin: Plugin = { + id: 'immich', + name: 'Immich Core Plugin', + description: 'Core actions and filters for workflows', + filters: [ + { + id: 'core.notInAnyAlbum', + name: 'Not in any album', + description: 'Filters assets that are not in any album', + type: EntityType.Asset, + async filter(ctx, { asset }) { + const albums = await ctx.sdk.getAllAlbums({ assetId: asset.id }); + return albums.length === 0; + }, + }, + { + id: 'core.notInAlbum', + name: 'Not in an album', + description: 'Run on assets not in the specified album', + type: EntityType.Asset, + configuration: [ + { + key: 'albumId', + type: 'string', + required: true, + }, + ], + async filter(ctx, { asset }) { + // missing api to check if an asset is in an album + const albums = await ctx.sdk.getAllAlbums({ assetId: asset.id }); + return !!albums.find((album) => album.id === ctx.config.albumId); + }, + }, + ], +}; diff --git a/server/src/plugins/asset.plugin.ts b/server/src/plugins/asset.plugin.ts new file mode 100644 index 0000000000..0216273cc8 --- /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-plugins', + 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 d2e1aa08c8..8702d9ce60 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -27,6 +27,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -44,7 +45,6 @@ import { TrashRepository } from 'src/repositories/trash.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; - export const repositories = [ AccessRepository, ActivityRepository, @@ -76,6 +76,7 @@ export const repositories = [ PartnerRepository, PersonRepository, ProcessRepository, + PluginRepository, SearchRepository, SessionRepository, ServerInfoRepository, diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts new file mode 100644 index 0000000000..4044fbc81b --- /dev/null +++ b/server/src/repositories/plugin.repository.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { writeFile } from 'node:fs/promises'; +import { PluginLike } from 'src/interfaces/plugin.interface'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { DB } from 'src/schema'; +import { PluginTable } from 'src/schema/tables/plugin.table'; + +type PluginSearchOptions = { + id?: string; + namespace?: string; + version?: number; + name?: string; + isEnabled?: boolean; + isInstalled?: boolean; + isTrusted?: boolean; +}; + +@Injectable() +export class PluginRepository { + constructor( + @InjectKysely() private db: Kysely, + private logger: LoggingRepository, + ) { + this.logger.setContext(PluginRepository.name); + } + + search(options: PluginSearchOptions) { + return this.db + .selectFrom('plugin') + .select([ + 'id', + 'packageId', + 'version', + 'name', + 'description', + 'isEnabled', + 'isInstalled', + 'isTrusted', + 'requirePath', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]) + .$if(!!options.id, (qb) => qb.where('id', '=', options.id!)) + .$if(!!options.version, (qb) => qb.where('version', '=', options.version!)) + .$if(!!options.name, (qb) => qb.where('name', '=', options.name!)) + .$if(!!options.isEnabled, (qb) => qb.where('isEnabled', '=', options.isEnabled!)) + .$if(!!options.isInstalled, (qb) => qb.where('isInstalled', '=', options.isInstalled!)) + .$if(!!options.isTrusted, (qb) => qb.where('isTrusted', '=', options.isTrusted!)) + .execute(); + } + + create(dto: Insertable) { + return this.db.insertInto('plugin').values(dto).returningAll().executeTakeFirstOrThrow(); + } + + get(id: string) { + return this.db.selectFrom('plugin').where('id', '=', id).executeTakeFirst(); + } + + update(dto: Updateable) { + return this.db.updateTable('plugin').set(dto).returningAll().executeTakeFirstOrThrow(); + } + + async delete(id: string): Promise { + await this.db.deleteFrom('plugin').where('id', '=', id).execute(); + } + + 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/schema/index.ts b/server/src/schema/index.ts index c8474cda03..670090f3cb 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -51,6 +51,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -105,6 +106,7 @@ export class ImmichDatabase { PartnerTable, PersonTable, PersonAuditTable, + PluginTable, SessionTable, SharedLinkAssetTable, SharedLinkTable, @@ -202,6 +204,8 @@ export interface DB { person: PersonTable; person_audit: PersonAuditTable; + plugin: PluginTable; + session: SessionTable; session_sync_checkpoint: SessionSyncCheckpointTable; diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts new file mode 100644 index 0000000000..988a7ddbc1 --- /dev/null +++ b/server/src/schema/tables/plugin.table.ts @@ -0,0 +1,61 @@ +import { Insertable } from 'kysely'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from 'src/sql-tools'; + +const plugin: Insertable = { + version: 1, + id: '123', + name: 'Immich Core Plugin', + description: 'Core plugins for Immich', + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + packageId: 'immich-plugin-', +}; + +@Table('plugins') +export class PluginTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @DeleteDateColumn() + deletedAt!: Date | null; + + @Column({ unique: true }) + packageId!: string; + + @Column() + version!: number; + + @Column() + name!: string; + + @Column() + description!: string; + + @Column({ type: 'boolean', default: true }) + isEnabled!: Generated; + + @Column({ type: 'boolean', default: false }) + isInstalled!: Generated; + + @Column({ type: 'boolean', default: false }) + isTrusted!: Generated; + + @Column({ nullable: true }) + requirePath!: string | null; +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 5a2dd42c3c..97ebb96cd4 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -34,6 +34,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -138,6 +139,7 @@ export class BaseService { protected oauthRepository: OAuthRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, + protected pluginRepository: PluginRepository, protected processRepository: ProcessRepository, protected searchRepository: SearchRepository, protected serverInfoRepository: ServerInfoRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index cad38ca1f4..57b91ee8a1 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -22,6 +22,7 @@ import { NotificationAdminService } from 'src/services/notification-admin.servic import { NotificationService } from 'src/services/notification.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 { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; @@ -40,6 +41,7 @@ import { UserAdminService } from 'src/services/user-admin.service'; 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'; export const services = [ ApiKeyService, @@ -66,6 +68,7 @@ export const services = [ NotificationAdminService, PartnerService, PersonService, + PluginService, SearchService, ServerService, SessionService, @@ -84,4 +87,5 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowService, ]; diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts new file mode 100644 index 0000000000..371a9f5ea0 --- /dev/null +++ b/server/src/services/plugin.service.ts @@ -0,0 +1,42 @@ +import { Plugin } from 'src/database'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { mapPlugin, PluginSearchDto, PluginUpdateDto } from 'src/dtos/plugin.dto'; +import { Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; + +const plugins: Plugin[] = [ + { + id: '123', + name: 'Immich Core Plugin', + description: 'Core plugins for Immich', + version: 1, + isEnabled: true, + isInstalled: true, + isTrusted: true, + createdAt: new Date(), + updatedAt: new Date(), + packageId: 'immich-plugin-', + }, +]; + +export class PluginService extends BaseService { + async search(auth: AuthDto, dto: PluginSearchDto) { + await this.requireAccess({ auth, permission: Permission.PluginRead, ids: [] }); + // return this.pluginRepository.search(dto); + + return plugins.map(mapPlugin); + } + + async update(auth: AuthDto, id: string, dto: PluginUpdateDto) { + await this.requireAccess({ auth, permission: Permission.PluginUpdate, ids: [id] }); + return this.pluginRepository.update({ + id, + isEnabled: dto.isEnabled, + }); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.PluginUpdate, ids: [id] }); + await this.pluginRepository.delete(id); + } +} diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts new file mode 100644 index 0000000000..dfdac8e160 --- /dev/null +++ b/server/src/services/workflow.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { PluginLike } from 'src/interfaces/plugin.interface'; +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class WorkflowService extends BaseService { + private plugins?: PluginLike[]; + + async init(): Promise { + const activePlugins = await this.pluginRepository.search({ isEnabled: true }); + const installPaths = activePlugins.map((p) => p.requirePath).filter(Boolean) as string[]; + this.plugins = await Promise.all(installPaths.map((path) => this.pluginRepository.load(path!))); + } + + // 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; + // } + // } + // } +} 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/constants.ts b/web/src/lib/constants.ts index 8d2b706ead..33bc0f2067 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -25,6 +25,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/lib/sidebars/AdminSidebar.svelte b/web/src/lib/sidebars/AdminSidebar.svelte index 2fecaebf49..284d28ed85 100644 --- a/web/src/lib/sidebars/AdminSidebar.svelte +++ b/web/src/lib/sidebars/AdminSidebar.svelte @@ -2,7 +2,7 @@ import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import { AppRoute } from '$lib/constants'; import { NavbarItem } from '@immich/ui'; - import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync } from '@mdi/js'; + import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiConnection, mdiServer, mdiSync } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -10,6 +10,7 @@
+ diff --git a/web/src/routes/admin/plugins/+page.svelte b/web/src/routes/admin/plugins/+page.svelte new file mode 100644 index 0000000000..8cf7f0a32e --- /dev/null +++ b/web/src/routes/admin/plugins/+page.svelte @@ -0,0 +1,54 @@ + + + + {#snippet buttons()} +
+ +
+ {/snippet} + +
+
+ {#each plugins as plugin, i (i)} +
+
+

+ {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..30df2cc198 --- /dev/null +++ b/web/src/routes/admin/plugins/+page.ts @@ -0,0 +1,16 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + const plugins = []; + const $t = await getFormatter(); + + return { + plugins, + meta: { + title: $t('plugins'), + }, + }; +}) satisfies PageLoad;