commit 9a139e44b7c139af84a443bd97178dbb2ed5741c Author: luk3yx Date: Sat Jul 20 15:01:22 2019 +1200 Initial commit 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 0000000..2df819c Binary files /dev/null and b/textures/snippets_button.png differ