Use middleware for plugin

This commit is contained in:
Felix Ableitner 2024-05-06 13:25:42 +02:00
parent 2abe6300da
commit e6795946e4
13 changed files with 140 additions and 59 deletions

2
.gitignore vendored
View file

@ -36,4 +36,4 @@ dev_pgdata/
*.sqldump *.sqldump
# compiled example plugin # compiled example plugin
example_plugin/plugin.wasm plugins/plugin.wasm

View file

@ -229,7 +229,7 @@ steps:
DO_WRITE_HOSTS_FILE: "1" DO_WRITE_HOSTS_FILE: "1"
commands: commands:
- *install_pnpm - *install_pnpm
- apt update && apt install -y bash curl postgresql-client golang tinygo - apt update && apt install -y bash curl postgresql-client golang
- bash api_tests/prepare-drone-federation-test.sh - bash api_tests/prepare-drone-federation-test.sh
- cd api_tests/ - cd api_tests/
- pnpm i - pnpm i

6
Cargo.lock generated
View file

@ -3140,8 +3140,6 @@ dependencies = [
"actix-web", "actix-web",
"anyhow", "anyhow",
"bcrypt", "bcrypt",
"extism",
"extism-convert",
"futures", "futures",
"lemmy_api_common", "lemmy_api_common",
"lemmy_db_schema", "lemmy_db_schema",
@ -3358,6 +3356,7 @@ version = "0.19.4-beta.6"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"actix-cors", "actix-cors",
"actix-http",
"actix-web", "actix-web",
"actix-web-prom", "actix-web-prom",
"chrono", "chrono",
@ -3366,6 +3365,8 @@ dependencies = [
"console-subscriber 0.1.10", "console-subscriber 0.1.10",
"diesel", "diesel",
"diesel-async", "diesel-async",
"extism",
"extism-convert",
"futures-util", "futures-util",
"lemmy_api", "lemmy_api",
"lemmy_api_common", "lemmy_api_common",
@ -3383,6 +3384,7 @@ dependencies = [
"reqwest", "reqwest",
"reqwest-middleware", "reqwest-middleware",
"reqwest-tracing", "reqwest-tracing",
"serde",
"serde_json", "serde_json",
"serial_test", "serial_test",
"tokio", "tokio",

View file

@ -204,7 +204,11 @@ chrono = { workspace = true }
prometheus = { version = "0.13.3", features = ["process"] } prometheus = { version = "0.13.3", features = ["process"] }
serial_test = { workspace = true } serial_test = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
serde.workspace = true
actix-web-prom = "0.7.0" actix-web-prom = "0.7.0"
actix-http = "3.6.0"
extism = "1.2.0"
extism-convert = "1.2.0"
[dev-dependencies] [dev-dependencies]
pretty_assertions = { workspace = true } pretty_assertions = { workspace = true }

View file

@ -74,10 +74,9 @@ LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \
target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 & target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 &
# plugin setup # plugin setup
pushd example_plugin pushd plugins
# TODO: not in ubuntu repos, better to use only `go` # need to use tinygo because apparently go has only experimental support for wasm target
# TODO: prevent it from creating useless `go` folder in home dir GOPATH=$HOME/.local/share/go tinygo build -o plugin.wasm -target wasi main.go
tinygo build -o plugin.wasm -target wasi main.go
popd popd
echo "start epsilon" echo "start epsilon"

View file

@ -31,8 +31,6 @@ anyhow.workspace = true
serde.workspace = true serde.workspace = true
webmention = "0.5.0" webmention = "0.5.0"
accept-language = "3.1.0" accept-language = "3.1.0"
extism = "1.2.0"
extism-convert = "1.2.0"
[package.metadata.cargo-machete] [package.metadata.cargo-machete]
ignored = ["futures"] ignored = ["futures"]

View file

@ -1,6 +1,5 @@
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use actix_web::web::Json; use actix_web::web::Json;
use extism::*;
use lemmy_api_common::{ use lemmy_api_common::{
build_response::build_post_response, build_response::build_post_response,
context::LemmyContext, context::LemmyContext,
@ -47,20 +46,16 @@ use lemmy_utils::{
}, },
}, },
}; };
use serde::Serialize;
use std::{ffi::OsStr, fs::read_dir};
use tracing::Instrument; use tracing::Instrument;
use url::Url; use url::Url;
use webmention::{Webmention, WebmentionError}; use webmention::{Webmention, WebmentionError};
#[tracing::instrument(skip(context))] #[tracing::instrument(skip(context))]
pub async fn create_post( pub async fn create_post(
mut data: Json<CreatePost>, data: Json<CreatePost>,
context: Data<LemmyContext>, context: Data<LemmyContext>,
local_user_view: LocalUserView, local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> { ) -> LemmyResult<Json<PostResponse>> {
plugin_hook("api_before_create_post", &mut (*data))?;
let local_site = LocalSite::read(&mut context.pool()).await?; let local_site = LocalSite::read(&mut context.pool()).await?;
honeypot_check(&data.honeypot)?; honeypot_check(&data.honeypot)?;
@ -205,45 +200,5 @@ pub async fn create_post(
} }
}; };
let mut res = build_post_response(&context, community_id, &local_user_view.person, post_id) build_post_response(&context, community_id, &local_user_view.person, post_id).await
.await?
.0;
plugin_hook("api_after_create_post", &mut res)?;
Ok(Json(res))
}
fn load_plugins() -> LemmyResult<Plugin> {
// TODO: make dir configurable via env var
// TODO: should only read fs once at startup for performance
let plugin_paths = read_dir("example_plugin")?;
let mut wasm_files = vec![];
for path in plugin_paths {
let path = path?.path();
if path.extension() == Some(OsStr::new("wasm")) {
wasm_files.push(path);
}
}
let manifest = Manifest::new(wasm_files);
let plugin = Plugin::new(manifest, [], true)?;
Ok(plugin)
}
fn plugin_hook<T: Serialize + for<'de> serde::Deserialize<'de> + Clone>(
name: &'static str,
data: &mut T,
) -> LemmyResult<()> {
let mut plugin = load_plugins()?;
if plugin.function_exists(name) {
*data = plugin
.call::<extism_convert::Json<T>, extism_convert::Json<T>>(name, (*data).clone().into())
.map_err(|e| {
dbg!(&e);
LemmyErrorType::PluginError(e.to_string())
})?
.0
.into();
}
Ok(())
} }

