--------------------------------------------------------
-- 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
