├── .circleci └── config.yml ├── .env-sample ├── .eslintrc.json ├── .gitignore ├── .jshintignore ├── .jshintrc ├── .mocharc.js ├── .node-version ├── DEVNOTES.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── app.js ├── bin └── import-data.js ├── client.js ├── config.js ├── controllers ├── docs.js ├── home.js ├── index.js ├── ping-form.js ├── ping.js ├── please-notify-form.js ├── please-notify.js ├── rpc2.js └── view-log.js ├── data └── .gitignore ├── docker-compose.yml ├── package-lock.json ├── package.json ├── public ├── .gitignore ├── css │ ├── bootstrap.css.map │ ├── bootstrap.min.css │ └── font-awesome.min.css ├── favicon.ico ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ ├── fontawesome-webfont.woff2 │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 ├── js │ ├── bootstrap.js │ ├── bootstrap.min.js │ ├── eventlog.tpl.js │ ├── handlebars-v4.0.10.js │ ├── jquery-1.11.3.min.js │ ├── modal.tpl.js │ ├── moment-with-locales.min.js │ └── npm.js ├── sw.js └── templates │ ├── eventlog.handlebars │ └── modal.handlebars ├── services ├── app-messages.js ├── error-response.js ├── error-result.js ├── get-random-password.js ├── init-resource.js ├── init-subscription.js ├── log-emitter.js ├── log-event.js ├── mongodb.js ├── notify-one-challenge.js ├── notify-one.js ├── notify-subscribers.js ├── parse-notify-params.js ├── parse-ping-params.js ├── parse-rpc-request.js ├── ping.js ├── please-notify.js ├── remove-expired-subscriptions.js ├── rest-return-success.js ├── rpc-return-fault.js └── rpc-return-success.js ├── test ├── fixtures.js ├── keys │ ├── README.md │ ├── server.cert │ └── server.key ├── mock.js ├── mongodb.js ├── ping.js ├── please-notify.js └── static.js └── views ├── docs.handlebars ├── home.handlebars ├── layouts └── main.handlebars ├── ping-form.handlebars ├── please-notify-form.handlebars └── view-log.handlebars /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | machine: true 5 | working_directory: ~/repo 6 | steps: 7 | - checkout 8 | - run: docker-compose up --build --abort-on-container-exit 9 | - store_test_results: 10 | path: xunit 11 | -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | DOMAIN=localhost 2 | PORT=5337 3 | MONGODB_URI=mongodb://localhost:27017/rsscloud 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2017 4 | }, 5 | "extends": "crockford", 6 | "env": { 7 | "node": true, 8 | "es6": true, 9 | "mocha": true 10 | }, 11 | "rules": { 12 | "no-underscore-dangle": "off", 13 | "no-continue": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .nyc_output/ 4 | /data/ 5 | /node_modules/ 6 | /xunit/ 7 | Procfile 8 | tunnel.sh 9 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { "esversion":8 } 2 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 'reporter': 'mocha-multi', 5 | 'reporter-option': ['spec=-,xunit=xunit/test-results.xml'], 6 | 'require': './test/fixtures.js', 7 | 'timeout': '10000', 8 | }; 9 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /DEVNOTES.md: -------------------------------------------------------------------------------- 1 | # Developer Notes 2 | 3 | ## Make sure I test pleaseNotify with multiple urls including where one url fails. 4 | 5 | Just because one fails doesn't mean the others aren't good. The response from Dave's server is a pass or fail so I'll stick with that and show a failure even if only one fails. This is probably an edge case. 6 | 7 | ## OPML Editor fails ping with a bad domain 8 | 9 | ```xml 10 | 11 | 12 | ``` 13 | 14 | I should make sure I return the same response if possible. 15 | 16 | ## OPML Editor fails pleaseNotify if missing parameters 17 | 18 | ```xml 19 | 20 | 21 | ``` 22 | 23 | I think I check for all of these except notifyProcedure. 24 | 25 | ## Update README docs 26 | 27 | Fully spec out XML-RPC endpoints with code samples. 28 | 29 | ## Build freestanding rssCloud testing tool 30 | 31 | Hopefully this can be based on the test suite I'm creating now, but it will be a hosted app that has a dashboard where you can put in the details for an rssCloud server. It mocks the RSS feed and aggregator endpoints so it can direct the real rssCloud server at resources it controls. This way it can run through a series of tests with red/greed lights to see if each test passes. 32 | 33 | Split out rssCloud.root functionality vs my extended functionality. 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 AS base 2 | 3 | # Dockerize is needed to sync containers startup 4 | ENV DOCKERIZE_VERSION v0.6.1 5 | RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 6 | && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ 7 | && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz 8 | 9 | RUN mkdir -p /app 10 | 11 | WORKDIR /app 12 | 13 | COPY package.json . 14 | COPY package-lock.json . 15 | 16 | FROM base AS dependencies 17 | 18 | RUN npm ci 19 | 20 | FROM dependencies AS runtime 21 | 22 | COPY . . 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2020 Andrew Shell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rssCloud Server v2 2 | 3 | [![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/rsscloud/rsscloud-server/blob/2.x/LICENSE.md) 4 | [![rssCloud Server 2.x](https://circleci.com/gh/rsscloud/rsscloud-server/tree/2.x.svg?style=shield)](https://circleci.com/gh/rsscloud/rsscloud-server/tree/2.x) 5 | [![Andrew Shell's Weblog](https://img.shields.io/badge/weblog-rssCloud-brightgreen)](https://blog.andrewshell.org/search/?keywords=rsscloud) 6 | 7 | rssCloud Server implementation in Node.js 8 | 9 | ## How to install 10 | 11 | ```bash 12 | git clone https://github.com/rsscloud/rsscloud-server.git 13 | cd rsscloud-server 14 | npm install 15 | npm start 16 | ``` 17 | 18 | ## How to test 19 | 20 | The API is tested using docker containers. I've only tested on MacOS so if you have experience testing on other platforms I'd love having these notes updated for those platforms. 21 | 22 | ### MacOS 23 | 24 | First install [Docker Desktop for Mac](https://hub.docker.com/editions/community/docker-ce-desktop-mac) 25 | 26 | ```bash 27 | npm run test-api 28 | ``` 29 | 30 | This should build the appropriate containers and show the test output. 31 | 32 | Our tests create mock API endpoints so we can verify rssCloud server works correctly when reading resources and notifying subscribers. 33 | 34 | ## How to use 35 | 36 | ### POST /pleaseNotify 37 | 38 | Posting to /pleaseNotify is your way of alerting the server that you want to receive notifications when one or more resources are updated. 39 | 40 | The POST parameters are: 41 | 42 | 1. domain -- optional, if omitted the requesting IP address is used 43 | 2. port 44 | 3. path 45 | 4. registerProcedure -- required, but isn't used in this server as it only applies to xml-rpc or soap. 46 | 5. protocol -- the spec allows for http-post, xml-rpc or soap but this server only supports http-post. This server also supports https-post which is identical to http-post except it notifies using https as the scheme instead of http. 47 | 6. url1, url2, ..., urlN this is the resource you're requesting to be notified about. In the case of an RSS feed you would specify the URL of the RSS feed. 48 | 49 | When you POST the server first checks if the urls you specifed are returning an [HTTP 2xx status code](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2) then it attempts to notify the subscriber of an update to make sure it works. This is done in one of two ways. 50 | 51 | 1. If you did not specify a domain parameter and we're using the requesting IP address we perform a POST request to the URL represented by `http://:` with a single parameter `url`. To accept the subscription that resource just needs to return an HTTP 2xx status code. 52 | 2. If you did specify a domain parameter then we perform a GET request to the URL represented by `http://:` with two query string parameters, url and challenge. To accept the subscription that resource needs to return an HTTP 2xx status code and have the challenge value as the response body. 53 | 54 | You will receive a response with two values: 55 | 56 | 1. success -- true or false depending on whether or not the subscription suceeded 57 | 2. msg -- a string that explains either that you succeed or why it failed 58 | 59 | The default response type is text/xml but if you POST with an [accept header](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1) specifying `application/json` we will return a JSON formatted response. 60 | 61 | Examples: 62 | 63 | ```xml 64 | 65 | 66 | ``` 67 | 68 | ```json 69 | {"success":false,"msg":"The subscription was cancelled because the call failed when we tested the handler."} 70 | ``` 71 | 72 | ### POST /ping 73 | 74 | Posting to /ping is your way of alerting the server that a resource has been updated. 75 | 76 | The POST parameters are: 77 | 78 | 1. url 79 | 80 | When you POST the server first checks if the url has actually changed since the last time it checked. If it has, it will go through it's list of subscribers and POST to the subscriber with the parameter `url`. 81 | 82 | The default response type is text/xml but if you POST with an [accept header](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1) specifying `application/json` we will return a JSON formatted response. 83 | 84 | Examples: 85 | 86 | ```xml 87 | 88 | 89 | ``` 90 | 91 | ```json 92 | {"success":true,"msg":"Thanks for the ping."} 93 | ``` 94 | 95 | ### GET /pingForm 96 | 97 | The path /pingForm is an HTML form intented to allow you to ping via a web browser. 98 | 99 | ### GET /viewLog 100 | 101 | The path /viewLog is a log of recent events that have occured on the server. It's very useful if you're trying to debug your tools. 102 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | require('dotenv').config(); 5 | 6 | const config = require('./config'), 7 | cors = require('cors'), 8 | express = require('express'), 9 | exphbs = require('express-handlebars'), 10 | fs = require('fs'), 11 | moment = require('moment'), 12 | mongodb = require('./services/mongodb'), 13 | morgan = require('morgan'), 14 | removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'); 15 | 16 | let app, 17 | expressWs, 18 | hbs, 19 | server; 20 | 21 | require('console-stamp')(console, 'HH:MM:ss.l'); 22 | 23 | console.log(`${config.appName} ${config.appVersion}`); 24 | 25 | // TODO: Every 24 hours run removeExpiredSubscriptions(data); 26 | 27 | morgan.format('mydate', function() { 28 | var df = require('dateformat'); 29 | return df(new Date(), 'HH:MM:ss.l'); 30 | }); 31 | 32 | app = express(); 33 | expressWs = require('express-ws')(app); 34 | 35 | app.use(morgan('[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms')); 36 | 37 | app.use(cors()); 38 | 39 | // Configure handlebars template engine to work with moment 40 | hbs = exphbs.create({ 41 | helpers: { 42 | formatDate: function (datetime, format) { 43 | return moment(datetime).format(format); 44 | } 45 | } 46 | }); 47 | 48 | // Configure express to use handlebars 49 | app.engine('handlebars', hbs.engine); 50 | app.set('view engine', 'handlebars'); 51 | 52 | // Handle static files in public directory 53 | app.use(express.static('public', { 54 | dotfiles: 'ignore', 55 | maxAge: '1d' 56 | })); 57 | 58 | // Load controllers 59 | app.use(require('./controllers')); 60 | 61 | // Start server 62 | mongodb.connect('rsscloud', config.mongodbUri) 63 | .then(() => { 64 | server = app.listen(config.port, function () { 65 | app.locals.host = config.domain; 66 | app.locals.port = server.address().port; 67 | 68 | if (app.locals.host.indexOf(':') > -1) { 69 | app.locals.host = '[' + app.locals.host + ']'; 70 | } 71 | 72 | console.log('Listening at http://%s:%s', app.locals.host, app.locals.port); 73 | }) 74 | .on('error', function (error) { 75 | switch (error.code) { 76 | case 'EADDRINUSE': 77 | console.log(`Error: Port ${config.port} is already in use.`); 78 | break; 79 | default: 80 | console.log(error.code); 81 | } 82 | }); 83 | }); 84 | }()); 85 | -------------------------------------------------------------------------------- /bin/import-data.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'), 2 | fs = require('fs'), 3 | mongodb = require('../services/mongodb'); 4 | 5 | async function doImport() { 6 | const db = await mongodb.connect('rsscloud', config.mongodbUri); 7 | 8 | if (fs.existsSync('./data/data.json')) { 9 | const data = JSON.parse(fs.readFileSync('./data/data.json', 'utf8')); 10 | 11 | await db.createCollection('events'); 12 | await db.createCollection('resources'); 13 | 14 | await db.collection('resources').bulkWrite( 15 | Object.keys(data.resources).map(id => { 16 | return { 17 | replaceOne: { 18 | filter: { _id: id }, 19 | replacement: data.resources[id], 20 | upsert: true 21 | } 22 | }; 23 | }) 24 | ); 25 | 26 | await db.createCollection('subscriptions'); 27 | 28 | await db.collection('subscriptions').bulkWrite( 29 | Object.keys(data.subscriptions).map(id => { 30 | const subscriptions = { 31 | _id: id, 32 | pleaseNotify: Object.keys(data.subscriptions[id]).map(sid => { 33 | const subscription = data.subscriptions[id][sid]; 34 | subscription.url = sid; 35 | subscription.notifyProcedure = false; 36 | subscription.protocol = 'http-post'; 37 | return subscription; 38 | }) 39 | }; 40 | return { 41 | replaceOne: { 42 | filter: { _id: id }, 43 | replacement: subscriptions, 44 | upsert: true 45 | } 46 | }; 47 | }) 48 | ); 49 | 50 | await mongodb.close('rsscloud'); 51 | } else { 52 | await mongodb.close('rsscloud'); 53 | 54 | throw new Error('Cannot find ./data/data.json'); 55 | } 56 | } 57 | 58 | doImport() 59 | .then(() => { 60 | console.log('Imported ./data/data.json'); 61 | }) 62 | .catch(err => { 63 | console.error(err); 64 | }); 65 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var app, 4 | bodyParser = require('body-parser'), 5 | express = require('express'), 6 | morgan = require('morgan'), 7 | nconf = require('nconf'), 8 | packageJson = require('./package.json'), 9 | server, 10 | textParser = bodyParser.text({ type: '*/xml'}), 11 | urlencodedParser = bodyParser.urlencoded({ extended: false }); 12 | 13 | require('console-stamp')(console, 'HH:MM:ss.l'); 14 | 15 | // Setup nconf to use (in-order): 16 | // 1. Overrides 17 | // 2. Command-line arguments 18 | // 3. Environment variables 19 | // 4. A config.json file 20 | // 5. Default values 21 | nconf 22 | .overrides({ 23 | 'APP_NAME': 'rssCloudClient', 24 | 'APP_VERSION': packageJson.version, 25 | }) 26 | .argv() 27 | .env() 28 | .defaults({ 29 | "DOMAIN": "localhost", 30 | "PORT": 9000 31 | }); 32 | 33 | console.log(nconf.get('APP_NAME') + ' ' + nconf.get('APP_VERSION')); 34 | 35 | morgan.format('mydate', function() { 36 | var df = require('dateformat'); 37 | return df(new Date(), 'HH:MM:ss.l'); 38 | }); 39 | 40 | app = express(); 41 | 42 | app.use(morgan('[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms')); 43 | 44 | app.use(express.static('public', { 45 | dotfiles: 'ignore', 46 | maxAge: '1d' 47 | })); 48 | 49 | app.post('/RPC2', textParser, function (req, res) { 50 | console.log('rpc'); 51 | console.dir(req.body); 52 | res.send(''); 53 | }) 54 | 55 | app.get('/*', function (req, res) { 56 | var challenge = req.query.challenge || ""; 57 | console.log('get'); 58 | console.dir(req.query); 59 | res.send(challenge); 60 | }); 61 | 62 | app.post('/*', urlencodedParser, function (req, res) { 63 | console.log('post'); 64 | console.dir(req.body); 65 | res.send(''); 66 | }); 67 | 68 | server = app.listen(nconf.get('PORT'), function () { 69 | var host = nconf.get('DOMAIN'), 70 | port = server.address().port; 71 | 72 | console.log('Listening at http://%s:%s', host, port); 73 | }) 74 | .on('error', function (error) { 75 | switch (error.code) { 76 | case 'EADDRINUSE': 77 | console.log('Error: Port ' + nconf.get('PORT') + ' is already in use.'); 78 | break; 79 | default: 80 | console.log(error.code); 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const nconf = require('nconf'), 2 | packageJson = require('./package.json'); 3 | 4 | // Setup nconf to use (in-order): 5 | // 1. Overrides 6 | // 2. Command-line arguments 7 | // 3. Environment variables 8 | // 4. Default values 9 | nconf 10 | .overrides({ 11 | 'APP_NAME': 'rssCloudServer', 12 | 'APP_VERSION': packageJson.version, 13 | }) 14 | .argv() 15 | .env() 16 | .defaults({ 17 | "DOMAIN": "localhost", 18 | "PORT": 5337, 19 | "MONGODB_URI": "mongodb://localhost:27017/rsscloud", 20 | "MAX_CONSECUTIVE_ERRORS": 3, 21 | "MAX_RESOURCE_SIZE": 256000, 22 | "CT_SECS_RESOURCE_EXPIRE": 90000, 23 | "MIN_SECS_BETWEEN_PINGS": 0, 24 | "REQUEST_TIMEOUT": 4000 25 | }); 26 | 27 | module.exports = { 28 | appName: nconf.get('APP_NAME'), 29 | appVersion: nconf.get('APP_VERSION'), 30 | domain: nconf.get('DOMAIN'), 31 | port: nconf.get('PORT'), 32 | mongodbUri: nconf.get('MONGODB_URI'), 33 | maxConsecutiveErrors: nconf.get('MAX_CONSECUTIVE_ERRORS'), 34 | maxResourceSize: nconf.get('MAX_RESOURCE_SIZE'), 35 | ctSecsResourceExpire: nconf.get('CT_SECS_RESOURCE_EXPIRE'), 36 | minSecsBetweenPings: nconf.get('MIN_SECS_BETWEEN_PINGS'), 37 | requestTimeout: nconf.get('REQUEST_TIMEOUT') 38 | }; 39 | -------------------------------------------------------------------------------- /controllers/docs.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const express = require('express'), 5 | router = new express.Router(), 6 | md = require('markdown-it')(), 7 | fs = require('fs'); 8 | 9 | router.get('/', function (req, res) { 10 | switch (req.accepts('html')) { 11 | case 'html': 12 | const vals = { 13 | htmltext: md.render(fs.readFileSync('README.md', { encoding: 'utf8' })) 14 | }; 15 | res.render('docs', vals); 16 | break; 17 | default: 18 | res.status(406).send('Not Acceptable'); 19 | break; 20 | } 21 | }); 22 | 23 | module.exports = router; 24 | }()); 25 | -------------------------------------------------------------------------------- /controllers/home.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const express = require('express'), 5 | router = new express.Router(); 6 | 7 | router.get('/', function (req, res) { 8 | switch (req.accepts('html')) { 9 | case 'html': 10 | res.render('home'); 11 | break; 12 | default: 13 | res.status(406).send('Not Acceptable'); 14 | break; 15 | } 16 | }); 17 | 18 | module.exports = router; 19 | }()); 20 | -------------------------------------------------------------------------------- /controllers/index.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const express = require('express'), 5 | router = new express.Router(); 6 | 7 | router.use('/', require('./home')); 8 | router.use('/docs', require('./docs')); 9 | router.use('/pleaseNotify', require('./please-notify')); 10 | router.use('/pleaseNotifyForm', require('./please-notify-form')); 11 | router.use('/ping', require('./ping')); 12 | router.use('/pingForm', require('./ping-form')); 13 | router.use('/viewLog', require('./view-log')); 14 | router.use('/RPC2', require('./rpc2')); 15 | 16 | module.exports = router; 17 | }()); 18 | -------------------------------------------------------------------------------- /controllers/ping-form.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const express = require('express'), 5 | router = new express.Router(); 6 | 7 | router.get('/', function (req, res) { 8 | switch (req.accepts('html')) { 9 | case 'html': 10 | res.render('ping-form'); 11 | break; 12 | default: 13 | res.status(406).send('Not Acceptable'); 14 | break; 15 | } 16 | }); 17 | 18 | module.exports = router; 19 | }()); 20 | -------------------------------------------------------------------------------- /controllers/ping.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const bodyParser = require('body-parser'), 5 | ErrorResponse = require('../services/error-response'), 6 | errorResult = require('../services/error-result'), 7 | express = require('express'), 8 | parsePingParams = require('../services/parse-ping-params'), 9 | ping = require('../services/ping'), 10 | restReturnSuccess = require('../services/rest-return-success'), 11 | router = new express.Router(), 12 | urlencodedParser = bodyParser.urlencoded({ extended: false }); 13 | 14 | function processResponse(req, res, result) { 15 | switch (req.accepts('xml', 'json')) { 16 | case 'xml': 17 | res.set('Content-Type', 'text/xml'); 18 | res.send(restReturnSuccess( 19 | result.success, 20 | result.msg, 21 | 'result' 22 | )); 23 | break; 24 | case 'json': 25 | res.json(result); 26 | break; 27 | default: 28 | res.status(406).send('Not Acceptable'); 29 | break; 30 | } 31 | } 32 | 33 | function handleError(req, res, err) { 34 | if (!(err instanceof ErrorResponse)) { 35 | console.error(err); 36 | } 37 | processResponse(req, res, errorResult(err.message)); 38 | } 39 | 40 | router.post('/', urlencodedParser, function (req, res) { 41 | try { 42 | const params = parsePingParams.rest(req); 43 | ping(params.url) 44 | .then(result => processResponse(req, res, result)) 45 | .catch(err => handleError(req, res, err)); 46 | } catch (err) { 47 | return handleError(req, res, err); 48 | } 49 | }); 50 | 51 | module.exports = router; 52 | }()); 53 | -------------------------------------------------------------------------------- /controllers/please-notify-form.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const express = require('express'), 5 | router = new express.Router(); 6 | 7 | router.get('/', function (req, res) { 8 | switch (req.accepts('html')) { 9 | case 'html': 10 | res.render('please-notify-form'); 11 | break; 12 | default: 13 | res.status(406).send('Not Acceptable'); 14 | break; 15 | } 16 | }); 17 | 18 | module.exports = router; 19 | }()); 20 | -------------------------------------------------------------------------------- /controllers/please-notify.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const bodyParser = require('body-parser'), 5 | ErrorResponse = require('../services/error-response'), 6 | errorResult = require('../services/error-result'), 7 | express = require('express'), 8 | parseNotifyParams = require('../services/parse-notify-params'), 9 | pleaseNotify = require('../services/please-notify'), 10 | restReturnSuccess = require('../services/rest-return-success'), 11 | router = new express.Router(), 12 | urlencodedParser = bodyParser.urlencoded({ extended: false }); 13 | 14 | function processResponse(req, res, result) { 15 | switch (req.accepts('xml', 'json')) { 16 | case 'xml': 17 | res.set('Content-Type', 'text/xml'); 18 | res.send(restReturnSuccess( 19 | result.success, 20 | result.msg, 21 | 'notifyResult' 22 | )); 23 | break; 24 | case 'json': 25 | res.json(result); 26 | break; 27 | default: 28 | res.status(406).send('Not Acceptable'); 29 | break; 30 | } 31 | } 32 | 33 | function handleError(req, res, err) { 34 | if (!(err instanceof ErrorResponse)) { 35 | console.error(err); 36 | } 37 | processResponse(req, res, errorResult(err.message)); 38 | } 39 | 40 | router.post('/', urlencodedParser, function (req, res) { 41 | const params = parseNotifyParams.rest(req); 42 | pleaseNotify( 43 | params.notifyProcedure, 44 | params.apiurl, 45 | params.protocol, 46 | params.urlList, 47 | params.diffDomain 48 | ) 49 | .then(result => processResponse(req, res, result)) 50 | .catch(err => handleError(req, res, err)); 51 | }); 52 | 53 | module.exports = router; 54 | }()); 55 | -------------------------------------------------------------------------------- /controllers/rpc2.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const bodyParser = require('body-parser'), 5 | ErrorResponse = require('../services/error-response'), 6 | express = require('express'), 7 | logEvent = require('../services/log-event'), 8 | moment = require('moment'), 9 | parseRpcRequest = require('../services/parse-rpc-request'), 10 | parseNotifyParams = require('../services/parse-notify-params'), 11 | parsePingParams = require('../services/parse-ping-params'), 12 | pleaseNotify = require('../services/please-notify'), 13 | ping = require('../services/ping'), 14 | router = new express.Router(), 15 | rpcReturnSuccess = require('../services/rpc-return-success'), 16 | rpcReturnFault = require('../services/rpc-return-fault'), 17 | textParser = bodyParser.text({ type: '*/xml'}); 18 | 19 | function processResponse(req, res, xmlString) { 20 | switch (req.accepts('xml')) { 21 | case 'xml': 22 | res.set('Content-Type', 'text/xml'); 23 | res.send(xmlString); 24 | break; 25 | default: 26 | res.status(406).send('Not Acceptable'); 27 | break; 28 | } 29 | } 30 | 31 | function handleError(req, res, err) { 32 | if (!(err instanceof ErrorResponse)) { 33 | console.error(err); 34 | } 35 | processResponse(req, res, rpcReturnFault(4, err.message)); 36 | } 37 | 38 | router.post('/', textParser, function (req, res) { 39 | let params; 40 | parseRpcRequest(req) 41 | .then(request => { 42 | logEvent( 43 | 'XmlRpc', 44 | request.methodName, 45 | moment().format('x') 46 | ); 47 | 48 | switch (request.methodName) { 49 | case 'rssCloud.hello': 50 | processResponse(req, res, rpcReturnSuccess(true)); 51 | break; 52 | case 'rssCloud.pleaseNotify': 53 | try { 54 | params = parseNotifyParams.rpc(req, request.params); 55 | pleaseNotify( 56 | params.notifyProcedure, 57 | params.apiurl, 58 | params.protocol, 59 | params.urlList, 60 | params.diffDomain 61 | ) 62 | .then(result => processResponse(req, res, rpcReturnSuccess(result.success))) 63 | .catch(err => handleError(req, res, err)); 64 | } catch (err) { 65 | handleError(req, res, err); 66 | } 67 | break; 68 | case 'rssCloud.ping': 69 | try { 70 | params = parsePingParams.rpc(req, request.params); 71 | // Dave's rssCloud server always returns true whether it succeeded or not 72 | ping(params.url) 73 | .then(result => processResponse(req, res, rpcReturnSuccess(result.success))) 74 | .catch(err => processResponse(req, res, rpcReturnSuccess(true))); 75 | } catch (err) { 76 | handleError(req, res, err); 77 | } 78 | break; 79 | default: 80 | handleError( 81 | req, 82 | res, 83 | new Error(`Can't make the call because "${request.methodName}" is not defined.`) 84 | ); 85 | } 86 | }) 87 | .catch(err => handleError(req, res, err)); 88 | }); 89 | 90 | module.exports = router; 91 | }()); 92 | -------------------------------------------------------------------------------- /controllers/view-log.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const ErrorResponse = require('../services/error-response'), 5 | errorResult = require('../services/error-result'), 6 | express = require('express'), 7 | logEmitter = require('../services/log-emitter'), 8 | mongodb = require('../services/mongodb'), 9 | router = new express.Router(); 10 | 11 | async function fetchVals(db, callback) { 12 | const vals = { 13 | 'eventlog': [] 14 | }, 15 | 16 | res = await mongodb.get('rsscloud') 17 | .collection('events') 18 | .find() 19 | .sort({ time: -1 }) 20 | .limit(1000) 21 | .toArray(); 22 | 23 | vals.eventlog = res.map(item => { 24 | item.id = item._id.toHexString(); 25 | delete item._id; 26 | 27 | item.headers = JSON.parse(item.headers); 28 | 29 | return item; 30 | }); 31 | 32 | return vals; 33 | } 34 | 35 | function processResponse(req, res, vals) { 36 | switch (req.accepts('html', 'json')) { 37 | case 'html': 38 | vals.wshost = res.app.locals.host + ':' + res.app.locals.port; 39 | res.render('view-log', vals); 40 | break; 41 | case 'json': 42 | res.json(vals.eventlog); 43 | break; 44 | default: 45 | res.status(406).send('Not Acceptable'); 46 | break; 47 | } 48 | } 49 | 50 | function handleError(req, res, err) { 51 | if (!(err instanceof ErrorResponse)) { 52 | console.error(err); 53 | } 54 | processResponse(req, res, errorResult(err.message)); 55 | } 56 | 57 | router.get('/', function (req, res) { 58 | fetchVals() 59 | .then(vals => processResponse(req, res, vals)) 60 | .catch(err => handleError(req, res, err)); 61 | }); 62 | 63 | router.ws('/', (ws, req) => { 64 | function sendLogEvent(logEvent) { 65 | ws.send(logEvent); 66 | } 67 | 68 | logEmitter.on('logged-event', sendLogEvent); 69 | 70 | ws.on('close', function () { 71 | logEmitter.removeListener('logged-event', sendLogEvent); 72 | }); 73 | }); 74 | 75 | module.exports = router; 76 | }()); 77 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | 5 | mongodb: 6 | image: mongo 7 | expose: 8 | - 27017 9 | 10 | rsscloud: 11 | build: . 12 | command: node --use_strict app.js 13 | environment: 14 | DOMAIN: rsscloud 15 | PORT: 5337 16 | MONGODB_URI: mongodb://mongodb:27017/rsscloud 17 | NODE_TLS_REJECT_UNAUTHORIZED: 0 18 | expose: 19 | - 5337 20 | depends_on: 21 | - mongodb 22 | 23 | rsscloud-tests: 24 | build: . 25 | command: dockerize -wait tcp://mongodb:27017 -wait http://rsscloud:5337 -timeout 10s bash -c "npm test" 26 | environment: 27 | APP_URL: http://rsscloud:5337 28 | MONGODB_URI: mongodb://mongodb:27017/rsscloud 29 | MOCK_SERVER_DOMAIN: rsscloud-tests 30 | MOCK_SERVER_PORT: 8002 31 | SECURE_MOCK_SERVER_PORT: 8003 32 | volumes: 33 | - ./xunit:/app/xunit 34 | expose: 35 | - 8002 36 | - 8003 37 | depends_on: 38 | - mongodb 39 | - rsscloud 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rsscloud-server", 3 | "version": "2.0.0", 4 | "description": "An rssCloud Server", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "nodemon --use_strict ./app.js", 8 | "client": "nodemon --use_strict ./client.js", 9 | "import-data": "node ./bin/import-data.js", 10 | "jshint": "jshint ./**/*.js", 11 | "eslint": "eslint --fix controllers/ services/ test/", 12 | "test": "mocha", 13 | "test-api": "docker-compose up --build --abort-on-container-exit" 14 | }, 15 | "engines": { 16 | "node": ">=10.6.0" 17 | }, 18 | "author": "Andrew Shell ", 19 | "license": "MIT", 20 | "dependencies": { 21 | "body-parser": "^1.19.0", 22 | "console-stamp": "^3.0.3", 23 | "cors": "^2.8.5", 24 | "davexmlrpc": "^0.4.26", 25 | "dotenv": "^10.0.0", 26 | "express": "^4.17.1", 27 | "express-handlebars": "^5.3.3", 28 | "express-ws": "^5.0.2", 29 | "markdown-it": "^12.2.0", 30 | "moment": "^2.29.1", 31 | "mongodb": "4.1.0", 32 | "morgan": "^1.10.0", 33 | "nconf": "^0.11.3", 34 | "request": "^2.88.0", 35 | "request-promise-native": "1.0.8", 36 | "sprintf-js": "^1.1.2", 37 | "xml2js": "^0.4.23", 38 | "xmlbuilder": "^15.1.1" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/andrewshell/rsscloud-server.git" 43 | }, 44 | "devDependencies": { 45 | "chai": "^4.3.4", 46 | "chai-http": "^4.3.0", 47 | "chai-json": "^1.0.0", 48 | "chai-xml": "^0.4.0", 49 | "eslint": "^7.32.0", 50 | "eslint-config-crockford": "^2.0.0", 51 | "https": "^1.0.0", 52 | "jshint": "^2.13.1", 53 | "mocha": "^9.1.0", 54 | "mocha-multi": "^1.1.3", 55 | "nodemon": "2.0.12", 56 | "supertest": "^6.1.6" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | 3 | -------------------------------------------------------------------------------- /public/css/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.3.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.3.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.3.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.3.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.3.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.3.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transform:translate(0, 0)}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-genderless:before,.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"} -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /public/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.4 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.4",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.4",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active"));a&&this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),c.preventDefault()}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.4",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));return a>this.$items.length-1||0>a?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.4",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=c(d),f={relatedTarget:this};e.hasClass("open")&&(e.trigger(b=a.Event("hide.bs.dropdown",f)),b.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f)))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.4",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a('',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(this.options.viewport.selector||this.options.viewport),this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c&&c.$tip&&c.$tip.is(":visible")?void(c.hoverState="in"):(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.options.container?a(this.options.container):this.$element.parent(),p=this.getPosition(o);h="bottom"==h&&k.bottom+m>p.bottom?"top":"top"==h&&k.top-mp.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.width&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){return this.$tip=this.$tip||a(this.options.template)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type)})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;(e||!/destroy|hide/.test(b))&&(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.4",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.4",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.4",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return c>e?"top":!1;if("bottom"==this.affixed)return null!=c?e+this.unpin<=f.top?!1:"bottom":a-d>=e+g?!1:"bottom";var h=null==this.affixed,i=h?e:f.top,j=h?g:b;return null!=c&&c>=e?"top":null!=d&&i+j>=a-d?"bottom":!1},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=a(document.body).height();"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); -------------------------------------------------------------------------------- /public/js/eventlog.tpl.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; 3 | templates['eventlog'] = template({"1":function(container,depth0,helpers,partials,data) { 4 | var helper; 5 | 6 | return " "; 9 | },"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data) { 10 | var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; 11 | 12 | return "\n " 13 | + alias4(((helper = (helper = helpers.eventtype || (depth0 != null ? depth0.eventtype : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"eventtype","hash":{},"data":data}) : helper))) 14 | + "\n " 15 | + ((stack1 = ((helper = (helper = helpers.htmltext || (depth0 != null ? depth0.htmltext : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"htmltext","hash":{},"data":data}) : helper))) != null ? stack1 : "") 16 | + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.headers : depth0),{"name":"if","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") 17 | + "\n " 18 | + alias4(((helper = (helper = helpers.time || (depth0 != null ? depth0.time : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"time","hash":{},"data":data}) : helper))) 19 | + "\n " 20 | + alias4(((helper = (helper = helpers.secs || (depth0 != null ? depth0.secs : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"secs","hash":{},"data":data}) : helper))) 21 | + "\n\n"; 22 | },"useData":true}); 23 | })(); -------------------------------------------------------------------------------- /public/js/modal.tpl.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; 3 | templates['modal'] = template({"1":function(container,depth0,helpers,partials,data) { 4 | var helper, alias1=container.escapeExpression; 5 | 6 | return "
  • " 7 | + alias1(((helper = (helper = helpers.key || (data && data.key)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),{"name":"key","hash":{},"data":data}) : helper))) 8 | + ": " 9 | + alias1(container.lambda(depth0, depth0)) 10 | + "
  • \n"; 11 | },"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data) { 12 | var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}); 13 | 14 | return "
    \n
    \n
    \n
    \n
      \n" 17 | + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.headers : depth0),{"name":"each","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") 18 | + "
    \n
    \n
    \n
    \n
    \n"; 19 | },"useData":true}); 20 | })(); -------------------------------------------------------------------------------- /public/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsscloud/rsscloud-server/e221eb8cdeae6f83c92724822e4e2517b915047f/public/sw.js -------------------------------------------------------------------------------- /public/templates/eventlog.handlebars: -------------------------------------------------------------------------------- 1 | 2 | {{eventtype}} 3 | {{{htmltext}}}{{#if headers}} {{/if}} 4 | {{time}} 5 | {{secs}} 6 | 7 | -------------------------------------------------------------------------------- /public/templates/modal.handlebars: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /services/app-messages.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | module.exports = { 5 | error: { 6 | subscription: { 7 | missingParams: 'The following parameters were missing from the request body: %s.', 8 | invalidProtocol: 'Can\'t accept the subscription because the protocol, %s, is unsupported.', 9 | readResource: 'The subscription was cancelled because there was an error reading the resource at URL %s.', 10 | noResources: 'No resources specified.', 11 | failedHandler: 'The subscription was cancelled because the call failed when we tested the handler.' 12 | }, 13 | ping: { 14 | tooRecent: 'Can\'t accept the request because the minimum seconds between pings is %s and you pinged us %s seconds ago.', 15 | readResource: 'The ping was cancelled because there was an error reading the resource at URL %s.' 16 | }, 17 | rpc: { 18 | notEnoughParams: 'Can\'t call "%s" because there aren\'t enough parameters.', 19 | tooManyParams: 'Can\'t call "%s" because there are too many parameters.' 20 | } 21 | }, 22 | log: { 23 | subscription: 'Subscriber %s requests notification when the resource changes via %s protocol.', 24 | ping: 'The resource was said to have changed. We checked and the claim appears to be %s.', 25 | notify: 'Subscriber %s was notified that resource has changed via %s protocol.', 26 | notifyFailed: 'Failed to notify subscriber %s that resource has changed via %s protocol.' 27 | }, 28 | success: { 29 | subscription: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!', 30 | ping: 'Thanks for the ping.' 31 | } 32 | }; 33 | }()); 34 | -------------------------------------------------------------------------------- /services/error-response.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | function ErrorResponse(message) { 5 | this.message = message; 6 | } 7 | 8 | ErrorResponse.prototype = Object.create(Error.prototype); 9 | ErrorResponse.prototype.constructor = ErrorResponse; 10 | ErrorResponse.prototype.name = 'ErrorResponse'; 11 | 12 | module.exports = ErrorResponse; 13 | }()); 14 | -------------------------------------------------------------------------------- /services/error-result.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | function errorResult(err) { 5 | return { 6 | 'success': false, 7 | 'msg': err 8 | }; 9 | } 10 | 11 | module.exports = errorResult; 12 | }()); 13 | -------------------------------------------------------------------------------- /services/get-random-password.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const crypto = require('crypto'); 5 | 6 | function getRandomPassword(len) { 7 | return crypto.randomBytes(Math.ceil(len * 3 / 4)) 8 | .toString('base64') // convert to base64 format 9 | .slice(0, len) // return required number of characters 10 | .replace(/\+/g, '0') // replace '+' with '0' 11 | .replace(/\//g, '0'); // replace '/' with '0' 12 | } 13 | 14 | module.exports = getRandomPassword; 15 | }()); 16 | -------------------------------------------------------------------------------- /services/init-resource.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const moment = require('moment'); 5 | 6 | function initResource(resource) { 7 | const defaultResource = { 8 | flDirty: true, 9 | lastSize: 0, 10 | lastHash: '', 11 | ctChecks: 0, 12 | whenLastCheck: new Date(moment.utc('0', 'x').format()), 13 | ctUpdates: 0, 14 | whenLastUpdate: new Date(moment.utc('0', 'x').format()) 15 | }; 16 | 17 | return Object.assign({}, defaultResource, resource); 18 | } 19 | 20 | module.exports = initResource; 21 | }()); 22 | -------------------------------------------------------------------------------- /services/init-subscription.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const config = require('../config'), 5 | moment = require('moment'); 6 | 7 | function initSubscription(subscriptions, notifyProcedure, apiurl, protocol) { 8 | const defaultSubscription = { 9 | ctUpdates: 0, 10 | whenLastUpdate: new Date(moment.utc('0', 'x').format()), 11 | ctErrors: 0, 12 | ctConsecutiveErrors: 0, 13 | whenLastError: new Date(moment.utc('0', 'x').format()), 14 | whenExpires: new Date(moment().utc().add(config.ctSecsResourceExpire, 'seconds').format()), 15 | url: apiurl, 16 | notifyProcedure, 17 | protocol 18 | }, 19 | 20 | index = subscriptions.pleaseNotify.findIndex(subscription => { 21 | return subscription.url === apiurl; 22 | }); 23 | 24 | if (-1 === index) { 25 | subscriptions.pleaseNotify.push(defaultSubscription); 26 | } else { 27 | subscriptions.pleaseNotify[index] = Object.assign( 28 | {}, 29 | defaultSubscription, 30 | subscriptions.pleaseNotify[index] 31 | ); 32 | } 33 | 34 | return subscriptions; 35 | } 36 | 37 | module.exports = initSubscription; 38 | }()); 39 | -------------------------------------------------------------------------------- /services/log-emitter.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | var EventEmitter = require('events').EventEmitter; 5 | 6 | module.exports = new EventEmitter(); 7 | }()); 8 | -------------------------------------------------------------------------------- /services/log-event.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const logEmitter = require('./log-emitter'), 5 | moment = require('moment'), 6 | mongodb = require('./mongodb'); 7 | 8 | async function logEvent(eventtype, htmltext, startticks, req) { 9 | let secs, time; 10 | 11 | time = moment(); 12 | secs = (parseInt(time.format('x'), 10) - parseInt(startticks, 10)) / 1000; 13 | 14 | if (undefined === req) { 15 | req = { headers: false }; 16 | } 17 | 18 | const res = await mongodb.get('rsscloud') 19 | .collection('events') 20 | .insertOne({ 21 | eventtype, 22 | htmltext, 23 | secs, 24 | time: new Date(time.utc().format()), 25 | headers: JSON.stringify(req.headers) 26 | }); 27 | 28 | logEmitter.emit('logged-event', JSON.stringify({ 29 | 'id': res.insertedId.toHexString(), 30 | 'eventtype': eventtype, 31 | 'htmltext': htmltext, 32 | 'secs': secs, 33 | 'time': new Date(time.utc().format()), 34 | 'headers': req.headers 35 | })); 36 | } 37 | 38 | module.exports = logEvent; 39 | }()); 40 | -------------------------------------------------------------------------------- /services/mongodb.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const { MongoClient } = require('mongodb'), 5 | state = {}; 6 | 7 | async function connect(name, uri) { 8 | if (state[name]) { 9 | return; 10 | } 11 | 12 | const client = await MongoClient.connect(uri, { useUnifiedTopology: true }); 13 | 14 | state[name] = client; 15 | 16 | // console.log(`${name} Database Connected`); 17 | 18 | return state[name].db(); 19 | } 20 | 21 | function get(name) { 22 | return state[name].db(); 23 | } 24 | 25 | async function close(name) { 26 | if (state[name]) { 27 | return state[name].close() 28 | .finally(() => { 29 | delete state[name]; 30 | }); 31 | } 32 | } 33 | 34 | async function closeAll() { 35 | await Promise.all(Object.keys(state).map(name => close)); 36 | } 37 | 38 | function cleanup() { 39 | closeAll() 40 | .finally(() => { 41 | process.exit(); 42 | }); 43 | } 44 | 45 | process.on('SIGINT', cleanup); 46 | process.on('SIGTERM', cleanup); 47 | 48 | module.exports = { 49 | connect, 50 | get, 51 | close, 52 | closeAll 53 | }; 54 | }()); 55 | -------------------------------------------------------------------------------- /services/notify-one-challenge.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const config = require('../config'), 5 | ErrorResponse = require('./error-response'), 6 | notifyOne = require('./notify-one'), 7 | getRandomPassword = require('./get-random-password'), 8 | querystring = require('querystring'), 9 | request = require('request-promise-native'); 10 | 11 | async function notifyOneChallengeRest(apiurl, resourceUrl) { 12 | const challenge = getRandomPassword(20), 13 | testUrl = apiurl + '?' + querystring.stringify({ 14 | 'url': resourceUrl, 15 | 'challenge': challenge 16 | }), 17 | res = await request({ 18 | method: 'GET', 19 | uri: testUrl, 20 | timeout: config.requestTimeout, 21 | resolveWithFullResponse: true 22 | }); 23 | 24 | console.log(`GET ${testUrl}`); 25 | 26 | if (res.statusCode < 200 || res.statusCode > 299 || res.body !== challenge) { 27 | throw new ErrorResponse('Notification Failed'); 28 | } 29 | } 30 | 31 | function notifyOneChallenge(notifyProcedure, apiurl, protocol, resourceUrl) { 32 | if ('xml-rpc' === protocol) { 33 | // rssCloud.root originally didn't support this flow 34 | return notifyOne(notifyProcedure, apiurl, protocol, resourceUrl); 35 | } 36 | 37 | return notifyOneChallengeRest(apiurl, resourceUrl); 38 | } 39 | 40 | module.exports = notifyOneChallenge; 41 | }()); 42 | -------------------------------------------------------------------------------- /services/notify-one.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const builder = require('xmlbuilder'), 5 | config = require('../config'), 6 | ErrorResponse = require('./error-response'), 7 | request = require('request-promise-native'); 8 | 9 | async function notifyOneRest(apiurl, resourceUrl) { 10 | let res; 11 | 12 | try { 13 | res = await request({ 14 | method: 'POST', 15 | uri: apiurl, 16 | timeout: config.requestTimeout, 17 | form: { 18 | 'url': resourceUrl 19 | }, 20 | resolveWithFullResponse: true 21 | }); 22 | } catch (err) { 23 | if (!err.response) { 24 | throw err; 25 | } 26 | 27 | res = err.response; 28 | if (res.statusCode >= 300 || res.statusCode < 400) { 29 | if (res.headers.location) { 30 | const location = new URL(res.headers.location, apiurl); 31 | return notifyOneRest(location.toString(), resourceUrl); 32 | } 33 | } 34 | } 35 | 36 | if (res.statusCode < 200 || res.statusCode > 299) { 37 | throw new ErrorResponse('Notification Failed'); 38 | } 39 | 40 | return true; 41 | } 42 | 43 | async function notifyOneRpc(notifyProcedure, apiurl, resourceUrl) { 44 | const xmldoc = builder.create({ 45 | methodCall: { 46 | methodName: notifyProcedure, 47 | params: { 48 | param: [ 49 | { value: resourceUrl } 50 | ] 51 | } 52 | } 53 | }).end({ pretty: true }); 54 | 55 | let res = await request({ 56 | method: 'POST', 57 | uri: apiurl, 58 | timeout: 4000, 59 | body: xmldoc, 60 | resolveWithFullResponse: true, 61 | headers: { 62 | 'content-type': 'text/xml' 63 | } 64 | }); 65 | 66 | if (res.statusCode < 200 || res.statusCode > 299) { 67 | throw new ErrorResponse('Notification Failed'); 68 | } 69 | 70 | return true; 71 | } 72 | 73 | function notifyOne(notifyProcedure, apiurl, protocol, resourceUrl) { 74 | if ('xml-rpc' === protocol) { 75 | return notifyOneRpc(notifyProcedure, apiurl, resourceUrl); 76 | } 77 | 78 | return notifyOneRest(apiurl, resourceUrl); 79 | } 80 | 81 | module.exports = notifyOne; 82 | }()); 83 | -------------------------------------------------------------------------------- /services/notify-subscribers.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const appMessages = require('./app-messages'), 5 | config = require('../config'), 6 | logEvent = require('./log-event'), 7 | moment = require('moment'), 8 | mongodb = require('./mongodb'), 9 | notifyOne = require('./notify-one'), 10 | sprintf = require('sprintf-js').sprintf, 11 | url = require('url'); 12 | 13 | async function fetchSubscriptions(resourceUrl) { 14 | const subscriptions = await mongodb.get('rsscloud') 15 | .collection('subscriptions') 16 | .findOne({ 17 | _id: resourceUrl 18 | }); 19 | 20 | return subscriptions || { _id: resourceUrl, pleaseNotify: [] }; 21 | } 22 | 23 | async function upsertSubscriptions(subscriptions) { 24 | await mongodb.get('rsscloud') 25 | .collection('subscriptions') 26 | .replaceOne( 27 | { _id: subscriptions._id }, 28 | subscriptions, 29 | { upsert: true } 30 | ); 31 | } 32 | 33 | async function notifyOneSubscriber(resourceUrl, subscription) { 34 | const apiurl = subscription.url, 35 | startticks = moment().format('x'), 36 | parts = url.parse(apiurl), 37 | notifyProcedure = subscription.notifyProcedure, 38 | protocol = subscription.protocol; 39 | 40 | try { 41 | await notifyOne(notifyProcedure, apiurl, protocol, resourceUrl); 42 | 43 | subscription.ctUpdates += 1; 44 | subscription.ctConsecutiveErrors = 0; 45 | subscription.whenLastUpdate = new Date(moment().utc().format()); 46 | 47 | await logEvent( 48 | 'Notify', 49 | sprintf(appMessages.log.notify, apiurl, parts.host, resourceUrl, parts.protocol), 50 | startticks 51 | ); 52 | } catch (err) { 53 | console.error(err.message); 54 | 55 | subscription.ctErrors += 1; 56 | subscription.ctConsecutiveErrors += 1; 57 | subscription.whenLastError = new Date(moment().utc().format()); 58 | 59 | await logEvent( 60 | 'NotifyFailed', 61 | sprintf(appMessages.log.notifyFailed, apiurl, parts.host, resourceUrl, parts.protocol), 62 | startticks 63 | ); 64 | } 65 | } 66 | 67 | function filterSubscribers(subscription) { 68 | if (moment().isAfter(subscription.whenExpires)) { 69 | return false 70 | } 71 | 72 | if (subscription.ctConsecutiveErrors >= config.maxConsecutiveErrors) { 73 | return false; 74 | } 75 | 76 | return true; 77 | } 78 | 79 | async function notifySubscribers(resourceUrl) { 80 | const subscriptions = await fetchSubscriptions(resourceUrl); 81 | 82 | await Promise.all(subscriptions.pleaseNotify.filter(filterSubscribers).map(notifyOneSubscriber.bind(null, resourceUrl))); 83 | 84 | console.log('upserting subscriptions'); 85 | 86 | await upsertSubscriptions(subscriptions); 87 | 88 | console.log('upserted subscriptions'); 89 | } 90 | 91 | module.exports = notifySubscribers; 92 | }()); 93 | -------------------------------------------------------------------------------- /services/parse-notify-params.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const appMessages = require('./app-messages'), 5 | ErrorResponse = require('./error-response'), 6 | sprintf = require('sprintf-js').sprintf; 7 | 8 | function validProtocol(protocol) { 9 | switch (protocol) { 10 | case 'http-post': 11 | case 'https-post': 12 | case 'xml-rpc': 13 | return true; 14 | default: 15 | throw new ErrorResponse(sprintf(appMessages.error.subscription.invalidProtocol, protocol)); 16 | } 17 | } 18 | 19 | function parseUrlList(argv) { 20 | let key, urlList = []; 21 | 22 | if (undefined === argv.hasOwnProperty) { 23 | Object.setPrototypeOf(argv, {}); 24 | } 25 | 26 | for (key in argv) { 27 | if (argv.hasOwnProperty(key) && 0 === key.toLowerCase().indexOf('url')) { 28 | urlList.push(argv[key]); 29 | } 30 | } 31 | 32 | return urlList; 33 | } 34 | 35 | function glueUrlParts(scheme, client, port, path) { 36 | let apiurl = scheme + '://'; 37 | 38 | if (client.indexOf(':') > -1) { 39 | client = '[' + client + ']'; 40 | } 41 | 42 | apiurl += client + ':' + port; 43 | 44 | if (0 !== path.indexOf('/')) { 45 | path = '/' + path; 46 | } 47 | 48 | apiurl += path; 49 | 50 | return apiurl; 51 | } 52 | 53 | function rest(req) { 54 | let s = '', 55 | params = {}, 56 | parts = {}; 57 | 58 | if (validProtocol(req.body.protocol)) { 59 | params.protocol = req.body.protocol; 60 | } 61 | 62 | params.urlList = parseUrlList(req.body); 63 | 64 | if (null == req.body.domain || '' === req.body.domain) { 65 | parts.client = req.headers['x-forwarded-for'] || req.connection.remoteAddress; 66 | params.diffDomain = false; 67 | } else { 68 | parts.client = req.body.domain; 69 | params.diffDomain = true; 70 | } 71 | if (undefined === req.body.port) { 72 | s += 'port, '; 73 | } 74 | if (undefined === req.body.path) { 75 | s += 'path, '; 76 | } 77 | if (undefined === req.body.protocol) { 78 | s += 'protocol, '; 79 | } 80 | 81 | if (req.body.notifyProcedure && 'xml-rpc' === req.body.protocol) { 82 | params.notifyProcedure = req.body.notifyProcedure; 83 | } else { 84 | params.notifyProcedure = false; 85 | } 86 | 87 | if (0 === s.length) { 88 | parts.scheme = 'https-post' === params.protocol ? 'https' : 'http'; 89 | parts.port = req.body.port; 90 | parts.path = req.body.path; 91 | 92 | params.apiurl = glueUrlParts( 93 | parts.scheme, 94 | parts.client, 95 | parts.port, 96 | parts.path 97 | ); 98 | 99 | return params; 100 | } else { 101 | s = s.substr(0, s.length - 2); 102 | throw new ErrorResponse(sprintf(appMessages.error.subscription.missingParams, s)); 103 | } 104 | } 105 | 106 | function rpc(req, rpcParams) { 107 | let params = {}, 108 | parts = {}; 109 | 110 | if (5 > rpcParams.length) { 111 | throw new ErrorResponse(sprintf(appMessages.error.rpc.notEnoughParams, 'pleaseNotify')); 112 | } else if (6 < rpcParams.length) { 113 | throw new ErrorResponse(sprintf(appMessages.error.rpc.tooManyParams, 'pleaseNotify')); 114 | } 115 | 116 | if (validProtocol(rpcParams[3])) { 117 | params.protocol = rpcParams[3]; 118 | } 119 | 120 | params.urlList = rpcParams[4]; 121 | 122 | if (undefined === rpcParams[5]) { 123 | parts.client = req.headers['x-forwarded-for'] || req.connection.remoteAddress; 124 | params.diffDomain = false; 125 | } else { 126 | parts.client = rpcParams[5]; 127 | params.diffDomain = true; 128 | } 129 | 130 | if (rpcParams[0] && 'xml-rpc' === params.protocol) { 131 | params.notifyProcedure = rpcParams[0]; 132 | } else { 133 | params.notifyProcedure = false; 134 | } 135 | 136 | parts.scheme = 'https-post' === params.protocol ? 'https' : 'http'; 137 | parts.port = rpcParams[1]; 138 | parts.path = rpcParams[2]; 139 | 140 | params.apiurl = glueUrlParts( 141 | parts.scheme, 142 | parts.client, 143 | parts.port, 144 | parts.path 145 | ); 146 | 147 | return params; 148 | } 149 | 150 | module.exports = { rest, rpc }; 151 | }()); 152 | -------------------------------------------------------------------------------- /services/parse-ping-params.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const appMessages = require('./app-messages'), 5 | ErrorResponse = require('./error-response'), 6 | sprintf = require('sprintf-js').sprintf; 7 | 8 | function rest(req) { 9 | var s = '', 10 | params = {}; 11 | 12 | if (undefined === req.body.url) { 13 | s += 'url, '; 14 | } 15 | if (0 === s.length) { 16 | params.url = req.body.url; 17 | return params; 18 | } else { 19 | s = s.substr(0, s.length - 2); 20 | throw new ErrorResponse(sprintf(appMessages.error.subscription.missingParams, s)); 21 | } 22 | } 23 | 24 | function rpc(req, rpcParams) { 25 | let params = {}; 26 | 27 | if (1 > rpcParams.length) { 28 | throw new ErrorResponse(sprintf(appMessages.error.rpc.notEnoughParams, 'ping')); 29 | } else if (1 < rpcParams.length) { 30 | throw new ErrorResponse(sprintf(appMessages.error.rpc.tooManyParams, 'ping')); 31 | } 32 | 33 | params.url = rpcParams[0]; 34 | 35 | return params; 36 | } 37 | 38 | module.exports = { rest, rpc }; 39 | }()); 40 | -------------------------------------------------------------------------------- /services/parse-rpc-request.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const moment = require('moment'), 5 | xml2js = require('xml2js'); 6 | 7 | function parseRpcParam(param) { 8 | let returnedValue, tag, member, values; 9 | 10 | const value = param.value || param; 11 | 12 | for (tag in value) { 13 | switch (tag) { 14 | case 'i4': 15 | case 'int': 16 | case 'double': 17 | returnedValue = Number(value[tag]); 18 | break; 19 | case 'string': 20 | returnedValue = value[tag]; 21 | break; 22 | case 'boolean': 23 | returnedValue = 'true' === value[tag] || !!Number(value[tag]); 24 | break; 25 | case "dateTime.iso8601": 26 | returnedValue = moment.utc(value[tag], ['YYYYMMDDTHHmmss', moment.ISO_8601]); 27 | break; 28 | case "base64": 29 | returnedValue = Buffer.from(value[tag], "base64").toString('utf8'); 30 | break; 31 | case "struct": 32 | member = value[tag].member || []; 33 | if (!Array.isArray(member)) { 34 | member = [member]; 35 | } 36 | returnedValue = member.reduce((acc, item) => { 37 | acc[item.name] = parseRpcParam(item); 38 | return acc; 39 | }, {}); 40 | break; 41 | case 'array': 42 | values = (value[tag].data || {}).value || []; 43 | if (!Array.isArray(values)) { 44 | values = [values]; 45 | } 46 | returnedValue = values.map(parseRpcParam); 47 | break; 48 | } 49 | } 50 | 51 | if (undefined === returnedValue) { 52 | returnedValue = value; 53 | } 54 | 55 | return returnedValue; 56 | } 57 | 58 | async function parseRpcRequest(req) { 59 | const parser = new xml2js.Parser({ explicitArray: false }), 60 | jstruct = await parser.parseStringPromise(req.body), 61 | methodCall = jstruct.methodCall, 62 | methodName = (methodCall || {}).methodName, 63 | params = ((methodCall || {}).params || {}).param || []; 64 | 65 | if (undefined === methodCall) { 66 | throw new Error('Bad XML-RPC call, missing "methodCall" element.'); 67 | } 68 | 69 | if (undefined === methodName) { 70 | throw new Error('Bad XML-RPC call, missing "methodName" element.'); 71 | } 72 | 73 | return { 74 | methodName, 75 | params: (Array.isArray(params) ? params : [params]).map(parseRpcParam) 76 | }; 77 | } 78 | 79 | module.exports = parseRpcRequest; 80 | }()); 81 | -------------------------------------------------------------------------------- /services/ping.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const appMessage = require('./app-messages'), 5 | config = require('../config'), 6 | crypto = require('crypto'), 7 | ErrorResponse = require('./error-response'), 8 | initResource = require('./init-resource'), 9 | logEvent = require('./log-event'), 10 | moment = require('moment'), 11 | mongodb = require('./mongodb'), 12 | notifySubscribers = require('./notify-subscribers'), 13 | request = require('request-promise-native'), 14 | sprintf = require('sprintf-js').sprintf; 15 | 16 | function checkPingFrequency(resource) { 17 | let ctsecs, minsecs = config.minSecsBetweenPings; 18 | if (0 < minsecs) { 19 | ctsecs = moment().diff(resource.whenLastCheck, 'seconds'); 20 | if (ctsecs < minsecs) { 21 | throw new ErrorResponse(sprintf(appMessage.error.ping.tooRecent, minsecs, ctsecs)); 22 | } 23 | } 24 | } 25 | 26 | function md5Hash(value) { 27 | return crypto.createHash('md5').update(value).digest('hex'); 28 | } 29 | 30 | async function checkForResourceChange(resource, resourceUrl, startticks) { 31 | let res; 32 | 33 | try { 34 | res = await request({ 35 | method: 'GET', 36 | uri: resourceUrl, 37 | timeout: config.requestTimeout, 38 | resolveWithFullResponse: true 39 | }); 40 | } catch (err) { 41 | res = { statusCode: 404 }; 42 | } 43 | 44 | resource.ctChecks += 1; 45 | resource.whenLastCheck = new Date(moment().utc().format()); 46 | 47 | if (res.statusCode < 200 || res.statusCode > 299) { 48 | throw new ErrorResponse(sprintf(appMessage.error.ping.readResource, resourceUrl)); 49 | } 50 | 51 | const hash = md5Hash(res.body); 52 | 53 | if (resource.lastHash !== hash) { 54 | resource.flDirty = true; 55 | } else if (resource.lastSize !== res.body.length) { 56 | resource.flDirty = true; 57 | } else { 58 | resource.flDirty = false; 59 | } 60 | 61 | resource.lastHash = hash; 62 | resource.lastSize = res.body.length; 63 | 64 | await logEvent( 65 | 'Ping', 66 | sprintf(appMessage.log.ping, resourceUrl, resource.flDirty.toString()), 67 | startticks 68 | ); 69 | } 70 | 71 | async function fetchResource(resourceUrl) { 72 | const resource = await mongodb.get('rsscloud') 73 | .collection('resources') 74 | .findOne({ 75 | _id: resourceUrl 76 | }); 77 | 78 | return resource || { _id: resourceUrl }; 79 | } 80 | 81 | async function upsertResource(resource) { 82 | await mongodb.get('rsscloud') 83 | .collection('resources') 84 | .replaceOne( 85 | { _id: resource._id }, 86 | resource, 87 | { upsert: true } 88 | ); 89 | } 90 | 91 | async function notifySubscribersIfDirty(resource, resourceUrl) { 92 | if (resource.flDirty) { 93 | resource.ctUpdates += 1; 94 | resource.whenLastUpdate = new Date(moment().utc().format()); 95 | return await notifySubscribers(resourceUrl); 96 | } 97 | } 98 | 99 | async function ping(resourceUrl) { 100 | const startticks = moment().format('x'), 101 | resource = initResource( 102 | await fetchResource(resourceUrl) 103 | ); 104 | 105 | checkPingFrequency(resource); 106 | await checkForResourceChange(resource, resourceUrl, startticks); 107 | await notifySubscribersIfDirty(resource, resourceUrl); 108 | await upsertResource(resource); 109 | 110 | return { 111 | 'success': true, 112 | 'msg': appMessage.success.ping 113 | }; 114 | } 115 | 116 | module.exports = ping; 117 | }()); 118 | -------------------------------------------------------------------------------- /services/please-notify.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const appMessages = require('./app-messages'), 5 | config = require('../config'), 6 | ErrorResponse = require('./error-response'), 7 | initSubscription = require('./init-subscription'), 8 | logEvent = require('./log-event'), 9 | moment = require('moment'), 10 | mongodb = require('./mongodb'), 11 | notifyOne = require('./notify-one'), 12 | notifyOneChallenge = require('./notify-one-challenge'), 13 | request = require('request-promise-native'), 14 | sprintf = require('sprintf-js').sprintf, 15 | url = require('url'); 16 | 17 | async function checkresourceUrlStatusCode(resourceUrl) { 18 | return request({ 19 | method: 'GET', 20 | uri: resourceUrl, 21 | timeout: config.requestTimeout, 22 | resolveWithFullResponse: true 23 | }) 24 | .then(res => { 25 | if (res.statusCode < 200 || res.statusCode > 299) { 26 | throw new ErrorResponse(sprintf(appMessages.error.subscription.readResource, resourceUrl)); 27 | } 28 | }) 29 | .catch(() => { 30 | throw new ErrorResponse(sprintf(appMessages.error.subscription.readResource, resourceUrl)); 31 | }); 32 | } 33 | 34 | async function fetchSubscriptions(resourceUrl) { 35 | const subscriptions = await mongodb.get('rsscloud') 36 | .collection('subscriptions') 37 | .findOne({ 38 | _id: resourceUrl 39 | }); 40 | 41 | return subscriptions || { _id: resourceUrl, pleaseNotify: [] }; 42 | } 43 | 44 | async function upsertSubscriptions(subscriptions) { 45 | await mongodb.get('rsscloud') 46 | .collection('subscriptions') 47 | .replaceOne( 48 | { _id: subscriptions._id }, 49 | subscriptions, 50 | { upsert: true } 51 | ); 52 | } 53 | 54 | async function notifyApiUrl(notifyProcedure, apiurl, protocol, resourceUrl, diffDomain) { 55 | const subscriptions = await fetchSubscriptions(resourceUrl), 56 | startticks = moment().format('x'), 57 | parts = url.parse(apiurl); 58 | 59 | initSubscription(subscriptions, notifyProcedure, apiurl, protocol); 60 | 61 | try { 62 | if (diffDomain) { 63 | await notifyOneChallenge(notifyProcedure, apiurl, protocol, resourceUrl); 64 | } else { 65 | await notifyOne(notifyProcedure, apiurl, protocol, resourceUrl); 66 | } 67 | 68 | const index = subscriptions.pleaseNotify.findIndex(subscription => { 69 | return subscription.url === apiurl; 70 | }); 71 | 72 | subscriptions.pleaseNotify[index].ctUpdates += 1; 73 | subscriptions.pleaseNotify[index].ctConsecutiveErrors = 0; 74 | subscriptions.pleaseNotify[index].whenLastUpdate = new Date(moment().utc().format()); 75 | subscriptions.pleaseNotify[index].whenExpires = moment().utc().add(config.ctSecsResourceExpire, 'seconds').format(); 76 | 77 | await upsertSubscriptions(subscriptions); 78 | 79 | await logEvent( 80 | 'Subscribe', 81 | sprintf(appMessages.log.subscription, apiurl, parts.host, resourceUrl, parts.protocol), 82 | startticks 83 | ); 84 | } catch (err) { 85 | console.dir(err); 86 | throw new ErrorResponse(appMessages.error.subscription.failedHandler); 87 | } 88 | } 89 | 90 | async function pleaseNotify(notifyProcedure, apiurl, protocol, urlList, diffDomain) { 91 | if (0 === urlList.length) { 92 | throw new ErrorResponse(appMessages.error.subscription.noResources); 93 | } 94 | 95 | let lastErr, resourceUrl; 96 | 97 | for (resourceUrl of urlList) { 98 | try { 99 | await checkresourceUrlStatusCode(resourceUrl); 100 | await notifyApiUrl(notifyProcedure, apiurl, protocol, resourceUrl, diffDomain); 101 | } catch (err) { 102 | lastErr = err; 103 | } 104 | } 105 | 106 | if (lastErr) { 107 | throw lastErr; 108 | } 109 | 110 | return { 111 | 'success': true, 112 | 'msg': appMessages.success.subscription 113 | }; 114 | } 115 | 116 | module.exports = pleaseNotify; 117 | }()); 118 | -------------------------------------------------------------------------------- /services/remove-expired-subscriptions.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | // TODO: Rewrite for mongodb 5 | 6 | var moment = require('moment'), 7 | mongodb = require('./mongodb'); 8 | 9 | function checkSubscription(data, resourceUrl, apiurl) { 10 | var subscription; 11 | subscription = data.subscriptions[resourceUrl][apiurl]; 12 | if (moment(subscription.whenExpires).isBefore(moment())) { 13 | delete data.subscriptions[resourceUrl][apiurl]; 14 | } else if (subscription.ctConsecutiveErrors > data.prefs.maxConsecutiveErrors) { 15 | delete data.subscriptions[resourceUrl][apiurl]; 16 | } 17 | } 18 | 19 | function scanApiUrls(data, resourceUrl) { 20 | var apiurl, subscriptions; 21 | subscriptions = data.subscriptions[resourceUrl]; 22 | for (apiurl in subscriptions) { 23 | if (subscriptions.hasOwnProperty(apiurl)) { 24 | checkSubscription(data, resourceUrl, apiurl); 25 | } 26 | } 27 | if (0 === subscriptions.length) { 28 | delete data.subscriptions[resourceUrl]; 29 | } 30 | } 31 | 32 | function scanResources(data) { 33 | var resourceUrl; 34 | for (resourceUrl in data.subscriptions) { 35 | if (data.subscriptions.hasOwnProperty(resourceUrl)) { 36 | scanApiUrls(data, resourceUrl); 37 | } 38 | } 39 | } 40 | 41 | function removeExpiredSubscriptions(data) { 42 | scanResources(data); 43 | } 44 | 45 | module.exports = removeExpiredSubscriptions; 46 | }()); 47 | -------------------------------------------------------------------------------- /services/rest-return-success.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const builder = require('xmlbuilder'); 5 | 6 | function restReturnSuccess(success, message, element) { 7 | element = element || 'result'; 8 | 9 | return builder.create(element) 10 | .att('success', success ? 'true' : 'false') 11 | .att('msg', message) 12 | .end({'pretty': true}); 13 | } 14 | 15 | module.exports = restReturnSuccess; 16 | }()); 17 | -------------------------------------------------------------------------------- /services/rpc-return-fault.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const builder = require('xmlbuilder'); 5 | 6 | function rpcReturnFault(faultCode, faultString) { 7 | return builder.create({ 8 | methodResponse: { 9 | fault: { 10 | value: { 11 | struct: { 12 | member: [ 13 | { 14 | name: 'faultCode', 15 | value: { 16 | int: faultCode 17 | } 18 | }, 19 | { 20 | name: 'faultString', 21 | value: { 22 | string: faultString 23 | } 24 | } 25 | ] 26 | } 27 | } 28 | } 29 | } 30 | }).end({'pretty': true}); 31 | } 32 | 33 | module.exports = rpcReturnFault; 34 | }()); 35 | -------------------------------------------------------------------------------- /services/rpc-return-success.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | 4 | const builder = require('xmlbuilder'); 5 | 6 | function rpcReturnSuccess(success) { 7 | return builder.create({ 8 | methodResponse: { 9 | params: { 10 | param: [ 11 | { 12 | value: { 13 | boolean: success ? 1 : 0 14 | } 15 | } 16 | ] 17 | } 18 | } 19 | }).end({'pretty': true}); 20 | } 21 | 22 | module.exports = rpcReturnSuccess; 23 | }()); 24 | -------------------------------------------------------------------------------- /test/fixtures.js: -------------------------------------------------------------------------------- 1 | const mongodb = require("./mongodb"); 2 | 3 | exports.mochaGlobalSetup = async function () { 4 | await mongodb.before(); 5 | }; 6 | 7 | exports.mochaGlobalTeardown = async function () { 8 | await mongodb.after(); 9 | }; 10 | -------------------------------------------------------------------------------- /test/keys/README.md: -------------------------------------------------------------------------------- 1 | # Key Security 2 | 3 | These keys are used by the mock server for testing but not by anything in production. 4 | -------------------------------------------------------------------------------- /test/keys/server.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDFDCCAfwCCQCYIfkY4/1xxzANBgkqhkiG9w0BAQsFADBMMQswCQYDVQQGEwJV 3 | UzESMBAGA1UECAwJV2lzY29uc2luMRAwDgYDVQQHDAdNYWRpc29uMRcwFQYDVQQD 4 | DA5yZXNjbG91ZC10ZXN0czAeFw0yMDA5MTMwMTQ1MjRaFw0yMDEwMTMwMTQ1MjRa 5 | MEwxCzAJBgNVBAYTAlVTMRIwEAYDVQQIDAlXaXNjb25zaW4xEDAOBgNVBAcMB01h 6 | ZGlzb24xFzAVBgNVBAMMDnJlc2Nsb3VkLXRlc3RzMIIBIjANBgkqhkiG9w0BAQEF 7 | AAOCAQ8AMIIBCgKCAQEA3Ydfk7ciyH45bL7AscNNrkFDNh+F2mDH22ZOwASGER+n 8 | AumWXx0UsesvX0j0PJQFlJfV1CBqOFn65O62AP38fQIjAFdxPDSKFN8smrEJ5+Xt 9 | FavUm159yxJTnCsPPCQC2PLFbvUPkBkluFhO9FCxFMqTUXqzhnrOaLr67Rlo4kbx 10 | xftWanTzVfCXOWdH1gxD7l7vShi5mIicXDbfncRCef//wDbb9b0S4vMBDhmdi8rr 11 | 6iF0WMqgxeGqx/ZuF82/xVJUNS6+/8zbVcuO/sz1J6+hZQfHA7tpvQj7QcLPnvn2 12 | zlrtqNU0hPJuzSxw8A7GWJaJZL/b9vD9jyRTSOiWvQIDAQABMA0GCSqGSIb3DQEB 13 | CwUAA4IBAQBWlR8vXlzunWPwKYuboQwlVdhfaQfy9eDgT8aGsbVewiWc2n46aqyY 14 | eevFTp6CxjpC1+g4EPGMIV8ZmqHH2rMLpd/APRFwDpAKpFdnJ1wyFyBpdJP4TO0i 15 | AqZeIhS3jN5rDvNx+MZm5MyiRfes3MXspQmxwkNMXt+FbTkzKAj6UNKzoaW3UK/V 16 | 4hxtzY+oGOvQ76waNy36zvQPFb/Lq0/YiGgPt9UL2CHc0qc8whoB2FH8u3ObX8G6 17 | CezY9nawiwuzQBh9EUsiw80ZoA1FJvKKNjGQyAlTbwSmDWjXPV5PfIE2XRTwSSKa 18 | ke289rzHpMaeI4XF2IysTF0pzNBB+CCq 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/keys/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDdh1+TtyLIfjls 3 | vsCxw02uQUM2H4XaYMfbZk7ABIYRH6cC6ZZfHRSx6y9fSPQ8lAWUl9XUIGo4Wfrk 4 | 7rYA/fx9AiMAV3E8NIoU3yyasQnn5e0Vq9SbXn3LElOcKw88JALY8sVu9Q+QGSW4 5 | WE70ULEUypNRerOGes5ouvrtGWjiRvHF+1ZqdPNV8Jc5Z0fWDEPuXu9KGLmYiJxc 6 | Nt+dxEJ5///ANtv1vRLi8wEOGZ2LyuvqIXRYyqDF4arH9m4Xzb/FUlQ1Lr7/zNtV 7 | y47+zPUnr6FlB8cDu2m9CPtBws+e+fbOWu2o1TSE8m7NLHDwDsZYlolkv9v28P2P 8 | JFNI6Ja9AgMBAAECggEBAJTF7mU4r45wXsZuUODRJeEqCSZXA0DGkFIDIk4Ie7US 9 | ScpF72vPDu/x322hhA0O57SER+FfJ5bSrxGCUw7Vg/S8M59uGijxq+4ad00oKlvn 10 | 6VINhQoW6mov06DktiWQlaz3VaqPv7TpfpCQFe40Jsisx0UIH3roDKj/Mbd7whl7 11 | hYRShEGydZqmP1uM8xkNjinsoGAGbVuD8gosu6RSRo0cSKEkXsM0yeaiyeTVagZh 12 | njnqPmNk51+0xtJ9yX16i1BGeACMCaEzyesCvQ977i6qNOJcQUGxpZliu7yUJ2xp 13 | zvNTcM4FI7E0Hdnq44ctMK0YIKam8qcy6JnaHEGZcA0CgYEA+OWY51/0nKjEZ0nk 14 | Ft0xPbPVZaqyd6UgU9rdBY4rlHKVGTypklVgs8ztGWfbey1Y+Dty885NIlACy+al 15 | U8LZOmCX88Zn/aZVMJKvjY1IZakIVs+bJyw5wIU+RW6Xet8GdW9LOfWrLLAzR5Kx 16 | x0hplJ7poFNpqAZrb2lnx1LHvhMCgYEA49nUQ68Rc6dICVO78c+kYhpMPfDcM56P 17 | k3Yab5uR8HNjuXUCwGsjESeSuWSsA43zRAodHK4jOsnEMXjs2bQePOpixHtLDjZw 18 | MrAwwKcNdu2ojl8LnqBg7Jt3dWBsNrRYk2+9nQrEKahYTObGo/0/cR+C3tl3U2xW 19 | 3KLbK3ZRse8CgYEA0sNZn1QaBy7CYdSoWNQlhmJqKhVJ2xbxMRD8aF/jd2kv6Jb9 20 | 4PR7VsYJYKIZsUzNMiblSJ9BofSu6Yr+JNeyq0RwZ/Vyyc+4A9Gy5uLkk3F3pktW 21 | zu0cWnJ6MMFddKa3CWqdAQ+PS+BJDCqkcGRZQ4jtvN3lcms9PrK7NoZarhECgYEA 22 | pg2QZqtl2pWlKDcAF+Ct+lkUC2hH8s6x8FP4PDJyj3xoxzYBhV4PNmdSxQ4bVpPT 23 | IFkGkmpOfn+hHjT02EwuZVKkHHGGZ5tA9Y/2V9DnPS9C0k9uVnKncIh07PQSqWOR 24 | 0hFzbMn14C4x/AmL5uOfor/GTsbJ8eBZBX8in3AhZb0CgYBPueLsAvBuMc7amPDV 25 | wKYJF6HJglgGJSFdc1a7lgPBJQzyQwD8Rew0zrJ4kVAi3/7y9L7esWh9P5qgIOY6 26 | yBg/D7kYnK0hd3U+F9LWJKj3PZAuuYhYO41sqgFGwqzGE8ICMvTq95Nskgp8N9hs 27 | UMl2SEGe5njpTfExAw7Vs4Luow== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /test/mock.js: -------------------------------------------------------------------------------- 1 | const https = require('https'), 2 | fs = require('fs'), 3 | express = require('express'), 4 | bodyParser = require('body-parser'), 5 | textParser = bodyParser.text({ type: '*/xml'}), 6 | urlencodedParser = bodyParser.urlencoded({ extended: false }), 7 | parseRpcRequest = require('../services/parse-rpc-request'), 8 | querystring = require('querystring'), 9 | MOCK_SERVER_DOMAIN = process.env.MOCK_SERVER_DOMAIN, 10 | MOCK_SERVER_PORT = process.env.MOCK_SERVER_PORT || 8002, 11 | MOCK_SERVER_URL = process.env.MOCK_SERVER_URL || `http://${MOCK_SERVER_DOMAIN}:${MOCK_SERVER_PORT}`, 12 | SECURE_MOCK_SERVER_PORT = process.env.SECURE_MOCK_SERVER_PORT || 8003, 13 | SECURE_MOCK_SERVER_URL = process.env.SECURE_MOCK_SERVER_URL || `https://${MOCK_SERVER_DOMAIN}:${SECURE_MOCK_SERVER_PORT}`, 14 | rpcReturnFault = require('../services/rpc-return-fault'); 15 | 16 | async function restController(req, res) { 17 | const method = req.method, 18 | path = req.path; 19 | 20 | if (this.routes[method] && this.routes[method][path]) { 21 | this.requests[method][path].push(req); 22 | let responseBody = this.routes[method][path].responseBody; 23 | if (300 <= this.routes[method][path].status && 400 > this.routes[method][path].status) { 24 | let location = typeof responseBody === 'function' ? await responseBody(req) : responseBody; 25 | if (0 < Object.keys(req.query).length) { 26 | location += '?' + querystring.stringify(req.query); 27 | } 28 | res 29 | .redirect( 30 | this.routes[method][path].status, 31 | location 32 | ); 33 | } else { 34 | res 35 | .status(this.routes[method][path].status) 36 | .send(typeof responseBody === 'function' ? await responseBody(req) : responseBody); 37 | } 38 | } else { 39 | res 40 | .status(501) 41 | .send(`Unknown route ${method} ${path}`); 42 | } 43 | } 44 | 45 | async function rpcController(req, res) { 46 | try { 47 | req.rpcBody = await parseRpcRequest(req); 48 | const method = req.rpcBody.methodName; 49 | 50 | if (this.routes.RPC2[method]) { 51 | this.requests.RPC2[method].push(req); 52 | let responseBody = this.routes.RPC2[method].responseBody; 53 | res 54 | .status(200) 55 | .send(typeof responseBody === 'function' ? await responseBody(req) : responseBody); 56 | } else { 57 | res 58 | .status(501) 59 | .send(rpcReturnFault(1, `Unknown methodName ${method}`)); 60 | } 61 | } catch(err) { 62 | res 63 | .status(500) 64 | .send(rpcReturnFault(1, err.message)); 65 | } 66 | } 67 | 68 | module.exports = { 69 | app: express(), 70 | server: null, 71 | serverDomain: MOCK_SERVER_DOMAIN, 72 | serverPort: MOCK_SERVER_PORT, 73 | serverUrl: MOCK_SERVER_URL, 74 | secureServer: null, 75 | secureServerPort: SECURE_MOCK_SERVER_PORT, 76 | secureServerUrl: SECURE_MOCK_SERVER_URL, 77 | requests: { 78 | 'GET': {}, 79 | 'POST': {}, 80 | 'RPC2': {} 81 | }, 82 | routes: { 83 | 'GET': {}, 84 | 'POST': {}, 85 | 'RPC2': {} 86 | }, 87 | route: function (method, path, status, responseBody) { 88 | this.requests[method][path] = []; 89 | this.routes[method][path] = { 90 | status, 91 | responseBody 92 | }; 93 | }, 94 | rpc: function (methodName, responseBody) { 95 | const method = 'RPC2'; 96 | this.requests[method][methodName] = []; 97 | this.routes[method][methodName] = { 98 | responseBody 99 | }; 100 | }, 101 | before: async function () { 102 | this.app.post("/RPC2", textParser, rpcController.bind(this)); 103 | this.app.get("*", restController.bind(this)); 104 | this.app.post("*", urlencodedParser, restController.bind(this)); 105 | 106 | this.server = await this.app.listen(MOCK_SERVER_PORT); 107 | console.log(` → Mock server started on port: ${MOCK_SERVER_PORT}`); 108 | 109 | this.secureServer = await https.createServer({ 110 | key: fs.readFileSync('test/keys/server.key'), 111 | cert: fs.readFileSync('test/keys/server.cert') 112 | }, this.app).listen(SECURE_MOCK_SERVER_PORT); 113 | console.log(` → Mock secure server started on port: ${SECURE_MOCK_SERVER_PORT}`); 114 | }, 115 | after: async function () { 116 | if (this.server) { 117 | this.server.close(); 118 | delete this.server; 119 | 120 | this.secureServer.close(); 121 | delete this.secureServer; 122 | 123 | this.routes = { 124 | 'GET': {}, 125 | 'POST': {}, 126 | 'RPC2': {} 127 | }; 128 | } 129 | }, 130 | beforeEach: async function () { 131 | // Nothing 132 | }, 133 | afterEach: async function () { 134 | this.requests = { 135 | 'GET': {}, 136 | 'POST': {}, 137 | 'RPC2': {} 138 | }; 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /test/mongodb.js: -------------------------------------------------------------------------------- 1 | const config = require("../config"), 2 | initSubscription = require('../services/init-subscription'), 3 | moment = require('moment'), 4 | mongodb = require('../services/mongodb'); 5 | 6 | async function fetchSubscriptions(resourceUrl) { 7 | const subscriptions = await mongodb.get('rsscloud') 8 | .collection('subscriptions') 9 | .findOne({ 10 | _id: resourceUrl 11 | }); 12 | 13 | return subscriptions || { _id: resourceUrl, pleaseNotify: [] }; 14 | } 15 | 16 | async function upsertSubscriptions(subscriptions) { 17 | await mongodb.get('rsscloud') 18 | .collection('subscriptions') 19 | .replaceOne( 20 | { _id: subscriptions._id }, 21 | subscriptions, 22 | { upsert: true } 23 | ); 24 | } 25 | 26 | module.exports = { 27 | addSubscription: async function (resourceUrl, notifyProcedure, apiurl, protocol) { 28 | const subscriptions = await fetchSubscriptions(resourceUrl); 29 | 30 | initSubscription(subscriptions, notifyProcedure, apiurl, protocol); 31 | await upsertSubscriptions(subscriptions); 32 | 33 | let index = subscriptions.pleaseNotify.findIndex(subscription => { 34 | return subscription.url === apiurl; 35 | }); 36 | 37 | if (-1 !== index) { 38 | return subscriptions.pleaseNotify[index]; 39 | } 40 | 41 | throw Error(`Cannot find ${apiurl} subscription`); 42 | }, 43 | updateSubscription: async function (resourceUrl, subscription) { 44 | const subscriptions = await fetchSubscriptions(resourceUrl), 45 | index = subscriptions.pleaseNotify.findIndex(match => { 46 | return subscription.url === match.url; 47 | }); 48 | 49 | if (-1 !== index) { 50 | subscriptions.pleaseNotify[index] = subscription; 51 | await upsertSubscriptions(subscriptions); 52 | return subscriptions.pleaseNotify[index]; 53 | } 54 | 55 | throw Error(`Cannot find ${subscription.url} subscription`); 56 | }, 57 | before: async function () { 58 | await mongodb.connect('rsscloud', config.mongodbUri); 59 | console.log(` → MongoDB 'rsscloud' Database Connected`); 60 | }, 61 | after: async function () { 62 | return mongodb.close('rsscloud'); 63 | }, 64 | beforeEach: async function () { 65 | await mongodb.get('rsscloud').createCollection('events'); 66 | await mongodb.get('rsscloud').createCollection('resources'); 67 | await mongodb.get('rsscloud').createCollection('subscriptions'); 68 | }, 69 | afterEach: async function () { 70 | await mongodb.get('rsscloud').collection('events').drop(); 71 | await mongodb.get('rsscloud').collection('resources').drop(); 72 | await mongodb.get('rsscloud').collection('subscriptions').drop(); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /test/ping.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"), 2 | chaiHttp = require("chai-http"), 3 | chaiXml = require("chai-xml"), 4 | config = require('../config'), 5 | expect = chai.expect, 6 | SERVER_URL = process.env.APP_URL || "http://localhost:5337", 7 | mock = require("./mock"), 8 | moment = require('moment'), 9 | mongodb = require("./mongodb"), 10 | xmlrpc = require("davexmlrpc"), 11 | rpcReturnSuccess = require('../services/rpc-return-success'), 12 | rpcReturnFault = require('../services/rpc-return-fault'); 13 | 14 | chai.use(chaiHttp); 15 | chai.use(chaiXml); 16 | 17 | function ping(pingProtocol, resourceUrl, returnFormat) { 18 | if ('XML-RPC' === pingProtocol) { 19 | let rpctext; 20 | if (null == resourceUrl) { 21 | rpctext = xmlrpc.buildCall('rssCloud.ping', [], 'xml'); 22 | } else { 23 | rpctext = xmlrpc.buildCall('rssCloud.ping', [resourceUrl], 'xml'); 24 | } 25 | 26 | return chai 27 | .request(SERVER_URL) 28 | .post("/RPC2") 29 | .set('content-type', 'text/xml') 30 | .send(rpctext); 31 | } else { 32 | let req = chai 33 | .request(SERVER_URL) 34 | .post("/ping") 35 | .set('content-type', 'application/x-www-form-urlencoded'); 36 | 37 | if ('JSON' === returnFormat) { 38 | req.set('accept', 'application/json'); 39 | } 40 | 41 | if (null == resourceUrl) { 42 | return req.send({}); 43 | } else { 44 | return req.send({ url: resourceUrl }); 45 | } 46 | } 47 | } 48 | 49 | for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { 50 | for (const returnFormat of ['XML', 'JSON']) { 51 | for (const pingProtocol of ['XML-RPC', 'REST']) { 52 | 53 | if ('XML-RPC' === pingProtocol && 'JSON' === returnFormat) { 54 | // Not Applicable 55 | continue; 56 | } 57 | 58 | describe(`Ping ${pingProtocol} to ${protocol} returning ${returnFormat}`, function () { 59 | before(async function () { 60 | await mock.before(); 61 | }); 62 | 63 | after(async function () { 64 | await mock.after(); 65 | }); 66 | 67 | beforeEach(async function () { 68 | await mongodb.beforeEach(); 69 | await mock.beforeEach(); 70 | }); 71 | 72 | afterEach(async function () { 73 | await mongodb.afterEach(); 74 | await mock.afterEach(); 75 | }); 76 | 77 | it(`should accept a ping for new resource`, async function () { 78 | const feedPath = '/rss.xml', 79 | pingPath = '/feedupdated', 80 | resourceUrl = mock.serverUrl + feedPath; 81 | 82 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 83 | notifyProcedure = false; 84 | 85 | if ('xml-rpc' === protocol) { 86 | apiurl = mock.serverUrl + '/RPC2'; 87 | notifyProcedure = 'river.feedUpdated'; 88 | } 89 | 90 | mock.route('GET', feedPath, 200, ''); 91 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 92 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 93 | await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 94 | 95 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 96 | 97 | expect(res).status(200); 98 | 99 | if ('XML-RPC' === pingProtocol) { 100 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 101 | } else { 102 | if ('JSON' === returnFormat) { 103 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 104 | } else { 105 | expect(res.text).xml.equal(''); 106 | } 107 | } 108 | 109 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 110 | 111 | if ('xml-rpc' === protocol) { 112 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); 113 | expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); 114 | expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); 115 | } else { 116 | expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); 117 | expect(mock.requests.POST[pingPath][0]).property('body'); 118 | expect(mock.requests.POST[pingPath][0].body).property('url'); 119 | expect(mock.requests.POST[pingPath][0].body.url).equal(resourceUrl); 120 | } 121 | }); 122 | 123 | it(`should ping multiple subscribers on same domain`, async function () { 124 | const feedPath = '/rss.xml', 125 | pingPath1 = '/feedupdated1', 126 | pingPath2 = '/feedupdated2' 127 | resourceUrl = mock.serverUrl + feedPath; 128 | 129 | let apiurl1 = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath1, 130 | apiurl2 = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath2, 131 | notifyProcedure = false; 132 | 133 | if ('xml-rpc' === protocol) { 134 | apiurl1 = mock.serverUrl + '/RPC2'; 135 | apiurl2 = mock.serverUrl + pingPath2; 136 | notifyProcedure = 'river.feedUpdated'; 137 | } 138 | 139 | mock.route('GET', feedPath, 200, ''); 140 | mock.route('POST', pingPath1, 200, 'Thanks for the update! :-)'); 141 | mock.route('POST', pingPath2, 200, 'Thanks for the update! :-)'); 142 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 143 | await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl1, protocol); 144 | await mongodb.addSubscription(resourceUrl, false, apiurl2, 'http-post'); 145 | 146 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 147 | 148 | expect(res).status(200); 149 | 150 | if ('XML-RPC' === pingProtocol) { 151 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 152 | } else { 153 | if ('JSON' === returnFormat) { 154 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 155 | } else { 156 | expect(res.text).xml.equal(''); 157 | } 158 | } 159 | 160 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 161 | 162 | if ('xml-rpc' === protocol) { 163 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); 164 | expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); 165 | expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); 166 | } else { 167 | expect(mock.requests.POST).property(pingPath1).lengthOf(1, `Missing POST ${pingPath1}`); 168 | expect(mock.requests.POST[pingPath1][0]).property('body'); 169 | expect(mock.requests.POST[pingPath1][0].body).property('url'); 170 | expect(mock.requests.POST[pingPath1][0].body.url).equal(resourceUrl); 171 | } 172 | 173 | expect(mock.requests.POST).property(pingPath2).lengthOf(1, `Missing POST ${pingPath2}`); 174 | expect(mock.requests.POST[pingPath2][0]).property('body'); 175 | expect(mock.requests.POST[pingPath2][0].body).property('url'); 176 | expect(mock.requests.POST[pingPath2][0].body.url).equal(resourceUrl); 177 | }); 178 | 179 | it('should reject a ping for bad resource', async function () { 180 | const feedPath = '/rss.xml', 181 | pingPath = '/feedupdated', 182 | resourceUrl = mock.serverUrl + feedPath; 183 | 184 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 185 | notifyProcedure = false; 186 | 187 | if ('xml-rpc' === protocol) { 188 | apiurl = mock.serverUrl + '/RPC2'; 189 | notifyProcedure = 'river.feedUpdated'; 190 | } 191 | 192 | mock.route('GET', feedPath, 404, 'Not Found'); 193 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 194 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 195 | await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 196 | 197 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 198 | 199 | expect(res).status(200); 200 | 201 | if ('XML-RPC' === pingProtocol) { 202 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 203 | } else { 204 | if ('JSON' === returnFormat) { 205 | expect(res.body).deep.equal({ success: false, msg: `The ping was cancelled because there was an error reading the resource at URL ${resourceUrl}.` }); 206 | } else { 207 | expect(res.text).xml.equal(``); 208 | } 209 | } 210 | 211 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 212 | 213 | if ('xml-rpc' === protocol) { 214 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Should not XML-RPC call ${notifyProcedure}`); 215 | } else { 216 | expect(mock.requests.POST).property(pingPath).lengthOf(0, `Should not POST ${pingPath}`); 217 | } 218 | }); 219 | 220 | it('should reject a ping with a missing url', async function () { 221 | const feedPath = '/rss.xml', 222 | pingPath = '/feedupdated', 223 | resourceUrl = null; 224 | 225 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 226 | notifyProcedure = false; 227 | 228 | if ('xml-rpc' === protocol) { 229 | apiurl = mock.serverUrl + '/RPC2'; 230 | notifyProcedure = 'river.feedUpdated'; 231 | } 232 | 233 | mock.route('GET', feedPath, 404, 'Not Found'); 234 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 235 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 236 | 237 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 238 | 239 | expect(res).status(200); 240 | 241 | if ('XML-RPC' === pingProtocol) { 242 | expect(res.text).xml.equal(rpcReturnFault(4, 'Can\'t call "ping" because there aren\'t enough parameters.')); 243 | } else { 244 | if ('JSON' === returnFormat) { 245 | expect(res.body).deep.equal({ success: false, msg: `The following parameters were missing from the request body: url.` }); 246 | } else { 247 | expect(res.text).xml.equal(``); 248 | } 249 | } 250 | 251 | expect(mock.requests.GET).property(feedPath).lengthOf(0, `Should not GET ${feedPath}`); 252 | 253 | if ('xml-rpc' === protocol) { 254 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Should not XML-RPC call ${notifyProcedure}`); 255 | } else { 256 | expect(mock.requests.POST).property(pingPath).lengthOf(0, `Should not POST ${pingPath}`); 257 | } 258 | }); 259 | 260 | it('should accept a ping for unchanged resource', async function () { 261 | const feedPath = '/rss.xml', 262 | pingPath = '/feedupdated', 263 | resourceUrl = mock.serverUrl + feedPath; 264 | 265 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 266 | notifyProcedure = false; 267 | 268 | if ('xml-rpc' === protocol) { 269 | apiurl = mock.serverUrl + '/RPC2'; 270 | notifyProcedure = 'river.feedUpdated'; 271 | } 272 | 273 | mock.route('GET', feedPath, 200, ''); 274 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 275 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 276 | await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 277 | 278 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 279 | 280 | expect(res).status(200); 281 | 282 | if ('XML-RPC' === pingProtocol) { 283 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 284 | } else { 285 | if ('JSON' === returnFormat) { 286 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 287 | } else { 288 | expect(res.text).xml.equal(''); 289 | } 290 | } 291 | 292 | res = await ping(pingProtocol, resourceUrl, returnFormat); 293 | 294 | expect(res).status(200); 295 | 296 | if ('XML-RPC' === pingProtocol) { 297 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 298 | } else { 299 | if ('JSON' === returnFormat) { 300 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 301 | } else { 302 | expect(res.text).xml.equal(''); 303 | } 304 | } 305 | 306 | expect(mock.requests.GET).property(feedPath).lengthOf(2, `Missing GET ${feedPath}`); 307 | 308 | if ('xml-rpc' === protocol) { 309 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Should only XML-RPC call ${notifyProcedure} once`); 310 | } else { 311 | expect(mock.requests.POST).property(pingPath).lengthOf(1, `Should only POST ${pingPath} once`); 312 | } 313 | }); 314 | 315 | it(`should accept a ping with slow subscribers`, async function () { 316 | this.timeout(5000); 317 | 318 | const feedPath = '/rss.xml', 319 | pingPath = '/feedupdated', 320 | resourceUrl = mock.serverUrl + feedPath; 321 | 322 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 323 | notifyProcedure = false; 324 | 325 | if ('xml-rpc' === protocol) { 326 | apiurl = mock.serverUrl + '/RPC2'; 327 | notifyProcedure = 'river.feedUpdated'; 328 | } 329 | 330 | function slowPostResponse(req) { 331 | return new Promise(function(resolve) { 332 | setTimeout(function () { 333 | resolve('Thanks for the update! :-)'); 334 | }, 1000); 335 | }); 336 | } 337 | 338 | mock.route('GET', feedPath, 200, ''); 339 | if ('xml-rpc' === protocol) { 340 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 341 | await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 342 | } else { 343 | for (let i = 0; i < 10; i++) { 344 | mock.route('POST', pingPath + i, 200, slowPostResponse); 345 | await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl + i, protocol); 346 | } 347 | } 348 | 349 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 350 | 351 | expect(res).status(200); 352 | 353 | if ('XML-RPC' === pingProtocol) { 354 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 355 | } else { 356 | if ('JSON' === returnFormat) { 357 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 358 | } else { 359 | expect(res.text).xml.equal(''); 360 | } 361 | } 362 | 363 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 364 | 365 | if ('xml-rpc' === protocol) { 366 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); 367 | expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); 368 | expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); 369 | } else { 370 | for (let i = 0; i < 10; i++) { 371 | expect(mock.requests.POST).property(pingPath + i).lengthOf(1, `Missing POST ${pingPath + i}`); 372 | expect(mock.requests.POST[pingPath + i][0]).property('body'); 373 | expect(mock.requests.POST[pingPath + i][0].body).property('url'); 374 | expect(mock.requests.POST[pingPath + i][0].body.url).equal(resourceUrl); 375 | } 376 | } 377 | }); 378 | 379 | it(`should not notify expired subscribers`, async function () { 380 | const feedPath = '/rss.xml', 381 | pingPath = '/feedupdated', 382 | resourceUrl = mock.serverUrl + feedPath; 383 | 384 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 385 | notifyProcedure = false; 386 | 387 | if ('xml-rpc' === protocol) { 388 | apiurl = mock.serverUrl + '/RPC2'; 389 | notifyProcedure = 'river.feedUpdated'; 390 | } 391 | 392 | mock.route('GET', feedPath, 200, ''); 393 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 394 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 395 | const subscription = await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 396 | subscription.whenExpires = moment().utc().subtract(config.ctSecsResourceExpire * 2, 'seconds').format(); 397 | await mongodb.updateSubscription(resourceUrl, subscription); 398 | 399 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 400 | 401 | expect(res).status(200); 402 | 403 | if ('XML-RPC' === pingProtocol) { 404 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 405 | } else { 406 | if ('JSON' === returnFormat) { 407 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 408 | } else { 409 | expect(res.text).xml.equal(''); 410 | } 411 | } 412 | 413 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 414 | 415 | if ('xml-rpc' === protocol) { 416 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Missing XML-RPC call ${notifyProcedure}`); 417 | } else { 418 | expect(mock.requests.POST).property(pingPath).lengthOf(0, `Missing POST ${pingPath}`); 419 | } 420 | }); 421 | 422 | it(`should not notify subscribers with excessive errors`, async function () { 423 | const feedPath = '/rss.xml', 424 | pingPath = '/feedupdated', 425 | resourceUrl = mock.serverUrl + feedPath; 426 | 427 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 428 | notifyProcedure = false; 429 | 430 | if ('xml-rpc' === protocol) { 431 | apiurl = mock.serverUrl + '/RPC2'; 432 | notifyProcedure = 'river.feedUpdated'; 433 | } 434 | 435 | mock.route('GET', feedPath, 200, ''); 436 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 437 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 438 | const subscription = await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 439 | subscription.ctConsecutiveErrors = config.maxConsecutiveErrors; 440 | await mongodb.updateSubscription(resourceUrl, subscription); 441 | 442 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 443 | 444 | expect(res).status(200); 445 | 446 | if ('XML-RPC' === pingProtocol) { 447 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 448 | } else { 449 | if ('JSON' === returnFormat) { 450 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 451 | } else { 452 | expect(res.text).xml.equal(''); 453 | } 454 | } 455 | 456 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 457 | 458 | if ('xml-rpc' === protocol) { 459 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Missing XML-RPC call ${notifyProcedure}`); 460 | } else { 461 | expect(mock.requests.POST).property(pingPath).lengthOf(0, `Missing POST ${pingPath}`); 462 | } 463 | }); 464 | 465 | it(`should consider a very slow subscription an error`, async function () { 466 | const feedPath = '/rss.xml', 467 | pingPath = '/feedupdated', 468 | resourceUrl = mock.serverUrl + feedPath; 469 | 470 | let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, 471 | notifyProcedure = false; 472 | 473 | if ('xml-rpc' === protocol) { 474 | apiurl = mock.serverUrl + '/RPC2'; 475 | notifyProcedure = 'river.feedUpdated'; 476 | } 477 | 478 | function slowRestResponse(req) { 479 | return new Promise((resolve) => { 480 | setTimeout(() => { 481 | resolve('Thanks for the update! :-)'); 482 | }, 8000) 483 | }); 484 | } 485 | 486 | function slowRpcResponse(req) { 487 | return new Promise((resolve) => { 488 | setTimeout(() => { 489 | resolve(rpcReturnSuccess(true)); 490 | }, 8000) 491 | }); 492 | } 493 | 494 | mock.route('GET', feedPath, 200, ''); 495 | mock.route('POST', pingPath, 200, slowRestResponse); 496 | mock.rpc(notifyProcedure, slowRpcResponse); 497 | await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 498 | 499 | let res = await ping(pingProtocol, resourceUrl, returnFormat); 500 | 501 | expect(res).status(200); 502 | 503 | const subscription = await mongodb.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); 504 | expect(subscription.ctConsecutiveErrors).equal(1); 505 | 506 | if ('XML-RPC' === pingProtocol) { 507 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 508 | } else { 509 | if ('JSON' === returnFormat) { 510 | expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); 511 | } else { 512 | expect(res.text).xml.equal(''); 513 | } 514 | } 515 | 516 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 517 | 518 | if ('xml-rpc' === protocol) { 519 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); 520 | expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); 521 | expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); 522 | } else { 523 | expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); 524 | expect(mock.requests.POST[pingPath][0]).property('body'); 525 | expect(mock.requests.POST[pingPath][0].body).property('url'); 526 | expect(mock.requests.POST[pingPath][0].body.url).equal(resourceUrl); 527 | } 528 | }); 529 | 530 | }); 531 | 532 | } // end for pingProtocol 533 | } // end for returnFormat 534 | } // end for protocol 535 | -------------------------------------------------------------------------------- /test/please-notify.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"), 2 | chaiHttp = require("chai-http"), 3 | chaiXml = require("chai-xml"), 4 | expect = chai.expect, 5 | SERVER_URL = process.env.APP_URL || "http://localhost:5337", 6 | mock = require("./mock"), 7 | mongodb = require("./mongodb"), 8 | xmlrpc = require("davexmlrpc"), 9 | rpcReturnSuccess = require('../services/rpc-return-success'), 10 | rpcReturnFault = require('../services/rpc-return-fault'); 11 | 12 | chai.use(chaiHttp); 13 | chai.use(chaiXml); 14 | 15 | function pleaseNotify(pingProtocol, body, returnFormat) { 16 | if ('XML-RPC' === pingProtocol) { 17 | const rpctext = xmlrpc.buildCall('rssCloud.pleaseNotify', body, 'xml'); 18 | 19 | return chai 20 | .request(SERVER_URL) 21 | .post("/RPC2") 22 | .set('content-type', 'text/xml') 23 | .send(rpctext); 24 | } else { 25 | let req = chai 26 | .request(SERVER_URL) 27 | .post("/pleaseNotify") 28 | .set('content-type', 'application/x-www-form-urlencoded'); 29 | 30 | if ('JSON' === returnFormat) { 31 | req.set('accept', 'application/json'); 32 | } 33 | 34 | return req.send(body); 35 | } 36 | } 37 | 38 | for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { 39 | for (const returnFormat of ['XML', 'JSON']) { 40 | for (const pingProtocol of ['XML-RPC', 'REST']) { 41 | 42 | if ('XML-RPC' === pingProtocol && 'JSON' === returnFormat) { 43 | // Not Applicable 44 | continue; 45 | } 46 | 47 | describe(`PleaseNotify ${pingProtocol} to ${protocol} returning ${returnFormat}`, function () { 48 | 49 | before(async function () { 50 | await mongodb.before(); 51 | await mock.before(); 52 | }); 53 | 54 | after(async function () { 55 | await mongodb.after(); 56 | await mock.after(); 57 | }); 58 | 59 | beforeEach(async function () { 60 | await mongodb.beforeEach(); 61 | await mock.beforeEach(); 62 | }); 63 | 64 | afterEach(async function () { 65 | await mongodb.afterEach(); 66 | await mock.afterEach(); 67 | }); 68 | 69 | it('should accept a pleaseNotify for new resource', async function () { 70 | const feedPath = '/rss.xml', 71 | resourceUrl = mock.serverUrl + feedPath; 72 | 73 | let pingPath = '/feedupdated', 74 | notifyProcedure = false; 75 | 76 | if ('xml-rpc' === protocol) { 77 | pingPath = '/RPC2'; 78 | notifyProcedure = 'river.feedUpdated'; 79 | } 80 | 81 | let body = { 82 | domain: mock.serverDomain, 83 | port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 84 | path: pingPath, 85 | notifyProcedure: notifyProcedure, 86 | protocol, 87 | url1: resourceUrl 88 | }; 89 | 90 | if ('XML-RPC' === pingProtocol) { 91 | body = [ 92 | notifyProcedure, 93 | 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 94 | pingPath, 95 | protocol, 96 | [resourceUrl], 97 | mock.serverDomain 98 | ]; 99 | } 100 | 101 | mock.route('GET', feedPath, 200, ''); 102 | mock.route('GET', pingPath, 200, (req) => { return req.query.challenge; }); 103 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 104 | 105 | let res = await pleaseNotify(pingProtocol, body, returnFormat); 106 | 107 | expect(res).status(200); 108 | 109 | if ('XML-RPC' === pingProtocol) { 110 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 111 | } else { 112 | if ('JSON' === returnFormat) { 113 | expect(res.body).deep.equal({ success: true, msg: `Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!` }); 114 | } else { 115 | expect(res.text).xml.equal(''); 116 | } 117 | } 118 | 119 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 120 | 121 | if ('xml-rpc' === protocol) { 122 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); 123 | expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); 124 | expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); 125 | } else { 126 | expect(mock.requests.GET).property(pingPath).lengthOf(1, `Missing GET ${pingPath}`); 127 | } 128 | }); 129 | 130 | it('should accept a pleaseNotify without domain for new resource', async function () { 131 | const feedPath = '/rss.xml', 132 | resourceUrl = mock.serverUrl + feedPath; 133 | 134 | let pingPath = '/feedupdated', 135 | notifyProcedure = false; 136 | 137 | if ('xml-rpc' === protocol) { 138 | pingPath = '/RPC2'; 139 | notifyProcedure = 'river.feedUpdated'; 140 | } 141 | 142 | let body = { 143 | port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 144 | path: pingPath, 145 | notifyProcedure: notifyProcedure, 146 | protocol, 147 | url1: resourceUrl 148 | }; 149 | 150 | if ('XML-RPC' === pingProtocol) { 151 | body = [ 152 | notifyProcedure, 153 | 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 154 | pingPath, 155 | protocol, 156 | [resourceUrl] 157 | ]; 158 | } 159 | 160 | mock.route('GET', feedPath, 200, ''); 161 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 162 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 163 | 164 | let res = await pleaseNotify(pingProtocol, body, returnFormat); 165 | 166 | expect(res).status(200); 167 | 168 | if ('XML-RPC' === pingProtocol) { 169 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 170 | } else { 171 | if ('JSON' === returnFormat) { 172 | expect(res.body).deep.equal({ success: true, msg: `Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!` }); 173 | } else { 174 | expect(res.text).xml.equal(''); 175 | } 176 | } 177 | 178 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 179 | 180 | if ('xml-rpc' === protocol) { 181 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); 182 | expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); 183 | expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); 184 | } else { 185 | expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); 186 | } 187 | }); 188 | 189 | it('should reject a pleaseNotify for bad resource', async function () { 190 | const feedPath = '/rss.xml', 191 | resourceUrl = mock.serverUrl + feedPath; 192 | 193 | let pingPath = '/feedupdated', 194 | notifyProcedure = false; 195 | 196 | if ('xml-rpc' === protocol) { 197 | pingPath = '/RPC2'; 198 | notifyProcedure = 'river.feedUpdated'; 199 | } 200 | 201 | let body = { 202 | port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 203 | path: pingPath, 204 | notifyProcedure: notifyProcedure, 205 | protocol, 206 | url1: resourceUrl 207 | }; 208 | 209 | if ('XML-RPC' === pingProtocol) { 210 | body = [ 211 | notifyProcedure, 212 | 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 213 | pingPath, 214 | protocol, 215 | [resourceUrl] 216 | ]; 217 | } 218 | 219 | mock.route('GET', feedPath, 404, 'Not Found'); 220 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 221 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 222 | 223 | let res = await pleaseNotify(pingProtocol, body, returnFormat); 224 | 225 | expect(res).status(200); 226 | 227 | if ('XML-RPC' === pingProtocol) { 228 | expect(res.text).xml.equal(rpcReturnFault(4, `The subscription was cancelled because there was an error reading the resource at URL ${resourceUrl}.`)); 229 | } else { 230 | if ('JSON' === returnFormat) { 231 | expect(res.body).deep.equal({ success: false, msg: `The subscription was cancelled because there was an error reading the resource at URL ${resourceUrl}.` }); 232 | } else { 233 | expect(res.text).xml.equal(``); 234 | } 235 | } 236 | 237 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 238 | 239 | if ('xml-rpc' === protocol) { 240 | expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Should not XML-RPC call ${notifyProcedure}`); 241 | } else { 242 | expect(mock.requests.POST).property(pingPath).lengthOf(0, `Should not POST ${pingPath}`); 243 | } 244 | }); 245 | 246 | }); 247 | 248 | if ('xml-rpc' === protocol) { 249 | // Not Applicable 250 | continue; 251 | } 252 | 253 | describe(`PleaseNotify ${pingProtocol} to ${protocol} via redirect returning ${returnFormat}`, function () { 254 | 255 | before(async function () { 256 | await mongodb.before(); 257 | await mock.before(); 258 | }); 259 | 260 | after(async function () { 261 | await mongodb.after(); 262 | await mock.after(); 263 | }); 264 | 265 | beforeEach(async function () { 266 | await mongodb.beforeEach(); 267 | await mock.beforeEach(); 268 | }); 269 | 270 | afterEach(async function () { 271 | await mongodb.afterEach(); 272 | await mock.afterEach(); 273 | }); 274 | 275 | it('should accept a pleaseNotify for a redirected subscriber', async function () { 276 | const feedPath = '/rss.xml', 277 | resourceUrl = mock.serverUrl + feedPath; 278 | 279 | let pingPath = '/feedupdated', 280 | redirPath = '/redirect', 281 | notifyProcedure = false; 282 | 283 | let body = { 284 | domain: mock.serverDomain, 285 | port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 286 | path: redirPath, 287 | notifyProcedure: notifyProcedure, 288 | protocol, 289 | url1: resourceUrl 290 | }; 291 | 292 | if ('XML-RPC' === pingProtocol) { 293 | body = [ 294 | notifyProcedure, 295 | 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 296 | redirPath, 297 | protocol, 298 | [resourceUrl], 299 | mock.serverDomain 300 | ]; 301 | } 302 | 303 | mock.route('GET', feedPath, 200, ''); 304 | mock.route('GET', redirPath, 302, (req) => { return pingPath; }); 305 | mock.route('GET', pingPath, 200, (req) => { return req.query.challenge; }); 306 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 307 | 308 | let res = await pleaseNotify(pingProtocol, body, returnFormat); 309 | 310 | expect(res).status(200); 311 | 312 | if ('XML-RPC' === pingProtocol) { 313 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 314 | } else { 315 | if ('JSON' === returnFormat) { 316 | expect(res.body).deep.equal({ success: true, msg: `Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!` }); 317 | } else { 318 | expect(res.text).xml.equal(''); 319 | } 320 | } 321 | 322 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 323 | expect(mock.requests.GET).property(pingPath).lengthOf(1, `Missing GET ${pingPath}`); 324 | }); 325 | 326 | it('should accept a pleaseNotify without domain for a redirected subscriber', async function () { 327 | const feedPath = '/rss.xml', 328 | resourceUrl = mock.serverUrl + feedPath; 329 | 330 | let pingPath = '/feedupdated', 331 | redirPath = '/redirect', 332 | notifyProcedure = false; 333 | 334 | 335 | let body = { 336 | port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 337 | path: redirPath, 338 | notifyProcedure: notifyProcedure, 339 | protocol, 340 | url1: resourceUrl 341 | }; 342 | 343 | if ('XML-RPC' === pingProtocol) { 344 | body = [ 345 | notifyProcedure, 346 | 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, 347 | redirPath, 348 | protocol, 349 | [resourceUrl] 350 | ]; 351 | } 352 | 353 | mock.route('GET', feedPath, 200, ''); 354 | mock.route('POST', redirPath, 302, (req) => { return pingPath; }); 355 | mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); 356 | mock.rpc(notifyProcedure, rpcReturnSuccess(true)); 357 | 358 | let res = await pleaseNotify(pingProtocol, body, returnFormat); 359 | 360 | expect(res).status(200); 361 | 362 | if ('XML-RPC' === pingProtocol) { 363 | expect(res.text).xml.equal(rpcReturnSuccess(true)); 364 | } else { 365 | if ('JSON' === returnFormat) { 366 | expect(res.body).deep.equal({ success: true, msg: `Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!` }); 367 | } else { 368 | expect(res.text).xml.equal(''); 369 | } 370 | } 371 | 372 | expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); 373 | expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); 374 | }); 375 | 376 | }); 377 | 378 | } // end for pingProtocol 379 | } // end for returnFormat 380 | } // end for protocol 381 | -------------------------------------------------------------------------------- /test/static.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"), 2 | chaiHttp = require("chai-http"), 3 | expect = chai.expect, 4 | SERVER_URL = process.env.APP_URL || "http://localhost:5337"; 5 | 6 | chai.use(chaiHttp); 7 | 8 | describe("Static Pages", function () { 9 | 10 | it("docs should return 200", async function () { 11 | let res = await chai 12 | .request(SERVER_URL) 13 | .get("/docs"); 14 | 15 | expect(res).status(200); 16 | }); 17 | 18 | it("home should return 200", async function () { 19 | let res = await chai 20 | .request(SERVER_URL) 21 | .get("/"); 22 | 23 | expect(res).status(200); 24 | }); 25 | 26 | it("pingForm should return 200", async function () { 27 | let res = await chai 28 | .request(SERVER_URL) 29 | .get("/pingForm"); 30 | 31 | expect(res).status(200); 32 | }); 33 | 34 | it("pleaseNotifyForm should return 200", async function () { 35 | let res = await chai 36 | .request(SERVER_URL) 37 | .get("/pleaseNotifyForm"); 38 | 39 | expect(res).status(200); 40 | }); 41 | 42 | it("viewLog should return 200", async function () { 43 | let res = await chai 44 | .request(SERVER_URL) 45 | .get("/viewLog"); 46 | 47 | expect(res).status(200); 48 | }); 49 | 50 | }); 51 | -------------------------------------------------------------------------------- /views/docs.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rssCloud v2: Documentation 6 | 7 | 8 | 9 | 10 |
    11 | {{{htmltext}}} 12 |
    13 | 14 | 15 | -------------------------------------------------------------------------------- /views/home.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rssCloud v2 6 | 7 | 8 | 9 | 10 |
    11 | 14 | 15 | 21 |
    22 | 23 | 24 | -------------------------------------------------------------------------------- /views/layouts/main.handlebars: -------------------------------------------------------------------------------- 1 | {{{body}}} 2 | -------------------------------------------------------------------------------- /views/ping-form.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rssCloud v2: Ping 6 | 7 | 8 | 9 | 10 |
    11 | 15 | 16 |
    17 |
    18 | 19 | 20 |
    21 | 22 |
    23 |
    24 | 25 | 26 | -------------------------------------------------------------------------------- /views/please-notify-form.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rssCloud v2: Please Notify 6 | 7 | 8 | 9 | 10 |
    11 | 15 | 16 |
    17 |
    18 | 19 | 20 |
    21 |
    22 | 23 | 24 |
    25 |
    26 | 27 | 28 |
    29 |
    30 | 31 | 32 |
    33 |
    34 | 35 | 36 |
    37 |
    38 | 39 | 40 |
    41 | 42 |
    43 |
    44 | 45 | 46 | -------------------------------------------------------------------------------- /views/view-log.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rssCloud: Log 6 | 7 | 8 | 9 | 10 |
    11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {{#if eventlog}} 27 | {{#each eventlog}} 28 | 29 | 30 | 31 | 32 | 33 | 34 | {{/each}} 35 | {{/if}} 36 | 37 |
    EventWhat HappenedWhenSeconds
    {{eventtype}}{{{htmltext}}}{{#if headers}} {{/if}}{{time}}{{secs}}
    38 |
    39 |
    40 | {{#if eventlog}} 41 | {{#each eventlog}} 42 | 55 | {{/each}} 56 | {{/if}} 57 |
    58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 105 | 106 | 107 | --------------------------------------------------------------------------------