View file

@ -16,8 +16,8 @@ type CreatePost struct {
Custom_thumbnail *string `json:"custom_thumbnail,omitempty"` Custom_thumbnail *string `json:"custom_thumbnail,omitempty"`
} }
//export api_before_create_post //export api_before_post_post
func api_before_create_post() int32 { func api_before_post_post() int32 {
params := CreatePost{} params := CreatePost{}
// use json input helper, which automatically unmarshals the plugin input into your struct // use json input helper, which automatically unmarshals the plugin input into your struct
err := pdk.InputJSON(&params) err := pdk.InputJSON(&params)

View file

@ -1,3 +1,4 @@
use crate::plugin_middleware::PluginMiddleware;
use actix_web::{guard, web}; use actix_web::{guard, web};
use lemmy_api::{ use lemmy_api::{
comment::{ comment::{
@ -139,6 +140,7 @@ use lemmy_utils::rate_limit::RateLimitCell;
pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
cfg.service( cfg.service(
web::scope("/api/v3") web::scope("/api/v3")
.wrap(PluginMiddleware::new())
.route("/image_proxy", web::get().to(image_proxy)) .route("/image_proxy", web::get().to(image_proxy))
// Site // Site
.service( .service(

View file

@ -1,5 +1,6 @@
pub mod api_routes_http; pub mod api_routes_http;
pub mod code_migrations; pub mod code_migrations;
pub mod plugin_middleware;
pub mod prometheus_metrics; pub mod prometheus_metrics;
pub mod root_span_builder; pub mod root_span_builder;
pub mod scheduled_tasks; pub mod scheduled_tasks;

120
src/plugin_middleware.rs Normal file
View file

@ -0,0 +1,120 @@
use actix_http::h1::Payload;
use actix_web::{
body::MessageBody,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
web::Bytes,
Error,
};
use core::future::Ready;
use extism::{Manifest, Plugin};
use futures_util::future::LocalBoxFuture;
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{ffi::OsStr, fs::read_dir, future::ready, rc::Rc};
use tracing::info;
#[derive(Clone)]
pub struct PluginMiddleware {}
impl PluginMiddleware {
pub fn new() -> Self {
PluginMiddleware {}
}
}
impl<S, B> Transform<S, ServiceRequest> for PluginMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: MessageBody + 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Transform = SessionService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(SessionService {
service: Rc::new(service),
}))
}
}
pub struct SessionService<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for SessionService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, mut service_req: ServiceRequest) -> Self::Future {
let svc = self.service.clone();
Box::pin(async move {
let method = service_req.method();
let path = service_req.path().replace("/api/v3/", "").replace("/", "_");
// TODO: naming can be a bit silly, `POST /api/v3/post` becomes `api_before_post_post`
let plugin_hook = format!("api_before_{method}_{path}").to_lowercase();
info!("Calling plugin hook {}", &plugin_hook);
if let Some(mut plugins) = load_plugins()? {
if plugins.function_exists(&plugin_hook) {
let payload = service_req.extract::<Bytes>().await?;
let mut json: Value = serde_json::from_slice(&payload.to_vec())?;
call_plugin(plugins, &plugin_hook, &mut json)?;
let (_, mut new_payload) = Payload::create(true);
new_payload.unread_data(Bytes::from(serde_json::to_vec_pretty(&json)?));
service_req.set_payload(new_payload.into());
}
}
let res = svc.call(service_req).await?;
Ok(res)
})
}
}
fn load_plugins() -> LemmyResult<Option<Plugin>> {
// TODO: make dir configurable via env var
// TODO: should only read fs once at startup for performance
let plugin_paths = read_dir("plugins")?;
let mut wasm_files = vec![];
for path in plugin_paths {
let path = path?.path();
if path.extension() == Some(OsStr::new("wasm")) {
wasm_files.push(path);
}
}
if !wasm_files.is_empty() {
// TODO: what if theres more than one plugin for the same hook?
let manifest = Manifest::new(wasm_files);
let plugin = Plugin::new(manifest, [], true)?;
Ok(Some(plugin))
} else {
Ok(None)
}
}
pub fn call_plugin<T: Serialize + for<'de> Deserialize<'de> + Clone>(
mut plugins: Plugin,
name: &str,
data: &mut T,
) -> LemmyResult<()> {
*data = plugins
.call::<extism_convert::Json<T>, extism_convert::Json<T>>(name, (*data).clone().into())
.map_err(|e| LemmyErrorType::PluginError(e.to_string()))?
.0
.into();
Ok(())
}