mirror of
https://github.com/RaisFast/raisfast.git
synced 2026-09-26 00:02:19 +00:00
improve extensions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -97,6 +97,13 @@ pub enum Event {
|
||||
email: String,
|
||||
verify_token: String,
|
||||
},
|
||||
|
||||
// ── 插件自定义事件 ──
|
||||
Custom {
|
||||
source: String,
|
||||
event_type: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// 事件订阅者
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ¶ms {
|
||||
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 ¶ms {
|
||||
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 ¶ms {
|
||||
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 ¶ms {
|
||||
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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user