mirror of
https://github.com/bjc/prosody.git
synced 2025-04-01 20:27:39 +03:00
The WIP groups support is not complete yet, and won't work without extra modules (which are not yet a part of Prosody). For now we hide --group support unless mod_invites_groups (community module) is specified in modules_enabled.
501 lines
16 KiB
Lua
501 lines
16 KiB
Lua
local id = require "prosody.util.id";
|
|
local it = require "prosody.util.iterators";
|
|
local url = require "socket.url";
|
|
local jid_node = require "prosody.util.jid".node;
|
|
local jid_split = require "prosody.util.jid".split;
|
|
local argparse = require "prosody.util.argparse";
|
|
local human_io = require "prosody.util.human.io";
|
|
|
|
local url_escape = require "prosody.util.http".urlencode;
|
|
local render_url = require "prosody.util.interpolation".new("%b{}", url_escape, {
|
|
urlescape = url_escape;
|
|
noscheme = function (urlstring)
|
|
return (urlstring:gsub("^[^:]+:", ""));
|
|
end;
|
|
});
|
|
|
|
local default_ttl = module:get_option_period("invite_expiry", "1 week");
|
|
|
|
local token_storage;
|
|
if prosody.process_type == "prosody" or prosody.shutdown then
|
|
token_storage = module:open_store("invite_token", "map");
|
|
end
|
|
|
|
local function get_uri(action, jid, token, params) --> string
|
|
return url.build({
|
|
scheme = "xmpp",
|
|
path = jid,
|
|
query = action..";preauth="..token..(params and (";"..params) or ""),
|
|
});
|
|
end
|
|
|
|
local function create_invite(invite_action, invite_jid, allow_registration, additional_data, ttl, reusable)
|
|
local token = id.medium();
|
|
|
|
local created_at = os.time();
|
|
local expires = created_at + (ttl or default_ttl);
|
|
|
|
local invite_params = (invite_action == "roster" and allow_registration) and "ibr=y" or nil;
|
|
|
|
local invite = {
|
|
type = invite_action;
|
|
jid = invite_jid;
|
|
|
|
token = token;
|
|
allow_registration = allow_registration;
|
|
additional_data = additional_data;
|
|
|
|
uri = get_uri(invite_action, invite_jid, token, invite_params);
|
|
|
|
created_at = created_at;
|
|
expires = expires;
|
|
|
|
reusable = reusable;
|
|
};
|
|
|
|
module:fire_event("invite-created", invite);
|
|
|
|
if allow_registration then
|
|
local ok, err = token_storage:set(nil, token, invite);
|
|
if not ok then
|
|
module:log("warn", "Failed to store account invite: %s", err);
|
|
return nil, "internal-server-error";
|
|
end
|
|
end
|
|
|
|
if invite_action == "roster" then
|
|
local username = jid_node(invite_jid);
|
|
local ok, err = token_storage:set(username, token, expires);
|
|
if not ok then
|
|
module:log("warn", "Failed to store subscription invite: %s", err);
|
|
return nil, "internal-server-error";
|
|
end
|
|
end
|
|
|
|
return invite;
|
|
end
|
|
|
|
-- Create invitation to register an account (optionally restricted to the specified username)
|
|
function create_account(account_username, additional_data, ttl) --luacheck: ignore 131/create_account
|
|
local jid = account_username and (account_username.."@"..module.host) or module.host;
|
|
return create_invite("register", jid, true, additional_data, ttl);
|
|
end
|
|
|
|
-- Create invitation to reset the password for an account
|
|
function create_account_reset(account_username, ttl) --luacheck: ignore 131/create_account_reset
|
|
return create_account(account_username, { allow_reset = account_username }, ttl or 86400);
|
|
end
|
|
|
|
-- Create invitation to become a contact of a local user
|
|
function create_contact(username, allow_registration, additional_data, ttl) --luacheck: ignore 131/create_contact
|
|
return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl);
|
|
end
|
|
|
|
-- Create invitation to register an account and join a user group
|
|
-- If explicit ttl is passed, invite is valid for multiple signups
|
|
-- during that time period
|
|
function create_group(group_ids, additional_data, ttl) --luacheck: ignore 131/create_group
|
|
local merged_additional_data = {
|
|
groups = group_ids;
|
|
};
|
|
if additional_data then
|
|
for k, v in pairs(additional_data) do
|
|
merged_additional_data[k] = v;
|
|
end
|
|
end
|
|
return create_invite("register", module.host, true, merged_additional_data, ttl, not not ttl);
|
|
end
|
|
|
|
-- Iterates pending (non-expired, unused) invites that allow registration
|
|
function pending_account_invites() --luacheck: ignore 131/pending_account_invites
|
|
local store = module:open_store("invite_token");
|
|
local now = os.time();
|
|
local function is_valid_invite(_, invite)
|
|
return invite.expires > now;
|
|
end
|
|
return it.filter(is_valid_invite, pairs(store:get(nil) or {}));
|
|
end
|
|
|
|
function get_account_invite_info(token) --luacheck: ignore 131/get_account_invite_info
|
|
if not token then
|
|
return nil, "no-token";
|
|
end
|
|
|
|
-- Fetch from host store (account invite)
|
|
local token_info = token_storage:get(nil, token);
|
|
if not token_info then
|
|
return nil, "token-invalid";
|
|
elseif os.time() > token_info.expires then
|
|
return nil, "token-expired";
|
|
end
|
|
|
|
return token_info;
|
|
end
|
|
|
|
function delete_account_invite(token) --luacheck: ignore 131/delete_account_invite
|
|
if not token then
|
|
return nil, "no-token";
|
|
end
|
|
|
|
return token_storage:set(nil, token, nil);
|
|
end
|
|
|
|
local valid_invite_methods = {};
|
|
local valid_invite_mt = { __index = valid_invite_methods };
|
|
|
|
function valid_invite_methods:use()
|
|
if self.reusable then
|
|
return true;
|
|
end
|
|
|
|
if self.username then
|
|
-- Also remove the contact invite if present, on the
|
|
-- assumption that they now have a mutual subscription
|
|
token_storage:set(self.username, self.token, nil);
|
|
end
|
|
token_storage:set(nil, self.token, nil);
|
|
|
|
return true;
|
|
end
|
|
|
|
-- Get a validated invite (or nil, err). Must call :use() on the
|
|
-- returned invite after it is actually successfully used
|
|
-- For "roster" invites, the username of the local user (who issued
|
|
-- the invite) must be passed.
|
|
-- If no username is passed, but the registration is a roster invite
|
|
-- from a local user, the "inviter" field of the returned invite will
|
|
-- be set to their username.
|
|
function get(token, username)
|
|
if not token then
|
|
return nil, "no-token";
|
|
end
|
|
|
|
local valid_until, inviter;
|
|
|
|
-- Fetch from host store (account invite)
|
|
local token_info = token_storage:get(nil, token);
|
|
|
|
if username then -- token being used for subscription
|
|
-- Fetch from user store (subscription invite)
|
|
valid_until = token_storage:get(username, token);
|
|
else -- token being used for account creation
|
|
valid_until = token_info and token_info.expires;
|
|
if token_info and token_info.type == "roster" then
|
|
username = jid_node(token_info.jid);
|
|
inviter = username;
|
|
end
|
|
end
|
|
|
|
if not valid_until then
|
|
module:log("debug", "Got unknown token: %s", token);
|
|
return nil, "token-invalid";
|
|
elseif os.time() > valid_until then
|
|
module:log("debug", "Got expired token: %s", token);
|
|
return nil, "token-expired";
|
|
end
|
|
|
|
return setmetatable({
|
|
token = token;
|
|
username = username;
|
|
inviter = inviter;
|
|
type = token_info and token_info.type or "roster";
|
|
uri = token_info and token_info.uri or get_uri("roster", username.."@"..module.host, token);
|
|
additional_data = token_info and token_info.additional_data or nil;
|
|
reusable = token_info and token_info.reusable or false;
|
|
}, valid_invite_mt);
|
|
end
|
|
|
|
function use(token) --luacheck: ignore 131/use
|
|
local invite = get(token);
|
|
return invite and invite:use();
|
|
end
|
|
|
|
-- Point at e.g. a deployment of https://github.com/modernxmpp/easy-xmpp-invitation
|
|
-- This URL must always be absolute, as it is shared standalone
|
|
local invite_url_template = module:get_option_string("invites_page");
|
|
local invites_page_supports = module:get_option_set("invites_page_supports", { "account", "contact", "account-and-contact" });
|
|
|
|
local function add_landing_url(invite)
|
|
if not invite_url_template or invite.landing_page then return; end
|
|
|
|
-- Determine whether this type of invitation is supported by the landing page
|
|
local invite_type;
|
|
if invite.type == "register" then
|
|
invite_type = "account";
|
|
elseif invite.type == "roster" then
|
|
if invite.allow_registration then
|
|
invite_type = "account-and-contact";
|
|
else
|
|
invite_type = "contact-only";
|
|
end
|
|
end
|
|
if not invites_page_supports:contains(invite_type) then
|
|
return; -- Invitation type unsupported
|
|
end
|
|
|
|
invite.landing_page = render_url(invite_url_template, { host = module.host, invite = invite });
|
|
end
|
|
|
|
module:hook("invite-created", add_landing_url, -1);
|
|
|
|
--- shell command
|
|
-- COMPAT: Dynamic groups are work in progress as of 13.0, so we'll use the
|
|
-- presence of mod_invites_groups (a community module) to determine whether to
|
|
-- expose our support for invites to groups.
|
|
local have_group_invites = module:get_option_inherited_set("modules_enabled"):contains("invites_groups");
|
|
|
|
module:add_item("shell-command", {
|
|
section = "invite";
|
|
section_desc = "Create and manage invitations";
|
|
name = "create_account";
|
|
desc = "Create an invitation to make an account on this server with the specified JID (supply only a hostname to allow any username)";
|
|
args = {
|
|
{ name = "user_jid", type = "string" };
|
|
};
|
|
host_selector = "user_jid";
|
|
flags = {
|
|
array_params = { role = true, group = have_group_invites };
|
|
value_params = { expires_after = true };
|
|
};
|
|
|
|
handler = function (self, user_jid, opts) --luacheck: ignore 212/self
|
|
local username = jid_split(user_jid);
|
|
local roles = opts and opts.role or {};
|
|
local groups = opts and opts.group or {};
|
|
|
|
if opts and opts.admin then
|
|
-- Insert it first since we don't get order out of argparse
|
|
table.insert(roles, 1, "prosody:admin");
|
|
end
|
|
|
|
local ttl;
|
|
if opts and opts.expires_after then
|
|
ttl = human_io.parse_duration(opts.expires_after);
|
|
if not ttl then
|
|
return false, "Unable to parse duration: "..opts.expires_after;
|
|
end
|
|
end
|
|
|
|
local invite = assert(create_account(username, {
|
|
roles = roles;
|
|
groups = groups;
|
|
}, ttl));
|
|
|
|
return true, invite.landing_page or invite.uri;
|
|
end;
|
|
});
|
|
|
|
module:add_item("shell-command", {
|
|
section = "invite";
|
|
section_desc = "Create and manage invitations";
|
|
name = "create_reset";
|
|
desc = "Create a password reset link for the specified user";
|
|
args = { { name = "user_jid", type = "string" } };
|
|
host_selector = "user_jid";
|
|
flags = {
|
|
value_params = { expires_after = true };
|
|
};
|
|
|
|
handler = function (self, user_jid, opts) --luacheck: ignore 212/self
|
|
local username = jid_split(user_jid);
|
|
if not username then
|
|
return nil, "Supply the JID of the account you want to generate a password reset for";
|
|
end
|
|
local duration_sec = require "prosody.util.human.io".parse_duration(opts and opts.expires_after or "1d");
|
|
if not duration_sec then
|
|
return nil, "Unable to parse duration: "..opts.expires_after;
|
|
end
|
|
local invite, err = create_account_reset(username, duration_sec);
|
|
if not invite then return nil, err; end
|
|
self.session.print(invite.landing_page or invite.uri);
|
|
return true, ("Password reset link for %s valid until %s"):format(user_jid, os.date("%Y-%m-%d %T", invite.expires));
|
|
end;
|
|
});
|
|
|
|
module:add_item("shell-command", {
|
|
section = "invite";
|
|
section_desc = "Create and manage invitations";
|
|
name = "create_contact";
|
|
desc = "Create an invitation to become contacts with the specified user";
|
|
args = { { name = "user_jid", type = "string" } };
|
|
host_selector = "user_jid";
|
|
flags = {
|
|
value_params = { expires_after = true };
|
|
kv_params = { allow_registration = true };
|
|
};
|
|
|
|
handler = function (self, user_jid, opts) --luacheck: ignore 212/self
|
|
local username = jid_split(user_jid);
|
|
if not username then
|
|
return nil, "Supply the JID of the account you want the recipient to become a contact of";
|
|
end
|
|
local ttl;
|
|
if opts and opts.expires_after then
|
|
ttl = require "prosody.util.human.io".parse_duration(opts.expires_after);
|
|
if not ttl then
|
|
return nil, "Unable to parse duration: "..opts.expires_after;
|
|
end
|
|
end
|
|
local invite, err = create_contact(username, opts and opts.allow_registration, nil, ttl);
|
|
if not invite then return nil, err; end
|
|
return true, invite.landing_page or invite.uri;
|
|
end;
|
|
});
|
|
|
|
module:add_item("shell-command", {
|
|
section = "invite";
|
|
section_desc = "Create and manage invitations";
|
|
name = "show";
|
|
desc = "Show details of an account invitation token";
|
|
args = { { name = "host", type = "string" }, { name = "token", type = "string" } };
|
|
host_selector = "host";
|
|
|
|
handler = function (self, host, token) --luacheck: ignore 212/self 212/host
|
|
local invite, err = get_account_invite_info(token);
|
|
if not invite then return nil, err; end
|
|
|
|
local print = self.session.print;
|
|
|
|
if invite.type == "roster" then
|
|
print("Invitation to register and become a contact of "..invite.jid);
|
|
elseif invite.type == "register" then
|
|
local jid_user, jid_host = jid_split(invite.jid);
|
|
if invite.additional_data and invite.additional_data.allow_reset then
|
|
print("Password reset for "..invite.additional_data.allow_reset.."@"..jid_host);
|
|
elseif jid_user then
|
|
print("Invitation to register on "..jid_host.." with username '"..jid_user.."'");
|
|
else
|
|
print("Invitation to register on "..jid_host);
|
|
end
|
|
else
|
|
print("Unknown invitation type");
|
|
end
|
|
|
|
if invite.inviter then
|
|
print("Creator:", invite.inviter);
|
|
end
|
|
|
|
print("Created:", os.date("%Y-%m-%d %T", invite.created_at));
|
|
print("Expires:", os.date("%Y-%m-%d %T", invite.expires));
|
|
|
|
print("");
|
|
|
|
if invite.uri then
|
|
print("XMPP URI:", invite.uri);
|
|
end
|
|
|
|
if invite.landing_page then
|
|
print("Web link:", invite.landing_page);
|
|
end
|
|
|
|
if invite.additional_data then
|
|
print("");
|
|
if invite.additional_data.roles then
|
|
if invite.additional_data.roles[1] then
|
|
print("Role:", invite.additional_data.roles[1]);
|
|
end
|
|
if invite.additional_data.roles[2] then
|
|
print("Secondary roles:", table.concat(invite.additional_data.roles, ", ", 2, #invite.additional_data.roles));
|
|
end
|
|
end
|
|
if invite.additional_data.groups then
|
|
print("Groups:", table.concat(invite.additional_data.groups, ", "));
|
|
end
|
|
if invite.additional_data.note then
|
|
print("Comment:", invite.additional_data.note);
|
|
end
|
|
end
|
|
|
|
return true, "Invitation valid";
|
|
end;
|
|
});
|
|
|
|
module:add_item("shell-command", {
|
|
section = "invite";
|
|
section_desc = "Create and manage invitations";
|
|
name = "delete";
|
|
desc = "Delete/revoke an invitation token";
|
|
args = { { name = "host", type = "string" }, { name = "token", type = "string" } };
|
|
host_selector = "host";
|
|
|
|
handler = function (self, host, token) --luacheck: ignore 212/self 212/host
|
|
local invite, err = delete_account_invite(token);
|
|
if not invite then return nil, err; end
|
|
return true, "Invitation deleted";
|
|
end;
|
|
});
|
|
|
|
module:add_item("shell-command", {
|
|
section = "invite";
|
|
section_desc = "Create and manage invitations";
|
|
name = "list";
|
|
desc = "List pending invitations which allow account registration";
|
|
args = { { name = "host", type = "string" } };
|
|
host_selector = "host";
|
|
|
|
handler = function (self, host) -- luacheck: ignore 212/host
|
|
local print_row = human_io.table({
|
|
{
|
|
title = "Token";
|
|
key = "invite";
|
|
width = 24;
|
|
mapper = function (invite)
|
|
return invite.token;
|
|
end;
|
|
};
|
|
{
|
|
title = "Expires";
|
|
key = "invite";
|
|
width = 20;
|
|
mapper = function (invite)
|
|
return os.date("%Y-%m-%dT%T", invite.expires);
|
|
end;
|
|
};
|
|
{
|
|
title = "Description";
|
|
key = "invite";
|
|
width = "100%";
|
|
mapper = function (invite)
|
|
if invite.type == "roster" then
|
|
return "Contact with "..invite.jid;
|
|
elseif invite.type == "register" then
|
|
local jid_user, jid_host = jid_split(invite.jid);
|
|
if invite.additional_data and invite.additional_data.allow_reset then
|
|
return "Password reset for "..invite.additional_data.allow_reset.."@"..jid_host;
|
|
end
|
|
if jid_user then
|
|
return "Register on "..jid_host.." with username "..jid_user;
|
|
end
|
|
return "Register on "..jid_host;
|
|
end
|
|
end;
|
|
};
|
|
}, self.session.width);
|
|
|
|
self.session.print(print_row());
|
|
local count = 0;
|
|
for _, invite in pending_account_invites() do
|
|
count = count + 1;
|
|
self.session.print(print_row({ invite = invite }));
|
|
end
|
|
return true, ("%d pending invites"):format(count);
|
|
end;
|
|
});
|
|
|
|
local subcommands = {};
|
|
|
|
--- prosodyctl command
|
|
function module.command(arg)
|
|
local opts = argparse.parse(arg, { short_params = { h = "help"; ["?"] = "help" } });
|
|
local cmd = table.remove(arg, 1); -- pop command
|
|
if opts.help or not cmd or not subcommands[cmd] then
|
|
print("usage: prosodyctl mod_"..module.name.." generate example.com");
|
|
return 2;
|
|
end
|
|
return subcommands[cmd](arg);
|
|
end
|
|
|
|
function subcommands.generate()
|
|
print("This command is deprecated. Please see 'prosodyctl shell help invite' for available commands.");
|
|
return 1;
|
|
end
|