added authentication mod for subnasa huge manage of users as first step

* using https://codeberg.org/minenux/minetest-mod-formspecs
* using https://codeberg.org/minenux/minetest-mod-auth_rx
* changes:
** provide a way to initialize files if there is not one
   currently doe snot touch the auth.txt file neither converted
** solves: close: https://codeberg.org/minenux/minetest-mod-auth_rx/issues/6
** solved: close: https://bitbucket.org/sorcerykid/auth_rx/issues/7
** init the files when are fresh install, still do not convert from auth.txt
** player object check for problematic joins on inpcomplete auth process
** close fixed https://codeberg.org/minenux/minetest-mod-auth_rx/issues/2
** added missing depends formspecs (it work without in basics but, some commands needs)
** we will later aded formspecs checks to made optional
master
Герхард PICCORO Lenz McKAY 2022-02-10 18:19:33 -04:00
parent f5c992c054
commit cfb83aab12
24 changed files with 4697 additions and 0 deletions

View File

@ -8,6 +8,8 @@ For further information, check https://codeberg.org/minenux/minetest-game-subna
* Informacion español: [GAMEINFO-es.md](GAMEINFO-es.md), **ADVERTENCIA: si se cuestiona porque no esta el mod "x" en vez de el mod "y" lease el archivo de informacion en la seccion inicial**
* Информация русском: [GAMEINFO-ru.md](GAMEINFO-ru.md), **ВНИМАНИЕ: если у вас возник вопрос, почему нет мода "x" вместо мода "y" прочитайте информационный файл в начальном разделе**
![screenshot.png](screenshot.png)
## Installation
For specific minetest installation check [docs/INSTALL.md](docs/INSTALL.md)
@ -33,6 +35,12 @@ To download you can play this game with the following minetest engines:
* https://github.com/minenux/minetest-engine-luk3yx/tree/stable-4.1
* https://github.com/MultiCraft/MultiCraft-legacy/releases/tag/v1.10.0
#### Mods
* minetest default
* minetest Auth Redux as `auth_rx` [mods/auth_rx](mods/auth_rx) from https://codeberg.org/minenux/minetest-mod-auth_rx
** so then minetest Formspecs as `formspecs` [mods/formspecs](mods/formspecs) from https://codeberg.org/minenux/minetest-mod-formspecs
## Licensing
See `LICENSE.txt`

159
mods/auth_rx/README.txt Normal file
View File

@ -0,0 +1,159 @@
Auth Redux Mod v2.13
By Leslie Krause
Auth Redux is a drop-in replacement for the builtin authentication handler of Minetest.
It is designed from the ground up to be robust and secure enough for use on high-traffic
Minetest servers, while also addressing a number of outstanding bugs (including #5334
and #6783 and #4451) in the Minetest 0.4.x engine.
Auth Redux is intended to be compatible with all versions of Minetest 0.4.14+.
https://forum.minetest.net/viewtopic.php?f=9&t=20393
Repository
----------------------
Browse source code:
https://bitbucket.org/sorcerykid/auth_rx
Download archive:
https://bitbucket.org/sorcerykid/auth_rx/get/master.zip
https://bitbucket.org/sorcerykid/auth_rx/get/master.tar.gz
Revision History
----------------------
Version 2.1b (30-Jun-2018)
- initial beta version
- included code samples for basic login filtering
- included a command-line database import script
Version 2.2b (04-Jul-2018)
- added install option to database import script
- improved exception handling by AuthFilter class
- fixed parsing of number literals in rulesets
- fixed type-checking of try statements in rulesets
- included mod.conf and description.txt files
Version 2.3b (08-Jul-2018)
- general code cleanup of AuthFilter class
- moved datasets into separate directory of world
- added two more comparison operators for rulesets
- tweaked pattern matching behavior in rulesets
- changed database search method to use Lua regexes
- removed hard-coded file names from database methods
Version 2.4b (13-Jul-2018)
- moved Journal and AuthDatabase classes into library
- added rollback function to AuthDatabase class
- reworked journal audit to support rollback option
- better encapsulated database commit function
- allowed for STOPPED opcode during database update
- various changes to error and action messages
- moved command-line scripts to separate directory
- included script to rollback database via journal
- included script to extract debug log into journal
Version 2.5b (17-Jul-2018)
- implemented function parsing algorithm for rulesets
- simplified operand matching logic in rulesets
- improved transcoding of literals in rulesets
- added some basic functions for use by rulesets
- fixed validation of dataset names in rulesets
Version 2.6b (19-Jul-2018)
- introduced support for array literals in rulesets
- added array-related functions for use by rulesets
- localized references to transcoding functions
- registered chat command to control login filtering
- included support for disabling login filtering
- added reload function to AuthFilter class
- tweaked lexer to skip comments on ruleset loading
- added search function to AuthDatabase class
Version 2.7b (22-Jul-2018)
- implemented time and date datatypes for rulesets
- updated code samples with latest feature-set
- added time-related functions for use by rulesets
- added time-related variables for use by rulesets
- minor formatting fixes to source code
- optimized comparison algorithm in ruleset parser
Version 2.8b (24-Jul-2018)
- introduced support for numeric patterns in rulesets
- created polymorphic pattern-matching classes
- excluded time-zone offsets in time/date comparisons
- standardized timestamps for use in filter functions
- removed daylight saving time from preset variables
- updated comparison algorithm in ruleset parser
- passed preset variables array to filter functions
Version 2.9b (26-Jul-2018)
- implemented address datatype for rulesets
- added more helper functions for use by rulesets
- fixed missing syntax checks in ruleset parser
- developed and integrated AuthWatchdog class
- added meta-variables for stateful login filtering
Version 2.10b (29-Jul-2018)
- major code reorganization via multiple libraries
- removed extra variables from AuthFilter class
- developed interactive debugger for testing rulesets
- added optional debugger hooks in AuthFilter class
- allowed for overriding preset variables by debugger
- included line-number in results of login filter
- added missing preset variable needed by rulesets
Version 2.11 (04-Aug-2018)
- developed a command-line player analytics script
Version 2.12 (10-Aug-2018)
- better code consolidation of AuthFilter class
- reworked ruleset parser to support syntax changes
Version 2.13 (20-Aug-2018)
- developed in-game database management console
- combined generic filter functions into superclass
- updated debugger to use new GenericFilter class
- added flag for constant-value operands in rulesets
- simplified operand matching logic in rulesets
- optimized comparison algorithm in ruleset parser
- consolidated lookup tables of lexical analyzer
- fixed erroneous status message shown in debugger
- added support for per-player debugging sessions
- redesigned login filter APIs for new architecture
- switched order of return values in login filter
- various code refactoring and better comments
Installation
----------------------
1) Unzip the archive into the mods directory of your game
2) Rename the auth_rx-master directory to "auth_rx"
3) Execute the "convert.awk" script (refer to instructions)
Source Code License
----------------------
The MIT License (MIT)
Copyright (c) 2016-2018, Leslie Krause (leslie@searstower.org)
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.
For more details:
https://opensource.org/licenses/MIT

741
mods/auth_rx/commands.lua Normal file
View File

