From 9a139e44b7c139af84a443bd97178dbb2ed5741c Mon Sep 17 00:00:00 2001 From: luk3yx Date: Sat, 20 Jul 2019 15:01:22 +1200 Subject: [PATCH] Initial commit --- LICENSE.md | 22 +++ README.md | 49 +++++++ console.lua | 238 +++++++++++++++++++++++++++++++ core.lua | 268 +++++++++++++++++++++++++++++++++++ init.lua | 20 +++ nodes.lua | 49 +++++++ persistence.lua | 56 ++++++++ textures/snippets_button.png | Bin 0 -> 211 bytes 8 files changed, 702 insertions(+) create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 console.lua create mode 100644 core.lua create mode 100644 init.lua create mode 100644 nodes.lua create mode 100644 persistence.lua create mode 100644 textures/snippets_button.png diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..61e68fa --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ + +# The MIT License (MIT) + +Copyright © 2019 by luk3yx. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1fed7d --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Minetest snippets mod + +A way for admins to run and save lua snippets. + +More documentation coming soon. + +## API + + - `snippets.register_snippet(name, )`: Registers a snippet. + `def` can be a table containing `code` (or `func`), and optionally `owner`. + If `persistent` is specified, this snippet will remain registered across + reboots. + - `snippets.unregister_snippet(name)`: The opposite of + `snippets.register_snippet`. + - `snippets.registered_snippets`: A table containing the above snippets. + - `snippets.log(level, msg)`: For use inside snippets: Logs a message. `level` + can be `none`, `debug`, `info`, `warning`, or `error`. + - `snippets.register_on_log(function(snippet, level, msg))`: Run when + snippets.log is called. `snippet` is the name of the snippet. Newest + functions are called first. If a callback returns `true`, any remaining + functions are not called (including the built-in log function). Callbacks + can check what player (if any) owns a snippet with + `snippets.registered_snippets[snippet].owner`. + - `snippets.log_levels`: A table containing functions that run + `minetest.colorize` on log levels (if applicable). + Example: `snippets.log_levels.error('Hello')` → + `minetest.colorize('red', 'Hello')` + - `snippets.exec_as_player(player_or_name, code)`: Executes `code` (a string) + inside an "anonymous snippet" owned by the player. + - `snippets.exec(code)`: Executes `code` inside a generic snippet. + - `snippets.run(name, ...)`: Executes a snippet. + +## Example snippets + +`get_connected_names`: +```lua +local res = {} +for _, player in ipairs(minetest.get_connected_players()) do + table.insert(res, player:get_player_name()) +end +return res +``` + +`greeting_test`: +```lua +for _, name in ipairs(snippets.run 'get_connected_names') do + minetest.chat_send_player(name, 'Hello ' .. name .. '!') +end +``` diff --git a/console.lua b/console.lua new file mode 100644 index 0000000..376f95c --- /dev/null +++ b/console.lua @@ -0,0 +1,238 @@ +-- +-- Snippet console - Allows players to create and edit persistent snippets +-- + +local snippet_list = {} +local selected_snippet = {} +local console_code = {} +local console_text = {} + +minetest.register_on_leaveplayer(function(player) + local name = player:get_player_name() + if snippet_list[name] then + snippet_list[name] = nil + selected_snippet[name] = nil + console_code[name] = nil + console_text[name] = nil + end +end) + +function snippets.show_console(name) + local formspec = 'size[14,10]' .. + 'label[0,0;My snippets]' .. + 'textlist[0,0.5;3.5,7.4;snippetlist;#aaaaaaNew snippet' + + snippet_list[name] = {} + for k, v in pairs(snippets.registered_snippets) do + if v.persistent then + table.insert(snippet_list[name], k) + end + end + table.sort(snippet_list[name]) + + local selected = 0 + local unsaved = false + for id, snippet in ipairs(snippet_list[name]) do + formspec = formspec .. ',##' .. minetest.formspec_escape(snippet) + if snippet == selected_snippet[name] then + selected = id + local def = snippets.registered_snippets[snippet] + if (def and def.code or '') ~= console_code[name] then + formspec = formspec .. ' (unsaved)' + end + end + end + + formspec = formspec .. ';' .. tostring(selected + 1) .. ']' .. + 'button[0,8.1;3.7,0.75;save;Save]' .. + 'button[0,8.85;3.7,0.75;save_as;Save as]' .. + 'button_exit[0,9.6;3.7,0.75;quit;Quit]' + + formspec = formspec .. + 'textlist[3.9,6.01;10,4.04;ignore;' + if console_text[name] then + if #console_text[name] > 0 then + for id, msg in ipairs(console_text[name]) do + if id > 1 then formspec = formspec .. ',' end + formspec = formspec .. minetest.formspec_escape(msg) + end + formspec = formspec .. ',;' .. (#console_text[name] + 1) + else + formspec = formspec .. ';1' + end + formspec = formspec .. + ']button[3.9,5.14;10.21,0.81;reset;Reset]' .. + 'box[3.9,0.4;10,4.5;#ffffff]' + else + formspec = formspec .. ';1]' .. + 'button[3.9,5.14;10.21,0.81;run;Run]' + end + + if not console_code[name] then console_code[name] = '' end + local code = minetest.formspec_escape(console_code[name]) + if code == '' and console_text[name] then code = '(no code)' end + + local snippet, owner + if selected_snippet[name] then + snippet = minetest.colorize('#aaa', selected_snippet[name]) + else + snippet = minetest.colorize('#888', 'New snippet') + end + + local def = snippets.registered_snippets[selected_snippet[name]] + if def and def.owner then + owner = minetest.colorize('#aaa', def.owner) + elseif selected_snippet[name] then + owner = minetest.colorize('#888', 'none') + else + owner = minetest.colorize('#aaa', name) + end + + formspec = formspec .. ']textarea[4.2,0.4;10.2,5.31;' .. + (console_text[name] and '' or 'code') .. ';Snippet: ' .. + minetest.formspec_escape(snippet .. ', owner: ' .. owner) .. ';' .. + code .. ']' + + minetest.show_formspec(name, 'snippets:console', formspec) +end + +function snippets.push_console_msg(name, msg, col) + if not col or col:sub(1, 1) ~= '#' or #col ~= 7 then + col = '##' + end + + if console_text[name] then + table.insert(console_text[name], col .. tostring(msg)) + snippets.show_console(name) + end +end + +snippets.register_on_log(function(snippet, level, msg) + local owner = snippets.registered_snippets[snippet].owner + if not owner or not console_text[owner] then return end + if level ~= 'none' then + msg = level:sub(1, 1):upper() .. level:sub(2) .. ': ' .. msg + end + + local col + if level == 'warning' then + col = '#FFFF00' + elseif level == 'error' then + col = '#FF0000' + elseif level == 'debug' then + col = '#888888' + end + + local p = snippet:sub(1, 16) == 'snippets:player_' + if not p then msg = 'From snippet "' .. snippet .. '": ' .. msg end + + snippets.push_console_msg(owner, msg, col) + + if p then return true end +end) + +minetest.register_chatcommand('snippets', { + description = 'Opens the snippets console.', + privs = {server=true}, + func = function(name, param) + snippets.show_console(name) + return true, 'Opened the snippets console.' + end, +}) + +minetest.register_on_player_receive_fields(function(player, formname, fields) + if formname ~= 'snippets:console' and + formname ~= 'snippets:console_save_as' then + return + end + local name = player:get_player_name() + + -- Sanity check + if not minetest.check_player_privs(name, 'server') then + if console_text[name] then + console_text[name] = nil + minetest.close_formspec(name, 'snippets:console') + elseif not fields.quit then + minetest.kick_player(name, + 'You appear to be using a "hacked" client.') + end + return + elseif not console_code[name] then + return + end + + -- Handle "Save as" + if formname == 'snippets:console_save_as' then + if not fields.filename or fields.filename == '' then + minetest.chat_send_player(name, 'Save operation cancelled.') + snippets.show_console(name) + return + end + + -- Don't overwrite non-persistent snippets + local filename = fields.filename:gsub(':', '/') + while snippets.registered_snippets[filename] and + not snippets.registered_snippets[filename].persistent do + filename = filename .. '_' + end + + -- Actually save it + snippets.register_snippet(filename, { + owner = name, + code = console_code[name], + persistent = true, + }) + + selected_snippet[name] = filename + snippets.show_console(name) + return + end + + if fields.code then console_code[name] = fields.code end + + if fields.ignore then + return + elseif fields.run then + local code = fields.code + console_text[name] = {} + snippets.show_console(name) + if not code or code == '' then return end + local good, msg = loadstring('return ' .. code) + if good then code = 'return ' .. code end + local res = snippets.exec_as_player(name, code) + if res ~= nil then + snippets.push_console_msg(name, res) + end + elseif fields.reset then + console_text[name] = nil + snippets.show_console(name) + elseif fields.snippetlist and snippet_list[name] then + local event = minetest.explode_textlist_event(fields.snippetlist) + local selected = snippet_list[name][event.index - 1] + if selected_snippet[name] == selected then return end + selected_snippet[name] = selected + if console_text[name] then console_text[name] = nil end + local def = snippets.registered_snippets[selected] + console_code[name] = def and def.code or '' + snippets.show_console(name) + elseif fields.save and selected_snippet[name] then + if console_code[name] == '' then + snippets.unregister_snippet(selected_snippet[name]) + selected_snippet[name] = nil + else + snippets.register_snippet(selected_snippet[name], { + owner = name, + code = console_code[name], + persistent = true, + }) + end + snippets.show_console(name) + elseif fields.save or fields.save_as and console_code[name] ~= '' then + console_text[name] = nil + minetest.show_formspec(name, 'snippets:console_save_as', + 'field[filename;Please enter a new snippet name.;]') + elseif fields.quit then + -- console_code[name] = nil + console_text[name] = nil + end +end) diff --git a/core.lua b/core.lua new file mode 100644 index 0000000..79d4fb7 --- /dev/null +++ b/core.lua @@ -0,0 +1,268 @@ +-- +-- Minetest snippets mod: Attempt to prevent snippets from crashing the server +-- + +-- Make loadstring a local variable +local loadstring +if minetest.global_exists('loadstring') then + loadstring = _G.loadstring +else + loadstring = assert(load) +end + +local copy = table.copy +local safe_funcs = {} +local orig_funcs, running_snippet + +function snippets.get_current_snippet() + if running_snippet then return copy(running_snippet) end +end + +-- Apply "safe functions": These wrap normal registration functions so that +-- snippets can't crash them as easily. +local function apply_safe_funcs() + if orig_funcs then return end + orig_funcs = {} + for k, v in pairs(safe_funcs) do + if k ~= 'print' then + orig_funcs[k] = minetest[k] + minetest[k] = v + end + end + orig_funcs.print, print = print, safe_funcs.print +end + +local function remove_safe_funcs() + if not orig_funcs then return end + for k, v in pairs(orig_funcs) do + minetest[k] = orig_funcs[k] + end + print = orig_funcs.print + orig_funcs = nil +end + +-- "Break out" of wrapped functions. +local function wrap_unsafe(func) + return function(...) + if orig_funcs then + remove_safe_funcs() + local res = {func(...)} + apply_safe_funcs() + return (table.unpack or unpack)(res) + else + return func(...) + end + end +end + +-- Logging +snippets.registered_on_log = {} +snippets.log_levels = {} +function snippets.log_levels.error(n) + return minetest.colorize('red', n) +end +function snippets.log_levels.warning(n) + return minetest.colorize('yellow', n) +end +function snippets.log_levels.info(n) + return n +end +snippets.log_levels.none = snippets.log_levels.info +function snippets.log_levels.debug(n) + return minetest.colorize('grey', n) +end + +function snippets.log(level, msg) + local snippet = running_snippet or 'snippets:anonymous' + if msg == nil then level, msg = 'none', level end + level, msg = tostring(level), tostring(msg) + + if level == 'warn' then + level = 'warning' + elseif not snippets.log_levels[level] then + level = 'none' + end + + for _, func in ipairs(snippets.registered_on_log) do + if func(snippet, level, msg) then return end + end +end +snippets.log = wrap_unsafe(snippets.log) + +function snippets.register_on_log(func) + assert(type(func) == 'function') + table.insert(snippets.registered_on_log, 1, func) +end + +-- Create the default log action +-- Only notify the player of errors or warnings +snippets.register_on_log(function(snippet, level, msg) + local rawmsg + if level == 'warning' then + rawmsg = 'Warning' + elseif level == 'error' then + rawmsg = 'Error' + else + return + end + + rawmsg = snippets.log_levels[level](rawmsg .. ' in snippet "' .. snippet .. + '": ' .. msg) + + local def = snippets.registered_snippets[snippet] + if def and def.owner then + minetest.chat_send_player(def.owner, rawmsg) + else + minetest.chat_send_all(rawmsg) + end +end) + +-- Create a safe print() +function safe_funcs.print(...) + local msg = '' + for i = 1, select('#', ...) do + if i > 1 then msg = msg .. '\t' end + msg = msg .. tostring(select(i, ...)) + end + snippets.log('none', msg) +end + +-- Mostly copied from https://stackoverflow.com/a/26367080 +local function wrap_raw(snippet, func, ...) + local old_running = running_snippet + running_snippet = snippet + local use_safe_funcs = not orig_funcs + if use_safe_funcs then apply_safe_funcs() end + local good, msg = pcall(func, ...) + if use_safe_funcs then remove_safe_funcs() end + if good then + running_snippet = old_running + return msg + else + snippets.log('error', msg) + running_snippet = old_running + end +end + +local function wrap(snippet, func) + if not snippet then return func end + return function(...) return wrap_raw(snippet, func, ...) end +end + +do + local after_ = minetest.after + function safe_funcs.after(after, func, ...) + after = tonumber(after) + assert(after and after == after, 'Invalid core.ater invocation') + after_(after, wrap_raw, running_snippet, func, ...) + end + + function snippets.wrap_register_on(orig) + return function(func, ...) + return orig(wrap(running_snippet, func), ...) + end + end + + for k, v in pairs(minetest) do + if type(k) == 'string' and k:sub(1, 12) == 'register_on_' then + safe_funcs[k] = snippets.wrap_register_on(v) + end + end +end + +-- Register a snippet +snippets.registered_snippets = {} +function snippets.register_snippet(name, def) + if def == nil and type(name) == 'table' then + name, def = name.name, name + elseif type(name) ~= 'string' then + error('Invalid name passed to snippets.register_snippet!', 2) + elseif type(def) == 'string' then + def = {code=def} + elseif type(def) ~= 'table' then + error('Invalid definition passed to snippets.register_snippet!', 2) + elseif def.owner and type(def.owner) ~= 'string' then + error('Invalid owner passed to snippets.register_snippet!', 2) + end + def = table.copy(def) + def.name = name + + if def.code then + local msg + def.func, msg = loadstring(def.code, name) + if def.func then + if name ~= 'snippets:anonymous' then + local old_def = snippets.registered_snippets[name] + def.env = old_def and old_def.env + end + if not def.env then + local g = {} + def.env = setmetatable({}, {__index = function(self, key) + local res = rawget(_G, key) + if res == nil and not g[key] then + snippets.log('warning', 'Undeclared global variable "' + .. key .. '" accessed.') + g[key] = true + end + return res + end}) + end + setfenv(def.func, def.env) + else + local r, s = running_snippet, snippets.registered_snippets[name] + function def.func() end + running_snippet, snippets.registered_snippets[name] = name, def + snippets.log('error', 'Load error: ' .. tostring(msg)) + running_snippet, snippets.registered_snippets[name] = r, s + end + else + def.persistent = nil + end + if not def.persistent then def.code = nil end + if type(def.func) ~= 'function' then return false end + + snippets.registered_snippets[name] = def + return true +end +snippets.register_snippet('snippets:anonymous', '') + +-- Run a snippet +function snippets.run(snippet, ...) + local def = snippets.registered_snippets[snippet] + if not def then error('Invalid snippet specified!', 2) end + return wrap_raw(snippet, def.func, ...) +end + +-- Run code as player +function snippets.exec_as_player(name, code) + if minetest.is_player(name) then name = name:get_player_name() end + local owner + if name and name ~= '' then + owner = name + name = 'snippets:player_' .. tostring(name) + else + name = 'snippets:anonymous' + end + + local def = { + code = tostring(code), + owner = owner, + } + if not snippets.register_snippet(name, def) then return end + + return snippets.run(name) +end + +function snippets.exec(code) return snippets.exec_as_player(nil, code) end + +minetest.register_on_leaveplayer(function(player) + snippets.registered_snippets['snippets:player_' .. + player:get_player_name()] = nil +end) + +-- In case console.lua isn't loaded +function snippets.unregister_snippet(name) + if snippets.registered_snippets[name] ~= nil then + snippets.registered_snippets[name] = nil + end +end diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..6de24f3 --- /dev/null +++ b/init.lua @@ -0,0 +1,20 @@ +-- +-- Minetest snippets mod: Allows admins to run a bunch of predefined snippets +-- + +assert(minetest.get_current_modname() == 'snippets') +snippets = {} + +local modpath = minetest.get_modpath('snippets') + +-- Load the core sandbox +dofile(modpath .. '/core.lua') + +-- Load persistence +loadfile(modpath .. '/persistence.lua')(minetest.get_mod_storage()) + +-- Load the "console" +dofile(modpath .. '/console.lua') + +-- Load "snippet buttons" +dofile(modpath .. '/nodes.lua') diff --git a/nodes.lua b/nodes.lua new file mode 100644 index 0000000..01f3856 --- /dev/null +++ b/nodes.lua @@ -0,0 +1,49 @@ +-- +-- Buttons that run snippets +-- + +minetest.register_node('snippets:button', { + description = 'Snippets button', + tiles = {'default_steel_block.png', 'default_steel_block.png', + 'default_steel_block.png^snippets_button.png'}, + groups = {cracky = 2}, + + on_construct = function(pos) + local meta = minetest.get_meta(pos) + meta:set_string('infotext', 'Unconfigured snippets button') + meta:set_string('formspec', 'field[snippet;Snippet to run:;]') + end, + + on_receive_fields = function(pos, formname, fields, sender) + if not fields.snippet or fields.snippet == '' then return end + + local name = sender:get_player_name() + if not minetest.check_player_privs(name, {server=true}) then + minetest.chat_send_player(name, 'Insufficient privileges!') + return + end + + local snippet = fields.snippet + if not snippets.registered_snippets[snippet] or + snippet:sub(1, 9) == 'snippets:' then + minetest.chat_send_player(name, 'Unknown snippet!') + else + local meta = minetest.get_meta(pos) + meta:set_string('snippet', snippet) + meta:set_string('infotext', 'Snippet: ' .. fields.snippet) + meta:set_string('formspec', '') + end + end, + + on_rightclick = function(pos, node, clicker, itemstack, pointed_thing) + local meta, name = minetest.get_meta(pos), clicker:get_player_name() + local snippet = meta:get_string('snippet') + if not snippet or snippet == '' then return end + if snippets.registered_snippets[snippet] then + snippets.run(snippet, name) + else + minetest.chat_send_player(name, 'Invalid snippet: "' .. snippet .. + '"') + end + end, +}) diff --git a/persistence.lua b/persistence.lua new file mode 100644 index 0000000..83c9653 --- /dev/null +++ b/persistence.lua @@ -0,0 +1,56 @@ +-- +-- Persistent snippets +-- + +-- Get storage +local storage = ... +assert(storage) + +-- Load persistent snippets +local register_snippet_raw = snippets.register_snippet +do + for name, def in pairs(storage:to_table().fields) do + if name:sub(1, 1) == '>' then + def = minetest.deserialize(def) + if def then + def.persistent = true + register_snippet_raw(name:sub(2), def) + end + end + end +end + +-- Override snippets.register_snippet so it accepts the "persistent" field. +function snippets.register_snippet(name, def) + if def == nil and type(name) == 'table' then + name, def = name.name, name + end + + -- Fix tracebacks + local good, msg = pcall(register_snippet_raw, name, def) + if not good then error(msg, 2) end + if not msg then return msg end + + -- Check for def.persistent + def = snippets.registered_snippets[name] + if type(def) == 'table' and def.persistent and def.code then + print('Saving snippet', name) + storage:set_string('>' .. name, minetest.serialize({ + code = def.code, + owner = def.owner, + })) + end + + -- Return the same value as register_snippet_raw. + return msg +end + +-- Override snippets.unregister_snippet +local unregister_snippet_raw = snippets.unregister_snippet +function snippets.unregister_snippet(name) + local def = snippets.registered_snippets[name] + if def and def.persistent then + storage:set_string('>' .. name, '') + end + return unregister_snippet_raw(name) +end diff --git a/textures/snippets_button.png b/textures/snippets_button.png new file mode 100644 index 0000000000000000000000000000000000000000..2df819ce3b40dde49f245a78f0cef8b77070713a GIT binary patch literal 211 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkEYGO%h zib8p2Nrr;Er*A-tUMf3K+}zW}F~s6@a>4=u1E-h&pS((6bcI!e?a%-J^NpA#Bqcq~ tSPrxH^z;;%8Mg6ma*#1iSRv(L$iR@kS5T