kp-bank/bank.lua

438 lines
14 KiB
Lua

--[[
bank.lua is the central bank server code, which runs 24/7 on a single computer
that is globally available via wireless connectivity (ender modem). It keeps a
persistent record of all accounts, and all transactions for accounts.
This program essentially serves as a simplified REST API for ingame clients,
like pocket computers and ATMs, for managing funds.
]]--
local USERS_DIR = "users"
local USER_DATA_FILE = "data.json"
local ACCOUNTS_FILE = "accounts.json"
local HOST = "central-bank"
local RUNNING = true
local SECURITY_KEY = nil
local g = require("simple-graphics")
local W, H = term.getSize()
g.clear(term, colors.black)
g.drawTextCenter(term, W/2, 1, "BANK Server @ " .. HOST, colors.lime, colors.black)
g.drawXLine(term, 1, W, 2, colors.black, colors.gray, "-")
g.drawText(term, W-3, 1, "Quit", colors.white, colors.red)
local console = g.createConsole(W, H-2, colors.white, colors.black, "DOWN")
local function log(msg)
g.appendAndDrawConsole(term, console, textutils.formatTime(os.time()) .. ": " .. msg, 1, 3)
end
-- Helper functions
local function readJSON(filename)
local f = io.open(filename, "r")
if not f then error("Cannot open file " .. filename .. " to read JSON data.") end
local data = textutils.unserializeJSON(f:read("*a"))
f:close()
return data
end
local function writeJSON(filename, data)
local f = io.open(filename, "w")
if not f then error("Cannot open file " .. filename .. " to write JSON data.") end
f:write(textutils.serializeJSON(data))
f:close()
end
-- Basic account functions:
local function validateUsername(name)
local i, j = string.find(name, "%a%a%a+")
return (
i == 1 and
j == #name and
j <= 12
)
end
local function validateTransactionDescription(desc)
return string.find(desc, "^%w+[ !%.%w]*$") ~= nil and #desc <= 64
end
local function userDir(name)
return fs.combine(USERS_DIR, name)
end
local function userDataFile(name)
return fs.combine(USERS_DIR, name, USER_DATA_FILE)
end
local function userAccountsFile(name)
return fs.combine(USERS_DIR, name, ACCOUNTS_FILE)
end
local function accountTransactionsFile(username, accountId)
return fs.combine(USERS_DIR, username, "tx_" .. accountId .. ".txt")
end
local function userExists(name)
return validateUsername(name) and fs.exists(userDir(name))
end
local function validatePassword(password)
return #password >= 8
end
local function randomAccountId()
local id = ""
for i = 1, 16 do
id = id .. tostring(math.random(0, 9))
if i % 4 == 0 and i < 16 then
id = id .. "-"
end
end
return id
end
local function getUserData(name)
return readJSON(userDataFile(name))
end
local function getAccounts(name)
return readJSON(userAccountsFile(name))
end
local function saveAccounts(name, accounts)
writeJSON(userAccountsFile(name), accounts)
end
local function findAccountById(accounts, id)
for i, account in pairs(accounts) do
if account.id == id then
return account
end
end
return nil
end
local function findAccountByName(accounts, name)
for i, account in pairs(accounts) do
if account.name == name then
return account
end
end
return nil
end
local function createAccount(username, accountName)
local accounts = getAccounts(username)
if findAccountByName(accounts, accountName) then
return false, "Duplicate account name"
end
local newAccount = {
id = randomAccountId(),
name = accountName,
balance = 0,
createdAt = os.epoch("utc")
}
table.insert(accounts, newAccount)
saveAccounts(username, accounts)
log("Created account " .. newAccount.id .. " for user " .. username)
return true, newAccount
end
local function deleteAccount(username, accountId)
local accounts = getAccounts(username)
local targetIndex = nil
for i, account in pairs(accounts) do
if account.id == accountId then
targetIndex = i
end
end
if targetIndex then
table.remove(accounts, targetIndex)
saveAccounts(username, accounts)
log("Deleted user " .. username .. " account " .. accountId)
return true
end
return false
end
local function renameAccount(username, accountId, newName)
local accounts = getAccounts(username)
local targetAccount = findAccountById(accounts, accountId)
if not targetAccount then return false, "Account not found" end
if findAccountByName(accounts, newName) ~= nil then
return false, "Duplicate account name"
end
targetAccount.name = newName
saveAccounts(accounts)
log("Renamed user " .. username .. " account " .. accountId .. " to " .. newName)
return true
end
local function createUser(name, password)
if not validateUsername(name) then return false, "Invalid username" end
if not validatePassword(password) then return false, "Invalid password" end
if userExists(name) then return false, "Username taken" end
local userData = {
password = password,
createdAt = os.epoch("utc")
}
fs.makeDir(userDir(name))
writeJSON(userDataFile(name), userData) -- Flush user data file.
saveAccounts(name, {}) -- Flush initial accounts file.
createAccount(name, "Checking")
createAccount(name, "Savings")
log("Created new user: " .. name)
return true
end
local function deleteUser(name)
if not userExists(name) then return false end
fs.delete(userDir(name))
log("Deleted user \"" .. name .. "\".")
return true
end
local function renameUser(oldName, newName)
if not validateUsername(newName) then return false, "Invalid new username" end
if not userExists(oldName) then return false, "User doesn't exist" end
if userExists(newName) then return false, "New username is taken" end
fs.move(userDir(oldName), userDir(newName))
log("Renamed user \"" .. oldName .. "\" to \"" .. newName .. "\".")
return true
end
local function recordTransaction(username, accountId, amount, description)
if not validateTransactionDescription(description) then return false, "Invalid transaction description" end
if not userExists(username) then return false, "User doesn't exist" end
local accounts = getAccounts(username)
local account = findAccountById(accounts, accountId)
if account == nil then return false, "Account doesn't exist" end
if account.balance + amount < 0 then return false, "Insufficient funds" end
-- Everything is OK, record the transaction.
local tx = {
amount = amount,
description = description,
timestamp = os.epoch("utc")
}
local f = io.open(accountTransactionsFile(username, accountId), "a")
local txStr = tostring(tx.amount)..";"..tostring(tx.timestamp)..";"..tx.description
f:write(txStr .. string.rep(" ", 99 - #txStr) .. "\n")
f:close()
if fs.getSize(accountTransactionsFile(username, accountId)) % 100 ~= 0 then
log("WARNING! Transaction file for account " .. accountId .. " is not consistent!")
end
account.balance = account.balance + amount
saveAccounts(username, accounts)
os.queueEvent("bank_account_balance", username, accountId, account.balance)
return true, tx
end
local function initSecurityKey()
-- Initialize security key
local SECURITY_KEY_FILE = "key.txt"
if not fs.exists(SECURITY_KEY_FILE) then
local f = io.open(SECURITY_KEY_FILE, "w")
SECURITY_KEY = randomAccountId() .. "-" .. randomAccountId() .. "-" .. randomAccountId() .. "-" .. randomAccountId()
f:write(SECURITY_KEY)
f:close()
log("Generated new security key.")
else
local f = io.open(SECURITY_KEY_FILE, "r")
SECURITY_KEY = f:read("*a")
f:close()
log("Loaded stored security key.")
end
end
-- EVENT HANDLING
-----------------
-- Helper function to wrap another function in an authentication check.
local function authProtect(func, secure)
return function (msg)
if (
not msg.auth or
not msg.auth.username or
not msg.auth.password or
not userExists(msg.auth.username) or
getUserData(msg.auth.username).password ~= msg.auth.password
) then
return {success = false, error = "Invalid credentials"}
end
if secure then
if not msg.auth.key then
return {success = false, error = "Missing security key"}
elseif msg.auth.key ~= SECURITY_KEY then
return {success = false, error = "Invalid security key"}
end
end
return func(msg)
end
end
local function handleGetStatus(msg)
return {success = true}
end
local function handleCreateUser(msg)
if not msg.data or not msg.data.username or not msg.data.password then
return {success = false, error = "Invalid request. Requires data.username and data.password."}
end
local success, errorMsg = createUser(msg.data.username, msg.data.password)
if not success then
return {success = false, error = errorMsg}
end
return {success = true}
end
local function handleDeleteUser(msg)
deleteUser(msg.auth.username)
return {success = true}
end
local function handleRenameUser(msg)
if not msg.data or not msg.data.newUsername then
return {success = false, error = "Invalid request. Requires data.newUsername."}
end
local success, errorMsg = renameUser(msg.auth.username, msg.data.newUsername)
if not success then
return {success = false, error = errorMsg}
end
return {success = true}
end
local function handleGetUserAccounts(msg)
return {success = true, data = getAccounts(msg.auth.username)}
end
local function handleGetUserAccount(msg)
if not msg.data or not msg.data.accountId then
return {success = false, error = "Invalid request. Requires data.accountId."}
end
local accounts = getAccounts(msg.auth.username)
local account = findAccountById(accounts, msg.data.accountId)
if not account then
return {success = false, error = "Account doesn't exist."}
end
return {success = true, data = account}
end
local function handleCreateUserAccount(msg)
if not msg.data or not msg.data.name then
return {success = false, error = "Invalid request. Requires data.name."}
end
local success, errorOrAccount = createAccount(msg.auth.username, msg.data.name)
if not success then
return {success = false, error = errorOrAccount}
end
return {success = true, data = errorOrAccount}
end
local function handleDeleteUserAccount(msg)
if not msg.data or not msg.data.accountId then
return {success = false, error = "Invalid request. Requires data.accountId."}
end
deleteAccount(msg.auth.username, msg.data.accountId)
return {success = true}
end
local function handleRenameUserAccount(msg)
if not msg.data or not msg.data.accountId or not msg.data.newName then
return {success = false, error = "Invalid request. Requires data.accountId and data.newName."}
end
local success, errorMsg = renameAccount(msg.auth.username, msg.data.accountid, msg.data.newName)
return {success = success, error = errorMsg}
end
local function handleRecordTransactionToAccount(msg)
if not msg.data or not msg.data.amount or not msg.data.description or not msg.data.accountId then
return {success = false, error = "Invalid request. Requires data.amount and data.description and data.accountId."}
end
local success, errorMsgOrTx = recordTransaction(msg.auth.username, msg.data.accountId, msg.data.amount, msg.data.description)
if not success then
return {success = false, error = errorMsgOrTx}
end
return {success = true, data = errorMsgOrTx}
end
-- A registry of all possible BANK requests, and their handler functions.
local BANK_REQUESTS = {
["STATUS"] = handleGetStatus,
["CREATE_USER"] = handleCreateUser,
["DELETE_USER"] = authProtect(handleDeleteUser),
["RENAME_USER"] = authProtect(handleRenameUser),
["GET_ACCOUNTS"] = authProtect(handleGetUserAccounts),
["GET_ACCOUNT"] = authProtect(handleGetUserAccount),
["CREATE_ACCOUNT"] = authProtect(handleCreateUserAccount),
["DELETE_ACCOUNT"] = authProtect(handleDeleteUserAccount),
["RENAME_ACCOUNT"] = authProtect(handleRenameUserAccount),
["RECORD_TRANSACTION"] = authProtect(handleRecordTransactionToAccount, true)
}
local function handleBankMessage(remoteId, msg)
if msg == nil or msg.command == nil or type(msg.command) ~= "string" then
return {success = false, error = "Invalid BANK request. Message is nil or missing \"command\" string property."}
end
if BANK_REQUESTS[msg.command] then
return BANK_REQUESTS[msg.command](msg)
end
return {success = false, error = "Unknown command: \"" .. msg.command .. "\""}
end
local function handleNetworkEvents()
log("Initializing Rednet hosting...")
rednet.open("top")
rednet.host("BANK", HOST)
log("Opened Rednet and hosted BANK at host \"" .. HOST .. "\".")
log("Now receiving requests.")
while RUNNING do
local remoteId, msg = rednet.receive("BANK", 3)
if remoteId ~= nil then
log("Received message from computer ID " .. remoteId)
local success, response = pcall(handleBankMessage, remoteId, msg)
if not success then
response = {success = false, error = "An error occurred: " .. response}
end
rednet.send(remoteId, response, "BANK")
end
end
rednet.unhost("BANK")
rednet.close()
end
local function handleGuiEvents()
while RUNNING do
local event, button, x, y = os.pullEvent("mouse_click")
if button == 1 and y == 1 and x > W - 4 then
log("Quitting...")
RUNNING = false
end
end
term.setBackgroundColor(colors.black)
term.setTextColor(colors.white)
term.clear()
term.setCursorPos(1, 1)
end
local function handleEvents()
parallel.waitForAll(
handleNetworkEvents,
handleGuiEvents
)
end
local args = {...}
if args[1] == "-i" then
print("Reinstalling from GitHub.")
fs.delete("bank.lua")
shell.execute("wget", "https://raw.githubusercontent.com/andrewlalis/kp-bank/main/bank.lua")
return
end
initSecurityKey()
handleEvents()