@ -0,0 +1,741 @@
--------------------------------------------------------
-- Minetest :: Auth Redux Mod v2.13 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
local auth_db, auth_filter -- imported
minetest.register_chatcommand( "filter", {
description = "Enable or disable ruleset-based login filtering, or reload a ruleset definition.",
privs = { server = true },
func = function( name, param )
if param == "" then
return true, "Login filtering is currently " .. ( auth_filter.is_enabled and "enabled" or "disabled" ) .. "."
elseif param == "disable" then
auth_filter.is_enabled = false
minetest.log( "action", "Login filtering disabled by " .. name .. "." )
return true, "Login filtering is disabled."
elseif param == "enable" then
auth_filter.is_enabled = true
minetest.log( "action", "Login filtering enabled by " .. name .. "." )
return true, "Login filtering is enabled."
elseif param == "reload" then
auth_filter.refresh( )
return true, "Ruleset definition was loaded successfully."
else
return false, "Unknown parameter specified."
end
end
} )
minetest.register_chatcommand( "fdebug", {
description = "Start an interactive debugger for testing ruleset definitions.",
privs = { server = true },
func = function( name, param )
if not minetest.create_form then
return false, "This feature is not supported."
end
local epoch = os.time( { year = 1970, month = 1, day = 1, hour = 0 } )
local vars = {
__debug = { type = FILTER_TYPE_NUMBER, value = 0 },
name = { type = FILTER_TYPE_STRING, value = "singleplayer" },
addr = { type = FILTER_TYPE_ADDRESS, value = convert_ipv4( "127.0.0.1" ) },
is_new = { type = FILTER_TYPE_BOOLEAN, value = true },
privs_list = { type = FILTER_TYPE_SERIES, value = { } },
users_list = { type = FILTER_TYPE_SERIES, is_auto = true },
cur_users = { type = FILTER_TYPE_NUMBER, is_auto = true },
max_users = { type = FILTER_TYPE_NUMBER, value = get_minetest_config( "max_users" ) },
lifetime = { type = FILTER_TYPE_PERIOD, value = 0 },
sessions = { type = FILTER_TYPE_NUMBER, value = 0 },
failures = { type = FILTER_TYPE_NUMBER, value = 0 },
attempts = { type = FILTER_TYPE_NUMBER, value = 0 },
owner = { type = FILTER_TYPE_STRING, value = get_minetest_config( "name" ) },
uptime = { type = FILTER_TYPE_PERIOD, is_auto = true },
oldlogin = { type = FILTER_TYPE_MOMENT, value = epoch },
newlogin = { type = FILTER_TYPE_MOMENT, value = epoch },
ip_names_list = { type = FILTER_TYPE_SERIES, value = { } },
ip_prelogin = { type = FILTER_TYPE_MOMENT, value = epoch },
ip_oldcheck = { type = FILTER_TYPE_MOMENT, value = epoch },
ip_newcheck = { type = FILTER_TYPE_MOMENT, value = epoch },
ip_failures = { type = FILTER_TYPE_NUMBER, value = 0 },
ip_attempts = { type = FILTER_TYPE_NUMBER, value = 0 }
}
local vars_list = { "__debug", "clock", "name", "addr", "is_new", "privs_list", "users_list", "cur_users", "max_users", "lifetime", "sessions", "failures", "attempts", "owner", "uptime", "oldlogin", "newlogin", "ip_names_list", "ip_prelogin", "ip_oldcheck", "ip_newcheck", "ip_failures", "ip_attempts" }
local datatypes = { [FILTER_TYPE_NUMBER] = "NUMBER", [FILTER_TYPE_STRING] = "STRING", [FILTER_TYPE_BOOLEAN] = "BOOLEAN", [FILTER_TYPE_ADDRESS] = "ADDRESS", [FILTER_TYPE_PERIOD] = "PERIOD", [FILTER_TYPE_MOMENT] = "MOMENT", [FILTER_TYPE_SERIES] = "SERIES" }
local has_prompt = true
local has_output = true
local login_index = 2
local var_index = 1
local translate = GenericFilter( ).translate
local temp_name = "~greenlist_" .. minetest.encode_base64( name ) .. ".mt"
local temp_file = io.open( minetest.get_worldpath( ) .. "/" .. temp_name, "w" ):close( )
local temp_filter = AuthFilter( minetest.get_worldpath( ), temp_name, function ( err, num )
return "The server encountered an internal error.", num, err
end )
local function clear_prompts( buffer, has_single )
-- clear debug prompts from source code
return string.gsub( buffer, "\n# ====== .- ======\n", "\n", has_single and 1 or nil )
end
local function insert_prompt( buffer, num, err )
-- insert debug prompts into source code
local i = 0
return string.gsub( buffer, "\n", function ( )
i = i + 1
return ( i == num and string.format( "\n# ====== ^ Line %d: %s ^ ======\n", num, err ) or "\n" )
end )
end
local function format_value( value, type )
-- convert values to a human-readable format
if type == FILTER_TYPE_STRING then
return "\"" .. value .. "\""
elseif type == FILTER_TYPE_NUMBER then
return tostring( value )
elseif type == FILTER_TYPE_BOOLEAN then
return "$" .. tostring( value )
elseif type == FILTER_TYPE_PERIOD then
return tostring( math.abs( value ) ) .. "s"
elseif type == FILTER_TYPE_MOMENT then
return "+" .. tostring( value - vars.epoch.value ) .. "s"
elseif type == FILTER_TYPE_ADDRESS then
return table.concat( unpack_address( value ), "." )
elseif type == FILTER_TYPE_SERIES then
return "(" .. string.gsub( table.concat( value, "," ), "[^,]+", "\"%1\"" ) .. ")"
end
end
local function update_vars( )
-- automatically update preset variables
if vars.uptime.is_auto then
vars.uptime.value = minetest.get_server_uptime( ) end
if vars.clock.is_auto then
vars.clock.value = os.time( ) end
if vars.users_list.is_auto then
vars.users_list.value = auth_db.search( true ) end
if vars.cur_users.is_auto then
vars.cur_users.value = #auth_db.search( true ) end
end
local function get_formspec( buffer, status, var_state )
local var_name = vars_list[ var_index ]
local var_type = vars[ var_name ].type
local var_value = vars[ var_name ].value
local var_is_auto = vars[ var_name ].is_auto
local formspec = "size[13.5,8.5]"
.. default.gui_bg
.. default.gui_bg_img
.. "label[0.1,0.0;Ruleset Definition:]"
.. "checkbox[2.6,-0.2;has_output;Show Client Output;" .. tostring( has_output ) .. "]"
.. "checkbox[5.6,-0.2;has_prompt;Show Debug Prompt;" .. tostring( has_prompt ) .. "]"
.. "textarea[0.4,0.5;8.6," .. ( not status and "8.4" or status.user and "5.6" or "7.3" ) .. ";buffer;;" .. minetest.formspec_escape( buffer ) .. "]"
.. "button[0.1,7.8;2,1;export_ruleset;Save]"
.. "button[2.0,7.8;2,1;import_ruleset;Load]"
.. "button[4.0,7.8;2,1;process_ruleset;Process]"
.. "dropdown[6,7.9;2.6,1;login_mode;Normal,New Account,Wrong Password;" .. login_index .. "]"
.. "label[9.0,0.0;Preset Variables:]"
.. "textlist[9.0,0.5;4,4.7;vars_list"
for i, v in pairs( vars_list ) do
formspec = formspec .. ( i == 1 and ";" or "," ) .. minetest.formspec_escape( v .. " = " .. format_value( vars[ v ].value, vars[ v ].type ) )
end
formspec = formspec .. string.format( ";%d;false]", var_index )
.. "label[9.0,5.4;Name:]"
.. "label[9.0,5.9;Type:]"
.. string.format( "label[10.5,5.4;%s]", minetest.colorize( "#BBFF77", "$" .. var_name ) )
.. string.format( "label[10.5,5.9;%s]", datatypes[ var_type ] )
.. "label[9.0,6.4;Value:]"
.. "field[9.2,7.5;4.3,0.25;var_value;;" .. minetest.formspec_escape( format_value( var_value, var_type ) ) .. "]"
.. "button[9.0,7.8;1,1;prev_var;<<]"
.. "button[10.0,7.8;1,1;next_var;>>]"
.. "button[11.8,7.8;1.5,1;set_var;Set]"
if var_is_auto ~= nil then
formspec = formspec .. "checkbox[10.5,6.2;var_is_auto;Auto Update;" .. tostring( var_is_auto ) .. "]"
end
if status then
formspec = formspec .. "box[0.1,6.9;8.4,0.8;#555555]"
.. "label[0.3,7.1;" .. minetest.colorize( status.type == "ERROR" and "#CCCC22" or "#22CC22", status.type .. ": " ) .. status.desc .. "]"
if status.user then
formspec = formspec .. "textlist[0.1,5.5;8.4,1.2;;Access denied. Reason: " .. minetest.formspec_escape( status.user ) .. ";0;false]"
end
end
return formspec
end
local function on_close( meta, player, fields )
login_index = ( { ["Normal"] = 1, ["New Account"] = 2, ["Wrong Password"] = 3 } )[ fields.login_mode ] or 1 -- sanity check
if fields.quit then
os.remove( minetest.get_worldpath( ) .. "/~greenlist.mt" )
elseif fields.vars_list then
local event = minetest.explode_textlist_event( fields.vars_list )
if event.type == "CHG" then
var_index = event.index
minetest.update_form( name, get_formspec( fields.buffer ) )
end
elseif fields.has_prompt then
has_prompt = fields.has_prompt == "true"
elseif fields.has_output then
has_output = fields.has_output == "true"
elseif fields.export_ruleset then
local buffer = clear_prompts( fields.buffer .. "\n", true )
local file = io.open( minetest.get_worldpath( ) .. "/greenlist.mt", "w" )
if not file then
error( "Cannot write to ruleset definition file." )
end
file:write( buffer )
file:close( )
minetest.update_form( name, get_formspec( buffer, { type = "ACTION", desc = "Ruleset definition exported." } ) )
elseif fields.import_ruleset then
local file = io.open( minetest.get_worldpath( ) .. "/greenlist.mt", "r" )
if not file then
error( "Cannot read from ruleset definition file." )
end
minetest.update_form( name, get_formspec( file:read( "*a" ), { type = "ACTION", desc = "Ruleset definition imported." } ) )
file:close( )
elseif fields.process_ruleset then
local status
local buffer = clear_prompts( fields.buffer .. "\n", true ) -- we need a trailing newline, or things will break
-- output ruleset to temp file for processing
local temp_file = io.open( minetest.get_worldpath( ) .. "/" .. temp_name, "w" )
temp_file:write( buffer )
temp_file:close( )
temp_filter.refresh( )
update_vars( )
if fields.login_mode == "New Account" then
vars.is_new.value = true
vars.privs_list.value = { }
vars.lifetime.value = 0
vars.sessions.value = 0
vars.failures.value = 0
vars.attempts.value = 0
vars.newlogin.value = epoch
vars.oldlogin.value = epoch
else
vars.is_new.value = false
vars.attempts.value = vars.attempts.value + 1
end
-- process ruleset and benchmark performance
local t = minetest.get_us_time( )
local res, num, err = temp_filter.process( vars )
t = ( minetest.get_us_time( ) - t ) / 1000
if err then
if has_prompt then buffer = insert_prompt( buffer, num, err ) end
status = { type = "ERROR", desc = string.format( "%s (line %d).", err, num ), user = has_output and res }
vars.ip_attempts.value = vars.ip_attempts.value + 1
vars.ip_prelogin.value = vars.clock.value
table.insert( vars.ip_names_list.value, vars.name.value )
elseif res then
if has_prompt then buffer = insert_prompt( buffer, num, "Ruleset failed" ) end
status = { type = "ACTION", desc = string.format( "Ruleset failed at line %d (took %0.1f ms).", num, t ), user = has_output and res }
vars.ip_attempts.value = vars.ip_attempts.value + 1
vars.ip_prelogin.value = vars.clock.value
table.insert( vars.ip_names_list.value, vars.name.value )
elseif fields.login_mode == "Wrong Password" then
if has_prompt then buffer = insert_prompt( buffer, num, "Ruleset failed" ) end
status = { type = "ACTION", desc = string.format( "Ruleset passed at line %d (took %0.1f ms).", num, t ), user = has_output and "Invalid password" }
vars.failures.value = vars.failures.value + 1
vars.ip_attempts.value = vars.ip_attempts.value + 1
vars.ip_failures.value = vars.ip_failures.value + 1
vars.ip_prelogin.value = vars.clock.value
vars.ip_newcheck.value = vars.clock.value
if vars.ip_oldcheck.value == epoch then
vars.ip_oldcheck.value = vars.clock.value
end
table.insert( vars.ip_names_list.value, vars.name.value )
else
if has_prompt then buffer = insert_prompt( buffer, num, "Ruleset passed" ) end
status = { type = "ACTION", desc = string.format( "Ruleset passed at line %d (took %0.1f ms).", num, t ) }
if fields.login_mode == "New Account" then
vars.privs_list.value = get_default_privs( )
end
vars.sessions.value = vars.sessions.value + 1
vars.newlogin.value = vars.clock.value
if vars.oldlogin.value == epoch then
vars.oldlogin.value = vars.clock.value
end
vars.ip_failures.value = 0
vars.ip_attempts.value = 0
vars.ip_prelogin.value = epoch
vars.ip_oldcheck.value = epoch
vars.ip_newcheck.value = epoch
vars.ip_names_list.value = { }
end
minetest.update_form( name, get_formspec( buffer, status ) )
elseif fields.next_var or fields.prev_var then
local idx = var_index
local off = fields.next_var and 1 or -1
if off == 1 and idx < #vars_list or off == -1 and idx > 1 then
local v = vars_list[ idx ]
vars_list[ idx ] = vars_list[ idx + off ]
vars_list[ idx + off ] = v
var_index = idx + off
minetest.update_form( name, get_formspec( fields.buffer ) )
end
elseif fields.var_is_auto then
local var_name = vars_list[ var_index ]
vars[ var_name ].is_auto = ( fields.var_is_auto == "true" )
elseif fields.set_var then
local oper = translate( string.trim( fields.var_value ), vars )
local var_name = vars_list[ var_index ]
if oper and var_name == "__debug" and datatypes[ oper.type ] then
-- debug variable can be any value/type
vars.__debug = oper
elseif oper and oper.type == vars[ var_name ].type then
vars[ var_name ].value = oper.value
end
minetest.update_form( name, get_formspec( fields.buffer ) )
end
end
temp_filter.add_preset_vars( vars )
vars.clock.is_auto = true
update_vars( )
minetest.create_form( nil, name, get_formspec( "pass now\n" ), on_close )
return true
end,
} )
minetest.register_chatcommand( "auth", {
description = "Open the authentication database management console.",
privs = { server = true },
func = function( name, param )
local base_filter = GenericFilter( )
local epoch = os.time( { year = 1970, month = 1, day = 1, hour = 0 } )
local is_sort_reverse = false
local vars_list = { "username", "password", "oldlogin", "newlogin", "lifetime", "total_sessions", "total_failures", "total_attempts", "assigned_privs" }
local columns_list = { "$username", "$oldlogin->cal('D-MM-YY')", "$newlogin->cal('D-MM-YY')", "$lifetime->when('h')", "$total_sessions->str()", "$total_attempts->str()", "$total_failures->str()", "$assigned_privs->join(',')" }
local results_list
local selects_list
local var_index = 1
local var_input = ""
local select_index
local select_input = ""
local result_index
local results_horz
local results_vert
local column_index = 1
local column_macro = ""
base_filter.define_func( "str", FILTER_TYPE_STRING, { FILTER_TYPE_NUMBER },
function ( v, a ) return tostring( a ) end )
base_filter.define_func( "join", FILTER_TYPE_STRING, { FILTER_TYPE_SERIES, FILTER_TYPE_STRING },
function ( v, a, b ) return table.concat( a, b ) end )
base_filter.define_func( "when", FILTER_TYPE_STRING, { FILTER_TYPE_PERIOD, FILTER_TYPE_STRING },
function ( v, a, b ) local f = { y = 31536000, w = 604800, d = 86400, h = 3600, m = 60, s = 1 }; return f[ b ] and ( math.floor( a / f[ b ] ) .. b ) or "?" end )
base_filter.define_func( "cal", FILTER_TYPE_STRING, { FILTER_TYPE_MOMENT, FILTER_TYPE_STRING },
function ( v, a, b ) local f = { ["Y"] = "%y", ["YY"] = "%Y", ["M"] = "%m", ["MM"] = "%b", ["D"] = "%d", ["DD"] = "%a", ["h"] = "%H", ["m"] = "%M", ["s"] = "%S" }; return os.date( string.gsub( b, "%a+", f ), a ) end )
local function get_record_vars( username )
local rec = auth_db.select_record( username )
return rec and {
username = { value = username, type = FILTER_TYPE_STRING },
password = { value = rec.password, type = FILTER_TYPE_STRING },
oldlogin = { value = rec.oldlogin, type = FILTER_TYPE_MOMENT },
newlogin = { value = rec.newlogin, type = FILTER_TYPE_MOMENT },
lifetime = { value = rec.lifetime, type = FILTER_TYPE_PERIOD },
total_sessions = { value = rec.total_sessions, type = FILTER_TYPE_NUMBER },
total_failures = { value = rec.total_failures, type = FILTER_TYPE_NUMBER },
total_attempts = { value = rec.total_attempts, type = FILTER_TYPE_NUMBER },
assigned_privs = { value = rec.assigned_privs, type = FILTER_TYPE_SERIES },
} or { username = { value = username, type = FILTER_TYPE_STRING } }
end
local function reset_results( )
result_index = 1
results_vert = 0
results_horz = 0
results_list = auth_db.search( false )
select_index = 1
selects_list = { { input = "(default)", cache = results_list } }
end
local function query_results( input )
local stmt = string.split( base_filter.tokenize( input ), " ", false )
if #stmt ~= 4 then
return "Invalid 'if' or 'unless' statement in selector"
end
local cond = ( { ["if"] = FILTER_COND_TRUE, ["unless"] = FILTER_COND_FALSE } )[ stmt[ 1 ] ]
local comp = ( { ["eq"] = FILTER_COMP_EQ, ["gt"] = FILTER_COMP_GT, ["lt"] = FILTER_COMP_LT, ["gte"] = FILTER_COMP_GTE, ["lte"] = FILTER_COMP_LTE, ["in"] = FILTER_COMP_IN, ["is"] = FILTER_COMP_IS, ["has"] = FILTER_COMP_HAS } )[ stmt[ 3 ] ]
if not cond or not comp then
return "Unrecognized keywords in selector"
end
-- initalize variables prior to loop (huge performance boost)
local vars = {
username = { type = FILTER_TYPE_STRING },
password = { type = FILTER_TYPE_STRING },
oldlogin = { type = FILTER_TYPE_MOMENT },
newlogin = { type = FILTER_TYPE_MOMENT },
lifetime = { type = FILTER_TYPE_PERIOD },
total_sessions = { type = FILTER_TYPE_NUMBER },
total_failures = { type = FILTER_TYPE_NUMBER },
total_attempts = { type = FILTER_TYPE_NUMBER },
assigned_privs = { type = FILTER_TYPE_SERIES },
}
base_filter.add_preset_vars( vars )
local refs1, refs2, proc1, proc2, oper1, oper2
local get_result = base_filter.get_result
local get_operand_parser = base_filter.get_operand_parser
local select_record = auth_db.select_record
local res = { }
for i, username in ipairs( results_list ) do
local rec = select_record( username )
if not rec then
return "Attempt to index a non-existent record"
end
vars.username.value = username
vars.password.value = rec.password
vars.oldlogin.value = rec.oldlogin
vars.newlogin.value = rec.newlogin
vars.lifetime.value = rec.lifetime
vars.total_sessions.value = rec.total_sessions
vars.total_failures.value = rec.total_failures
vars.total_attempts.value = rec.total_attempts
vars.assigned_privs.value = rec.assigned_privs
if not oper1 then
-- get parser on first iteration
if not proc1 then
proc1, refs1 = get_operand_parser( stmt[ 2 ] )
end
oper1 = proc1 and proc1( refs1, vars )
end
if not oper2 then
-- get parser on first iteration
if not proc2 then
proc2, refs2 = get_operand_parser( stmt[ 4 ] )
end
oper2 = proc2 and proc2( refs2, vars )
end
if not oper1 or not oper2 then
return "Unrecognized operands in selector"
end
local expr = get_result( cond, comp, oper1, oper2 )
if expr == nil then
return "Mismatched operands in selector"
end
-- add matching records to results
if expr then
table.insert( res, username )
end
-- cache operands that are constant
if not oper1.const then oper1 = nil end
if not oper2.const then oper2 = nil end
end
result_index = 1
results_list = res
results_vert = 0
select_index = select_index + 1
table.insert( selects_list, select_index, { input = input, cache = results_list } )
end
local function format_value( oper )
if oper.type == FILTER_TYPE_STRING then
return "\"" .. oper.value .. "\""
elseif oper.type == FILTER_TYPE_NUMBER then
return tostring( oper.value )
elseif oper.type == FILTER_TYPE_MOMENT then
return "+" .. tostring( math.max( 0, oper.value - epoch ) ) .. "s"
elseif oper.type == FILTER_TYPE_PERIOD then
return tostring( math.abs( oper.value ) ) .. "s"
elseif oper.type == FILTER_TYPE_SERIES then
return "(" .. string.gsub( table.concat( oper.value, "," ), "[^,]+", "\"%1\"" ) .. ")"
end
end
local function get_escaped_fields( username )
local fields = { }
local vars = get_record_vars( username )
base_filter.add_preset_vars( vars )
for i = 1 + results_horz, #columns_list do
local oper = base_filter.translate( columns_list[ i ], vars )
table.insert( fields, minetest.formspec_escape(
oper and oper.type == FILTER_TYPE_STRING and oper.value or "?" )
)
end
return fields
end
local function sort_results( )
local cache = { }
local field = vars_list[ var_index ]
local select_record = auth_db.select_record
for i, v in ipairs( results_list ) do
local rec = select_record( v )
if rec then
cache[ v ] = ( field == "username" and v or field == "assigned_privs" and #rec[ field ] or rec[ field ] )
end
end
table.sort( results_list, function ( a, b )
local value1, value2 = cache[ a ], cache[ b ]
-- deleted records are lowest sort order
if not value1 then return false end
if not value2 then return true end
if is_sort_reverse then
return value1 > value2
else
return value1 < value2
end
end )
result_index = 1
results_vert = 0
end
local function get_formspec( err )
local fs = minetest.formspec_escape
local horz = ( #columns_list > 1 and ( 1000 / ( #columns_list - 1 ) * results_horz ) or 0 )
local vert = ( #results_list > 1 and ( 1000 / ( #results_list - 1 ) * results_vert ) or 0 )
local formspec = "size[13.5,9.0]"
.. default.gui_bg
.. default.gui_bg_img
.. "label[0.1,0.0;Results (" .. #results_list .. " Records Selected):]"
.. "checkbox[6.5,-0.2;is_sort_reverse;Reverse Sort;" .. tostring( is_sort_reverse ) .. "]"
.. "tablecolumns[color" .. string.rep( ";text,width=10", #columns_list - results_horz ) .. "]"
.. "table[0.1,0.5;8.6,7.3;results_list;#66DD66"
for i = 1 + results_horz, #columns_list do
formspec = formspec .. "," .. fs( string.sub( columns_list[ i ], 1, 18 ) )
end
for i = 1 + results_vert, math.min( #results_list, 15 + results_vert ) do
formspec = formspec .. ",#FFFFFF," .. table.concat( get_escaped_fields( results_list[ i ] ), "," )
end
formspec = formspec .. ";" .. result_index .. "]"
.. "scrollbar[0.1,7.8;8.6,0.4;horizontal;results_horz;" .. horz .. "]"
.. "scrollbar[8.7,0.5;0.37,7.2;vertical;results_vert;" .. vert .. "]"
if err then
formspec = formspec .. "box[0.1,8.4;7.8,0.7;#555555]"
.. "label[0.3,8.5;" .. minetest.colorize( "#CCCC22", "ERROR: " ) .. fs( err ) .. "]"
.. "button[8.1,8.3;1.2,1;okay;Okay]"
else
formspec = formspec .. "dropdown[0.1,8.4;2.4,1;var_index;" .. table.concat( vars_list, "," ) .. ";" .. var_index .. "]"
.. "field[2.8,9.0;3.7,0.25;var_input;;" .. fs( var_input ) .. "]"
.. "button[6.1,8.3;1,1;set_records;Set]"
.. "button[7.0,8.3;1,1;del_records;Del]"
.. "button[8.1,8.3;1.2,1;sort_records;Sort]"
end
formspec = formspec .. "label[9.4,0.0;Columns:]"
.. "textlist[9.4,0.5;2.9,2.7;columns_list"
for i, v in ipairs( columns_list ) do
formspec = formspec .. ( i == 1 and ";" or "," ) .. fs( v )
end
formspec = formspec .. ";" .. column_index .. ";false]"
.. "button[12.4,0.4;1,1;prev_column;<<]"
.. "button[12.4,1.2;1,1;next_column;>>]"
.. "button[12.4,2.0;1,1;del_column;Del]"
.. "button[12.4,3.2;1,1;add_column;Add]"
.. "field[9.7,3.9;3.1,0.25;column_macro;;" .. fs( column_macro ) .. "]"
.. "label[9.4,4.6;Selectors:]"
.. "textlist[9.4,5.1;3.8,2.3;selects_list"
for i, v in ipairs( selects_list ) do
formspec = formspec .. ( i == 1 and ";" or "," ) .. fs( v.input )
end
formspec = formspec .. ";" .. select_index .. ";false]"
.. "field[9.7,8.1;4.0,0.25;select_input;;" .. fs( select_input ) .. "]"
.. "button[9.4,8.3;1.4,1;reset_results;Clear]"
.. "button[12.0,8.3;1.4,1;query_results;Query]"
return formspec
end
local function on_close( meta, player, fields )
-- check single-operation elements first
if fields.okay then
minetest.update_form( name, get_formspec( ) )
elseif fields.is_sort_reverse then
is_sort_reverse = ( fields.is_sort_reverse == "true" )
elseif fields.columns_list then
local event = minetest.explode_textlist_event( fields.columns_list )
if event.type == "CHG" then
column_index = event.index
elseif event.type == "DCL" then
column_macro = columns_list[ column_index ]
minetest.update_form( name, get_formspec( ) )
end
elseif fields.selects_list then
local event = minetest.explode_textlist_event( fields.selects_list )
if event.type == "CHG" then
select_index = event.index
results_list = selects_list[ event.index ].cache
results_vert = 0
minetest.update_form( name, get_formspec( ) )
elseif event.type == "DCL" and select_index > 1 then
select_input = selects_list[ event.index ].input
minetest.update_form( name, get_formspec( ) )
end
elseif fields.results_list then
local event = minetest.explode_table_event( fields.results_list )
if event.type == "CHG" then
result_index = event.row
elseif event.type == "DCL" and result_index > 1 then
local vars = get_record_vars( results_list[ results_vert + result_index - 1 ] )
local oper = vars[ vars_list[ var_index ] ]
var_input = oper and format_value( oper ) or ""
minetest.update_form( name, get_formspec( ) )
end
elseif fields.next_column or fields.prev_column then
local idx = column_index
local off = fields.next_column and 1 or -1
if off == 1 and idx < #columns_list or off == -1 and idx > 1 then
local v = columns_list[ idx ]
columns_list[ idx ] = columns_list[ idx + off ]
columns_list[ idx + off ] = v
column_index = idx + off
minetest.update_form( name, get_formspec( ) )
end
elseif fields.del_column then
if #columns_list > 1 then
table.remove( columns_list, column_index )
column_index = math.min( column_index, #columns_list )
results_horz = 0
minetest.update_form( name, get_formspec( ) )
end
elseif fields.add_column and fields.column_macro then
if string.match( fields.column_macro, "%S+" ) and #columns_list < 10 then
table.insert( columns_list, string.trim( fields.column_macro ) )
column_macro = ""
column_index = #columns_list
minetest.update_form( name, get_formspec( ) )
end
elseif fields.del_records then
local delete_record = auth_db.delete_record
if result_index == 1 then
for i, username in ipairs( results_list ) do
delete_record( username )
end
else
delete_record( results_list[ results_vert + result_index - 1 ] )
end
minetest.update_form( name, get_formspec( ) )
elseif fields.sort_records then
sort_results( )
minetest.update_form( name, get_formspec( ) )
elseif fields.query_results and fields.select_input then
if string.match( fields.select_input, "%S+" ) and #selects_list < 5 then
local input = string.trim( fields.select_input )
local err = query_results( input )
select_input = ( not err and "" or input )
minetest.update_form( name, get_formspec( err ) )
end
elseif fields.reset_results then
reset_results( )
select_input = ""
minetest.update_form( name, get_formspec( ) )
-- check dual-operation elements last
elseif fields.results_horz and fields.results_vert then
local horz_event = minetest.explode_scrollbar_event( fields.results_horz )
local vert_event = minetest.explode_scrollbar_event( fields.results_vert )
if horz_event.type == "CHG" then
local offset = horz_event.value - 1000 / ( #columns_list - 1 ) * results_horz
if offset > 10 then
results_horz = #columns_list - 1
elseif offset < -10 then
results_horz = 0
elseif offset > 0 then
results_horz = results_horz + 1
elseif offset < 0 then
results_horz = results_horz - 1
end
minetest.update_form( name, get_formspec( ) )
elseif vert_event.type == "CHG" then
-- TODO: Fix offset calculation to be more accurate?
local offset = vert_event.value - 1000 / ( #results_list - 1 ) * results_vert
if offset > 10 then
results_vert = math.min( #results_list - 1, results_vert + 100 )
elseif offset < -10 then
results_vert = math.max( 0, results_vert - 100 )
elseif offset > 0 then
results_vert = math.min( #results_list - 1, results_vert + 10 )
elseif offset < 0 then
results_vert = math.max( 0, results_vert - 10 )
end
result_index = 1
minetest.update_form( name, get_formspec( ) )
end
var_index = ( { ["username"] = 1, ["password"] = 2, ["oldlogin"] = 3, ["newlogin"] = 4, ["lifetime"] = 5, ["total_sessions"] = 6, ["total_failures"] = 7, ["total_attempts"] = 8, ["assigned_privs"] = 9 } )[ fields.var_index ] or 1 -- sanity check
end
end
reset_results( )
minetest.create_form( nil, name, get_formspec( ), on_close )
end,
} )
return function ( import )
auth_db = import.auth_db
auth_filter = import.auth_filter
end

447
mods/auth_rx/db.lua Normal file
View File

@ -0,0 +1,447 @@
--------------------------------------------------------
-- Minetest :: Auth Redux Mod v2.6 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
----------------------------
-- Transaction Op Codes
----------------------------
local LOG_STARTED = 10 -- <timestamp> 10
local LOG_CHECKED = 11 -- <timestamp> 11
local LOG_STOPPED = 12 -- <timestamp> 12
local TX_CREATE = 20 -- <timestamp> 20 <username> <password>
local TX_DELETE = 21 -- <timestamp> 21 <username>
local TX_SET_PASSWORD = 40 -- <timestamp> 40 <username> <password>
local TX_SET_APPROVED_ADDRS = 41 -- <timestamp> 41 <username> <approved_addrs>
local TX_SET_ASSIGNED_PRIVS = 42 -- <timestamp> 42 <username> <assigned_privs>
local TX_SESSION_OPENED = 50 -- <timestamp> 50 <username>
local TX_SESSION_CLOSED = 51 -- <timestamp> 51 <username>
local TX_LOGIN_ATTEMPT = 30 -- <timestamp> 30 <username> <ip>
local TX_LOGIN_FAILURE = 31 -- <timestamp> 31 <username> <ip>
local TX_LOGIN_SUCCESS = 32 -- <timestamp> 32 <username>
----------------------------
-- Journal Class
----------------------------
function Journal( path, name, is_rollback )
local file, err = io.open( path .. "/" .. name, "r+b" )
local self = { }
local cursor = 0
local rtime = 1.0
-- TODO: Verify integrity of database index
if not file then
minetest.log( "error", "Cannot open " .. path .. "/" .. name .. " for writing." )
error( "Fatal exception in Journal( ), aborting." )
end
self.audit = function ( update_proc, is_rollback )
-- Advance to the last set of noncommitted transactions (if any)
if not is_rollback then
minetest.log( "action", "Advancing database transaction log...." )
for line in file:lines( ) do
local fields = string.split( line, " ", true )
if tonumber( fields[ 2 ] ) == LOG_STOPPED then
cursor = file:seek( )
end
end
file:seek( "set", cursor )
end
-- Update the database with all noncommitted transactions
local meta = { }
minetest.log( "action", "Replaying database transaction log...." )
for line in file:lines( ) do
local fields = string.split( line, " ", true )
local optime = tonumber( fields[ 1 ] )
local opcode = tonumber( fields[ 2 ] )
update_proc( meta, optime, opcode, select( 3, unpack( fields ) ) )
if opcode == LOG_CHECKED then
-- Perform the commit and reset the log, if successful
minetest.log( "action", "Resetting database transaction log..." )
file:seek( "set", cursor )
file:write( optime .. " " .. LOG_STOPPED .. "\n" )
return optime
end
cursor = file:seek( )
end
end
self.start = function ( )
self.optime = os.time( )
file:seek( "end", 0 )
file:write( self.optime .. " " .. LOG_STARTED .. "\n" )
cursor = file:seek( )
file:write( self.optime .. " " .. LOG_CHECKED .. "\n" )
end
self.reset = function ( )
file:seek( "set", cursor )
file:write( self.optime .. " " .. LOG_STOPPED .. "\n" )
self.optime = nil
end
self.record_raw = function ( opcode, ... )
file:seek( "set", cursor )
file:write( table.concat( { self.optime, opcode, ... }, " " ) .. "\n" )
cursor = file:seek( )
file:write( self.optime .. " " .. LOG_CHECKED .. "\n" )
end
minetest.register_globalstep( function( dtime )
rtime = rtime - dtime
if rtime <= 0.0 then
if self.optime then
-- touch file every 1.0 secs so we know if/when server crashes
self.optime = os.time( )
file:seek( "set", cursor )
file:write( self.optime .. " " .. LOG_CHECKED .. "\n" )
end
rtime = 1.0
end
end )
return self
end
----------------------------
-- AuthDatabase Class
----------------------------
function AuthDatabase( path, name )
local data, users, index
local self = { }
local journal = Journal( path, name .. "x" )
-- Private methods
local db_update = function( meta, optime, opcode, ... )
local fields = { ... }
if opcode == TX_CREATE then
local rec =
{
password = fields[ 2 ],
oldlogin = -1,
newlogin = -1,
lifetime = 0,
total_sessions = 0,
total_attempts = 0,
total_failures = 0,
approved_addrs = { },
assigned_privs = { },
}
data[ fields[ 1 ] ] = rec
elseif opcode == TX_DELETE then
data[ fields[ 1 ] ] = nil
elseif opcode == TX_SET_PASSWORD then
data[ fields[ 1 ] ].password = fields[ 2 ]
elseif opcode == TX_SET_APPROVED_ADDRS then
data[ fields[ 1 ] ].filered_addrs = string.split( fields[ 2 ], ",", true )
elseif opcode == TX_SET_ASSIGNED_PRIVS then
data[ fields[ 1 ] ].assigned_privs = string.split( fields[ 2 ], ",", true )
elseif opcode == TX_LOGIN_ATTEMPT then
data[ fields[ 1 ] ].total_attempts = data[ fields[ 1 ] ].total_attempts + 1
elseif opcode == TX_LOGIN_FAILURE then
data[ fields[ 1 ] ].total_failures = data[ fields[ 1 ] ].total_failures + 1
elseif opcode == TX_LOGIN_SUCCESS then
if data[ fields[ 1 ] ].oldlogin == -1 then
data[ fields[ 1 ] ].oldlogin = optime
end
meta.users[ fields[ 1 ] ] = data[ fields[ 1 ] ].newlogin
data[ fields[ 1 ] ].newlogin = optime
elseif opcode == TX_SESSION_OPENED then
data[ fields[ 1 ] ].total_sessions = data[ fields[ 1 ] ].total_sessions + 1
elseif opcode == TX_SESSION_CLOSED then
data[ fields[ 1 ] ].lifetime = data[ fields[ 1 ] ].lifetime + ( optime - data[ fields[ 1 ] ].newlogin )
meta.users[ fields[ 1 ] ] = nil
elseif opcode == LOG_STARTED then
meta.users = { }
elseif opcode == LOG_CHECKED or opcode == LOG_STOPPED then
-- calculate leftover session lengths due to abnormal server termination
for u, t in pairs( meta.users ) do
data[ u ].lifetime = data[ u ].lifetime + ( optime - data[ u ].newlogin )
end
meta.users = nil
end
end
local db_reload = function ( )
minetest.log( "action", "Reading authentication data from disk..." )
local file, errmsg = io.open( path .. "/" .. name, "r+b" )
if not file then
minetest.log( "error", "Cannot open " .. path .. "/" .. name .. " for reading." )
error( "Fatal exception in AuthDatabase:db_reload( ), aborting." )
end
local head = assert( file:read( "*line" ) )
index = tonumber( string.match( head, "^auth_rx/2.1 @(%d+)$" ) )
if not index or index < 0 then
minetest.log( "error", "Invalid header in authentication database." )
error( "Fatal exception in AuthDatabase:reload( ), aborting." )
end
for line in file:lines( ) do
if line ~= "" then
local fields = string.split( line, ":", true )
if #fields ~= 10 then
minetest.log( "error", "Invalid record in authentication database." )
error( "Fatal exception in AuthDatabase:reload( ), aborting." )
end
data[ fields[ 1 ] ] = {
password = fields[ 2 ],
oldlogin = tonumber( fields[ 3 ] ),
newlogin = tonumber( fields[ 4 ] ),
lifetime = tonumber( fields[ 5 ] ),
total_sessions = tonumber( fields[ 6 ] ),
total_attempts = tonumber( fields[ 7 ] ),
total_failures = tonumber( fields[ 8 ] ),
approved_addrs = string.split( fields[ 9 ], "," ),
assigned_privs = string.split( fields[ 10 ], "," ),
}
end
end
file:close( )
end
local db_commit = function ( )
minetest.log( "action", "Writing authentication data to disk..." )
local file, errmsg = io.open( path .. "/~" .. name, "w+b" )
if not file then
minetest.log( "error", "Cannot open " .. path .. "/~" .. name .. " for writing." )
error( "Fatal exception in AuthDatabase:db_commit( ), aborting." )
end
index = index + 1
file:write( "auth_rx/2.1 @" .. index .. "\n" )
for username, rec in pairs( data ) do
assert( file:write( table.concat( {
username,
rec.password,
rec.oldlogin,
rec.newlogin,
rec.lifetime,
rec.total_sessions,
rec.total_attempts,
rec.total_failures,
table.concat( rec.approved_addrs, "," ),
table.concat( rec.assigned_privs, "," ),
}, ":" ) .. "\n" ) )
end
file:close( )
assert( os.remove( path .. "/" .. name ) )
assert( os.rename( path .. "/~" .. name, path .. "/" .. name ) )
end
-- Public methods
self.rollback = function ( )
data = { }
db_reload( )
journal.audit( db_update, true )
db_commit( )
data = nil
end
self.connect = function ( )
data = { }
users = { }
db_reload( )
if journal.audit( db_update, false ) then
db_commit( )
end
journal.start( )
end
self.disconnect = function ( )
for u, t in pairs( users ) do
data[ u ].lifetime = data[ u ].lifetime + ( journal.optime - data[ u ].newlogin )
end
db_commit( )
journal.reset( )
data = nil
users = nil
end
self.create_record = function ( username, password )
-- don't allow clobbering existing users
if data[ username ] then return false end
local rec =
{
password = password,
oldlogin = -1,
newlogin = -1,
lifetime = 0,
total_sessions = 0,
total_attempts = 0,
total_failures = 0,
approved_addrs = { },
assigned_privs = { },
}
data[ username ] = rec
journal.record_raw( TX_CREATE, username, password )
return true
end
self.delete_record = function ( username )
-- don't allow deletion of online users or non-existent users
if not data[ username ] or users[ username ] then return false end
data[ username ] = nil
journal.record_raw( TX_DELETE, username )
return true
end
self.set_password = function ( username, password )
if not data[ username ] then return false end
data[ username ].password = password
journal.record_raw( TX_SET_PASSWORD, username, password )
return true
end
self.set_assigned_privs = function ( username, assigned_privs )
if not data[ username ] then return false end
data[ username ].assigned_privs = assigned_privs
journal.record_raw( TX_SET_ASSIGNED_PRIVS, username, table.concat( assigned_privs, "," ) )
return true
end
self.set_approved_addrs = function ( username, approved_addrs )
if not data[ username ] then return false end
data[ username ].approved_addrs = approved_addrs
journal.record_raw( TX_SET_APPROVED_ADDRS, username, table.concat( approved_addrs, "," ) )
return true
end
self.on_session_opened = function ( username )
data[ username ].total_sessions = data[ username ].total_sessions + 1
journal.record_raw( TX_SESSION_OPENED, username )
end
self.on_session_closed = function ( username )
data[ username ].lifetime = data[ username ].lifetime + ( journal.optime - data[ username ].newlogin )
users[ username ] = nil
journal.record_raw( TX_SESSION_CLOSED, username )
end
self.on_login_attempt = function ( username, ip )
data[ username ].total_attempts = data[ username ].total_attempts + 1
journal.record_raw( TX_LOGIN_ATTEMPT, username, ip )
end
self.on_login_failure = function ( username, ip )
data[ username ].total_failures = data[ username ].total_failures + 1
journal.record_raw( TX_LOGIN_FAILURE, username, ip )
end
self.on_login_success = function ( username, ip )
if not data[ username ].oldlogin or data[ username ].oldlogin == -1 then
data[ username ].oldlogin = journal.optime
end
users[ username ] = data[ username ].newlogin
data[ username ].newlogin = journal.optime
journal.record_raw( TX_LOGIN_SUCCESS, username, ip )
end
self.records = function ( )
return pairs( data )
end
self.records_match = function ( pattern )
local k
return function ( )
local v
local p = string.lower( pattern )
while true do
k, v = next( data, k )
if not k then
return
elseif string.match( string.lower( k ), p ) then
return k, v
end
end
end
end
self.select_record = function ( username )
return data[ username ]
end
self.search = function ( is_online, pattern )
local res = { }
local src = is_online and users or data
for k, v in pairs( src ) do
if pattern == nil or string.match( k, pattern ) then
table.insert( res, k )
end
end
return res
end
return self
end
----------------------------
-- AuthInitFile Class
----------------------------
function AuthInitFile( path, name)
local file, err = io.open( path .. "/" .. name, "r+b" )
-- init empty files if not present
if file then
file:close( )
return
end
file = io.open( path .. "/" .. name, "w+")
if not file then
minetest.log( "error", "Cannot init file " .. path .. "/" .. name .. " for writing." )
error( "Fatal exception in InitFiles( ), aborting." )
end
if name == "auth.db" then
file:write("auth_rx/2.1 @0")
AuthInitFile( path, name .. "x")
end
file:write("")
file:close( )
-- convert present txt file if present or autentications, after convert, change extension to auth.txt.converted
-- TODO: made a conversion in lua line by line, after rename , delete it
-- provide a way to rollback to a txt file if there already a auth db file, to stay in sync
-- TODO way to get sync with txt file
end

2
mods/auth_rx/depends.txt Normal file
View File

@ -0,0 +1,2 @@
default
formspecs

View File

@ -0,0 +1 @@
Auth Redux is a drop-in replacement for the builtin authentication handler of Minetest. It is designed from the ground up to be robust and secure enough for use on high-traffic Minetest servers, while also addressing a number of outstanding engine bugs.

616
mods/auth_rx/filter.lua Normal file
View File

@ -0,0 +1,616 @@
--------------------------------------------------------
-- Minetest :: Auth Redux Mod v2.13 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
FILTER_TYPE_STRING = 10
FILTER_TYPE_NUMBER = 11
FILTER_TYPE_ADDRESS = 12
FILTER_TYPE_BOOLEAN = 13
FILTER_TYPE_PATTERN = 14
FILTER_TYPE_SERIES = 15
FILTER_TYPE_PERIOD = 16
FILTER_TYPE_MOMENT = 17
FILTER_TYPE_DATESPEC = 18
FILTER_TYPE_TIMESPEC = 19
FILTER_MODE_FAIL = 20
FILTER_MODE_PASS = 21
FILTER_BOOL_AND = 30
FILTER_BOOL_OR = 31
FILTER_BOOL_XOR = 32
FILTER_BOOL_NOW = 33
FILTER_COND_FALSE = 40
FILTER_COND_TRUE = 41
FILTER_COMP_EQ = 50
FILTER_COMP_GT = 51
FILTER_COMP_GTE = 52
FILTER_COMP_LT = 53
FILTER_COMP_LTE = 54
FILTER_COMP_IN = 55
FILTER_COMP_IS = 56
FILTER_COMP_HAS = 57
local decode_base64 = minetest.decode_base64
local encode_base64 = minetest.encode_base64
----------------------------
-- StringPattern class
----------------------------
function StringPattern( phrase, is_mode, tokens )
local glob = "^" .. string.gsub( phrase, ".", tokens ) .. "$"
return { compare = function ( value, type )
if not is_mode[ type ] then return end
return string.find( value, glob ) == 1
end }
end
----------------------------
-- NumberPattern class
----------------------------
function NumberPattern( phrase, is_mode, tokens, parser )
local glob = { }
local ref
local find_token = function ( str, pat )
ref = { string.match( str, pat ) }
return #ref > 0
end
if #phrase ~= #tokens then
return nil
end
for i, v in ipairs( phrase ) do
local eval, args
local t = tokens[ i ]
if find_token( v, "^(" .. t .. ")$" ) then
eval = function ( a, b ) return a == b end
args = { tonumber( ref[ 1 ] ) }
elseif find_token( v, "^(" .. t .. ")%^(" .. t .. ")$" ) then
eval = function ( a, b, c ) return a >= b and a <= c end
args = { tonumber( ref[ 1 ] ), tonumber( ref[ 2 ] ) }
elseif find_token( v, "^(" .. t .. ")([<>])$" ) then
eval = ref[ 2 ] == "<" and
( function ( a, b ) return a <= b end ) or
( function ( a, b ) return a >= b end )
args = { tonumber( ref[ 1 ] ) }
elseif v == "?" then
eval = function ( ) return true end
args = { }
else
return nil
end
table.insert( glob, { eval = eval, args = args } )
end
return { compare = function ( value, type )
if not is_mode[ type ] then return end
local fields = parser( value, type )
for i, v in ipairs( glob ) do
if not v.eval( fields[ i ], unpack( v.args ) ) then return false end
end
return true
end }
end
----------------------------
-- GenericFilter class
----------------------------
function GenericFilter( )
local self = { }
local trim, localtime, redate
local funcs = {
["add"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return a + b end },
["sub"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return a - b end },
["mul"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return a * b end },
["div"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return a / b end },
["neg"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER }, def = function ( v, a ) return -a end },
["abs"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER }, def = function ( v, a ) return math.abs( a ) end },
["max"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return math.max( a, b ) end },
["min"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return math.min( a, b ) end },
["int"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_NUMBER }, def = function ( v, a ) return a < 0 and math.ceil( a ) or math.floor( a ) end },
["num"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_STRING }, def = function ( v, a ) return tonumber( a ) or 0 end },
["len"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_STRING }, def = function ( v, a ) return string.len( a ) end },
["lc"] = { type = FILTER_TYPE_STRING, args = { FILTER_TYPE_STRING }, def = function ( v, a ) return string.lower( a ) end },
["uc"] = { type = FILTER_TYPE_STRING, args = { FILTER_TYPE_STRING }, def = function ( v, a ) return string.upper( a ) end },
["trim"] = { type = FILTER_TYPE_STRING, args = { FILTER_TYPE_STRING, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return b > 0 and string.sub( a, 1, -b - 1 ) or string.sub( a, -b + 1 ) end },
["crop"] = { type = FILTER_TYPE_STRING, args = { FILTER_TYPE_STRING, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return b > 0 and string.sub( a, 1, b ) or string.sub( a, b, -1 ) end },
["size"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_SERIES }, def = function ( v, a ) return #a end },
["elem"] = { type = FILTER_TYPE_STRING, args = { FILTER_TYPE_SERIES, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) return a[ b > 0 and b or #a + b + 1 ] or "" end },
["split"] = { type = FILTER_TYPE_SERIES, args = { FILTER_TYPE_STRING, FILTER_TYPE_STRING }, def = function ( v, a, b ) return string.split( a, b, true ) end },
["time"] = { type = FILTER_TYPE_TIMESPEC, args = { FILTER_TYPE_MOMENT }, def = function ( v, a ) return redate( a - v.epoch.value ) % 86400 end },
["date"] = { type = FILTER_TYPE_DATESPEC, args = { FILTER_TYPE_MOMENT }, def = function ( v, a ) return math.floor( redate( a - v.epoch.value ) / 86400 ) end },
["age"] = { type = FILTER_TYPE_PERIOD, args = { FILTER_TYPE_MOMENT }, def = function ( v, a ) return v.clock.value - a end },
["before"] = { type = FILTER_TYPE_MOMENT, args = { FILTER_TYPE_MOMENT, FILTER_TYPE_PERIOD }, def = function ( v, a, b ) return a - b end },
["after"] = { type = FILTER_TYPE_MOMENT, args = { FILTER_TYPE_MOMENT, FILTER_TYPE_PERIOD }, def = function ( v, a, b ) return a + b end },
["day"] = { type = FILTER_TYPE_STRING, args = { FILTER_TYPE_MOMENT }, def = function ( v, a ) return os.date( "%a", a ) end },
["at"] = { type = FILTER_TYPE_MOMENT, args = { FILTER_TYPE_STRING }, def = function ( v, a ) return localtime( a ) or 0 end },
["ip"] = { type = FILTER_TYPE_STRING, args = { FILTER_TYPE_ADDRESS }, def = function ( v, a ) return table.concat( unpack_address( a ), "." ) end },
["count"] = { type = FILTER_TYPE_NUMBER, args = { FILTER_TYPE_SERIES, FILTER_TYPE_STRING }, def = function ( v, a, b ) local t = 0; for i, v in ipairs( a ) do if v == b then t = t + 1; end; end; return t end },
["clip"] = { type = FILTER_TYPE_SERIES, args = { FILTER_TYPE_SERIES, FILTER_TYPE_NUMBER }, def = function ( v, a, b ) local x = { }; local s = b < 0 and #a + b + 1 or 0; for i = 0, math.abs( b ) do table.insert( x, a[ s + i ] ); end; return x; end },
}
local do_math = { [FILTER_TYPE_NUMBER] = true, [FILTER_TYPE_PERIOD] = true, [FILTER_TYPE_MOMENT] = true, [FILTER_TYPE_DATESPEC] = true, [FILTER_TYPE_TIMESPEC] = true }
local periods = { y = 31536000, w = 604800, d = 86400, h = 3600, m = 60, s = 1 }
local parsers = {
{ expr = "^%$([a-zA-Z0-9_]+)$", proc = function ( refs, vars )
local name = refs[ 1 ]
if not vars[ name ] or vars[ name ].value == nil then
return nil
end
return { type = vars[ name ].type, value = vars[ name ].value, const = false }
end },
{ expr = "^(.-)([a-zA-Z0-9_]+)&([A-Za-z0-9+/]*);$", proc = function ( refs, vars )
local name = refs[ 2 ]
local suffix = decode_base64( refs[ 3 ] )
local prefix = refs[ 1 ]
suffix = string.gsub( suffix, "%b()", function( str )
-- encode nested function arguments
return "&" .. encode_base64( trim( str ) ) .. ";"
end )
local args = string.split( suffix, ",", false )
if string.match( prefix, "->$" ) then
-- insert prefixed arguments
table.insert( args, 1, string.sub( prefix, 1, -3 ) )
elseif prefix ~= "" then
return nil
end
if not funcs[ name ] or #funcs[ name ].args ~= #args then
return nil
end
local params = { }
local c = true
for i, a in ipairs( args ) do
local oper, ix, rx = self.get_operand( a, vars )
if not oper or oper.type ~= funcs[ name ].args[ i ] then
return nil
end
if not oper.const then
-- propagate non-constant to parent
c = false
end
table.insert( params, oper.value )
end
return { type = funcs[ name ].type, value = funcs[ name ].def( vars, unpack( params ) ), const = c }
end },
{ expr = "^&([A-Za-z0-9+/]*);$", proc = function ( refs, vars )
local suffix = decode_base64( refs[ 1 ] )
suffix = string.gsub( suffix, "%b()", function( str )
-- encode nested function arguments
return "&" .. encode_base64( trim( str ) ) .. ";"
end )
local elems = string.split( suffix, ",", false )
local v = { }
local c = true
for i, a in ipairs( elems ) do
local oper = self.get_operand( a, vars )
if not oper or oper.type ~= FILTER_TYPE_STRING then
return nil
end
if not oper.const then
-- propagate non-constant to parent
c = false
end
table.insert( v, oper.value )
end
return { type = FILTER_TYPE_SERIES, value = v, const = c }
end },
{ expr = "^@([a-zA-Z0-9_]+%.txt)$", proc = function ( refs, vars )
local v = { }
local file = io.open( path .. "/filters/" .. refs[ 1 ], "rb" )
if not file then
return nil
end
for line in file:lines( ) do
table.insert( v, line )
end
return { type = FILTER_TYPE_SERIES, value = v, const = true }
end },
{ expr = "^/([a-zA-Z0-9+/]*),([stda]);$", proc = function( refs, vars )
local v
local phrase = minetest.decode_base64( refs[ 1 ] )
if refs[ 2 ] == "s" then
v = StringPattern( phrase, { [FILTER_TYPE_STRING] = true }, {
["["] = "",
["]"] = "",
["^"] = "%^",
["$"] = "%$",
["("] = "%(",
[")"] = "%)",
["%"] = "%%",
["-"] = "%-",
[","] = "[a-z]",
[";"] = "[A-Z]",
["="] = "[-_]",
["!"] = "[a-zA-Z0-9]",
["*"] = "[a-zA-Z0-9_-]*",
["+"] = "[a-zA-Z0-9_-]+",
["?"] = "[a-zA-Z0-9_-]",
["#"] = "%d",
["&"] = "%a",
} )
elseif refs[ 2 ] == "t" then
phrase = string.split( phrase, ":", false )
v = NumberPattern( phrase, { [FILTER_TYPE_MOMENT] = true }, { "%d?%d", "%d%d", "%d%d" }, function ( value )
-- direct translation (accounts for daylight saving time and time-zone offset)
local timespec = os.date( "*t", value )
return { timespec.hour, timespec.min, timespec.sec }
end )
elseif refs[ 2 ] == "d" then
phrase = string.split( phrase, "-", false )
v = NumberPattern( phrase, { [FILTER_TYPE_MOMENT] = true }, { "%d%d", "%d%d", "%d%d%d%d" }, function ( value )
-- direct translation (accounts for daylight saving time and time-zone offset)
local datespec = os.date( "*t", value )
return { datespec.day, datespec.month, datespec.year }
end )
elseif refs[ 2 ] == "a" then
phrase = string.split( phrase, ".", false )
v = NumberPattern( phrase, { [FILTER_TYPE_ADDRESS] = true }, { "%d?%d?%d", "%d?%d?%d", "%d?%d?%d", "%d?%d?%d" }, function ( value )
return unpack_address( value )
end )
end
if not v then
return nil
end
return { type = FILTER_TYPE_PATTERN, value = v, const = true }
end },
{ expr = "^(%d+)([ywdhms])$", proc = function ( refs, vars )
local v = tonumber( refs[ 1 ] ) * periods[ refs[ 2 ] ]
return { type = FILTER_TYPE_PERIOD, value = v, const = true }
end },
{ expr = "^([-+]%d+)([ywdhms])$", proc = function ( refs, vars )
local origin = string.byte( refs[ 1 ] ) == 45 and vars.clock.value or vars.epoch.value
local v = origin + tonumber( refs[ 1 ] ) * periods[ refs[ 2 ] ]
return { type = FILTER_TYPE_MOMENT, value = v, const = true }
end },
{ expr = "^(%d?%d):(%d%d):(%d%d)$", proc = function ( refs, vars )
local timespec = {
isdst = false, day = 1, month = 1, year = 1970, hour = tonumber( refs[ 1 ] ), min = tonumber( refs[ 2 ] ), sec = tonumber( refs[ 3 ] ),
}
-- strip date component and time-zone offset (standardize time and account for overflow too)
local v = ( os.time( timespec ) - vars.epoch.value ) % 86400
return { type = FILTER_TYPE_TIMESPEC, value = v, const = true }
end },
{ expr = "^(%d%d)%-(%d%d)%-(%d%d%d%d)$", proc = function ( refs, vars )
local datespec = {
isdst = false, day = tonumber( refs[ 1 ] ), month = tonumber( refs[ 2 ] ), year = tonumber( refs[ 3 ] ), hour = 0,
}
-- strip time component and time-zone offset (standardize time too)
local v = math.floor( ( os.time( datespec ) - vars.epoch.value ) / 86400 )
return { type = FILTER_TYPE_DATESPEC, value = v, const = true }
end },
{ expr = "^'([a-zA-Z0-9+/]*);$", proc = function ( refs, vars )
local v = decode_base64( refs[ 1 ] )
return { type = FILTER_TYPE_STRING, value = v, const = true }
end },
{ expr = "^\"([a-zA-Z0-9+/]*);$", proc = function ( refs, vars )
local v = decode_base64( refs[ 1 ] )
local c = true
v = string.gsub( v, "%$([a-zA-Z_]+)", function ( name )
-- variable interpolation is non-constant
c = false
return vars[ name ] and tostring( vars[ name ].value ) or "?"
end )
return { type = FILTER_TYPE_STRING, value = v, const = c }
end },
{ expr = "^-?%d+$", proc = function ( refs, vars )
local v = tonumber( refs[ 1 ] )
return { type = FILTER_TYPE_NUMBER, value = v, const = true }
end },
{ expr = "^-?%d*%.%d+$", proc = function ( refs, vars )
local v = tonumber( refs[ 1 ] )
return { type = FILTER_TYPE_NUMBER, value = v, const = true }
end },
{ expr = "^(%d+)%.(%d+)%.(%d+)%.(%d+)$", proc = function ( refs, vars )
local v = tonumber( refs[ 1 ] ) * 16777216 + tonumber( refs[ 2 ] ) * 65536 + tonumber( refs[ 3 ] ) * 256 + tonumber( refs[ 4 ] )
return { type = FILTER_TYPE_ADDRESS, value = v, const = true }
end },
}
---- private methods ----
function trim( str )
return string.sub( str, 2, -2 )
end
function localtime( str )
-- daylight saving time is factored in automatically
local x = { string.match( str, "^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)Z$" ) }
return #x > 0 and os.time( { year = x[ 1 ], month = x[ 2 ], day = x[ 3 ], hour = x[ 4 ], min = x[ 5 ], sec = x[ 6 ] } ) or nil
end
function redate( ts )
-- convert to standard time (for timespec and datespec comparisons)
local x = os.date( "*t", ts )
x.isdst = false
return os.time( x )
end
---- public methods ----
self.define_func = function ( name, type, args, def )
funcs[ name ] = { type = type, args = args, def = def }
end
self.add_preset_vars = function ( vars )
vars["clock"] = { type = FILTER_TYPE_MOMENT, value = os.time( ) }
vars["epoch"] = { type = FILTER_TYPE_MOMENT, value = os.time( { year = 1970, month = 1, day = 1, hour = 0 } ) }
vars["true"] = { type = FILTER_TYPE_BOOLEAN, value = true }
vars["false"] = { type = FILTER_TYPE_BOOLEAN, value = false }
end
self.get_operand_parser = function ( token )
local match = string.match
for i, v in ipairs( parsers ) do
local refs = { match( token, v.expr ) }
if #refs > 0 then
return v.proc, refs
end
end
end
self.get_operand = function ( token, vars )
local proc, refs = self.get_operand_parser( token )
if proc then return proc( refs, vars ) end
end
self.translate = function ( input, vars )
return self.get_operand( self.tokenize( input ), vars )
end
self.get_result = function ( cond, comp, oper1, oper2 )
local type1 = oper1.type
local type2 = oper2.type
local expr
-- only allow comparisons of appropriate and equivalent datatypes
if comp == FILTER_COMP_EQ and type1 == type2 and type1 ~= FILTER_TYPE_SERIES and type1 ~= FILTER_TYPE_PATTERN then
expr = ( oper1.value == oper2.value )
elseif comp == FILTER_COMP_GT and type1 == type2 and do_math[ type2 ] then
expr = ( oper1.value > oper2.value )
elseif comp == FILTER_COMP_GTE and type1 == type2 and do_math[ type2 ] then
expr = ( oper1.value >= oper2.value )
elseif comp == FILTER_COMP_LT and type1 == type2 and do_math[ type2 ] then
expr = ( oper1.value < oper2.value )
elseif comp == FILTER_COMP_LTE and type1 == type2 and do_math[ type2 ] then
expr = ( oper1.value <= oper2.value )
elseif comp == FILTER_COMP_IS and type1 == FILTER_TYPE_STRING and type2 == FILTER_TYPE_STRING then
expr = ( string.upper( oper1.value ) == string.upper( oper2.value ) )
elseif comp == FILTER_COMP_IS and type2 == FILTER_TYPE_PATTERN then
expr = oper2.value.compare( oper1.value, type1 )
if expr == nil then return end
elseif comp == FILTER_COMP_HAS and type1 == FILTER_TYPE_SERIES and type2 == FILTER_TYPE_STRING then
local value2 = string.upper( oper2.value )
expr = false
for i, value1 in ipairs( oper1.value ) do
expr = ( string.upper( value1 ) == value2 )
if expr then break end
end
elseif comp == FILTER_COMP_HAS and type1 == FILTER_TYPE_SERIES and type2 == FILTER_TYPE_PATTERN then
local compare = oper2.value.compare
expr = false
for i, value1 in ipairs( oper1.value ) do
expr = compare( value1, FILTER_TYPE_STRING )
if expr == nil then return end
if expr then break end
end
elseif comp == FILTER_COMP_IN and type1 == FILTER_TYPE_STRING and type2 == FILTER_TYPE_SERIES then
local value1 = oper1.value
expr = false
for i, value2 in ipairs( oper2.value ) do
expr = ( value1 == value2 )
if expr then break end
end
else
return
end
if cond == FILTER_COND_FALSE then expr = not expr end
return expr
end
self.tokenize = function ( line )
-- encode string and pattern literals and function arguments to simplify parsing (order IS significant)
line = string.gsub( line, "\"(.-)\"", function ( str )
return "\"" .. encode_base64( str ) .. ";"
end )
line = string.gsub( line, "'(.-)'", function ( str )
return "'" .. encode_base64( str ) .. ";"
end )
line = string.gsub( line, "/(.-)/([stda]?)", function ( a, b )
return "/" .. encode_base64( a ) .. "," .. ( b == "" and "s" or b ) .. ";"
end )
line = string.gsub( line, "%b()", function ( str )
return "&" .. encode_base64( trim( str ) ) .. ";"
end )
return line
end
return self
end
----------------------------
-- AuthFilter subclass
----------------------------
function AuthFilter( path, name, debug )
local self = { }
local parent = GenericFilter( ) -- inherit from parent class
local src
local mode_defs = { ["pass"] = FILTER_MODE_PASS, ["fail"] = FILTER_MODE_FAIL }
local bool_defs = { ["all"] = FILTER_BOOL_AND, ["any"] = FILTER_BOOL_OR, ["one"] = FILTER_BOOL_XOR, ["now"] = FILTER_BOOL_NOW }
local cond1_defs = { ["when"] = FILTER_COND_TRUE, ["until"] = FILTER_COND_FALSE }
local cond2_defs = { ["if"] = FILTER_COND_TRUE, ["unless"] = FILTER_COND_FALSE }
local comp_defs = { ["in"] = FILTER_COMP_IN, ["eq"] = FILTER_COMP_EQ, ["gt"] = FILTER_COMP_GT, ["lt"] = FILTER_COMP_LT, ["gte"] = FILTER_COMP_GTE, ["lte"] = FILTER_COMP_LTE, ["has"] = FILTER_COMP_HAS, ["is"] = FILTER_COMP_IS }
---- private methods ----
local get_operand = parent.get_operand
local get_result = parent.get_result
local tokenize = parent.tokenize
local trace = debug or function ( msg, num )
minetest.log( "error", string.format( "%s (%s/%s, line %d)", msg, path, name, num ) )
return "The server encountered an internal error.", num
end
local evaluate = function ( rule )
-- short circuit binary logic to simplify evaluation
local res = ( rule.bool == FILTER_BOOL_AND )
local xor = 0
for i, v in ipairs( rule.expr ) do
if rule.bool == FILTER_BOOL_AND and not v then
return false
elseif rule.bool == FILTER_BOOL_OR and v then
return true
elseif rule.bool == FILTER_BOOL_XOR and v then
xor = xor + 1
end
end
if xor == 1 then return true end
return res
end
---- public methods ----
self.add_preset_vars = parent.add_preset_vars
self.refresh = function ( )
local file = io.open( path .. "/" .. name, "r" )
if not file then
file = io.open( path .. "/" .. name, "w+")
file:write("pass now")
if not file then
error( "The specified ruleset file does not exist." )
end
file:close( file )
file = io.open( path .. "/" .. name, "r" )
end
src = { }
for line in file:lines( ) do
-- skip comments (lines beginning with hash character) and blank lines
table.insert( src, string.byte( line ) ~= 35 and tokenize( line ) or "" )
end
file:close( file )
end
self.process = function( vars, is_local )
local rule
local note = "Access denied."
if is_local then
self.add_preset_vars( vars )
end
for num, line in ipairs( src ) do
local stmt = string.split( line, " ", false )
if #stmt == 0 then
-- skip no-op statements
elseif stmt[ 1 ] == "continue" then
if not rule then return trace( "Unexpected 'continue' statement in ruleset", num ) end
if #stmt ~= 1 then return trace( "Invalid 'continue' statement in ruleset", num ) end
if evaluate( rule ) then
return ( rule.mode == FILTER_MODE_FAIL and note or nil ), num
end
rule = nil
elseif stmt[ 1 ] == "try" then
if rule then return trace( "Missing 'continue' statement in ruleset", num ) end
if #stmt ~= 2 then return trace( "Invalid 'try' statement in ruleset", num ) end
local oper = get_operand( stmt[ 2 ], vars )
if not oper or oper.type ~= FILTER_TYPE_STRING then
return trace( "Unrecognized operand in ruleset", num )
end
note = oper.value
elseif stmt[ 1 ] == "pass" or stmt[ 1 ] == "fail" then
if rule then return trace( "Missing 'continue' statement in ruleset", num ) end
if #stmt ~= 2 then return trace( "Invalid 'pass' or 'fail' statement in ruleset", num ) end
local mode = mode_defs[ stmt[ 1 ] ]
local bool = bool_defs[ stmt[ 2 ] ]
if not mode or not bool then
return trace( "Unrecognized keywords in ruleset", num )
end
if bool == FILTER_BOOL_NOW then
return ( mode == FILTER_MODE_FAIL and note or nil ), num
end
rule = { mode = mode, bool = bool, expr = { } }
elseif stmt[ 1 ] == "when" or stmt[ 1 ] == "until" then
if rule then return trace( "Unexpected 'when' or 'until' statement in ruleset", num ) end
if #stmt ~= 5 then return trace( "Invalid 'when' or 'until' statement in ruleset", num ) end
local cond = cond1_defs[ stmt[ 1 ] ]
local comp = comp_defs[ stmt[ 3 ] ]
local mode = mode_defs[ stmt[ 5 ] ]
if not cond or not comp then
return trace( "Unrecognized keywords in ruleset", num )
end
local oper1 = get_operand( stmt[ 2 ], vars )
local oper2 = get_operand( stmt[ 4 ], vars )
if not oper1 or not oper2 then
return trace( "Unrecognized operands in ruleset", num )
end
local expr = get_result( cond, comp, oper1, oper2 )
if expr == nil then
return trace( "Mismatched operands in ruleset", num )
elseif expr then
return ( mode == FILTER_MODE_FAIL and note or nil ), num
end
elseif stmt[ 1 ] == "if" or stmt[ 1 ] == "unless" then
if not rule then return trace( "Unexpected 'if' or 'unless' statement in ruleset", num ) end
if #stmt ~= 4 then return trace( "Invalid 'if' or 'unless' statement in ruleset", num ) end
local cond = cond2_defs[ stmt[ 1 ] ]
local comp = comp_defs[ stmt[ 3 ] ]
if not cond or not comp then
return trace( "Unrecognized keywords in ruleset", num )
end
local oper1 = get_operand( stmt[ 2 ], vars )
local oper2 = get_operand( stmt[ 4 ], vars )
if not oper1 or not oper2 then
return trace( "Unrecognized operands in ruleset", num )
end
local expr = get_result( cond, comp, oper1, oper2 )
if expr == nil then
return trace( "Mismatched operands in ruleset", num )
end
table.insert( rule.expr, expr )
else
return trace( "Invalid statement in ruleset", num )
end
end
return trace( "Unexpected end-of-file in ruleset", 0 )
end
self.refresh( )
return self
end

45
mods/auth_rx/helpers.lua Normal file
View File

@ -0,0 +1,45 @@
--------------------------------------------------------
-- Minetest :: Auth Redux Mod v2.10 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
-----------------------------------------------------
-- Global Helper Functions
-----------------------------------------------------
get_minetest_config = core.setting_get -- backwards compatibility
function convert_ipv4( str )
local ref = string.split( str, ".", false )
return tonumber( ref[ 1 ] ) * 16777216 + tonumber( ref[ 2 ] ) * 65536 + tonumber( ref[ 3 ] ) * 256 + tonumber( ref[ 4 ] )
end
function unpack_address( addr )
return { math.floor( addr / 16777216 ), math.floor( ( addr % 16777216 ) / 65536 ), math.floor( ( addr % 65536 ) / 256 ), addr % 256 }
end
function get_default_privs( )
local default_privs = { }
for _, p in pairs( string.split( get_minetest_config( "default_privs" ), "," ) ) do
table.insert( default_privs, string.trim( p ) )
end
return default_privs
end
function unpack_privileges( assigned_privs )
local privileges = { }
for _, p in ipairs( assigned_privs ) do
privileges[ p ] = true
end
return privileges
end
function pack_privileges( privileges )
local assigned_privs = { }
for p, _ in pairs( privileges ) do
table.insert( assigned_privs, p )
end
return assigned_privs
end

167
mods/auth_rx/init.lua Normal file
View File

@ -0,0 +1,167 @@
--------------------------------------------------------
-- Minetest :: Auth Redux Mod v2.13 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
local world_path = minetest.get_worldpath( )
local mod_name = minetest.get_current_modname() or "auth_rx"
local mod_path = minetest.get_modpath( mod_name )
dofile( mod_path .. "/helpers.lua" )
dofile( mod_path .. "/filter.lua" )
dofile( mod_path .. "/db.lua" )
dofile( mod_path .. "/watchdog.lua" )
local __commands = dofile( mod_path .. "/commands.lua" )
AuthInitFile( world_path, "auth.db" )
AuthInitFile( world_path, "greenlist.mt" )
-----------------------------------------------------
-- Registered Authentication Handler
-----------------------------------------------------
local auth_filter = AuthFilter( world_path, "greenlist.mt" )
local auth_db = AuthDatabase( world_path, "auth.db" )
local auth_watchdog = AuthWatchdog( )
if minetest.register_on_auth_fail then
minetest.register_on_auth_fail( function ( player_name, player_ip )
auth_db.on_login_failure( player_name, player_ip )
auth_watchdog.on_failure( convert_ipv4( player_ip ) )
end )
end
minetest.register_on_prejoinplayer( function ( player_name, player_ip )
local rec = auth_db.select_record( player_name )
local meta = auth_watchdog.get_metadata( convert_ipv4( player_ip ) )
if rec then
auth_db.on_login_attempt( player_name, player_ip )
else
-- prevent creation of case-insensitive duplicate accounts
local uname = string.lower( player_name )
for cname in auth_db.records( ) do
if string.lower( cname ) == uname then
return string.format( "A player named %s already exists on this server.", cname )
end
end
end
local res = auth_filter.is_enabled and auth_filter.process( {
name = { type = FILTER_TYPE_STRING, value = player_name },
addr = { type = FILTER_TYPE_ADDRESS, value = convert_ipv4( player_ip ) },
is_new = { type = FILTER_TYPE_BOOLEAN, value = rec == nil },
privs_list = { type = FILTER_TYPE_SERIES, value = rec and rec.assigned_privs or { } },
users_list = { type = FILTER_TYPE_SERIES, value = auth_db.search( true ) },
cur_users = { type = FILTER_TYPE_NUMBER, value = #auth_db.search( true ) },
max_users = { type = FILTER_TYPE_NUMBER, value = get_minetest_config( "max_users" ) },
lifetime = { type = FILTER_TYPE_PERIOD, value = rec and rec.lifetime or 0 },
sessions = { type = FILTER_TYPE_NUMBER, value = rec and rec.total_sessions or 0 },
failures = { type = FILTER_TYPE_NUMBER, value = rec and rec.total_failures or 0 },
attempts = { type = FILTER_TYPE_NUMBER, value = rec and rec.total_attempts or 0 },
owner = { type = FILTER_TYPE_STRING, value = get_minetest_config( "name" ) },
uptime = { type = FILTER_TYPE_PERIOD, value = minetest.get_server_uptime( ) },
oldlogin = { type = FILTER_TYPE_MOMENT, value = rec and rec.oldlogin or 0 },
newlogin = { type = FILTER_TYPE_MOMENT, value = rec and rec.newlogin or 0 },
ip_names_list = { type = FILTER_TYPE_SERIES, value = meta.previous_names or { } },
ip_prelogin = { type = FILTER_TYPE_MOMENT, value = meta.prelogin or 0 },
ip_oldcheck = { type = FILTER_TYPE_MOMENT, value = meta.oldcheck or 0 },
ip_newcheck = { type = FILTER_TYPE_MOMENT, value = meta.newcheck or 0 },
ip_failures = { type = FILTER_TYPE_NUMBER, value = meta.count_failures or 0 },
ip_attempts = { type = FILTER_TYPE_NUMBER, value = meta.count_attempts or 0 }
}, true ) or nil
auth_watchdog.on_attempt( convert_ipv4( player_ip ), player_name )
return res
end )
minetest.register_on_joinplayer( function ( player )
local player_name
local player_oj
local player_ip
if player ~= nil then
player_name = player:get_player_name( )
player_oj = minetest.get_player_information( player_name )
player_ip = player_oj.address -- this doesn't work in singleplayer!
auth_db.on_login_success( player_name, player_ip )
auth_db.on_session_opened( player_name )
auth_watchdog.on_success( convert_ipv4( player_ip ) )
minetest.log( "warning", "[auth_rx] in authentication database players success joined ".. player_name )
else
minetest.log( "error", "[auth_rx] incomplete auth process on player obj nil ip obj address" )
end
end )
minetest.register_on_leaveplayer( function ( player )
local name
if player ~= nil then
name = player:get_player_name( )
auth_db.on_session_closed( name )
minetest.log( "warning", "[auth_rx] player "..name.." leaving" )
else
minetest.log( "error", "[auth_rx] incomplete player leaving without writing in dbx due nil object" )
end
end )
minetest.register_on_shutdown( function( )
auth_db.disconnect( )
end )
minetest.register_authentication_handler( {
-- translate old auth hooks to new database backend
get_auth = function( username )
local rec = auth_db.select_record( username )
if rec then
local assigned_privs = rec.assigned_privs
if get_minetest_config( "name" ) == username then
-- grant server operator all privileges
-- (TODO: implement as function that honors give_to_admin flag)
assigned_privs = { }
for priv in pairs( core.registered_privileges ) do
table.insert( assigned_privs, priv )
end
end
return { password = rec.password, privileges = unpack_privileges( assigned_privs ), last_login = rec.newlogin }
end
end,
create_auth = function( username, password )
if auth_db.create_record( username, password ) then
auth_db.set_assigned_privs( username, get_default_privs( ) )
minetest.log( "info", "Created player '" .. username .. "' in authentication database" )
end
end,
delete_auth = function( username )
if auth_db.delete_record( username ) then
minetest.log( "info", "Deleted player '" .. username .. "' in authenatication database" )
end
end,
set_password = function ( username, password )
if auth_db.set_password( username, password ) then
minetest.log( "info", "Reset password of player '" .. username .. "' in authentication database" )
end
end,
set_privileges = function ( username, privileges )
-- server operator's privileges are immutable
if get_minetest_config( "name" ) == username then return end
if auth_db.set_assigned_privs( username, pack_privileges( privileges ) ) then
minetest.notify_authentication_modified( username )
minetest.log( "info", "Reset privileges of player '" .. username .. "' in authentication database" )
end
end,
record_login = function ( ) end,
reload = function ( ) end,
iterate = auth_db.records
} )
auth_db.connect( )
auth_filter.is_enabled = true
__commands( { auth_db = auth_db, auth_filter = auth_filter } )
print("[auth_rx] mod authentication database loaded" )

7
mods/auth_rx/mod.conf Normal file
View File

@ -0,0 +1,7 @@
name = auth_rx
title = Auth Redux
author = sorcerykid
license = MIT
depends = default, formspecs
optional_depends =
description = Auth Redux is a drop-in replacement for the builtin authentication handler of Minetest. It is designed from the ground up to be robust and secure enough for use on high-traffic Minetest servers, while also addressing a number of outstanding engine bugs.

184
mods/auth_rx/samples.mt Normal file
View File

@ -0,0 +1,184 @@
#####################################################################
#
# only allow administrator access (by username or IP address)
#
#####################################################################
pass any
if $addr eq 172.16.100.1
if $addr eq 172.16.100.2
if $name eq "admin"
continue
fail now
#####################################################################
#
# block access from a range of IP addresses
#
#####################################################################
try "This subnet is blocked by the administrator."
fail any
if $addr is /192.88.99.6</a
if $addr is /203.0.113.?/a
if $addr is /192.168.12^14.?/a
continue
pass now
#####################################################################
#
# only allow access from whitelisted players
#
#####################################################################
try "The account '$name' is not permitted to join this server."
when $name in @whitelist.txt pass
fall now
#####################################################################
#
# never allow access from blacklisted players
#
#####################################################################
try "The account '$name' is not permitted to join this server."
when $name in @blacklist.txt fail
pass now
#####################################################################
#
# notify players that the server is unavailable right now
#
#####################################################################
try "The server is temporarily offline for maintenance."
fail now
#####################################################################
#
# disallow players with all uppercase names
#
#####################################################################
try "Sorry, we do not accept all uppercase player names."
when $name eq uc($name) fail
pass now
#####################################################################
#
# disallow players with very short or very long names
#
#####################################################################
try "Sorry, this player name is too long or too short."
fail any
if $name->len() gt 20
if $name->len() lt 3
continue
pass now
#####################################################################
#
# disallow users that appear to be bots or guests
#
#####################################################################
try "Sorry, we do not accept autogenerated player names."
fail any
if $name is /;*;*##/
if $name is /;*;*###/
if $name is /Player#/
if $name is /Player##/
if $name is /Guest#/
if $name is /Guest##/
continue
pass now
#####################################################################
#
# disallow new players when the server is near capacity
#
#####################################################################
try "There are too many players online right now."
fail all
if $is_new eq $true
if $cur_users gte $max_users->mul(0.8)
continue
pass now
#####################################################################
#
# prevent players from joining with a reserved name
#
#####################################################################
try "Sorry, this acccount has been permanently restricted."
fail all
if $is_new eq $true
if ("moderator","server","client","owner","player","system","operator","minetest") has $name
continue
pass now
#####################################################################
#
# disallow players that have been inactive for 90 days
#
#####################################################################
try "Sorry, this acccount has been disabled for inactivity."
fail all
if $is_new eq $false
if age($newlogin) gt 90d
continue
pass now
#####################################################################
#
# disallow new players during the weekends
#
#####################################################################
try "Sorry, we are not accepting new players at this time."
fail now
if $is_new eq $true
if $clock->day() in ("Sat","Sun")
continue
pass now
#####################################################################
#
# prevent players from spam-logging the server
#
#####################################################################
try "You are doing that too much. Please wait awhile."
fail all
if $is_new eq $false
if age($newlogin) lt 15s
continue
pass now

View File

@ -0,0 +1,106 @@
#!/bin/awk -f
################################################################################
# Database Import Script for Auth Redux Mod
# ------------------------------------------
# This script will convert the specified 'auth.txt' file into a database format
# required by the Auth Redux Mod. The output file will be generated in the same
# world directory as the original 'auth.txt' file (which will be unchanged).
#
# Setting the mode to 'install' will automatically install the required journal
# and ruleset files into the world directory as well.
#
# EXAMPLE:
# awk -f convert.awk -v mode=convert ~/.minetest/worlds/world/auth.txt
################################################################################
function error( msg ) {
skipped++;
print msg " at line " NR " in " FILENAME ".";
}
BEGIN {
FS = ":";
OFS = ":";
checked = 0;
skipped = 0;
db_file = "auth.db";
journal_file = "auth.dbx";
ruleset_file = "greenlist.mt";
# determine output file name from arguments
path = ARGV[ 1 ]
if( sub( /[-_A-Za-z0-9]+\.txt$/, "", path ) == 0 ) {
# sanity check for nonstandard input file
path = "";
}
# install required journal and ruleset files
if( mode == "install" ) {
print "Installing the required journal and ruleset files...";
print "" > path journal_file
print "pass now" > path ruleset_file
}
else if( mode != "convert" ) {
print "Unknown argument, defaulting to convert mode.";
}
# set default values for new database fields
approved_addrs = "";
oldlogin = -1;
lifetime = 0;
total_failures = 0;
total_attempts = 0;
total_sessions = 0;
# print database headline to the output file
print "Converting " ARGV[ 1 ] "...";
print "auth_rx/2.1 @0" > path db_file;
}
NF != 4 {
error( "Malformed record" );
next;
}
{
username = $1;
password = $2;
assigned_privs = $3;
newlogin = $4;
if( !match( username, "^[a-zA-Z0-9_-]+$" ) ) {
error( "Invalid username field" );
next;
}
if( !match( newlogin, "^[0-9]+$" ) && newlogin != -1 ) {
error( "Invalid last_login field" );
next;
}
# Database File Format
# --------------------
# username
# password
# oldlogin
# newlogin
# lifetime
# total_sessions
# total_attempts
# total_failures
# approved_addrs
# assigned_privs
print username, password, oldlogin, newlogin, lifetime, total_sessions, total_attempts, total_failures, approved_addrs, assigned_privs > path db_file;
checked++;
}
END {
print "Done! " checked " of " ( checked + skipped ) " total records were imported to " db_file " (" skipped " records skipped)."
}

View File

@ -0,0 +1,223 @@
#!/bin/awk -f
################################################################################
# Database Import Script for Auth Redux Mod (Step 2)
# ---------------------------------------------------
# WARNING: This script is to be run immediately after the database conversion.
#
# This script will extract player login activity from the specified 'debug.txt'
# file and produce an import journal in the world directory. For best results,
# the 'auth.txt' and 'debug.txt' files should be complete and current. If they
# are not entirely synchronous, then errors are likely to result.
#
# Lastly, run the rollback.lua script from the command line. This will replay
# the import journal against the newly converted database, applying all player
# login activity as it was derived from the corresponding debug log.
#
# Below is the required sequence of commands:
#
# > awk -f convert.awk -v mode=convert ~/.minetest/worlds/world/auth.txt
# > awk -f extract.awk ~/.minetest/worlds/world/auth.txt ~/.minetest/debug.txt
# > lua rollback.lua ~/.minetest/worlds/world/auth.db
#
# It is not necessary to operate on the original files. They can be moved into
# into a temporary subdirectory for use with all of these scripts.
#
# WARNING: The 'auth.dbx' file will be overwritten. This should not cause any
# problems if you began with a freshly converted database. When in doubt, then
# you may want to backup the 'auth.db' and 'auth.dbx' files as a precaution.
#
# For more detailed output, you can change the debug level as follows:
#
# > awk -f extract.awk -v debug=verbose ...
#
# This will display both errors and warnings. It might be helpful to redirect
# output to a temporary file for this purpose. To see only errors, the default
# debug level of 'terse' should be sufficient.
#
# Warnings occur if there are orphaned accounts in the 'auth.txt' file that do
# not appear in the corresponding debug log. This happens when the debug log
# is incomplete. Orphaned accounts will have no player login activity applied.
#
# Errors occur if the 'auth.txt' file is inconsistent with the debug log. This
# could be the result of a server crash or intentional deletion of accounts.
# Such accounts are deemed invalid and player login activity will be ignored.
# However, you should verify that you are using the correct 'auth.txt' file.
################################################################################
# USAGE EXAMPLE:
# awk -f extract.awk -v debug=terse /home/minetest/.minetest/worlds/world/auth.txt /home/minetest/.minetest/debug.txt
function get_timestamp( date_str, time_str ) {
return mktime( sprintf( "%d %d %d %d %d %d", \
substr( date_str, 1, 4 ), substr( date_str, 6, 2 ), substr( date_str, 9, 2 ), substr( time_str, 1, 2 ), substr( time_str, 4, 2 ), substr( time_str, 7, 2 ) ) );
}
function print_info( source, result ) {
if( is_verbose == 1 ) {
print "[" source "]", result;
}
}
function check_user( name ) {
if( name in db_users ) {
++db_users[ name ];
return 0;
}
if( !log_users[ name ] ) {
print "ERROR: Player '" name "' does not exist in auth.txt file.";
}
return ++log_users[ name ];
}
function trim( str ) {
return substr( str, 2, length( str ) - 2 )
}
BEGIN {
FS = " "
LOG_STARTED = 10
LOG_STOPPED = 12
LOG_TOUCHED = 13
TX_SESSION_OPENED = 50
TX_SESSION_CLOSED = 51
TX_LOGIN_FAILURE = 31
TX_LOGIN_SUCCESS = 32
journal_file = "auth.dbx";
is_started = 0;
is_verbose = 0;
total_records = 0;
if( debug == "verbose" ) {
is_verbose = 1;
}
else if( debug != "terse" ) {
print "ERROR: Unknown argument, defaulting to terse debug level.";
}
if( ARGC != 3 ) {
print( "The required arguments are missing, aborting." )
exit 1;
}
world_path = ARGV[ 1 ];
if( sub( /auth.txt$/, "", world_path ) == 0 ) {
print( "The specified auth.txt file is not recognized, aborting." )
exit 1;
}
if( ARGV[ 2 ] != "debug.txt" && ARGV[ 2 ] !~ /\/debug.txt$/ ) {
print( "The specified debug.txt file is not recognized, aborting." )
exit 1;
}
}
# parse the 'auth.txt' file
ARGIND == 1 && FNR == 1 {
print( "Reading the " world_path "auth.txt file..." )
}
ARGIND == 1 {
name = substr( $0, 1, index( $0, ":" ) - 1 )
db_users[ name ] = 0;
total_records++;
}
# parse the 'debug.txt' file
ARGIND == 2 && FNR == 1 {
print( "Reading the " world_path "debug.txt file..." )
}
ARGIND == 2 {
cur_date_str = $1;
cur_time_str = $2;
if( $3 == "ACTION[Main]:" && ( $4 FS $5 ) == "World at" ) {
# 2018-07-10 12:16:09: ACTION[Main]: World at [/root/.minetest/worlds/world]
print_info( "debug.txt", $1 " @connect" );
print get_timestamp( cur_date_str, cur_time_str ), LOG_STARTED > ( world_path journal_file );
is_started = 1;
}
else if( !is_started ) {
# sanity check since mod loading errors precede startup logging
# and shutdowns are not always immediate after they are logged.
next;
}
else if( $3 == "[Main]:" && $5 == "sigint_handler():" || $3 == "ERROR[Main]:" && ( $4 FS $5 ) == "stack traceback:" ) {
# 2018-07-10 06:47:46: [Main]: INFO: sigint_handler(): Ctrl-C pressed, shutting down.
# 2018-07-10 16:18:52: ERROR[Main]: stack traceback:
print_info( "debug.txt", $1 " @disconnect " ( $4 == "stack" ? "(err)" : "(sig)" ) );
print get_timestamp( cur_date_str, cur_time_str ), LOG_STOPPED > ( world_path journal_file );
is_started = 0;
}
else if( $3 == "ACTION[Server]:" && ( $5 == "joins" || $6 == "joins" || $5 == "leaves" || $5 == "times" || $5 == "shuts" || $4 == "Server:" ) ) { # optimization hack
cur_action_str = substr( $0, 38 );
if( cur_action_str ~ /^[a-zA-Z0-9_-]+ \[[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\] joins game\./ ) {
# 2017-06-09 16:49:26: ACTION[Server]: sorcerykid [127.0.0.1] joins game.
# print_info( "debug.txt", $1 " @on_login_success " $4 FS trim( $5 ) );
if( check_user( $4 ) > 0 ) next;
print get_timestamp( cur_date_str, cur_time_str ), TX_LOGIN_SUCCESS, $4, trim( $5 ) > ( world_path journal_file );
}
else if( cur_action_str ~ /^Server: User [a-zA-Z0-9_-]+ at [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ supplied wrong password/ ) {
# 2017-06-09 20:35:20: ACTION[Server]: Server: User sorcerykid at 127.0.0.1 supplied wrong password (auth mechanism: SRP)
# print_info( "debug.txt", $1 " @on_login_failure " $6 FS $8 );
if( check_user( $6 ) > 0 ) next;
print get_timestamp( cur_date_str, cur_time_str ), TX_LOGIN_FAILURE, $6, $8 > ( world_path journal_file );
}
else if( cur_action_str ~ /^[a-zA-Z0-9_-]+ joins game\./ ) {
# 2017-06-09 16:49:26: ACTION[Server]: sorcerykid joins game. List of players:
# print_info( "debug.txt", $1 " @on_session_opened " $4 );
if( check_user( $4 ) > 0 ) next;
print get_timestamp( cur_date_str, cur_time_str ), TX_SESSION_OPENED, $4 > ( world_path journal_file );
}
else if( cur_action_str ~ /^[a-zA-Z0-9_-]+ leaves game\./ || cur_action_str ~ /[a-zA-Z0-9_-]+ times out\./ ) {
# 2017-06-09 20:32:32: ACTION[Server]: sorcerykid leaves game. List of players:
# 2017-06-09 20:34:47: ACTION[Server]: sorcerykid times out. List of players:
# print_info( "debug.txt", $1 " @on_session_closed " $4 );
if( check_user( $4 ) > 0 ) next;
print get_timestamp( cur_date_str, cur_time_str ), TX_SESSION_CLOSED, $4 > ( world_path journal_file );
}
else if( cur_action_str ~ /^[a-zA-Z0-9_-]+ shuts down server/ ) {
# 2017-06-09 20:32:32: ACTION[Server]: sorcerykid shuts down server
# print_info( "debug.txt", $1 " @disconnect (req)" );
print get_timestamp( cur_date_str, cur_time_str ), LOG_STOPPED > ( world_path journal_file );
is_started = 0;
}
}
}
END {
total_orphans = 0;
for( name in db_users ) {
if( db_users[ name ] == 0 ) {
if( is_verbose == 1 ) {
print "WARNING: No player activity for '" name "' in debug.txt file.";
}
total_orphans++;
}
}
print "Total accounts in database: " total_records " (" total_orphans " orphaned accounts)";
print "Done!"
}

335
mods/auth_rx/tools/report.awk Executable file
View File

@ -0,0 +1,335 @@
#!/bin/awk -f
########################################################
# #
# Minetest :: Auth Redux Mod v2.11 (auth_rx) #
# #
# See README.txt for licensing and release notes. #
# Copyright (c) 2017-2018, Leslie E. Krause #
# #
########################################################
# Run this script and optionally redirect output to a text file
# awk -f report.awk -v days=1 type=txt ~/.minetest/worlds/world/auth.dbx
function throw( msg ) {
print msg > "/dev/tty";
status = 1;
exit 1;
}
function get_period( t ) {
return int( ( t - rel_time ) / 3600 )
}
function on_server_startup( cur_time ) {
server_start = cur_time
}
function on_server_shutdown( cur_time, _old_time, _new_time ) {
_old_time = server_start < rel_time ? rel_time : server_start;
_new_time = cur_time >= rel_time + 86400 ? rel_time + 86399 : cur_time;
server_uptime = server_uptime + ( _new_time - _old_time );
server_start = NIL;
}
function on_login_failure( cur_time ) {
hourly_failures[ get_period( cur_time ) ]++;
total_failures++;
}
function on_login_attempt( cur_time ) {
hourly_attempts[ get_period( cur_time ) ]++;
total_attempts++;
}
function on_create( cur_time, cur_user ) {
player_is_new[ cur_user ] = 1;
}
function on_session_opened( cur_time, cur_user ) {
while( cur_period < get_period( cur_time ) ) {
if( hourly_players[ cur_period ] == NIL ) {
# initialize client and player stats in prior periods
hourly_clients_max[ cur_period ] = cur_clients;
hourly_clients_min[ cur_period ] = cur_clients;
hourly_players[ cur_period ] = cur_clients;
}
cur_period++;
}
if( hourly_players[ cur_period ] == NIL ) {
# initialize client and player stats for this period
hourly_clients_max[ cur_period ] = cur_clients + 1;
hourly_clients_min[ cur_period ] = cur_clients;
hourly_players[ cur_period ] = cur_clients;
delete player_check;
}
else if( cur_clients + 1 > hourly_clients_max[ cur_period ] ) {
# update client stats for this period, if needed
hourly_clients_max[ cur_period ] = cur_clients + 1;
}
if( player_check[ cur_user ] == NIL ) {
# track another unique player
player_check[ cur_user ] = 1;
hourly_players[ cur_period ]++;
}
# update some general stats
if( player_is_new[ cur_user ] == 1 ) {
# only count new players after joining game (sanity check)
player_is_new[ cur_user ] = -1;
total_players_new++;
}
if( max_clients == NIL || cur_clients + 1 > max_clients ) {
max_clients = cur_clients + 1;
}
if( min_clients == NIL || cur_clients < min_clients ) {
min_clients = cur_clients;
}
}
function on_session_closed( cur_time, cur_user, _old_time, _new_time ) {
_old_time = player_login[ cur_user ] < rel_time ? rel_time : player_login[ cur_user ];
_new_time = cur_time >= rel_time + 86400 ? rel_time + 86399 : cur_time;
lifetime = _new_time - _old_time;
while( cur_period < get_period( _new_time ) ) {
if( hourly_players[ cur_period ] == NIL ) {
# initialize client and player stats in prior periods
hourly_clients_max[ cur_period ] = cur_clients;
hourly_clients_min[ cur_period ] = cur_clients;
hourly_players[ cur_period ] = cur_clients;
}
cur_period++;
}
if( hourly_players[ cur_period ] == NIL ) {
# initialize client and player stats for this period
hourly_clients_max[ cur_period ] = cur_clients;
hourly_clients_min[ cur_period ] = cur_clients - 1;
hourly_players[ cur_period ] = cur_clients;
delete player_check;
}
else if( cur_clients - 1 < hourly_clients_min[ cur_period ] ) {
# update client stats for this period, if needed
hourly_clients_min[ cur_period ] = cur_clients - 1;
}
if( player_check[ cur_user ] == NIL ) {
# track another unique player
player_check[ cur_user ] = 1;
}
for( p = get_period( _old_time ); p <= cur_period; p++ ) {
# update session stats in all prior periods
hourly_sessions[ p ]++;
}
# update some general stats
if( lifetime > max_lifetime ) {
max_lifetime = lifetime;
}
if( cur_time < rel_time + 86400 ) {
if( max_clients == NIL || cur_clients > max_clients ) {
max_clients = cur_clients;
}
if( min_clients == NIL || cur_clients - 1 < min_clients ) {
min_clients = cur_clients - 1;
}
}
if( !player_sessions[ cur_user ] ) {
# if no previous sessions, it's a unique player
total_players++;
}
total_sessions++;
total_lifetime += lifetime;
player_lifetime[ cur_user ] += lifetime;
player_sessions[ cur_user ]++;
}
BEGIN {
NIL = ""; # undefined variables are ambiguous (either 0 or "", so we'll create our own nil)
TX_CREATE = 20;
TX_SESSION_OPENED = 50;
TX_SESSION_CLOSED = 51;
TX_LOGIN_ATTEMPT = 30;
TX_LOGIN_FAILURE = 31;
LOG_STARTED = 10;
LOG_CHECKED = 11;
LOG_STOPPED = 12;
stat_bar[ 0 ] = "-";
stat_bar[ 1 ] = "\\";
stat_bar[ 2 ] = "|";
stat_bar[ 3 ] = "/";
stat_idx = 0;
cur_period = 0;
if( ARGC != 2 ) {
throw( "The required arguments are missing, aborting." );
}
if( ARGV[ 1 ] != "-" && ARGV[ 1 ] !~ /\.dbx$/ ) {
throw( "The specified journal file is not recognized, aborting." );
}
if( days !~ /^[0-9]+$/ ) {
throw( "The required 'days' parameter is invalid, aborting." );
}
if( type != "txt" && type != "js" ) {
throw( "The required 'type' parameter is invalid, aborting." );
}
# calculate the relative date from offset
rel_date = ( int( systime( ) / 86400 ) - days )
rel_time = rel_date * 86400;
printf "Working on it..." > "/dev/tty";
}
{
# show an animated progress indicator
if( stat_idx++ % 50001 == 0 ) printf "%s\b", stat_bar[ stat_idx % 4 ] > "/dev/tty";
cur_time = $1;
if( $2 == TX_LOGIN_ATTEMPT ) {
if( cur_time >= rel_time && cur_time < rel_time + 86400 ) {
on_login_attempt( cur_time )
}
}
else if( $2 == TX_LOGIN_FAILURE ) {
if( cur_time >= rel_time && cur_time < rel_time + 86400 ) {
on_login_failure( cur_time )
}
}
else if( $2 == TX_CREATE ) {
cur_user = $3;
if( cur_time >= rel_time && cur_time < rel_time + 86400 ) {
on_login_attempt( cur_time )
on_create( cur_time, cur_user )
}
}
else if( $2 == TX_SESSION_OPENED ) {
# player joined game
cur_user = $3;
if( cur_time < rel_time + 86400 ) {
player_login[ cur_user ] = cur_time;
if( cur_time >= rel_time ) {
# only track sessions within the specified timeframe
on_session_opened( cur_time, cur_user )
}
}
cur_clients++;
}
else if( $2 == TX_SESSION_CLOSED ) {
# player left game
cur_user = $3;
if( cur_time >= rel_time && cur_user in player_login ) {
# only track sessions within the specified timeframe
on_session_closed( cur_time, cur_user )
}
cur_clients--;
delete player_login[ cur_user ];
}
else if( $2 == LOG_STARTED ) {
if( cur_time < rel_time + 86400 ) {
on_server_startup( cur_time )
}
# sanity check (these should already not exist!)
delete player_login;
cur_clients = 0;
}
else if( $2 == LOG_STOPPED || $2 == LOG_CHECKED ) {
if( cur_time >= rel_time && server_start != NIL ) {
on_server_shutdown( cur_time )
}
# on server shutdown, all players logged off
for( cur_user in player_login ) {
if( cur_time >= rel_time ) {
# only track sessions within the specified timeframe
on_session_closed( cur_time, cur_user )
}
}
# purge stale data for next server startup
delete player_login;
cur_clients = 0;
}
}
END {
# abort during an abnormal condition
if( status ) exit;
printf "Done!\n" > "/dev/tty";
avg_lifetime = total_players > 0 ? total_lifetime / total_sessions : 0;
if( type == "txt" ) {
print "Daily Player Analytics Report (" strftime( "%d-%b-%Y UTC", rel_time, 1 ) ")\n";
print "Player Activity: 24-Hour Totals";
print "===========================================";
print sprintf( " %-19s %10s %10s", "Player", "Sessions", "Lifetime", "Failures", "Attempts" );
print "-------------------------------------------";
for( i in player_sessions ) {
print sprintf( " %-19s %10d %5dm %02ds", player_is_new[ i ] ? "* " i : i, player_sessions[ i ], player_lifetime[ i ] / 60, player_lifetime[ i ] % 60 );
}
print "-------------------------------------------";
print "\nPlayer Activity: Hourly Totals";
print "======================================================";
print sprintf( " %-8s %10s %10s %10s %10s", "Period", "Sessions", "Failures", "Attempts", "Players" );
print "------------------------------------------------------";
for( i = 0; i < 24; i++ ) {
print sprintf( " [%02d:00] %10s %10s %10s %10s", i,
i in hourly_sessions ? hourly_sessions[ i ] : 0,
i in hourly_failures ? hourly_failures[ i ] : 0,
i in hourly_attempts ? hourly_attempts[ i ] : 0,
i in hourly_players ? hourly_players[ i ] : 0 );
}
print "------------------------------------------------------";
print "\nPlayer Activity: Hourly Trends";
print "===========================================";
print sprintf( " %-9s %15s %15s", "Period", "Min Clients", "Max Clients" );
print "-------------------------------------------";
for( i = 0; i < 24; i++ ) {
print sprintf( " [%02d:00] %15s %15s", i,
i in hourly_clients_min ? hourly_clients_min[ i ] : 0,
i in hourly_clients_max ? hourly_clients_max[ i ] : 0 );
}
print "-------------------------------------------";
print "\nPlayer Activity: 24-Hour Summary"
print "===========================================";
print sprintf( " %-30s %10d", "Total Players:", total_players );
print sprintf( " %-30s %10d", "Total New Players:", total_players_new );
print sprintf( " %-30s %10d", "Total Player Sessions:", total_sessions );
print sprintf( " %-30s %10d", "Total Login Failures:", total_failures );
print sprintf( " %-30s %10d", "Total Login Attempts:", total_attempts );
print sprintf( " %-30s %9d%%", "Overall Server Uptime:", server_uptime / 86399 * 100 );
print sprintf( " %-30s %10d", "Maximum Connected Clients:", max_clients );
print sprintf( " %-30s %10d", "Minimum Connected Clients:", min_clients );
print sprintf( " %-30s %5dm %02ds", "Maximum Player Lifetime:", max_lifetime / 60, max_lifetime % 60 );
print sprintf( " %-30s %5dm %02ds", "Average Player Lifetime:", avg_lifetime / 60, avg_lifetime % 60 );
print "-------------------------------------------";
}
else if( type == "js" ) {
printf "{ datespec: %d, filespec: \"%s\", ", rel_date, ARGV[ 1 ];
printf "global_stats: { total_players: %d, total_players_new: %d, total_sessions: %d, total_failures: %d, total_attempts: %d, server_uptime: %d, max_clients: %d, min_clients: %d, max_lifetime: %d, avg_lifetime: %d }, ",
total_players, total_players_new, total_sessions, total_failures, total_attempts, server_uptime, max_clients, min_clients, max_lifetime, avg_lifetime;
printf "hourly_stats: [ ";
for( i = 0; i < 24; i++ ) {
# printf coerces any nil values to zero automatically
printf "{ sessions: %d, failures: %d, attempts: %d, players: %d, clients_max: %d, clients_min: %d }, ",
hourly_sessions[ i ], hourly_failures[ i ], hourly_attempts[ i ], hourly_players[ i ], hourly_clients_max[ i ], hourly_clients_min[ i ];
}
printf "], ";
printf "player_stats: { ";
for( i in player_sessions ) {
printf "\"%s\": { sessions: %d, lifetime: %d }, ", i, player_sessions[ i ], player_lifetime[ i ]
}
printf "} ";
printf "};\n";
}
}

View File

@ -0,0 +1,38 @@
#!/bin/awk -f
################################################################################
# Database Export Script for Auth Redux Mod
# ------------------------------------------
# This script will revert to the default 'auth.txt' flat-file database required
# by the builtin authentication handler.
#
# EXAMPLE:
# awk -f revert.awk ~/.minetest/worlds/world/auth.db
################################################################################
BEGIN {
FS = ":";
OFS = ":";
db_file = "auth.txt";
path = ARGV[ 1 ]
if( sub( /[-_A-Za-z0-9]+\.db$/, "", path ) == 0 ) {
# sanity check for nonstandard input file
path = "";
}
print "Reverting " ARGV[ 1 ] "...";
}
NF == 10 {
username = $1;
password = $2;
assigned_privs = $10;
newlogin = $4;
print username, password, assigned_privs, newlogin > path db_file;
}
END {
print "Done!"
}

View File

@ -0,0 +1,66 @@
--------------------------------------------------------
-- Minetest :: Auth Redux Mod v2.4 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
minetest = { }
function string.split( str, sep, has_nil )
res = { }
for val in string.gmatch( str .. sep, "(.-)" .. sep ) do
if val ~= "" or has_nil then
table.insert( res, val )
end
end
return res
end
minetest.log = function ( act, str )
print( "[" .. act .. "]", str )
end
minetest.register_globalstep = function ( ) end
--------------------------------------------------------
dofile( "../db.lua" )
local name = "auth.db"
local path = "."
print( "******************************************************" )
print( "* This script will rollback the Auth Redux database. *" )
print( "* Do not proceed unless you know what you are doing! *" )
print( "* -------------------------------------------------- *" )
print( "* Usage Example: *" )
print( "* lua rollback.lua ~/.minetest/worlds/world/auth.db *" )
print( "******************************************************" )
if arg[ 1 ] and arg[ 1 ] ~= "auth.db" then
path = string.match( arg[ 1 ], "^(.*)/auth%.db$" )
if not path then
error( "Invalid arguments specified." )
end
end
print( "The following database will be modified:" )
print( " " .. path .. "/" .. name )
print( )
io.write( "Do you wish to continue (y/n)? " )
local opt = io.read( 1 )
if opt == "y" then
print( "Initiating rollback procedure..." )
local auth_db = AuthDatabase( path, name )
auth_db.rollback( )
os.rename( path .. "/" .. name .. "x", path .. "/~" .. name .. "x" )
if not io.open( path .. "/" .. name .. "x", "w+b" ) then
minetest.log( "error", "Cannot open " .. path .. "/~" .. name .. " for writing." )
end
end

47
mods/auth_rx/watchdog.lua Normal file
View File

@ -0,0 +1,47 @@
--------------------------------------------------------
-- Minetest :: Auth Redux Mod v2.9 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
----------------------------
-- AuthWatchdog Class
----------------------------
function AuthWatchdog( )
local self = { }
local clients = { }
self.get_metadata = function ( ip )
return clients[ ip ] or { }
end
self.on_failure = function ( ip )
local meta = clients[ ip ]
meta.count_failures = meta.count_failures + 1
meta.newcheck = os.time( )
if not meta.oldcheck then
meta.oldcheck = os.time( )
end
return meta
end
self.on_success = function ( ip )
clients[ ip ] = nil
end
self.on_attempt = function ( ip, name )
if not clients[ ip ] then
clients[ ip ] = { count_attempts = 0, count_failures = 0, previous_names = { } }
end
local meta = clients[ ip ]
meta.count_attempts = meta.count_attempts + 1
meta.prelogin = os.time( )
table.insert( meta.previous_names, name )
return meta
end
return self
end

447
mods/formspecs/README.md Normal file
View File

@ -0,0 +1,447 @@
minetest ActiveFormspecs
========================
**active formspecs** self-contained API that provides formspecs secure session
tracking, formspecs session-based state tables, and localized event handling of
formspecs
Information
------------
This mod is named `formspecs` an improved version of dinamic secured forms
![](screenshot.png?raw=true)
ActiveFormspecs (named `formspecs` is a self-contained API that provides secure session tracking,
session-based state tables, and localized event handling of formspecs for individual mods as well
as entire games. It evolved out of a recurring need for secure "node formspecs" on my
server, while avoiding the burden of "reinventing the wheel" with every new project.
## Tecnical info
Since I've had many requests for source code from the Just Test Tribute subgame,
I finally decided to release ActiveFormspecs with instructions and code examples
so that other mod authors can start making use of this framework as well.
It's easy to install and use, and arguably a more robust alternative to the
builtin formspecs API.
ActiveFormspecs is intended to be compatible with all versions of Minetest as possible,
currently tested with 0.4.16 and 5.2.
It has been in continuous use on my server since December 2016 with only minor revisions.
So it should prove secure and stable enough for any production environment, as long as
you follow the instructions below.
#### Dependences
none, core minetest api
#### Overview:
ActiveFormspecs is a framework that abstracts the builtin formspec API of Minetest.
It is intended to address a number of known security issues related to formspecs:
* **Secure Session Tracking**
Formspec names have been deprecated as they can be easily forged. Now each
formspec session is assigned a unique session ID. Due to the persistent nature
of the Minetest client-server protocol (unlike HTTP, for example), all session
tracking is performed server-side. Negotiation and validation with the client
is entirely unnecessary. Thus, integrity of the session ID is always guaranteed.
* **Session-Based State Table**
Since the session ID token is retained throughout the lifetime of the formspec, it is therefore possible
to update a formspec dynamically (e.g. in response to an event) with contextual data spanning multiple
instances. This data is stored server-side via a session-based state table and it can even be initialized
from within the formspec string itself using a new "hidden" element.
* **Localized Event Handling**
The minetest.register_on_player_receive_fields( ) method has also been deprecated. Instead, each formspec
is assigned its own callback function at runtime, which allows for completely localized event handling.
This callback function is invoked after any event associated with the formspec
(hence the moniker "ActiveFormspecs"). Both the meta table and form fields are passed as arguments.
#### Progress and status
https://forum.minetest.net/viewtopic.php?f=9&t=19303
The project is a WIP and will be undergoing continuous development based upon your suggestions as well
as my personal needs. Version 3.0 is already underway, and I am planning to introduce substantial
improvements to the core functionality. New features and bug-fixes will be announced here as they become
available. During significant milestones, I will include a roadmap so as to gauge your feedback about
long-term goals. I will make every effort to ensure backward compatibility, when possible.
#### Usage Instructions:
An interactive form session monitor can be accessed in-game via the /fs chat command (requires "server" privilege).
A realtime summary of form sessions is displayed along with navigation buttons and related statistics.
![](screenshot.png?raw=true)
The summary is sorted chronologically and divided into four columns per player:
* player - the name of the player viewing the formspec
* origin - the mod (or node, if attached) that created the formspec
* idletime - the elapsed time since a form-related event or signal
* lifetime - the elapsed time since the form was first opened
A detached formspec can be opened using the minetest.create_form( ) method which returns a hashed session
token (guaranteed to be unique):
* minetest.create_form( state, player_name, formspec, on_close, signal )
* state - an optional session-based state-table (can be nil)
* player_name - a valid player name
* formspec - a standard formspec string
* on_close - an optional callback function to be invoked after an event or signal
* signal - an optional signal to pass to the on_close( ) callback function
The form can subsequently be re-opened or closed using the following methods:
* minetest.update_form( player_name, formspec )
* player_name - a valid player name
* formspec - a standard formspec string
* minetest.destroy_form( player_name )
* player_name - a valid player name
The associated on_close( ) callback function will be automatically invoked with three arguments during a
form-related event or signal, including closure. The return value is currently ignored, but in the future
it may allow for additional behavior.
* on_close( state, player, fields )
* state - the session-based state table
* player - the player object
* fields - the table of submitted fields
In the event of abnormal form session termination, the callback function will receive a signal indicating
the specific condition that led to the closure. One of the following values will be stored in the "quit"
field:
* minetest.FORMSPEC_SIGEXIT (player clicked exit button or pressed esc key)
* minetest.FORMSPEC_SIGQUIT (player logged off)
* minetest.FORMSPEC_SIGKILL (player was killed)
* minetest.FORMSPEC_SIGTERM (server is shutting down)
* minetest.FORMSPEC_SIGPROC (programmatic closure)
* minetest.FORMSPEC_SIGTIME (timeout reached)
* minetest.FORMSPEC_SIGHOLD (child form opened)
* minetest.FORMSPEC_SIGCONT (child form closed)
A non-trappable SIGSTOP signal can also be passed to minetest.destroy_form( ) or minetest.create_form( )
to forcibly kill the current form session, thereby avoiding recursion in callbacks during a procedural
form closure.
```
local function open_alertbox( player_name, message )
local function get_formspec( )
local formspec = <generate a basic formspec string here>
return formspec
end
minetest.create_form( nil, player_name, get_formspec( ), nil, minetest.FORMSPEC_SIGSTOP )
end
```
When form closure may produce unanticipated side-effects, such as the example above, then the SIGSTOP
signal can prove essential to avoid or even defer such actions.
Cascading forms such as dialog boxes, alerts, etc. can be implemented through the use of the SIGHOLD
and SIGCONT signals. By passing a SIGHOLD signal to minetest.create_form(), the callback will be
notified of the signal and the form session will be suspended (this also stops any form timers).
As soon as the child form is closed, the previous form session will be restored and the callback
will be notified of a SIGCONT signal. ActiveFormspecs manages all of the session housekeeping behind
the scenes.
Here is an example of a popup alert that, when opened, will temporarily suspend the current form
session until closed:
```
local function open_alert( player_name, message )
local get_formspec = function ( )
local formspec = "size[4,3]"
.. default.gui_bg_img
.. string.format( "label[0.5,0.5;%s]", minetest.formspec_escape( message ) )
.. "button_exit[1.0,2;2.0,1;close;Close]"
return formspec
end
minetest.create_form( nil, player_name, get_formspec( ), nil, minetest.FORMSPEC_SIGHOLD )
end
```
One caveat is that the parent form must be updated whenever a SIGCONT signal is received. This is for
security reasons, given that element states (like dropdowns, scrollbars, etc.) cannot be preserved
automatically, and some elements may even contain stale data (as with the case of textareas, fields,
etc.) which should not be preserved anyway. Thankfully, only one line of code is required:
```
if fields.quit == minetest.FORMSPEC_SIGCONT then minetest.update_form( player_name, get_formspec( ) ) end
```
Formspecs are also supported within a node definition. If an on_open( ) method is defined, then it will
be invoked whenever the player right-clicks the node. The return value must be a standard formspec string,
or nil to abort.
* nodedef.on_open( pos, player, fields )
* pos - the position of the node (or your own state table)
* player - the player object
The on_close( ) method is invoked during any formspec event or signal. If defined,
it will receive three arguments. Currently, the return value is ignored, but in the
future it may allow for additional behavior.
* nodedef.on_close( pos, player, fields )
* pos - the position of the node (or your own state table)
* player - the player object
* fields - the table of submitted fields
An optional before_open( ) method may also be defined. It should return a custom
state table to be subsequently passed to the on_open( ) method in place of the
default "pos" parameter.
* nodedef.before_open( pos, node, player )
* pos - the position of the node (or your own state table)
* node - the node
* player - the player object
The "hidden" formspec element of allows for a slightly different workflow, by
presetting the state table from the formspec string itself. The key-value pairs
are used only for initialization and will never be transmitted to the client.
An optional data type can be specified within the element as follows:
* hidden[name;value;string]
* hidden[name;value;boolean]
* hidden[name;value;number]
* hidden[name;value]
If the data type is not specified, then no type-conversion will occur. Here are
some examples:
* hidden[pos_x;-240;number]"
* hidden[wool_color;darkgreen;string]"
* hidden[is_dead;true;boolean]"
Through the use of form timers, it possible to re-open a formspec at regular intervals,
or even to close the formspec after a specified period. There is no need for complex
chains of minetest.after( ) or additional globalstep registrations. A form timer persists
for the duration of a form session.
* minetest.get_form_timer( player_name, form_name )
Returns a form timer object associated with the form session of a given player.
Three methods are exposed by the form timer object, providing similar behavior to node timers.
* timer.start( timeout ) Starts a timer with the given timeout in seconds.
* timer.stop( ) Cancels a running timer.
* timer.get_state( ) Returns a table with information about the running timer
The table returned including:
* elapsed - the number of seconds since the timer started
* remain - the number of seconds until the timer expires
* overrun - the number of milliseconds overrun for this period (only valid within an on_close( ) callback)
* counter - the number of periods that have accrued
The associated on_close( ) callback function will be notified of timer expiration via a SIG_TIME signal.
#### Code examples
To better understand this methodology, here are some working code examples. Let's say that we want to register an "uptime" chat command that displays the current server uptime to any player, with an option to automatically refresh the formspec each second.
```
minetest.register_chatcommand( "uptime", {
description = "View the uptime of the server interactively",
func = function( player_name, param )
local is_refresh = true
local get_formspec = function( )
local uptime = minetest.get_server_uptime( )
local formspec = "size[4,2]"
.. string.format( "label[0.5,0.5;%s %d secs]",
minetest.colorize( "#FFFF00", "Server Uptime:" ), uptime
)
.. "checkbox[0.5,1;is_refresh;Auto Refresh;" .. tostring( is_refresh ) .. "]"
return formspec
end
local on_close = function( state, player, fields )
if fields.quit == minetest.FORMSPEC_SIGTIME then
minetest.update_form( player_name, get_formspec( ) )
elseif fields.is_refresh then
is_refresh = fields.is_refresh == "true"
if is_refresh == true then
minetest.get_form_timer( player_name ).start( 1 )
else
minetest.get_form_timer( player_name ).stop( )
end
end
end
minetest.create_form( nil, player_name, get_formspec( ), on_close )
minetest.get_form_timer( player_name ).start( 1 )
end
} )
```
Of course, we could implement similar functionality without the need for a chat command. Perhaps we want to display the server uptime only when a privileged player clicks on a Nyan Cat while limiting the total number of refreshes to ten.
```
minetest.register_privilege( "uptime", "View the uptime of the server interactively" )
local function open_system_monitor( player_name, is_minutes )
local view_count = 0
local view_limit = 10
local function get_formspec( )
local uptime = minetest.get_server_uptime( )
local formspec = "size[4,3]"
.. string.format( "label[0.5,0.5;%s %0.1f %s]",
minetest.colorize( "#FFFF00", "Server Uptime:" ),
is_minutes and uptime / 60 or uptime,
is_minutes and "mins" or "secs"
)
.. "checkbox[0.5,1;is_minutes;Show Minutes;" .. tostring( is_minutes ) .. "]"
.. "button[0.5,2;2.5,1;update;Refresh]"
.. "hidden[view_count;1;number]"
.. "hidden[view_limit;10;number]"
return formspec
end
minetest.create_form( nil, player_name, get_formspec( ), function( state, player, fields )
if not minetest.check_player_privs( player_name, "uptime" ) then -- sanity check
return
end
if fields.update then
-- limit the number of refreshes!
if view_count == view_limit then
minetest.destroy_form( player_name )
minetest.chat_send_player( player_name, "You've exceeded the refresh limit." )
else
view_count = view_count + 1
minetest.update_form( player_name, get_formspec( ) )
end
elseif fields.is_minutes then
is_minutes = fields.is_minutes == "true"
minetest.update_form( player_name, get_formspec( ) )
end
end )
end
minetest.override_item( "nyancat:nyancat", {
description = "System Monitor",
on_rightclick = function( pos, node, player )
local player_name = player:get_player_name( )
if minetest.check_player_privs( player_name, "uptime" ) then
open_system_monitor( player_name, true )
else
minetest.chat_send_player( player_name, "Your privileges are insufficient." )
end
end,
} )
```
Revision History
----------------------
Version 1.0a (15-Dec-2016)
- initial version within default mod
Version 1.1b (16-Dec-2016)
- added better comments
Version 1.2b (18-Dec-2016)
- renamed public methods
Version 1.3b (04-Jan-2017)
- fixed logic of quit event to require valid session
Version 1.4b (26-Jul-2017)
- added method to update formspec and maintain session
Version 2.0 (24-Dec-2017)
- separated all routines into new mod for public release
Version 2.1 (08-Jan-2018)
- various code refactoring and better comments
- introduced password hashing of form names
- improved sanity checks during form submission
- fully reworked parsing of hidden elements
- ensured hidden elements are always stripped
- gave hidden elements default-state behavior
- localized old node registration functions
- included player object within form table
- added signal handling on formspec termination
- added support for callbacks in node overrides
Version 2.2 (19-Jan-2018)
- introduced player-name arguments for all API calls
- added signal for programmatic formspec closure
- ensured callbacks are notified of session resets
- renamed some local variables to improve clarity
Version 2.3 (28-Jan-2018)
- corrected erroneous value of formspec exit signal
- removed two experimental form session methods
- included timestamp and origin within form table
- added form session validation on destroy and update
- introduced form timers with start and stop methods
- created routine to notify callbacks of timeout
- added support for internal statistical tracking
- added chat command to view form session summary
Version 2.4 (12-Feb-2018)
- various code refactoring and better comments
- full rewrite of timer queue for higher precision
Version 2.5 (01-Feb-2019)
- made callback function optional with default no-op
- added non-trappable form session termination signal
- properly reset timestamp for lifetime calculation
Version 2.6 (02-Feb-2020)
- added callback to preset state of node formspecs
- removed experimental property from node definition
- implemented reverse-lookups for dropdown fields
- extended dropdown element with optional parameter
- added signal to suspend and restore form sessions
- combined element parsers into dedicated function
- added functions for escaping formspec strings
- revamped element parsers to ignore malformed tags
- added conditional pattern matching helper function
- compatability bumped to Minetest 0.4.15+
Compatibility
----------------------
Minetest 0.4.15+ required or 5.X
Installation
----------------------
1. Unzip the archive into the mods directory or game mods directory
2. Get sure to the name of this directory to "formspecs" (if you cloned or decompress)
3. Add "formspecs" as a dependency to any mods using the API.
Source Code License
----------------------
The MIT License (MIT)
Copyright (c) 2016-2020, Leslie E. Krause (leslie@searstower.org) aka sorceredkid
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.
For more details:
https://opensource.org/licenses/MIT

447
mods/formspecs/README.txt Normal file
View File

@ -0,0 +1,447 @@
minetest ActiveFormspecs
========================
**active formspecs** self-contained API that provides formspecs secure session
tracking, formspecs session-based state tables, and localized event handling of
formspecs
Information
------------
This mod is named `formspecs` an improved version of dinamic secured forms
![](screenshot.png?raw=true)
ActiveFormspecs (named `formspecs` is a self-contained API that provides secure session tracking,
session-based state tables, and localized event handling of formspecs for individual mods as well
as entire games. It evolved out of a recurring need for secure "node formspecs" on my
server, while avoiding the burden of "reinventing the wheel" with every new project.
## Tecnical info
Since I've had many requests for source code from the Just Test Tribute subgame,
I finally decided to release ActiveFormspecs with instructions and code examples
so that other mod authors can start making use of this framework as well.
It's easy to install and use, and arguably a more robust alternative to the
builtin formspecs API.
ActiveFormspecs is intended to be compatible with all versions of Minetest as possible,
currently tested with 0.4.16 and 5.2.
It has been in continuous use on my server since December 2016 with only minor revisions.
So it should prove secure and stable enough for any production environment, as long as
you follow the instructions below.
#### Dependences
none, core minetest api
#### Overview:
ActiveFormspecs is a framework that abstracts the builtin formspec API of Minetest.
It is intended to address a number of known security issues related to formspecs:
* **Secure Session Tracking**
Formspec names have been deprecated as they can be easily forged. Now each
formspec session is assigned a unique session ID. Due to the persistent nature
of the Minetest client-server protocol (unlike HTTP, for example), all session
tracking is performed server-side. Negotiation and validation with the client
is entirely unnecessary. Thus, integrity of the session ID is always guaranteed.
* **Session-Based State Table**
Since the session ID token is retained throughout the lifetime of the formspec, it is therefore possible
to update a formspec dynamically (e.g. in response to an event) with contextual data spanning multiple
instances. This data is stored server-side via a session-based state table and it can even be initialized
from within the formspec string itself using a new "hidden" element.
* **Localized Event Handling**
The minetest.register_on_player_receive_fields( ) method has also been deprecated. Instead, each formspec
is assigned its own callback function at runtime, which allows for completely localized event handling.
This callback function is invoked after any event associated with the formspec
(hence the moniker "ActiveFormspecs"). Both the meta table and form fields are passed as arguments.
#### Progress and status
https://forum.minetest.net/viewtopic.php?f=9&t=19303
The project is a WIP and will be undergoing continuous development based upon your suggestions as well
as my personal needs. Version 3.0 is already underway, and I am planning to introduce substantial
improvements to the core functionality. New features and bug-fixes will be announced here as they become
available. During significant milestones, I will include a roadmap so as to gauge your feedback about
long-term goals. I will make every effort to ensure backward compatibility, when possible.
#### Usage Instructions:
An interactive form session monitor can be accessed in-game via the /fs chat command (requires "server" privilege).
A realtime summary of form sessions is displayed along with navigation buttons and related statistics.
![](screenshot.png?raw=true)
The summary is sorted chronologically and divided into four columns per player:
* player - the name of the player viewing the formspec
* origin - the mod (or node, if attached) that created the formspec
* idletime - the elapsed time since a form-related event or signal
* lifetime - the elapsed time since the form was first opened
A detached formspec can be opened using the minetest.create_form( ) method which returns a hashed session
token (guaranteed to be unique):
* minetest.create_form( state, player_name, formspec, on_close, signal )
* state - an optional session-based state-table (can be nil)
* player_name - a valid player name
* formspec - a standard formspec string
* on_close - an optional callback function to be invoked after an event or signal
* signal - an optional signal to pass to the on_close( ) callback function
The form can subsequently be re-opened or closed using the following methods:
* minetest.update_form( player_name, formspec )
* player_name - a valid player name
* formspec - a standard formspec string
* minetest.destroy_form( player_name )
* player_name - a valid player name
The associated on_close( ) callback function will be automatically invoked with three arguments during a
form-related event or signal, including closure. The return value is currently ignored, but in the future
it may allow for additional behavior.
* on_close( state, player, fields )
* state - the session-based state table
* player - the player object
* fields - the table of submitted fields
In the event of abnormal form session termination, the callback function will receive a signal indicating
the specific condition that led to the closure. One of the following values will be stored in the "quit"
field:
* minetest.FORMSPEC_SIGEXIT (player clicked exit button or pressed esc key)
* minetest.FORMSPEC_SIGQUIT (player logged off)
* minetest.FORMSPEC_SIGKILL (player was killed)
* minetest.FORMSPEC_SIGTERM (server is shutting down)
* minetest.FORMSPEC_SIGPROC (programmatic closure)
* minetest.FORMSPEC_SIGTIME (timeout reached)
* minetest.FORMSPEC_SIGHOLD (child form opened)
* minetest.FORMSPEC_SIGCONT (child form closed)
A non-trappable SIGSTOP signal can also be passed to minetest.destroy_form( ) or minetest.create_form( )
to forcibly kill the current form session, thereby avoiding recursion in callbacks during a procedural
form closure.
```
local function open_alertbox( player_name, message )
local function get_formspec( )
local formspec = <generate a basic formspec string here>
return formspec
end
minetest.create_form( nil, player_name, get_formspec( ), nil, minetest.FORMSPEC_SIGSTOP )
end
```
When form closure may produce unanticipated side-effects, such as the example above, then the SIGSTOP
signal can prove essential to avoid or even defer such actions.
Cascading forms such as dialog boxes, alerts, etc. can be implemented through the use of the SIGHOLD
and SIGCONT signals. By passing a SIGHOLD signal to minetest.create_form(), the callback will be
notified of the signal and the form session will be suspended (this also stops any form timers).
As soon as the child form is closed, the previous form session will be restored and the callback
will be notified of a SIGCONT signal. ActiveFormspecs manages all of the session housekeeping behind
the scenes.
Here is an example of a popup alert that, when opened, will temporarily suspend the current form
session until closed:
```
local function open_alert( player_name, message )
local get_formspec = function ( )
local formspec = "size[4,3]"
.. default.gui_bg_img
.. string.format( "label[0.5,0.5;%s]", minetest.formspec_escape( message ) )
.. "button_exit[1.0,2;2.0,1;close;Close]"
return formspec
end
minetest.create_form( nil, player_name, get_formspec( ), nil, minetest.FORMSPEC_SIGHOLD )
end
```
One caveat is that the parent form must be updated whenever a SIGCONT signal is received. This is for
security reasons, given that element states (like dropdowns, scrollbars, etc.) cannot be preserved
automatically, and some elements may even contain stale data (as with the case of textareas, fields,
etc.) which should not be preserved anyway. Thankfully, only one line of code is required:
```
if fields.quit == minetest.FORMSPEC_SIGCONT then minetest.update_form( player_name, get_formspec( ) ) end
```
Formspecs are also supported within a node definition. If an on_open( ) method is defined, then it will
be invoked whenever the player right-clicks the node. The return value must be a standard formspec string,
or nil to abort.
* nodedef.on_open( pos, player, fields )
* pos - the position of the node (or your own state table)
* player - the player object
The on_close( ) method is invoked during any formspec event or signal. If defined,
it will receive three arguments. Currently, the return value is ignored, but in the
future it may allow for additional behavior.
* nodedef.on_close( pos, player, fields )
* pos - the position of the node (or your own state table)
* player - the player object
* fields - the table of submitted fields
An optional before_open( ) method may also be defined. It should return a custom
state table to be subsequently passed to the on_open( ) method in place of the
default "pos" parameter.
* nodedef.before_open( pos, node, player )
* pos - the position of the node (or your own state table)
* node - the node
* player - the player object
The "hidden" formspec element of allows for a slightly different workflow, by
presetting the state table from the formspec string itself. The key-value pairs
are used only for initialization and will never be transmitted to the client.
An optional data type can be specified within the element as follows:
* hidden[name;value;string]
* hidden[name;value;boolean]
* hidden[name;value;number]
* hidden[name;value]
If the data type is not specified, then no type-conversion will occur. Here are
some examples:
* hidden[pos_x;-240;number]"
* hidden[wool_color;darkgreen;string]"
* hidden[is_dead;true;boolean]"
Through the use of form timers, it possible to re-open a formspec at regular intervals,
or even to close the formspec after a specified period. There is no need for complex
chains of minetest.after( ) or additional globalstep registrations. A form timer persists
for the duration of a form session.
* minetest.get_form_timer( player_name, form_name )
Returns a form timer object associated with the form session of a given player.
Three methods are exposed by the form timer object, providing similar behavior to node timers.
* timer.start( timeout ) Starts a timer with the given timeout in seconds.
* timer.stop( ) Cancels a running timer.
* timer.get_state( ) Returns a table with information about the running timer
The table returned including:
* elapsed - the number of seconds since the timer started
* remain - the number of seconds until the timer expires
* overrun - the number of milliseconds overrun for this period (only valid within an on_close( ) callback)
* counter - the number of periods that have accrued
The associated on_close( ) callback function will be notified of timer expiration via a SIG_TIME signal.
#### Code examples
To better understand this methodology, here are some working code examples. Let's say that we want to register an "uptime" chat command that displays the current server uptime to any player, with an option to automatically refresh the formspec each second.
```
minetest.register_chatcommand( "uptime", {
description = "View the uptime of the server interactively",
func = function( player_name, param )
local is_refresh = true
local get_formspec = function( )
local uptime = minetest.get_server_uptime( )
local formspec = "size[4,2]"
.. string.format( "label[0.5,0.5;%s %d secs]",
minetest.colorize( "#FFFF00", "Server Uptime:" ), uptime
)
.. "checkbox[0.5,1;is_refresh;Auto Refresh;" .. tostring( is_refresh ) .. "]"
return formspec
end
local on_close = function( state, player, fields )
if fields.quit == minetest.FORMSPEC_SIGTIME then
minetest.update_form( player_name, get_formspec( ) )
elseif fields.is_refresh then
is_refresh = fields.is_refresh == "true"
if is_refresh == true then
minetest.get_form_timer( player_name ).start( 1 )
else
minetest.get_form_timer( player_name ).stop( )
end
end
end
minetest.create_form( nil, player_name, get_formspec( ), on_close )
minetest.get_form_timer( player_name ).start( 1 )
end
} )
```
Of course, we could implement similar functionality without the need for a chat command. Perhaps we want to display the server uptime only when a privileged player clicks on a Nyan Cat while limiting the total number of refreshes to ten.
```
minetest.register_privilege( "uptime", "View the uptime of the server interactively" )
local function open_system_monitor( player_name, is_minutes )
local view_count = 0
local view_limit = 10
local function get_formspec( )
local uptime = minetest.get_server_uptime( )
local formspec = "size[4,3]"
.. string.format( "label[0.5,0.5;%s %0.1f %s]",
minetest.colorize( "#FFFF00", "Server Uptime:" ),
is_minutes and uptime / 60 or uptime,
is_minutes and "mins" or "secs"
)
.. "checkbox[0.5,1;is_minutes;Show Minutes;" .. tostring( is_minutes ) .. "]"
.. "button[0.5,2;2.5,1;update;Refresh]"
.. "hidden[view_count;1;number]"
.. "hidden[view_limit;10;number]"
return formspec
end
minetest.create_form( nil, player_name, get_formspec( ), function( state, player, fields )
if not minetest.check_player_privs( player_name, "uptime" ) then -- sanity check
return
end
if fields.update then
-- limit the number of refreshes!
if view_count == view_limit then
minetest.destroy_form( player_name )
minetest.chat_send_player( player_name, "You've exceeded the refresh limit." )
else
view_count = view_count + 1
minetest.update_form( player_name, get_formspec( ) )
end
elseif fields.is_minutes then
is_minutes = fields.is_minutes == "true"
minetest.update_form( player_name, get_formspec( ) )
end
end )
end
minetest.override_item( "nyancat:nyancat", {
description = "System Monitor",
on_rightclick = function( pos, node, player )
local player_name = player:get_player_name( )
if minetest.check_player_privs( player_name, "uptime" ) then
open_system_monitor( player_name, true )
else
minetest.chat_send_player( player_name, "Your privileges are insufficient." )
end
end,
} )
```
Revision History
----------------------
Version 1.0a (15-Dec-2016)
- initial version within default mod
Version 1.1b (16-Dec-2016)
- added better comments
Version 1.2b (18-Dec-2016)
- renamed public methods
Version 1.3b (04-Jan-2017)
- fixed logic of quit event to require valid session
Version 1.4b (26-Jul-2017)
- added method to update formspec and maintain session
Version 2.0 (24-Dec-2017)
- separated all routines into new mod for public release
Version 2.1 (08-Jan-2018)
- various code refactoring and better comments
- introduced password hashing of form names
- improved sanity checks during form submission
- fully reworked parsing of hidden elements
- ensured hidden elements are always stripped
- gave hidden elements default-state behavior
- localized old node registration functions
- included player object within form table
- added signal handling on formspec termination
- added support for callbacks in node overrides
Version 2.2 (19-Jan-2018)
- introduced player-name arguments for all API calls
- added signal for programmatic formspec closure
- ensured callbacks are notified of session resets
- renamed some local variables to improve clarity
Version 2.3 (28-Jan-2018)
- corrected erroneous value of formspec exit signal
- removed two experimental form session methods
- included timestamp and origin within form table
- added form session validation on destroy and update
- introduced form timers with start and stop methods
- created routine to notify callbacks of timeout
- added support for internal statistical tracking
- added chat command to view form session summary
Version 2.4 (12-Feb-2018)
- various code refactoring and better comments
- full rewrite of timer queue for higher precision
Version 2.5 (01-Feb-2019)
- made callback function optional with default no-op
- added non-trappable form session termination signal
- properly reset timestamp for lifetime calculation
Version 2.6 (02-Feb-2020)
- added callback to preset state of node formspecs
- removed experimental property from node definition
- implemented reverse-lookups for dropdown fields
- extended dropdown element with optional parameter
- added signal to suspend and restore form sessions
- combined element parsers into dedicated function
- added functions for escaping formspec strings
- revamped element parsers to ignore malformed tags
- added conditional pattern matching helper function
- compatability bumped to Minetest 0.4.15+
Compatibility
----------------------
Minetest 0.4.15+ required or 5.X
Installation
----------------------
1. Unzip the archive into the mods directory or game mods directory
2. Get sure to the name of this directory to "formspecs" (if you cloned or decompress)
3. Add "formspecs" as a dependency to any mods using the API.
Source Code License
----------------------
The MIT License (MIT)
Copyright (c) 2016-2020, Leslie E. Krause (leslie@searstower.org) aka sorceredkid
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.
For more details:
https://opensource.org/licenses/MIT

View File

@ -0,0 +1 @@
ActiveFormspecs is a self-contained API that provides secure session tracking, session-based state tables, and localized event handling of formspecs for individual mods as well as entire subgames

509
mods/formspecs/init.lua Normal file
View File

@ -0,0 +1,509 @@
--------------------------------------------------------
-- Minetest :: ActiveFormspecs Mod v2.6 (formspecs)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2016-2019, Leslie Ellen Krause
--
-- ./games/just_test_tribute/mods/formspecs/init.lua
--------------------------------------------------------
print( "Loading ActiveFormspecs Mod" )
minetest.FORMSPEC_SIGEXIT = "true" -- player clicked exit button or pressed esc key (boolean for backward compatibility)
minetest.FORMSPEC_SIGQUIT = 1 -- player logged off
minetest.FORMSPEC_SIGKILL = 2 -- player was killed
minetest.FORMSPEC_SIGTERM = 3 -- server is shutting down
minetest.FORMSPEC_SIGPROC = 4 -- procedural closure
minetest.FORMSPEC_SIGTIME = 5 -- timeout reached
minetest.FORMSPEC_SIGSTOP = 6 -- procedural closure (cannot be trapped)
minetest.FORMSPEC_SIGHOLD = 7 -- child form opened, parent is suspended
minetest.FORMSPEC_SIGCONT = 8 -- child form closed, parent can continue
local afs = { } -- obtain localized, protected namespace
afs.forms = { }
afs.timers = { }
afs.session_id = 0
afs.session_seed = math.random( 0, 65535 )
afs.stats = { active = 0, opened = 0, closed = 0 }
afs.stats.on_open = function ( self )
self.active = self.active + 1
self.opened = self.opened + 1
end
afs.stats.on_close = function ( self )
self.active = self.active - 1
self.closed = self.closed + 1
end
-----------------------------------------------------------------
-- trigger callbacks at set intervals within timer queue
-----------------------------------------------------------------
do
-- localize needed object references for efficiency
local get_us_time = minetest.get_us_time
local timers = afs.timers
local t_cur = get_us_time( )
local t_off = -t_cur
-- step monotonic clock with graceful 32-bit overflow
local step_clock = function( )
local t_new = get_us_time( )
if t_new < t_cur then
t_off = t_off + 4294967290
end
t_cur = t_new
return t_off + t_new
end
afs.get_uptime = function( )
return ( t_off + t_cur ) / 1000000
end
minetest.register_globalstep( function( dtime )
--local x = get_us_time( )
local curtime = step_clock( ) / 1000000
local idx = #timers
-- iterate through table in reverse order to allow removal
while idx > 0 do
local self = timers[ idx ]
if curtime >= self.exptime then
self.counter = self.counter + 1
self.overrun = curtime - self.exptime
self.exptime = curtime + self.form.timeout
self.form.newtime = math.floor( curtime )
self.form.on_close( self.form.meta, self.form.player, { quit = minetest.FORMSPEC_SIGTIME } )
self.overrun = 0.0
end
idx = idx - 1
end
end )
end
-----------------------------------------------------------------
-- override node registrations for attached formspecs
-----------------------------------------------------------------
local on_rightclick = function( pos, node, player )
local nodedef = minetest.registered_nodes[ node.name ]
local meta = nodedef.before_open and nodedef.before_open( pos, node, player ) or pos
local formspec = nodedef.on_open( meta, player )
if formspec then
local player_name = player:get_player_name( )
minetest.create_form( meta, player_name, formspec, nodedef.on_close )
afs.forms[ player_name ].origin = node.name
end
end
local old_register_node = minetest.register_node
local old_override_item = minetest.override_item
minetest.register_node = function ( name, def )
if def.on_open and not def.on_rightclick then
def.on_rightclick = on_rightclick
end
old_register_node( name, def )
end
minetest.override_item = function ( name, def )
if minetest.registered_nodes[ name ] and def.on_open then
def.on_rightclick = on_rightclick
end
old_override_item( name, def )
end
-----------------------------------------------------------------
-- trigger callbacks during formspec events
-----------------------------------------------------------------
minetest.register_on_player_receive_fields( function( player, formname, fields )
local player_name = player:get_player_name( )
local form = afs.forms[ player_name ]
-- perform a basic sanity check, since these shouldn't technically occur
if not form or player ~= form.player or formname ~= form.name then return end
-- handle reverse-lookups of dropdown indexes
for name, keys in pairs( form.dropdowns ) do
if fields[ name ] then
fields[ name ] = keys[ fields[ name ] ]
end
end
form.newtime = os.time( )
form.on_close( form.meta, form.player, fields )
-- end current session when closing formspec
if fields.quit then
minetest.get_form_timer( player_name ).stop( )
afs.stats:on_close( )
if form.parent_form then
-- restore previous session
form = form.parent_form
afs.forms[ player_name ] = form
-- delay a single tick to ensure formspec updates are handled by client
minetest.after( 0.0, function ( )
form.on_close( form.meta, form.player, { quit = minetest.FORMSPEC_SIGCONT } )
end )
else
afs.forms[ player_name ] = nil
end
end
end )
-----------------------------------------------------------------
-- expose timer functionality within a helper object
-----------------------------------------------------------------
minetest.get_form_timer = function ( player_name, form_name )
local self = { }
local form = afs.forms[ player_name ]
if not form or form_name and form_name ~= form.name then return end
self.start = function ( timeout )
if not form.timeout and timeout >= 0.5 then
local curtime = afs.get_uptime( )
form.timeout = timeout
table.insert( afs.timers, { form = form, counter = 0, oldtime = curtime, exptime = curtime + timeout, overrun = 0.0 } )
end
end
self.stop = function ( )
if not form.timeout then return end
form.timeout = nil
for i, v in ipairs( afs.timers ) do
if v.form == form then
table.remove( afs.timers, i )
return
end
end
end
self.get_state = function ( )
if not form.timeout then return end
for i, v in ipairs( afs.timers ) do
local curtime = afs.get_uptime( )
if v.form == form then
return { elapsed = curtime - v.oldtime, remain = v.exptime - curtime, overrun = v.overrun, counter = v.counter }
end
end
end
return self
end
-----------------------------------------------------------------
-- parse specialized formspec elements and escapes codes
-----------------------------------------------------------------
local _
local function is_match( str, pat )
-- use array for captures
_ = { string.match( str, pat ) }
return #_ > 0 and _ or nil
end
local function escape( str )
return string.gsub( str, "\\.",
{ ["\\]"] = "\\x5D", ["\\["] = "\\x5B", ["\\,"] = "\\x2C", ["\\;"] = "\\x3B" } )
end
local function unescape( str, is_raw )
return string.gsub( str, "\\x..",
{ ["\\x5D"] = "\\]", ["\\x5B"] = "\\[", ["\\x2C"] = "\\,", ["\\x3B"] = "\\;" } )
end
local function unescape_raw( str, is_raw )
return string.gsub( str, "\\x..",
{ ["\\x5D"] = "]", ["\\x5B"] = "[", ["\\x2C"] = ",", ["\\x3B"] = ";" } )
end
local function parse_elements( form, formspec )
formspec = escape( formspec )
form.dropdowns = { } -- reset the dropdown lookup
-- dropdown elements can optionally return the selected
-- index rather than the value of the option itself
formspec = string.gsub( formspec, "dropdown%[(.-)%]", function( params )
if is_match( params, "^([^;]*;[^;]*;([^;]*);([^;]*);[^;]*);([^;]*)$" ) then
local prefix = _[ 1 ]
local name = _[ 2 ]
local options = _[ 3 ]
local use_index = _[ 4 ]
if use_index == "true" then
form.dropdowns[ name ] = { }
for idx, val in ipairs( string.split( options, ",", true ) ) do
form.dropdowns[ name ][ unescape_raw( val ) ] = idx -- add to reverse lookup table
end
return string.format( "dropdown[%s]", prefix )
elseif use_index == "false" or use_index == "" then
return string.format( "dropdown[%s]", prefix )
else
return "" -- strip invalid dropdown elements
end
end
return string.format( "dropdown[%s]", params )
end )
-- hidden elements only provide default, initial values
-- for state table and are always stripped afterward
formspec = string.gsub( formspec, "hidden%[(.-)%]", function( params )
if is_match( params, "^([^;]*);([^;]*)$" ) or is_match( params, "^([^;]*);([^;]*);([^;]*)$" ) then
local key = _[ 1 ]
local value = _[ 2 ]
local type = _[ 3 ]
if key ~= "" and form.meta[ key ] == nil then
-- parse according to specified data type
if type == "string" or type == "" or type == nil then
form.meta[ key ] = unescape_raw( value )
elseif type == "number" then
form.meta[ key ] = tonumber( value )
elseif type == "boolean" then
form.meta[ key ] = ( { ["1"] = true, ["0"] = false, ["true"] = true, ["false"] = false } )[ value ]
end
end
end
return "" -- strip hidden elements prior to showing formspec
end )
return unescape( formspec )
end
-----------------------------------------------------------------
-- open detached formspec with session-based state table
-----------------------------------------------------------------
minetest.create_form = function ( meta, player_name, formspec, on_close, signal )
-- short circuit whenever required params are missing
if not player_name or not formspec then return end
if type( player_name ) ~= "string" then
player_name = player_name:get_player_name( )
end
local form = afs.forms[ player_name ]
-- trigger previous callback before formspec closure
if form then
minetest.get_form_timer( player_name, form.name ).stop( )
if signal ~= minetest.FORMSPEC_SIGSTOP then
form.on_close( form.meta, form.player, { quit = signal or minetest.FORMSPEC_SIGPROC } )
end
if signal ~= minetest.FORMSPEC_SIGHOLD then
form = nil
afs.stats:on_close( )
end
end
-- start new session when opening formspec
afs.session_id = afs.session_id + 1
form = { parent_form = form }
form.id = afs.session_id
form.name = minetest.get_password_hash( player_name, afs.session_seed + afs.session_id )
form.player = minetest.get_player_by_name( player_name )
form.origin = string.match( debug.getinfo( 2 ).source, "^@.*[/\\]mods[/\\](.-)[/\\]" ) or "?"
form.on_close = on_close or function ( ) end
form.meta = meta or { }
form.oldtime = math.floor( afs.get_uptime( ) )
form.newtime = form.oldtime
afs.forms[ player_name ] = form
afs.stats:on_open( )
minetest.show_formspec( player_name, form.name, parse_elements( form, formspec ) )
return form.name
end
minetest.update_form = function ( player, formspec )
local pname = type( player ) == "string" and player or player:get_player_name( )
local form = afs.forms[ pname ]
if form then
form.oldtime = math.floor( afs.get_uptime( ) )
minetest.show_formspec( pname, form.name, parse_elements( form, formspec ) )
end
end
minetest.destroy_form = function ( player, signal )
local pname = type( player ) == "string" and player or player:get_player_name( )
local form = afs.forms[ pname ]
if form then
minetest.close_formspec( pname, form.name )
minetest.get_form_timer( pname ):stop( )
if signal ~= minetest.FORMSPEC_SIGSTOP then
form.on_close( form.meta, form.player, { quit = signal or minetest.FORMSPEC_SIGPROC } )
end
afs.stats:on_close( )
afs.forms[ pname ] = nil
end
end
-----------------------------------------------------------------
-- trigger callbacks after unexpected formspec closure
-----------------------------------------------------------------
minetest.register_on_leaveplayer( function( player, is_timeout )
local pname = player:get_player_name( )
local form = afs.forms[ pname ]
if form then
minetest.get_form_timer( pname, form.name ).stop( )
form.newtime = os.time( )
form.on_close( form.meta, form.player, { quit = minetest.FORMSPEC_SIGQUIT } )
afs.stats:on_close( )
afs.forms[ pname ] = nil
end
end )
minetest.register_on_dieplayer( function( player )
local pname = player:get_player_name( )
local form = afs.forms[ pname ]
if form then
minetest.get_form_timer( pname, form.name ).stop( )
form.newtime = os.time( )
form.on_close( form.meta, form.player, { quit = minetest.FORMSPEC_SIGKILL } )
afs.stats:on_close( )
afs.forms[ pname ] = nil
end
end )
minetest.register_on_shutdown( function( )
for _, form in pairs( afs.forms ) do
minetest.get_form_timer( form.player:get_player_name( ), form.name ).stop( )
form.newtime = os.time( )
form.on_close( form.meta, form.player, { quit = minetest.FORMSPEC_SIGTERM } )
afs.stats:on_close( )
end
afs.forms = { }
end )
-----------------------------------------------------------------
-- display realtime information about form sessions
-----------------------------------------------------------------
minetest.register_chatcommand( "fs", {
description = "Display realtime information about form sessions",
privs = { server = true },
func = function( pname, param )
local page_idx = 1
local page_size = 10
local sorted_forms
local get_sorted_forms = function( )
local f = { }
for k, v in pairs( afs.forms ) do
table.insert( f, v )
end
table.sort( f, function( a, b ) return a.id < b.id end )
return f
end
local get_formspec = function( )
local uptime = math.floor( afs.get_uptime( ) )
local formspec = "size[9.5,7.5]"
.. default.gui_bg
.. default.gui_bg_img
.. "label[0.1,6.7;ActiveFormspecs v2.6"
.. string.format( "label[0.1,0.0;%s]label[0.1,0.5;%d min %02d sec]",
minetest.colorize( "#888888", "uptime:" ), math.floor( uptime / 60 ), uptime % 60 )
.. string.format( "label[5.6,0.0;%s]label[5.6,0.5;%d]",
minetest.colorize( "#888888", "active" ), afs.stats.active )
.. string.format( "label[6.9,0.0;%s]label[6.9,0.5;%d]",
minetest.colorize( "#888888", "opened" ), afs.stats.opened )
.. string.format( "label[8.2,0.0;%s]label[8.2,0.5;%d]",
minetest.colorize( "#888888", "closed" ), afs.stats.closed )
.. string.format( "label[0.5,1.5;%s]label[3.5,1.5;%s]label[6.9,1.5;%s]label[8.2,1.5;%s]",
minetest.colorize( "#888888", "player" ),
minetest.colorize( "#888888", "origin" ),
minetest.colorize( "#888888", "idletime" ),
minetest.colorize( "#888888", "lifetime" )
)
.. "box[0,1.2;9.2,0.1;#111111]"
.. "box[0,6.2;9.2,0.1;#111111]"
local num = 0
for idx = ( page_idx - 1 ) * page_size + 1, math.min( page_idx * page_size, #sorted_forms ) do
local form = sorted_forms[ idx ]
local player_name = form.player:get_player_name( )
local lifetime = uptime - form.oldtime
local idletime = uptime - form.newtime
local vert = 2.0 + num * 0.5
formspec = formspec
.. string.format( "button[0.1,%0.1f;0.5,0.3;del:%s;x]", vert + 0.1, player_name )
.. string.format( "label[0.5,%0.1f;%s]", vert, player_name )
.. string.format( "label[3.5,%0.1f;%s]", vert, form.origin )
.. string.format( "label[6.9,%0.1f;%dm %02ds]", vert, math.floor( idletime / 60 ), idletime % 60 )
.. string.format( "label[8.2,%0.1f;%dm %02ds]", vert, math.floor( lifetime / 60 ), lifetime % 60 )
num = num + 1
end
formspec = formspec
.. "button[6.4,6.5;1,1;prev;<<]"
.. string.format( "label[7.4,6.7;%d of %d]", page_idx, math.max( 1, math.ceil( #sorted_forms / page_size ) ) )
.. "button[8.4,6.5;1,1;next;>>]"
return formspec
end
local on_close = function( meta, player, fields )
if fields.quit == minetest.FORMSPEC_SIGTIME then
sorted_forms = get_sorted_forms( )
minetest.update_form( pname, get_formspec( ) )
elseif fields.prev and page_idx > 1 then
page_idx = page_idx - 1
minetest.update_form( pname, get_formspec( ) )
elseif fields.next and page_idx < #sorted_forms / page_size then
page_idx = page_idx + 1
minetest.update_form( pname, get_formspec( ) )
else
local player_name = string.match( next( fields, nil ), "del:(.+)" )
if player_name and afs.forms[ player_name ] then
minetest.destroy_form( player_name )
end
end
end
sorted_forms = get_sorted_forms( )
minetest.create_form( nil, pname, get_formspec( ), on_close )
minetest.get_form_timer( pname ).start( 1 )
return true
end,
} )

5
mods/formspecs/mod.conf Normal file
View File

@ -0,0 +1,5 @@
name = formspecs
title = ActiveFormspecs
author = sorcerykid
license = MIT
description = ActiveFormspecs is a self-contained API that provides secure session tracking, session-based state tables, and localized event handling of formspecs for individual mods as well as entire subgames

View File

@ -0,0 +1,96 @@
-------------------------------------------------------------------------------------------
-- How to try this example:
-- 1) Move this file into a new "afs_test" directory under mods and rename it "init.lua".
-- 2) Create a "depends.txt" file in the new directory with the following lines of text:
-- nyancat
-- formspecs
-- 3) Launch your Minetest server and enable the "afs_test" mod. Then, login as usual!
-------------------------------------------------------------------------------------------
minetest.register_privilege( "uptime", "View the uptime of the server interactively" )
local get_nyancat_formspec = function( meta )
local uptime = minetest.get_server_uptime( )
local formspec = "size[4,3]"
.. default.gui_bg_img
.. string.format( "label[0.5,0.5;%s %0.1f %s]",
minetest.colorize( "#FFFF00", "Server Uptime:" ),
meta.is_minutes == true and uptime / 60 or uptime,
meta.is_minutes == true and "mins" or "secs"
)
.. "checkbox[0.5,1;is_minutes;Show Minutes;" .. tostring( meta.is_minutes ) .. "]"
.. "button[0.5,2;2.5,1;update;Refresh]"
.. "hidden[view_count;1;number]"
.. "hidden[view_limit;10;number]" -- limit the number of refreshes!
return formspec
end
minetest.override_item( "nyancat:nyancat", {
description = "System Monitor",
on_open = function( meta, player )
local player_name = player:get_player_name( )
if meta.is_minutes == nil then meta.is_minutes = true end
if minetest.check_player_privs( player, "uptime" ) then
return get_nyancat_formspec( meta )
else
minetest.chat_send_player( player_name, "Your privileges are insufficient." )
end
end,
on_close = function( meta, player, fields )
local player_name = player:get_player_name( )
if not minetest.check_player_privs( player, "uptime" ) then return end
if fields.update then
if meta.view_count == meta.view_limit then
minetest.destroy_form( player_name )
minetest.chat_send_player( player_name, "You've exceeded the refresh limit." )
else
meta.view_count = meta.view_count + 1
minetest.update_form( player_name, get_nyancat_formspec( meta ) )
end
elseif fields.is_minutes then
meta.is_minutes = fields.is_minutes == "true"
minetest.update_form( player_name, get_nyancat_formspec( meta ) )
end
end
} )
minetest.register_chatcommand( "uptime", {
description = "View the uptime of the server interactively",
func = function( player_name, param )
local is_refresh = true
local get_formspec = function( )
local uptime = minetest.get_server_uptime( )
local formspec = "size[4,2]"
.. default.gui_bg_img
.. string.format( "label[0.5,0.5;%s %d secs]",
minetest.colorize( "#FFFF00", "Server Uptime:" ), uptime
)
.. "checkbox[0.5,1;is_refresh;Auto Refresh;" .. tostring( is_refresh ) .. "]"
return formspec
end
local on_close = function( meta, player, fields )
if fields.quit == minetest.FORMSPEC_SIGTIME then
minetest.update_form( player_name, get_formspec( ) )
elseif fields.is_refresh then
is_refresh = fields.is_refresh == "true"
if is_refresh == true then
minetest.get_form_timer( player_name ).start( 1 )
else
minetest.get_form_timer( player_name ).stop( )
end
end
end
minetest.create_form( nil, player_name, get_formspec( ), on_close )
minetest.get_form_timer( player_name ).start( 1 )
end
} )

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB