feat: OMEMO по умолчанию, admin_web+BOSH, MUC-модули (vcard_muc, muc_moderation), модули из apt в modules/, scripts/omemo_check.py, backup.sh на Яндекс.Диск

- index.html: omemo_default=true (XEP-0384, встроен в Converse v14)
- prosody.cfg.lua: admin_web + bosh (глобальные), MUC: vcard_muc (XEP-0153) + muc_moderation (XEP-0425)
- modules/: mod_admin_web (+www_files), mod_http_upload_external, mod_muc_moderation, mod_vcard_muc — из prosody-modules (apt)
- scripts/omemo_check.py — проверка OMEMO-бандлов
- backup.sh — ежедневный бэкап проекта (data/config/certs/modules/webchat/доки) на /mnt/yandex-disk, ротация 7/30 дней
- STATUS/WALKTHROUGH: OMEMO, admin_web, HTTP Upload итоги
This commit is contained in:
2026-08-29 07:33:14 +00:00
parent 0695ddef75
commit 527584281d
19 changed files with 1878 additions and 5 deletions
+343
View File
@@ -0,0 +1,343 @@
-- Copyright (C) 2010 Florian Zeitz
--
-- This file is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--
-- <session xmlns="http://prosody.im/streams/c2s" jid="alice@example.com/brussels">
-- <encrypted/>
-- <compressed/>
-- </session>
-- <session xmlns="http://prosody.im/streams/s2s" jid="example.com">
-- <encrypted>
-- <valid/> / <invalid/>
-- </encrypted>
-- <compressed/>
-- <in/> / <out/>
-- </session>
local st = require "util.stanza";
local uuid_generate = require "util.uuid".generate;
local is_admin = require "core.usermanager".is_admin;
local pubsub = require "util.pubsub";
local jid_bare = require "util.jid".bare;
local hosts = prosody.hosts;
local incoming_s2s = prosody.incoming_s2s;
module:set_global();
local service = {};
local xmlns_adminsub = "http://prosody.im/adminsub";
local xmlns_c2s_session = "http://prosody.im/streams/c2s";
local xmlns_s2s_session = "http://prosody.im/streams/s2s";
local idmap = {};
local function add_client(session, host)
local name = session.full_jid;
local id = idmap[name];
if not id then
id = uuid_generate();
idmap[name] = id;
end
local item = st.stanza("item", { id = id }):tag("session", {xmlns = xmlns_c2s_session, jid = name}):up();
if session.secure then
local encrypted = item:tag("encrypted");
local sock = session.conn and session.conn.socket and session.conn:socket()
local info = sock and sock.info and sock:info();
for k, v in pairs(info or {}) do
encrypted:tag("info", { name = k }):text(tostring(v)):up();
end
end
if session.compressed then
item:tag("compressed"):up();
end
service[host]:publish(xmlns_c2s_session, host, id, item);
module:log("debug", "Added client %s", name);
end
local function del_client(session, host)
local name = session.full_jid;
local id = idmap[name];
if id then
local notifier = st.stanza("retract", { id = id });
service[host]:retract(xmlns_c2s_session, host, id, notifier);
end
end
local function add_host(session, type, host)
local name = (type == "out" and session.to_host) or (type == "in" and session.from_host);
local id = idmap[name.."_"..type];
if not id then
id = uuid_generate();
idmap[name.."_"..type] = id;
end
local item = st.stanza("item", { id = id }):tag("session", {xmlns = xmlns_s2s_session, jid = name})
:tag(type):up();
if session.secure then
local encrypted = item:tag("encrypted");
local sock = session.conn and session.conn.socket and session.conn:socket()
local info = sock and sock.info and sock:info();
for k, v in pairs(info or {}) do
encrypted:tag("info", { name = k }):text(tostring(v)):up();
end
if session.cert_identity_status == "valid" then
encrypted:tag("valid");
else
encrypted:tag("invalid");
end
end
if session.compressed then
item:tag("compressed"):up();
end
service[host]:publish(xmlns_s2s_session, host, id, item);
module:log("debug", "Added host %s s2s%s", name, type);
end
local function del_host(session, type, host)
local name = (type == "out" and session.to_host) or (type == "in" and session.from_host);
local id = idmap[name.."_"..type];
if id then
local notifier = st.stanza("retract", { id = id });
service[host]:retract(xmlns_s2s_session, host, id, notifier);
end
end
local function get_affiliation(jid, host)
local bare_jid = jid_bare(jid);
if is_admin(bare_jid, host) then
return "member";
else
return "none";
end
end
function module.add_host(module)
-- Dependencies
module:depends("bosh");
module:depends("admin_adhoc");
module:depends("http");
local serve;
if not pcall(function ()
local http_files = require "net.http.files";
serve = http_files.serve;
end) then
serve = module:depends"http_files".serve;
end
local serve_file = serve {
path = module:get_directory() .. "/www_files";
};
-- Setup HTTP server
module:provides("http", {
title = "Admin Interface";
name = "admin";
route = {
["GET"] = function(event)
event.response.headers.location = event.request.path .. "/";
return 301;
end;
["GET /*"] = serve_file;
}
});
-- Setup adminsub service
local function simple_broadcast(kind, node, jids, item)
if item then
item = st.clone(item);
item.attr.xmlns = nil; -- Clear the pubsub namespace
end
local message = st.message({ from = module.host, type = "headline" })
:tag("event", { xmlns = xmlns_adminsub .. "#event" })
:tag(kind, { node = node })
:add_child(item);
for jid in pairs(jids) do
module:log("debug", "Sending notification to %s", jid);
message.attr.to = jid;
module:send(message);
end
end
service[module.host] = pubsub.new({
broadcaster = simple_broadcast;
normalize_jid = jid_bare;
get_affiliation = function(jid) return get_affiliation(jid, module.host) end;
capabilities = {
member = {
create = false;
publish = false;
retract = false;
get_nodes = true;
subscribe = true;
unsubscribe = true;
get_subscription = true;
get_subscriptions = true;
get_items = true;
subscribe_other = false;
unsubscribe_other = false;
get_subscription_other = false;
get_subscriptions_other = false;
be_subscribed = true;
be_unsubscribed = true;
set_affiliation = false;
};
owner = {
create = true;
publish = true;
retract = true;
get_nodes = true;
subscribe = true;
unsubscribe = true;
get_subscription = true;
get_subscriptions = true;
get_items = true;
subscribe_other = true;
unsubscribe_other = true;
get_subscription_other = true;
get_subscriptions_other = true;
be_subscribed = true;
be_unsubscribed = true;
set_affiliation = true;
};
};
});
-- Create node for s2s sessions
local ok, err = service[module.host]:create(xmlns_s2s_session, true);
if not ok then
module:log("warn", "Could not create node %s: %s", xmlns_s2s_session, err);
else
service[module.host]:set_affiliation(xmlns_s2s_session, true, module.host, "owner")
end
-- Add outgoing s2s sessions
for _, session in pairs(hosts[module.host].s2sout) do
if session.type ~= "s2sout_unauthed" then
add_host(session, "out", module.host);
end
end
-- Add incoming s2s sessions
for session in pairs(incoming_s2s) do
if session.to_host == module.host then
add_host(session, "in", module.host);
end
end
-- Create node for c2s sessions
ok, err = service[module.host]:create(xmlns_c2s_session, true);
if not ok then
module:log("warn", "Could not create node %s: %s", xmlns_c2s_session, tostring(err));
else
service[module.host]:set_affiliation(xmlns_c2s_session, true, module.host, "owner")
end
-- Add c2s sessions
for _, user in pairs(hosts[module.host].sessions or {}) do
for _, session in pairs(user.sessions or {}) do
add_client(session, module.host);
end
end
-- Register adminsub handler
module:hook("iq/host/http://prosody.im/adminsub:adminsub", function(event)
-- luacheck: ignore 431/ok
local origin, stanza = event.origin, event.stanza;
local adminsub = stanza.tags[1];
local action = adminsub.tags[1];
local reply;
if action.name == "subscribe" then
local ok, ret = service[module.host]:add_subscription(action.attr.node, stanza.attr.from, stanza.attr.from);
if ok then
reply = st.reply(stanza)
:tag("adminsub", { xmlns = xmlns_adminsub });
else
reply = st.error_reply(stanza, "cancel", ret);
end
elseif action.name == "unsubscribe" then
local ok, ret = service[module.host]:remove_subscription(action.attr.node, stanza.attr.from, stanza.attr.from);
if ok then
reply = st.reply(stanza)
:tag("adminsub", { xmlns = xmlns_adminsub });
else
reply = st.error_reply(stanza, "cancel", ret);
end
elseif action.name == "items" then
local node = action.attr.node;
local ok, ret = service[module.host]:get_items(node, stanza.attr.from);
if not ok then
origin.send(st.error_reply(stanza, "cancel", ret));
return true;
end
local data = st.stanza("items", { node = node });
for _, entry in pairs(ret) do
data:add_child(entry);
end
if data then
reply = st.reply(stanza)
:tag("adminsub", { xmlns = xmlns_adminsub })
:add_child(data);
else
reply = st.error_reply(stanza, "cancel", "item-not-found");
end
elseif action.name == "adminfor" then
local data = st.stanza("adminfor");
for host_name in pairs(hosts) do
if is_admin(stanza.attr.from, host_name) then
data:tag("item"):text(host_name):up();
end
end
reply = st.reply(stanza)
:tag("adminsub", { xmlns = xmlns_adminsub })
:add_child(data);
else
reply = st.error_reply(stanza, "feature-not-implemented");
end
origin.send(reply);
return true;
end);
-- Add/remove c2s sessions
module:hook("resource-bind", function(event)
add_client(event.session, module.host);
end);
module:hook("resource-unbind", function(event)
del_client(event.session, module.host);
service[module.host]:remove_subscription(xmlns_c2s_session, module.host, event.session.full_jid);
service[module.host]:remove_subscription(xmlns_s2s_session, module.host, event.session.full_jid);
end);
-- Add/remove s2s sessions
module:hook("s2sout-established", function(event)
add_host(event.session, "out", module.host);
end);
module:hook("s2sin-established", function(event)
add_host(event.session, "in", module.host);
end);
module:hook("s2sout-destroyed", function(event)
del_host(event.session, "out", module.host);
end);
module:hook("s2sin-destroyed", function(event)
del_host(event.session, "in", module.host);
end);
end
@@ -0,0 +1,114 @@
body {
margin: 0
}
a {
color: #0000FF
}
#adhocCommands > ul {
margin: 0
}
.btn {
margin-right: 0.3em
}
.btn:last-child {
margin-right: 0
}
#log_container {
clear: both;
display: none
}
#adhocCommands {
border-right: solid 1px
}
#adhocCommands li {
list-style: inside
}
#login {
float: left;
margin: 1em 2em 0 1em;
padding-right: 1em;
border: solid 1px;
background: #eef0f2;
color: #000000
}
#main {
display: none;
margin: 1em
}
#main p {
margin: 0
}
#top {
clear: both;
width: 100%;
padding: 0;
}
@media screen and (min-width: 757px) {
#header {
background: url(../images/blue_orange.png) repeat-x
}
}
#header img {
max-width: 100%;
height: auto
}
#menu {
display: none;
color: #454748;
font-size: 1.1em;
background: #eef0f2;
width: 100%;
}
#menu ul {
display: inline;
list-style-type: none;
margin: 0;
padding: 0.5em 0
}
#menu li {
display: inline;
padding: 0 0.5em
}
#menu a {
color: #454748;
text-decoration: none
}
#menu li a:hover {
color: #6197DF;
text-decoration: underline
}
#selector {
display: inline-block
}
#s2sList h2, #c2sList h2 {
color: #4b8ade;
margin: 0
}
#s2sList li, #c2sList li {
cursor: pointer
}
#host {
margin: 0.25em;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Prosody Webadmin</title>
<link rel="stylesheet" type="text/css" href="css/bootstrap-1.4.0.min.css" />
<link rel="stylesheet" type="text/css" href="css/style.css" />
<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
<script type="text/javascript" src="js/jquery-1.10.2.min.js"></script>
<script type="text/javascript" src="js/strophe.min.js"></script>
<script type="text/javascript" src="js/adhoc.js"></script>
<script type="text/javascript" src="js/main.js"></script>
</head>
<body>
<div id='top'>
<div id='header'>
<img src="images/prosody.png" alt="Prosody"/>
</div>
<div id='menu'>
<ul>
<li><a id='adhocMenu' href="#adhoc">General</a></li>
<li><a id='serverMenu' href="#s2sList">Servers</a></li>
<li><a id='clientMenu' href="#c2sList">Clients</a></li>
<li><span id="selector">Host:&nbsp;<select id="host"></select></span></li>
<li><a href="#" id='logout'>Logout</a></li>
</ul>
</div>
</div>
<div id='login'>
<form id='cred' name='cred' class='form-stacked'>
<fieldset>
<div class='clearfix'>
<label for='jid'>JID:</label>
<div class='input'>
<input type='text' id='jid' />
</div>
</div>
<div class='clearfix'>
<label for='pass'>Password:</label>
<div class='input'>
<input type='password' id='pass' />
</div>
</div>
<div class='clearfix'>
<div class='input'>
<input type='submit' id='connect' value='Login' class='btn' />
</div>
</div>
</fieldset>
</form>
</div>
<div id='main'>
<div id="adhoc">
<div class="row">
<div id="adhocCommands" class="span4"></div>
<div id="adhocDisplay" class="span12"></div>
</div>
</div>
<div id="s2sList" class="container">
<div class="row">
<div class="span8">
<h2>Incoming S2S connections:</h2>
<ul id="s2sin"></ul>
</div>
<div class="span8">
<h2>Outgoing S2S connections:</h2>
<ul id="s2sout"></ul>
</div>
</div>
</div>
<div id="c2sList" class="container">
<div class="row">
<div class="span16">
<h2>Client connections:</h2>
<ul id="c2s"></ul>
</div>
</div>
</div>
</div>
<div id='log_container'>
<a id='log_toggle' href='#'>Status Log :</a>
<div id='log'></div>
</div>
</body>
</html>
+252
View File
@@ -0,0 +1,252 @@
// adhocweb
// Copyright (c) 2010-2013 Florian Zeitz
// This project is MIT licensed.
// http://git.babelmonkeys.de/?p=adhocweb.git;a=blob_plain;f=js/adhoc.js
Strophe.addNamespace('ADHOC', 'http://jabber.org/protocol/commands')
function Adhoc (view, readycb) {
this.status = {
sessionid: null,
cmdNode: null,
queryJID: null,
readycb: readycb,
view: view
}
}
Adhoc.prototype = {
constructor: Adhoc,
addNote: function (text, type) {
if (!type) {
type = 'info'
}
text = text.replace(/\n/g, '<br/>')
$(this.status.view).append(`<p class='${type}Note'>${text}</p>`)
},
addForm: function (x) {
let self = this
let form = $(`<form class='form-stacked' action='#'/>`)
form.submit(function (event) {
self.executeCommand('execute', self.serializeToDataform('form'),
function (e) { self.displayResult(e) })
event.preventDefault()
})
let fieldset = $('<fieldset/>')
form.append(fieldset)
$(x).find('title').each(function () { $('<legend/>').text($(this).text()).appendTo(fieldset) })
$(x).find('instructions').each(function () { $('<p/>').text($(this).text()).appendTo(fieldset) })
$(x).find('field').each(function () {
let clearfix = $(`<div class='clearfix'/>`)
let item = self.buildHTMLField(this)
let label = $(this).attr('label')
if (label) {
$('<label/>').text(label).attr('for', $(this).attr('var')).appendTo(clearfix)
}
if ($(x).attr('type') === 'result') {
item.attr('readonly', true)
}
clearfix.append(item)
fieldset.append(clearfix)
})
$(self.status.view).append(form)
},
buildHTMLField: function (fld) {
let field = $(fld), html = {
'hidden': `<input type='hidden'/>`,
'boolean': `<input type='checkbox'/>`,
'fixed': `<input type='text' readonly='readonly'/>`,
'text-single': `<input type='text'/>`,
'text-private': `<input type='password'/>`,
'text-multi': `<textarea rows='10' cols='70'/>`,
'jid-single': `<input type='text'/>`,
'jid-multi': `<textarea rows='10' cols='70'/>`,
'list-single': `<select/>`,
'list-multi': `<select multiple='multiple'/>`,
}
let type = field.attr('type')
let input = $(html[type] || '<input/>')
let name = field.attr('var')
input.addClass('df-item')
if (name) {
input.attr('name', name)
input.attr('id', name)
}
if (field.find('required').length > 0) {
input.attr('required', 'required')
}
/* Add possible values to the lists */
if (type === 'list-multi' || type === 'list-single') {
field.find('option').each(function () {
let option = $('<option/>')
option.text($(this).attr('label'))
option.val($(this).find('value').text())
input.append(option)
})
}
/* Add/select default values */
field.children('value').each(function () {
let value = $(this).text()
if ((type === 'text-multi') || (type === 'jid-multi')) {
input.text(input.text() + value + '\n') /* .append() would work, but doesn't escape */
} else if (type === 'list-multi') {
input.children(`option[value="${value}"]`).each(function () {
$(this).attr('selected', 'selected')
})
} else {
input.val(value)
}
})
return input
},
serializeToDataform: function (form) {
let st = $build('x', { 'xmlns': 'jabber:x:data', 'type': 'submit' })
$(form).find('.df-item').each(function () {
st.c('field', { 'var': $(this).attr('name') })
if (this.nodeName.toLowerCase() === 'select' && this.multiple) {
for (let i = 0; i < this.options.length; i++) {
if (this.options[i].selected) {
st.c('value').t(this.options[i].text).up()
}
}
} else if (this.nodeName.toLowerCase() === 'textarea') {
let sp_value = this.value.split(/\r?\n|\r/g)
for (let i = 0; i < sp_value.length; i++) {
st.c('value').t(sp_value[i]).up()
}
} else if (this.nodeName.toLowerCase() === 'input' && this.type === 'checkbox') {
if (this.checked) {
st.c('value').t('1')
} else {
st.c('value').t('0')
}
} else {
/* if this has value then */
st.c('value').t($(this).val()).up()
}
st.up()
})
st.up()
return st.tree()
},
displayResult: function (result) {
let self = this
let status = $(result).find('command').attr('status')
let kinds = { 'prev': 'Prev', 'next': 'Next', 'complete': 'Complete' }
let actions = $(result).find('actions:first')
$(self.status.view).empty()
$(result).find('command > *').each(function () {
if ($(this).is('note')) {
self.addNote($(this).text(), $(this).attr('type'))
} else if ($(this).is('x[xmlns=jabber:x:data]')) {
self.addForm(this)
}
})
if (status === 'executing') {
let controls = $(`<div class='actions'/>`)
for (let kind in kinds) {
let input;
(function (type) {
input = $(`<input type='button' disabled='disabled' class='btn' value='${kinds[type]}'/>`)
.click(function () {
self.executeCommand(type, (type !== 'prev') && self.serializeToDataform('form'), function (e) { self.displayResult(e) })
}).appendTo(controls)
})(kind)
if (actions.find(kind).length > 0) {
input.removeAttr('disabled')
}
if (actions.attr('execute') === kind) {
input.addClass('primary')
}
}
$(`<input type='button' class='btn' value='Cancel'/>`).click(function () {
self.cancelCommand(function (e) { self.displayResult(e) })
}).appendTo(controls)
$(self.status.view + ' fieldset').append(controls)
} else {
self.status.sessionid = null
self.status.cmdNode = null
self.status.readycb()
}
},
runCommand: function (item, callback) {
this.status.cmdNode = $(item).attr('id') /* Save node of executed command */
let cb = function (result) {
this.status.sessionid = $(result).find('command').attr('sessionid')
callback(result)
}
this.executeCommand('execute', false, cb.bind(this))
},
executeCommand: function (type, childs, callback) {
let execIQ
if (this.status.sessionid) {
execIQ = $iq({ type: 'set', to: this.status.queryJID, id: connection.getUniqueId() })
.c('command', { xmlns: Strophe.NS.ADHOC, node: this.status.cmdNode, sessionid: this.status.sessionid, action: type })
} else {
execIQ = $iq({ type: 'set', to: this.status.queryJID, id: connection.getUniqueId() })
.c('command', { xmlns: Strophe.NS.ADHOC, node: this.status.cmdNode, action: type })
}
if (childs) {
execIQ.cnode(childs)
}
connection.sendIQ(execIQ, callback)
},
cancelCommand: function (callback) {
if (this.status.cmdNode == null) return
this.executeCommand('cancel', false, callback)
this.status.cmdNode = null
this.status.sessionid = null
},
getCommandNodes: function (callback) {
let self = this
let nodesIQ = $iq({ type: 'get', to: self.status.queryJID, id: connection.getUniqueId() }).c('query', { xmlns: Strophe.NS.DISCO_ITEMS, node: Strophe.NS.ADHOC })
connection.sendIQ(nodesIQ, function (result) {
let items = $('<ul></ul>')
$(result).find('item').each(function () {
let attrNode = $(this).attr('node')
let attrName = $(this).attr('name')
$('<li></li>').append($(`<a href='#' id='${attrNode}'>${attrName}</a>`).click(function (event) {
self.cancelCommand(function () {})
self.runCommand(this, function (result) { self.displayResult(result) })
event.preventDefault()
})).appendTo(items)
})
callback(items)
})
},
checkFeatures: function (jid, cb, ecb) {
if (this.status.sessionid) {
this.cancelCommand()
}
this.status.queryJID = jid
let featureIQ = $iq({ type: 'get', to: this.status.queryJID, id: connection.getUniqueId() }).c('query', { xmlns: Strophe.NS.DISCO_INFO })
$(this.status.view).empty()
function callback (result) {
if ($(result).find(`feature[var='${trophe.NS.ADHOC}']`).length > 0) {
cb(result)
} else {
ecb(result)
}
}
connection.sendIQ(featureIQ, callback, ecb)
}
}
+277
View File
@@ -0,0 +1,277 @@
const BOSH_SERVICE = '/http-bind/'
let show_log = false
Strophe.addNamespace('C2SSTREAM', 'http://prosody.im/streams/c2s')
Strophe.addNamespace('S2SSTREAM', 'http://prosody.im/streams/s2s')
Strophe.addNamespace('ADMINSUB', 'http://prosody.im/adminsub')
Strophe.addNamespace('CAPS', 'http://jabber.org/protocol/caps')
let localJID = null
let connection = null
let adminsubHost = null
let adhocControl = new Adhoc('#adhocDisplay', function () {})
function log (msg) {
let entry = $('<div></div>').append(document.createTextNode(msg))
$('#log').append(entry)
}
function rawInput (data) {
log('RECV: ' + data)
}
function rawOutput (data) {
log('SENT: ' + data)
}
function _cbNewS2S (e) {
let items = e.getElementsByTagName('item')
for (let i = 0; i < items.length; i++) {
let item = items[i]
let id = item.attributes.getNamedItem('id').value
let jid = item.getElementsByTagName('session')[0].attributes.getNamedItem('jid').value
let infos = item.getElementsByTagName('info')
let entry = $(`<li id="${id}">${jid}</li>`)
let tmp = item.getElementsByTagName('encrypted')[0]
if (tmp) {
if (tmp.getElementsByTagName('valid')[0]) {
entry.append('<img src="images/secure.png" title="encrypted (certificate valid)" alt=" (secure) (encrypted)" />')
} else {
entry.append('<img src="images/encrypted.png" title="encrypted (certificate invalid)" alt=" (encrypted)" />')
}
}
if (item.getElementsByTagName('compressed')[0]) {
entry.append('<img src="images/compressed.png" title="compressed" alt=" (compressed)" />')
}
let metadata = $('<ul/>').css('display', 'none')
entry.on('click', function () {
$(this).find('ul').slideToggle()
})
metadata.appendTo(entry)
for (let j = 0; j < infos.length; j++) {
let info = infos[j]
let infoName = info.attributes.getNamedItem('name').value
let infoText = info.textContent
metadata.append(`<li><b>${infoName}:</b> ${infoText}</li>`)
}
if (infos.length == 0) {
metadata.append('<li>No information available</li>')
}
if (items[i].getElementsByTagName('out')[0]) {
entry.appendTo('#s2sout')
} else {
entry.appendTo('#s2sin')
}
}
let retract = e.getElementsByTagName('retract')[0]
if (retract) {
let id = retract.attributes.getNamedItem('id').value
$('#' + id).remove()
}
return true
}
function _cbNewC2S (e) {
let items = e.getElementsByTagName('item')
for (let i = 0; i < items.length; i++) {
let item = items[i]
let id = item.attributes.getNamedItem('id').value
let jid = item.getElementsByTagName('session')[0].attributes.getNamedItem('jid').value
let infos = item.getElementsByTagName('info')
let entry = $(`<li id="${id}">${jid}</li>`)
let tmp = item.getElementsByTagName('encrypted')[0]
if (tmp) {
entry.append('<img src="images/encrypted.png" title="encrypted" alt=" (encrypted)" />')
}
if (item.getElementsByTagName('compressed')[0]) {
entry.append('<img src="images/compressed.png" title="compressed" alt=" (compressed)" />')
}
let metadata = $('<ul/>').css('display', 'none')
entry.on('click', function () {
$(this).find('ul').slideToggle()
})
metadata.appendTo(entry)
for (let j = 0; j < infos.length; j++) {
let info = infos[j]
metadata.append('<li><b>' + info.attributes.getNamedItem('name').value + ':</b> ' + info.textContent + '</li>')
}
if (infos.length == 0) {
metadata.append('<li>No information available</li>')
}
entry.appendTo('#c2s')
}
let retract = e.getElementsByTagName('retract')[0]
if (retract) {
let id = retract.attributes.getNamedItem('id').value
$('#' + id).remove()
}
return true
}
function _cbAdminSub (e) {
let node = e.getElementsByTagName('items')[0].attributes.getNamedItem('node').value
if (node == Strophe.NS.C2SSTREAM) {
_cbNewC2S(e)
} else if (node == Strophe.NS.S2SSTREAM) {
_cbNewS2S(e)
}
return true
}
function onConnect (status) {
if (status == Strophe.Status.CONNECTING) {
log('Strophe is connecting.')
} else if (status == Strophe.Status.CONNFAIL) {
alert('Connection failed (Wrong host?)')
log('Strophe failed to connect.')
showConnect()
} else if (status == Strophe.Status.DISCONNECTING) {
log('Strophe is disconnecting.')
} else if (status == Strophe.Status.DISCONNECTED) {
log('Strophe is disconnected.')
showConnect()
} else if (status == Strophe.Status.AUTHFAIL) {
alert('Wrong username and/or password')
log('Authentication failed')
if (connection) {
connection.disconnect()
}
} else if (status == Strophe.Status.CONNECTED) {
log('Strophe is connected.')
connection.sendIQ($iq({ to: connection.domain, type: 'get', id: connection.getUniqueId() })
.c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('adminfor'), function (e) {
let domainpart = Strophe.getDomainFromJid(connection.jid)
let items = e.getElementsByTagName('item')
if (items.length == 0) {
alert('You are not an administrator')
connection.disconnect()
return false
}
for (let i = 0; i < items.length; i++) {
let host = $(items[i]).text()
$('<option/>').text(host).prop('selected', host == domainpart).appendTo('#host')
}
showDisconnect()
adminsubHost = $('#host').val()
adhocControl.checkFeatures(adminsubHost,
function () {
adhocControl.getCommandNodes(function (result) {
$('#adhocDisplay').empty()
$('#adhocCommands').html(result)
})
},
function () {
$('#adhocCommands').empty()
$('#adhocDisplay').html('<p>This host does not support commands</p>')
}
)
connection.addHandler(_cbAdminSub, Strophe.NS.ADMINSUB + '#event', 'message')
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('subscribe', { node: Strophe.NS.C2SSTREAM }))
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('subscribe', { node: Strophe.NS.S2SSTREAM }))
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('items', { node: Strophe.NS.S2SSTREAM }), _cbNewS2S)
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('items', { node: Strophe.NS.C2SSTREAM }), _cbNewC2S)
})
}
}
function showConnect () {
$('#login').show()
$('#menu').hide()
$('#main').hide()
$('#s2sin').empty()
$('#s2sout').empty()
$('#c2s').empty()
$('#host').empty()
}
function showDisconnect () {
$('#s2sList').hide()
$('#c2sList').hide()
$('#login').hide()
$('#menu').show()
$('#main').show()
$('#adhoc').show()
}
$(document).ready(function () {
connection = new Strophe.Connection(BOSH_SERVICE)
if (show_log) {
$('#log_container').show()
connection.rawInput = rawInput
connection.rawOutput = rawOutput
}
$('#log_toggle').click(function () {
$('#log').toggle()
})
$('#cred').on('submit', function (event) {
let button = $('#connect').get(0)
let jid = $('#jid')
let pass = $('#pass')
localJID = jid.get(0).value
$('#log').empty()
connection.connect(localJID, pass.get(0).value, onConnect)
event.preventDefault()
})
$('#logout').click(function (event) {
connection.disconnect()
event.preventDefault()
})
$('#adhocMenu, #serverMenu, #clientMenu').click(function (event) {
event.preventDefault()
let tab = $(this).attr('href')
$('#main > div').hide()
$(tab).fadeIn('fast')
})
$('#host').on('change', function (event) {
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('unsubscribe', { node: Strophe.NS.C2SSTREAM }))
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('unsubscribe', { node: Strophe.NS.S2SSTREAM }))
adminsubHost = $(this).val()
adhocControl.checkFeatures(adminsubHost,
function () {
adhocControl.getCommandNodes(function (result) {
$('#adhocDisplay').empty()
$('#adhocCommands').html(result)
})
},
function () {
$('#adhocCommands').empty()
$('#adhocDisplay').html('<p>This host does not support commands</p>')
})
$('#s2sin').empty()
$('#s2sout').empty()
$('#c2s').empty()
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('subscribe', { node: Strophe.NS.C2SSTREAM }))
connection.send($iq({ to: adminsubHost, type: 'set', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('subscribe', { node: Strophe.NS.S2SSTREAM }))
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('items', { node: Strophe.NS.S2SSTREAM }), _cbNewS2S)
connection.sendIQ($iq({ to: adminsubHost, type: 'get', id: connection.getUniqueId() }).c('adminsub', { xmlns: Strophe.NS.ADMINSUB })
.c('items', { node: Strophe.NS.C2SSTREAM }), _cbNewC2S)
})
})
window.onunload = window.onbeforeunload = function () {
if (connection) {
connection.disconnect();
}
}
@@ -0,0 +1,143 @@
-- mod_http_upload_external
--
-- Copyright (C) 2015-2016 Kim Alvefur
--
-- This file is MIT/X11 licensed.
--
-- imports
local st = require"util.stanza";
local uuid = require"util.uuid".generate;
local http = require "util.http";
local dataform = require "util.dataforms".new;
local HMAC = require "util.hashes".hmac_sha256;
local jid = require "util.jid";
-- config
local file_size_limit = module:get_option_number(module.name .. "_file_size_limit", 100 * 1024 * 1024); -- 100 MB
local base_url = assert(module:get_option_string(module.name .. "_base_url"),
module.name .. "_base_url is a required option");
local secret = assert(module:get_option_string(module.name .. "_secret"),
module.name .. "_secret is a required option");
local access = module:get_option_set(module.name .. "_access", {});
local token_protocol = module:get_option_string(module.name .. "_protocol", "v1");
-- depends
module:depends("disco");
-- namespace
local legacy_namespace = "urn:xmpp:http:upload";
local namespace = "urn:xmpp:http:upload:0";
-- identity and feature advertising
module:add_identity("store", "file", module:get_option_string("name", "HTTP File Upload"))
module:add_feature(namespace);
module:add_feature(legacy_namespace);
module:add_extension(dataform {
{ name = "FORM_TYPE", type = "hidden", value = namespace },
{ name = "max-file-size", type = "text-single" },
}:form({ ["max-file-size"] = tostring(file_size_limit) }, "result"));
module:add_extension(dataform {
{ name = "FORM_TYPE", type = "hidden", value = legacy_namespace },
{ name = "max-file-size", type = "text-single" },
}:form({ ["max-file-size"] = tostring(file_size_limit) }, "result"));
local function magic_crypto_dust(random, filename, filesize, filetype)
local param, message;
if token_protocol == "v1" then
param, message = "v", string.format("%s/%s %d", random, filename, filesize);
else
param, message = "v2", string.format("%s/%s\0%d\0%s", random, filename, filesize, filetype);
end
local digest = HMAC(secret, message, true);
random, filename = http.urlencode(random), http.urlencode(filename);
return base_url .. random .. "/" .. filename, "?"..param.."=" .. digest;
end
local function handle_request(origin, stanza, xmlns, filename, filesize, filetype)
local user_bare = jid.bare(stanza.attr.from);
local user_host = jid.host(user_bare);
-- local clients or whitelisted jids/hosts only
if not (origin.type == "c2s" or access:contains(user_bare) or access:contains(user_host)) then
module:log("debug", "Request for upload slot from a %s", origin.type);
origin.send(st.error_reply(stanza, "cancel", "not-authorized"));
return nil, nil;
end
-- validate
if not filename or filename:find("/") then
module:log("debug", "Filename %q not allowed", filename or "");
origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid filename"));
return nil, nil;
end
if not filesize then
module:log("debug", "Missing file size");
origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing or invalid file size"));
return nil, nil;
elseif filesize > file_size_limit then
module:log("debug", "File too large (%d > %d)", filesize, file_size_limit);
origin.send(st.error_reply(stanza, "modify", "not-acceptable", "File too large",
st.stanza("file-too-large", {xmlns=xmlns})
:tag("max-size"):text(tostring(file_size_limit))));
return nil, nil;
end
local random = uuid();
local get_url, verify = magic_crypto_dust(random, filename, filesize, filetype);
local put_url = get_url .. verify;
module:log("debug", "Handing out upload slot %s to %s@%s [%d %s]", get_url, origin.username, origin.host, filesize, filetype);
return get_url, put_url;
end
-- hooks
module:hook("iq/host/"..legacy_namespace..":request", function (event)
local stanza, origin = event.stanza, event.origin;
local request = stanza.tags[1];
local filename = request:get_child_text("filename");
local filesize = tonumber(request:get_child_text("size"));
local filetype = request:get_child_text("content-type") or "application/octet-stream";
local get_url, put_url = handle_request(
origin, stanza, legacy_namespace, filename, filesize, filetype);
if not get_url then
-- error was already sent
return true;
end
local reply = st.reply(stanza)
:tag("slot", { xmlns = legacy_namespace })
:tag("get"):text(get_url):up()
:tag("put"):text(put_url):up()
:up();
origin.send(reply);
return true;
end);
module:hook("iq/host/"..namespace..":request", function (event)
local stanza, origin = event.stanza, event.origin;
local request = stanza.tags[1];
local filename = request.attr.filename;
local filesize = tonumber(request.attr.size);
local filetype = request.attr["content-type"] or "application/octet-stream";
local get_url, put_url = handle_request(
origin, stanza, namespace, filename, filesize, filetype);
if not get_url then
-- error was already sent
return true;
end
local reply = st.reply(stanza)
:tag("slot", { xmlns = namespace})
:tag("get", { url = get_url }):up()
:tag("put", { url = put_url }):up()
:up();
origin.send(reply);
return true;
end);
@@ -0,0 +1,174 @@
-- mod_muc_moderation
--
-- Copyright (C) 2015-2021 Kim Alvefur
--
-- This file is MIT licensed.
--
-- Implements: XEP-0425: Message Moderation
--
-- Imports
local dt = require "util.datetime";
local id = require "util.id";
local jid = require "util.jid";
local st = require "util.stanza";
-- Plugin dependencies
local mod_muc = module:depends "muc";
local muc_util = module:require "muc/util";
local valid_roles = muc_util.valid_roles;
local muc_log_archive = module:open_store("muc_log", "archive");
if not muc_log_archive.set then
module:log("warn", "Selected archive storage module does not support message replacement, no tombstones will be saved");
end
-- Namespaces
local xmlns_fasten = "urn:xmpp:fasten:0";
local xmlns_moderate = "urn:xmpp:message-moderate:0";
local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
local xmlns_retract = "urn:xmpp:message-retract:0";
-- Discovering support
module:hook("muc-disco#info", function (event)
event.reply:tag("feature", { var = xmlns_moderate }):up();
end);
-- TODO error registry, requires Prosody 0.12+
-- moderate : function (string, string, string, boolean, string) : boolean, enum, enum, string
local function moderate(actor, room_jid, stanza_id, retract, reason)
local room_node = jid.split(room_jid);
local room = mod_muc.get_room_from_jid(room_jid);
-- Permissions is based on role, which is a property of a current occupant,
-- so check if the actor is an occupant, otherwise if they have a reserved
-- nickname that can be used to retrieve the role.
local actor_nick = room:get_occupant_jid(actor);
if not actor_nick then
local reserved_nickname = room:get_affiliation_data(jid.bare(actor), "reserved_nickname");
if reserved_nickname then
actor_nick = room.jid .. "/" .. reserved_nickname;
end
end
-- Retrieve their current role, iff they are in the room, otherwise what they
-- would have based on affiliation.
local affiliation = room:get_affiliation(actor);
local role = room:get_role(actor_nick) or room:get_default_role(affiliation);
if valid_roles[role or "none"] < valid_roles.moderator then
return false, "auth", "forbidden", "You need a role of at least 'moderator'";
end
-- Original stanza to base tombstone on
local original, err;
if muc_log_archive.get then
original, err = muc_log_archive:get(room_node, stanza_id);
else
-- COMPAT missing :get API
err = "item-not-found";
for i, item in muc_log_archive:find(room_node, { key = stanza_id, limit = 1 }) do
if i == stanza_id then
original, err = item, nil;
end
end
end
if not original then
if err == "item-not-found" then
return false, "modify", "item-not-found";
else
return false, "wait", "internal-server-error";
end
end
local announcement = st.message({ from = room_jid, type = "groupchat", id = id.medium(), })
:tag("apply-to", { xmlns = xmlns_fasten, id = stanza_id })
:tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
if retract then
announcement:tag("retract", { xmlns = xmlns_retract }):up();
end
if reason then
announcement:text_tag("reason", reason);
end
local moderated_occupant_id = original:get_child("occupant-id", xmlns_occupant_id);
if room.get_occupant_id and moderated_occupant_id then
announcement:add_direct_child(moderated_occupant_id);
end
local actor_occupant = room:get_occupant_by_real_jid(actor) or room:new_occupant(jid.bare(actor), actor_nick);
if room.get_occupant_id then
-- This isn't a regular broadcast message going through the events occupant_id.lib hooks so we do this here
announcement:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }))
end
if muc_log_archive.set and retract then
local tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id })
:tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
:tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up();
if room.get_occupant_id then
tombstone:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }))
if moderated_occupant_id then
-- Copy occupant id from moderated message
tombstone:add_child(moderated_occupant_id);
end
end
if reason then
tombstone:text_tag("reason", reason);
end
tombstone:reset();
local was_replaced = muc_log_archive:set(room_node, stanza_id, tombstone);
if not was_replaced then
return false, "wait", "internal-server-error";
end
end
-- Done, tell people about it
module:log("info", "Message with id '%s' in room %s moderated by %s, reason: %s", stanza_id, room_jid, actor, reason);
room:broadcast_message(announcement);
return true;
end
-- Main handling
module:hook("iq-set/bare/" .. xmlns_fasten .. ":apply-to", function (event)
local stanza, origin = event.stanza, event.origin;
local actor = stanza.attr.from;
local room_jid = stanza.attr.to;
-- Collect info we need
local apply_to = stanza.tags[1];
local moderate_tag = apply_to:get_child("moderate", xmlns_moderate);
if not moderate_tag then return end -- some other kind of fastening?
local reason = moderate_tag:get_child_text("reason");
local retract = moderate_tag:get_child("retract", xmlns_retract);
local stanza_id = apply_to.attr.id;
local ok, error_type, error_condition, error_text = moderate(actor, room_jid, stanza_id, retract, reason);
if not ok then
origin.send(st.error_reply(stanza, error_type, error_condition, error_text));
return true;
end
origin.send(st.reply(stanza));
return true;
end);
module:hook("muc-message-is-historic", function (event)
-- Ensure moderation messages are stored
if event.stanza.attr.from == event.room.jid then
return event.stanza:get_child("apply-to", xmlns_fasten);
end
end, 1);
+114
View File
@@ -0,0 +1,114 @@
-- Prosody IM
-- Copyright (C) 2008-2010 Matthew Wild
-- Copyright (C) 2008-2010 Waqas Hussain
-- Copyright (C) 2018 Michel Le Bihan
--
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--
local st = require "util.stanza"
local jid_split = require "util.jid".split;
local base64 = require"util.encodings".base64;
local sha1 = require"util.hashes".sha1;
local mod_muc = module:depends"muc";
local vcards = module:open_store();
module:add_feature("vcard-temp");
local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or
function (jid)
local rooms = rawget(mod_muc, "rooms");
return rooms[jid];
end
local function get_photo_hash(room)
local room_node = jid_split(room.jid);
local vcard = st.deserialize(vcards:get(room_node));
if vcard then
local photo = vcard:get_child("PHOTO");
if photo then
local photo_b64 = photo:get_child_text("BINVAL");
local photo_raw = photo_b64 and base64.decode(photo_b64);
return sha1(photo_raw, true);
end
end
end
local function broadcast_presence(room, to)
local photo_hash = get_photo_hash(room);
local presence_vcard = st.presence({to = to, from = room.jid})
:tag("x", { xmlns = "vcard-temp:x:update" })
:tag("photo"):text(photo_hash):up();
if to == nil then
room:broadcast_message(presence_vcard);
else
module:send(presence_vcard);
end
end
local function handle_vcard(event)
local session, stanza = event.origin, event.stanza;
local room_jid = stanza.attr.to;
local room_node = jid_split(room_jid);
local room = get_room_from_jid(room_jid);
if not room then
session.send(st.error_reply(stanza, "cancel", "item-not-found"))
return true;
end
local from = stanza.attr.from;
local from_affiliation = room:get_affiliation(from);
if stanza.attr.type == "get" then
local vCard;
vCard = st.deserialize(vcards:get(room_node));
if vCard then
session.send(st.reply(stanza):add_child(vCard)); -- send vCard!
else
session.send(st.error_reply(stanza, "cancel", "item-not-found"));
end
else
if from_affiliation == "owner" or (module.may and module:may("muc:automatic-ownership", from)) then
if vcards:set(room_node, st.preserialize(stanza.tags[1])) then
session.send(st.reply(stanza):tag("vCard", { xmlns = "vcard-temp" }));
broadcast_presence(room, nil)
room:broadcast_message(st.message({ from = room.jid, type = "groupchat" })
:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" })
:tag("status", { code = "104" }));
else
-- TODO unable to write file, file may be locked, etc, what's the correct error?
session.send(st.error_reply(stanza, "wait", "internal-server-error"));
end
else
session.send(st.error_reply(stanza, "auth", "forbidden"));
end
end
return true;
end
module:hook("iq/bare/vcard-temp:vCard", handle_vcard);
module:hook("iq/host/vcard-temp:vCard", handle_vcard);
module:hook("muc-disco#info", function(event)
event.reply:tag("feature", { var = "vcard-temp" }):up();
table.insert(event.form, {
name = "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1",
type = "text-single",
});
event.formdata["{http://modules.prosody.im/mod_vcard_muc}avatar#sha1"] = get_photo_hash(event.room);
end);
module:hook("muc-occupant-session-new", function(event)
broadcast_presence(event.room, event.jid);
end)