From 4dcc04946584b7822faba82644cf8644d4d7da8d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Nov 2025 14:05:05 -0600 Subject: [PATCH] feat: workflow foundation (#23621) * feat: plugins * feat: table definition * feat: type and migration * feat: add repositories * feat: validate manifest with class-validator and load manifest info to database * feat: workflow/plugin controller/service layer * feat: implement workflow logic * feat: make trigger static * feat: dynamical instantiate plugin instances * fix: access control and helper script * feat: it works * chore: simplify * refactor: refactor and use queue for workflow execution * refactor: remove unsused property in plugin-schema * build wasm in prod * feat: plugin loader in transaction * fix: docker build arm64 * generated files * shell check * fix tests * fix: waiting for migration to finish before loading plugin * remove context reassignment * feat: use mise to manage extism tools (#23760) * pr feedback * refactor: create workflow now including create filters and actions * feat: workflow medium tests * fix: broken medium test * feat: medium tests * chore: unify workflow job * sign user id with jwt * chore: query plugin with filters and action * chore: read manifest in repository * chore: load manifest from server configs * merge main * feat: endpoint documentation * pr feedback * load plugin from absolute path * refactor:handle trigger * throw error and return early * pr feedback * unify plugin services * fix: plugins code * clean up * remove triggerConfig * clean up * displayName and methodName --------- Co-authored-by: Jason Rasmussen Co-authored-by: bo0tzz --- docker/docker-compose.dev.yml | 1 + i18n/en.json | 1 + mobile/openapi/README.md | Bin 45988 -> 47454 bytes mobile/openapi/lib/api.dart | Bin 14782 -> 15349 bytes mobile/openapi/lib/api/plugins_api.dart | Bin 0 -> 3773 bytes mobile/openapi/lib/api/workflows_api.dart | Bin 0 -> 9132 bytes mobile/openapi/lib/api_client.dart | Bin 36741 -> 37871 bytes mobile/openapi/lib/api_helper.dart | Bin 7208 -> 7424 bytes mobile/openapi/lib/model/permission.dart | Bin 24361 -> 25669 bytes .../lib/model/plugin_action_response_dto.dart | Bin 0 -> 4649 bytes mobile/openapi/lib/model/plugin_context.dart | Bin 0 -> 2732 bytes .../lib/model/plugin_filter_response_dto.dart | Bin 0 -> 4649 bytes .../lib/model/plugin_response_dto.dart | Bin 0 -> 5058 bytes .../lib/model/plugin_trigger_type.dart | Bin 0 -> 2796 bytes mobile/openapi/lib/model/queue_name.dart | Bin 4618 -> 4737 bytes .../lib/model/queues_response_dto.dart | Bin 7757 -> 8017 bytes .../lib/model/system_config_job_dto.dart | Bin 6196 -> 6452 bytes .../lib/model/workflow_action_item_dto.dart | Bin 0 -> 3688 bytes .../model/workflow_action_response_dto.dart | Bin 0 -> 4089 bytes .../lib/model/workflow_create_dto.dart | Bin 0 -> 5089 bytes .../lib/model/workflow_filter_item_dto.dart | Bin 0 -> 3688 bytes .../model/workflow_filter_response_dto.dart | Bin 0 -> 4089 bytes .../lib/model/workflow_response_dto.dart | Bin 0 -> 7988 bytes .../lib/model/workflow_update_dto.dart | Bin 0 -> 5187 bytes open-api/immich-openapi-specs.json | 757 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 194 ++++- plugins/.gitignore | 2 + plugins/LICENSE | 26 + plugins/esbuild.js | 12 + plugins/manifest.json | 127 +++ plugins/mise.toml | 11 + plugins/package-lock.json | 443 ++++++++++ plugins/package.json | 19 + plugins/src/index.d.ts | 12 + plugins/src/index.ts | 71 ++ plugins/tsconfig.json | 24 + pnpm-lock.yaml | 130 +++ pnpm-workspace.yaml | 1 + server/Dockerfile | 20 + server/package.json | 4 + server/src/config.ts | 1 + server/src/constants.ts | 4 + server/src/controllers/index.ts | 4 + server/src/controllers/plugin.controller.ts | 36 + server/src/controllers/workflow.controller.ts | 76 ++ server/src/database.ts | 55 ++ server/src/dtos/env.dto.ts | 7 + server/src/dtos/plugin-manifest.dto.ts | 110 +++ server/src/dtos/plugin.dto.ts | 77 ++ server/src/dtos/queue.dto.ts | 3 + server/src/dtos/system-config.dto.ts | 6 + server/src/dtos/workflow.dto.ts | 120 +++ server/src/enum.ts | 27 + server/src/plugins.ts | 37 + server/src/queries/access.repository.sql | 9 + server/src/queries/plugin.repository.sql | 159 ++++ server/src/queries/workflow.repository.sql | 68 ++ server/src/repositories/access.repository.ts | 22 + server/src/repositories/config.repository.ts | 12 + server/src/repositories/crypto.repository.ts | 9 + server/src/repositories/event.repository.ts | 2 + server/src/repositories/index.ts | 4 + server/src/repositories/plugin.repository.ts | 176 ++++ server/src/repositories/storage.repository.ts | 4 + .../src/repositories/workflow.repository.ts | 139 ++++ server/src/schema/index.ts | 16 + ...762297277677-AddPluginAndWorkflowTables.ts | 113 +++ server/src/schema/tables/plugin.table.ts | 95 +++ server/src/schema/tables/workflow.table.ts | 78 ++ server/src/services/asset-media.service.ts | 3 + server/src/services/base.service.ts | 7 + server/src/services/index.ts | 4 + server/src/services/plugin-host.functions.ts | 120 +++ server/src/services/plugin.service.ts | 317 ++++++++ server/src/services/queue.service.spec.ts | 3 +- .../services/system-config.service.spec.ts | 1 + server/src/services/workflow.service.ts | 159 ++++ server/src/types.ts | 24 +- server/src/types/plugin-schema.types.ts | 35 + server/src/utils/access.ts | 6 + server/test/medium.factory.ts | 10 +- .../specs/services/plugin.service.spec.ts | 308 +++++++ .../specs/services/workflow.service.spec.ts | 697 ++++++++++++++++ .../repositories/access.repository.mock.ts | 4 + .../repositories/config.repository.mock.ts | 6 + .../repositories/crypto.repository.mock.ts | 2 + .../repositories/storage.repository.mock.ts | 1 + server/test/utils.ts | 8 + web/src/lib/utils.ts | 1 + 89 files changed, 5032 insertions(+), 8 deletions(-) create mode 100644 mobile/openapi/lib/api/plugins_api.dart create mode 100644 mobile/openapi/lib/api/workflows_api.dart create mode 100644 mobile/openapi/lib/model/plugin_action_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_context.dart create mode 100644 mobile/openapi/lib/model/plugin_filter_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_trigger_type.dart create mode 100644 mobile/openapi/lib/model/workflow_action_item_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_action_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_create_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_filter_item_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_filter_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_update_dto.dart create mode 100644 plugins/.gitignore create mode 100644 plugins/LICENSE create mode 100644 plugins/esbuild.js create mode 100644 plugins/manifest.json create mode 100644 plugins/mise.toml create mode 100644 plugins/package-lock.json create mode 100644 plugins/package.json create mode 100644 plugins/src/index.d.ts create mode 100644 plugins/src/index.ts create mode 100644 plugins/tsconfig.json create mode 100644 server/src/controllers/plugin.controller.ts create mode 100644 server/src/controllers/workflow.controller.ts create mode 100644 server/src/dtos/plugin-manifest.dto.ts create mode 100644 server/src/dtos/plugin.dto.ts create mode 100644 server/src/dtos/workflow.dto.ts create mode 100644 server/src/plugins.ts create mode 100644 server/src/queries/plugin.repository.sql create mode 100644 server/src/queries/workflow.repository.sql create mode 100644 server/src/repositories/plugin.repository.ts create mode 100644 server/src/repositories/workflow.repository.ts create mode 100644 server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts create mode 100644 server/src/schema/tables/plugin.table.ts create mode 100644 server/src/schema/tables/workflow.table.ts create mode 100644 server/src/services/plugin-host.functions.ts create mode 100644 server/src/services/plugin.service.ts create mode 100644 server/src/services/workflow.service.ts create mode 100644 server/src/types/plugin-schema.types.ts create mode 100644 server/test/medium/specs/services/plugin.service.spec.ts create mode 100644 server/test/medium/specs/services/workflow.service.spec.ts diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5968c5bb3..e2fb8fbc3 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -41,6 +41,7 @@ services: - app-node_modules:/usr/src/app/node_modules - sveltekit:/usr/src/app/web/.svelte-kit - coverage:/usr/src/app/web/coverage + - ../plugins:/build/corePlugin env_file: - .env environment: diff --git a/i18n/en.json b/i18n/en.json index f0b10d2ac..ce999793d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2181,6 +2181,7 @@ "welcome": "Welcome", "welcome_to_immich": "Welcome to Immich", "wifi_name": "Wi-Fi Name", + "workflow": "Workflow", "wrong_pin_code": "Wrong PIN code", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5e93c571bd59ff4be58953b51591eeb412d16f62..4e34f66a81fc669fff9f52c5bae2f9cbc9ecc2fc 100644 GIT binary patch delta 1378 zcma)5u};G<5EUIWF@P8llA#N+1nt7WM%5IAD55~B4i!Vym?|P|LhK3#F z{YphqwueqRt_R5(Gv-rz(6A{b6@VsR?$Q-}8dM$ot<3+D<^|e3wrjdvke=re@Zgi0 zwLE3@>uN+het321`8T;F{gCwp!$RhXfMm)*K)XrLgLCWTJc)&Kl z>2=wEgb+NSS)&Y}F@Yfzx2M(SoJ`tV)z*g1sQGRnG(U`tVImDK&TNbfpYFXm3qPVP B@ihPd delta 37 vcmV+=0NVfF@&csi0TOITa7; diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index c64295837c502bddd8271a45ba02b324880ac2fa..f3db370c9230353921c4a98242cb308f9649db0b 100644 GIT binary patch delta 248 zcmdm2{Iz^TEh}3=PHB2(-sC`L@y)fY(-|q(=NDzC<>YT>Wj`Ut1D20ZOfJdH z&zmeLtHcWtNY2kINv$ZEJW*DR6J$bsT4qkkWCv*#R)~QJl_f=)>FKFOlM59kH=mJR zDLFZlorfQ4MLEpiUOrh2F+o{H4w$;h4l42p1t7bCCIU6_!W6*lS4L<-(LYgAcyp<_ GCldfjlUj=a delta 27 jcmexbzOQ&gE$il4tkW1b`?K#7+k91Sh2-WnmYz%iw)zX) diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..264d3049e82772e95ddad0444632b3e7904eda48 GIT binary patch literal 3773 zcmeHJTaOwy6n^Jdoa7-uHH9?FLqv9=WMLDebfFufs9LQiX6C@S9qftiSwdI*_a56b zFw=!pRWDMjJaDn+TG0fFr^y}N@qYWL1rje#yj+R*=RJPlxYpKG+%L{hZ%?a22_@<8``@_IVB$ex%j`g zZRj0Va9;U87(k3mmZxU#NNE6;T(A`Odj}8qv`fXlMx!|1Au!0?amb7I{rBN17u1e5 zGh7N_3z-|T*BM4so2YPv#TK~HFd21fF|MSc9?lc4feN3<7!;aZ2@NJecr%~R;N}{2 zMm5LVy6bG;tX?hTH*oWgjxAQVz+-5YEpA(@%bbGaSsAepD>S~)AGk^07?VxtE-mj* z?m-)v-ipYzz}~&UUk@^cOX{ctLl`6VOt}JR%2Jk=nloc2p=PLN*B4NC?|HmyIwOFN z!YpMG4%4*NbPobb+IICnmd$mHz!b66=b0{k#)x2$No`)qcw2b-&VI^KZC#Z#9pUW9 z{jz^^`?7M(1X+ygVCZijfdB2^@2bYO(_b`ofn8aEz73&tzRl2`pa^KQTl_)M>jUsQ zT-?cDa5$T^iV77a42os*^?2Sa7nS>!TV)-}b@giH-r)x2PW7A}uE*=lY+(Q*Pg5GI z+EYgh-jCV@u(KEWcShwe;N3&KME%IfK?V6I(e6>Cis`eIvSHku7#TY&9w&Thht7{| zhnUjj`a;GC{d2Gb_Rj5e7A(Fd@v#$S;Mc>G@AXW~eM zF`$U8Hy0#HkIT@Pj#BrPfQ`L`QCk;ZmS>?z=-{dy<@tlvI$$qHpeCaAxPJ14uKut) zk80f!^1{#Vp*fzHi`vszdfDm3NcH3_<-g+4GG)ty~-fOjup+MC>TOTNM){txs!3FC3Qs Rd$6QeYAoNpPl(N~@f*@5(SiT~ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api/workflows_api.dart b/mobile/openapi/lib/api/workflows_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..c589ec982346c237711420a9c1a2c75e7a6ebdb7 GIT binary patch literal 9132 zcmeHNTT>f16n^)wILSk7ceZw#$wP*4se?_zOd2RL!*m$N5xX*0vR-W@EsmSvzxPO5 z$*bZu(?A;2@(^HkIg-xh`;Ly%?KZky=p7Faettc8H#q7I2YuMve>;eve*o_ehVa`# ze}C_fM>tH6{52s$Z9VJ!@}i+X*+s?$9gVpdjaf=JpvVeIlMzknlx8Y&-)x$5p_a}d z>5R;i|W3CTt;o{r)P5wg+1#cWh4dPwwPghyQuJ8L5ZS3U{-Dj zXJNbjx#-R4%#VmbO{o8Zm}Edw$>E$rY@{VHV`>6)nyWc(A=MfOk|jW5#ZbMSV2yWN zp`C{lCIO8z;Ry(;3Xw^(%$wox5KfP%%yA+;3#T4>gKh*Oe}Yq|Uwgj3;%9!xz6n~+ zLE3u$UV+QfO}Ex|c^W6_1kdzFseC^h^OkE|$a4DgEm0eYoM*AIu2yR4+#8S!I!1Rf zo;FG-I)orbF}Mk5Gqn04K_za1Xm*`!*)(=rCd^nG9Wh2z7%>OuA8Cy4lXIzF@nnvY zoEX2wQkwOW&jl58qfv=tH-Gn^+oxwQD?6J|l2EZ2_+AN?T|{7iZ-2e24#q&9t{iFv z8%+TIROs=;c}~p^N`a?MlIJOljf%P-CC{3;AMM>xddkABJ1o28pJ?xJzh(MCcWFIF zsN9W>BqLqja(Tg zMW8sDP{dU9?kQp;NGdL=@Tiy}(&~6N7)!uzSo6%Yr_Upp;rIzbx68OfPS6ViQyMl6 zYV?#rRmG-MPnij(FnF@mVdg{H_3+HpRq=fx`NP^#oKP~v40ZVRT$;2GPsVHDcD+FbH4=(I}Wyw|3=oz3R zXr7Xo_R_S~G#wExi*e<=(AMe!nya+$ zzg5WG0@9qTS5g&B#49oIPw}lXH3*+I1viubK4jMahs^pdlSo8(dzrZ^TaZBbP$ZD* zk@_O!1_HSxEG6eD1;x!>i4<7w!b%_(An@vg)kty6{QFv+sS-#sh@|zZApPf4;4=yj2;bxSrLzz#^X}gbf&Dahn@-$iZ7cwTnBS zujwtsy6nKW+`oEO<@v{-;(ieN-9G;uuj%#C8l~+-UWGh+bs-2y1v$} qf<9z9&v4-q0mk6P*k3fPMG?l-n2LX literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 373a4b9d8beaffed80fd4fee5d96c5042850bb3b..91dc670d12905cc7a219b54a6111a24906775a77 100644 GIT binary patch delta 418 zcmZpj&-8vd(}ur3966=wnR$-MC6f&!mAC?6yv)f4YP!4-u5*4~NoqyOWJV8B93Zar)2VhMnxWo4iGoBX!1oL9agZ0$rqL7nSoN17g*~FK$L_OWu~X67KKz6 zOg1zVh1vuaoSdI(JUPIZV>63yt;OU3SvH~Y{G#l%ocwa2J2Uh1JWEn@Cm)m*$0qD3 zr_7C{8ssY;BtF<*958{&`F64h&c-@TxQP%SU|57C473PtFwFaWFoU51;HaaGFdi5N V2o8!2qb!mu5~YM0KgX`O#lD@ delta 12 TcmZp$T4Aw4lYcX#z<(Y99moVy diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index e05c3e84bc9b650b1b71ca06ff86a0207c2ab95f..8b05de523b86e36f523eae8207d6e48a13c1d50f 100644 GIT binary patch delta 579 zcmZ3vkMZaU#tqeCYy~-`>6v+x6Pe^#!OY1Ygk@MjVv_|#Mn4zt%LPzkezFfawP8#d7zlRr`jvjkFI4wGJD J3bTu0x&jdo6F2|> diff --git a/mobile/openapi/lib/model/plugin_action_response_dto.dart b/mobile/openapi/lib/model/plugin_action_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..75b23fc8a4b0c3d0371cf151a4cc2f8885057960 GIT binary patch literal 4649 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 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/plugin_trigger_type.dart b/mobile/openapi/lib/model/plugin_trigger_type.dart new file mode 100644 index 0000000000000000000000000000000000000000..b200f1b9e6cf24322b75c27af23f1dc66239c710 GIT binary patch literal 2796 zcma)8ZExE)5dQ98aRG|P0X%uzr^2n928*+0=#rpyJ`9E-Fw)89N|PE%#qf&!_uY|_ z;z)M30I@CUcrVX$_ngM#!FUW8_w(Byex2RSzFf>_mvD9cc{YN}Te!KM!ymVo*H?d@ zkQrNk%7yjQpOc^74fs;7w6T~L#-@c-IDtx64)YWhZczJCb1gTevHsu*p|)b@(z5cY z#lI_Q>9`gf`YeT|Z;#fIbL$Qt&y03nXrIcQ0?I<74aeQVU`lFqsWN&+W_~Sn`tHXx zTMO$30}AKC6ktl!qLc~!zaI<+nG(*yovKz+&#he1$@#8C_&xv_69m^Wz7!rofW|GL zt~Rihehq$&P+Idyx#h5U6sp2yLgzx#eV<7>^lRyoV0cEsV}v<*sdSE3?WA5oc*=>g zZ49=-%>!EYTu3bxeD$`q3~oM|N3@pGAd~u`H}S{t45lBW(_q>@{@oefeMnGynbr9d zF6e);$dykIR%1^uo^4L4B7hejO5)b&FPNE?mj4h&$K2d?4B1zW&S&WYZ9*w1jdSur zVJydi)DffwQ?gw2NDO4W$S8p8D31?4WB@Yy!Ud zj(VLCjg&JJJ!|f^xfR;clx@%s&yt(~%W=5ZCUqpn;y~h{=>AnVr2dO8aY-ZYd&S1Er47Als3kp&FdCs0=gQr^A=zD}gOF@Clw{jPCJZjFUu zY*q@*M6v1f^Coa?_CsyaLP+OBXK@ZOi+Jc{3c6Bi-zPvUBoTSu!WYj0JcTNn?|5a= zS5`Ntyy3cr4Oo*Fwh;em@Wh%phL$Xv6@nuQ2#Jk{OlY89WcHRozh%&l40)OlbSHRI zPwOZ+7MfHflD~web8_JYJc#TYTOV+e98<7PUj@Dkk$Gcx|Dy@HRns>A^YiI3QR{>u zjf=B;L<5hR*RmAB8}^0-7>1x^{39x8D-oArUL48uqCqXR$a2BPO9oE#OJ+GGI^!o? z6GJ31W_v?Dwd5Z6wj#wRN?7F&J0`xh@$i5USw*lY#pEOOG_pk z!pnU(smbgomFz8%D4r}rwTs@ad&N(8_dGkxu@EHL6T+nVGrwYaeo=N>PJX$9twLaFYH6xpVs5Hlyhf2aOk(pd)>({P3JP%k b1om=H{^Z2sR0V{h$&7p=9B>tX1$Y?&JWM1^ delta 22 ecmZov?NZtBk9D&V+cd__*Vv0WH`fU=GXel*nFrhe diff --git a/mobile/openapi/lib/model/queues_response_dto.dart b/mobile/openapi/lib/model/queues_response_dto.dart index b20f8c3c09fc49d9efa6c9d2b3ca75db6751e4d0..be40a56fb172bb03dfeda568c7ae5e88d33af813 100644 GIT binary patch delta 230 zcmX?WbJ1?YH0H_WOagr6`9;}jIr-%}TnY-cnv)+ei)~)VtiUvRC95C@Om6c@R!wFl z1vNFG8U=;?l8n?MJ(!$=t*ru#z4;S|71QK>oE!+9O5Dm!B06vtwkmM7uDngm$_ffu z#rb*BMd~nN^;iX4g_4ZSVz>pH4+{9Q0WF59n0!%08DTz?xGTGuf&xN6*dNwhwOm{P DraMiG delta 49 zcmca;ch+XZH0I5lm@Sw#bFir}Z}#J~V%ofjTasz>dEO@G%~gW#Y@3-REZNy=t+{Hs FxB#Wo4zd6M diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 3eeb9c7d3b49549f75c40575f4544aa3f24e0789..461420b3e3a9ed5815850c1b78d3cae1f851535d 100644 GIT binary patch delta 235 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 Dz{4`N literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/workflow_create_dto.dart b/mobile/openapi/lib/model/workflow_create_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..c6e44743acbe63bb31e4a12b6f00905048b8ed7a GIT binary patch literal 5089 zcmeHL-*4MC5PtVxaS4Lj##E=Tyo%A!hAHH`|kKd zq9dox2JCGQO(pW~c=z3R$CHnbJIBXx`up|d>96Nk=O0e5&(GlE^6hy7XA`)ZT*JG` z+2zGw2Pj68uQI00=$FCI&pY%gmW5P&zL09Z5IG-0RaBa1bDr}xFH92N#k!Qr?A2iT zhHdq{tjt{TFO^WJUa&R(Et$gq4ObeCE4^Pmm4!B}FmsXNfnp)Jvg&&8VYw){p69Yy zqL^8+V*dQuJY6xRI~_dEfLwqqdBI9Cz<)0~oit}!!=F-pUgUD~T5)FhnUQeY0XRkk zK5$d1LIZ<>Z(!OIoPn?orhI@Jt?eG!0Wd3}2P`!Lqk$3FpkO*nn$3*sREd%#qE+l^8$s6r$v?L6v+X66h+F>Ne;T? zDdsa}6f+RLyzxwOO{NjqG_Nv_*k^pfs@%X=mRB5ll@`U)8aebk!^7HxegZ4G;a|B* ztjLlVTwzu-Z(hqvp$n-*%j-%TSg<^Y84PcYP)2>*lrUxR)lWqty_%G$MXVT-M<1z&qs z&ZP^e(CqvByjqIlx~VUSYRyW#l1NE~*~etlxzvb|mr}uK)SKLV;wiQ#EDY6$(HLC2 zOIh9{t|VdS+c)Cl6cP}hC%I&M26 zA9@vYaaYbGMl#{?$7g%^5~_9TVfW4^D9NDxf5)o`NfL-&TfmCKz>*s*W4(G!6+RkU zNb0+cuCRiqTx+JbbOqt9vf^53E7tSvZV>eQ@RQrPhYWQGIDuy{j6phR>jiiyutvwP z?veM{v<(p~?bQg2X0wHgC##X}p=TM^ZSiO7qaPjlha?VebQEEbguIWAd@OZZM@My{ z$>-4#{l!+?l*Z^NdYN^tyYE=}(s_r6Zc#;~V{Dgiv@FnFcZ)7W!E*S7OL+aYq;KzD z*i?a)n5wQ1qB}$B_yTrfVga_a22oUS;Ib4BtW8BSjk##inY;s@3} ziw`Z_(G>E}rY&Ga25#)sAdGgSfrf>WYj137rv)6)7AVlA5pIesnJD*?;0qic?TDtT6#KKDQ|F%J7-~d1$BA_%Gin!Zq1}d0tP_^Gp2^O+z^bZTDwffd z*|Q!daM&uvL!8X0l@m4G(1Q&hy$?13n`l%FR%DPeoa62nQP0hIWXz-9I;>}pA_-qI z!#b$bcp4n!I4E8qz&19j!??=&Zr~&{5#fihX5}9=woMi^c6$HDJyK6VbtBnsWWtlS zrA{|zqa3ImDSF;EAzZ|l_}&m|?nh>b+O5MKw$1Uj8u|xK;1SV>I3(X22?j*Y7`sTk zJ8SIt+~3)>_@srxwWZ>jw==ibQ@|K@(iAA@@V>Q^$5P1+jpNuDaro6dxeL5|s~5l;LU^)P;zY!kR~s>^ zfVyhpbcFaFFenvYYi&E>f}@xN@_6!hfwyuamzamz19sk~`-N}vIer%7*FSybyU}PT ziMXn^A&moi6CCHc+q#jskRFjvYiVEwqvZS`tq9fBDSqZ#y56n6<7Jc0kky^sqAJPx z8;4Ru7lVaKx*2>U%nFyNi0J2nRvw-@IaKqP4t>?(Co28CfacJ+FVA<;NJC88Q%qyh zpuN5PNZ@hhiZ){AJapE-;m;$f@fezUZbYr%hb+RtcJtgy#%@24$duI8+SF}QGi*=M z<5>|gj`3FwUUQ)S5e4a{ah7D5Y5L{hhSRv9@mQ(_25&x2uIP*26Ttn<6O?}=-Fj{v dyIQG-RCkAT!&T&8?FHkXTmk%A`wDF^n|-edm&r zEhkEw?IDgW-Y=eW?j?0})H^z&H-BDCp8j@ve){pv#pwy1jo+UpbTXmy$pw9woQ%)@ z*@GEdzRrd9!(Ru#Jn!*OHPgn*v@kX;R4I?C(v_2WDoeSL+9%C#wOAVKJ3U0X5^I+( zE1z2VwNe)DS}gFn6c+!zv<|_o>vqqKc3xt&^Ij{Y|GOHrEDnJQ7w*vrLi6|fGG0-xEy3)N#uDn>nH$qKd`}& z0RC78WQ-nDg~0fnTz!_Af5kv-cnPR~@|IJ@=i@v)du^~ii%4+1GNxonVUNbraM+KL z$N*$}NW&3D;bx-jbKay``SvY-Bh=C<7kcsp@hMvE2nK0(3hlBS=Uc0evX%BUlOBM( z`CH~+|Lq(?k}S;9>aNDno0zl$$QaS>RQc;@Fs*oV6GqbaxQi z8HEH^YnEey4KLMlW<@SB>ieO}g!Vf{wvPeKpfTBT2BK%NizW_1X)Un?lR+k&zbBi1 z9OW$rIurIoI<{&n=9m^@`4`XNNpXR(_%e=~QJ+Pc&>eXl#$#n0WSrj%5PRTm0I240 zuko(8ej6&PRnw?0sHP_1y~1`oH#4FVYiD^=q$!!B|1lCH& z=MVa@JHzyHOHk-o@WTaRFb0Valsh;6bg}g7CNQAIyv7`3;*g$?4R+nGH>^g+husnz zY!rqVHC7DyRx0;q0WZf54RDS{LA5+1(%h14xPyD`Z&9@MxO-g&tbtA`; z>ZGQ@Ap!)e_7v=n)t-qB3#j0`Bm}!*B41MJ>6$Guu5nwBq&7qw1mhM_KcE7ELm^YhobHRt!cUFcxLAl|_}LY8a{i`|e1+ zP_~t1{SZeY?~V67cSoI`_D)aX{ogm!7k{2#onODdIX{Do$*1!G&ZclRy@AW=+2rEi zBNQXacQI3D^hfyns~)|IMJ5%G5~-p@qpX=+1ylK;}GSxd`#^b+0#1nbz<{s&7du*B2tic&@q5(JenS67G8drsiys4@a(DRhV_M(-bFmWV~rxj%^y?TLe{QpH>y z5?_}GcE+|Rmh3|l8Y3M&UE@SCqWiT8KJfi-)^ebMG*&EDP_OClNR z9tIZa{+F)moB3xN9s6%V&BkgSsf?r<9b+Xp97e}B)WA#n=(q{ z0~=`o&pK=28TM!bgji;UsRQC^DqDaB;jz?;gmb3N9$yEp+mo>pjZUWn>yjjWx(;E* z@_#7X)8uB8^Ea+}j|WsC0qk)5#Lb_^$Ryq~;0kcp08;kksL>XW>mh@E4NKA?qP+^S zU1~n)AKz1qSbim)w|y_XhO3VT*ob-#ON6#IX=Q4wsBur3U=Q|65r(y)84Z_>6qSjgho)qtnzvQ8a^ z1X~!CT1L|O237KgP(4{IK0y9V=*}Qj!9kd+e7HoRls`4P?fG-0LIfju5D+Uja)5Oe zJNDP|B0q%s&K=>k7jO4;i+A^j7Pz>oHWj=fy$Pw7Z@s@rx4uS>?`>Y%*FiV)$~ByV(^b literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/workflow_response_dto.dart b/mobile/openapi/lib/model/workflow_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..5132e7cb73b42389c57860d4a6556fbff7897495 GIT binary patch literal 7988 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 Pw`HRf>J{Xn2*m#Y("/plugins", { + ...opts + })); +} +/** + * Retrieve a plugin + */ +export function getPlugin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginResponseDto; + }>(`/plugins/${encodeURIComponent(id)}`, { + ...opts + })); +} /** * Retrieve assets by city */ @@ -4824,6 +4928,72 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * List all workflows + */ +export function getWorkflows(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto[]; + }>("/workflows", { + ...opts + })); +} +/** + * Create a workflow + */ +export function createWorkflow({ workflowCreateDto }: { + workflowCreateDto: WorkflowCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: WorkflowResponseDto; + }>("/workflows", oazapfts.json({ + ...opts, + method: "POST", + body: workflowCreateDto + }))); +} +/** + * Delete a workflow + */ +export function deleteWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/workflows/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve a workflow + */ +export function getWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Update a workflow + */ +export function updateWorkflow({ id, workflowUpdateDto }: { + id: string; + workflowUpdateDto: WorkflowUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: workflowUpdateDto + }))); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -4976,6 +5146,10 @@ export enum Permission { PinCodeCreate = "pinCode.create", PinCodeUpdate = "pinCode.update", PinCodeDelete = "pinCode.delete", + PluginCreate = "plugin.create", + PluginRead = "plugin.read", + PluginUpdate = "plugin.update", + PluginDelete = "plugin.delete", ServerAbout = "server.about", ServerApkLinks = "server.apkLinks", ServerStorage = "server.storage", @@ -5025,6 +5199,10 @@ export enum Permission { UserProfileImageRead = "userProfileImage.read", UserProfileImageUpdate = "userProfileImage.update", UserProfileImageDelete = "userProfileImage.delete", + WorkflowCreate = "workflow.create", + WorkflowRead = "workflow.read", + WorkflowUpdate = "workflow.update", + WorkflowDelete = "workflow.delete", AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", @@ -5083,7 +5261,8 @@ export enum QueueName { Library = "library", Notifications = "notifications", BackupDatabase = "backupDatabase", - Ocr = "ocr" + Ocr = "ocr", + Workflow = "workflow" } export enum QueueCommand { Start = "start", @@ -5104,6 +5283,11 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } +export enum PluginContext { + Asset = "asset", + Album = "album", + Person = "person" +} export enum SearchSuggestionType { Country = "country", State = "state", @@ -5255,3 +5439,11 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum TriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} +export enum PluginTriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 000000000..76add878f --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/plugins/LICENSE b/plugins/LICENSE new file mode 100644 index 000000000..53f0fa695 --- /dev/null +++ b/plugins/LICENSE @@ -0,0 +1,26 @@ +Copyright 2024, The Extism Authors. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/esbuild.js b/plugins/esbuild.js new file mode 100644 index 000000000..04cb6e85a --- /dev/null +++ b/plugins/esbuild.js @@ -0,0 +1,12 @@ +const esbuild = require('esbuild'); + +esbuild + .build({ + entryPoints: ['src/index.ts'], + outdir: 'dist', + bundle: true, + sourcemap: true, + minify: false, // might want to use true for production build + format: 'cjs', // needs to be CJS for now + target: ['es2020'] // don't go over es2020 because quickjs doesn't support it + }) \ No newline at end of file diff --git a/plugins/manifest.json b/plugins/manifest.json new file mode 100644 index 000000000..1172530c1 --- /dev/null +++ b/plugins/manifest.json @@ -0,0 +1,127 @@ +{ + "name": "immich-core", + "version": "2.0.0", + "title": "Immich Core", + "description": "Core workflow capabilities for Immich", + "author": "Immich Team", + + "wasm": { + "path": "dist/plugin.wasm" + }, + + "filters": [ + { + "methodName": "filterFileName", + "title": "Filter by filename", + "description": "Filter assets by filename pattern using text matching or regular expressions", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Text or regex pattern to match against filename" + }, + "matchType": { + "type": "string", + "enum": ["contains", "regex", "exact"], + "default": "contains", + "description": "Type of pattern matching to perform" + }, + "caseSensitive": { + "type": "boolean", + "default": false, + "description": "Whether matching should be case-sensitive" + } + }, + "required": ["pattern"] + } + }, + { + "methodName": "filterFileType", + "title": "Filter by file type", + "description": "Filter assets by file type", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "fileTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["IMAGE", "VIDEO"] + }, + "description": "Allowed file types" + } + }, + "required": ["fileTypes"] + } + }, + { + "methodName": "filterPerson", + "title": "Filter by person", + "description": "Filter by detected person", + "supportedContexts": ["person"], + "schema": { + "type": "object", + "properties": { + "personIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of person to match" + }, + "matchAny": { + "type": "boolean", + "default": true, + "description": "Match any name (true) or require all names (false)" + } + }, + "required": ["personIds"] + } + } + ], + + "actions": [ + { + "methodName": "actionArchive", + "title": "Archive", + "description": "Move the asset to archive", + "supportedContexts": ["asset"], + "schema": {} + }, + { + "methodName": "actionFavorite", + "title": "Favorite", + "description": "Mark the asset as favorite or unfavorite", + "supportedContexts": ["asset"], + "schema": { + "type": "object", + "properties": { + "favorite": { + "type": "boolean", + "default": true, + "description": "Set favorite (true) or unfavorite (false)" + } + } + } + }, + { + "methodName": "actionAddToAlbum", + "title": "Add to Album", + "description": "Add the item to a specified album", + "supportedContexts": ["asset", "person"], + "schema": { + "type": "object", + "properties": { + "albumId": { + "type": "string", + "description": "Target album ID" + } + }, + "required": ["albumId"] + } + } + ] +} diff --git a/plugins/mise.toml b/plugins/mise.toml new file mode 100644 index 000000000..c1001e574 --- /dev/null +++ b/plugins/mise.toml @@ -0,0 +1,11 @@ +[tools] +"github:extism/cli" = "1.6.3" +"github:webassembly/binaryen" = "version_124" +"github:extism/js-pdk" = "1.5.1" + +[tasks.install] +run = "pnpm install --frozen-lockfile" + +[tasks.build] +depends = ["install"] +run = "pnpm run build" diff --git a/plugins/package-lock.json b/plugins/package-lock.json new file mode 100644 index 000000000..3b0f0b34c --- /dev/null +++ b/plugins/package-lock.json @@ -0,0 +1,443 @@ +{ + "name": "js-pdk-template", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "js-pdk-template", + "version": "1.0.0", + "license": "BSD-3-Clause", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extism/js-pdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.0.1.tgz", + "integrity": "sha512-YJWfHGeOuJnQw4V8NPNHvbSr6S8iDd2Ga6VEukwlRP7tu62ozTxIgokYw8i+rajD/16zz/gK0KYARBpm2qPAmQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/typescript": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/plugins/package.json b/plugins/package.json new file mode 100644 index 000000000..ab6b2f843 --- /dev/null +++ b/plugins/package.json @@ -0,0 +1,19 @@ +{ + "name": "plugins", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "scripts": { + "build": "pnpm build:tsc && pnpm build:wasm", + "build:tsc": "tsc --noEmit && node esbuild.js", + "build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" + }, + "keywords": [], + "author": "", + "license": "AGPL-3.0", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } +} diff --git a/plugins/src/index.d.ts b/plugins/src/index.d.ts new file mode 100644 index 000000000..7f805aafe --- /dev/null +++ b/plugins/src/index.d.ts @@ -0,0 +1,12 @@ +declare module 'main' { + export function filterFileName(): I32; + export function actionAddToAlbum(): I32; + export function actionArchive(): I32; +} + +declare module 'extism:host' { + interface user { + updateAsset(ptr: PTR): I32; + addAssetToAlbum(ptr: PTR): I32; + } +} diff --git a/plugins/src/index.ts b/plugins/src/index.ts new file mode 100644 index 000000000..9566c02cd --- /dev/null +++ b/plugins/src/index.ts @@ -0,0 +1,71 @@ +const { updateAsset, addAssetToAlbum } = Host.getFunctions(); + +function parseInput() { + return JSON.parse(Host.inputString()); +} + +function returnOutput(output: any) { + Host.outputString(JSON.stringify(output)); + return 0; +} + +export function filterFileName() { + const input = parseInput(); + const { data, config } = input; + const { pattern, matchType = 'contains', caseSensitive = false } = config; + + const fileName = data.asset.originalFileName || data.asset.fileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + + let passed = false; + + if (matchType === 'exact') { + passed = searchName === searchPattern; + } else if (matchType === 'regex') { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + passed = regex.test(fileName); + } else { + // contains + passed = searchName.includes(searchPattern); + } + + return returnOutput({ passed }); +} + +export function actionAddToAlbum() { + const input = parseInput(); + const { authToken, config, data } = input; + const { albumId } = config; + + const ptr = Memory.fromString( + JSON.stringify({ + authToken, + assetId: data.asset.id, + albumId: albumId, + }) + ); + + addAssetToAlbum(ptr.offset); + ptr.free(); + + return returnOutput({ success: true }); +} + +export function actionArchive() { + const input = parseInput(); + const { authToken, data } = input; + const ptr = Memory.fromString( + JSON.stringify({ + authToken, + id: data.asset.id, + visibility: 'archive', + }) + ); + + updateAsset(ptr.offset); + ptr.free(); + + return returnOutput({ success: true }); +} diff --git a/plugins/tsconfig.json b/plugins/tsconfig.json new file mode 100644 index 000000000..86c9e766b --- /dev/null +++ b/plugins/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2020", // Specify ECMAScript target version + "module": "commonjs", // Specify module code generation + "lib": [ + "es2020" + ], // Specify a list of library files to be included in the compilation + "types": [ + "@extism/js-pdk", + "./src/index.d.ts" + ], // Specify a list of type definition files to be included in the compilation + "strict": true, // Enable all strict type-checking options + "esModuleInterop": true, // Enables compatibility with Babel-style module imports + "skipLibCheck": true, // Skip type checking of declaration files + "allowJs": true, // Allow JavaScript files to be compiled + "noEmit": true // Do not emit outputs (no .js or .d.ts files) + }, + "include": [ + "src/**/*.ts" // Include all TypeScript files in src directory + ], + "exclude": [ + "node_modules" // Exclude the node_modules directory + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ad7fcd3..c0e4b5ea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,8 +299,23 @@ importers: specifier: ^5.3.3 version: 5.9.3 + plugins: + devDependencies: + '@extism/js-pdk': + specifier: ^1.0.1 + version: 1.1.1 + esbuild: + specifier: ^0.19.6 + version: 0.19.12 + typescript: + specifier: ^5.3.2 + version: 5.9.3 + server: dependencies: + '@extism/extism': + specifier: 2.0.0-rc13 + version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 version: 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1) @@ -367,6 +382,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.5) + ajv: + specifier: ^8.17.1 + version: 8.17.1 archiver: specifier: ^7.0.0 version: 7.0.1 @@ -430,6 +448,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 kysely: specifier: 0.28.2 version: 0.28.2 @@ -569,6 +590,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/lodash': specifier: ^4.14.197 version: 4.17.20 @@ -2572,6 +2596,12 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@extism/extism@2.0.0-rc13': + resolution: {integrity: sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==} + + '@extism/js-pdk@1.1.1': + resolution: {integrity: sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==} + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -4590,6 +4620,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/justified-layout@4.1.4': resolution: {integrity: sha512-q2ybP0u0NVj87oMnGZOGxY2iUN8ddr48zPOBHBdbOLpsMTA/keGj+93ou+OMCnJk0xewzlNIaVEkxM6VBD3E2w==} @@ -5364,6 +5397,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6294,6 +6330,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7717,12 +7756,22 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + just-compare@2.3.0: resolution: {integrity: sha512-6shoR7HDT+fzfL3gBahx1jZG3hWLrhPAf+l7nCwahDdT9XDtosB9kIF0ZrzUp5QY8dJWfQVr5rnsPqsbvflDzg==} justified-layout@4.1.0: resolution: {integrity: sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + kdbush@3.0.0: resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} @@ -7922,15 +7971,36 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} @@ -11043,6 +11113,9 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} + urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -14170,6 +14243,12 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 + '@extism/extism@2.0.0-rc13': {} + + '@extism/js-pdk@1.1.1': + dependencies: + urlpattern-polyfill: 8.0.2 + '@faker-js/faker@10.1.0': {} '@fig/complete-commander@3.2.0(commander@11.1.0)': @@ -16414,6 +16493,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.0 + '@types/justified-layout@4.1.4': {} '@types/keygrip@1.0.6': {} @@ -17389,6 +17473,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -18341,6 +18427,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.243: {} @@ -20155,10 +20245,34 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + just-compare@2.3.0: {} justified-layout@4.1.0: {} + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + kdbush@3.0.0: {} kdbush@4.0.2: {} @@ -20323,12 +20437,26 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.uniq@4.5.0: {} lodash@4.17.21: {} @@ -24145,6 +24273,8 @@ snapshots: punycode: 1.4.1 qs: 6.14.0 + urlpattern-polyfill@8.0.2: {} + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db33e87a0..d5629d232 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - e2e - open-api/typescript-sdk - server + - plugins - web - .github ignoredBuiltDependencies: diff --git a/server/Dockerfile b/server/Dockerfile index 0fc412692..0bb7fc6be 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -48,6 +48,24 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ pnpm --filter @immich/sdk --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned +FROM builder AS plugins + +COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise + +WORKDIR /usr/src/app +COPY ./plugins/mise.toml ./plugins/ +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml +RUN mise install --cd plugins + +COPY ./plugins ./plugins/ +# Build plugins +RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ + --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ + --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ + --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ + cd plugins && mise run build + FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb WORKDIR /usr/src/app @@ -58,6 +76,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli +COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist +COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE diff --git a/server/package.json b/server/package.json index aa6ba671a..a252a53b8 100644 --- a/server/package.json +++ b/server/package.json @@ -34,6 +34,7 @@ "email:dev": "email dev -p 3050 --dir src/emails" }, "dependencies": { + "@extism/extism": "2.0.0-rc13", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", @@ -56,6 +57,7 @@ "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", "@socket.io/redis-adapter": "^8.3.0", + "ajv": "^8.17.1", "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^6.0.0", @@ -77,6 +79,7 @@ "i18n-iso-countries": "^7.6.0", "ioredis": "^5.8.2", "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", "kysely": "0.28.2", "kysely-postgres-js": "^3.0.0", "lodash": "^4.17.21", @@ -124,6 +127,7 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/fluent-ffmpeg": "^2.1.21", + "@types/jsonwebtoken": "^9.0.10", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/luxon": "^3.6.2", diff --git a/server/src/config.ts b/server/src/config.ts index e81ad4962..c18acd79f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -235,6 +235,7 @@ export const defaults = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, logging: { enabled: true, diff --git a/server/src/constants.ts b/server/src/constants.ts index ddf8bc91d..d624557c5 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -160,6 +160,8 @@ export const endpointTags: Record = { [ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.', [ApiTag.People]: 'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.', + [ApiTag.Plugins]: + 'A plugin is an installed module that makes filters and actions available for the workflow feature.', [ApiTag.Search]: 'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.', [ApiTag.Server]: @@ -185,4 +187,6 @@ export const endpointTags: Record = { [ApiTag.Users]: 'Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more.', [ApiTag.Views]: 'Endpoints for specialized views, such as the folder view.', + [ApiTag.Workflows]: + 'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.', }; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e3661ec79..c0c0461fb 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'; @@ -32,6 +33,7 @@ import { TrashController } from 'src/controllers/trash.controller'; import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; import { ViewController } from 'src/controllers/view.controller'; +import { WorkflowController } from 'src/controllers/workflow.controller'; export const controllers = [ ApiKeyController, @@ -54,6 +56,7 @@ export const controllers = [ OAuthController, PartnerController, PersonController, + PluginController, SearchController, ServerController, SessionController, @@ -68,4 +71,5 @@ export const controllers = [ UserAdminController, UserController, ViewController, + WorkflowController, ]; diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts new file mode 100644 index 000000000..a0a4d14b0 --- /dev/null +++ b/server/src/controllers/plugin.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { PluginResponseDto } from 'src/dtos/plugin.dto'; +import { Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { PluginService } from 'src/services/plugin.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Plugins') +@Controller('plugins') +export class PluginController { + constructor(private service: PluginService) {} + + @Get() + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'List all plugins', + description: 'Retrieve a list of plugins available to the authenticated user.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getPlugins(): Promise { + return this.service.getAll(); + } + + @Get(':id') + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'Retrieve a plugin', + description: 'Retrieve information about a specific plugin by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getPlugin(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); + } +} diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts new file mode 100644 index 000000000..e07b6443f --- /dev/null +++ b/server/src/controllers/workflow.controller.ts @@ -0,0 +1,76 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { WorkflowService } from 'src/services/workflow.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Workflows') +@Controller('workflows') +export class WorkflowController { + constructor(private service: WorkflowService) {} + + @Post() + @Authenticated({ permission: Permission.WorkflowCreate }) + @Endpoint({ + summary: 'Create a workflow', + description: 'Create a new workflow, the workflow can also be created with empty filters and actions.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Get() + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'List all workflows', + description: 'Retrieve a list of workflows available to the authenticated user.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getWorkflows(@Auth() auth: AuthDto): Promise { + return this.service.getAll(auth); + } + + @Get(':id') + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'Retrieve a workflow', + description: 'Retrieve information about a specific workflow by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.WorkflowUpdate }) + @Endpoint({ + summary: 'Update a workflow', + description: + 'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + updateWorkflow( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: WorkflowUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.WorkflowDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a workflow', + description: 'Delete a workflow by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + deleteWorkflow(@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 b62cb7034..4aa69127f 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -7,6 +7,8 @@ import { AssetVisibility, MemoryType, Permission, + PluginContext, + PluginTriggerType, SharedLinkType, SourceType, UserAvatarColor, @@ -14,7 +16,10 @@ import { } from 'src/enum'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { UserMetadataItem } from 'src/types'; +import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types'; export type AuthUser = { id: string; @@ -277,6 +282,45 @@ export type AssetFace = { updateId: string; }; +export type Plugin = Selectable; + +export type PluginFilter = Selectable & { + methodName: string; + title: string; + description: string; + supportedContexts: PluginContext[]; + schema: JSONSchema | null; +}; + +export type PluginAction = Selectable & { + methodName: string; + title: string; + description: string; + supportedContexts: PluginContext[]; + schema: JSONSchema | null; +}; + +export type Workflow = Selectable & { + triggerType: PluginTriggerType; + name: string | null; + description: string; + enabled: boolean; +}; + +export type WorkflowFilter = Selectable & { + workflowId: string; + filterId: string; + filterConfig: FilterConfig | null; + order: number; +}; + +export type WorkflowAction = Selectable & { + workflowId: string; + actionId: string; + actionConfig: ActionConfig | null; + order: number; +}; + const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; const userWithPrefixColumns = [ 'user2.id', @@ -418,4 +462,15 @@ export const columns = { 'asset_exif.state', 'asset_exif.timeZone', ], + plugin: [ + 'plugin.id as id', + 'plugin.name as name', + 'plugin.title as title', + 'plugin.description as description', + 'plugin.author as author', + 'plugin.version as version', + 'plugin.wasmPath as wasmPath', + 'plugin.createdAt as createdAt', + 'plugin.updatedAt as updatedAt', + ], } as const; diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 3543d8dae..2a9dd8b66 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -57,6 +57,13 @@ export class EnvDto { @Type(() => Number) IMMICH_MICROSERVICES_METRICS_PORT?: number; + @ValidateBoolean({ optional: true }) + IMMICH_PLUGINS_ENABLED?: boolean; + + @Optional() + @Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' }) + IMMICH_PLUGINS_INSTALL_FOLDER?: string; + @IsInt() @Optional() @Type(() => Number) diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts new file mode 100644 index 000000000..fcb3ad4a2 --- /dev/null +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -0,0 +1,110 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsSemVer, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; +import { PluginContext } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +class PluginManifestWasmDto { + @IsString() + @IsNotEmpty() + path!: string; +} + +class PluginManifestFilterDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @IsEnum(PluginContext, { each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +class PluginManifestActionDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +export class PluginManifestDto { + @IsString() + @IsNotEmpty() + @Matches(/^[a-z0-9-]+[a-z0-9]$/, { + message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen', + }) + name!: string; + + @IsString() + @IsNotEmpty() + @IsSemVer() + version!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsString() + @IsNotEmpty() + author!: string; + + @ValidateNested() + @Type(() => PluginManifestWasmDto) + wasm!: PluginManifestWasmDto; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestFilterDto) + @IsOptional() + filters?: PluginManifestFilterDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestActionDto) + @IsOptional() + actions?: PluginManifestActionDto[]; +} diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts new file mode 100644 index 000000000..ce80eccd6 --- /dev/null +++ b/server/src/dtos/plugin.dto.ts @@ -0,0 +1,77 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { PluginAction, PluginFilter } from 'src/database'; +import { PluginContext } from 'src/enum'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +export class PluginResponseDto { + id!: string; + name!: string; + title!: string; + description!: string; + author!: string; + version!: string; + createdAt!: string; + updatedAt!: string; + filters!: PluginFilterResponseDto[]; + actions!: PluginActionResponseDto[]; +} + +export class PluginFilterResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginActionResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginInstallDto { + @IsString() + @IsNotEmpty() + manifestPath!: string; +} + +export type MapPlugin = { + id: string; + name: string; + title: string; + description: string; + author: string; + version: string; + wasmPath: string; + createdAt: Date; + updatedAt: Date; + filters: PluginFilter[]; + actions: PluginAction[]; +}; + +export function mapPlugin(plugin: MapPlugin): PluginResponseDto { + return { + id: plugin.id, + name: plugin.name, + title: plugin.title, + description: plugin.description, + author: plugin.author, + version: plugin.version, + createdAt: plugin.createdAt.toISOString(), + updatedAt: plugin.updatedAt.toISOString(), + filters: plugin.filters, + actions: plugin.actions, + }; +} diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts index 1492e014d..df00c5cfc 100644 --- a/server/src/dtos/queue.dto.ts +++ b/server/src/dtos/queue.dto.ts @@ -91,4 +91,7 @@ export class QueuesResponseDto implements Record { @ApiProperty({ type: QueueResponseDto }) [QueueName.Ocr]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Workflow]!: QueueResponseDto; } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 6d36e2cc8..c835073c3 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -224,6 +224,12 @@ class SystemConfigJobDto implements Record @IsObject() @Type(() => JobSettingsDto) [QueueName.Notification]!: JobSettingsDto; + + @ApiProperty({ type: JobSettingsDto }) + @ValidateNested() + @IsObject() + @Type(() => JobSettingsDto) + [QueueName.Workflow]!: JobSettingsDto; } class SystemConfigLibraryScanDto { diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts new file mode 100644 index 000000000..307440945 --- /dev/null +++ b/server/src/dtos/workflow.dto.ts @@ -0,0 +1,120 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { WorkflowAction, WorkflowFilter } from 'src/database'; +import { PluginTriggerType } from 'src/enum'; +import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; +import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation'; + +export class WorkflowFilterItemDto { + @IsUUID() + filterId!: string; + + @IsObject() + @Optional() + filterConfig?: FilterConfig; +} + +export class WorkflowActionItemDto { + @IsUUID() + actionId!: string; + + @IsObject() + @Optional() + actionConfig?: ActionConfig; +} + +export class WorkflowCreateDto { + @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' }) + triggerType!: PluginTriggerType; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + filters!: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + actions!: WorkflowActionItemDto[]; +} + +export class WorkflowUpdateDto { + @IsString() + @IsNotEmpty() + @Optional() + name?: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + @Optional() + filters?: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + @Optional() + actions?: WorkflowActionItemDto[]; +} + +export class WorkflowResponseDto { + id!: string; + ownerId!: string; + triggerType!: PluginTriggerType; + name!: string | null; + description!: string; + createdAt!: string; + enabled!: boolean; + filters!: WorkflowFilterResponseDto[]; + actions!: WorkflowActionResponseDto[]; +} + +export class WorkflowFilterResponseDto { + id!: string; + workflowId!: string; + filterId!: string; + filterConfig!: FilterConfig | null; + order!: number; +} + +export class WorkflowActionResponseDto { + id!: string; + workflowId!: string; + actionId!: string; + actionConfig!: ActionConfig | null; + order!: number; +} + +export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto { + return { + id: filter.id, + workflowId: filter.workflowId, + filterId: filter.filterId, + filterConfig: filter.filterConfig, + order: filter.order, + }; +} + +export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto { + return { + id: action.id, + workflowId: action.workflowId, + actionId: action.actionId, + actionConfig: action.actionConfig, + order: action.order, + }; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index f3814863b..6055ee85b 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -177,6 +177,11 @@ export enum Permission { PinCodeUpdate = 'pinCode.update', PinCodeDelete = 'pinCode.delete', + PluginCreate = 'plugin.create', + PluginRead = 'plugin.read', + PluginUpdate = 'plugin.update', + PluginDelete = 'plugin.delete', + ServerAbout = 'server.about', ServerApkLinks = 'server.apkLinks', ServerStorage = 'server.storage', @@ -240,6 +245,11 @@ export enum Permission { UserProfileImageUpdate = 'userProfileImage.update', UserProfileImageDelete = 'userProfileImage.delete', + WorkflowCreate = 'workflow.create', + WorkflowRead = 'workflow.read', + WorkflowUpdate = 'workflow.update', + WorkflowDelete = 'workflow.delete', + AdminUserCreate = 'adminUser.create', AdminUserRead = 'adminUser.read', AdminUserUpdate = 'adminUser.update', @@ -525,6 +535,7 @@ export enum QueueName { Notification = 'notifications', BackupDatabase = 'backupDatabase', Ocr = 'ocr', + Workflow = 'workflow', } export enum JobName { @@ -601,6 +612,9 @@ export enum JobName { // OCR OcrQueueAll = 'OcrQueueAll', Ocr = 'Ocr', + + // Workflow + WorkflowRun = 'WorkflowRun', } export enum QueueCommand { @@ -793,6 +807,7 @@ export enum ApiTag { NotificationsAdmin = 'Notifications (admin)', Partners = 'Partners', People = 'People', + Plugins = 'Plugins', Search = 'Search', Server = 'Server', Sessions = 'Sessions', @@ -807,4 +822,16 @@ export enum ApiTag { UsersAdmin = 'Users (admin)', Users = 'Users', Views = 'Views', + Workflows = 'Workflows', +} + +export enum PluginContext { + Asset = 'asset', + Album = 'album', + Person = 'person', +} + +export enum PluginTriggerType { + AssetCreate = 'AssetCreate', + PersonRecognized = 'PersonRecognized', } diff --git a/server/src/plugins.ts b/server/src/plugins.ts new file mode 100644 index 000000000..0c6948369 --- /dev/null +++ b/server/src/plugins.ts @@ -0,0 +1,37 @@ +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; + +export type PluginTrigger = { + name: string; + type: PluginTriggerType; + description: string; + context: PluginContext; + schema: JSONSchema | null; +}; + +export const pluginTriggers: PluginTrigger[] = [ + { + name: 'Asset Uploaded', + type: PluginTriggerType.AssetCreate, + description: 'Triggered when a new asset is uploaded', + context: PluginContext.Asset, + schema: { + type: 'object', + properties: { + assetType: { + type: 'string', + description: 'Type of the asset', + default: 'ALL', + enum: ['Image', 'Video', 'All'], + }, + }, + }, + }, + { + name: 'Person Recognized', + type: PluginTriggerType.PersonRecognized, + description: 'Triggered when a person is detected in an asset', + context: PluginContext.Person, + schema: null, + }, +]; diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 9ce6d845d..1239260dc 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -243,3 +243,12 @@ from where "partner"."sharedById" in ($1) and "partner"."sharedWithId" = $2 + +-- AccessRepository.workflow.checkOwnerAccess +select + "workflow"."id" +from + "workflow" +where + "workflow"."id" in ($1) + and "workflow"."ownerId" = $2 diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql new file mode 100644 index 000000000..82c203daf --- /dev/null +++ b/server/src/queries/plugin.repository.sql @@ -0,0 +1,159 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- PluginRepository.getPlugin +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +where + "plugin"."id" = $1 + +-- PluginRepository.getPluginByName +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +where + "plugin"."name" = $1 + +-- PluginRepository.getAllPlugins +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."wasmPath" as "wasmPath", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_filter" + where + "plugin_filter"."pluginId" = "plugin"."id" + ) as agg + ) as "filters", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_action" + where + "plugin_action"."pluginId" = "plugin"."id" + ) as agg + ) as "actions" +from + "plugin" +order by + "plugin"."name" + +-- PluginRepository.getFilter +select + * +from + "plugin_filter" +where + "id" = $1 + +-- PluginRepository.getFiltersByPlugin +select + * +from + "plugin_filter" +where + "pluginId" = $1 + +-- PluginRepository.getAction +select + * +from + "plugin_action" +where + "id" = $1 + +-- PluginRepository.getActionsByPlugin +select + * +from + "plugin_action" +where + "pluginId" = $1 diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql new file mode 100644 index 000000000..3797c5bb0 --- /dev/null +++ b/server/src/queries/workflow.repository.sql @@ -0,0 +1,68 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- WorkflowRepository.getWorkflow +select + * +from + "workflow" +where + "id" = $1 + +-- WorkflowRepository.getWorkflowsByOwner +select + * +from + "workflow" +where + "ownerId" = $1 +order by + "name" + +-- WorkflowRepository.getWorkflowsByTrigger +select + * +from + "workflow" +where + "triggerType" = $1 + and "enabled" = $2 + +-- WorkflowRepository.getWorkflowByOwnerAndTrigger +select + * +from + "workflow" +where + "ownerId" = $1 + and "triggerType" = $2 + and "enabled" = $3 + +-- WorkflowRepository.deleteWorkflow +delete from "workflow" +where + "id" = $1 + +-- WorkflowRepository.getFilters +select + * +from + "workflow_filter" +where + "workflowId" = $1 +order by + "order" asc + +-- WorkflowRepository.deleteFiltersByWorkflow +delete from "workflow_filter" +where + "workflowId" = $1 + +-- WorkflowRepository.getActions +select + * +from + "workflow_action" +where + "workflowId" = $1 +order by + "order" asc diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index a801c046a..533e74a31 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -462,6 +462,26 @@ class TagAccess { } } +class WorkflowAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, workflowIds: Set) { + if (workflowIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('workflow') + .select('workflow.id') + .where('workflow.id', 'in', [...workflowIds]) + .where('workflow.ownerId', '=', userId) + .execute() + .then((workflows) => new Set(workflows.map((workflow) => workflow.id))); + } +} + @Injectable() export class AccessRepository { activity: ActivityAccess; @@ -476,6 +496,7 @@ export class AccessRepository { stack: StackAccess; tag: TagAccess; timeline: TimelineAccess; + workflow: WorkflowAccess; constructor(@InjectKysely() db: Kysely) { this.activity = new ActivityAccess(db); @@ -490,5 +511,6 @@ export class AccessRepository { this.stack = new StackAccess(db); this.tag = new TagAccess(db); this.timeline = new TimelineAccess(db); + this.workflow = new WorkflowAccess(db); } } diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d5c279099..05d4bd2ac 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -85,6 +85,7 @@ export interface EnvData { root: string; indexHtml: string; }; + corePlugin: string; }; redis: RedisOptions; @@ -102,6 +103,11 @@ export interface EnvData { workers: ImmichWorker[]; + plugins: { + enabled: boolean; + installFolder?: string; + }; + noColor: boolean; nodeVersion?: string; } @@ -304,6 +310,7 @@ const getEnv = (): EnvData => { root: folders.web, indexHtml: join(folders.web, 'index.html'), }, + corePlugin: join(buildFolder, 'corePlugin'), }, storage: { @@ -319,6 +326,11 @@ const getEnv = (): EnvData => { workers, + plugins: { + enabled: !!dto.IMMICH_PLUGINS_ENABLED, + installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER, + }, + noColor: !!dto.NO_COLOR, }; }; diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index c3136db45..bcd791ade 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; +import jwt from 'jsonwebtoken'; import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -57,4 +58,12 @@ export class CryptoRepository { randomBytesAsText(bytes: number) { return randomBytes(bytes).toString('base64').replaceAll(/\W/g, ''); } + + signJwt(payload: string | object | Buffer, secret: string, options?: jwt.SignOptions): string { + return jwt.sign(payload, secret, { algorithm: 'HS256', ...options }); + } + + verifyJwt(token: string, secret: string): T { + return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T; + } } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index c3e6cd20c..80d411c5a 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -4,6 +4,7 @@ import { ClassConstructor } from 'class-transformer'; import _ from 'lodash'; import { Socket } from 'socket.io'; import { SystemConfig } from 'src/config'; +import { Asset } from 'src/database'; import { EventConfig } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; @@ -41,6 +42,7 @@ type EventMap = { AlbumInvite: [{ id: string; userId: string }]; // asset events + AssetCreate: [{ asset: Asset }]; AssetTag: [{ assetId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index cf65cfcb2..c69536a32 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -28,6 +28,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.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'; @@ -46,6 +47,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; export const repositories = [ AccessRepository, @@ -78,6 +80,7 @@ export const repositories = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, SessionRepository, @@ -96,4 +99,5 @@ export const repositories = [ ViewRepository, VersionHistoryRepository, WebsocketRepository, + WorkflowRepository, ]; diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts new file mode 100644 index 000000000..621723794 --- /dev/null +++ b/server/src/repositories/plugin.repository.ts @@ -0,0 +1,176 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; +import { InjectKysely } from 'nestjs-kysely'; +import { readdir } from 'node:fs/promises'; +import { columns } from 'src/database'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class PluginRepository { + constructor(@InjectKysely() private db: Kysely) {} + + /** + * Loads a plugin from a validated manifest file in a transaction. + * This ensures all plugin, filter, and action operations are atomic. + * @param manifest The validated plugin manifest + * @param basePath The base directory path where the plugin is located + */ + async loadPlugin(manifest: PluginManifestDto, basePath: string) { + return this.db.transaction().execute(async (tx) => { + // Upsert the plugin + const plugin = await tx + .insertInto('plugin') + .values({ + name: manifest.name, + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmPath: `${basePath}/${manifest.wasm.path}`, + }) + .onConflict((oc) => + oc.column('name').doUpdateSet({ + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmPath: `${basePath}/${manifest.wasm.path}`, + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + + const filters = manifest.filters + ? await tx + .insertInto('plugin_filter') + .values( + manifest.filters.map((filter) => ({ + pluginId: plugin.id, + methodName: filter.methodName, + title: filter.title, + description: filter.description, + supportedContexts: filter.supportedContexts, + schema: filter.schema, + })), + ) + .onConflict((oc) => + oc.column('methodName').doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + supportedContexts: eb.ref('excluded.supportedContexts'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + const actions = manifest.actions + ? await tx + .insertInto('plugin_action') + .values( + manifest.actions.map((action) => ({ + pluginId: plugin.id, + methodName: action.methodName, + title: action.title, + description: action.description, + supportedContexts: action.supportedContexts, + schema: action.schema, + })), + ) + .onConflict((oc) => + oc.column('methodName').doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + supportedContexts: eb.ref('excluded.supportedContexts'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + return { plugin, filters, actions }; + }); + } + + async readDirectory(path: string) { + return readdir(path, { withFileTypes: true }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getPlugin(id: string) { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .where('plugin.id', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.STRING] }) + getPluginByName(name: string) { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .where('plugin.name', '=', name) + .executeTakeFirst(); + } + + @GenerateSql() + getAllPlugins() { + return this.db + .selectFrom('plugin') + .select((eb) => [ + ...columns.plugin, + jsonArrayFrom( + eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), + ).as('filters'), + jsonArrayFrom( + eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), + ).as('actions'), + ]) + .orderBy('plugin.name') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFilter(id: string) { + return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFiltersByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getAction(id: string) { + return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getActionsByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute(); + } +} diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 50f44d9f6..e901273b5 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -113,6 +113,10 @@ export class StorageRepository { } } + async readTextFile(filepath: string): Promise { + return fs.readFile(filepath, 'utf8'); + } + async checkFileExists(filepath: string, mode = constants.F_OK): Promise { try { await fs.access(filepath, mode); diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts new file mode 100644 index 000000000..4ae657cfb --- /dev/null +++ b/server/src/repositories/workflow.repository.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { PluginTriggerType } from 'src/enum'; +import { DB } from 'src/schema'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Injectable() +export class WorkflowRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflow(id: string) { + return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflowsByOwner(ownerId: string) { + return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute(); + } + + @GenerateSql({ params: [PluginTriggerType.AssetCreate] }) + getWorkflowsByTrigger(type: PluginTriggerType) { + return this.db + .selectFrom('workflow') + .selectAll() + .where('triggerType', '=', type) + .where('enabled', '=', true) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] }) + getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) { + return this.db + .selectFrom('workflow') + .selectAll() + .where('ownerId', '=', ownerId) + .where('triggerType', '=', type) + .where('enabled', '=', true) + .execute(); + } + + async createWorkflow( + workflow: Insertable, + filters: Insertable[], + actions: Insertable[], + ) { + return await this.db.transaction().execute(async (tx) => { + const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow(); + + if (filters.length > 0) { + const newFilters = filters.map((filter) => ({ + ...filter, + workflowId: createdWorkflow.id, + })); + + await tx.insertInto('workflow_filter').values(newFilters).execute(); + } + + if (actions.length > 0) { + const newActions = actions.map((action) => ({ + ...action, + workflowId: createdWorkflow.id, + })); + await tx.insertInto('workflow_action').values(newActions).execute(); + } + + return createdWorkflow; + }); + } + + async updateWorkflow( + id: string, + workflow: Updateable, + filters: Insertable[] | undefined, + actions: Insertable[] | undefined, + ) { + return await this.db.transaction().execute(async (trx) => { + if (Object.keys(workflow).length > 0) { + await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute(); + } + + if (filters !== undefined) { + await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute(); + if (filters.length > 0) { + const filtersWithWorkflowId = filters.map((filter) => ({ + ...filter, + workflowId: id, + })); + await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute(); + } + } + + if (actions !== undefined) { + await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute(); + if (actions.length > 0) { + const actionsWithWorkflowId = actions.map((action) => ({ + ...action, + workflowId: id, + })); + await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute(); + } + } + + return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow(); + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteWorkflow(id: string) { + await this.db.deleteFrom('workflow').where('id', '=', id).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFilters(workflowId: string) { + return this.db + .selectFrom('workflow_filter') + .selectAll() + .where('workflowId', '=', workflowId) + .orderBy('order', 'asc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteFiltersByWorkflow(workflowId: string) { + await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getActions(workflowId: string) { + return this.db + .selectFrom('workflow_action') + .selectAll() + .where('workflowId', '=', workflowId) + .orderBy('order', 'asc') + .execute(); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 7f4bdbeed..9e206826e 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -53,6 +53,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 { PluginActionTable, PluginFilterTable, 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'; @@ -69,6 +70,7 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { Database, Extensions, Generated, Int8 } from 'src/sql-tools'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @@ -125,6 +127,12 @@ export class ImmichDatabase { UserMetadataAuditTable, UserTable, VersionHistoryTable, + PluginTable, + PluginFilterTable, + PluginActionTable, + WorkflowTable, + WorkflowFilterTable, + WorkflowActionTable, ]; functions = [ @@ -231,4 +239,12 @@ export interface DB { user_metadata_audit: UserMetadataAuditTable; version_history: VersionHistoryTable; + + plugin: PluginTable; + plugin_filter: PluginFilterTable; + plugin_action: PluginActionTable; + + workflow: WorkflowTable; + workflow_filter: WorkflowFilterTable; + workflow_action: WorkflowActionTable; } diff --git a/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts new file mode 100644 index 000000000..6dacc1056 --- /dev/null +++ b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts @@ -0,0 +1,113 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "plugin" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "author" character varying NOT NULL, + "version" character varying NOT NULL, + "wasmPath" character varying NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "plugin_name_uq" UNIQUE ("name"), + CONSTRAINT "plugin_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db); + await sql`CREATE TABLE "plugin_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_filter_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_filter_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_filter_supportedContexts_idx" ON "plugin_filter" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_filter_pluginId_idx" ON "plugin_filter" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_filter_methodName_idx" ON "plugin_filter" ("methodName");`.execute(db); + await sql`CREATE TABLE "plugin_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_action_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_action_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_action_supportedContexts_idx" ON "plugin_action" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_action_pluginId_idx" ON "plugin_action" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_action_methodName_idx" ON "plugin_action" ("methodName");`.execute(db); + await sql`CREATE TABLE "workflow" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "triggerType" character varying NOT NULL, + "name" character varying, + "description" character varying NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "enabled" boolean NOT NULL DEFAULT true, + CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db); + await sql`CREATE TABLE "workflow_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "filterId" uuid NOT NULL, + "filterConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_filter_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db); + await sql`CREATE INDEX "workflow_filter_workflowId_order_idx" ON "workflow_filter" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_filter_workflowId_idx" ON "workflow_filter" ("workflowId");`.execute(db); + await sql`CREATE TABLE "workflow_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "actionId" uuid NOT NULL, + "actionConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_action_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db); + await sql`CREATE INDEX "workflow_action_workflowId_order_idx" ON "workflow_action" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_action_workflowId_idx" ON "workflow_action" ("workflowId");`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_filter_supportedContexts_idx', '{"type":"index","name":"plugin_filter_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_filter_supportedContexts_idx\\" ON \\"plugin_filter\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_action_supportedContexts_idx', '{"type":"index","name":"plugin_action_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_action_supportedContexts_idx\\" ON \\"plugin_action\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "workflow";`.execute(db); + await sql`DROP TABLE "workflow_filter";`.execute(db); + await sql`DROP TABLE "workflow_action";`.execute(db); + + await sql`DROP TABLE "plugin";`.execute(db); + await sql`DROP TABLE "plugin_filter";`.execute(db); + await sql`DROP TABLE "plugin_action";`.execute(db); + + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db); +} diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts new file mode 100644 index 000000000..3de7ca63c --- /dev/null +++ b/server/src/schema/tables/plugin.table.ts @@ -0,0 +1,95 @@ +import { PluginContext } from 'src/enum'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from 'src/sql-tools'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; + +@Table('plugin') +export class PluginTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @Column({ index: true, unique: true }) + name!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column() + author!: string; + + @Column() + version!: string; + + @Column() + wasmPath!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; +} + +@Index({ columns: ['supportedContexts'], using: 'gin' }) +@Table('plugin_filter') +export class PluginFilterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @Column({ index: true }) + pluginId!: string; + + @Column({ index: true, unique: true }) + methodName!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + supportedContexts!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} + +@Index({ columns: ['supportedContexts'], using: 'gin' }) +@Table('plugin_action') +export class PluginActionTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @Column({ index: true }) + pluginId!: string; + + @Column({ index: true, unique: true }) + methodName!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + supportedContexts!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts new file mode 100644 index 000000000..8f7c9adb0 --- /dev/null +++ b/server/src/schema/tables/workflow.table.ts @@ -0,0 +1,78 @@ +import { PluginTriggerType } from 'src/enum'; +import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, +} from 'src/sql-tools'; +import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; + +@Table('workflow') +export class WorkflowTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + ownerId!: string; + + @Column() + triggerType!: PluginTriggerType; + + @Column({ nullable: true }) + name!: string | null; + + @Column() + description!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['filterId'] }) +@Table('workflow_filter') +export class WorkflowFilterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + filterId!: string; + + @Column({ type: 'jsonb', nullable: true }) + filterConfig!: FilterConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['actionId'] }) +@Table('workflow_action') +export class WorkflowActionTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + actionId!: string; + + @Column({ type: 'jsonb', nullable: true }) + actionConfig!: ActionConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 8cf1ef331..4db60c349 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -426,6 +426,9 @@ export class AssetMediaService extends BaseService { } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); + + await this.eventRepository.emit('AssetCreate', { asset }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); return asset; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 51041c1b1..2c6d07b63 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -35,6 +35,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.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'; @@ -53,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { UserTable } from 'src/schema/tables/user.table'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -88,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, ServerInfoRepository, @@ -105,6 +108,8 @@ export const BASE_SERVICE_DEPENDENCIES = [ UserRepository, VersionHistoryRepository, ViewRepository, + WebsocketRepository, + WorkflowRepository, ]; @Injectable() @@ -142,6 +147,7 @@ export class BaseService { protected ocrRepository: OcrRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, + protected pluginRepository: PluginRepository, protected processRepository: ProcessRepository, protected searchRepository: SearchRepository, protected serverInfoRepository: ServerInfoRepository, @@ -160,6 +166,7 @@ export class BaseService { protected versionRepository: VersionHistoryRepository, protected viewRepository: ViewRepository, protected websocketRepository: WebsocketRepository, + protected workflowRepository: WorkflowRepository, ) { this.logger.setContext(this.constructor.name); this.storageCore = StorageCore.create( diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 8862a5b37..9d09bdaa5 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -23,6 +23,7 @@ import { NotificationService } from 'src/services/notification.service'; import { OcrService } from 'src/services/ocr.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; +import { PluginService } from 'src/services/plugin.service'; import { QueueService } from 'src/services/queue.service'; import { SearchService } from 'src/services/search.service'; import { ServerService } from 'src/services/server.service'; @@ -43,6 +44,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, @@ -70,6 +72,7 @@ export const services = [ OcrService, PartnerService, PersonService, + PluginService, QueueService, SearchService, ServerService, @@ -90,4 +93,5 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowService, ]; diff --git a/server/src/services/plugin-host.functions.ts b/server/src/services/plugin-host.functions.ts new file mode 100644 index 000000000..50b1052b5 --- /dev/null +++ b/server/src/services/plugin-host.functions.ts @@ -0,0 +1,120 @@ +import { CurrentPlugin } from '@extism/extism'; +import { UnauthorizedException } from '@nestjs/common'; +import { Updateable } from 'kysely'; +import { Permission } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { requireAccess } from 'src/utils/access'; + +/** + * Plugin host functions that are exposed to WASM plugins via Extism. + * These functions allow plugins to interact with the Immich system. + */ +export class PluginHostFunctions { + constructor( + private assetRepository: AssetRepository, + private albumRepository: AlbumRepository, + private accessRepository: AccessRepository, + private cryptoRepository: CryptoRepository, + private logger: LoggingRepository, + private pluginJwtSecret: string, + ) {} + + /** + * Creates Extism host function bindings for the plugin. + * These are the functions that WASM plugins can call. + */ + getHostFunctions() { + return { + 'extism:host/user': { + updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs), + addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs), + }, + }; + } + + /** + * Host function wrapper for updateAsset. + * Reads the input from the plugin, parses it, and calls the actual update function. + */ + private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) { + const input = JSON.parse(cp.read(offs)!.text()); + await this.updateAsset(input); + } + + /** + * Host function wrapper for addAssetToAlbum. + * Reads the input from the plugin, parses it, and calls the actual add function. + */ + private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) { + const input = JSON.parse(cp.read(offs)!.text()); + await this.addAssetToAlbum(input); + } + + /** + * Validates the JWT token and returns the auth context. + */ + private validateToken(authToken: string): { userId: string } { + try { + const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret); + if (!auth.userId) { + throw new UnauthorizedException('Invalid token: missing userId'); + } + return auth; + } catch (error) { + this.logger.error('Token validation failed:', error); + throw new UnauthorizedException('Invalid token'); + } + } + + /** + * Updates an asset with the given properties. + */ + async updateAsset(input: { authToken: string } & Updateable & { id: string }) { + const { authToken, id, ...assetData } = input; + + // Validate token + const auth = this.validateToken(authToken); + + // Check access to the asset + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AssetUpdate, + ids: [id], + }); + + this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`); + await this.assetRepository.update({ id, ...assetData }); + } + + /** + * Adds an asset to an album. + */ + async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) { + const { authToken, assetId, albumId } = input; + + // Validate token + const auth = this.validateToken(authToken); + + // Check access to both the asset and the album + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AssetRead, + ids: [assetId], + }); + + await requireAccess(this.accessRepository, { + auth: { user: { id: auth.userId } } as any, + permission: Permission.AlbumUpdate, + ids: [albumId], + }); + + this.logger.log(`Adding asset ${assetId} to album ${albumId}`); + await this.albumRepository.addAssetIds(albumId, [assetId]); + return 0; + } +} diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts new file mode 100644 index 000000000..28d1ac56c --- /dev/null +++ b/server/src/services/plugin.service.ts @@ -0,0 +1,317 @@ +import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validateOrReject } from 'class-validator'; +import { join } from 'node:path'; +import { Asset, WorkflowAction, WorkflowFilter } from 'src/database'; +import { OnEvent, OnJob } from 'src/decorators'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto'; +import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { BaseService } from 'src/services/base.service'; +import { PluginHostFunctions } from 'src/services/plugin-host.functions'; +import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types'; + +interface WorkflowContext { + authToken: string; + asset: Asset; +} + +interface PluginInput { + authToken: string; + config: T; + data: { + asset: Asset; + }; +} + +@Injectable() +export class PluginService extends BaseService { + private pluginJwtSecret!: string; + private loadedPlugins: Map = new Map(); + private hostFunctions!: PluginHostFunctions; + + @OnEvent({ name: 'AppBootstrap' }) + async onBootstrap() { + this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32); + + await this.loadPluginsFromManifests(); + + this.hostFunctions = new PluginHostFunctions( + this.assetRepository, + this.albumRepository, + this.accessRepository, + this.cryptoRepository, + this.logger, + this.pluginJwtSecret, + ); + + await this.loadPlugins(); + } + + // + // CRUD operations for plugins + // + async getAll(): Promise { + const plugins = await this.pluginRepository.getAllPlugins(); + return plugins.map((plugin) => mapPlugin(plugin)); + } + + async get(id: string): Promise { + const plugin = await this.pluginRepository.getPlugin(id); + if (!plugin) { + throw new BadRequestException('Plugin not found'); + } + return mapPlugin(plugin); + } + + /////////////////////////////////////////// + // Plugin Loader + ////////////////////////////////////////// + async loadPluginsFromManifests(): Promise { + // Load core plugin + const { resourcePaths, plugins } = this.configRepository.getEnv(); + const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`; + + const coreManifest = await this.readAndValidateManifest(coreManifestPath); + await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin); + + this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); + + // Load external plugins + if (plugins.enabled && plugins.installFolder) { + await this.loadExternalPlugins(plugins.installFolder); + } + } + + private async loadExternalPlugins(installFolder: string): Promise { + try { + const entries = await this.pluginRepository.readDirectory(installFolder); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const pluginFolder = join(installFolder, entry.name); + const manifestPath = join(pluginFolder, 'manifest.json'); + try { + const manifest = await this.readAndValidateManifest(manifestPath); + await this.loadPluginToDatabase(manifest, pluginFolder); + + this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`); + } catch (error) { + this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error); + } + } + } catch (error) { + this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error); + } + } + + private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { + const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); + if (currentPlugin != null && currentPlugin.version === manifest.version) { + this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); + return; + } + + const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath); + + this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`); + + for (const filter of filters) { + this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`); + } + + for (const action of actions) { + this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`); + } + } + + private async readAndValidateManifest(manifestPath: string): Promise { + const content = await this.storageRepository.readTextFile(manifestPath); + const manifestData = JSON.parse(content); + const manifest = plainToInstance(PluginManifestDto, manifestData); + + await validateOrReject(manifest, { + whitelist: true, + forbidNonWhitelisted: true, + }); + + return manifest; + } + + /////////////////////////////////////////// + // Plugin Execution + /////////////////////////////////////////// + private async loadPlugins() { + const plugins = await this.pluginRepository.getAllPlugins(); + for (const plugin of plugins) { + try { + this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`); + + const extismPlugin = await newPlugin(plugin.wasmPath, { + useWasi: true, + functions: this.hostFunctions.getHostFunctions(), + }); + + this.loadedPlugins.set(plugin.id, extismPlugin); + this.logger.log(`Successfully loaded plugin: ${plugin.name}`); + } catch (error) { + this.logger.error(`Failed to load plugin ${plugin.name}:`, error); + } + } + } + + @OnEvent({ name: 'AssetCreate' }) + async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) { + await this.handleTrigger(PluginTriggerType.AssetCreate, { + ownerId: asset.ownerId, + event: { userId: asset.ownerId, asset }, + }); + } + + private async handleTrigger( + triggerType: T, + params: { ownerId: string; event: WorkflowData[T] }, + ): Promise { + const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType); + if (workflows.length === 0) { + return; + } + + const jobs: JobItem[] = workflows.map((workflow) => ({ + name: JobName.WorkflowRun, + data: { + id: workflow.id, + type: triggerType, + event: params.event, + } as IWorkflowJob, + })); + + await this.jobRepository.queueAll(jobs); + this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`); + } + + @OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow }) + async handleWorkflowRun({ id: workflowId, type, event }: JobOf): Promise { + try { + const workflow = await this.workflowRepository.getWorkflow(workflowId); + if (!workflow) { + this.logger.error(`Workflow ${workflowId} not found`); + return JobStatus.Failed; + } + + const workflowFilters = await this.workflowRepository.getFilters(workflowId); + const workflowActions = await this.workflowRepository.getActions(workflowId); + + switch (type) { + case PluginTriggerType.AssetCreate: { + const data = event as WorkflowData[PluginTriggerType.AssetCreate]; + const asset = data.asset; + + const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret); + + const context = { + authToken, + asset, + }; + + const filtersPassed = await this.executeFilters(workflowFilters, context); + if (!filtersPassed) { + return JobStatus.Skipped; + } + + await this.executeActions(workflowActions, context); + this.logger.debug(`Workflow ${workflowId} executed successfully`); + return JobStatus.Success; + } + + case PluginTriggerType.PersonRecognized: { + this.logger.error('unimplemented'); + return JobStatus.Skipped; + } + + default: { + this.logger.error(`Unknown workflow trigger type: ${type}`); + return JobStatus.Failed; + } + } + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId}:`, error); + return JobStatus.Failed; + } + } + + private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise { + for (const workflowFilter of workflowFilters) { + const filter = await this.pluginRepository.getFilter(workflowFilter.filterId); + if (!filter) { + this.logger.error(`Filter ${workflowFilter.filterId} not found`); + return false; + } + + const pluginInstance = this.loadedPlugins.get(filter.pluginId); + if (!pluginInstance) { + this.logger.error(`Plugin ${filter.pluginId} not loaded`); + return false; + } + + const filterInput: PluginInput = { + authToken: context.authToken, + config: workflowFilter.filterConfig, + data: { + asset: context.asset, + }, + }; + + this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`); + + const filterResult = await pluginInstance.call( + filter.methodName, + new TextEncoder().encode(JSON.stringify(filterInput)), + ); + + if (!filterResult) { + this.logger.error(`Filter ${filter.methodName} returned null`); + return false; + } + + const result = JSON.parse(filterResult.text()); + if (result.passed === false) { + this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`); + return false; + } + } + + return true; + } + + private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise { + for (const workflowAction of workflowActions) { + const action = await this.pluginRepository.getAction(workflowAction.actionId); + if (!action) { + throw new Error(`Action ${workflowAction.actionId} not found`); + } + + const pluginInstance = this.loadedPlugins.get(action.pluginId); + if (!pluginInstance) { + throw new Error(`Plugin ${action.pluginId} not loaded`); + } + + const actionInput: PluginInput = { + authToken: context.authToken, + config: workflowAction.actionConfig, + data: { + asset: context.asset, + }, + }; + + this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`); + + await pluginInstance.call(action.methodName, JSON.stringify(actionInput)); + } + } +} diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 1cc53df64..5dce9476e 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -22,7 +22,7 @@ describe(QueueService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); @@ -97,6 +97,7 @@ describe(QueueService.name, () => { [QueueName.Notification]: expectedJobStatus, [QueueName.BackupDatabase]: expectedJobStatus, [QueueName.Ocr]: expectedJobStatus, + [QueueName.Workflow]: expectedJobStatus, }); }); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index b9a38e4b0..fbdd655bb 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -40,6 +40,7 @@ const updatedConfig = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, backup: { database: { diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts new file mode 100644 index 000000000..ae72187d7 --- /dev/null +++ b/server/src/services/workflow.service.ts @@ -0,0 +1,159 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Workflow } from 'src/database'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapWorkflowAction, + mapWorkflowFilter, + WorkflowCreateDto, + WorkflowResponseDto, + WorkflowUpdateDto, +} from 'src/dtos/workflow.dto'; +import { Permission, PluginContext, PluginTriggerType } from 'src/enum'; +import { pluginTriggers } from 'src/plugins'; + +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class WorkflowService extends BaseService { + async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { + const trigger = this.getTriggerOrFail(dto.triggerType); + + const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context); + const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context); + + const workflow = await this.workflowRepository.createWorkflow( + { + ownerId: auth.user.id, + triggerType: dto.triggerType, + name: dto.name, + description: dto.description || '', + enabled: dto.enabled ?? true, + }, + filterInserts, + actionInserts, + ); + + return this.mapWorkflow(workflow); + } + + async getAll(auth: AuthDto): Promise { + const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id); + + return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow))); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); + const workflow = await this.findOrFail(id); + return this.mapWorkflow(workflow); + } + + async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] }); + + if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { + throw new BadRequestException('No fields to update'); + } + + const workflow = await this.findOrFail(id); + const trigger = this.getTriggerOrFail(workflow.triggerType); + + const { filters, actions, ...workflowUpdate } = dto; + const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context)); + const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context)); + + const updatedWorkflow = await this.workflowRepository.updateWorkflow( + id, + workflowUpdate, + filterInserts, + actionInserts, + ); + + return this.mapWorkflow(updatedWorkflow); + } + + async delete(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] }); + await this.workflowRepository.deleteWorkflow(id); + } + + private async validateAndMapFilters( + filters: Array<{ filterId: string; filterConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of filters) { + const filter = await this.pluginRepository.getFilter(dto.filterId); + if (!filter) { + throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`); + } + + if (!filter.supportedContexts.includes(requiredContext)) { + throw new BadRequestException( + `Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`, + ); + } + } + + return filters.map((dto, index) => ({ + filterId: dto.filterId, + filterConfig: dto.filterConfig || null, + order: index, + })); + } + + private async validateAndMapActions( + actions: Array<{ actionId: string; actionConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of actions) { + const action = await this.pluginRepository.getAction(dto.actionId); + if (!action) { + throw new BadRequestException(`Invalid action ID: ${dto.actionId}`); + } + if (!action.supportedContexts.includes(requiredContext)) { + throw new BadRequestException( + `Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`, + ); + } + } + + return actions.map((dto, index) => ({ + actionId: dto.actionId, + actionConfig: dto.actionConfig || null, + order: index, + })); + } + + private getTriggerOrFail(triggerType: PluginTriggerType) { + const trigger = pluginTriggers.find((t) => t.type === triggerType); + if (!trigger) { + throw new BadRequestException(`Invalid trigger type: ${triggerType}`); + } + return trigger; + } + + private async findOrFail(id: string) { + const workflow = await this.workflowRepository.getWorkflow(id); + if (!workflow) { + throw new BadRequestException('Workflow not found'); + } + return workflow; + } + + private async mapWorkflow(workflow: Workflow): Promise { + const filters = await this.workflowRepository.getFilters(workflow.id); + const actions = await this.workflowRepository.getActions(workflow.id); + + return { + id: workflow.id, + ownerId: workflow.ownerId, + triggerType: workflow.triggerType, + name: workflow.name, + description: workflow.description, + createdAt: workflow.createdAt.toISOString(), + enabled: workflow.enabled, + filters: filters.map((f) => mapWorkflowFilter(f)), + actions: actions.map((a) => mapWorkflowAction(a)), + }; + } +} diff --git a/server/src/types.ts b/server/src/types.ts index afc72480e..ad947e377 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,5 +1,6 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; +import { Asset } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -11,6 +12,7 @@ import { ImageFormat, JobName, MemoryType, + PluginTriggerType, QueueName, StorageFolder, SyncEntityType, @@ -263,6 +265,23 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } +export interface WorkflowData { + [PluginTriggerType.AssetCreate]: { + userId: string; + asset: Asset; + }; + [PluginTriggerType.PersonRecognized]: { + personId: string; + assetId: string; + }; +} + +export interface IWorkflowJob { + id: string; + type: T; + event: WorkflowData[T]; +} + export interface JobCounts { active: number; completed: number; @@ -374,7 +393,10 @@ export type JobItem = // OCR | { name: JobName.OcrQueueAll; data: IBaseJob } - | { name: JobName.Ocr; data: IEntityJob }; + | { name: JobName.Ocr; data: IEntityJob } + + // Workflow + | { name: JobName.WorkflowRun; data: IWorkflowJob }; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; diff --git a/server/src/types/plugin-schema.types.ts b/server/src/types/plugin-schema.types.ts new file mode 100644 index 000000000..793bb3c1f --- /dev/null +++ b/server/src/types/plugin-schema.types.ts @@ -0,0 +1,35 @@ +/** + * JSON Schema types for plugin configuration schemas + * Based on JSON Schema Draft 7 + */ + +export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; + +export interface JSONSchemaProperty { + type?: JSONSchemaType | JSONSchemaType[]; + description?: string; + default?: any; + enum?: any[]; + items?: JSONSchemaProperty; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JSONSchemaProperty; +} + +export interface JSONSchema { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + description?: string; +} + +export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; + +export interface FilterConfig { + [key: string]: ConfigValue; +} + +export interface ActionConfig { + [key: string]: ConfigValue; +} diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 7a0f701f7..f8d5f0ca0 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -298,6 +298,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.stack.checkOwnerAccess(auth.user.id, ids); } + case Permission.WorkflowRead: + case Permission.WorkflowUpdate: + case Permission.WorkflowDelete: { + return access.workflow.checkOwnerAccess(auth.user.id, ids); + } + default: { return new Set(); } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 6167b6a6f..efcdc5979 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -36,6 +36,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OcrRepository } from 'src/repositories/ocr.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 { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; @@ -49,6 +50,7 @@ import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -380,6 +382,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -389,7 +392,8 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: - case VersionHistoryRepository: { + case VersionHistoryRepository: + case WorkflowRepository: { return new key(db); } @@ -441,13 +445,15 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: case VersionHistoryRepository: - case TagRepository: { + case TagRepository: + case WorkflowRepository: { return automock(key); } diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts new file mode 100644 index 000000000..b70e8e8d5 --- /dev/null +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -0,0 +1,308 @@ +import { Kysely } from 'kysely'; +import { PluginContext } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { DB } from 'src/schema'; +import { PluginService } from 'src/services/plugin.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; +let pluginRepo: PluginRepository; + +const setup = (db?: Kysely) => { + return newMediumService(PluginService, { + database: db || defaultDatabase, + real: [PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); + pluginRepo = new PluginRepository(defaultDatabase); +}); + +afterEach(async () => { + await defaultDatabase.deleteFrom('plugin').execute(); +}); + +describe(PluginService.name, () => { + describe('getAll', () => { + it('should return empty array when no plugins exist', async () => { + const { sut } = setup(); + + const plugins = await sut.getAll(); + + expect(plugins).toEqual([]); + }); + + it('should return plugin without filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'test-plugin', + title: 'Test Plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/test.wasm' }, + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'test-plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + filters: [], + actions: [], + }); + }); + + it('should return plugin with filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'full-plugin', + title: 'Full Plugin', + description: 'A plugin with filters and actions', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/full.wasm' }, + filters: [ + { + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + actions: [ + { + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'full-plugin', + filters: [ + { + id: result.filters[0].id, + pluginId: result.plugin.id, + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + actions: [ + { + id: result.actions[0].id, + pluginId: result.plugin.id, + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + }); + }); + + it('should return multiple plugins with their respective filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + name: 'plugin-1', + title: 'Plugin 1', + description: 'First plugin', + author: 'Author 1', + version: '1.0.0', + wasm: { path: '/path/to/plugin1.wasm' }, + filters: [ + { + methodName: 'filter-1', + title: 'Filter 1', + description: 'Filter for plugin 1', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + await pluginRepo.loadPlugin( + { + name: 'plugin-2', + title: 'Plugin 2', + description: 'Second plugin', + author: 'Author 2', + version: '2.0.0', + wasm: { path: '/path/to/plugin2.wasm' }, + actions: [ + { + methodName: 'action-2', + title: 'Action 2', + description: 'Action for plugin 2', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(2); + expect(plugins[0].name).toBe('plugin-1'); + expect(plugins[0].filters).toHaveLength(1); + expect(plugins[0].actions).toHaveLength(0); + + expect(plugins[1].name).toBe('plugin-2'); + expect(plugins[1].filters).toHaveLength(0); + expect(plugins[1].actions).toHaveLength(1); + }); + + it('should handle plugin with multiple filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + name: 'multi-plugin', + title: 'Multi Plugin', + description: 'Plugin with multiple items', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/multi.wasm' }, + filters: [ + { + methodName: 'filter-a', + title: 'Filter A', + description: 'First filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + { + methodName: 'filter-b', + title: 'Filter B', + description: 'Second filter', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'action-x', + title: 'Action X', + description: 'First action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + { + methodName: 'action-y', + title: 'Action Y', + description: 'Second action', + supportedContexts: [PluginContext.Person], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0].filters).toHaveLength(2); + expect(plugins[0].actions).toHaveLength(2); + }); + }); + + describe('get', () => { + it('should throw error when plugin does not exist', async () => { + const { sut } = setup(); + + await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found'); + }); + + it('should return single plugin with filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'single-plugin', + title: 'Single Plugin', + description: 'A single plugin', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/single.wasm' }, + filters: [ + { + methodName: 'single-filter', + title: 'Single Filter', + description: 'A single filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'single-action', + title: 'Single Action', + description: 'A single action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/test/base/path', + ); + + const pluginResult = await sut.get(result.plugin.id); + + expect(pluginResult).toMatchObject({ + id: result.plugin.id, + name: 'single-plugin', + filters: [ + { + id: result.filters[0].id, + methodName: 'single-filter', + title: 'Single Filter', + }, + ], + actions: [ + { + id: result.actions[0].id, + methodName: 'single-action', + title: 'Single Action', + }, + ], + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts new file mode 100644 index 000000000..af12019ef --- /dev/null +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -0,0 +1,697 @@ +import { Kysely } from 'kysely'; +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; +import { DB } from 'src/schema'; +import { WorkflowService } from 'src/services/workflow.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(WorkflowService, { + database: db || defaultDatabase, + real: [WorkflowRepository, PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(WorkflowService.name, () => { + let testPluginId: string; + let testFilterId: string; + let testActionId: string; + + beforeAll(async () => { + // Create a test plugin with filters and actions once for all tests + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'test-core-plugin', + title: 'Test Core Plugin', + description: 'A test core plugin for workflow tests', + author: 'Test Author', + version: '1.0.0', + wasm: { + path: '/test/path.wasm', + }, + filters: [ + { + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + actions: [ + { + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + testPluginId = result.plugin.id; + testFilterId = result.filters[0].id; + testActionId = result.actions[0].id; + }); + + afterAll(async () => { + await defaultDatabase.deleteFrom('plugin').where('id', '=', testPluginId).execute(); + }); + + describe('create', () => { + it('should create a workflow without filters or actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [], + actions: [], + }); + + expect(workflow).toMatchObject({ + id: expect.any(String), + ownerId: user.id, + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [], + actions: [], + }); + }); + + it('should create a workflow with filters and actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow-with-relations', + description: 'A test workflow with filters and actions', + enabled: true, + filters: [ + { + filterId: testFilterId, + filterConfig: { key: 'value' }, + }, + ], + actions: [ + { + actionId: testActionId, + actionConfig: { action: 'test' }, + }, + ], + }); + + expect(workflow).toMatchObject({ + id: expect.any(String), + ownerId: user.id, + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow-with-relations', + enabled: true, + }); + + expect(workflow.filters).toHaveLength(1); + expect(workflow.filters[0]).toMatchObject({ + id: expect.any(String), + workflowId: workflow.id, + filterId: testFilterId, + filterConfig: { key: 'value' }, + order: 0, + }); + + expect(workflow.actions).toHaveLength(1); + expect(workflow.actions[0]).toMatchObject({ + id: expect.any(String), + workflowId: workflow.id, + actionId: testActionId, + actionConfig: { action: 'test' }, + order: 0, + }); + }); + + it('should throw error when creating workflow with invalid filter', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid filter', + enabled: true, + filters: [ + { + filterId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + filterConfig: { key: 'value' }, + }, + ], + actions: [], + }), + ).rejects.toThrow('Invalid filter ID'); + }); + + it('should throw error when creating workflow with invalid action', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid action', + enabled: true, + filters: [], + actions: [ + { + actionId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + actionConfig: { action: 'test' }, + }, + ], + }), + ).rejects.toThrow('Invalid action ID'); + }); + + it('should throw error when filter does not support trigger context', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + // Create a plugin with a filter that only supports Album context + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'album-only-plugin', + title: 'Album Only Plugin', + description: 'Plugin with album-only filter', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/test/album-plugin.wasm' }, + filters: [ + { + methodName: 'album-filter', + title: 'Album Filter', + description: 'A filter that only works with albums', + supportedContexts: [PluginContext.Album], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-context-workflow', + description: 'A workflow with context mismatch', + enabled: true, + filters: [{ filterId: result.filters[0].id }], + actions: [], + }), + ).rejects.toThrow('does not support asset context'); + }); + + it('should throw error when action does not support trigger context', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + // Create a plugin with an action that only supports Person context + const pluginRepo = new PluginRepository(defaultDatabase); + const result = await pluginRepo.loadPlugin( + { + name: 'person-only-plugin', + title: 'Person Only Plugin', + description: 'Plugin with person-only action', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/test/person-plugin.wasm' }, + actions: [ + { + methodName: 'person-action', + title: 'Person Action', + description: 'An action that only works with persons', + supportedContexts: [PluginContext.Person], + schema: undefined, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-context-workflow', + description: 'A workflow with context mismatch', + enabled: true, + filters: [], + actions: [{ actionId: result.actions[0].id }], + }), + ).rejects.toThrow('does not support asset context'); + }); + + it('should create workflow with multiple filters and actions in correct order', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'multi-step-workflow', + description: 'A workflow with multiple filters and actions', + enabled: true, + filters: [ + { filterId: testFilterId, filterConfig: { step: 1 } }, + { filterId: testFilterId, filterConfig: { step: 2 } }, + ], + actions: [ + { actionId: testActionId, actionConfig: { step: 1 } }, + { actionId: testActionId, actionConfig: { step: 2 } }, + { actionId: testActionId, actionConfig: { step: 3 } }, + ], + }); + + expect(workflow.filters).toHaveLength(2); + expect(workflow.filters[0].order).toBe(0); + expect(workflow.filters[0].filterConfig).toEqual({ step: 1 }); + expect(workflow.filters[1].order).toBe(1); + expect(workflow.filters[1].filterConfig).toEqual({ step: 2 }); + + expect(workflow.actions).toHaveLength(3); + expect(workflow.actions[0].order).toBe(0); + expect(workflow.actions[1].order).toBe(1); + expect(workflow.actions[2].order).toBe(2); + }); + }); + + describe('getAll', () => { + it('should return all workflows for a user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow1 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-1', + description: 'First workflow', + enabled: true, + filters: [], + actions: [], + }); + + const workflow2 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-2', + description: 'Second workflow', + enabled: false, + filters: [], + actions: [], + }); + + const workflows = await sut.getAll(auth); + + expect(workflows).toHaveLength(2); + expect(workflows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: workflow1.id, name: 'workflow-1' }), + expect.objectContaining({ id: workflow2.id, name: 'workflow-2' }), + ]), + ); + }); + + it('should return empty array when user has no workflows', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflows = await sut.getAll(auth); + + expect(workflows).toEqual([]); + }); + + it('should not return workflows from other users', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'user1-workflow', + description: 'User 1 workflow', + enabled: true, + filters: [], + actions: [], + }); + + const user2Workflows = await sut.getAll(auth2); + + expect(user2Workflows).toEqual([]); + }); + }); + + describe('get', () => { + it('should return a specific workflow by id', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }], + actions: [{ actionId: testActionId, actionConfig: { action: 'test' } }], + }); + + const workflow = await sut.get(auth, created.id); + + expect(workflow).toMatchObject({ + id: created.id, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + }); + expect(workflow.filters).toHaveLength(1); + expect(workflow.actions).toHaveLength(1); + }); + + it('should throw error when workflow does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow(); + }); + + it('should throw error when user does not have access to workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private workflow', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.get(auth2, workflow.id)).rejects.toThrow(); + }); + }); + + describe('update', () => { + it('should update workflow basic fields', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'original-workflow', + description: 'Original description', + enabled: true, + filters: [], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + name: 'updated-workflow', + description: 'Updated description', + enabled: false, + }); + + expect(updated).toMatchObject({ + id: created.id, + name: 'updated-workflow', + description: 'Updated description', + enabled: false, + }); + }); + + it('should update workflow filters', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { old: 'config' } }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [ + { filterId: testFilterId, filterConfig: { new: 'config' } }, + { filterId: testFilterId, filterConfig: { second: 'filter' } }, + ], + }); + + expect(updated.filters).toHaveLength(2); + expect(updated.filters[0].filterConfig).toEqual({ new: 'config' }); + expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' }); + }); + + it('should update workflow actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [{ actionId: testActionId, actionConfig: { old: 'config' } }], + }); + + const updated = await sut.update(auth, created.id, { + actions: [ + { actionId: testActionId, actionConfig: { new: 'config' } }, + { actionId: testActionId, actionConfig: { second: 'action' } }, + ], + }); + + expect(updated.actions).toHaveLength(2); + expect(updated.actions[0].actionConfig).toEqual({ new: 'config' }); + expect(updated.actions[1].actionConfig).toEqual({ second: 'action' }); + }); + + it('should clear filters when updated with empty array', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [], + }); + + expect(updated.filters).toHaveLength(0); + }); + + it('should throw error when no fields to update', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update'); + }); + + it('should throw error when updating non-existent workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect( + sut.update(auth, 'non-existent-id', { + name: 'updated-name', + }), + ).rejects.toThrow(); + }); + + it('should throw error when user does not have access to update workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth2, workflow.id, { + name: 'hacked-workflow', + }), + ).rejects.toThrow(); + }); + + it('should throw error when updating with invalid filter', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth, created.id, { + filters: [{ filterId: 'invalid-filter-id', filterConfig: {} }], + }), + ).rejects.toThrow(); + }); + + it('should throw error when updating with invalid action', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await expect( + sut.update(auth, created.id, { + actions: [{ actionId: 'invalid-action-id', actionConfig: {} }], + }), + ).rejects.toThrow(); + }); + }); + + describe('delete', () => { + it('should delete a workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [], + }); + + await sut.delete(auth, workflow.id); + + await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); + }); + + it('should delete workflow with filters and actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: {} }], + actions: [{ actionId: testActionId, actionConfig: {} }], + }); + + await sut.delete(auth, workflow.id); + + await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); + }); + + it('should throw error when deleting non-existent workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + await expect(sut.delete(auth, 'non-existent-id')).rejects.toThrow(); + }); + + it('should throw error when user does not have access to delete workflow', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + const workflow = await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'private-workflow', + description: 'Private', + enabled: true, + filters: [], + actions: [], + }); + + await expect(sut.delete(auth2, workflow.id)).rejects.toThrow(); + }); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 50db983cb..208b09c12 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -65,5 +65,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { tag: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + + workflow: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index e31e1a334..656027fab 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -72,6 +72,7 @@ const envData: EnvData = { root: '/build/www', indexHtml: '/build/www/index.html', }, + corePlugin: '/build/corePlugin', }, storage: { @@ -86,6 +87,11 @@ const envData: EnvData = { workers: [ImmichWorker.Api, ImmichWorker.Microservices], + plugins: { + enabled: true, + installFolder: '/app/data/plugins', + }, + noColor: false, }; diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index 1167923c0..773891206 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -13,5 +13,7 @@ export const newCryptoRepositoryMock = (): Mocked Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), + signJwt: vitest.fn().mockReturnValue('mock-jwt-token'), + verifyJwt: vitest.fn().mockImplementation((token) => ({ verified: true, token })), }; }; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 9752a3944..31451da82 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -49,6 +49,7 @@ export const newStorageRepositoryMock = (): Mocked = T extends RepositoryInterface ? U : never; @@ -308,6 +312,7 @@ export const newTestService = ( oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), + plugin: automock(PluginRepository, { strict: true }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), // eslint-disable-next-line no-sparse-arrays @@ -330,6 +335,7 @@ export const newTestService = ( view: automock(ViewRepository), // eslint-disable-next-line no-sparse-arrays websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }), + workflow: automock(WorkflowRepository, { strict: true }), }; const sut = new Service( @@ -363,6 +369,7 @@ export const newTestService = ( overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), + overrides.plugin || (mocks.plugin as As), overrides.process || (mocks.process as As), overrides.search || (mocks.search as As), overrides.serverInfo || (mocks.serverInfo as As), @@ -381,6 +388,7 @@ export const newTestService = ( overrides.versionHistory || (mocks.versionHistory as As), overrides.view || (mocks.view as As), overrides.websocket || (mocks.websocket as As), + overrides.workflow || (mocks.workflow as As), ); return { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 87f0d7e7b..100f80727 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -162,6 +162,7 @@ export const getQueueName = derived(t, ($t) => { [QueueName.Notifications]: $t('notifications'), [QueueName.BackupDatabase]: $t('admin.backup_database'), [QueueName.Ocr]: $t('admin.machine_learning_ocr'), + [QueueName.Workflow]: $t('workflow'), }; return names[name];