prosody/util/datamanager.lua
2024-11-16 09:20:29 +01:00

733 lines
21 KiB
Lua

-- Prosody IM
-- Copyright (C) 2008-2010 Matthew Wild
-- Copyright (C) 2008-2010 Waqas Hussain
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--
local string = string;
local format = string.format;
local setmetatable = setmetatable;
local ipairs = ipairs;
local char = string.char;
local pcall = pcall;
local log = require "prosody.util.logger".init("datamanager");
local io_open = io.open;
local os_remove = os.remove;
local os_rename = os.rename;
local tonumber = tonumber;
local floor = math.floor;
local next = next;
local type = type;
local t_insert = table.insert;
local t_concat = table.concat;
local envloadfile = require"prosody.util.envload".envloadfile;
local envload = require"prosody.util.envload".envload;
local serialize = require "prosody.util.serialization".serialize;
local lfs = require "lfs";
-- Extract directory separator from package.config (an undocumented string that comes with lua)
local path_separator = assert ( package.config:match ( "^([^\n]+)" ) , "package.config not in standard form" )
local prosody = prosody;
--luacheck: ignore 211/blocksize 211/remove_blocks
local blocksize = 0x1000;
local raw_mkdir = lfs.mkdir;
local atomic_append;
local remove_blocks;
local ENOENT = 2;
pcall(function()
local pposix = require "prosody.util.pposix";
raw_mkdir = pposix.mkdir or raw_mkdir; -- Doesn't trample on umask
atomic_append = pposix.atomic_append;
-- remove_blocks = pposix.remove_blocks;
ENOENT = pposix.ENOENT or ENOENT;
end);
local _ENV = nil;
-- luacheck: std none
---- utils -----
local encode, decode, store_encode;
do
local urlcodes = setmetatable({}, { __index = function (t, k) t[k] = char(tonumber(k, 16)); return t[k]; end });
decode = function (s)
return s and (s:gsub("%%(%x%x)", urlcodes));
end
encode = function (s)
return s and (s:gsub("%W", function (c) return format("%%%02x", c:byte()); end));
end
-- Special encode function for store names, which historically were unencoded.
-- All currently known stores use a-z and underscore, so this one preserves underscores.
store_encode = function (s)
return s and (s:gsub("[^_%w]", function (c) return format("%%%02x", c:byte()); end));
end
end
if not atomic_append then
function atomic_append(f, data)
local pos = f:seek();
if not f:write(data) or not f:flush() then
f:seek("set", pos);
f:write((" "):rep(#data));
f:flush();
return nil, "write-failed";
end
return true;
end
end
local _mkdir = {};
local function mkdir(path)
path = path:gsub("/", path_separator); -- TODO as an optimization, do this during path creation rather than here
if not _mkdir[path] then
raw_mkdir(path);
_mkdir[path] = true;
end
return path;
end
local data_path = (prosody and prosody.paths and prosody.paths.data) or ".";
local callbacks = {};
------- API -------------
local function set_data_path(path)
log("debug", "Setting data path to: %s", path);
data_path = path;
end
local function callback(username, host, datastore, data)
for _, f in ipairs(callbacks) do
username, host, datastore, data = f(username, host, datastore, data);
if username == false then break; end
end
return username, host, datastore, data;
end
local function add_callback(func)
if not callbacks[func] then -- Would you really want to set the same callback more than once?
callbacks[func] = true;
callbacks[#callbacks+1] = func;
return true;
end
end
local function remove_callback(func)
if callbacks[func] then
for i, f in ipairs(callbacks) do
if f == func then
callbacks[i] = nil;
callbacks[f] = nil;
return true;
end
end
end
end
local function getpath(username, host, datastore, ext, create)
ext = ext or "dat";
host = (host and encode(host)) or "_global";
username = username and encode(username);
datastore = store_encode(datastore);
if username then
if create then mkdir(mkdir(mkdir(data_path).."/"..host).."/"..datastore); end
return format("%s/%s/%s/%s.%s", data_path, host, datastore, username, ext);
else
if create then mkdir(mkdir(data_path).."/"..host); end
return format("%s/%s/%s.%s", data_path, host, datastore, ext);
end
end
local function load(username, host, datastore)
local data, err, errno = envloadfile(getpath(username, host, datastore), {});
if not data then
if errno == ENOENT then
-- No such file, ok to ignore
return nil;
end
log("error", "Failed to load %s storage ('%s') for user: %s@%s", datastore, err, username or "nil", host or "nil");
return nil, "Error reading storage";
end
local success, ret = pcall(data);
if not success then
log("error", "Unable to load %s storage ('%s') for user: %s@%s", datastore, ret, username or "nil", host or "nil");
return nil, "Error reading storage";
end
return ret;
end
local function atomic_store(filename, data)
local scratch = filename.."~";
local f, ok, msg, errno; -- luacheck: ignore errno
-- TODO return util.error with code=errno?
f, msg, errno = io_open(scratch, "w");
if not f then
return nil, msg;
end
ok, msg = f:write(data);
if not ok then
f:close();
os_remove(scratch);
return nil, msg;
end
ok, msg = f:close();
if not ok then
os_remove(scratch);
return nil, msg;
end
return os_rename(scratch, filename);
end
if prosody and prosody.platform ~= "posix" then
-- os.rename does not overwrite existing files on Windows
-- TODO We could use Transactional NTFS on Vista and above
function atomic_store(filename, data)
local f, err = io_open(filename, "w");
if not f then return f, err; end
local ok, msg = f:write(data);
if not ok then f:close(); return ok, msg; end
return f:close();
end
end
local function store(username, host, datastore, data)
if not data then
data = {};
end
username, host, datastore, data = callback(username, host, datastore, data);
if username == false then
return true; -- Don't save this data at all
end
-- save the datastore
local d = "return " .. serialize(data) .. ";\n";
local mkdir_cache_cleared;
repeat
local ok, msg = atomic_store(getpath(username, host, datastore, nil, true), d);
if not ok then
if not mkdir_cache_cleared then -- We may need to recreate a removed directory
_mkdir = {};
mkdir_cache_cleared = true;
else
log("error", "Unable to write to %s storage ('%s') for user: %s@%s", datastore, msg, username or "nil", host or "nil");
return nil, "Error saving to storage";
end
end
if next(data) == nil then -- try to delete empty datastore
log("debug", "Removing empty %s datastore for user %s@%s", datastore, username or "nil", host or "nil");
os_remove(getpath(username, host, datastore));
end
-- we write data even when we are deleting because lua doesn't have a
-- platform independent way of checking for nonexisting files
until ok;
return true;
end
-- Append a blob of data to a file
local function append(username, host, datastore, ext, data)
if type(data) ~= "string" then return; end
local filename = getpath(username, host, datastore, ext, true);
local f = io_open(filename, "r+");
if not f then
return atomic_store(filename, data);
-- File did probably not exist, let's create it
end
local pos = f:seek("end");
--[[ TODO needs tests
if (blocksize-(pos%blocksize)) < (#data%blocksize) then
-- pad to blocksize with newlines so that the next item is both on a new
-- block and a new line
atomic_append(f, ("\n"):rep(blocksize-(pos%blocksize)));
pos = f:seek("end");
end
--]]
local ok, msg = atomic_append(f, data);
if not ok then
f:close();
return ok, msg, "write";
end
ok, msg = f:close();
if not ok then
return ok, msg, "close";
end
return true, pos;
end
local index_fmt, index_item_size, index_magic;
if string.packsize then
index_fmt = "T"; -- offset to the end of the item, length can be derived from two index items
index_item_size = string.packsize(index_fmt);
index_magic = string.pack(index_fmt, 7767639 + 1); -- Magic string: T9 for "prosody", version number
end
local function list_append(username, host, datastore, data)
if not data then return; end
if callback(username, host, datastore) == false then return true; end
-- save the datastore
data = "item(" .. serialize(data) .. ");\n";
local ok, msg, where = append(username, host, datastore, "list", data);
if not ok then
log("error", "Unable to write to %s storage ('%s' in %s) for user: %s@%s",
datastore, msg, where, username or "nil", host or "nil");
return ok, msg;
end
if string.packsize then
local offset = type(msg) == "number" and msg or 0;
local index_entry = string.pack(index_fmt, offset + #data);
if offset == 0 then
index_entry = index_magic .. index_entry;
end
local ok, off = append(username, host, datastore, "lidx", index_entry);
off = off or 0;
-- If this was the first item, then both the data and index offsets should
-- be zero, otherwise there's some kind of mismatch and we should drop the
-- index and recreate it from scratch
-- TODO Actually rebuild the index in this case?
if not ok or (off == 0 and offset ~= 0) or (off ~= 0 and offset == 0) then
os_remove(getpath(username, host, datastore, "lidx"));
end
end
return true;
end
local function list_store(username, host, datastore, data)
if not data then
data = {};
end
if callback(username, host, datastore) == false then return true; end
-- save the datastore
local d = {};
for i, item in ipairs(data) do
d[i] = "item(" .. serialize(item) .. ");\n";
end
os_remove(getpath(username, host, datastore, "lidx"));
local ok, msg = atomic_store(getpath(username, host, datastore, "list", true), t_concat(d));
if not ok then
log("error", "Unable to write to %s storage ('%s') for user: %s@%s", datastore, msg, username or "nil", host or "nil");
return;
end
if next(data) == nil then -- try to delete empty datastore
log("debug", "Removing empty %s datastore for user %s@%s", datastore, username or "nil", host or "nil");
os_remove(getpath(username, host, datastore, "list"));
end
-- we write data even when we are deleting because lua doesn't have a
-- platform independent way of checking for nonexisting files
return true;
end
local function build_list_index(username, host, datastore, items)
log("debug", "Building index for (%s@%s/%s)", username, host, datastore);
local filename = getpath(username, host, datastore, "list");
local fh, err, errno = io_open(filename);
if not fh then
return fh, err, errno;
end
local prev_pos = 0; -- position before reading
local last_item_start = nil;
if items and items[1] then
local last_item = items[#items];
last_item_start = fh:seek("set", last_item.start + last_item.length);
else
items = {};
end
for line in fh:lines() do
if line:sub(1, 4) == "item" then
if prev_pos ~= 0 and last_item_start then
t_insert(items, { start = last_item_start; length = prev_pos - last_item_start });
end
last_item_start = prev_pos
end
-- seek position is at the start of the next line within each loop iteration
-- so we need to collect the "current" position at the end of the previous
prev_pos = fh:seek()
end
fh:close();
if prev_pos ~= 0 then
t_insert(items, { start = last_item_start; length = prev_pos - last_item_start });
end
return items;
end
local function store_list_index(username, host, datastore, index)
local data = { index_magic };
for i, v in ipairs(index) do
data[i + 1] = string.pack(index_fmt, v.start + v.length);
end
local filename = getpath(username, host, datastore, "lidx");
return atomic_store(filename, t_concat(data));
end
local index_mt = {
__index = function(t, i)
if type(i) ~= "number" or i % 1 ~= 0 or i < 0 then
return
end
if i <= 0 then
return 0
end
local fh = t.file;
local pos = (i - 1) * index_item_size;
if fh:seek("set", pos) ~= pos then
return nil
end
local data = fh:read(index_item_size * 2);
if not data or #data ~= index_item_size * 2 then
return nil
end
local start, next_pos = string.unpack(index_fmt .. index_fmt, data);
if pos == 0 then
start = 0
end
local length = next_pos - start;
local v = { start = start; length = length };
t[i] = v;
return v;
end;
__len = function(t)
-- Account for both the header and the fence post error
return floor(t.file:seek("end") / index_item_size) - 1;
end;
}
local function get_list_index(username, host, datastore)
log("debug", "Loading index for (%s@%s/%s)", username, host, datastore);
local index_filename = getpath(username, host, datastore, "lidx");
local ih = io_open(index_filename);
if ih then
local magic = ih:read(#index_magic);
if magic ~= index_magic then
log("debug", "Index %q has wrong version number (got %q, expected %q), rebuilding...", index_filename, magic, index_magic);
-- wrong version or something
ih:close();
ih = nil;
end
end
if ih then
local first_length = string.unpack(index_fmt, ih:read(index_item_size));
return setmetatable({ file = ih; { start = 0; length = first_length } }, index_mt);
end
local index, err = build_list_index(username, host, datastore);
if not index then
return index, err
end
-- TODO How to handle failure to store the index?
local dontcare = store_list_index(username, host, datastore, index); -- luacheck: ignore 211/dontcare
return index;
end
local function list_load_one(fh, start, length)
if fh:seek("set", start) ~= start then
return nil
end
local raw_data = fh:read(length)
if not raw_data or #raw_data ~= length then
return
end
local item;
local data, err, errno = envload(raw_data, "@list", {
item = function(i)
item = i;
end;
});
if not data then
return data, err, errno
end
local success, ret = pcall(data);
if not success then
return success, ret;
end
return item;
end
local function list_close(list)
if list.index and list.index.file then
list.index.file:close();
end
return list.file:close();
end
local indexed_list_mt = {
__index = function(t, i)
if type(i) ~= "number" or i % 1 ~= 0 or i < 1 then
return
end
local ix = t.index[i];
if not ix then
return
end
local item = list_load_one(t.file, ix.start, ix.length);
return item;
end;
__len = function(t)
return #t.index;
end;
__close = list_close;
}
local function list_load(username, host, datastore)
local items = {};
local data, err, errno = envloadfile(getpath(username, host, datastore, "list"), {item = function(i) t_insert(items, i); end});
if not data then
if errno == ENOENT then
-- No such file, ok to ignore
return nil;
end
log("error", "Failed to load %s storage ('%s') for user: %s@%s", datastore, err, username or "nil", host or "nil");
return nil, "Error reading storage";
end
local success, ret = pcall(data);
if not success then
log("error", "Unable to load %s storage ('%s') for user: %s@%s", datastore, ret, username or "nil", host or "nil");
return nil, "Error reading storage";
end
return items;
end
local function list_open(username, host, datastore)
if not index_magic then
log("debug", "Falling back from lazy loading to loading full list for %s storage for user: %s@%s", datastore, username or "nil", host or "nil");
return list_load(username, host, datastore);
end
local filename = getpath(username, host, datastore, "list");
local file, err, errno = io_open(filename);
if not file then
if errno == ENOENT then
return nil;
end
return file, err, errno;
end
local index, err = get_list_index(username, host, datastore);
if not index then
file:close()
return index, err;
end
return setmetatable({ file = file; index = index; close = list_close }, indexed_list_mt);
end
local function shift_index(index_filename, index, trim_to, offset) -- luacheck: ignore 212
os_remove(index_filename);
return "deleted";
-- TODO move and recalculate remaining items
end
local function list_shift(username, host, datastore, trim_to)
if trim_to == 1 then
return true
end
if type(trim_to) ~= "number" or trim_to < 1 then
return nil, "invalid-argument";
end
local list_filename = getpath(username, host, datastore, "list");
local index_filename = getpath(username, host, datastore, "lidx");
local index, err = get_list_index(username, host, datastore);
if not index then
return nil, err;
end
local new_first = index[trim_to];
if not new_first then
os_remove(index_filename);
return os_remove(list_filename);
end
local offset = new_first.start;
if offset == 0 then
return true;
end
--[[
if remove_blocks then
local f, err = io_open(list_filename, "r+");
if not f then
return f, err;
end
local diff = 0;
local block_offset = 0;
if offset % 0x1000 ~= 0 then
-- Not an even block boundary, we will have to overwrite
diff = offset % 0x1000;
block_offset = offset - diff;
end
if block_offset == 0 then
log("debug", "")
else
local ok, err = remove_blocks(f, 0, block_offset);
log("debug", "remove_blocks(%s, 0, %d)", f, block_offset);
if not ok then
log("warn", "Could not remove blocks from %q[%d, %d]: %s", list_filename, 0, block_offset, err);
else
if diff ~= 0 then
-- overwrite unaligned leftovers
if f:seek("set", 0) then
local wrote, err = f:write(string.rep("\n", diff));
if not wrote then
log("error", "Could not blank out %q[%d, %d]: %s", list_filename, 0, diff, err);
end
end
end
local ok, err = f:close();
shift_index(index_filename, index, trim_to, offset); -- Shift or delete the index
return ok, err;
end
end
end
--]]
local r, err = io_open(list_filename, "r");
if not r then
return nil, err;
end
local w, err = io_open(list_filename .. "~", "w");
if not w then
return nil, err;
end
r:seek("set", offset);
for block in r:lines(0x1000) do
local ok, err = w:write(block);
if not ok then
return nil, err;
end
end
r:close();
local ok, err = w:close();
if not ok then
return nil, err;
end
shift_index(index_filename, index, trim_to, offset)
return os_rename(list_filename .. "~", list_filename);
end
local type_map = {
keyval = "dat";
list = "list";
}
local function users(host, store, typ) -- luacheck: ignore 431/store
typ = "."..(type_map[typ or "keyval"] or typ);
local store_dir = format("%s/%s/%s", data_path, encode(host), store_encode(store));
local mode, err = lfs.attributes(store_dir, "mode");
if not mode then
return function() log("debug", "%s", err or (store_dir .. " does not exist")) end
end
local next, state = lfs.dir(store_dir); -- luacheck: ignore 431/next 431/state
return function(state) -- luacheck: ignore 431/state
for node in next, state do
if node:sub(-#typ, -1) == typ then
return decode(node:sub(1, -#typ-1));
end
end
end, state;
end
local function stores(username, host, typ)
typ = type_map[typ or "keyval"];
local store_dir = format("%s/%s/", data_path, encode(host));
local mode, err = lfs.attributes(store_dir, "mode");
if not mode then
return function() log("debug", "Could not iterate over stores in %s: %s", store_dir, err); end
end
local next, state = lfs.dir(store_dir); -- luacheck: ignore 431/next 431/state
return function(state) -- luacheck: ignore 431/state
for node in next, state do
if not node:match"^%." then
if username == true then
if lfs.attributes(store_dir..node, "mode") == "directory" then
return decode(node);
end
elseif username then
local store_name = decode(node);
if lfs.attributes(getpath(username, host, store_name, typ), "mode") then
return store_name;
end
elseif lfs.attributes(node, "mode") == "file" then
local file, ext = node:match("^(.*)%.([dalist]+)$");
if ext == typ then
return decode(file)
end
end
end
end
end, state;
end
local function do_remove(path)
local ok, err = os_remove(path);
if not ok and lfs.attributes(path, "mode") then
return ok, err;
end
return true
end
local function purge(username, host)
local host_dir = format("%s/%s/", data_path, encode(host));
local ok, iter, state, var = pcall(lfs.dir, host_dir);
if not ok then
return ok, iter;
end
local errs = {};
for file in iter, state, var do
if lfs.attributes(host_dir..file, "mode") == "directory" then
local store_name = decode(file);
local ok, err = do_remove(getpath(username, host, store_name));
if not ok then errs[#errs+1] = err; end
local ok, err = do_remove(getpath(username, host, store_name, "list"));
if not ok then errs[#errs+1] = err; end
local ok, err = do_remove(getpath(username, host, store_name, "lidx"));
if not ok then errs[#errs+1] = err; end
end
end
return #errs == 0, t_concat(errs, ", ");
end
return {
set_data_path = set_data_path;
add_callback = add_callback;
remove_callback = remove_callback;
getpath = getpath;
load = load;
store = store;
append_raw = append;
store_raw = atomic_store;
list_append = list_append;
list_store = list_store;
list_load = list_load;
users = users;
stores = stores;
purge = purge;
path_decode = decode;
path_encode = encode;
build_list_index = build_list_index;
list_open = list_open;
list_shift = list_shift;
};