improve extensions

This commit is contained in:
zhongqin
2026-04-23 07:46:52 +08:00
parent c3caa78f21
commit acf8a39e40
17 changed files with 1968 additions and 206 deletions
File diff suppressed because it is too large Load Diff
+48 -24
View File
@@ -1,5 +1,3 @@
// ── 工具函数 ──────────────────────────────────────────────────
var Plugin = {};
function ok(result) {
@@ -14,13 +12,29 @@ function err(status, msg) {
}
function parseBody(input) {
try { return input.body ? JSON.parse(input.body) : {}; }
catch (e) { return {}; }
try {
if (typeof input === "string") {
var parsed = JSON.parse(input);
if (parsed && typeof parsed.body === "string" && parsed.body.charAt(0) === "{") {
return JSON.parse(parsed.body);
}
return parsed;
}
if (input && input.body) return JSON.parse(input.body);
return {};
} catch (e) { return {}; }
}
function routeParam(input) {
var parts = (input.path || "").replace(/\/+$/, "").split("/");
return parts[parts.length - 1];
function routeParam(input, index) {
var obj = input;
if (typeof input === "string") {
try { obj = JSON.parse(input); } catch (e) { return ""; }
}
var path = (obj.path || "").replace(/\/+$/, "");
var qIdx = path.indexOf("?");
if (qIdx >= 0) path = path.substring(0, qIdx);
var parts = path.split("/");
return parts[parts.length - (index || 1)];
}
function genId() {
@@ -31,8 +45,8 @@ function genId() {
});
}
function query(sql) {
var result = Host.dbQuery(sql);
function query(sql, params) {
var result = Host.dbQuery(sql, params ? JSON.stringify(params) : null);
if (!result || result.indexOf("error:") === 0) return null;
return JSON.parse(result);
}
@@ -54,7 +68,7 @@ Plugin.listProducts = function(input) {
Plugin.getProduct = function(input) {
var id = routeParam(input);
var rows = query("SELECT id, name, slug, description, price, compare_at_price, stock, sku, images, featured, weight FROM products WHERE id = '" + id + "' AND status = 'published'");
var rows = query("SELECT id, name, slug, description, price, compare_at_price, stock, sku, images, featured, weight FROM products WHERE id = ? AND status = 'published'", [id]);
if (!rows) return ok(err(500, "query failed"));
if (rows.length === 0) return ok(err(404, "product not found"));
return ok(rows[0]);
@@ -66,7 +80,7 @@ Plugin.viewCart = function(input) {
var data = parseBody(input);
var userId = data.user_id;
if (!userId) return ok(err(400, "user_id required"));
var rows = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.images, p.sku FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = '" + userId + "' ORDER BY c.created_at DESC");
var rows = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.sku FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = ?", [userId]);
if (!rows) return ok(err(500, "query failed"));
var total = 0;
for (var i = 0; i < rows.length; i++) {
@@ -85,13 +99,13 @@ Plugin.addToCart = function(input) {
var quantity = data.quantity || 1;
if (!userId || !productId) return ok(err(400, "user_id and product_id required"));
var products = query("SELECT id, name, price, stock, status FROM products WHERE id = '" + productId + "'");
var products = query("SELECT id, name, price, stock, status FROM products WHERE id = ?", [productId]);
if (!products) return ok(err(500, "query failed"));
if (products.length === 0) return ok(err(404, "product not found"));
if (products[0].status !== "published") return ok(err(400, "product not available"));
if (products[0].stock < quantity) return ok(err(400, "insufficient stock"));
var existing = query("SELECT id, quantity FROM cart_items WHERE user_id = '" + userId + "' AND product_id = '" + productId + "'");
var existing = query("SELECT id, quantity FROM cart_items WHERE user_id = ? AND product_id = ?", [userId, productId]);
if (!existing) return ok(err(500, "query failed"));
if (existing.length > 0) {
@@ -100,7 +114,8 @@ Plugin.addToCart = function(input) {
if (r.error) return ok(err(500, r.error));
} else {
var id = genId();
var r = exec("INSERT INTO cart_items (id, user_id, product_id, quantity) VALUES (?, ?, ?, ?)", [id, userId, productId, quantity]);
var now = new Date().toISOString();
var r = exec("INSERT INTO cart_items (id, tenant_id, user_id, product_id, quantity, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, ?, ?)", [id, userId, productId, quantity, now, now]);
if (r.error) return ok(err(500, r.error));
}
return ok({ added: true });
@@ -140,14 +155,14 @@ Plugin.removeCartItem = function(input) {
return ok({ removed: true });
}
// ── POST /checkout ───────────────────────────────────────────
// ── POST /checkout (事务) ────────────────────────────────────
Plugin.checkout = function(input) {
var data = parseBody(input);
var userId = data.user_id;
if (!userId) return ok(err(400, "user_id required"));
var cartItems = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.status FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = '" + userId + "'");
var cartItems = query("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.status FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = ?", [userId]);
if (!cartItems) return ok(err(500, "query failed"));
if (cartItems.length === 0) return ok(err(400, "cart is empty"));
@@ -167,15 +182,21 @@ Plugin.checkout = function(input) {
orderItems.push({ product_id: item.product_id, product_name: item.name, price: item.price, quantity: item.quantity, subtotal: subtotal });
}
var beginResult = JSON.parse(Host.dbBegin());
if (!beginResult.ok) return ok(err(500, "failed to begin transaction"));
var orderId = genId();
var orderNo = "ORD-" + Date.now().toString(36).toUpperCase() + "-" + Math.random().toString(36).substring(2, 6).toUpperCase();
var shippingJson = data.shipping_address ? JSON.stringify(data.shipping_address) : "";
var r = exec(
"INSERT INTO orders (id, order_no, user_id, status, total_amount, shipping_address, note) VALUES (?, ?, ?, 'pending', ?, ?, ?)",
[orderId, orderNo, userId, totalAmount, shippingJson, data.note || ""]
"INSERT INTO orders (id, tenant_id, order_no, user_id, status, total_amount, shipping_address, note, created_at, updated_at) VALUES (?, 'default', ?, ?, 'pending', ?, ?, ?, ?, ?)",
[orderId, orderNo, userId, totalAmount, shippingJson, data.note || "", new Date().toISOString(), new Date().toISOString()]
);
if (r.error) return ok(err(500, "create order failed: " + r.error));
if (r.error) {
Host.dbRollback();
return ok(err(500, "create order failed: " + r.error));
}
for (var i = 0; i < orderItems.length; i++) {
var oi = orderItems[i];
@@ -185,19 +206,22 @@ Plugin.checkout = function(input) {
[oiId, orderId, oi.product_id, oi.product_name, oi.price, oi.quantity, oi.subtotal]
);
if (r2.error) {
exec("UPDATE orders SET status = 'cancelled' WHERE id = ?", [orderId]);
Host.dbRollback();
return ok(err(500, "create order item failed: " + r2.error));
}
var r3 = exec("UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?", [oi.quantity, oi.product_id, oi.quantity]);
if (r3.error || r3.rows_affected === 0) {
exec("UPDATE orders SET status = 'cancelled' WHERE id = ?", [orderId]);
Host.dbRollback();
return ok(err(500, "stock deduction failed for " + oi.product_name));
}
}
exec("DELETE FROM cart_items WHERE user_id = ?", [userId]);
var commitResult = JSON.parse(Host.dbCommit());
if (!commitResult.ok) return ok(err(500, "commit failed"));
return ok({
order_id: orderId,
order_no: orderNo,
@@ -212,7 +236,7 @@ Plugin.checkout = function(input) {
Plugin.listOrders = function(input) {
var data = parseBody(input);
if (!data.user_id) return ok(err(400, "user_id required"));
var rows = query("SELECT id, order_no, status, total_amount, note, created_at FROM orders WHERE user_id = '" + data.user_id + "' ORDER BY created_at DESC LIMIT 50");
var rows = query("SELECT id, order_no, status, total_amount, note, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", [data.user_id]);
if (!rows) return ok(err(500, "query failed"));
return ok({ items: rows, total: rows.length });
}
@@ -223,13 +247,13 @@ Plugin.getOrder = function(input) {
var orderId = routeParam(input);
var data = parseBody(input);
if (!orderId) return ok(err(400, "order id required"));
var orders = query("SELECT id, order_no, user_id, status, total_amount, shipping_address, note, paid_at, shipped_at, created_at FROM orders WHERE id = '" + orderId + "'");
var orders = query("SELECT id, order_no, user_id, status, total_amount, shipping_address, note, paid_at, shipped_at, created_at FROM orders WHERE id = ?", [orderId]);
if (!orders) return ok(err(500, "query failed"));
if (orders.length === 0) return ok(err(404, "order not found"));
var order = orders[0];
if (data.user_id && order.user_id !== data.user_id) return ok(err(403, "forbidden"));
var items = query("SELECT id, product_id, product_name, price, quantity, subtotal FROM order_items WHERE order_id = '" + orderId + "'");
var items = query("SELECT id, product_id, product_name, price, quantity, subtotal FROM order_items WHERE order_id = ?", [orderId]);
order.items = items || [];
return ok(order);
}
+35 -34
View File
@@ -49,8 +49,8 @@ function nowISO() {
return new Date().toISOString();
}
function query(sql) {
var result = Host.dbQuery(sql);
function query(sql, params) {
var result = Host.dbQuery(sql, params ? JSON.stringify(params) : null);
if (!result || result.indexOf("error:") === 0) return null;
return JSON.parse(result);
}
@@ -60,11 +60,6 @@ function exec(sql, params) {
return JSON.parse(result);
}
function escapeSQL(str) {
if (str === null || str === undefined) return "";
return String(str).replace(/'/g, "''");
}
// ── Hooks ───────────────────────────────────────────────────
Plugin.on_content_creating = function (input) {
@@ -75,7 +70,7 @@ Plugin.on_content_creating = function (input) {
if (ct === "forum_reply") {
var topicId = body.topic_id;
if (topicId) {
var topics = query("SELECT is_locked FROM forum_topics WHERE id = '" + escapeSQL(topicId) + "'");
var topics = query("SELECT is_locked FROM forum_topics WHERE id = ?", [topicId]);
if (topics && topics.length > 0 && topics[0].is_locked) {
return JSON.stringify({ status: 400, body: JSON.stringify({ ok: false, error: "topic is locked" }) });
}
@@ -91,7 +86,7 @@ Plugin.on_content_created = function (input) {
var id = data.id;
if (ct === "forum_topic") {
var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(id) + "'");
var topics = query("SELECT board_id FROM forum_topics WHERE id = ?", [id]);
if (topics && topics.length > 0) {
var now = nowISO();
exec("UPDATE forum_boards SET topic_count = topic_count + 1, post_count = post_count + 1, last_activity_at = ?, last_topic_id = ? WHERE id = ?",
@@ -100,20 +95,22 @@ Plugin.on_content_created = function (input) {
}
if (ct === "forum_reply") {
var replies = query("SELECT topic_id, author_id FROM forum_replies WHERE id = '" + escapeSQL(id) + "'");
var replies = query("SELECT topic_id, author_id FROM forum_replies WHERE id = ?", [id]);
if (replies && replies.length > 0) {
var reply = replies[0];
var now = nowISO();
exec("UPDATE forum_topics SET reply_count = reply_count + 1, last_reply_at = ?, last_reply_user_id = ?, updated_at = ? WHERE id = ?",
[now, reply.author_id, now, reply.topic_id]);
var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(reply.topic_id) + "'");
var topics = query("SELECT board_id FROM forum_topics WHERE id = ?", [reply.topic_id]);
if (topics && topics.length > 0) {
exec("UPDATE forum_boards SET post_count = post_count + 1, last_activity_at = ? WHERE id = ?",
[now, topics[0].board_id]);
}
}
}
return ok(data);
};
Plugin.on_content_deleted = function (input) {
@@ -122,7 +119,7 @@ Plugin.on_content_deleted = function (input) {
var id = data.id;
if (ct === "forum_topic") {
var topics = query("SELECT board_id FROM forum_topics WHERE id = '" + escapeSQL(id) + "'");
var topics = query("SELECT board_id FROM forum_topics WHERE id = ?", [id]);
if (topics && topics.length > 0) {
exec("UPDATE forum_boards SET topic_count = CASE WHEN topic_count > 0 THEN topic_count - 1 ELSE 0 END, post_count = CASE WHEN post_count > 0 THEN post_count - 1 ELSE 0 END WHERE id = ?",
[topics[0].board_id]);
@@ -130,12 +127,14 @@ Plugin.on_content_deleted = function (input) {
}
if (ct === "forum_reply") {
var replies = query("SELECT topic_id FROM forum_replies WHERE id = '" + escapeSQL(id) + "'");
var replies = query("SELECT topic_id FROM forum_replies WHERE id = ?", [id]);
if (replies && replies.length > 0) {
exec("UPDATE forum_topics SET reply_count = CASE WHEN reply_count > 0 THEN reply_count - 1 ELSE 0 END WHERE id = ?",
[replies[0].topic_id]);
}
}
return ok(data);
};
Plugin.on_content_viewed = function (input) {
@@ -146,12 +145,13 @@ Plugin.on_content_viewed = function (input) {
if (ct === "forum_topic") {
exec("UPDATE forum_topics SET view_count = view_count + 1 WHERE id = ?", [id]);
}
return ok(data);
};
// ── GET /boards/:slug/topics ────────────────────────────────
Plugin.listBoardTopics = function (input) {
var fullPath = input.path || "";
var slug = routeParam(input, 2);
var page = 1;
var pageSize = 20;
@@ -159,19 +159,20 @@ Plugin.listBoardTopics = function (input) {
if (pageSize < 1 || pageSize > 100) pageSize = 20;
var offset = (page - 1) * pageSize;
var boards = query("SELECT id FROM forum_boards WHERE slug = '" + escapeSQL(slug) + "'");
var boards = query("SELECT id FROM forum_boards WHERE slug = ?", [slug]);
if (!boards || boards.length === 0) return ok(err(404, "board not found for slug: " + slug));
var boardId = boards[0].id;
var totalResult = query("SELECT COUNT(*) as cnt FROM forum_topics WHERE board_id = '" + boardId + "'");
var totalResult = query("SELECT COUNT(*) as cnt FROM forum_topics WHERE board_id = ?", [boardId]);
var total = (totalResult && totalResult[0]) ? parseInt(totalResult[0].cnt, 10) : 0;
var rows = query(
"SELECT id, title, slug, author_id, reply_count, view_count, is_pinned, is_locked, is_solved, " +
"last_reply_at, last_reply_user_id, tags, created_at " +
"FROM forum_topics WHERE board_id = '" + boardId + "' " +
"FROM forum_topics WHERE board_id = ? " +
"ORDER BY is_pinned DESC, last_reply_at DESC, created_at DESC " +
"LIMIT " + pageSize + " OFFSET " + offset
"LIMIT ? OFFSET ?",
[boardId, pageSize, offset]
);
return ok({ items: rows || [], total: total, page: page, page_size: pageSize, board_id: boardId });
@@ -185,11 +186,11 @@ Plugin.acceptAnswer = function (input) {
var userId = data.user_id;
if (!userId) return ok(err(400, "user_id required"));
var replies = query("SELECT id, topic_id, author_id FROM forum_replies WHERE id = '" + escapeSQL(replyId) + "' AND status = 'published'");
var replies = query("SELECT id, topic_id, author_id FROM forum_replies WHERE id = ?", [replyId]);
if (!replies || replies.length === 0) return ok(err(404, "reply not found"));
var reply = replies[0];
var topics = query("SELECT id, author_id FROM forum_topics WHERE id = '" + escapeSQL(reply.topic_id) + "' AND status = 'published'");
var topics = query("SELECT id, author_id FROM forum_topics WHERE id = ?", [reply.topic_id]);
if (!topics || topics.length === 0) return ok(err(404, "topic not found"));
if (topics[0].author_id !== userId) return ok(err(403, "only topic author can accept answer"));
@@ -217,7 +218,7 @@ Plugin.vote = function (input) {
if (!targetId) return ok(err(400, "target_id required"));
if (targetType !== "topic" && targetType !== "reply") return ok(err(400, "target_type must be topic or reply"));
var existing = query("SELECT id, value FROM forum_votes WHERE target_type = '" + escapeSQL(targetType) + "' AND target_id = '" + escapeSQL(targetId) + "' AND user_id = '" + escapeSQL(userId) + "'");
var existing = query("SELECT id, value FROM forum_votes WHERE target_type = ? AND target_id = ? AND user_id = ?", [targetType, targetId, userId]);
if (existing && existing.length > 0) {
var oldValue = parseInt(existing[0].value, 10);
var diff = value - oldValue;
@@ -227,7 +228,7 @@ Plugin.vote = function (input) {
} else {
var id = genId();
var now = nowISO();
exec("INSERT INTO forum_votes (id, target_type, target_id, user_id, value, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'published', ?, ?)",
exec("INSERT INTO forum_votes (id, tenant_id, target_type, target_id, user_id, value, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, ?, ?, ?)",
[id, targetType, targetId, userId, value, now, now]);
updateVoteCount(targetType, targetId, value);
}
@@ -247,7 +248,7 @@ Plugin.unvote = function (input) {
if (!targetType) return ok(err(400, "target_type required"));
if (!targetId) return ok(err(400, "target_id required"));
var existing = query("SELECT id, value FROM forum_votes WHERE target_type = '" + escapeSQL(targetType) + "' AND target_id = '" + escapeSQL(targetId) + "' AND user_id = '" + escapeSQL(userId) + "'");
var existing = query("SELECT id, value FROM forum_votes WHERE target_type = ? AND target_id = ? AND user_id = ?", [targetType, targetId, userId]);
if (!existing || existing.length === 0) return ok(err(404, "vote not found"));
var oldValue = parseInt(existing[0].value, 10);
@@ -280,11 +281,11 @@ Plugin.createPoll = function (input) {
if (maxChoices < 1) maxChoices = 1;
if (maxChoices > options.length) maxChoices = options.length;
var topics = query("SELECT id, author_id FROM forum_topics WHERE id = '" + escapeSQL(topicId) + "'");
var topics = query("SELECT id, author_id FROM forum_topics WHERE id = ?", [topicId]);
if (!topics || topics.length === 0) return ok(err(404, "topic not found"));
if (topics[0].author_id !== userId) return ok(err(403, "only topic author can create poll"));
var existing = query("SELECT id FROM forum_polls WHERE topic_id = '" + escapeSQL(topicId) + "'");
var existing = query("SELECT id FROM forum_polls WHERE topic_id = ?", [topicId]);
if (existing && existing.length > 0) return ok(err(400, "poll already exists for this topic"));
var pollId = genId();
@@ -332,18 +333,18 @@ Plugin.getPoll = function (input) {
}
}
var polls = query("SELECT id, topic_id, question, max_choices, is_closed, created_at FROM forum_polls WHERE topic_id = '" + escapeSQL(topicId) + "'");
var polls = query("SELECT id, topic_id, question, max_choices, is_closed, created_at FROM forum_polls WHERE topic_id = ?", [topicId]);
if (!polls || polls.length === 0) return ok(null);
var poll = polls[0];
var options = query("SELECT id, text, CAST(vote_count AS TEXT) as vote_count, CAST(sort_order AS TEXT) as sort_order FROM forum_poll_options WHERE poll_id = '" + poll.id + "' ORDER BY sort_order");
var options = query("SELECT id, text, CAST(vote_count AS TEXT) as vote_count, CAST(sort_order AS TEXT) as sort_order FROM forum_poll_options WHERE poll_id = ? ORDER BY sort_order", [poll.id]);
var totalResult = query("SELECT CAST(SUM(vote_count) AS TEXT) as total FROM forum_poll_options WHERE poll_id = '" + poll.id + "'");
var totalResult = query("SELECT CAST(SUM(vote_count) AS TEXT) as total FROM forum_poll_options WHERE poll_id = ?", [poll.id]);
var totalVotes = (totalResult && totalResult[0] && totalResult[0].total) ? parseInt(totalResult[0].total, 10) : 0;
var userVotes = [];
if (userId) {
var votes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = '" + poll.id + "' AND user_id = '" + escapeSQL(userId) + "'");
var votes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = ? AND user_id = ?", [poll.id, userId]);
if (votes) {
for (var i = 0; i < votes.length; i++) {
userVotes.push(votes[i].option_id);
@@ -385,7 +386,7 @@ Plugin.castVote = function (input) {
if (!userId) return ok(err(400, "user_id required"));
if (!optionIds || optionIds.length === 0) return ok(err(400, "option_ids required"));
var polls = query("SELECT id, topic_id, question, max_choices, is_closed FROM forum_polls WHERE id = '" + escapeSQL(pollId) + "'");
var polls = query("SELECT id, topic_id, question, max_choices, is_closed FROM forum_polls WHERE id = ?", [pollId]);
if (!polls || polls.length === 0) return ok(err(404, "poll not found"));
var poll = polls[0];
@@ -394,12 +395,12 @@ Plugin.castVote = function (input) {
var maxChoices = parseInt(poll.max_choices, 10);
if (optionIds.length > maxChoices) return ok(err(400, "too many choices (max " + maxChoices + ")"));
var existingVotes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = '" + escapeSQL(pollId) + "' AND user_id = '" + escapeSQL(userId) + "'");
var existingVotes = query("SELECT option_id FROM forum_poll_votes WHERE poll_id = ? AND user_id = ?", [pollId, userId]);
if (existingVotes && existingVotes.length > 0) return ok(err(400, "already voted"));
for (var i = 0; i < optionIds.length; i++) {
var optId = optionIds[i];
var opts = query("SELECT id FROM forum_poll_options WHERE id = '" + escapeSQL(optId) + "' AND poll_id = '" + escapeSQL(pollId) + "'");
var opts = query("SELECT id FROM forum_poll_options WHERE id = ? AND poll_id = ?", [optId, pollId]);
if (!opts || opts.length === 0) return ok(err(400, "option not found: " + optId));
var voteId = genId();
@@ -419,10 +420,10 @@ Plugin.deletePoll = function (input) {
if (!userId) return ok(err(400, "user_id required"));
var polls = query("SELECT id, topic_id FROM forum_polls WHERE id = '" + escapeSQL(pollId) + "'");
var polls = query("SELECT id, topic_id FROM forum_polls WHERE id = ?", [pollId]);
if (!polls || polls.length === 0) return ok(err(404, "poll not found"));
var topics = query("SELECT author_id FROM forum_topics WHERE id = '" + escapeSQL(polls[0].topic_id) + "'");
var topics = query("SELECT author_id FROM forum_topics WHERE id = ?", [polls[0].topic_id]);
if (!topics || topics.length === 0 || topics[0].author_id !== userId) return ok(err(403, "only topic author can delete poll"));
exec("DELETE FROM forum_poll_votes WHERE poll_id = ?", [pollId]);
+2 -1
View File
@@ -52,7 +52,7 @@ interface host-api {
get-data: func(key: string) -> option<string>;
set-data: func(key: string, value: string) -> bool;
get-post: func(slug: string) -> option<string>;
db-query: func(sql: string) -> string;
db-query: func(sql: string, params: option<string>) -> string;
db-execute: func(sql: string, params: option<string>) -> string;
db-begin: func() -> string;
db-commit: func() -> string;
@@ -63,6 +63,7 @@ interface host-api {
fs-exists: func(path: string) -> bool;
fs-list: func(path: string) -> option<string>;
fs-stat: func(path: string) -> option<string>;
emit-event: func(event-type: string, data: string) -> string;
}
interface plugin-hooks {
+7
View File
@@ -97,6 +97,13 @@ pub enum Event {
email: String,
verify_token: String,
},
// ── 插件自定义事件 ──
Custom {
source: String,
event_type: String,
data: serde_json::Value,
},
}
/// 事件订阅者
+87
View File
@@ -208,6 +208,31 @@ impl ExtensionManager {
ext_id
))
})?;
} else if let Some(ref existing) = db_record {
let new_version = &manifest.extension.version;
if existing.version != *new_version {
tracing::info!(
"extension '{}' upgrading: {} → {}",
ext_id,
existing.version,
new_version
);
if let Err(e) = self
.run_extension_migrations(ext_root, &existing.version, new_version)
.await
{
tracing::error!("extension '{}' migration failed: {e}", ext_id);
}
let (_, now) = crate::utils::id::new_id_and_timestamp();
model::update_version(&self.pool, ext_id, new_version, &manifest.extension.name, &now)
.await
.map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"extension '{}': update version failed: {e}",
ext_id
))
})?;
}
}
if let Some(ct_dir) = manifest.content_types_dir(ext_root)
@@ -288,6 +313,68 @@ impl ExtensionManager {
Ok(names)
}
/// 执行 Extension 目录下的自定义 SQL 迁移脚本。
///
/// 迁移文件放在 `extensions/{ext_id}/migrations/` 目录下,
/// 文件名格式:`{version}.sql`(如 `0.2.0.sql`)。
/// 仅执行版本号大于 old_version 且小于等于 new_version 的脚本。
async fn run_extension_migrations(
&self,
ext_root: &Path,
old_version: &str,
new_version: &str,
) -> AppResult<()> {
let migrations_dir = ext_root.join("migrations");
if !migrations_dir.exists() {
tracing::debug!(
"no migrations/ directory for extension, skipping custom migrations"
);
return Ok(());
}
let entries = std::fs::read_dir(&migrations_dir).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"cannot read migrations dir {migrations_dir:?}: {e}"
))
})?;
let mut migration_files: Vec<(String, std::path::PathBuf)> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "sql") {
let stem = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
migration_files.push((stem, path));
}
}
migration_files.sort_by(|a, b| a.0.cmp(&b.0));
for (version, path) in &migration_files {
if version.as_str() > old_version && version.as_str() <= new_version {
let sql =
std::fs::read_to_string(path)
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?;
tracing::info!("running extension migration: {} ({})", path.display(), version);
sqlx::query(&sql)
.execute(&self.pool)
.await
.map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"migration {} failed: {e}",
path.display()
))
})?;
}
}
Ok(())
}
/// 获取所有已加载 Extension 列表
pub fn list_loaded(&self) -> Vec<LoadedExtension> {
self.extensions
+32 -22
View File
@@ -3,6 +3,7 @@
//! 将 `EventBus` 的事件流转换为 HTTP SSE 推送,供前端实时接收业务事件。
//! 支持按事件类型过滤和心跳保活。
use std::borrow::Cow;
use std::convert::Infallible;
use std::sync::Arc;
@@ -25,25 +26,26 @@ pub struct SubscribeQuery {
}
/// 提取事件类型名称
pub fn event_type_name(event: &Event) -> &'static str {
pub fn event_type_name(event: &Event) -> Cow<'static, str> {
match event {
Event::PostCreating { .. } => "PostCreating",
Event::PostCreated { .. } => "PostCreated",
Event::PostUpdated { .. } => "PostUpdated",
Event::PostDeleted { .. } => "PostDeleted",
Event::CommentCreated { .. } => "CommentCreated",
Event::CommentDeleted { .. } => "CommentDeleted",
Event::ContentCreating { .. } => "ContentCreating",
Event::ContentCreated { .. } => "ContentCreated",
Event::ContentUpdating { .. } => "ContentUpdating",
Event::ContentUpdated { .. } => "ContentUpdated",
Event::ContentDeleted { .. } => "ContentDeleted",
Event::UserRegistered { .. } => "UserRegistered",
Event::UserLoggedIn { .. } => "UserLoggedIn",
Event::MediaUploaded { .. } => "MediaUploaded",
Event::MediaDeleted { .. } => "MediaDeleted",
Event::PasswordResetRequested { .. } => "PasswordResetRequested",
Event::EmailVerificationRequested { .. } => "EmailVerificationRequested",
Event::PostCreating { .. } => Cow::Borrowed("PostCreating"),
Event::PostCreated { .. } => Cow::Borrowed("PostCreated"),
Event::PostUpdated { .. } => Cow::Borrowed("PostUpdated"),
Event::PostDeleted { .. } => Cow::Borrowed("PostDeleted"),
Event::CommentCreated { .. } => Cow::Borrowed("CommentCreated"),
Event::CommentDeleted { .. } => Cow::Borrowed("CommentDeleted"),
Event::ContentCreating { .. } => Cow::Borrowed("ContentCreating"),
Event::ContentCreated { .. } => Cow::Borrowed("ContentCreated"),
Event::ContentUpdating { .. } => Cow::Borrowed("ContentUpdating"),
Event::ContentUpdated { .. } => Cow::Borrowed("ContentUpdated"),
Event::ContentDeleted { .. } => Cow::Borrowed("ContentDeleted"),
Event::UserRegistered { .. } => Cow::Borrowed("UserRegistered"),
Event::UserLoggedIn { .. } => Cow::Borrowed("UserLoggedIn"),
Event::MediaUploaded { .. } => Cow::Borrowed("MediaUploaded"),
Event::MediaDeleted { .. } => Cow::Borrowed("MediaDeleted"),
Event::PasswordResetRequested { .. } => Cow::Borrowed("PasswordResetRequested"),
Event::EmailVerificationRequested { .. } => Cow::Borrowed("EmailVerificationRequested"),
Event::Custom { event_type, .. } => Cow::Owned(event_type.clone()),
}
}
@@ -75,7 +77,7 @@ pub async fn subscribe(
let type_name = event_type_name(arc_event.as_ref());
if !filter_types.is_empty() && !filter_types.iter().any(|f| f == type_name) {
if !filter_types.is_empty() && !filter_types.iter().any(|f| f == type_name.as_ref()) {
return None;
}
@@ -170,9 +172,17 @@ mod tests {
"MediaUploaded",
),
(Event::MediaDeleted { id: "1".into() }, "MediaDeleted"),
(
Event::Custom {
source: "test-plugin".into(),
event_type: "OrderCreated".into(),
data: serde_json::json!({"order_id": "o1"}),
},
"OrderCreated",
),
];
assert_eq!(cases.len(), 10, "所有 Event 变体都应有对应名称");
assert_eq!(cases.len(), 11, "所有 Event 变体都应有对应名称");
for (event, expected_name) in &cases {
assert_eq!(event_type_name(event), *expected_name);
}
@@ -252,7 +262,7 @@ mod tests {
.unwrap()
.unwrap();
let name = event_type_name(event.as_ref());
if allowed.iter().any(|a| a == name) {
if allowed.iter().any(|a| *a == name.as_ref()) {
received.push(name.to_string());
}
}
@@ -292,7 +302,7 @@ mod tests {
.unwrap()
.unwrap();
let name = event_type_name(event.as_ref());
if allowed.iter().any(|a| a == name) {
if allowed.iter().any(|a| *a == name.as_ref()) {
received.push(name.to_string());
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ async fn handle_socket(socket: WebSocket, state: crate::AppState, initial_filter
let type_name = event_type_name(arc_event.as_ref());
if !filter_types.is_empty()
&& !filter_types.iter().any(|f| f == type_name)
&& !filter_types.iter().any(|f| f == type_name.as_ref())
{
continue;
}
+5 -3
View File
@@ -208,6 +208,7 @@ pub struct PluginInfoResponse {
/// 设置 `PluginManager` 的可选依赖
pub struct PluginManagerOptions {
pub pool: Option<Pool>,
pub event_bus: Option<crate::eventbus::EventBus>,
}
impl PluginManager {
@@ -215,7 +216,7 @@ impl PluginManager {
///
/// 返回 `Arc` 是因为热重载 watcher 需要持有自引用来执行 reload。
pub async fn new(config: Arc<AppConfig>) -> Arc<Self> {
Self::new_with_options(config, PluginManagerOptions { pool: None }).await
Self::new_with_options(config, PluginManagerOptions { pool: None, event_bus: None }).await
}
/// 带可选依赖创建 `PluginManager`
@@ -259,13 +260,13 @@ impl PluginManager {
};
#[cfg(feature = "plugin-js")]
let js_engine = JsEngine::new(&config, opts.pool.clone())
let js_engine = JsEngine::new(&config, opts.pool.clone(), opts.event_bus.clone())
.await
.expect("failed to create js engine");
#[cfg(feature = "plugin-lua")]
let lua_engine =
LuaEngine::new(&config, opts.pool.clone()).expect("failed to create lua engine");
LuaEngine::new(&config, opts.pool.clone(), opts.event_bus).expect("failed to create lua engine");
let (reload_tx, reload_rx) = tokio::sync::mpsc::channel::<PathBuf>(32);
let (event_tx, _) = tokio::sync::broadcast::channel::<Arc<PluginEvent>>(256);
@@ -2932,6 +2933,7 @@ end
config,
PluginManagerOptions {
pool: Some(pool.clone()),
event_bus: None,
},
)
.await;
+22 -15
View File
@@ -54,10 +54,15 @@ pub struct JsEngine {
config: Arc<AppConfig>,
pool: Option<Pool>,
pool_size: usize,
event_bus: Option<crate::eventbus::EventBus>,
}
impl JsEngine {
pub async fn new(config: &AppConfig, pool: Option<Pool>) -> anyhow::Result<Self> {
pub async fn new(
config: &AppConfig,
pool: Option<Pool>,
event_bus: Option<crate::eventbus::EventBus>,
) -> anyhow::Result<Self> {
let default_memory_limit_bytes = (config.plugin_max_memory_mb as usize) * 1024 * 1024;
let pool_size = config.plugin_js_pool_size.max(1) as usize;
@@ -69,6 +74,7 @@ impl JsEngine {
config: Arc::new(config.clone()),
pool,
pool_size,
event_bus,
})
}
@@ -99,6 +105,7 @@ impl JsEngine {
plugin_id,
perms,
self.pool.clone(),
self.event_bus.clone(),
)?;
ctx.eval::<(), _>(code)?;
Ok::<_, rquickjs::Error>(())
@@ -302,13 +309,13 @@ mod tests {
#[tokio::test]
async fn js_engine_create() {
let engine = JsEngine::new(&test_config(), None).await;
let engine = JsEngine::new(&test_config(), None, None).await;
assert!(engine.is_ok());
}
#[tokio::test]
async fn js_engine_load_and_call_filter() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"
var Plugin = {
@@ -338,7 +345,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_call_filter_missing_plugin() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let result: Option<serde_json::Value> = engine
.call_filter("nonexistent", "on_post_creating", &serde_json::json!({}))
.await
@@ -348,7 +355,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_call_filter_missing_function() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"var Plugin = {};"#;
engine
@@ -365,7 +372,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_call_action() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"
var Plugin = {
@@ -392,7 +399,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_call_string_filter() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"
var Plugin = {
@@ -421,7 +428,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_unload_plugin() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"var Plugin = {};"#;
engine
@@ -436,7 +443,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_multiple_plugins() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
for i in 0..3 {
let code = format!(
@@ -453,7 +460,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_host_log_available() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"
var Plugin = {
@@ -472,7 +479,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_host_get_config_returns_value() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"
var Plugin = {
@@ -502,7 +509,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_syntax_error_fails_load() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let result = engine
.load_plugin_default("test-bad-syntax", "var !!!invalid!!!")
.await;
@@ -513,7 +520,7 @@ var Plugin = {
async fn js_engine_timeout_interrupts_long_execution() {
let mut config = (*test_config()).clone();
config.plugin_default_timeout_ms = 100;
let engine = JsEngine::new(&Arc::new(config), None).await.unwrap();
let engine = JsEngine::new(&Arc::new(config), None, None).await.unwrap();
let code = r#"
var Plugin = {
@@ -537,7 +544,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_filter_chain_multiple_plugins() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code_a = r#"
var Plugin = {
@@ -579,7 +586,7 @@ var Plugin = {
#[tokio::test]
async fn js_engine_action_exception_does_not_crash() {
let engine = JsEngine::new(&test_config(), None).await.unwrap();
let engine = JsEngine::new(&test_config(), None, None).await.unwrap();
let code = r#"
var Plugin = {
+22 -15
View File
@@ -52,10 +52,15 @@ pub struct LuaEngine {
config: Arc<AppConfig>,
pool: Option<Pool>,
pool_size: usize,
event_bus: Option<crate::eventbus::EventBus>,
}
impl LuaEngine {
pub fn new(config: &AppConfig, pool: Option<Pool>) -> anyhow::Result<Self> {
pub fn new(
config: &AppConfig,
pool: Option<Pool>,
event_bus: Option<crate::eventbus::EventBus>,
) -> anyhow::Result<Self> {
let pool_size = config.plugin_lua_pool_size.max(1) as usize;
Ok(Self {
pools: Mutex::new(HashMap::new()),
@@ -63,6 +68,7 @@ impl LuaEngine {
config: Arc::new(config.clone()),
pool,
pool_size,
event_bus,
})
}
@@ -89,6 +95,7 @@ impl LuaEngine {
plugin_id.to_string(),
permissions.clone(),
self.pool.clone(),
self.event_bus.clone(),
)?;
lua.load(code).exec()?;
Ok(lua)
@@ -264,13 +271,13 @@ mod tests {
#[tokio::test]
async fn lua_engine_create() {
let engine = LuaEngine::new(&test_config(), None);
let engine = LuaEngine::new(&test_config(), None, None);
assert!(engine.is_ok());
}
#[tokio::test]
async fn lua_engine_load_and_call_filter() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let code = r#"
Plugin = {
@@ -299,7 +306,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_call_filter_missing_plugin() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let result: Option<serde_json::Value> = engine
.call_filter("nonexistent", "on_post_creating", &serde_json::json!({}))
.await
@@ -309,7 +316,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_call_filter_missing_function() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
engine
.load_plugin_default("test-nofunc", "Plugin = {}")
.await
@@ -324,7 +331,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_call_action() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let code = r#"
Plugin = {
@@ -350,7 +357,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_call_string_filter() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let code = r#"
Plugin = {
@@ -379,7 +386,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_unload_plugin() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
engine
.load_plugin_default("test-unload", "Plugin = {}")
.await
@@ -392,7 +399,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_multiple_plugins() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
for i in 0..3 {
let code = format!(
@@ -409,7 +416,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_syntax_error_fails_load() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let result = engine
.load_plugin_default("test-bad", "function !!!invalid!!!")
.await;
@@ -420,7 +427,7 @@ Plugin = {
async fn lua_engine_timeout_interrupts_long_execution() {
let mut config = (*test_config()).clone();
config.plugin_default_timeout_ms = 100;
let engine = LuaEngine::new(&Arc::new(config), None).unwrap();
let engine = LuaEngine::new(&Arc::new(config), None, None).unwrap();
let code = r#"
Plugin = {
@@ -444,7 +451,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_action_exception_does_not_crash() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let code = r#"
Plugin = {
@@ -470,7 +477,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_host_get_config_returns_value() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let code = r#"
Plugin = {
@@ -500,7 +507,7 @@ Plugin = {
#[tokio::test]
async fn lua_engine_no_io_os_libs() {
let engine = LuaEngine::new(&test_config(), None).unwrap();
let engine = LuaEngine::new(&test_config(), None, None).unwrap();
let code = r#"
Plugin = {}
@@ -516,7 +523,7 @@ if debug ~= nil then error("debug should not be available") end
async fn lua_engine_memory_limit_enforced() {
let mut config = (*test_config()).clone();
config.plugin_max_memory_mb = 1;
let engine = LuaEngine::new(&Arc::new(config), None).unwrap();
let engine = LuaEngine::new(&Arc::new(config), None, None).unwrap();
let code = r#"
local t = {}
+6 -2
View File
@@ -40,8 +40,8 @@ impl Host for Arc<HostContext> {
(**self).get_post(&slug)
}
fn db_query(&mut self, sql: String) -> String {
(**self).db_query(&sql)
fn db_query(&mut self, sql: String, params: Option<String>) -> String {
(**self).db_query(&sql, params.as_deref())
}
fn db_execute(&mut self, sql: String, params: Option<String>) -> String {
@@ -86,4 +86,8 @@ impl Host for Arc<HostContext> {
fn fs_stat(&mut self, path: String) -> Option<String> {
(**self).fs_stat(&path).ok()
}
fn emit_event(&mut self, event_type: String, data: String) -> String {
(**self).emit_event(&event_type, &data)
}
}
+109 -48
View File
@@ -11,6 +11,7 @@ use crate::db::Pool;
use crate::plugins::Permissions;
use crate::plugins::permissions::PermissionChecker;
use crate::plugins::vfs::VirtualFs;
use crate::eventbus::{EventBus, Event};
use sqlx::Arguments;
use std::sync::Mutex;
@@ -31,6 +32,7 @@ pub struct HostContext {
pool: Option<Pool>,
tx: Mutex<Option<TxState>>,
vfs: Option<Arc<VirtualFs>>,
event_bus: Option<EventBus>,
}
impl Clone for HostContext {
@@ -43,6 +45,7 @@ impl Clone for HostContext {
pool: self.pool.clone(),
tx: Mutex::new(None),
vfs: self.vfs.clone(),
event_bus: self.event_bus.clone(),
}
}
}
@@ -65,9 +68,15 @@ impl HostContext {
pool,
tx: Mutex::new(None),
vfs: None,
event_bus: None,
}
}
/// 设置事件总线(在 PluginManager 初始化后调用)
pub fn set_event_bus(&mut self, bus: EventBus) {
self.event_bus = Some(bus);
}
/// 返回插件 ID
#[must_use]
pub fn plugin_id(&self) -> &str {
@@ -213,9 +222,12 @@ impl HostContext {
})
}
/// 执行只读 SQL 查询(返回 JSON 数组字符串)
/// 执行只读 SQL 查询(返回 JSON 数组字符串)。
///
/// 当 `params_json` 为 `Some` 时使用参数化查询(防 SQL 注入),
/// 为 `None` 时执行原始 SQL(向后兼容)。
#[must_use]
pub fn db_query(&self, sql: &str) -> String {
pub fn db_query(&self, sql: &str, params_json: Option<&str>) -> String {
if !PermissionChecker::is_readonly_query(sql) {
return "error: only SELECT queries are allowed".to_string();
}
@@ -232,11 +244,24 @@ impl HostContext {
if !PermissionChecker::is_table_readable(&self.permissions, &table) {
return format!("error: no read permission for table: {table}");
}
let parsed_params = Self::parse_params(params_json);
if let Err(e) = &parsed_params {
return format!(r#"{{"error":"invalid params: {e}"}}"#);
}
let handle = tokio::runtime::Handle::current();
let sql = crate::db::dialect::translate(sql).into_owned();
tokio::task::block_in_place(|| {
match handle.block_on(async {
let rows = sqlx::query(&sql).fetch_all(pool).await?;
let rows = match parsed_params.unwrap() {
Some(params) => {
let mut args = sqlx::sqlite::SqliteArguments::default();
for p in &params {
Self::add_param(&mut args, p);
}
sqlx::query_with(&sql, args).fetch_all(pool).await?
}
None => sqlx::query(&sql).fetch_all(pool).await?,
};
let json = crate::plugins::rows_to_json(&rows);
Ok::<_, sqlx::Error>(json)
}) {
@@ -270,23 +295,9 @@ impl HostContext {
if !PermissionChecker::is_table_writable(&self.permissions, &table) {
return format!(r#"{{"error":"no write permission for table: {table}"}}"#);
}
let parsed_params = match params_json {
Some(pj) if !pj.is_empty() => {
let params: Vec<serde_json::Value> = match serde_json::from_str(pj) {
Ok(p) => p,
Err(e) => return format!(r#"{{"error":"invalid params JSON: {e}"}}"#),
};
for p in &params {
if matches!(
p,
serde_json::Value::Array(_) | serde_json::Value::Object(_)
) {
return format!(r#"{{"error":"unsupported param type: {p}"}}"#);
}
}
Some(params)
}
_ => None,
let parsed_params = match Self::parse_params(params_json) {
Ok(p) => p,
Err(e) => return format!(r#"{{"error":"{e}"}}"#),
};
let tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner());
@@ -317,25 +328,7 @@ impl HostContext {
Some(params) => {
let mut args = sqlx::sqlite::SqliteArguments::default();
for p in &params {
match p {
serde_json::Value::String(s) => {
args.add(s.clone()).ok();
}
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
args.add(i).ok();
} else {
args.add(n.as_f64().unwrap_or(0.0)).ok();
}
}
serde_json::Value::Bool(b) => {
args.add(*b).ok();
}
serde_json::Value::Null => {
args.add(Option::<String>::None).ok();
}
_ => {}
}
Self::add_param(&mut args, p);
}
handle.block_on(async { sqlx::query_with(&sql, args).execute(pool).await })
}
@@ -477,6 +470,74 @@ impl HostContext {
let info = vfs.stat(path).map_err(|e| e.to_string())?;
serde_json::to_string(&info).map_err(|e| format!("error: {e}"))
}
/// 插件主动触发自定义事件,通过 EventBus 广播。
///
/// 其他插件可通过 Hook 监听,WebSocket 客户端也会收到推送。
/// 返回 `{"ok":true}` 或 `{"error":"..."}`。
#[must_use]
pub fn emit_event(&self, event_type: &str, data: &str) -> String {
match &self.event_bus {
Some(bus) => {
let data_value: serde_json::Value = serde_json::from_str(data)
.unwrap_or(serde_json::Value::String(data.to_string()));
bus.emit(Event::Custom {
source: self.plugin_id.clone(),
event_type: event_type.to_string(),
data: data_value,
});
tracing::info!(
"[plugin:{}] emitted custom event: {}",
self.plugin_id,
event_type
);
r#"{"ok":true}"#.to_string()
}
None => r#"{"error":"event bus not available"}"#.to_string(),
}
}
/// 解析参数 JSON 为 Vec<serde_json::Value>
fn parse_params(
params_json: Option<&str>,
) -> Result<Option<Vec<serde_json::Value>>, String> {
match params_json {
Some(pj) if !pj.is_empty() => {
let params: Vec<serde_json::Value> = serde_json::from_str(pj)
.map_err(|e| format!("invalid params JSON: {e}"))?;
for p in &params {
if matches!(p, serde_json::Value::Array(_) | serde_json::Value::Object(_)) {
return Err(format!("unsupported param type: {p}"));
}
}
Ok(Some(params))
}
_ => Ok(None),
}
}
/// 将单个 JSON Value 添加到 sqlx 参数列表
fn add_param(args: &mut sqlx::sqlite::SqliteArguments, p: &serde_json::Value) {
match p {
serde_json::Value::String(s) => {
args.add(s.clone()).ok();
}
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
args.add(i).ok();
} else {
args.add(n.as_f64().unwrap_or(0.0)).ok();
}
}
serde_json::Value::Bool(b) => {
args.add(*b).ok();
}
serde_json::Value::Null => {
args.add(Option::<String>::None).ok();
}
_ => {}
}
}
}
/// 在连接上执行参数化或原始写操作
@@ -585,7 +646,7 @@ mod tests {
fn host_context_db_query_rejects_non_select() {
let config = make_test_config();
let ctx = HostContext::new("test", config, "p1".into(), Permissions::default(), None);
let result = ctx.db_query("DELETE FROM posts");
let result = ctx.db_query("DELETE FROM posts", None);
assert!(result.contains("error"));
assert!(!result.contains("status"));
}
@@ -615,7 +676,7 @@ mod tests {
fn host_context_db_query_returns_error_without_pool() {
let config = make_test_config();
let ctx = HostContext::new("test", config, "p1".into(), Permissions::default(), None);
let result = ctx.db_query("SELECT 1");
let result = ctx.db_query("SELECT 1", None);
assert!(result.contains("no database access"));
}
@@ -653,11 +714,11 @@ mod tests {
let config = make_test_config();
let ctx = HostContext::new("test", config, "p1".into(), Permissions::default(), None);
assert!(
ctx.db_query("INSERT INTO posts VALUES(1)")
ctx.db_query("INSERT INTO posts VALUES(1)", None)
.contains("error")
);
assert!(ctx.db_query("UPDATE posts SET title='x'").contains("error"));
assert!(ctx.db_query("DELETE FROM posts").contains("error"));
assert!(ctx.db_query("UPDATE posts SET title='x'", None).contains("error"));
assert!(ctx.db_query("DELETE FROM posts", None).contains("error"));
}
#[test]
@@ -669,7 +730,7 @@ mod tests {
};
// No pool → first check is "no database access", but even with pool it should fail
let ctx = HostContext::new("test", config, "p1".into(), perms, None);
let result = ctx.db_query("SELECT * FROM posts");
let result = ctx.db_query("SELECT * FROM posts", None);
assert!(result.contains("no database access"));
}
@@ -787,7 +848,7 @@ mod tests {
let config = make_test_config();
let ctx = HostContext::new("test", config, "p1".into(), perms, Some(pool));
let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts");
let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts", None);
assert!(!result.contains("error"));
assert!(result.contains("cnt"));
}
@@ -807,7 +868,7 @@ mod tests {
let config = make_test_config();
let ctx = HostContext::new("test", config, "p1".into(), perms, Some(pool));
let result = ctx.db_query("SELECT * FROM posts");
let result = ctx.db_query("SELECT * FROM posts", None);
assert!(result.contains("no read permission"));
}
@@ -826,7 +887,7 @@ mod tests {
let config = make_test_config();
let ctx = HostContext::new("test", config, "p1".into(), perms, Some(pool));
let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts");
let result = ctx.db_query("SELECT COUNT(*) as cnt FROM posts", None);
assert!(!result.contains("error"));
}
+34 -18
View File
@@ -19,11 +19,17 @@ pub fn register_host_functions(
plugin_id: String,
permissions: Permissions,
pool: Option<Pool>,
event_bus: Option<crate::eventbus::EventBus>,
) -> rquickjs::Result<()> {
let global = ctx.globals();
let host = Object::new(ctx.clone())?;
let host_ctx = Arc::new(HostContext::new("js", config, plugin_id, permissions, pool));
let mut hc_inner =
HostContext::new("js", config, plugin_id, permissions, pool);
if let Some(bus) = event_bus {
hc_inner.set_event_bus(bus);
}
let host_ctx = Arc::new(hc_inner);
let hc = host_ctx.clone();
let log_fn = Function::new(ctx.clone(), move |level: String, msg: String| {
@@ -68,9 +74,12 @@ pub fn register_host_functions(
host.set("getPost", get_post_fn)?;
let hc = host_ctx.clone();
let db_query_fn = Function::new(ctx.clone(), move |sql: String| -> String {
hc.db_query(&sql)
})?;
let db_query_fn = Function::new(
ctx.clone(),
move |sql: String, params: Option<String>| -> String {
hc.db_query(&sql, params.as_deref())
},
)?;
host.set("dbQuery", db_query_fn)?;
let hc = host_ctx.clone();
@@ -124,12 +133,19 @@ pub fn register_host_functions(
})?;
host.set("fsList", fs_list_fn)?;
let hc = host_ctx;
let fs_stat_fn = Function::new(ctx, move |path: String| -> Option<String> {
let hc = host_ctx.clone();
let fs_stat_fn = Function::new(ctx.clone(), move |path: String| -> Option<String> {
hc.fs_stat(&path).ok()
})?;
host.set("fsStat", fs_stat_fn)?;
let hc = host_ctx;
let emit_event_fn =
Function::new(ctx, move |event_type: String, data: String| -> String {
hc.emit_event(&event_type, &data)
})?;
host.set("emitEvent", emit_event_fn)?;
global.set("Host", host)?;
Ok(())
}
@@ -151,7 +167,7 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
@@ -178,7 +194,7 @@ mod tests {
};
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
@@ -202,7 +218,7 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
@@ -223,7 +239,7 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
@@ -244,7 +260,7 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
@@ -265,7 +281,7 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
@@ -286,7 +302,7 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
@@ -307,14 +323,14 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
let host: Object = global.get("Host").unwrap();
let db_fn: Function = host.get("dbQuery").unwrap();
let result: String = db_fn.call(("SELECT 1",)).unwrap();
let result: String = db_fn.call(("SELECT 1", rquickjs::Undefined)).unwrap();
assert!(result.contains("no database access"));
})
.await;
@@ -328,14 +344,14 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
let host: Object = global.get("Host").unwrap();
let db_fn: Function = host.get("dbQuery").unwrap();
let result: String = db_fn.call(("DELETE FROM posts",)).unwrap();
let result: String = db_fn.call(("DELETE FROM posts", rquickjs::Undefined)).unwrap();
assert!(result.contains("only SELECT"));
})
.await;
@@ -349,7 +365,7 @@ mod tests {
let perms = Permissions::default();
ctx.with(|ctx| {
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None)
register_host_functions(ctx.clone(), config, "test-plugin".into(), perms, None, None)
.unwrap();
let global = ctx.globals();
+32 -22
View File
@@ -9,8 +9,8 @@ use mlua::Lua;
use crate::config::app::AppConfig;
use crate::db::Pool;
use crate::plugins::Permissions;
use crate::plugins::host_common::HostContext;
use crate::plugins::Permissions;
/// 注册宿主函数到 Lua 全局作用域。
pub fn register_host_functions(
@@ -19,17 +19,16 @@ pub fn register_host_functions(
plugin_id: String,
permissions: Permissions,
pool: Option<Pool>,
event_bus: Option<crate::eventbus::EventBus>,
) -> anyhow::Result<()> {
let globals = lua.globals();
let host = lua.create_table()?;
let host_ctx = Arc::new(HostContext::new(
"lua",
config,
plugin_id,
permissions,
pool,
));
let mut hc_inner = HostContext::new("lua", config, plugin_id, permissions, pool);
if let Some(bus) = event_bus {
hc_inner.set_event_bus(bus);
}
let host_ctx = Arc::new(hc_inner);
let hc = host_ctx.clone();
let log_fn = lua.create_function(move |_, (level, msg): (String, String)| {
@@ -79,9 +78,12 @@ pub fn register_host_functions(
host.set("getPost", get_post_fn)?;
let hc = host_ctx.clone();
let db_query_fn = lua.create_function(move |lua, sql: String| {
Ok(mlua::Value::String(lua.create_string(hc.db_query(&sql))?))
})?;
let db_query_fn =
lua.create_function(move |lua, (sql, params): (String, Option<String>)| {
Ok(mlua::Value::String(
lua.create_string(hc.db_query(&sql, params.as_deref()))?,
))
})?;
host.set("dbQuery", db_query_fn)?;
let hc = host_ctx.clone();
@@ -151,13 +153,21 @@ pub fn register_host_functions(
})?;
host.set("fsList", fs_list_fn)?;
let hc = host_ctx;
let hc = host_ctx.clone();
let fs_stat_fn = lua.create_function(move |lua, path: String| match hc.fs_stat(&path) {
Ok(json) => Ok(mlua::Value::String(lua.create_string(&json)?)),
Err(_) => Ok(mlua::Value::Nil),
})?;
host.set("fsStat", fs_stat_fn)?;
let hc = host_ctx;
let emit_event_fn = lua.create_function(move |lua, (event_type, data): (String, String)| {
Ok(mlua::Value::String(
lua.create_string(hc.emit_event(&event_type, &data))?,
))
})?;
host.set("emitEvent", emit_event_fn)?;
globals.set("Host", host)?;
Ok(())
}
@@ -183,7 +193,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -204,7 +214,7 @@ mod tests {
config: vec!["app.*".into()],
..Permissions::default()
};
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -225,7 +235,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -240,7 +250,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -255,7 +265,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -270,7 +280,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -285,7 +295,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -300,7 +310,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -315,7 +325,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
@@ -330,7 +340,7 @@ mod tests {
let lua = create_sandboxed_lua();
let config = make_test_config();
let perms = Permissions::default();
register_host_functions(&lua, config, "test-plugin".into(), perms, None).unwrap();
register_host_functions(&lua, config, "test-plugin".into(), perms, None, None).unwrap();
let globals = lua.globals();
let host: mlua::Table = globals.get("Host").unwrap();
+1
View File
@@ -152,6 +152,7 @@ async fn build_app(config: &AppConfig, limiters: RateLimiterSet) -> anyhow::Resu
Arc::new(config.clone()),
crate::plugins::PluginManagerOptions {
pool: Some(pool.clone()),
event_bus: Some(eventbus.clone()),
},
)
.await;
+1 -1
View File
@@ -64,7 +64,7 @@ mod tests {
let config = Arc::new(crate::config::app::AppConfig::test_defaults());
let mgr = PluginManager::new_with_options(
config,
crate::plugins::PluginManagerOptions { pool: None },
crate::plugins::PluginManagerOptions { pool: None, event_bus: None },
)
.await;
let dispatcher = PluginCronDispatcher::new(mgr);