mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 04:41:40 -08:00
Compare commits
1 Commits
renovate/f
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b0f0c76a |
@@ -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}}}",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
server/src/controllers/plugin.controller.ts
Normal file
36
server/src/controllers/plugin.controller.ts
Normal file
@@ -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<PluginResponseDto[]> {
|
||||
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<PluginResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deletePlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
58
server/src/dtos/plugin.dto.ts
Normal file
58
server/src/dtos/plugin.dto.ts
Normal file
@@ -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,
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
91
server/src/interfaces/plugin.interface.ts
Normal file
91
server/src/interfaces/plugin.interface.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type PluginFactory = {
|
||||
register: () => MaybePromise<Plugin>;
|
||||
};
|
||||
|
||||
export type PluginLike = Plugin | PluginFactory | { default: Plugin } | { plugin: Plugin };
|
||||
|
||||
export interface Plugin<T extends PluginConfig | undefined = undefined> {
|
||||
version: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
actions: PluginAction<T>[];
|
||||
}
|
||||
|
||||
export type PluginAction<T extends PluginConfig | undefined = undefined> = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
events?: EventType[];
|
||||
config?: T;
|
||||
} & (
|
||||
| { type: ActionType.ASSET; onAction: OnAction<T, AssetDto> }
|
||||
| { type: ActionType.ALBUM; onAction: OnAction<T, AlbumDto> }
|
||||
| { type: ActionType.ALBUM_ASSET; onAction: OnAction<T, { asset: AssetDto; album: AlbumDto }> }
|
||||
);
|
||||
|
||||
export type OnAction<T extends PluginConfig | undefined, D = PluginActionData> = T extends undefined
|
||||
? (ctx: PluginContext, data: D) => MaybePromise<void>
|
||||
: (ctx: PluginContext, data: D, config: InferConfig<T>) => MaybePromise<void>;
|
||||
|
||||
export interface PluginContext {
|
||||
updateAsset: (asset: { id: string; isArchived: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
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<string, ConfigItem>;
|
||||
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
} & { [K in Types]: { type: K; default?: InferType<K> } }[Types];
|
||||
|
||||
export type InferType<T extends Types> = 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<T = void> = Promise<T> | T;
|
||||
type IfRequired<T extends ConfigItem, Type> = T['required'] extends true ? Type : Type | undefined;
|
||||
type InferConfig<T> = T extends PluginConfig
|
||||
? {
|
||||
[K in keyof T]: IfRequired<T[K], InferType<T[K]['type']>>;
|
||||
}
|
||||
: 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' };
|
||||
92
server/src/plugin_types.ts
Normal file
92
server/src/plugin_types.ts
Normal file
@@ -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<C = Record<string, any>, D = any> = {
|
||||
api: {
|
||||
getAssetAlbums: (assetId: string) => Promise<any[]>;
|
||||
};
|
||||
sdk: typeof sdk;
|
||||
config: C;
|
||||
};
|
||||
|
||||
type AssetFilter = {
|
||||
type: EntityType.Asset;
|
||||
filter: (ctx: FilterContext, input: { asset: { id: string } }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type AlbumFilter = {
|
||||
type: EntityType.Album;
|
||||
filter: (ctx: FilterContext, input: { album: { id: string; name: string } }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
55
server/src/plugins/asset.plugin.ts
Normal file
55
server/src/plugins/asset.plugin.ts
Normal file
@@ -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');
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
83
server/src/repositories/plugin.repository.ts
Normal file
83
server/src/repositories/plugin.repository.ts
Normal file
@@ -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<DB>,
|
||||
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<PluginTable>) {
|
||||
return this.db.insertInto('plugin').values(dto).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.db.selectFrom('plugin').where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
update(dto: Updateable<PluginTable>) {
|
||||
return this.db.updateTable('plugin').set(dto).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('plugin').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
async download(url: string, downloadPath: string): Promise<void> {
|
||||
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<PluginLike> {
|
||||
return import(pluginPath);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
61
server/src/schema/tables/plugin.table.ts
Normal file
61
server/src/schema/tables/plugin.table.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Insertable } from 'kysely';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
|
||||
const plugin: Insertable<PluginTable> = {
|
||||
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<string>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@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<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isInstalled!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isTrusted!: Generated<boolean>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
requirePath!: string | null;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
42
server/src/services/plugin.service.ts
Normal file
42
server/src/services/plugin.service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
31
server/src/services/workflow.service.ts
Normal file
31
server/src/services/workflow.service.ts
Normal file
@@ -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<void> {
|
||||
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;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
12
server/src/utils/plugin.ts
Normal file
12
server/src/utils/plugin.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AssetDto, EventType, OnAction, PluginConfig } from 'src/interfaces/plugin.interface';
|
||||
|
||||
export const createPluginAction = <T extends PluginConfig | undefined = undefined>(options: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
events?: EventType[];
|
||||
config?: T;
|
||||
}) => ({
|
||||
addHandler: (onAction: OnAction<T>) => ({ ...options, onAction }),
|
||||
onAsset: (onAction: OnAction<T, AssetDto>) => ({ ...options, onAction }),
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('jobs')} href={AppRoute.ADMIN_JOBS} icon={mdiSync} />
|
||||
<NavbarItem title={$t('plugins')} href={AppRoute.ADMIN_PLUGINS} icon={mdiConnection} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
|
||||
54
web/src/routes/admin/plugins/+page.svelte
Normal file
54
web/src/routes/admin/plugins/+page.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import { Button, Icon, Switch } from '@immich/ui';
|
||||
import { mdiCheckDecagram, mdiWrench } from '@mdi/js';
|
||||
import { range } from 'lodash-es';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
const plugins = range(0, 8).map((index) => ({
|
||||
name: `Plugin-${index}`,
|
||||
description: `Plugin ${index} is awesome because it can do x and even y!`,
|
||||
isEnabled: Math.random() < 0.5,
|
||||
isInstalled: Math.random() < 0.5,
|
||||
isOfficial: Math.random() < 0.5,
|
||||
version: 1,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<AdminPageLayout title={data.meta.title}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Button leadingIcon={mdiWrench} onclick={() => console.log('clicked')}>Test</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{#each plugins as plugin, i (i)}
|
||||
<section
|
||||
class="flex flex-col gap-4 justify-between dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 items-start flex gap-2">
|
||||
{plugin.name}
|
||||
{#if plugin.isOfficial}
|
||||
<Icon icon={mdiCheckDecagram} size="18" class="text-success" />
|
||||
{/if}
|
||||
<div class="place-self-end justify-self-end justify-end self-end">Version {plugin.version}</div>
|
||||
</h1>
|
||||
|
||||
<p class="m-0 text-sm text-gray-600 dark:text-gray-300">{plugin.description}</p>
|
||||
</div>
|
||||
<div class="flex">Is {plugin.isInstalled ? '' : 'not '}installed</div>
|
||||
<Switch checked={plugin.isEnabled} id={plugin.name} title="Enabled" />
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
16
web/src/routes/admin/plugins/+page.ts
Normal file
16
web/src/routes/admin/plugins/+page.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user