pax_global_header00006660000000000000000000000064120224134640014510gustar00rootroot0000000000000052 comment=78895869a10ba5b9b0d9f123e21fbd2764b0019c buddycloud-buddycloud-server-7889586/000077500000000000000000000000001202241346400175325ustar00rootroot00000000000000buddycloud-buddycloud-server-7889586/.gitignore000066400000000000000000000000631202241346400215210ustar00rootroot00000000000000config.js lib/* node_modules/* .project .settings buddycloud-buddycloud-server-7889586/.npmignore000066400000000000000000000000171202241346400215270ustar00rootroot00000000000000src/* debian/* buddycloud-buddycloud-server-7889586/Cakefile000066400000000000000000000016321202241346400211620ustar00rootroot00000000000000path = require 'path' { run, compileScript, readFile, writeFile, notify } = require 'muffin' task 'build', 'compile coffeescript → javascript', (options) -> run options:options files:[ "./src/**/*.coffee" "package.json" ] map: 'src/(.+).coffee': (m) -> compileScript m[0], path.join("lib" ,"#{m[1]}.js"), options 'package.json': (m) -> readFile(m[0]).then (item) -> json = JSON.parse(item) data = "module.exports=\"#{json.version}\"\n" writeFile("lib/version.js", data).then -> notify m[0], "Extracted version: #{json.version}" data = "module.exports=\"#{json.name}\"\n" writeFile("lib/name.js", data).then -> notify m[0], "Extracted name: #{json.name}" buddycloud-buddycloud-server-7889586/LICENSE000066400000000000000000000261361202241346400205470ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. buddycloud-buddycloud-server-7889586/README.md000066400000000000000000000003611202241346400210110ustar00rootroot00000000000000# buddycloud-server The primary network protocol is [buddycloud channels](http://buddycloud.org/). buddycloud-server is distributed under the Apache License 2.0. See the `LICENSE` file. ## Installation https://buddycloud.org/wiki/Installbuddycloud-buddycloud-server-7889586/_etc_init.d_buddycloud-server000077500000000000000000000112171202241346400253630ustar00rootroot00000000000000#! /bin/sh ### BEGIN INIT INFO # Provides: buddycloud-server # Required-Start: $remote_fs $syslog # Required-Stop: $remote_fs $syslog # Default-Start: 2 3 4 5 # Default-Stop: 1 # Short-Description: buddycloud-server # Description: This script starts, stops and restarts the buddycloud server # daemon which then connects to the domains XMPP server. ### END INIT INFO # Author: Simon Tennant # # Please remove the "Author" lines above and replace them # with your own name if you copy and modify this script. # Do NOT "set -e" # PATH should only include /usr/* if it runs after the mountnfs.sh script PATH=/sbin:/usr/sbin:/bin:/usr/bin DESC="buddycloud server" NAME=buddycloud RUN_AS_USER=buddycloud RUNDIR=/opt/buddycloud-server DAEMON=`which node` DAEMON_ARGS="lib/main.js --config config.js" PIDDIR=/var/run/buddycloud-server PIDFILE="$PIDDIR/$NAME.pid" SCRIPTNAME=/etc/init.d/$NAME LOGDIR=/var/log/buddycloud-server LOGFILE="$LOGDIR/buddycloud-server.log" # Exit if the package is not installed [ -x "$DAEMON" ] || exit 0 # Read configuration variable file if it is present [ -r /etc/default/$NAME ] && . /etc/default/$NAME # Load the VERBOSE setting and other rcS variables . /lib/init/vars.sh # Define LSB log_* functions. # Depend on lsb-base (>= 3.2-14) to ensure that this file is present # and status_of_proc is working. . /lib/lsb/init-functions # # Function that starts the daemon/service # do_start() { # Return # 0 if daemon has been started # 1 if daemon was already running # 2 if daemon could not be started for dir in "$PIDDIR" "$LOGDIR"; do if [ ! -d $dir ]; then mkdir -p "$dir" || return 2 chown "$RUN_AS_USER": "$dir" || return 2 chmod 755 "$dir" || return 2 fi done start-stop-daemon --start --pidfile $PIDFILE --exec $DAEMON --chuid $RUN_AS_USER --test > /dev/null \ || return 1 start-stop-daemon --start --background --pidfile $PIDFILE --chdir $RUNDIR --chuid $RUN_AS_USER --exec $DAEMON -- \ $DAEMON_ARGS \ || return 2 # Add code here, if necessary, that waits for the process to be ready # to handle requests from services started subsequently which depend # on this one. As a last resort, sleep for some time. } # # Function that stops the daemon/service # do_stop() { # Return # 0 if daemon has been stopped # 1 if daemon was already stopped # 2 if daemon could not be stopped # other if a failure occurred start-stop-daemon --stop --quiet --retry=TERM/5/KILL/5 --pidfile $PIDFILE --name $NAME RETVAL="$?" [ "$RETVAL" = 2 ] && return 2 # Wait for children to finish too if this is a daemon that forks # and if the daemon is only ever run from this initscript. # If the above conditions are not satisfied then add some other code # that waits for the process to drop all resources that could be # needed by services started subsequently. A last resort is to # sleep for some time. start-stop-daemon --stop --quiet --oknodo --retry=0/5/KILL/5 --exec $DAEMON [ "$?" = 2 ] && return 2 # Many daemons don't delete their pidfiles when they exit. rm -f $PIDFILE return "$RETVAL" } # # Function that sends a SIGHUP to the daemon/service # do_reload() { # # If the daemon can reload its configuration without # restarting (for example, when it is sent a SIGHUP), # then implement that here. # start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE --name $NAME return 0 } case "$1" in start) [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" do_start case "$?" in 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; esac ;; stop) [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" do_stop case "$?" in 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; esac ;; status) status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? ;; #reload|force-reload) # # If do_reload() is not implemented then leave this commented out # and leave 'force-reload' as an alias for 'restart'. # #log_daemon_msg "Reloading $DESC" "$NAME" #do_reload #log_end_msg $? #;; restart|force-reload) # # If the "reload" option is implemented then remove the # 'force-reload' alias # log_daemon_msg "Restarting $DESC" "$NAME" do_stop case "$?" in 0|1) do_start case "$?" in 0) log_end_msg 0 ;; 1) log_end_msg 1 ;; # Old process is still running *) log_end_msg 1 ;; # Failed to start esac ;; *) # Failed to stop log_end_msg 1 ;; esac ;; *) #echo "Usage: $SCRIPTNAME {start|stop|restart|reload|force-reload}" >&2 echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 exit 3 ;; esac : buddycloud-buddycloud-server-7889586/bin/000077500000000000000000000000001202241346400203025ustar00rootroot00000000000000buddycloud-buddycloud-server-7889586/bin/buddycloud-server000077500000000000000000000014611202241346400236740ustar00rootroot00000000000000#!/bin/sh cwd=`pwd` # find where this file really is by dereferencing the symlink(s). this=$0 cd `dirname $this` while [ -n "`readlink $this`" ] ; do this=`readlink $this` cd `dirname $this` done moduleDir=`pwd` moduleDir=${moduleDir%%/bin} # build or display help. args='' skipnext=0 for arg in $@; do if [ "$skipnext" == "1" ]; then skipnext=0 continue fi case $arg in '--help') help=1 ;; '--node-debug') debug=" " if [[ $2 =~ ^[0-9]+$ ]]; then debug="="$2 skipnext=1 fi continue;; esac args=$args" "$arg done # execute the real programk if [ -z "$debug" ]; then exec env node "$moduleDir/lib/main.js" $args else exec env node --debug-brk$debug "$moduleDir/lib/main.js" $args fibuddycloud-buddycloud-server-7889586/config.js.example000066400000000000000000000032201202241346400227640ustar00rootroot00000000000000/** * XMPP Component connection * * change EXAMPLE.COM to match your domain * jid referrs to your component address. Not a user JID */ exports.xmpp = { jid: 'buddycloud.EXAMPLE.COM', password: 'tellnoone', host: 'localhost', port: 5347 }; /** * PostgreSQL backend */ exports.modelBackend = 'postgres'; exports.modelConfig = { host: 'localhost', port: 5432, database: 'buddycloud-server', user: 'buddycloud-server', password: 'tellnoone', poolSize: 4 }; /** * Advertise additional components to the XMPP server e.g. for a topics domain */ /*exports.advertiseComponents = [ 'topics.EXAMPLE.COM' ];*/ /** * Default visibility to new channels */ exports.defaults = { userChannel: { openByDefault: true }, topicChannel: { openByDefault: true }, }; /** * Logging */ exports.logging = { colorized: true, /** * one of "FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE" */ level: "INFO", /* stdout: true, */ syslog: { /* hostname: "localhost", port: 514 */ }, file: "/var/log/buddycloud-server.log" }; /** * Who may create what node? * * Default: only allow creating a personal channel, or a topic channel * on a specific domain. */ exports.checkCreateNode = function(opts) { return (opts.nodeUser === opts.actor) || (opts.nodeUser.split("@")[1] === "topics.buddycloud.org"); }; /** * Welcome new users by auto-subscribing them to a few channels */ exports.autosubscribeNewUsers = [ "report_bugs@topics.buddycloud.org", "help@topics.buddycloud.org", "lounge@topics.buddycloud.org", "music@topics.buddycloud.org", "topics@topics.buddycloud.org" ]; buddycloud-buddycloud-server-7889586/package.js000077500000000000000000000027571202241346400215010ustar00rootroot00000000000000#!/usr/bin/env node require('colors') var fs = require('fs') var path = require('path') var tarballify = require('tarballify') var version = require('./lib/version') var name = require('./lib/name') console.log("creating new tarball …".green) var tarball = tarballify("./lib/main.js", { dirname:__dirname, cache:false, }) .on('warn', function(w){console.warn( "WARN".yellow,w)}) .on('error', function(e){console.error("ERR ".red ,e)}) .on('skip', function(s){console.log( "skip".bold.blue,s.name.cyan,s.dirname)}) .on('wait', function(){console.log("waiting for tarball to finish …".green)}) // .on('append', function(f,e){console.log("append",f.props.size,"\t",e.name, "\t",f.path)}) .on('close', function(){console.log("done.".bold.green)}) // .on('syntaxError', console.error.bind(console)) tarball.pipe(fs.createWriteStream(path.join(__dirname, name+"-"+version+".tar.gz"))) ;[ // fix 'modName is not defined' "node_modules/ltx/lib/sax_expat.js", "node_modules/ltx/lib/sax_ltx.js", "node_modules/ltx/lib/sax_saxjs.js", // server files "_etc_init.d_buddycloud-server", "bin/buddycloud-server", "config.js.example", "package.json", "postgres.sql", "LICENSE", "README.md" ].forEach(function(file){tarball.append(file)}) ;[ "fixed 'modName is not defined'.", "'file is not defined' in jsconfig can be ignored.", ].forEach(function(msg){console.log(msg.bold.black)}) console.log("setup ready …".green) tarball.end() buddycloud-buddycloud-server-7889586/package.json000066400000000000000000000024451202241346400220250ustar00rootroot00000000000000{ "name": "buddycloud-server" ,"version": "0.3.5" ,"bin": { "buddycloud-server": "./bin/buddycloud-server" } ,"description": "buddycloud channels service for XMPP" ,"dependencies": { "node-xmpp": ">=0.3.2", "node-stringprep": ">=0.1.4", "node-expat": ">=1.4.4", "ltx": ">=0.1.3", "jsconfig": ">=0.2.0", "async": ">=0.1.18", "node-uuid": ">=1.3.3", "pg": ">=0.6.14", "underscore.logger": ">=0.3.1", "ain2": ">=1.1.0", "glob": "<3" } ,"devDependencies": { "colors": ">=0.6.0-1", "tarballify": ">=0.0.5", "muffin": ">=0.2.7", "coffee-script": ">=1.2.0" } ,"repositories": [{"type": "git" ,"path": "git://github.com/buddycloud/buddycloud-server.git" }] ,"scripts": { "install": "cake build", "start": "./bin/buddycloud-server --nobuild" } ,"homepage": "http://buddycloud.org" ,"bugs": "http://github.com/buddycloud/buddycloud-server/issues" ,"maintainer": {"name": "Astro" ,"email": "astro@spaceboyz.net" ,"web": "http://spaceboyz.net/~astro/" } ,"contributors": ["Stephan Maka", "Jonas Smedegaard", "Simon Tennant", "dodo", "James Tait", "Thomas Jost", "Lloyd Watkin"] ,"licenses": [{"type": "APL2"}] , "engines": {"node": ">= 0.4"} } buddycloud-buddycloud-server-7889586/postgres.sql000066400000000000000000000020771202241346400221270ustar00rootroot00000000000000CREATE TABLE nodes (node TEXT NOT NULL PRIMARY KEY); CREATE TABLE node_config (node TEXT NOT NULL REFERENCES nodes (node), "key" TEXT, "value" TEXT, updated TIMESTAMP, PRIMARY KEY (node, "key")); CREATE TABLE items (node TEXT REFERENCES nodes (node), id TEXT NOT NULL, updated TIMESTAMP, xml TEXT, PRIMARY KEY (node, id)); CREATE INDEX items_updated ON items (updated); CREATE TABLE subscriptions (node TEXT REFERENCES nodes (node), "user" TEXT, listener TEXT, subscription TEXT, updated TIMESTAMP, PRIMARY KEY (node, "user")); CREATE INDEX subscriptions_updated ON subscriptions (updated); CREATE TABLE affiliations (node TEXT REFERENCES nodes (node), "user" TEXT, affiliation TEXT, updated TIMESTAMP, PRIMARY KEY (node, "user")); CREATE INDEX affiliations_updated ON affiliations (updated); CREATE VIEW open_nodes AS SELECT DISTINCT node FROM node_config WHERE "key"='accessModel' AND "value"='open'; buddycloud-buddycloud-server-7889586/src/000077500000000000000000000000001202241346400203215ustar00rootroot00000000000000buddycloud-buddycloud-server-7889586/src/errors.coffee000066400000000000000000000036011202241346400230060ustar00rootroot00000000000000try inherits = require("util").inherits catch x inherits = require("sys").inherits xmpp = require("node-xmpp") NS_XMPP_STANZAS = "urn:ietf:params:xml:ns:xmpp-stanzas" ## # Base class for our well-defined error conditions class ServerError extends Error constructor: (message) -> # Isn't message set by Error()? @message = message condition: 'undefined-condition' type: 'cancel' xmppElement: -> errorEl = new xmpp.Element("error", type: @type) errorEl.c @condition, xmlns: NS_XMPP_STANZAS if @message errorEl.c("text", xmlns: NS_XMPP_STANZAS).t @message errorEl ## # The actual exported error classes class exports.Forbidden extends ServerError condition: "forbidden" type: "auth" class exports.Conflict extends ServerError condition: "conflict" type: "cancel" class exports.BadRequest extends ServerError condition: "bad-request" type: "modify" class exports.FeatureNotImplemented extends ServerError condition: "feature-not-implemented" type: "cancel" class exports.InternalServerError extends ServerError condition: "internal-server-error" type: "cancel" class exports.NotFound extends ServerError condition: "item-not-found" type: "cancel" class exports.NotAllowed extends ServerError condition: "not-allowed" type: "cancel" ## # For wrapping errors from remote class exports.StanzaError extends Error constructor: (stanza) -> @el = stanza.getChild('error') @message = @el?.children[0]?.name xmppElement: -> @el ## # Signaling the router that a node is actually local class exports.SeeLocal extends Error constructor: -> super("Locally stored") ## # Thrown by Connection.send() class exports.MaxStanzaSizeExceeded extends Error constructor: (bytes) -> super("Maximum stanza size exceeded (#{bytes} bytes)") buddycloud-buddycloud-server-7889586/src/local/000077500000000000000000000000001202241346400214135ustar00rootroot00000000000000buddycloud-buddycloud-server-7889586/src/local/model_postgres.coffee000066400000000000000000000615261202241346400256240ustar00rootroot00000000000000## # PostgreSQL backend # # For table subscriptions: # * "user" = "listener" means subscription to a local node # * "user" != "listener" means subscription to a remote node logger = require('../logger').makeLogger 'local/model_postgres' pg = require("pg") ltx = require("ltx") # for item XML parsing & serialization async = require("async") errors = require("../errors") # ready DB connections pool = [] # waiting transaction requests queue = [] debugDB = (db) -> oldQuery = db.query db.query = (sql, params) -> logger.trace "query #{sql} #{JSON.stringify(params)}" oldQuery.apply(@, arguments) # at start and when connection died connectDB = (config) -> db = new pg.Client(config) debugDB db db.connect() # Reconnect in up to 5s db.on "error", (err) -> logger.error "Postgres: " + err.message setTimeout -> connectDB config , Math.ceil(Math.random() * 5000) try db.end() catch e # wait until connected & authed db.connection.once "readyForQuery", -> dbIsAvailable db ## # Put into connection pool dbIsAvailable = (db) -> if (cb = queue.shift()) # request was waiting in queue cb db else # no request, put into pool pool.push db ## # Get from connection pool withNextDb = (cb) -> if (db = pool.shift()) # Got one from pool cb(db) else # Pool was empty, waiting... TODO: limit length, shift first queue.push (db) -> cb(db) # config: { user, database, host, port, poolSize: 4 } exports.start = (config) -> for i in [0..(config.poolSize or 4)] connectDB config exports.transaction = (cb) -> withNextDb (db) -> new Transaction(db, cb) # TODO: currently unused, should re-check to delete local node after an unsubscribe exports.isListeningToNode = (node, listenerJids, cb) -> i = 1 conditions = listenerJids.map((listenerJid) -> i++ "listener = $#{i}" ).join(" OR ") unless conditions # Short-cut return cb null, false withNextDb (db) -> db.query "SELECT listener FROM subscriptions WHERE node = $1 AND (#{conditions}) LIMIT 1" , [node, listenerJids...] , (err, res) -> process.nextTick -> dbIsAvailable(db) cb err, (res?.rows?[0]?) exports.nodeExists = (node, cb) -> withNextDb (db) -> db.query "SELECT node FROM nodes WHERE node=$1", [ node ], (err, res) -> process.nextTick -> dbIsAvailable(db) if err cb err else cb null, res?.rows?[0]? # TODO: batchify exports.forListeners = (iter) -> withNextDb (db) -> db.query "SELECT DISTINCT listener FROM subscriptions WHERE listener IS NOT NULL", (err, res) -> process.nextTick -> dbIsAvailable(db) if err logger.error err return res?.rows?.forEach (row) -> iter row.listener # TODO: batchify exports.getAllNodes = (cb) -> withNextDb (db) -> db.query "SELECT node FROM nodes", (err, res) -> process.nextTick -> dbIsAvailable(db) if err return cb err nodes = res?.rows?.map (row) -> row.node cb null, nodes exports.getListenerNodes = (listener, cb) -> db.query "SELECT DISTINCT node FROM subscriptions WHERE listener=$1", [listener], (err, res) -> cb err, res?.rows?.map (row) -> row.node LOST_TRANSACTION_TIMEOUT = 60 * 1000 ## # Wraps the postgres-js transaction with our model operations. class Transaction constructor: (db, cb) -> @db = db db.query "BEGIN", [], (err, res) => cb err, @ timeout = setTimeout => logger.error "Danger: lost transaction, rolling back!" timeout = undefined @rollback() , LOST_TRANSACTION_TIMEOUT @rmTimeout = -> if timeout clearTimeout timeout timeout = undefined commit: (cb) -> @db.query "COMMIT", [], (err, res) => @rmTimeout() process.nextTick => dbIsAvailable @db cb? err rollback: (cb) -> @db.query "ROLLBACK", [], (err, res) => @rmTimeout() process.nextTick => dbIsAvailable @db cb? err ## # Actual data model ## # Can be dropped in a async.waterfall() sequence to validate presence of a node. validateNode: (node) -> db = @db (cb) -> db.query "SELECT node FROM nodes WHERE node=$1", [ node ], (err, res) -> if err cb err else if res?.rows?[0]? cb null else logger.warn "#{node} does not exist!" cb new errors.NotFound("Node does not exist") nodeExists: (node, cb) -> @db.query "SELECT node FROM nodes WHERE node=$1", [ node ], (err, res) -> if err cb err else exists = res?.rows?[0]? unless exists logger.warn "#{node} does not exist" cb null, exists createNode: (node, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT node FROM nodes WHERE node=$1", [ node ], cb2 , (res, cb2) -> if res?.rows?[0] # Node already exists: ignore cb2(null, false) else db.query "INSERT INTO nodes (node) VALUES ($1)", [ node ], (err) -> cb2 err, true ], cb purgeNode: (node, cb) -> db = @db q = (sql) -> (cb2) -> db.query sql, [ node ], cb2 async.series [ q "DELETE FROM items WHERE node=$1" q "DELETE FROM subscriptions WHERE node=$1" q "DELETE FROM affiliations WHERE node=$1" q "DELETE FROM node_config WHERE node=$1" q "DELETE FROM nodes WHERE node=$1" ], (err) -> logger.info "Purged all data of node #{node}" cb err ## # only open ones # # cb(err, [{ node: String, title: String }]) listNodes: (cb) -> db = @db async.waterfall [(cb2) -> db.query """SELECT node FROM nodes WHERE node IN (SELECT node FROM open_nodes) ORDER BY node ASC""", cb2 , (res, cb2) -> nodes = res.rows.map (row) -> node: row.node title: undefined # TODO cb2 null, nodes ], cb ## # Subscription management ## getSubscription: (node, user, cb) -> unless node return cb(new Error("No node")) unless user return cb(new Error("No user")) @db.query "SELECT subscription FROM subscriptions WHERE node=$1 AND \"user\"=$2" , [ node, user ] , (err, res) -> cb err, res?.rows?[0]?.subscription or "none" getSubscriptionListener: (node, user, cb) -> @db.query "SELECT listener FROM subscriptions WHERE node=$1 AND \"user\"=$2" , [ node, user ] , (err, res) -> cb err, res?.rows?[0]?.listener or "none" setSubscription: (node, user, listener, subscription, cb) -> unless node return cb(new Error("No node")) unless user return cb(new Error("No user")) db = @db toDelete = not subscription or subscription == "none" async.waterfall [ @validateNode(node) , (cb2) -> db.query "SELECT subscription FROM subscriptions WHERE node=$1 AND \"user\"=$2", [ node, user ], cb2 , (res, cb2) -> isSet = res?.rows?[0]? logger.debug "setSubscription #{node} #{user} isSet=#{isSet} toDelete=#{toDelete}" if isSet and not toDelete if listener db.query "UPDATE subscriptions SET listener=$1, subscription=$2, updated=CURRENT_TIMESTAMP WHERE node=$3 AND \"user\"=$4" , [ listener, subscription, node, user ] , cb2 else db.query "UPDATE subscriptions SET subscription=$1, updated=CURRENT_TIMESTAMP WHERE node=$2 AND \"user\"=$3" , [ subscription, node, user ] , cb2 else if not isSet and not toDelete # listener=null is allowed for 3rd-party inboxes if listener db.query "INSERT INTO subscriptions (node, \"user\", listener, subscription, updated) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)" , [ node, user, listener, subscription ] , cb2 else db.query "INSERT INTO subscriptions (node, \"user\", subscription, updated) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)" , [ node, user, subscription ] , cb2 else if isSet and toDelete db.query "DELETE FROM subscriptions WHERE node=$1 AND \"user\"=$2" , [ node, user ] , cb2 else if not isSet and toDelete cb2 null else cb2 new Error('Invalid subscription transition') ], (err) -> cb err getSubscribers: (node, cb) -> unless node return cb(new Error("No node")) db = @db async.waterfall [(cb2) -> db.query "SELECT \"user\", subscription FROM subscriptions WHERE node=$1 ORDER BY updated DESC", [ node ], cb2 , (res, cb2) -> subscribers = for row in res.rows { user: row.user, subscription: row.subscription } cb2 null, subscribers ], cb ## # Not only by users but also by listeners. # @param cb {Function} cb(Error, { user, node, subscription }) getSubscriptions: (actor, cb) -> @db.query "SELECT \"user\", node, subscription FROM subscriptions WHERE \"user\"=$1 OR listener=$1 ORDER BY updated DESC", [ actor ], (err, res) -> cb err, res?.rows getPending: (node, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT \"user\" FROM subscriptions WHERE subscription = 'pending' AND node = $1 ORDER BY updated DESC", [ node ], cb2 , (res, cb2) -> cb2 null, res.rows.map((row) -> row.user ) ], cb getNodeListeners: (node, cb) -> @db.query """SELECT DISTINCT listener FROM subscriptions WHERE node=$1 AND subscription='subscribed' AND listener IS NOT NULL""" , [node] , (err, res) -> cb err, res?.rows?.map((row) -> row.listener) getNodeModeratorListeners: (node, cb) -> # TODO: on subscriptions.listener=affiliations.listener @db.query """SELECT DISTINCT listener FROM subscriptions WHERE node=$1 AND listener IS NOT NULL AND EXISTS (SELECT affiliation FROM affiliations WHERE node=$1 AND (affiliation='owner' OR affiliation='moderator'))""" , [node] , (err, res) -> cb err, res?.rows?.map((row) -> row.listener) walkModeratorAuthorizationRequests: (user, iter, cb) -> # TODO: make batched @db.query """SELECT "user", node FROM subscriptions WHERE subscription='pending' AND node IN (SELECT node FROM affiliations WHERE "user"=$1 AND (affiliation='owner' OR affiliation='moderator'))""" , [user] , (err, res) -> res?.rows?.forEach (row) -> iter(row) cb err getUserRemoteSubscriptions: (user, cb) -> @db.query "SELECT node, listener, subscription FROM subscriptions WHERE \"user\"=$1 AND listener!=$1", [user], (err, res) -> cb err, res?.rows clearUserSubscriptions: (user, cb) -> @db.query "DELETE FROM subscriptions WHERE \"user\"=$1", [user], (err) -> cb err ## # Affiliation management ## getAffiliation: (node, user, cb) -> unless node return cb(new Error("No node")) unless user return cb(new Error("No user")) @db.query "SELECT affiliation FROM affiliations WHERE node=$1 AND \"user\"=$2" , [ node, user ] , (err, res) -> cb err, (res?.rows?[0]?.affiliation or "none") getListenerAffiliations: (node, listener, cb) -> unless node return cb(new Error("No node")) unless listener return cb(new Error("No user")) @db.query "SELECT DISTINCT affiliation FROM affiliations WHERE node=$1 AND \"user\" IN (SELECT \"user\" FROM subscriptions WHERE listener=$2)" , [ node, listener ] , (err, res) -> cb err, res?.rows?.map((row) -> row.affiliation or "none") setAffiliation: (node, user, affiliation, cb) -> unless node return cb(new Error("No node")) unless user return cb(new Error("No user")) db = @db async.waterfall [ @validateNode(node) , (cb2) -> db.query "SELECT affiliation FROM affiliations WHERE node=$1 AND \"user\"=$2", [ node, user ], cb2 , (res, cb2) -> isSet = res and res.rows and res.rows[0] toDelete = not affiliation or affiliation == "none" if isSet and not toDelete db.query "UPDATE affiliations SET affiliation=$1 WHERE node=$2 AND \"user\"=$3", [ affiliation, node, user ], cb2 else if not isSet and not toDelete db.query "INSERT INTO affiliations (node, \"user\", affiliation) VALUES ($1, $2, $3)", [ node, user, affiliation ], cb2 else if isSet and toDelete db.query "DELETE FROM affiliations WHERE node=$1 AND \"user\"=$2", [ node, user ], cb2 else if not isSet and toDelete cb2 null ], (err) -> cb err getAffiliations: (user, cb) -> unless user return cb(new Error("No user")) db = @db async.waterfall [(cb2) -> db.query "SELECT node, affiliation FROM affiliations WHERE \"user\"=$1 ORDER BY updated DESC", [ user ], cb2 , (res, cb2) -> affiliations = for row in res.rows { node: row.node, affiliation: row.affiliation } cb2 null, affiliations ], cb getAffiliated: (node, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT \"user\", affiliation FROM affiliations WHERE node=$1 ORDER BY updated DESC", [ node ], cb2 , (res, cb2) -> affiliations = for row in res.rows { user: row.user, affiliation: row.affiliation } cb2 null, affiliations ], cb getOutcast: (node, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT \"user\" FROM affiliations WHERE affiliation = 'outcast' AND node = $1 ORDER BY updated DESC", [ node ], cb2 , (res, cb2) -> cb2 null, res.rows.map((row) -> row.user ) ], cb getOwners: (node, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT \"user\" FROM affiliations WHERE node=$1 AND affiliation='owner' ORDER BY updated DESC", [ node ], cb2 , (res, cb2) -> cb2 null, res.rows.map((row) -> row.user ) ], cb getOwnersByNodePrefix: (nodePrefix, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT DISTINCT \"user\" FROM affiliations WHERE node LIKE ($1 || '%') AND affiliation='owner'" , [ nodePrefix ] , cb2 , (res, cb2) -> cb2 null, res.rows.map (row) -> row.user ], cb writeItem: (node, id, el, cb) -> db = @db async.waterfall [ @validateNode(node), (cb2) -> db.query "SELECT id FROM items WHERE node=$1 AND id=$2", [ node, id ], cb2 , (res, cb2) -> isSet = res and res.rows and res.rows[0] xml = el.toString() params = [ node, id, xml ] updated = el.getChildText('updated') or el.getChildText('published') if updated params.push updated updated_query = "$4" else updated_query = "CURRENT_TIMESTAMP" if isSet db.query "UPDATE items SET xml=$3, updated=#{updated_query} WHERE node=$1 AND id=$2" , params , cb2 else unless isSet db.query "INSERT INTO items (node, id, xml, updated) VALUES ($1, $2, $3, #{updated_query})" , params , cb2 ], cb ## # sorted by time getItemIds: (node, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT id FROM items WHERE node=$1 ORDER BY updated DESC", [ node ], cb2 , (res, cb2) -> ids = res.rows.map((row) -> row.id ) cb2 null, ids ], cb getItem: (node, id, cb) -> db = @db async.waterfall [(cb2) -> db.query "SELECT xml FROM items WHERE node=$1 AND id=$2", [ node, id ], cb2 , (res, cb2) -> if res?.rows?[0]?.xml el = parseEl(res.rows[0].xml) if el? cb2 null, el else cb2 new errors.InternalServerError("Item XML parse error") else cb2 new errors.NotFound("No such item") ], cb ## # @param itemCb {Function} itemCb({ node: String, id: String, item: Element }) getUpdatesByTime: (subscriber, timeStart, timeEnd, itemCb, cb) -> conditions = [ "node IN (SELECT node FROM subscriptions WHERE \"user\"=$1 AND subscription='subscribed')" ] params = [ subscriber ] i = 1 if timeStart conditions.push "updated >= $" + (++i) + "::timestamp" params.push timeStart if timeEnd conditions.push "updated <= $" + (++i) + "::timestamp" params.push timeEnd q = @db.query("SELECT id, node, xml FROM items WHERE " + conditions.join(" AND ") + " ORDER BY updated ASC", params) q.on "row", (row) -> if item itemCb node: row.node id: row.id item: row.xml q.on "error", (err_) -> err = err_ q.on "end", -> cb err ## # Config management ## getConfig: (node, cb) -> db = @db async.waterfall [ @validateNode(node), (cb2) -> db.query "SELECT \"key\", \"value\" FROM node_config WHERE node=$1", [ node ], cb2 , (res, cb2) -> if res.rows config = {} res.rows.forEach (row) -> config[row.key] = row.value cb2 null, config else cb2 new errors.NotFound("No such node") ], cb setConfig: (node, config, cb) -> db = @db logger.debug "setConfig " + node + ": " + require("util").inspect(config) async.waterfall [ @validateNode(node), (cb2) -> async.parallel(for own key, value of config do (key, value) -> (cb3) -> if value? db.query "DELETE FROM node_config WHERE node=$1 AND key=$2" , [ node, key ] , (err) -> if err return cb err db.query "INSERT INTO node_config (key, value, node, updated) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)" , [ key, value, node ] , cb3 else cb3 null , (err) -> cb2 err ) ], cb ## # Synchronization preparation ## resetConfig: (node, cb) -> @db.query "DELETE FROM node_config WHERE node=$1", [node], cb resetItems: (node, cb) -> @db.query "DELETE FROM items WHERE node=$1", [node], cb ## # Calls back with { User: Listener } resetSubscriptions: (node, cb) -> @db.query "SELECT \"user\", listener FROM subscriptions WHERE node=$1 AND listener IS NOT NULL", [node], (err, res) => if err return cb err userListeners = {} for row in res.rows userListeners[row.user] = row.listener @db.query "DELETE FROM subscriptions WHERE node=$1", [node], (err) -> cb err, userListeners resetAffiliations: (node, cb) -> @db.query "DELETE FROM affiliations WHERE node=$1", [node], cb ## # MAM # # @param cb: Function(err, results) walkListenerArchive: (listener, start, end, max, iter, cb) -> db = @db params = [listener] conds = "" i = params.length if start conds += "AND updated >= $#{i += 1}::timestamp" params.push start if end conds += " AND updated <= $#{i += 1}::timestamp" params.push end limit = if max params.push max "LIMIT $#{i += 1}" else "" q = (fields, table, cb2, mapper) -> db.query "SELECT #{fields}, updated FROM #{table} WHERE node in (SELECT node FROM subscriptions WHERE listener=$1) #{conds} ORDER BY updated DESC #{limit}", params , (err, res) -> if err return cb2 err if mapper iter res.rows.map(mapper) else iter res.rows cb2() async.parallel [ (cb2) -> db.query "SELECT node, MAX(updated) AS updated FROM node_config WHERE node in (SELECT node FROM subscriptions WHERE listener=$1) #{conds} GROUP BY node ORDER BY updated DESC #{limit}", params , (err, res) => if err return cb2 err async.forEach res.rows, (row, cb3) -> node = row.node db.query "SELECT key, value FROM node_config WHERE node=$1", [node] , (err, res) -> if err return cb3 err config = {} for row in res.rows config[row.key] = row.value iter [{ type: 'config', node, config }] cb3() , cb2 , (cb2) -> q "node, id, xml", "items" , cb2, (row) -> { type: 'items', node: row.node, items: [{ id: row.id, el: parseEl(row.xml) }] } , (cb2) -> q "node, \"user\", subscription, 'subscription' as type", "subscriptions" , cb2 , (cb2) -> q "node, \"user\", affiliation, 'affiliation' as type", "affiliations" , cb2 ], (err) -> logger.debug 'walkListenerArchive done' cb err ## # Stats ## getTopFollowedNodes: (count, timespan="7 days", nodePattern="/user/%@%/posts", cb) -> @db.query """SELECT node, COUNT(user) AS count FROM subscriptions WHERE node LIKE $1 AND node IN (SELECT node FROM open_nodes) AND updated >= CURRENT_TIMESTAMP - $2 :: INTERVAL GROUP BY node ORDER BY count DESC LIMIT $3""" , [nodePattern, timespan, count] , (err, res) -> cb err, res?.rows getTopPublishedNodes: (count, timespan="7 days", nodePattern="/user/%@%/posts", cb) -> @db.query """SELECT node, COUNT(xml) AS count FROM items WHERE node LIKE $1 AND node IN (SELECT node FROM open_nodes) AND updated >= CURRENT_TIMESTAMP - $2 :: INTERVAL GROUP BY node ORDER BY count DESC LIMIT $3""" , [nodePattern, timespan, count] , (err, res) -> cb err, res?.rows parseEl = (xml) -> try return ltx.parse(xml) catch e logger.error "Parsing " + xml + ": " + e.stack return undefined buddycloud-buddycloud-server-7889586/src/local/operations.coffee000066400000000000000000001503711202241346400247560ustar00rootroot00000000000000logger = require('../logger').makeLogger 'local/operations' {inspect} = require('util') cfg = require('jsconfig') {getNodeUser, getNodeType} = require('../util') async = require('async') uuid = require('node-uuid') errors = require('../errors') NS = require('../xmpp/ns') {normalizeItem} = require('../normalize') {makeTombstone} = require('../tombstone') {Element} = require('node-xmpp') runTransaction = null exports.setModel = (model) -> runTransaction = model.transaction exports.checkCreateNode = null defaultConfiguration = (user) -> accessModel = innerAccessModel = "authorize" if cfg.defaults?.userChannel?.openByDefault # only viewable by followers, followers+post and moderators. innerAccessModel = "whitelist" # anyone can view the channel accessModel = "open" posts: title: "#{user} Channel Posts" description: "A buddycloud channel" channelType: "personal" accessModel: accessModel publishModel: "publishers" defaultAffiliation: "publisher" status: title: "#{user} Status Updates" description: "M000D" accessModel: accessModel publishModel: "publishers" defaultAffiliation: "member" 'geo/previous': title: "#{user} Previous Location" description: "Where #{user} has been before" accessModel: innerAccessModel publishModel: "publishers" defaultAffiliation: "member" 'geo/current': title: "#{user} Current Location" description: "Where #{user} is at now" accessModel: innerAccessModel publishModel: "publishers" defaultAffiliation: "member" 'geo/next': title: "#{user} Next Location" description: "Where #{user} intends to go" accessModel: innerAccessModel publishModel: "publishers" defaultAffiliation: "member" subscriptions: title: "#{user} Subscriptions" description: "Browse my interests" accessModel: accessModel publishModel: "publishers" defaultAffiliation: "member" # user is "topic@domain" string defaultTopicConfiguration = (user) => accessModel = if cfg.defaults?.topicChannel?.openByDefault then "open" else "authorize" posts: title: "#{user} Topic Channel" description: "All about #{user.split('@')?[0]}" channelType: "topic" accessModel: accessModel publishModel: "subscribers" defaultAffiliation: "member" NODE_OWNER_TYPE_REGEXP = /^\/user\/([^\/]+)\/?(.*)/ AFFILIATIONS = [ 'outcast', 'none', 'member', 'publisher', 'moderator', 'owner' ] isAffiliationAtLeast = (affiliation1, affiliation2) -> i1 = AFFILIATIONS.indexOf(affiliation1) i2 = AFFILIATIONS.indexOf(affiliation2) if i2 < 0 false else i1 >= i2 canModerate = (affiliation) -> affiliation is 'owner' or affiliation is 'moderator' ### # Base Operations ### ## # Is created with options from the request # # Implementations set result class Operation constructor: (@router, @req) -> if @req.node? and (m = @req.node.match(NODE_OWNER_TYPE_REGEXP)) and m[2] is 'subscriptions' # Affords for specialized items handling in RetrieveItems and Publish @subscriptionsNodeOwner = m[1] run: (cb) -> cb new errorsFeature.NotImplemented("Operation defined but not yet implemented") class ModelOperation extends Operation run: (cb) -> runTransaction (err, t) => if err return cb err opName = @req.operation or @constructor?.name or "?" @transaction t, (err, results) -> if err logger.warn "Transaction #{opName} rollback: #{err}" t.rollback -> cb err else t.commit -> logger.debug "Transaction #{opName} committed" cb null, results # Must be implemented by subclass transaction: (t, cb) -> cb null class PrivilegedOperation extends ModelOperation transaction: (t, cb) -> async.waterfall [ (cb2) => @fetchActorAffiliation t, cb2 , (cb2) => @fetchNodeConfig t, cb2 , (cb2) => @checkAccessModel t, cb2 , (cb2) => @checkRequiredAffiliation t, cb2 ], (err) => if err return cb err @privilegedTransaction t, cb fetchActorAffiliation: (t, cb) -> unless @req.node return cb() if @req.actor.indexOf('@') >= 0 t.getAffiliation @req.node, @req.actor, (err, affiliation) => if err return cb err @actorAffiliation = affiliation or 'none' else # actor no user? check if listener! t.getListenerAffiliations @req.node, @req.actor, (err, affiliations) => if err return cb err @actorAffiliation = 'none' for affiliation in affiliations if affiliation isnt @actorAffiliation and isAffiliationAtLeast affiliation, @actorAffiliation @actorAffiliation = affiliation if canModerate @actorAffiliation # Moderators get to see everything @filterSubscription = @filterSubscriptionModerator @filterAffiliation = @filterAffiliationModerator cb() fetchNodeConfig: (t, cb) -> unless @req.node return cb() t.getConfig @req.node, (err, config) => if err return cb err @nodeConfig = config cb() checkAccessModel: (t, cb) -> # Deny any outcast if @actorAffiliation is 'outcast' return cb new errors.Forbidden("Outcast") # Set default according to node config unless @requiredAffiliation if @nodeConfig.accessModel is 'open' # Open nodes allow anyone @requiredAffiliation = 'none' else # For all other access models, actor has to be member @requiredAffiliation = 'member' cb() checkRequiredAffiliation: (t, cb) -> if @requiredAffiliation?.constructor is Function requiredAffiliation = @requiredAffiliation() else requiredAffiliation = @requiredAffiliation if not requiredAffiliation? or isAffiliationAtLeast @actorAffiliation, requiredAffiliation cb() else cb new errors.Forbidden("Requires affiliation #{requiredAffiliation} (you are #{@actorAffiliation})") # Used by Publish operation checkPublishModel: (t, cb) -> pass = false switch @nodeConfig.publishModel when 'open' pass = true when 'members' pass = isAffiliationAtLeast @actorAffiliation, 'member' when 'publishers' pass = isAffiliationAtLeast @actorAffiliation, 'publisher' else # Owners can always post pass = (@actorAffiliation is 'owner') if pass cb() else if @nodeConfig.publishModel is 'subscribers' # Special handling because subscription state must be # fetched t.getSubscription @req.node, @req.actor, (err, subscription) -> if !err and subscription is 'subscribed' cb() else cb err or new errors.Forbidden("Only subscribers may publish") else cb new errors.Forbidden("Only #{@nodeConfig.publishModel} may publish") filterSubscription: (subscription) => subscription.jid is @actor or subscription.subscription is 'subscribed' filterAffiliation: (affiliation) -> affiliation.affiliation isnt 'outcast' filterSubscriptionModerator: (subscription) -> yes filterAffiliationModerator: (affiliation) -> yes ### # Discrete Operations ### class BrowseInfo extends Operation run: (cb) -> cb null, features: [ NS.DISCO_INFO, NS.DISCO_ITEMS, NS.REGISTER, NS.PUBSUB, NS.PUBSUB_OWNER ] identities: [{ category: "pubsub" type: "service" name: "XEP-0060 service" }, { category: "pubsub" type: "channels" name: "Channels service" }, { category: "pubsub" type: "inbox" name: "Channels inbox service" }] class BrowseNodeInfo extends PrivilegedOperation ## # See access control notice of RetrieveNodeConfiguration transaction: (t, cb) -> t.getConfig @req.node, (err, config) => cb err, node: @req.node features: [ NS.DISCO_INFO, NS.DISCO_ITEMS, NS.REGISTER, NS.PUBSUB, NS.PUBSUB_OWNER ] identities: [{ category: "pubsub" type: "leaf" name: "XEP-0060 node" }, { category: "pubsub" type: "channel" name: "buddycloud channel" }] config: config class BrowseNodes extends ModelOperation transaction: (t, cb) -> rsm = @req.rsm @fetchNodes t, (err, results) => if err return cb err results = rsm.cropResults(results, 'node') results.forEach (item) => item.jid = @req.me cb null, results fetchNodes: (t, cb) -> t.listNodes cb class BrowseTopFollowedNodes extends BrowseNodes fetchNodes: (t, cb) -> max = @req.rsm.max or 10 t.getTopFollowedNodes max, null, null, cb class BrowseTopPublishedNodes extends BrowseNodes fetchNodes: (t, cb) -> max = @req.rsm.max or 10 t.getTopPublishedNodes max, null, null, cb class BrowseNodeItems extends PrivilegedOperation privilegedTransaction: (t, cb) -> if @subscriptionsNodeOwner? t.getSubscriptions @subscriptionsNodeOwner, (err, subscriptions) => if err return cb err # Group for item ids by followee: subscriptionsByFollowee = {} for subscription in subscriptions if (m = subscription.node.match(NODE_OWNER_TYPE_REGEXP)) followee = m[1] subscriptionsByFollowee[followee] = true # Prepare RSM suitable result set results = [] for own followee, present of subscriptionsByFollowee results.push name: followee jid: @req.me node: @req.node # Sort for a stable traversal with multiple RSM'ed queries results.sort (result1, result2) -> if result1.name < result2.name -1 else if result1.name > result2.name 1 else 0 # Apply RSM results = @req.rsm.cropResults results, 'name' cb null, results else t.getItemIds @req.node, (err, ids) => if err return cb err # Apply RSM ids = @req.rsm.cropResults ids results = ids.map (id) => { name: id, jid: @req.me, node: @req.node } results.node = @req.node results.rsm = @req.rsm cb null, results class Register extends ModelOperation run: (cb) -> # check if this component is authoritative for the requesting # user's domain @router.authorizeFor @req.me, @req.actor, (err, valid) => if err return cb err if valid # asynchronous super: ModelOperation::run.call @, cb else cb new errors.NotAllowed("This is not the authoritative buddycloud-server for your domain") transaction: (t, cb) -> user = @req.actor jobs = [] for own nodeType, config of defaultConfiguration(user) # rescope loop variables: do (nodeType, config) => jobs.push (cb2) => node = "/user/#{user}/#{nodeType}" config.creationDate = new Date().toISOString() @createNodeWithConfig t, node, config, cb2 async.series jobs, (err) -> cb err createNodeWithConfig: (t, node, config, cb) -> user = @req.actor created = no async.waterfall [(cb2) -> logger.info "creating #{node}" t.createNode node, cb2 , (created_, cb2) -> created = created_ t.setAffiliation node, user, 'owner', cb2 , (cb2) => t.setSubscription node, user, @req.sender, 'subscribed', cb2 , (cb2) -> # if already present, don't overwrite config if created t.setConfig node, config, cb2 else cb2 null ], cb class CreateNode extends ModelOperation transaction: (t, cb) -> nodeUser = getNodeUser @req.node unless nodeUser return cb new errors.BadRequest("Malformed node") if nodeUser.split("@")[0].length < 1 return cb new errors.BadRequest("Malformed node name") isTopic = nodeUser isnt @req.actor nodePrefix = "/user/#{nodeUser}/" try opts = node: @req.node nodeUser: nodeUser actor: @req.actor unless exports.checkCreateNode?(opts) return cb new errors.NotAllowed("Node creation not allowed") catch e return cb e async.waterfall [(cb2) => t.getOwnersByNodePrefix nodePrefix, cb2 , (owners, cb2) => if owners.length < 1 or owners.indexOf(@req.actor) >= 0 # Either there are no owners yet, or the user is # already one of them. t.createNode @req.node, cb2 else cb2 new errors.NotAllowed("Nodes with prefix #{nodePrefix} are already owned by #{owners.join(', ')}") , (created, cb2) => if created # Worked, proceed cb2 null else # Already exists cb2 new errors.Conflict("Node #{@req.node} already exists") , (cb2) => config = @req.config or {} config.creationDate = new Date().toISOString() # Pick config defaults if isTopic defaults = defaultTopicConfiguration nodeUser else defaults = defaultConfiguration nodeUser defaults = defaults[getNodeType @req.node] if defaults # Mix into config for own key, value of defaults unless config config = {} # Don't overwrite existing unless config[key]? config[key] = value # Set t.setConfig @req.node, config, cb2 , (cb2) => t.setSubscription @req.node, @req.actor, @req.sender, 'subscribed', cb2 , (cb2) => t.setAffiliation @req.node, @req.actor, 'owner', cb2 ], cb class Publish extends PrivilegedOperation # checks affiliation with @checkPublishModel below privilegedTransaction: (t, cb) -> if @subscriptionsNode? return cb new errors.NotAllowed("The subscriptions node is automagically populated") async.waterfall [ (cb2) => @checkPublishModel t, cb2 , (cb2) => async.series @req.items.map((item) => (cb3) => async.waterfall [(cb4) => unless item.id? item.id = uuid() cb4 null, null else t.getItem @req.node, item.id, (err, item) -> if err and err.constructor is errors.NotFound cb4 null, null else cb4 err, item , (oldItem, cb4) => normalizeItem @req, oldItem, item, cb4 , (newItem, cb4) => t.writeItem @req.node, newItem.id, newItem.el, (err) -> cb4 err, newItem.id ], cb3 ), cb2 ], cb notification: -> [{ type: 'items' node: @req.node items: @req.items }] class Subscribe extends PrivilegedOperation ## # Overwrites PrivilegedOperation#transaction() to use a different # permissions checking model, but still uses its methods. transaction: (t, cb) -> async.waterfall [ (cb2) => @fetchActorAffiliation t, cb2 , (cb2) => @fetchNodeConfig t, cb2 , (cb2) => if @nodeConfig.accessModel is 'authorize' @subscription = 'pending' # Immediately return: return cb2() @subscription = 'subscribed' defaultAffiliation = @nodeConfig.defaultAffiliation or 'none' unless isAffiliationAtLeast @actorAffiliation, defaultAffiliation # Less than current affiliation? Bump up to defaultAffiliation @affiliation = @nodeConfig.defaultAffiliation or 'member' @checkAccessModel t, cb2 ], (err) => if err return cb err @privilegedTransaction t, cb privilegedTransaction: (t, cb) -> async.waterfall [ (cb2) => t.setSubscription @req.node, @req.actor, @req.sender, @subscription, cb2 , (cb2) => if @affiliation t.setAffiliation @req.node, @req.actor, @affiliation, cb2 else cb2() ], (err) => cb err, user: @req.actor subscription: @subscription notification: -> ns = [{ type: 'subscription' node: @req.node user: @req.actor subscription: @subscription }] if @affiliation ns.push type: 'affiliation' node: @req.node user: @req.actor affiliation: @affiliation ns moderatorNotification: -> if @subscription is 'pending' type: 'authorizationPrompt' node: @req.node user: @req.actor ## # Not privileged as anybody should be able to unsubscribe him/herself class Unsubscribe extends PrivilegedOperation transaction: (t, cb) -> if @req.node.indexOf("/user/#{@req.actor}/") == 0 return cb new errors.Forbidden("You may not unsubscribe from your own nodes") async.waterfall [ (cb2) => t.setSubscription @req.node, @req.actor, @req.sender, 'none', cb2 , (cb2) => @fetchActorAffiliation t, cb2 , (cb2) => @fetchNodeConfig t, cb2 , (cb2) => # only decrease if <= defaultAffiliation if isAffiliationAtLeast(@nodeConfig.defaultAffiliation, @actorAffiliation) and @actorAffiliation isnt 'outcast' @actorAffiliation = 'none' t.setAffiliation @req.node, @req.actor, 'none', cb2 else cb2() ], cb notification: -> [{ type: 'subscription' node: @req.node user: @req.actor subscription: 'none' }, { type: 'affiliation' node: @req.node user: @req.actor affiliation: @actorAffiliation }] class RetrieveItems extends PrivilegedOperation run: (cb) -> if @subscriptionsNodeOwner? # Special case: only handle virtually when local server is # authoritative @router.authorizeFor @req.me, @subscriptionsNodeOwner, (err, valid) => if err return cb err if valid # Patch in virtual items @privilegedTransaction = @retrieveSubscriptionsItems # asynchronous super: PrivilegedOperation::run.call @, cb else super privilegedTransaction: (t, cb) -> node = @req.node rsm = @req.rsm if @req.itemIds? fetchItemIds = (cb2) => cb2 null, @req.itemIds else fetchItemIds = (cb2) -> t.getItemIds node, cb2 fetchItemIds (err, ids) -> # Apply RSM ids = rsm.cropResults ids # Fetching actual items async.series ids.map((id) -> (cb2) -> t.getItem node, id, (err, el) -> if err return cb2 err cb2 null, id: id el: el ), (err, results) -> if err cb err else # Annotate results array results.node = node results.rsm = rsm cb null, results ## # For /user/.../subscriptions # # # # # 2010-12-26T17:30:00Z # # # # # # # retrieveSubscriptionsItems: (t, cb) -> async.waterfall [ (cb2) => t.getSubscriptions @subscriptionsNodeOwner, cb2 , (subscriptions, cb2) => # No pending subscriptions: subscriptions = subscriptions.filter (subscription) -> subscription.subscription is 'subscribed' # Group for item ids by followee: subscriptionsByFollowee = {} for subscription in subscriptions if (m = subscription.node.match(NODE_OWNER_TYPE_REGEXP)) followee = m[1] unless subscriptionsByFollowee[followee]? subscriptionsByFollowee[followee] = [] subscriptionsByFollowee[followee].push subscription # Prepare RSM suitable result set results = [] for own followee, followeeSubscriptions of subscriptionsByFollowee results.push id: followee subscriptions: followeeSubscriptions # Sort for a stable traversal with multiple RSM'ed queries results.sort (result1, result2) -> if result1.id < result2.id -1 else if result1.id > result2.id 1 else 0 # Apply RSM results = @req.rsm.cropResults results, 'id' # get affiliations per node async.forEachSeries results, (result, cb3) => async.forEach result.subscriptions, (subscription, cb4) => t.getAffiliation subscription.node, @subscriptionsNodeOwner, (err, affiliation) -> subscription.affiliation ?= affiliation cb4 err , cb3 , (err) -> cb2 err, results , (results, cb2) => # Transform to specified items format for item in results item.el = new Element('query', xmlns: NS.DISCO_ITEMS 'xmlns:pubsub': NS.PUBSUB ) for subscription in item.subscriptions itemAttrs = jid: @subscriptionsNodeOwner node: subscription.node itemAttrs['pubsub:subscription'] ?= subscription.subscription itemAttrs['pubsub:affiliation'] ?= subscription.affiliation item.el.c('item', itemAttrs) delete item.subscriptions results.rsm = @req.rsm results.node = @req.node cb2 null, results ], cb class RetractItems extends PrivilegedOperation privilegedTransaction: (t, cb) -> @retractedItems = [] async.waterfall [ (cb2) => # Get full items, not just IDs async.map @req.items, (id, cb3) => t.getItem @req.node, id, cb3 , cb2 , (fullItems, cb2) => if isAffiliationAtLeast @actorAffiliation, 'moderator' # Owners and moderators may remove any post cb2 null, fullItems else # Anyone may remove only their own posts @checkItemsAuthor fullItems, cb2 , (fullItems, cb2) => async.forEach fullItems, (el, cb3) => item = el: makeTombstone el id: el.getChildText('id') t.writeItem @req.node, item.id, item.el, cb3 @retractedItems.push item , cb2 ], cb checkItemsAuthor: (fullItems, cb) -> async.forEachSeries fullItems, (el, cb2) => # Check for post authorship author = el?.is('entry') and el.getChild('author')?.getChild('uri')?.getText() if author is "acct:#{@req.actor}" # Authenticated! cb2() else cb2 new errors.NotAllowed("You may not retract other people's posts") , (err) => if err? cb err else cb null, fullItems notification: -> [{ type: 'items' node: @req.node retract: @retractedItems }] class RetrieveUserSubscriptions extends ModelOperation transaction: (t, cb) -> rsm = @req.rsm t.getSubscriptions @req.actor, (err, subscriptions) -> if err return cb err subscriptions = rsm.cropResults subscriptions, 'node' cb null, subscriptions class RetrieveUserAffiliations extends ModelOperation transaction: (t, cb) -> rsm = @req.rsm t.getAffiliations @req.actor, (err, affiliations) -> if err return cb err affiliations = rsm.cropResults affiliations, 'node' cb null, affiliations class RetrieveNodeSubscriptions extends PrivilegedOperation privilegedTransaction: (t, cb) -> rsm = @req.rsm t.getSubscribers @req.node, (err, subscriptions) => if err return cb err subscriptions = subscriptions.filter @filterSubscription subscriptions = rsm.cropResults subscriptions, 'user' cb null, subscriptions class RetrieveNodeAffiliations extends PrivilegedOperation privilegedTransaction: (t, cb) -> rsm = @req.rsm t.getAffiliated @req.node, (err, affiliations) => if err return cb err affiliations = affiliations.filter @filterAffiliation affiliations = rsm.cropResults affiliations, 'user' cb null, affiliations class RetrieveNodeConfiguration extends PrivilegedOperation ## # Allowed for anyone. We do not have hidden channels yet. # # * Even closed channels should be browsable so that subscription # can be requested at all # * outcast shall not receive too much punishment (or glorification) transaction: (t, cb) -> t.getConfig @req.node, (err, config) -> # wrap into { config: ...} result cb err, { config } class ManageNodeSubscriptions extends PrivilegedOperation requiredAffiliation: => if @nodeConfig.channelType is 'topic' 'moderator' else 'owner' privilegedTransaction: (t, cb) -> defaultAffiliation = null async.waterfall [(cb2) => t.getConfig @req.node, (error, config) => defaultAffiliation = config.defaultAffiliation or 'member' cb2(error) , (cb2) => async.forEach @req.subscriptions , ({user, subscription}, cb3) => async.waterfall [(cb4) => if @req.node.indexOf("/user/#{user}/") == 0 and subscription isnt 'subscribed' cb4 new errors.Forbidden("You may not unsubscribe the owner") else t.setSubscription @req.node, user, null, subscription, cb4 , (cb4) => t.getAffiliation @req.node, user, cb4 , (affiliation, cb4) => if affiliation is 'none' t.setAffiliation @req.node, user, defaultAffiliation, cb4 else cb4() ], cb3 , cb2 ], cb notification: -> @req.subscriptions.map ({user, subscription}) => { type: 'subscription' node: @req.node user subscription } class ManageNodeAffiliations extends PrivilegedOperation requiredAffiliation: => if @nodeConfig.channelType is 'topic' 'moderator' else 'owner' privilegedTransaction: (t, cb) -> @newModerators = [] async.series @req.affiliations.map(({user, affiliation}) => (cb2) => async.waterfall [ (cb3) => t.getAffiliation @req.node, user, cb3 , (oldAffiliation, cb3) => if oldAffiliation is affiliation # No change cb3() else if oldAffiliation isnt 'owner' and affiliation is 'owner' and @actorAffiliation isnt 'owner' # Non-owner tries to elect a new owner! cb3 new errors.Forbidden("You may not elect a new owner") else async.series [ (cb4) => if not canModerate(oldAffiliation) and canModerate(affiliation) t.getSubscriptionListener @req.node, user, (err, listener) => if (not err) and listener @newModerators.push { user, listener, node: @req.node } cb4 err else cb4() , (cb4) => if @req.node.indexOf("/user/#{user}/") == 0 and affiliation isnt 'owner' cb4 new errors.Forbidden("You may not demote the owner") else t.setAffiliation @req.node, user, affiliation, cb4 ], (err) -> cb3 err ], cb2 ), cb notification: -> affiliations = @req.affiliations.map ({user, affiliation}) => { type: 'affiliation' node: @req.node user affiliation } ALLOWED_ACCESS_MODELS = ['open', 'whitelist', 'authorize'] ALLOWED_PUBLISH_MODELS = ['open', 'subscribers', 'publishers'] class ManageNodeConfiguration extends PrivilegedOperation requiredAffiliation: 'owner' run: (cb) -> # Validate some config if @req.config.accessModel? and ALLOWED_ACCESS_MODELS.indexOf(@req.config.accessModel) < 0 cb new errors.BadRequest("Invalid access model") else if @req.config.publishModel? and ALLOWED_PUBLISH_MODELS.indexOf(@req.config.publishModel) < 0 cb new errors.BadRequest("Invalid publish model") else if @req.config.creationDate? cb new errors.BadRequest("Cannot set creation date") else # All is well, actually run super(cb) privilegedTransaction: (t, cb) -> t.setConfig @req.node, @req.config, cb notification: -> [{ type: 'config' node: @req.node config: @req.config }] ## # Removes all subscriptions of the actor, call back with all remote # subscriptions that the backends must then unsubscribe for. # # Two uses: # # * Rm subscriptions of an anonymous (temporary) user # * TODO: Completely clear out a user account class RemoveUser extends ModelOperation transaction: (t, cb) -> t.getUserRemoteSubscriptions @req.actor, (err, subscriptions) => if err return cb(err) t.clearUserSubscriptions @req.actor, (err) => cb err, subscriptions class AuthorizeSubscriber extends PrivilegedOperation requiredAffiliation: => if @nodeConfig.channelType is 'topic' 'moderator' else 'owner' privilegedTransaction: (t, cb) -> if @req.allow @subscription = 'subscribed' unless isAffiliationAtLeast @actorAffiliation, @nodeConfig.defaultAffiliation # Less than current affiliation? Bump up to defaultAffiliation @affiliation = @nodeConfig.defaultAffiliation or 'member' else @subscription = 'none' async.waterfall [ (cb2) => t.setSubscription @req.node, @req.user, @req.sender, @subscription, cb2 , (cb2) => if @affiliation t.setAffiliation @req.node, @req.user, @affiliation, cb2 else cb2() ], (err) -> cb err notification: -> ns = [{ type: 'subscription' node: @req.node user: @req.user subscription: @subscription }] if @affiliation ns.push type: 'affiliation' node: @req.node user: @req.user affiliation: @affiliation ns ## # MAM replay # and authorization form query # # The RSM handling here respects only a value. # # Doesn't care about about subscriptions nodes. class ReplayArchive extends ModelOperation transaction: (t, cb) -> max = @req.rsm?.max or 50 sent = 0 async.waterfall [ (cb2) => t.walkListenerArchive @req.sender, @req.start, @req.end, max, (results) => if sent < max results.sort (a, b) -> if a.updated < b.updated -1 else if a.updated > b.updated 1 else 0 results = results.slice(0, max - sent) @sendNotification results sent += results.length , cb2 , (cb2) => sent = 0 t.walkModeratorAuthorizationRequests @req.sender, (req) => if sent < max req.type = 'authorizationPrompt' @sendNotification req sent++ , cb2 ], cb sendNotification: (results) -> notification = Object.create(results) notification.listener = @req.fullSender notification.replay = true notification.queryId = @req.queryId @router.notify notification class PushInbox extends ModelOperation transaction: (t, cb) -> notification = [] newNodes = [] newModerators = [] unsubscribedNodes = {} async.waterfall [(cb2) => logger.debug "pushUpdates: #{inspect @req}" async.filter @req, (update, cb3) => if update.type is 'subscription' and update.listener? # Was successful remote subscription attempt t.createNode update.node, (err, created) => if created newNodes.push update.node cb3 not err else # Just an update, to be cached locally? t.nodeExists update.node, (err, exists) -> cb3 not err and exists , (updates) -> cb2 null, updates , (updates, cb2) => logger.debug "pushFilteredUpdates: #{inspect updates}" async.forEach updates, (update, cb3) -> switch update.type when 'items' notification.push update {node, items} = update async.forEach items, (item, cb4) -> {id, el} = item t.writeItem node, id, el, cb4 , cb3 when 'subscription' notification.push update {node, user, listener, subscription} = update if subscription isnt 'subscribed' unsubscribedNodes[node] = yes t.setSubscription node, user, listener, subscription, cb3 when 'affiliation' notification.push update {node, user, affiliation} = update t.getAffiliation node, user, (err, oldAffiliation) -> if err return cb3 err async.series [ (cb4) => if not canModerate(oldAffiliation) and canModerate(affiliation) t.getSubscriptionListener node, user, (err, listener) -> if (not err) and listener newModerators.push { user, listener, node } cb4 err else cb4() , (cb4) => t.setAffiliation node, user, affiliation, cb4 ], (err) -> cb3 err when 'config' notification.push update {node, config} = update t.setConfig node, config, cb3 else cb3 new errors.InternalServerError("Bogus push update type: #{update.type}") , cb2 , (cb2) => # Memorize updates for notifications, same format: @notification = -> notification if newNodes.length > 0 @newNodes = newNodes if newModerators.length > 0 @newModerators = newModerators cb2() , (cb2) => # no local subscriptions left to remote node? delete it. checks = [] for own node, _ of unsubscribedNodes checks.push (cb3) -> t.getNodeListeners node, (err, listeners) -> if err return cb3 err unless listeners? and listeners.length > 0 t.purgeNode node, cb3 async.parallel checks, cb2 ], cb class Notify extends ModelOperation transaction: (t, cb) -> async.waterfall [(cb2) => async.forEach @req, (update, cb3) => # First, we complete subscriptions node items m = update.node.match(NODE_OWNER_TYPE_REGEXP) # Fill in missing subscriptions node items if update.type is 'items' and m?[2] is 'subscriptions' subscriber = m[1] t.getSubscriptions subscriber, (err, subscriptions) => if err return cb3 err async.map update.items, ({id, el}, cb4) => if el # Content already exists, perhaps # relaying a notification from another # service return cb4 null, { id, el } userSubscriptions = subscriptions.filter (subscription) -> subscription.node.indexOf("/user/#{id}/") is 0 affiliations = {} async.forEach userSubscriptions, (subscription, cb5) => t.getAffiliation subscriber, subscription.node, (err, affiliation) => affiliations[subscription.node] = affiliation cb5(err) , (err) => el = new Element('query', xmlns: NS.DISCO_ITEMS 'xmlns:pubsub': NS.PUBSUB ) for subscription in userSubscriptions itemAttrs = jid: subscriber node: subscription.node itemAttrs['pubsub:subscription'] ?= subscription.subscription itemAttrs['pubsub:affiliation'] ?= affiliations[subscription.node] el.c('item', itemAttrs) cb4 null, { id, el } , (err, items) => if err return cb3(err) update.items = items cb3() else cb3() , cb2 , (cb2) => # Then, retrieve all listeners # (assuming all updates pertain one single node) # TODO: walk in batches logger.debug "notifyNotification: #{inspect @req}" t.getNodeListeners @req.node, cb2 , (listeners, cb2) => # Always notify all users pertained by a subscription # notification, even if just unsubscribed. for update in @req if update.type is 'subscription' and listeners.indexOf(update.user) < 0 listeners.push update.user cb2 null, listeners , (listeners, cb2) => moderatorListeners = [] otherListeners = [] async.forEach listeners, (listener, cb3) => t.getAffiliation @req.node, listener, (err, affiliation) -> if err return cb3 err if canModerate affiliation moderatorListeners.push listener else otherListeners.push listener cb3() , (err) => cb2 err, moderatorListeners, otherListeners , (moderatorListeners, otherListeners, cb2) => # Send out through backends if moderatorListeners.length > 0 for listener in moderatorListeners notification = Object.create(@req) notification.listener = listener @router.notify notification if otherListeners.length > 0 req = @req.filter (update) => switch update.type when 'subscription' PrivilegedOperation::filterSubscription update when 'affiliation' PrivilegedOperation::filterAffiliation update else yes # Any left after filtering? Don't send empty # notification when somebody got banned. if req.length > 0 for listener in otherListeners notification = Object.create(req) notification.node = @req.node notification.listener = listener @router.notify notification cb2() ], cb class ModeratorNotify extends ModelOperation transaction: (t, cb) -> # TODO: walk in batches logger.debug "moderatorNotifyNotification: #{inspect(@req)}" t.getNodeModeratorListeners @req.node, (err, listeners) => if err return cb err for listener in listeners notification = Object.create(@req) notification.listener = listener @router.notify notification cb() class NewModeratorNotify extends PrivilegedOperation privilegedTransaction: (t, cb) -> async.parallel [ (cb2) => t.getPending @req.node, cb2 , (cb2) => t.getOutcast @req.node, cb2 ], (err, [pendingUsers, bannedUsers]) => if err return cb err notification = [] notification.node = @req.node notification.listener = @req.listener for user in pendingUsers notification.push type: 'subscription' node: @req.node user: user subscription: 'pending' for user in bannedUsers notification.push type: 'affiliation' node: @req.node user: user affiliation: 'outcast' @router.notify notification cb() OPERATIONS = 'browse-info': BrowseInfo 'browse-node-info': BrowseNodeInfo 'browse-nodes': BrowseNodes 'browse-top-followed-nodes': BrowseTopFollowedNodes 'browse-top-published-nodes': BrowseTopPublishedNodes 'browse-node-items': BrowseNodeItems 'register-user': Register 'create-node': CreateNode 'publish-node-items': Publish 'subscribe-node': Subscribe 'unsubscribe-node': Unsubscribe 'retrieve-node-items': RetrieveItems 'retract-node-items': RetractItems 'retrieve-user-subscriptions': RetrieveUserSubscriptions 'retrieve-user-affiliations': RetrieveUserAffiliations 'retrieve-node-subscriptions': RetrieveNodeSubscriptions 'retrieve-node-affiliations': RetrieveNodeAffiliations 'retrieve-node-configuration': RetrieveNodeConfiguration 'manage-node-subscriptions': ManageNodeSubscriptions 'manage-node-affiliations': ManageNodeAffiliations 'manage-node-configuration': ManageNodeConfiguration 'remove-user': RemoveUser 'confirm-subscriber-authorization': AuthorizeSubscriber 'replay-archive': ReplayArchive 'push-inbox': PushInbox exports.run = (router, request, cb) -> opName = request.operation unless opName # No operation specified, reply immediately return cb?() opClass = OPERATIONS[opName] unless opClass logger.warn "Unimplemented operation #{opName}: #{inspect request}" return cb?(new errors.FeatureNotImplemented("Unimplemented operation #{opName}")) logger.debug "operations.run #{opName}: #{inspect request}" op = new opClass(router, request) op.run (error, result) -> if error logger.warn "Operation #{opName} failed: #{error.stack or error}" cb? error else # Successfully done logger.debug "Operation #{opName} ran: #{inspect result}" async.series [(cb2) -> # Run notifications if (notification = op.notification?()) # Extend by subscriptions node notifications notification = notification.concat generateSubscriptionsNotifications(notification) # Call Notify operation grouped by node # (looks up subscribers by node) blocks = [] for own node, notifications of groupByNode(notification) notifications.node = node do (notifications) -> blocks.push (cb3) -> new Notify(router, notifications).run (err) -> if err logger.error("Error running notifications: #{err.stack or err.message or err}") cb3() async.series blocks, cb2 else cb2() , (cb2) -> if (notification = op.moderatorNotification?()) new ModeratorNotify(router, notification).run (err) -> if err logger.error("Error running notifications: #{err.stack or err.message or err}") cb2() else cb2() , (cb2) -> if (op.newModerators and op.newModerators.length > 0) blocks = [] for {user, node, listener} in op.newModerators req = operation: 'new-moderator-notification' node: node actor: user listener: listener do (req) -> blocks.push (cb3) -> new NewModeratorNotify(router, req).run (err) -> if err logger.error("Error running new moderator notification: #{err.stack or err.message or err}") cb3() async.series blocks, cb2 else cb2() , (cb2) -> # May need to sync new nodes if op.newNodes? async.forEach op.newNodes, (node, cb3) -> router.syncNode node, cb3 , cb2 else cb2() ], -> # Ignore any sync result try cb? null, result catch e logger.error e.stack or e groupByNode = (updates) -> result = {} for update in updates unless result.hasOwnProperty(update.node) result[update.node] = [] result[update.node].push update result generateSubscriptionsNotifications = (updates) -> itemIdsSeen = {} updates.filter((update) -> (update.type is 'subscription' and update.subscription is 'subscribed') or update.type is 'affiliation' ).map((update) -> followee = update.node.match(NODE_OWNER_TYPE_REGEXP)?[1] type: 'items' node: "/user/#{update.user}/subscriptions" items: [{ id: followee }] # Actual item payload will be completed by Notify transaction ).filter((update) -> itemId = update.items[0].id if itemIdsSeen[itemId]? no else itemIdsSeen[itemId] = yes yes ) buddycloud-buddycloud-server-7889586/src/logger.coffee000066400000000000000000000033251202241346400227540ustar00rootroot00000000000000{ constructor: CommonLogger } = require 'underscore.logger' SysLogger = require 'ain2' fs = require 'fs' { inspect } = require 'util' config = {} logFile = undefined exports.setConfig = (config_) -> config = Object.create(config_) # Translate user-passed string to level index config.level = Math.max(0, CommonLogger.levels.indexOf config_.level) if config_.file logFile = fs.createWriteStream config_.file, flags: 'a' # syslog needs a hostname as dgram_unix # support has been removed from node. if config_.syslog? and Object.keys(config_.syslog).length > 0 ain2 = new SysLogger() ain2.set tag: 'buddycloud' facility: 'daemon' transport: 'file' if config_.syslog.hostname ain2.set transport: 'udp' hostname: config_.syslog.hostname if config_.syslog.port ain2.set port: config_.syslog.port class Logger extends CommonLogger constructor: (@module) -> super(config) # Monkey patch to always convert the format string object to an actual string _log: (level, args) -> if args[0] and typeof args[0] isnt 'string' args[0] = inspect args[0] super # + @module output format: (date, level, message) -> "[#{date.toUTCString()}] #{CommonLogger.levels[level]} [#{@module}] #{message}" # more targets than just console.log() out: (message) -> if config.stdout console.log message if logFile logFile.write "#{message}\n" if config.syslog? and Object.keys(config.syslog).length > 0 ain2?['info']?(message) exports.makeLogger = (module) -> new Logger(module) buddycloud-buddycloud-server-7889586/src/main.coffee000066400000000000000000000157741202241346400224340ustar00rootroot00000000000000# 3rd-party libs fs = require('fs') path = require('path') async = require('async') {inspect} = require('util') Connection = require('./xmpp/connection') xmpp = require('node-xmpp') NS = require('./xmpp/ns') # Config config = require('jsconfig') version = require('./version') defaultConfigFile = path.join(__dirname,"..","config.js") if fs.existsSync(defaultConfigFile) config.defaults defaultConfigFile process.title = "buddycloud-server #{version}" config.set 'ignore unknown', yes config.set 'env', HOST: 'xmpp.host' PORT: ['xmpp.port', parseInt] config.cli host: ['xmpp.host', ['b', "xmpp server listen address", 'host']] port: ['xmpp.port', ['p', "xmpp server listen port", 'number']] config: ['c', "load config file", 'path'] debug: [off, "enable debug mode"] nobuild: [off, "[INTERNAL] disable build"] stdout: ['logging.stdout', [off, "Log to stdout"]] version: [off, "Display version"] config.load (args, opts) -> if opts.version console.log version process.exit 0 if opts.config?.length unless opts.config[0] is '/' opts.config = path.join(process.cwd(), opts.config) # Always reload config for -c argument config.merge(opts.config) else if fs.existsSync("/etc/buddycloud-server/config.js") config.merge("/etc/buddycloud-server/config.js") # Kludge: if opts.stdout config.logging.stdout = true # Logger logger_ = require('./logger') logger_.setConfig config.logging logger = logger_.makeLogger 'main' if opts.debug process.on 'uncaughtException', (err) -> logger.error "uncaughtException: #{err.stack || err.message || err.toString()}" errors = require('./errors') model = require('./local/model_postgres') model.start config.modelConfig router = new (require('./router').Router)(model, config.checkCreateNode, config.autosubscribeNewUsers) # XMPP Connection, w/ presence tracking xmppConn = new (Connection.Connection)(config.xmpp) pubsubServer = new (require('./xmpp/pubsub_server').PubsubServer)(xmppConn) pubsubBackend = new (require('./xmpp/backend_pubsub').PubsubBackend)(xmppConn) router.addBackend pubsubBackend # Handle XEP-0060 Publish-Subscribe and related requests: pubsubServer.on 'request', (request) -> logger.trace "request: #{inspect request}" if request.operation is 'get-version' request.callback null, name: "buddycloud-server" version: version os: process.platform else if request.sender isnt request.actor # Validate if sender is authorized to act on behalf of the # actor pubsubBackend.authorizeFor request.sender, request.actor, (err, valid) -> if err request.callback err else unless valid request.callback new errors.BadRequest('Requesting service not authorized for actor') else # Pass to router router.run request, (args...) -> request.callback(args...) else # Pass to router router.run request, (args...) -> request.callback(args...) # Handle incoming XEP-0060 Publish-Subscribe notifications pubsubBackend.on 'notificationPush', (opts) -> logger.trace "notificationPush: #{inspect(opts)}" # Sender is already authenticated at this point opts.operation = 'push-inbox' router.run opts, -> pubsubBackend.on 'syncNeeded', (server) -> router.syncServer server, -> pubsubBackend.on 'authorizationPrompt', (opts) -> # verify node authority pubsubBackend.authorizeFor opts.sender, opts.nodeOwner, (err, valid) -> if valid # Just relay opts.type = 'authorizationPrompt' router.notify opts pubsubBackend.on 'authorizationConfirm', (opts) -> opts.operation = 'confirm-subscriber-authorization' router.run opts, -> # Clean-up for anonymous users xmppConn.on 'userOffline', (user) -> router.onUserOffline user xmppConn.on 'online', -> logger.info "XMPP connection established" process.title = "buddycloud-server #{version}: #{xmppConn.jid}" saidHello = no model.forListeners (listener) -> unless saidHello logger.info "server successfully started" saidHello = yes xmppConn.probePresence(listener) # wait for a fully initialised server before starting tasks sync = -> router.setupSync Math.ceil((config.modelConfig.poolSize or 2) / 2) setTimeout sync, 5000 if !config.advertiseComponents? config.advertiseComponents = [] for index of config.advertiseComponents componentConfig = {} for key, value of config.xmpp componentConfig[key] = value componentConfig.jid = config.advertiseComponents[index] componentConfig.reconnect = true connection = new xmpp.Component(componentConfig) connection.on "error", (e) -> logger.error e connection.on "stanza", (stanza) => # Just debug output: logger.trace "<< Extra connection request: #{stanza.toString()}" from = stanza.attrs.from if stanza.name is 'iq' and stanza.attrs.type is 'get' # IQ requests if !stanza.children? stanza.children = [] for i, child of stanza.children if child.name is 'query' query = child if !query? return switch query.attrs.xmlns when NS.DISCO_ITEMS reply = new xmpp.Element("iq", from: stanza.attrs.to to: stanza.attrs.from id: stanza.attrs.id or "" type: "result" xmlns: Connection.NS_STREAM). c('query', xmlns: NS.DISCO_ITEMS). c('item', jid: config.xmpp.jid, name: 'buddycloud-server') when NS.DISCO_INFO reply = new xmpp.Element("iq", from: stanza.attrs.to to: stanza.attrs.from id: stanza.attrs.id or "" type: "result" xmlns: Connection.NS_STREAM). c('query', xmlns: NS.DISCO_INFO). c('feature', var: NS.DISCO_INFO).up(). c('feature', var: NS.DISCO_ITEMS).up(). c('feature', var: NS.REGISTER).up(). c('identity', category:'pubsub', type:'service', name:'Buddycloud proxy domain') else return logger.trace "<< Extra connection response: #{reply.root().toString()}" connection.send reply buddycloud-buddycloud-server-7889586/src/normalize.coffee000066400000000000000000000100331202241346400234670ustar00rootroot00000000000000logger = require('./logger').makeLogger 'normalize' errors = require('./errors') NS_ATOM = "http://www.w3.org/2005/Atom" NS_AS = "http://activitystrea.ms/spec/1.0/" NS_THR = "http://purl.org/syndication/thread/1.0" nodeRegexp = /^\/user\/([^\/]+)\/(.+)/ exports.normalizeItem = (req, oldItem, item, cb) -> unless (m = nodeRegexp.exec(req.node)) return cb new errors.BadRequest("No recognized node type") nodeType = m[2] # TODO: apply according to pubsub#type config if nodeType is 'posts' or nodeType is 'status' if item.el?.is('entry', NS_ATOM) req2 = Object.create(req) req2.nodeType = nodeType req2.oldItem = oldItem req2.item = item normalizeEntry req2, (err, req3) -> cb err, req3?.item else cb new errors.BadRequest("Item payload must be an ATOM entry") else # Other nodeType, no ATOM to enforce cb null, item ## # Normalize an ATOM entry # # `req' is the controller request, annotated with the `item' and # `oldItem' fields. normalizeEntry = (req, cb) -> try normalizeTextNodes req normalizeAuthor req normalizeId req normalizePublished req normalizeUpdated req normalizeLink req normalizeActivityStream req cb null, req catch e logger.error e.stack cb e # Remove empty text nodes ( --> # ), except in content item (may contain HTML or other markup). normalizeTextNodes = (req) -> deleteEmptyTextNodes = (el) -> unless el.is('content', NS_ATOM) cleanChildren = [] for child in el.children if typeof child is 'string' unless child.trim().length is 0 cleanChildren.push child else cleanChildren.push deleteEmptyTextNodes(child) el.children = cleanChildren return el req.item.el = deleteEmptyTextNodes req.item.el # # acct:foo@example.com # normalizeAuthor = (req) -> # Deal with an arbitrary amount of elements, ensuring at # least one authorEls = req.item.el.getChildren('author') if authorEls.length < 1 authorEls = [req.item.el.c('author')] authorEls.forEach (authorEl) -> authorEl.remove 'uri' authorEl.c('uri'). t("acct:#{req.actor}") normalizeId = (req) -> req.item.el.remove "id", NS_ATOM req.item.el.c("id").t req.item.id normalizePublished = (req) -> req.item.el.remove "published", NS_ATOM # The local now by default published = new Date().toISOString() # Find previous published date if req.oldItem? req.oldItem.getChildren("published").forEach (publishedEl) -> published = publishedEl.getText() req.item.el.c("published"). t(published) normalizeUpdated = (req) -> req.item.el.remove "updated", NS_ATOM updated = new Date().toISOString() req.item.el.c("updated"). t(updated) normalizeActivityStream = (req) -> irtEl = req.item.el.getChild('in-reply-to', NS_THR) # Ensure a unless req.item.el.getChild('verb', NS_AS) verb = if irtEl then 'comment' else 'post' req.item.el.c('verb', xmlns: NS_AS). t(verb) # Ensure a unless req.item.el.getChild('object', NS_AS) objectType = if irtEl then 'comment' else 'note' req.item.el.c('object', xmlns: NS_AS). c('object-type'). t(objectType) normalizeLink = (req) -> link = "xmpp:#{req.me}?pubsub;action=retrieve;" + "node=#{encodeURI req.node};" + "item=#{encodeURI req.item.id}" alreadyPresent = req.item.el.children.some (child) -> typeof child != 'string' and child.is('link') and child.attrs.rel is 'self' and child.attrs.href is link unless alreadyPresent req.item.el.c('link', rel: 'self' href: link ) buddycloud-buddycloud-server-7889586/src/router.coffee000066400000000000000000000244521202241346400230210ustar00rootroot00000000000000logger = require('./logger').makeLogger 'router' {inspect} = require('util') errors = require('./errors') sync = require('./sync') async = require('async') rsmWalk = require('./rsm_walk') {RSM} = require('./xmpp/rsm') {getNodeUser, nodeTypes} = require('./util') CACHE_TIMEOUT = 60 * 1000 ## # Routes to multiple backends class RemoteRouter constructor: (@router) -> @backends = [] addBackend: (backend) => @backends.push backend getMyJids: -> jids = [] @backends.map (backend) -> if backend.getMyJids? jids.push(backend.getMyJids()...) jids isUserOnline: (user) => @backends.some (backend) -> backend.isUserOnline user run: (opts, cb) -> backends = new Array(@backends...) tryBackend = => backend = backends.shift() backend.run @router, opts, (err, results) -> if err && backends.length > 0 # Retry with next backend tryBackend() else # Was last backend cb err, results tryBackend() notify: (notification) -> for backend in @backends backend.notify notification authorizeFor: (sender, actor, cb) => backends = new Array(@backends...) tryBackend = => backend = backends.shift() backend.authorizeFor sender, actor, (err, valid) -> if (err or !valid) and backends.length > 0 # Retry with next backend tryBackend() else # Was valid or last backend cb err, valid tryBackend() detectAnonymousUser: (user, cb) => backends = new Array(@backends...) tryBackend = => backend = backends.shift() backend.detectAnonymousUser user, (err, isAnonymous) -> if err and backends.length > 0 # Retry with next backend tryBackend() else # Was valid or last backend cb err, isAnonymous tryBackend() ## # Decides whether operations can be served from the local DB by an # Operation, or to go remote class exports.Router constructor: (@model, checkCreateNode, @autosubscribeNewUsers) -> @remote = new RemoteRouter(@) @operations = require('./local/operations') @operations.setModel model @operations.checkCreateNode = checkCreateNode @addBackend = @remote.addBackend @isUserOnline = @remote.isUserOnline @authorizeFor = @remote.authorizeFor # Keep them for clean-up upon unavailable presence @anonymousUsers = {} ## # Block any requests on a node that has sync pending. The # alternative was: push notifications after sync, but that may # be unnecessary in many cases. @perNodeQueue = {} detectUserType: (user, cb) -> if user.indexOf("@") >= 0 # '@' in JID means it's a user or anonymous if @anonymousUsers.hasOwnProperty(user) and @anonymousUsers[user] cb null, true else @remote.detectAnonymousUser user, (err, isAnonymous) => if err and user.indexOf('@anon') >= 0 # Can't make sure? Fall back to stupid heuristics: cb null, 'anonymous' else if isAnonymous cb null, 'anonymous' else cb null, 'user' else cb null, 'server' ## # If not, we may still find ourselves through disco isLocallySubscribed: (node, cb) -> @model.nodeExists node, cb runLocally: (opts, cb) -> if opts.node and @perNodeQueue.hasOwnProperty(opts.node) @perNodeQueue[opts.node].push => @runLocally opts, cb else @operations.run @, opts, cb runRemotely: (opts, cb) -> @remote.run opts, cb run: (opts, cb) -> logger.trace "Router.run #{inspect opts}" @runCheckAnonymous opts, (err) => if err return cb? err logger.info "run #{opts.operation} #{opts.node} by #{opts.actor}(#{opts.actorType})/#{opts.sender}" unless opts.node? @runLocally opts, (err, results) => cb? err, results # Auto-subscribe new user if not err? and opts.operation is 'register-user' and @autosubscribeNewUsers? for userid in @autosubscribeNewUsers for type in nodeTypes req = Object.create(opts) req.operation = 'subscribe-node' req.node = "/user/#{userid}/#{type}" req.writes = yes @run req else if opts.writes # Request to mess with data, run remotely @runRemotely opts, (err, results) => if err and err.constructor is errors.SeeLocal # Remote discovered ourselves @runLocally opts, cb else # result/error from remote cb? err, results else @isLocallySubscribed opts.node, (err, locallySubscribed) => if locallySubscribed @runLocally opts, cb else # run remotely @runRemotely opts, (err, results) -> if err?.constructor is errors.SeeLocal # Is not locally present but discovery # returned ourselves. cb? new errors.NotFound("Node does not exist here") else cb? err, results runCheckAnonymous: (opts, cb) -> # May have been set by pubsub_server or previous recursion # (see tail of this method) if opts.actorType unless opts.writes # No writing request, no need to check further... cb() else # Disallow any writing requests except # (un)subscribing, for which we do explicit clean-up # upon unavailable presence. if opts.actorType is 'anonymous' if opts.operation is 'subscribe-node' or opts.operation is 'unsubscribe-node' if @isUserOnline opts.actor # Allow but track @anonymousUsers[opts.actor] = true cb() else cb new errors.Forbidden("Send presence to be able to temporarily subscribe.") else # Disallow cb new errors.NotAllowed("You are anonymous. You are legion.") else # Not anonymous, allow everything cb() else @detectUserType opts.actor, (err, type) => opts.actorType = type or 'user' # Finally recurse: @runCheckAnonymous opts, cb pushData: (opts, cb) -> opts.operation = 'push-inbox' @runLocally opts, cb notify: (notification) -> @remote.notify notification ## # # Also goes to the backend to sync all nodes setupSync: (jobs) -> sync.setup jobs @model.getAllNodes (err, nodes) => if err logger.error "getAllNodes #{err.stack or err}" return for node in nodes @syncNode node, (err) -> if err logger.error "syncNode #{node} error: #{err}" # TODO: once batchified, syncQueue.drain = ... ## # Synchronize node from remote syncNode: (node, cb) -> unless @perNodeQueue.hasOwnProperty(node) @perNodeQueue[node] = [] sync.syncNode @, @model, node, (err) => blocked = @perNodeQueue[node] or [] delete @perNodeQueue[node] blocked.forEach (cb1) -> cb1() cb(err) ## # Batchified by walking RSM: the next result set page will be # requested after all nodes have been processed. syncServer: (server, cb) -> opts = operation: 'retrieve-user-subscriptions' jid: server opts.rsm = new RSM() rsmWalk (nextOffset, cb2) => opts.rsm.after = nextOffset @runRemotely opts, (err, results) => if err return cb2 err async.forEach results, (subscription, cb3) => unless subscription.node # Weird, skip; return cb3() user = getNodeUser(subscription.node) unless user # Weird, skip; return cb3() @authorizeFor server, user, (err, valid) => if err or !valid logger.warn((err and err.stack) or err or "Cannot sync #{subscription.node} from unauthorized server #{server}" ) cb3() else @syncNode subscription.node, (err) -> # Ignore err, a single node may fail cb3() , (err) -> cb2 err, results?.rsm?.last , cb # No need to detectAnonymousUser() again: # * Disco info may not be available anymore # * If missing from anonymousUsers no clean-up is needed onUserOffline: (user) -> if @anonymousUsers.hasOwnProperty(user) and @anonymousUsers[user] delete @anonymousUsers[user] req = operation: 'remove-user' actor: user sender: user runLocally req, -> buddycloud-buddycloud-server-7889586/src/rsm_walk.coffee000066400000000000000000000012551202241346400233140ustar00rootroot00000000000000## # @param iter(nextOffset, cb(err, lastOffset)) # @param cb(err) module.exports = (iter, cb) -> # for detecting RSM loops seenOffsets = {} walk = (offset) -> iter offset, (err, lastOffset) -> if err return cb err if lastOffset # Remote supports RSM, walk: if seenOffsets.hasOwnProperty(lastOffset) cb new Error("RSM offset loop detected: #{offset} already seen") else seenOffsets[lastOffset] = true walk lastOffset else # No RSM support, done: cb() # Go walk() buddycloud-buddycloud-server-7889586/src/sync.coffee000066400000000000000000000125431202241346400224530ustar00rootroot00000000000000## # Called by router to ensure per-node locking logger = require('./logger').makeLogger 'sync' async = require('async') RSM = require('./xmpp/rsm') NS = require('./xmpp/ns') rsmWalk = require('./rsm_walk') errors = require('./errors') class Synchronization constructor: (@router, @node) -> @request = { operation: @operation node: @node writes: true } run: (t, cb) -> @runRequest (err, results) => if err return cb err @reset t, (err) => if err return cb err @writeResults t, results, cb runRequest: (cb) -> @router.runRemotely @request, cb class ConfigSynchronization extends Synchronization operation: 'browse-node-info' reset: (t, cb) -> t.resetConfig(@node, cb) writeResults: (t, results, cb) -> if results.config t.setConfig(@node, results.config, cb) else cb(new Error("No pubsub meta data form found")) ## # Walks items with RSM class PaginatedSynchronization extends Synchronization constructor: -> super @request.rsm = new RSM.RSM() run: (t, cb) -> rsmWalk (offset, cb2) => logger.debug "PaginatedSynchronization walk #{offset}" @request.rsm.after = offset @runRequest (err, results) => logger.debug "ranRequest #{err or results?.length}" if err return cb2 err go = (err) => if err return cb2 err @writeResults t, results, (err) => if err return cb2 err nextOffset = results.rsm?.last cb2 null, nextOffset # reset() only after 1st successful result unless @resetted # (excuse my grammar) @resetted = true @reset t, go else go() , cb class ItemsSynchronization extends PaginatedSynchronization reset: (t, cb) -> t.resetItems(@node, cb) operation: 'retrieve-node-items' writeResults: (t, results, cb) -> async.forEach results, (item, cb2) => t.writeItem @node, item.id, item.el, cb2 , cb class SubscriptionsSynchronization extends PaginatedSynchronization reset: (t, cb) -> # Preserve the subscriptions listeners that are local, which # is only a small subset of the global subscriptions to a # remote node. t.resetSubscriptions @node, (err, @userListeners) => cb err operation: 'retrieve-node-subscriptions' run: (t, cb) -> super t, (err) => if err return cb err cb2 = => cb.apply @, arguments # After all subscriptions have synced, check if any local # subscriptions are left: t.getNodeListeners @node, (err, listeners) => if err logger.error "Cannot get node listeners: #{err.stack or err}" unless listeners? and listeners.length > 0 t.purgeNode @node, cb2 cb2() # TODO: none left? remove whole node. writeResults: (t, results, cb) -> async.forEach results, (item, cb2) => listener = @userListeners[item.user] t.setSubscription @node, item.user, listener, item.subscription, cb2 , cb class AffiliationsSynchronization extends PaginatedSynchronization reset: (t, cb) -> async.waterfall [ # Only AffiliationsSynchronization happens after # SubscriptionsSynchronization which may have purged the # node. t.validateNode @node , (cb2) => t.resetAffiliations(@node, cb2) ], cb operation: 'retrieve-node-affiliations' writeResults: (t, results, cb) -> async.forEach results, (item, cb2) => t.setAffiliation @node, item.user, item.affiliation, cb2 , cb # TODO: move queueing here syncQueue = async.queue (task, cb) -> { model, router, node, syncClass } = task synchronization = new syncClass(router, node) model.transaction (err, t) -> if err logger.error "sync transaction: #{err.stack or err}" return cb err synchronization.run t, (err) -> if err t.rollback -> cb err else t.commit (err) -> cb err , 1 # TODO: emit notifications for all changed things? exports.syncNode = (router, model, node, cb) -> logger.debug "syncNode #{node}" async.forEachSeries [ ConfigSynchronization, ItemsSynchronization, SubscriptionsSynchronization, AffiliationsSynchronization ] , (syncClass, cb2) -> syncQueue.push { router, model, node, syncClass }, cb2 , (err) -> if err and err.constructor is errors.SeeLocal logger.debug "Omitted syncing local node #{node}" cb?() else if err logger.error "sync #{node}: #{err}" cb?(err) else logger.info "synced #{node}" cb?() ## # Setup synchronization queue exports.setup = (jobs) -> syncQueue.concurrency = jobs buddycloud-buddycloud-server-7889586/src/tombstone.coffee000066400000000000000000000016041202241346400235050ustar00rootroot00000000000000logger = require('./logger').makeLogger 'tombstone' {Element} = require('node-xmpp') NS_AS = "http://activitystrea.ms/spec/1.0/" NS_AT = "http://purl.org/atompub/tombstones/1.0" NS_ATOM = "http://www.w3.org/2005/Atom" NS_THR = "http://purl.org/syndication/thread/1.0" exports.makeTombstone = (item) -> ref = item.getChild('link', NS_ATOM).attrs.href now = new Date().toISOString() tsEl = new Element('deleted-entry', xmlns: NS_AT, ref: ref, when: now). c("updated").t(now).up() children = [] children.push(item.getChild(name, NS_ATOM)?.attr('xmlns', NS_ATOM)) for name in ['id', 'link', 'published'] children.push(item.getChild('in-reply-to', NS_THR)?.attr('xmlns', NS_THR)) children.push(item.getChild(name, NS_AS)?.attr('xmlns', NS_AS)) for name in ['object', 'verb'] for child in children tsEl = tsEl.cnode(child).up() if child? return tsEl buddycloud-buddycloud-server-7889586/src/util.coffee000066400000000000000000000007451202241346400224550ustar00rootroot00000000000000nodeRegexp = /^\/user\/([^\/]+)\/?(.*)/ exports.getNodeUser = (node) -> unless node return null nodeRegexp.exec(node)?[1] exports.getNodeType = (node) -> unless node return null nodeRegexp.exec(node)?[2] exports.getUserDomain = (user) -> if user.indexOf('@') >= 0 user.substr(user.indexOf('@') + 1) else user exports.nodeTypes = [ "posts", "status", "geo/previous", "geo/current", "geo/next", "subscriptions" ]; buddycloud-buddycloud-server-7889586/src/xmpp/000077500000000000000000000000001202241346400213055ustar00rootroot00000000000000buddycloud-buddycloud-server-7889586/src/xmpp/backend_pubsub.coffee000066400000000000000000000303451202241346400254320ustar00rootroot00000000000000logger = require('../logger').makeLogger 'xmpp/backend_pubsub' {inspect} = require('util') {EventEmitter} = require('events') async = require('async') xmpp = require('node-xmpp') Notifications = require('./pubsub_notifications') pubsubClient = require('./pubsub_client') errors = require('../errors') NS = require('./ns') forms = require('./forms') {getNodeUser, getUserDomain} = require('../util') ## # Initialize with XMPP connection class exports.PubsubBackend extends EventEmitter constructor: (@conn) -> @conn.on 'message', (args...) => @onMessage_(args...) @disco = new BuddycloudDiscovery(@conn) @authorizeFor = @disco.authorizeFor @detectAnonymousUser = @disco.detectAnonymousUser getMyJids: -> [@conn.jid] isUserOnline: (user) -> @conn.getOnlineResources(user).length > 0 run: (router, opts, cb) -> if opts.jid? # Target server already known reqClass = pubsubClient.byOperation(opts.operation) unless reqClass return cb(new errors.FeatureNotImplemented("Operation #{opts.operation} not implemented for remote pubsub")) req = new reqClass @conn, opts, (err, result) -> if err cb err else # Successfully done at remote if (localPushData = req.localPushData?()) router.pushData localPushData, (err) -> cb err, result else cb null, result else # Discover first user = getNodeUser opts.node unless user return cb new errors.NotFound("Unrecognized node form") logger.debug "PubsubBackend.run user: #{user}, opts: #{inspect opts}" @disco.findService user, (err, service) => if err return cb err if @getMyJids().indexOf(service) >= 0 # is local, return to router return cb new errors.SeeLocal() else opts2 = Object.create(opts) opts2.jid = service # Target server now known, recurse: @run router, opts2, cb notify: (opts) -> if opts.length < 1 # Avoid empty notifications return notification = Notifications.make(opts) listener = opts.listener try if not opts.replay and listener and listener.indexOf("@") >= 0 # is user? send to all resources... # # except for replays, which are requested by a certain # fullJID for onlineJid in @conn.getOnlineResources listener logger.info "notifying client #{onlineJid} for #{opts.node}" @conn.send notification.toStanza(@conn.jid, onlineJid) else if listener # other component (inbox)? just send out logger.info "notifying #{listener} for #{opts.node}" @conn.send notification.toStanza(@conn.jid, listener) catch e if e.constructor is errors.MaxStanzaSizeExceeded and opts.length > 1 # FIXME: a notification may have been sent already to a previous shorter listener jid copyProps = (opts_) -> ['type', 'node', 'user', 'listener', 'replay', 'queryId'].forEach (prop) -> opts_[prop] = opts[prop] pivot = Math.floor(opts.length / 2) opts1 = opts.slice(0, pivot) copyProps opts1 opts2 = opts.slice(pivot, opts.length) copyProps opts2 logger.warn "MaxStanzaSizeExceeded: split notification from #{opts.length} into #{opts1.length}+#{opts2.length}" @notify opts1 @notify opts2 else throw e # # # TODO: encapsulate XMPP protocol cruft onMessage_: (message) -> sender = new xmpp.JID(message.attrs.from).bare().toString() updates = [] for child in message.children unless child.is # Ignore any text child continue # if child.is("event", NS.PUBSUB_EVENT) child.children.forEach (child) -> unless child.is # No element, but text return node = child.attrs.node unless node return if child.is('items') items = [] for itemEl in child.getChildren('item') item = el: itemEl.children.filter((itemEl) -> itemEl.hasOwnProperty('children') )[0] if itemEl.attrs.id item.id = itemEl.attrs.id if item.el items.push item updates.push type: 'items' node: node items: items if child.is('subscription') updates.push type: 'subscription' node: node user: child.attrs.jid subscription: child.attrs.subscription if child.is('affiliation') updates.push type: 'affiliation' node: node user: child.attrs.jid affiliation: child.attrs.affiliation if child.is('configuration') xEl = child.getChild('x', NS.DATA) form = xEl and forms.fromXml(xEl) config = form and forms.formToConfig(form) if config updates.push type: 'config' node: node config: config # if child.is("you-missed-something", NS.BUDDYCLOUD_V1) @emit 'syncNeeded', sender # data form if child.is("x", NS.DATA) form = forms.fromXml(child) if form.type is 'form' and form.getFormType() is NS.PUBSUB_SUBSCRIBE_AUTHORIZATION # authorization prompt node = form.get('pubsub#node') user = form.get('pubsub#subscriber_jid') nodeOwner = getNodeUser node if nodeOwner @emit 'authorizationPrompt', { node, user, sender, nodeOwner } else if form.type is 'submit' and form.getFormType() is NS.PUBSUB_SUBSCRIBE_AUTHORIZATION # authorization confirm node = form.get('pubsub#node') user = form.get('pubsub#subscriber_jid') allow = (form.get('pubsub#allow') is 'true') actor = form.get('buddycloud#actor') or sender @emit 'authorizationConfirm', { node, user, allow, actor, sender } # Which nodes' updates pertain our local cache? async.filter updates, (update, cb) => user = getNodeUser(update.node) unless user return cb false # Security: authorize @authorizeFor sender, user, (err, valid) -> cb not err and valid , (updates) => # Add a listener to each subscription update async.map updates, (update, cb2) => if update.type is 'subscription' @disco.findService update.user, (err, service) => if err cb2 err, null else # If remote user, listener = sender. If local user, listener = user. update.listener = if @getMyJids().indexOf(service) >= 0 then update.user else sender cb2 null, update else cb2 null, update , (err, updates) => if err logger.error "incoming message: #{err.stack or err.message or err}" return if updates? and updates.length > 0 updates.sender = sender updates.actor = sender # Apply pushed updates @emit 'notificationPush', updates class BuddycloudDiscovery constructor: (@conn) -> @infoCache = new RequestCache (id, cb) => new pubsubClient.DiscoverInfo(@conn, { jid: id }, cb) @itemsCache = new RequestCache (id, cb) => logger.debug "discover items of #{id}" new pubsubClient.DiscoverItems(@conn, { jid: id }, cb) authorizeFor: (sender, actor, cb) => @itemsCache.get getUserDomain(actor), (err, items) -> if err return cb err valid = items?.some (item) -> item.jid is sender logger.debug "authorizing #{sender} for #{actor}: #{valid}" cb null, valid findService: (user, cb) => domain = getUserDomain(user) @itemsCache.get domain, (err, items) => if err return cb err # Respond on earliest, or if nothing to do pending = 1 resultSent = false done = () -> pending-- # No items left, but no result yet? if pending < 1 and not resultSent resultSent = true cb new errors.NotFound("No pubsub channel service discovered for #{user}") items.forEach (item) => @infoCache.get item.jid, (err, result) -> for identity in result?.identities or [] if identity.category is "pubsub" and identity.type is "channels" and not resultSent # Found one! resultSent = true logger.debug "found service for #{user}: #{item.jid}" cb null, item.jid done() pending++ # `pending' initialized with 1, to not miss the items=[] case done() ## # @param user {String} Bare JID # @param cb {Function} callback(error, isAnonymous) detectAnonymousUser: (user, cb) => @infoCache.get user, (err, result) -> isAnonymous = false results?.identities?.forEach (identity) -> isAnonymous ||= identity.category is "account" and identity.type is "anonymous" cb err, isAnonymous class RequestCache cacheTimeout: 30 * 1000 constructor: (@getter) -> @entries = {} get: (id, cb) -> unless @entries.hasOwnProperty(id) logger.trace "Cache miss for #{id}" @entries[id] = queued: [cb] # Go fetch @getter id, (err, results) => queued = @entries[id].queued @entries[id] = { err, results } # flush after timeout setTimeout => delete @entries[id] , @cacheTimeout # respond to requests for cb1 in queued cb1 err, results else if @entries[id].queued? # Already fetching logger.trace "Queued for #{id}" @entries[id].queued.push cb else # Result already present logger.trace "Cache hit for #{id}" process.nextTick => cb @entries[id].err, @entries[id].results buddycloud-buddycloud-server-7889586/src/xmpp/connection.coffee000066400000000000000000000213451202241346400246220ustar00rootroot00000000000000### # Encapsulate XMPP Component connection # # * Provide RPC interface # * Track presence ### logger = require('../logger').makeLogger 'xmpp/connection' xmpp = require("node-xmpp") {EventEmitter} = require('events') errors = require("../errors") NS = require('./ns') IQ_TIMEOUT = 10000 MAX_STANZA_SIZE = 65000 # 535 bytes spare room MISSED_INTERVAL = 120 * 1000 ## # XMPP Component Connection, # encapsulates the real node-xmpp connection class exports.Connection extends EventEmitter constructor: (config) -> # For iq response tracking (@sendIq): @lastIqId = 0 @iqCallbacks = {} # For sending @missedServerTimeouts = {} # For presence tracking: @onlineResources = {} # Setup connection: @jid = config.jid # Anyone wants reconnecting, regardless of the config file: config.reconnect = true @conn = new xmpp.Component(config) @conn.on "error", (e) -> logger.error e @conn.on "online", => @emit "online" @conn.on "stanza", (stanza) => # Just debug output: logger.trace "<< #{stanza.toString()}" from = stanza.attrs.from switch stanza.name when "iq" switch stanza.attrs.type when "get", "set" # IQ requests @_handleIq stanza when "result" , "error" # IQ replies @iqCallbacks.hasOwnProperty(stanza.attrs.id) cb = @iqCallbacks[stanza.attrs.id] delete @iqCallbacks[stanza.attrs.id] if stanza.attrs.type is 'error' # TODO: wrap into new Error(...) cb and cb(new errors.StanzaError(stanza)) else cb and cb(null, stanza) when "presence" @_handlePresence stanza when "message" if stanza.attrs.type isnt "error" @_handleMessage stanza else if from.indexOf('@') < 0 unless @missedServerTimeouts.hasOwnProperty(from) # We've got an error back from a component, # which are not subject to presence and must # be probed manually. @missedServerTimeouts[from] = setTimeout => delete @missedServerTimeouts[from] @send new xmpp.Element('message', type: 'headline' from: @jid to: from ).c('you-missed-something', xmlns: NS.BUDDYCLOUD_V1) , MISSED_INTERVAL send: (stanza) -> stanza = stanza.root() unless stanza.attrs.from stanza.attrs.from = @jid # Count serialized length (do not actually allocate yet) bytes = 0 stanza.root().write (s) -> bytes += Buffer.byteLength(s) if bytes > MAX_STANZA_SIZE logger.warn "Stanza with #{bytes} bytes: #{stanza.toString().substr(0, 1024)}..." throw new errors.MaxStanzaSizeExceeded(bytes) # Serialize once (for logging and sending) s = stanza.toString() logger.trace ">> #{s}" if stanza.attrs.to is @jid # Loopback short-cut @conn.emit 'stanza', stanza else @conn.send s ## # @param {Function} cb: Called with (errorStanza, resultStanza) sendIq: (iq, cb) -> # Generate a new unique request id @lastIqId += Math.ceil(Math.random() * 999) id = iq.attrs.id = "#{@lastIqId}" # Set up timeout timeout = setTimeout () => delete @iqCallbacks[id] cb(new Error('timeout')) , IQ_TIMEOUT # Wrap callback to cancel timeout in case of success @iqCallbacks[id] = (error, result) -> clearTimeout timeout cb(error, result) # Finally, send out: @send iq _handleMessage: (message) -> @emit 'message', message ## # Returns all full JIDs we've seen presence from for a bare JID # @param {String} bareJid getOnlineResources: (bareJid) -> if @onlineResources.hasOwnProperty(bareJid) @onlineResources[bareJid].map (resource) -> jid = new xmpp.JID(bareJid) jid.resource = resource jid.toString() else [] subscribePresence: (jid) -> unless @onlineResources.hasOwnProperty(jid) @send new xmpp.Element("presence", to: jid type: "subscribe" ) _handlePresence: (presence) -> jid = new xmpp.JID(presence.attrs.from) bareJid = jid.bare().toString() resource = jid.resource rmUserResource = () => if @onlineResources[bareJid]? # Remove specific resource @onlineResources[bareJid] = @onlineResources[bareJid].filter (r) -> r != resource # No resources left? if @onlineResources[bareJid].length < 1 delete @onlineResources[bareJid] @emit 'userOffline', bareJid switch presence.attrs.type when "subscribe" # User subscribes to us @send new xmpp.Element("presence", from: presence.attrs.to to: presence.attrs.from id: presence.attrs.id type: "subscribed" ) when "unsubscribe" # User unsubscribes from us @send new xmpp.Element("presence", from: presence.attrs.to to: presence.attrs.from id: presence.attrs.id type: "unsubscribed" ) when "probe" # We are always available @send new xmpp.Element("presence", from: presence.attrs.to to: presence.attrs.from id: presence.attrs.id ).c("status").t("buddycloud-server") when "subscribed" then # User allowed us subscription when "unsubscribed" then # User denied us subscription when "error" # Error from a bare JID? unless resource # Remove all resources delete @onlineResources[bareJid] else rmUserResource() when "unavailable" rmUserResource() else # available unless bareJid of @onlineResources @onlineResources[bareJid] = [] @emit 'userOnline', bareJid if @onlineResources[bareJid].indexOf(resource) < 0 @onlineResources[bareJid].push resource _handleIq: (stanza) -> ## # Prepare stanza reply hooks # Safety first: replied = false replying = () -> if replied throw 'Sending additional iq reply' # Interface for stanza.reply = (child) => replying() reply = new xmpp.Element("iq", from: stanza.attrs.to to: stanza.attrs.from id: stanza.attrs.id or "" type: "result" ) reply.cnode(child.root()) if child?.children? @send reply replied = true # Interface for stanza.replyError = (err) => replying() reply = new xmpp.Element("iq", from: stanza.attrs.to to: stanza.attrs.from id: stanza.attrs.id or "" type: "error" ) if err.xmppElement reply.cnode err.xmppElement() else reply.c("error", type: "cancel"). c("text"). t('' + err.message) @send reply ## # Fire handler, done. @emit 'iqRequest', stanza probePresence: (user) -> sendPresence = (type) => @conn.send new xmpp.Element('presence', type: type from: @conn.jid to: user ) sendPresence 'probe' buddycloud-buddycloud-server-7889586/src/xmpp/forms.coffee000066400000000000000000000072151202241346400236110ustar00rootroot00000000000000logger = require('../logger').makeLogger 'xmpp/forms' xmpp = require('node-xmpp') NS = require('./ns') class exports.Field constructor: (@var='', @type='text-single', @label, value) -> @values = [] if value @values.push value toXml: -> fieldAttrs = {} fieldAttrs.var ?= @var fieldAttrs.label ?= @label fieldAttrs.type ?= @type fieldEl = new xmpp.Element('field', fieldAttrs) addValue = (value) -> fieldEl.c('value'). t(value) if not /-multi$/.test(@type) and @values[0]? addValue @values[0] else @values.forEach addValue fieldEl class exports.Form constructor: (@type='result', formType) -> @fields = [] if formType @fields.push new exports.Field('FORM_TYPE', 'hidden', undefined, formType) getFormType: -> for field in @fields if field.var is 'FORM_TYPE' return field.values[0] null get: (fieldVar) -> for field in @fields if field.var is fieldVar return field.values[0] null addField: (var_, type, label, value) -> @fields.push new exports.Field(var_, type, label, value) toXml: -> formEl = new xmpp.Element('x', xmlns: NS.DATA type: @type ) if @title? formEl.c('title'). t(@title) if @instructions? formEl.c('instructions'). t(@instructions) @fields.forEach (field) -> formEl.cnode field.toXml() formEl exports.fromXml = (xEl) -> unless xEl.is('x', NS.DATA) logger.warn "Importing non-form: #{xEl.toString()}" form = new exports.Form(xEl.attrs.type) form.fields = xEl.getChildren("field").map (fieldEl) -> field = new exports.Field( fieldEl.attrs.var, fieldEl.attrs.type, fieldEl.attrs.label ) field.values = fieldEl.getChildren("value").map (valueEl) -> valueEl.getText() field form exports.configToForm = (config, type, formType) -> form = new exports.Form(type, formType) addField = (key, fvar, label) -> if config[key] form.fields.push new exports.Field(fvar, 'text-single', label, config[key]) addField 'title', 'pubsub#title', 'A short name for the node' addField 'description', 'pubsub#description', 'A description of the node' addField 'accessModel', 'pubsub#access_model', 'Who may subscribe and retrieve items' addField 'publishModel', 'pubsub#publish_model', 'Who may publish items' addField 'defaultAffiliation', 'buddycloud#default_affiliation', 'What role do new subscribers have?' addField 'creationDate', 'pubsub#creation_date', 'Creation date' addField 'channelType', 'buddycloud#channel_type', 'Type of channel' form exports.formToConfig = (form) -> config = null if (form.getFormType() is NS.PUBSUB_NODE_CONFIG or form.getFormType() is NS.PUBSUB_META_DATA) and (form.type is 'submit' or form.type is 'result') config = {} config.title ?= form.get('pubsub#title') config.description ?= form.get('pubsub#description') config.accessModel ?= form.get('pubsub#access_model') config.publishModel ?= form.get('pubsub#publish_model') config.defaultAffiliation ?= form.get('buddycloud#default_affiliation') config.creationDate ?= form.get('pubsub#creation_date') config.channelType ?= form.get('buddycloud#channel_type') config buddycloud-buddycloud-server-7889586/src/xmpp/ns.coffee000066400000000000000000000014771202241346400231070ustar00rootroot00000000000000module.exports = PUBSUB: "http://jabber.org/protocol/pubsub" PUBSUB_EVENT: "http://jabber.org/protocol/pubsub#event" PUBSUB_OWNER: "http://jabber.org/protocol/pubsub#owner" PUBSUB_NODE_CONFIG: "http://jabber.org/protocol/pubsub#node_config" PUBSUB_META_DATA: "http://jabber.org/protocol/pubsub#meta-data" PUBSUB_SUBSCRIBE_AUTHORIZATION: "http://jabber.org/protocol/pubsub#subscribe_authorization" DISCO_INFO: "http://jabber.org/protocol/disco#info" DISCO_ITEMS: "http://jabber.org/protocol/disco#items" DATA: "jabber:x:data" REGISTER: "jabber:iq:register" COMMANDS: "http://jabber.org/protocol/commands" RSM: "http://jabber.org/protocol/rsm" BUDDYCLOUD_V1: "http://buddycloud.org/v1" MAM: "urn:xmpp:mam:tmp" FORWARD: "urn:xmpp:forward:0" VERSION: "jabber:iq:version" buddycloud-buddycloud-server-7889586/src/xmpp/pubsub_client.coffee000066400000000000000000000244011202241346400253150ustar00rootroot00000000000000logger = require('../logger').makeLogger 'xmpp/pubsub_client' xmpp = require('node-xmpp') async = require('async') NS = require('./ns') errors = require('../errors') forms = require('./forms') RSM = require('./rsm') class Request constructor: (conn, @opts, cb) -> @myJid = conn.jid iq = @requestIq().root() iq.attrs.to = @opts.jid conn.sendIq iq, (err, replyStanza) => if err # wrap child return cb err result = null err = null try result = @decodeReply replyStanza catch e logger.error e.stack err = e cb err, result requestIq: -> throw new TypeError("Unimplemented request") decodeReply: (stanza) -> throw new TypeError("Unimplemented reply") ### # XEP-0030: Service Discovery ### class DiscoverRequest extends Request xmlns: undefined requestIq: -> queryAttrs = xmlns: @xmlns if @opts.node? queryAttrs.node = @opts.node new xmpp.Element('iq', type: 'get'). c('query', queryAttrs) decodeReply: (stanza) -> @results = [] queryEl = stanza?.getChild('query', @xmlns) if queryEl for child in queryEl.children unless typeof child is 'string' @decodeReplyEl child @results # Can add to @results decodeReplyEl: (el) -> class exports.DiscoverItems extends DiscoverRequest xmlns: NS.DISCO_ITEMS decodeReplyEl: (el) -> if el.is('item', @xmlns) and el.attrs.jid? result = { jid: el.attrs.jid } if el.attrs.node result.node = el.attrs.node @results.push result class exports.DiscoverInfo extends DiscoverRequest xmlns: NS.DISCO_INFO decodeReplyEl: (el) -> @results.identities ?= [] @results.features ?= [] @results.config ?= null if el.is('identity', @xmlns) @results.identities.push name: el.attrs.name category: el.attrs.category type: el.attrs.type else if el.is('feature', @xmlns) @results.features.push el.attrs.var else if el.is('x', NS.DATA) form = forms.fromXml(el) if form.getFormType() is NS.PUBSUB_META_DATA @results.config = forms.formToConfig(form) ## # XEP-0060 ## class PubsubRequest extends Request xmlns: NS.PUBSUB requestIq: -> pubsubEl = new xmpp.Element('iq', type: @iqType()). c('pubsub', xmlns: @xmlns) pubsubEl.cnode(@pubsubChild().root()) if @opts.actor actorEl = pubsubEl.c('actor', xmlns: NS.BUDDYCLOUD_V1) actorEl.attrs.type ?= @opts.actorType actorEl.t(@opts.actor) if @opts.rsm pubsubEl.cnode @opts.rsm.toXml() pubsubEl.up() iqType: -> throw new TypeError("Unimplemented request") pubsubChild: -> throw new TypeError("Unimplemented reply") decodeReply: (stanza) -> @results = [] pubsubEl = stanza?.getChild('pubsub', @xmlns) if pubsubEl? if @decodeReplyEl? for child in pubsubEl.children unless typeof child is 'string' @decodeReplyEl child # With fromRemote=true so that remote RSM information # won't be overwritten by RSM.setReplyInfo(): @results.rsm = RSM.fromXml(pubsubEl.getChild('set', NS.RSM), true) @results class CreateNode extends PubsubRequest iqType: -> 'set' pubsubChild: -> new xmpp.Element('create', node: @opts.node) class Publish extends PubsubRequest iqType: -> 'set' pubsubChild: -> publishEl = new xmpp.Element('publish', node: @opts.node) for item in @opts.items itemAttrs = {} itemAttrs.id ?= item.id publishEl.c('item', itemAttrs). cnode(item.el) publishEl class RetractItems extends PubsubRequest iqType: -> 'set' pubsubChild: -> publishEl = new xmpp.Element('retract', node: @opts.node) for item in @opts.items publishEl.c('item', { id: item.id }) publishEl class Subscribe extends PubsubRequest iqType: -> 'set' pubsubChild: -> new xmpp.Element('subscribe', node: @opts.node, jid: @opts.actor) decodeReplyEl: (el) -> if el.is('subscription', @xmlns) and el.attrs.node is @opts.node @results.user ?= el.attrs.jid or @opts.actor @results.subscription ?= el.attrs.subscription or 'subscribed' localPushData: -> if @results.subscription is 'subscribed' [{ type: 'subscription' node: @opts.node user: @results.user listener: @opts.sender subscription: @results.subscription }] else [] class Unsubscribe extends PubsubRequest iqType: -> 'set' pubsubChild: -> new xmpp.Element('unsubscribe', node: @opts.node) localPushData: -> [{ type: 'subscription' node: @opts.node user: @opts.actor subscription: 'none' }] class RetrieveItems extends PubsubRequest iqType: -> 'get' pubsubChild: -> new xmpp.Element('items', node: @opts.node) decodeReplyEl: (el) -> if el.is('items', @xmlns) for itemEl in el.getChildren('item') @results.push id: itemEl.attrs.id el: itemEl.children.filter((child) -> child isnt 'string' )[0] class RetrieveUserSubscriptions extends PubsubRequest iqType: -> 'get' pubsubChild: -> new xmpp.Element('subscriptions') decodeReplyEl: (el) -> if el.is('subscriptions', @xmlns) for subscriptionEl in el.getChildren('subscription') subscription = {} subscription.user ?= subscriptionEl.attrs.jid subscription.subscription ?= subscriptionEl.attrs.subscription subscription.node ?= subscriptionEl.attrs.node @results.push subscription class PubsubOwnerRequest extends PubsubRequest xmlns: NS.PUBSUB_OWNER class RetrieveNodeSubscriptions extends PubsubOwnerRequest iqType: -> 'get' pubsubChild: -> new xmpp.Element('subscriptions', node: @opts.node) decodeReplyEl: (el) -> if el.is('subscriptions', @xmlns) for subscriptionEl in el.getChildren('subscription') @results.push user: subscriptionEl.attrs.jid subscription: subscriptionEl.attrs.subscription class RetrieveNodeAffiliations extends PubsubOwnerRequest iqType: -> 'get' pubsubChild: -> new xmpp.Element('affiliations', node: @opts.node) decodeReplyEl: (el) -> if el.is('affiliations', @xmlns) for affiliationEl in el.getChildren('affiliation') @results.push user: affiliationEl.attrs.jid affiliation: affiliationEl.attrs.affiliation class ManageNodeSubscriptions extends PubsubOwnerRequest iqType: -> 'set' pubsubChild: -> subscriptionsEl = new xmpp.Element('subscriptions', node: @opts.node) for subscription in @opts.subscriptions subscriptionsEl.c 'subscription', jid: subscription.user subscription: subscription.subscription subscriptionsEl class ManageNodeAffiliations extends PubsubOwnerRequest iqType: -> 'set' pubsubChild: -> affiliationsEl = new xmpp.Element('affiliations', node: @opts.node) for affiliation in @opts.affiliations affiliationsEl.c 'affiliation', jid: affiliation.user affiliation: affiliation.affiliation affiliationsEl class RetrieveNodeConfiguration extends PubsubOwnerRequest iqType: -> 'get' pubsubChild: -> new xmpp.Element('configure', node: @opts.node) decodeReplyEl: (el) -> if el.is('configure', @xmlns) xEl = el.getChild('x', NS.DATA) form = xEl and forms.fromXml(xEl) @results.config ?= form and forms.formToConfig(form) class ManageNodeConfiguration extends PubsubOwnerRequest iqType: -> 'set' pubsubChild: -> new xmpp.Element('configure', node: @opts.node). cnode(forms.configToForm(@opts.config, 'submit', NS.PUBSUB_NODE_CONFIG).toXml()) # No but a without expected reply class AuthorizeSubscriber constructor: (conn, @opts, cb) -> conn.send @makeStanza() cb() makeStanza: -> form = new forms.Form('submit', NS.PUBSUB_SUBSCRIBE_AUTHORIZATION) form.addField 'pubsub#node', 'text-single', 'Node', @opts.node form.addField 'pubsub#subscriber_jid', 'jid-single', 'Subscriber Address', @opts.user form.addField 'pubsub#allow', 'boolean', 'Allow?', (if @opts.allow then 'true' else 'false') form.addField 'buddycloud#actor', 'jid-single', 'Authorizing actor', @opts.actor new xmpp.Element('message', type: 'headline' to: @opts.jid ).cnode form.toXml() REQUESTS = 'browse-node-info': exports.DiscoverInfo 'browse-info': exports.DiscoverInfo 'create-node': CreateNode 'publish-node-items': Publish 'subscribe-node': Subscribe 'unsubscribe-node': Unsubscribe 'retrieve-node-items': RetrieveItems 'retract-node-items': RetractItems 'retrieve-user-subscriptions': RetrieveUserSubscriptions 'retrieve-node-subscriptions': RetrieveNodeSubscriptions 'retrieve-node-affiliations': RetrieveNodeAffiliations 'manage-node-subscriptions': ManageNodeSubscriptions 'manage-node-affiliations': ManageNodeAffiliations 'retrieve-node-configuration': RetrieveNodeConfiguration 'manage-node-configuration': ManageNodeConfiguration 'confirm-subscriber-authorization': AuthorizeSubscriber exports.byOperation = (opName) -> REQUESTS[opName] buddycloud-buddycloud-server-7889586/src/xmpp/pubsub_notifications.coffee000066400000000000000000000104321202241346400267070ustar00rootroot00000000000000xmpp = require('node-xmpp') NS = require('./ns') forms = require('./forms') uuid = require('node-uuid') ## # All notifications are per-node, so listeners can be fetched once # # TODO: enforce MAX_STANZA_LIMIT class Notification constructor: (@opts) -> toStanza: (fromJid, toJid) -> message = new xmpp.Element('message', type: 'headline' from: fromJid to: toJid ) if @opts.replay # For the MAM case the stanza is packaged up into # message.c('result', xmlns: NS.MAM queryid: @opts.queryId if @opts.queryId? id: uuid() ).up(). c('forwarded', xmlns: NS.FORWARD). c('message', type: 'headline' from: fromJid to: toJid ) else message class EventNotification extends Notification toStanza: (fromJid, toJid) -> eventEl = super.c('event', xmlns: NS.PUBSUB_EVENT) for update in @opts switch update.type when 'items' itemsEl = eventEl. c('items', node: update.node) if update.items? then for item in update.items itemsEl.c('item', id: item.id). cnode(item.el) if update.retract? then for item in update.retract itemsEl.c('retract', id: item.id) itemsEl.c('item', id: item.id). cnode(item.el) when 'subscription' eventEl. c('subscription', jid: update.user node: update.node subscription: update.subscription ) when 'affiliation' eventEl. c('affiliation', jid: update.user node: update.node affiliation: update.affiliation ) when 'config' eventEl. c('configuration', node: update.node ).cnode(forms.configToForm(update.config, 'result', NS.PUBSUB_NODE_CONFIG).toXml()) eventEl ## # # # PubSub subscriber request # # To approve this entity's subscription request, # click the OK button. To deny the request, click the # cancel button. # # # http://jabber.org/protocol/pubsub#subscribe_authorization # # 123-abc # # princely_musings # # # horatio@denmark.lit # # # false # # # class AuthorizationPromptNotification extends Notification toStanza: (fromJid, toJid) -> form = new forms.Form('form', NS.PUBSUB_SUBSCRIBE_AUTHORIZATION) form.title = 'Confirm channel subscription' form.instructions = "Allow #{@opts.user} to subscribe to node #{@opts.node}?" form.addField 'pubsub#node', 'text-single', 'Node', @opts.node form.addField 'pubsub#subscriber_jid', 'jid-single', 'Subscriber Address', @opts.user form.addField 'pubsub#allow', 'boolean', 'Allow?', 'false' super.cnode form.toXml() exports.make = (opts) -> switch opts.type when 'authorizationPrompt' new AuthorizationPromptNotification(opts) else new EventNotification(opts) buddycloud-buddycloud-server-7889586/src/xmpp/pubsub_server.coffee000066400000000000000000000516661202241346400253620ustar00rootroot00000000000000logger = require('../logger').makeLogger 'xmpp/pubsub_server' xmpp = require('node-xmpp') { EventEmitter } = require('events') { inspect } = require('util') NS = require('./ns') forms = require('./forms') errors = require('../errors') RSM = require('./rsm') ## # A request: # * Unpacks the request # * Specifies the operation to run # * Compiles the response class Request constructor: (stanza) -> @iq = stanza @sender = new xmpp.JID(stanza.attrs.from).bare().toString() @fullSender = stanza.attrs.from # can be overwritten by : @actor = @sender @me = stanza.attrs.to ## # Is this handler eligible for the request, or proceed to next # handler? matches: () -> false ## # Empty by default reply: (child) -> @iq.reply child replyError: (error) -> @iq.replyError error callback: (err, results) -> if err @replyError err else try @reply results catch e if e.constructor is errors.MaxStanzaSizeExceeded and results.length > 0 # Retry with smaller result set logger.warn "MaxStanzaSizeExceeded: #{results.length} items" smallerResults = results?.slice(0, results.length - 1) smallerResults.rsm ?= results?.rsm @callback err, smallerResults else throw e operation: undefined setActor: (childEl) -> actorEl = childEl?.getChild("actor", NS.BUDDYCLOUD_V1) if actorEl? @actor = actorEl.getText() @actorType ?= actorEl.attrs.type # Elsewhile @actor stays @sender (see @constructor) setRSM: (childEl) -> # Even if there was no element, # code relies on @rsm being present rsmEl = childEl?.getChild('set', NS.RSM) @rsm = RSM.fromXml rsmEl class NotImplemented extends Request matches: () -> true reply: () -> @replyError new errors.FeatureNotImplemented("Feature not implemented") ### # XEP-0092: Software Version ### # # # class VersionGetRequest extends Request matches: () -> @iq.attrs.type is 'get' && @iq.getChild("query", NS.VERSION)? reply: (result) -> queryEl = new xmpp.Element("query", xmlns: NS.VERSION) if result.name queryEl.c('name').t result.name if result.version queryEl.c('version').t result.version if result.os queryEl.c('os').t result.os super queryEl operation: 'get-version' ### # XEP-0030: Service Discovery ### # # # class DiscoInfoRequest extends Request constructor: (stanza) -> super @discoInfoEl = @iq.getChild("query", NS.DISCO_INFO) @node = @discoInfoEl?.attrs.node if @node @operation = 'browse-node-info' else @operation = 'browse-info' matches: () -> @iq.attrs.type is 'get' && @discoInfoEl? reply: (result) -> queryEl = new xmpp.Element("query", xmlns: NS.DISCO_INFO) if result?.node? queryEl.attrs.node = result.node for identity in result.identities queryEl.c "identity", category: identity.category type: identity.type name: identity.name for feature in result.features queryEl.c "feature", var: feature if result.config? queryEl.cnode forms.configToForm(result.config, 'result', NS.PUBSUB_META_DATA).toXml() super queryEl # # # # # TODO: RSM class DiscoItemsRequest extends Request constructor: (stanza) -> super @discoItemsEl = @iq.getChild("query", NS.DISCO_ITEMS) @node = @discoItemsEl?.attrs.node unless @node? @operation = 'browse-nodes' else if @node is "/top-followed-nodes" @operation = 'browse-top-followed-nodes' # not requesting a particular node: delete @node else if @node is "/top-published-nodes" @operation = 'browse-top-published-nodes' # not requesting a particular node: delete @node else @operation = 'browse-node-items' @setRSM @discoItemsEl matches: () -> @iq.attrs.type is 'get' && @discoItemsEl? reply: (results) -> logger.debug "DiscoItemsRequest.reply: #{inspect results}" queryEl = new xmpp.Element("query", xmlns: NS.DISCO_ITEMS) if results?.node queryEl.attrs.node = results.node for item in results attrs = {} attrs.jid ?= item.jid attrs.name ?= item.name attrs.node ?= item.node queryEl.c "item", attrs if results.rsm if @operation is 'browse-node-items' results.rsm.setReplyInfo results, 'name' else results.rsm.setReplyInfo results, 'node' results.rsm.rmRequestInfo() queryEl.cnode results.rsm.toXml() super queryEl ## # XEP-0077: In-Band Registration ## class RegisterRequest extends Request constructor: (stanza) -> super @registerEl = @iq.getChild("query", NS.REGISTER) matches: () -> @registerEl class RegisterGetRequest extends RegisterRequest matches: () -> super && @iq.attrs.type is 'get' reply: () -> super new xmpp.Element("query", xmlns: NS.REGISTER). c("instructions"). t("Simply register here") class RegisterSetRequest extends RegisterRequest matches: () -> super && @iq.attrs.type is 'set' operation: 'register-user' subscriptionRequired: true writes: true ### # XEP-0060: Publish-Subscribe ### class PubsubRequest extends Request xmlns: NS.PUBSUB constructor: (stanza) -> super @pubsubEl = @iq.getChild("pubsub", @xmlns) if @pubsubEl @setActor @pubsubEl @setRSM @pubsubEl matches: () -> (@iq.attrs.type is 'get' || @iq.attrs.type is 'set') && @pubsubEl? reply: (child, rsm) -> if child?.children? pubsubEl = new xmpp.Element("pubsub", { xmlns: @xmlns }) pubsubEl.cnode child if rsm rsm.rmRequestInfo() pubsubEl.cnode rsm.toXml() super pubsubEl else super() ## # *Owner* is not related to a required affiliation. The derived # *operations are all requested with the pubsub#owner xmlns. class PubsubOwnerRequest extends PubsubRequest xmlns: NS.PUBSUB_OWNER # # # # # class PubsubCreateRequest extends PubsubRequest constructor: (stanza) -> super @createEl = @pubsubEl?.getChild("create") @node = @createEl?.attrs.node configureEl = @pubsubEl?.getChild("configure") if configureEl @config = {} configureEl?.getChildren("x", NS.DATA).forEach (formEl) => form = forms.fromXml formEl @config = forms.formToConfig(form) or @config matches: () -> super && @iq.attrs.type is 'set' && @node operation: 'create-node' writes: true # # # # # class PubsubSubscribeRequest extends PubsubRequest constructor: (stanza) -> super @subscribeEl = @pubsubEl?.getChild("subscribe") @node = @subscribeEl?.attrs.node matches: () -> super && @iq.attrs.type is 'set' && @node reply: (result) -> attrs = node: @node attrs.jid ?= result?.user attrs.subscription ?= result?.subscription super new xmpp.Element("subscription", attrs) operation: 'subscribe-node' writes: true # # # # # class PubsubUnsubscribeRequest extends PubsubRequest constructor: (stanza) -> super @unsubscribeEl = @pubsubEl?.getChild("unsubscribe") @node = @unsubscribeEl?.attrs.node matches: () -> super && @iq.attrs.type is 'set' && @node operation: 'unsubscribe-node' writes: true # # # # # ... class PubsubPublishRequest extends PubsubRequest constructor: (stanza) -> super @publishEl = @pubsubEl?.getChild("publish") @items = [] if @publishEl @node = @publishEl.attrs.node for itemEl in @publishEl.getChildren("item") # el is 1st XML child item = el: itemEl.children.filter((itemEl) -> itemEl.hasOwnProperty('children') )[0] if itemEl.attrs.id item.id = itemEl.attrs.id @items.push item matches: () -> super && @iq.attrs.type is 'set' && @node operation: 'publish-node-items' reply: (ids) -> if ids? publishEl = new xmpp.Element('publish', node: @node) for id in ids publishEl.c('item', id: id) super publishEl else super() writes: true # # # # # # # class PubsubRetractRequest extends PubsubRequest constructor: (stanza) -> super @retractEl = @pubsubEl?.getChild("retract") @items = [] if @retractEl @node = @retractEl.attrs.node for itemEl in @retractEl.getChildren("item") if itemEl.attrs.id @items.push itemEl.attrs.id matches: () -> super && @iq.attrs.type is 'set' && @node operation: 'retract-node-items' writes: true # # # # # class PubsubItemsRequest extends PubsubRequest constructor: (stanza) -> super @itemsEl = @pubsubEl?.getChild("items") if (itemEls = @itemsEl?.getChildren("item"))?.length > 0 @itemIds = itemEls.map (itemEl) -> itemEl.attrs.id @node = @itemsEl?.attrs.node matches: () -> super && @iq.attrs.type is 'get' && @node reply: (items) -> items.rsm.setReplyInfo(items, 'id') itemsEl = new xmpp.Element("items", node: items.node) for item in items itemEl = itemsEl.c("item", id: item.id) itemEl.cnode(item.el) super itemsEl, items.rsm operation: 'retrieve-node-items' # # # # # class PubsubSubscriptionsRequest extends PubsubRequest constructor: (stanza) -> super @subscriptionsEl = @pubsubEl?.getChild("subscriptions") matches: () -> super && @iq.attrs.type is 'get' && @subscriptionsEl reply: (nodes) -> nodes.rsm.setReplyInfo(nodes, 'node') subscriptionsEl = new xmpp.Element("subscriptions") for node in nodes attrs = node: node.node subscription: node.subscription if node.jid attrs.jid = node.jid subscriptionsEl.c "subscription", attrs super subscriptionsEl, nodes.rsm operation: 'retrieve-user-subscriptions' # # # # # class PubsubAffiliationsRequest extends PubsubRequest constructor: (stanza) -> super @affiliationsEl = @pubsubEl?.getChild("affiliations") matches: () -> super && @iq.attrs.type is 'get' && @affiliationsEl reply: (nodes) -> nodes.rsm.setReplyInfo(nodes, 'node') affiliationsEl = new xmpp.Element("affiliations") for node in nodes attrs = node: node.node affiliation: node.affiliation if node.jid attrs.jid = node.jid affiliationsEl.c "affiliation", attrs super affiliationsEl, nodes.rsm operation: 'retrieve-user-affiliations' # # # # # class PubsubOwnerGetSubscriptionsRequest extends PubsubOwnerRequest constructor: (stanza) -> super @subscriptionsEl = @pubsubEl?.getChild("subscriptions") @node = @subscriptionsEl?.attrs.node matches: () -> super && @iq.attrs.type is 'get' && @node reply: (subscriptions) -> subscriptions.rsm.setReplyInfo(subscriptions, 'user') subscriptionsEl = new xmpp.Element("subscriptions") for subscription in subscriptions subscriptionsEl.c 'subscription', jid: subscription.user subscription: subscription.subscription super subscriptionsEl, subscriptions.rsm operation: 'retrieve-node-subscriptions' # # # # # # # class PubsubOwnerSetSubscriptionsRequest extends PubsubOwnerRequest constructor: (stanza) -> super @subscriptionsEl = @pubsubEl?.getChild("subscriptions") @subscriptions = [] if @subscriptionsEl @node = @subscriptionsEl.attrs.node @subscriptions = @subscriptionsEl.getChildren("subscription").map( (subscriptionEl) -> user: subscriptionEl.attrs.jid subscription: subscriptionEl.attrs.subscription ) matches: () -> super && @iq.attrs.type is 'set' && @subscriptionsEl operation: 'manage-node-subscriptions' writes: true # # # # # class PubsubOwnerGetAffiliationsRequest extends PubsubOwnerRequest constructor: (stanza) -> super @affiliationsEl = @pubsubEl?.getChild("affiliations") @node = @affiliationsEl?.attrs.node matches: () -> super && @iq.attrs.type is 'get' && @affiliationsEl reply: (affiliations) -> affiliations.rsm.setReplyInfo(affiliations, 'user') affiliationsEl = new xmpp.Element("affiliations") for affiliation in affiliations affiliationsEl.c 'affiliation', jid: affiliation.user affiliation: affiliation.affiliation super affiliationsEl, affiliations.rsm operation: 'retrieve-node-affiliations' # # # # # # # class PubsubOwnerSetAffiliationsRequest extends PubsubOwnerRequest constructor: (stanza) -> super @affiliationsEl = @pubsubEl?.getChild("affiliations") @affiliations = [] if @affiliationsEl @node = @affiliationsEl.attrs.node @affiliations = @affiliationsEl?.getChildren("affiliation").map( (affiliationEl) -> user: affiliationEl.attrs.jid affiliation: affiliationEl.attrs.affiliation ) matches: () -> super && @iq.attrs.type is 'set' && @affiliationsEl operation: 'manage-node-affiliations' writes: true class PubsubOwnerGetConfigurationRequest extends PubsubOwnerRequest constructor: (stanza) -> super @configureEl = @pubsubEl?.getChild("configure") @node = @configureEl?.attrs?.node matches: () -> super && @iq.attrs.type is 'get' && @node operation: 'retrieve-node-configuration' reply: (result) -> configureEl = new xmpp.Element("configure", node: @node) if result.config? configureEl.cnode forms.configToForm(result.config, 'result', NS.PUBSUB_NODE_CONFIG).toXml() super configureEl class PubsubOwnerSetConfigurationRequest extends PubsubOwnerRequest constructor: (stanza) -> super @configureEl = @pubsubEl?.getChild("configure") @node = @configureEl?.attrs?.node @config = {} @configureEl?.getChildren("x", NS.DATA).forEach (formEl) => form = forms.fromXml formEl @config = forms.formToConfig(form) or @config matches: () -> super && @iq.attrs.type is 'set' && @node operation: 'manage-node-configuration' writes: true class MessageArchiveRequest extends Request constructor: (stanza) -> super @mamEl = @iq.getChild("query", NS.MAM) @queryId = @mamEl?.attrs?.queryid @start = @mamEl?.getChildText("start") @end = @mamEl?.getChildText("end") matches: () -> @iq.attrs.type is 'get' && @mamEl? operation: 'replay-archive' REQUESTS = [ VersionGetRequest, DiscoInfoRequest, DiscoItemsRequest, RegisterGetRequest, RegisterSetRequest, PubsubCreateRequest, PubsubSubscribeRequest, PubsubUnsubscribeRequest, PubsubPublishRequest, PubsubRetractRequest, PubsubItemsRequest, PubsubSubscriptionsRequest, PubsubAffiliationsRequest, PubsubOwnerGetSubscriptionsRequest, PubsubOwnerSetSubscriptionsRequest, PubsubOwnerGetAffiliationsRequest, PubsubOwnerSetAffiliationsRequest, PubsubOwnerGetConfigurationRequest, PubsubOwnerSetConfigurationRequest, MessageArchiveRequest, NotImplemented ] ## # Reacts on all *requests # # Emits recognized requests with @onRequest(request) class exports.PubsubServer extends EventEmitter constructor: (@conn) -> @conn.on 'iqRequest', (stanza) => request = @makeRequest stanza @emit 'request', request if request.subscriptionRequired bareJid = new xmpp.JID(stanza.attrs.from).bare().toString() @conn.subscribePresence bareJid ## # Generates stanza-receiving function, invokes cb # # Matches the above REQUESTS for the received stanza makeRequest: (stanza) -> result = null for r in REQUESTS result = new r(stanza) if result.matches() logger.trace "found subrequest #{r.name}" break else result = null # Synchronous result: result buddycloud-buddycloud-server-7889586/src/xmpp/rsm.coffee000066400000000000000000000066311202241346400232650ustar00rootroot00000000000000xmpp = require('node-xmpp') NS = require('./ns') class exports.RSM ## # key = null: results is array of ids # key = String: results is array of objects, with key as id # # @return Modified results cropResults: (results, key) -> if key indexOf = (id) -> i = 0 for result in results if result[key] is id return i i++ return -1 else indexOf = (id) -> results.indexOf id # RSM offsets @firstIndex = 0 @count = results.length if @after # Paging forward afterIdx = indexOf(@after) if afterIdx >= 0 results = results.slice(afterIdx + 1) @firstIndex = afterIdx + 1 if @before # Paging backwards beforeIdx = indexOf(@before) if beforeIdx >= 0 results = results.slice(0, beforeIdx) # RSM crop item amount @max = Math.min(100, @max or 100) if 'before' of @ # Paging backwards results = results.slice(Math.max(0, results.length - @max), results.length) @firstIndex = @count - results.length else # Paging forward results = results.slice(0, Math.min(@max, results.length)) # And attach for convenience: results.rsm = @ results setReplyInfo: (results, key) -> if @fromRemote # Do not change return delete @first delete @last if results.length > 0 if key getKey = (result) -> result[key] else getKey = (result) -> result @first = getKey results[0] @last = getKey results[results.length - 1] rmRequestInfo: -> delete @max delete @after delete @before toXml: -> el = new xmpp.Element('set', xmlns: NS.RSM) ## # Request data if 'max' of @ el.c('max').t("#{@max}") if 'index' of @ el.c('index').t("#{@index}") if @after el.c('after').t(@after) if @before el.c('before').t(@before) ## # Response data if @first firstEl = el.c('first') firstEl.t(@first) if 'firstIndex' of @ firstEl.attrs.index = "#{@firstIndex}" if @last el.c('last').t(@last) if 'count' of @ el.c('count').t("#{@count}") el exports.fromXml = (el, @fromRemote) -> rsm = new exports.RSM() unless el return rsm ## # Request data if (max = el.getChildText('max')) rsm.max = parseInt(max, 10) if (index = el.getChildText('index')) rsm.index = parseInt(index, 10) # Creates key even if empty if (afterEl = el.getChild('after'))? rsm.after = afterEl.getText() if (beforeEl = el.getChild('before'))? rsm.before = beforeEl.getText() ## # Response data if (firstEl = el.getChild('first')) rsm.first = firstEl.getText() if 'index' of firstEl.attrs rsm.firstIndex = parseInt(firstEl.attrs.index, 10) rsm.last ?= el.getChildText('last') if (count = el.getChild('count')?.getText()) rsm.count = parseInt(count, 10) rsm