diff --git a/customize.dist/src/less2/include/alertify.less b/customize.dist/src/less2/include/alertify.less index 2f95a637e..982fa0db1 100644 --- a/customize.dist/src/less2/include/alertify.less +++ b/customize.dist/src/less2/include/alertify.less @@ -64,6 +64,10 @@ color: @cryptpad_text_col; } + .cp-admin-message { + color: @cryptpad_text_col; + } + .cp-inline-alert-text { flex: 1; } diff --git a/customize.dist/src/less2/include/colortheme-dark.less b/customize.dist/src/less2/include/colortheme-dark.less index fdb3ad772..2c143c9a6 100644 --- a/customize.dist/src/less2/include/colortheme-dark.less +++ b/customize.dist/src/less2/include/colortheme-dark.less @@ -414,3 +414,8 @@ @cp_whiteboard-board-border: @cryptpad_color_grey_800; @cp_whiteboard-bg: @cp_app-bg; @cp_whiteboard-fg: @cryptpad_text_col; + +// Flatpickr +@cp_flatpickr-bg: @cryptpad_color_grey_800; +@cp_flatpickr-highlight: @cryptpad_color_brand_300; +@cp_flatpickr-highlight-text: @cryptpad_color_grey_800; diff --git a/customize.dist/src/less2/include/colortheme.less b/customize.dist/src/less2/include/colortheme.less index 430273089..255540bec 100644 --- a/customize.dist/src/less2/include/colortheme.less +++ b/customize.dist/src/less2/include/colortheme.less @@ -190,7 +190,7 @@ // Dropdown @cp_dropdown-fg: @cryptpad_text_col; @cp_dropdown-bg: @cryptpad_color_grey_100; -@cp_dropdown-bg-hover: @cryptpad_color_grey_100; +@cp_dropdown-bg-hover: @cryptpad_color_grey_200; @cp_dropdown-bg-active: @cryptpad_color_grey_300; // Rendered Markdown @@ -414,3 +414,8 @@ @cp_whiteboard-board-border: @cryptpad_color_grey_600; @cp_whiteboard-bg: @cp_app-bg; @cp_whiteboard-fg: @cryptpad_text_col; + +// Flatpickr +@cp_flatpickr-bg: @cryptpad_color_grey_50; +@cp_flatpickr-highlight: @cryptpad_color_brand_fadest; +@cp_flatpickr-highlight-text: @cryptpad_text_col; diff --git a/customize.dist/src/less2/include/forms.less b/customize.dist/src/less2/include/forms.less index e74b67cb0..6181df09b 100644 --- a/customize.dist/src/less2/include/forms.less +++ b/customize.dist/src/less2/include/forms.less @@ -8,7 +8,7 @@ & { @alertify_padding-base: @variables_padding; - input:not(.form-control):not([type="checkbox"]), textarea, div.cp-textarea { + input:not(.numInput):not(.form-control):not([type="checkbox"]), textarea, div.cp-textarea { // background-color: @alertify-input-fg; color: @cp_forms-fg; background-color: @cp_forms-bg; @@ -169,7 +169,7 @@ &:hover, &:not(:disabled):active, &:focus { border-color: @cryptpad_text_col; color: @cryptpad_text_col; - background-color: fade(@cryptpad_text_col, 25%); + background-color: fade(@cryptpad_text_col, 10%); } } @@ -280,4 +280,90 @@ color: @cp_drive-infobox-fg; } } + + // Flatpickr + body { + .flatpickr-calendar { + background: @cp_flatpickr-bg; + color: @cryptpad_text_col; + border-radius: 0; + box-shadow: @variables_shadow; + -webkit-box-shadow: @variables_shadow; + &.arrowTop::before, &.arrowTop::after { + border-bottom: 0; + } + .flatpickr-months { + .flatpickr-month, .flatpickr-months, .flatpickr-next-month, .flatpickr-prev-month { + color: @cryptpad_text_col; + fill: @cryptpad_text_col; + &:hover { + svg { + fill: @cryptpad_text_col; + } + } + } + .flatpickr-current-month { + span.cur-month:hover { + background: fade(@cryptpad_text_col, 10%); + } + .numInputWrapper span.arrowUp:after { + border-bottom-color: @cryptpad_text_col; + } + .numInputWrapper span.arrowDown:after { + border-top-color: @cryptpad_text_col; + } + } + } + .flatpickr-innerContainer { + border-bottom: 0; + .flatpickr-weekdays { + span.flatpickr-weekday { + color: @cryptpad_text_col; + } + } + .flatpickr-days { + border-left: 0; + border-right: 0; + .flatpickr-day { + color: @cryptpad_text_col; + &:hover { + background-color: fade(@cryptpad_text_col, 10%); + border: 0; + } + &.selected { + background: @cp_flatpickr-highlight; + color: @cp_flatpickr-highlight-text; + border: 0; + } + } + .flatpickr-disabled { + color: fade(@cryptpad_text_col, 20%); + } + } + } + .flatpickr-time { + border-top: none; + .flatpickr-time-separator, .flatpickr-am-pm { + color: @cryptpad_text_col; + } + .flatpickr-am-pm { + &:hover { + background-color: fade(@cryptpad_text_col, 10%); + } + } + .numInputWrapper { + .numInput, .arrowUp, .arrowDown { + color: @cryptpad_text_col; + &:hover, &:focus { + background-color: fade(@cryptpad_text_col, 10%); + } + } + span.arrowDown::after, span.arrowUp::after { + border-top-color: @cryptpad_text_col; + border-bottom-color: @cryptpad_text_col; + } + } + } + } + } } diff --git a/customize.dist/src/less2/include/notifications.less b/customize.dist/src/less2/include/notifications.less index 46209eb6a..892779a37 100644 --- a/customize.dist/src/less2/include/notifications.less +++ b/customize.dist/src/less2/include/notifications.less @@ -17,6 +17,16 @@ .cp-notification { min-height: @notif-height; display: flex; + .cp-broadcast { + display: flex; + font-size: 30px; + align-items: center; + padding: 0 5px; + color: @cp_dropdown-fg; + &.preview { + color: @cryptpad_color_red; + } + } .cp-avatar { .avatar_main(30px); padding: 0 5px; diff --git a/customize.dist/src/less2/include/toolbar.less b/customize.dist/src/less2/include/toolbar.less index 24bb09c11..3cb9da2d2 100644 --- a/customize.dist/src/less2/include/toolbar.less +++ b/customize.dist/src/less2/include/toolbar.less @@ -400,7 +400,7 @@ button { .toolbar_button; - &.cp-notifications-bell { + &.cp-notifications-bell, &.cp-maintenance-wrench { color: @cryptpad_text_col; } } @@ -506,7 +506,7 @@ } .cp-toolbar-user { height: @toolbar_line-height; - .cp-toolbar-notifications { + .cp-toolbar-notifications, .cp-toolbar-maintenance { height: @toolbar_line-height; width: @toolbar_line-height; margin-left: 0; @@ -709,7 +709,7 @@ height: 43px; } } - .cp-toolbar-link, .cp-toolbar-notifications { + .cp-toolbar-link, .cp-toolbar-notifications, .cp-toolbar-maintenance { line-height: @toolbar_top-height; width: @toolbar_top-height; height: @toolbar_top-height; @@ -717,7 +717,7 @@ box-sizing: border-box; display: inline-block; } - .cp-toolbar-notifications { + .cp-toolbar-notifications, .cp-toolbar-maintenance { text-align: center; font-size: 32px; margin-left: 10px; diff --git a/lib/commands/channel.js b/lib/commands/channel.js index 86ab7bc8c..e69f93180 100644 --- a/lib/commands/channel.js +++ b/lib/commands/channel.js @@ -191,7 +191,8 @@ var ARRAY_LINE = /^\[/; */ Channel.isNewChannel = function (Env, channel, cb) { if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); } - if (channel.length !== 32) { return void cb('INVALID_CHAN'); } + if (channel.length !== HK.STANDARD_CHANNEL_LENGTH && + channel.length !== HK.ADMIN_CHANNEL_LENGTH) { return void cb('INVALID_CHAN'); } // TODO replace with readMessagesBin var done = false; @@ -229,7 +230,8 @@ Channel.writePrivateMessage = function (Env, args, _cb, Server, netfluxId) { if (!msg) { return void cb("INVALID_MESSAGE"); } // don't support anything except regular channels - if (!Core.isValidId(channelId) || channelId.length !== 32) { + if (!Core.isValidId(channelId) || (channelId.length !== HK.STANDARD_CHANNEL_LENGTH + && channelId.length !== HK.ADMIN_CHANNEL_LENGTH)) { return void cb("INVALID_CHAN"); } @@ -254,6 +256,11 @@ Channel.writePrivateMessage = function (Env, args, _cb, Server, netfluxId) { var session = HK.getNetfluxSession(Env, netfluxId); var allowed = HK.listAllowedUsers(metadata); + // Special broadcast channel + if (channelId.length === HK.ADMIN_CHANNEL_LENGTH) { + allowed = Env.admins; + } + if (HK.isUserSessionAllowed(allowed, session)) { return; } w.abort(); @@ -278,12 +285,19 @@ Channel.writePrivateMessage = function (Env, args, _cb, Server, netfluxId) { // historyKeeper already knows how to handle metadata and message validation, so we just pass it off here // if the message isn't valid it won't be stored. - Env.historyKeeper.channelMessage(Server, channelStruct, fullMessage); + Env.historyKeeper.channelMessage(Server, channelStruct, fullMessage, function (err) { + if (err) { + // Message not stored... + return void cb(err); + } - Server.getChannelUserList(channelId).forEach(function (userId) { - Server.send(userId, fullMessage); + // Broadcast the message + Server.getChannelUserList(channelId).forEach(function (userId) { + Server.send(userId, fullMessage); + }); }); + cb(); }); }; diff --git a/lib/commands/core.js b/lib/commands/core.js index f4e6a9f70..42ed0455b 100644 --- a/lib/commands/core.js +++ b/lib/commands/core.js @@ -10,7 +10,7 @@ Core.SESSION_EXPIRATION_TIME = 60 * 1000; Core.isValidId = function (chan) { return chan && chan.length && /^[a-zA-Z0-9=+-]*$/.test(chan) && - [32, 48].indexOf(chan.length) > -1; + [32, 33, 48].indexOf(chan.length) > -1; }; var makeToken = Core.makeToken = function () { diff --git a/lib/commands/metadata.js b/lib/commands/metadata.js index 896c89f31..564c4f00d 100644 --- a/lib/commands/metadata.js +++ b/lib/commands/metadata.js @@ -9,7 +9,8 @@ const HK = require("../hk-util"); Data.getMetadataRaw = function (Env, channel /* channelName */, _cb) { const cb = Util.once(Util.mkAsync(_cb)); if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); } - if (channel.length !== HK.STANDARD_CHANNEL_LENGTH) { return cb("INVALID_CHAN_LENGTH"); } + if (channel.length !== HK.STANDARD_CHANNEL_LENGTH && + channel.length !== HK.ADMIN_CHANNEL_LENGTH) { return cb("INVALID_CHAN_LENGTH"); } var cached = Env.metadata_cache[channel]; if (HK.isMetadataMessage(cached)) { diff --git a/lib/decrees.js b/lib/decrees.js index 2672efdd3..0b5e8572e 100644 --- a/lib/decrees.js +++ b/lib/decrees.js @@ -24,6 +24,10 @@ SET_PREMIUM_UPLOAD_SIZE DISABLE_INTEGRATED_TASKS DISABLE_INTEGRATED_EVICTION +// BROADCAST +SET_LAST_BROADCAST_HASH +SET_SURVEY_URL + NOT IMPLEMENTED: // RESTRICTED REGISTRATION @@ -121,6 +125,36 @@ commands.SET_ARCHIVE_RETENTION_TIME = makeIntegerSetter('archiveRetentionTime'); // CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_ACCOUNT_RETENTION_TIME', [365]]], console.log) commands.SET_ACCOUNT_RETENTION_TIME = makeIntegerSetter('accountRetentionTime'); +var args_isString = function (args) { + return Array.isArray(args) && typeof(args[0]) === "string"; +}; +var args_isMaintenance = function (args) { + return Array.isArray(args) && args[0] && args[0].end && args[0].start; +}; + +var makeBroadcastSetter = function (attr) { + return function (Env, args) { + if (!args_isString(args) && !args_isMaintenance(args)) { + throw new Error('INVALID_ARGS'); + } + var str = args[0]; + if (str === Env[attr]) { return false; } + Env[attr] = str; + Env.broadcastCache = {}; + return true; + }; +}; + +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LAST_BROADCAST_HASH', [hash]]], console.log) +commands.SET_LAST_BROADCAST_HASH = makeBroadcastSetter('lastBroadcastHash'); + +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_SURVEY_URL', [url]]], console.log) +commands.SET_SURVEY_URL = makeBroadcastSetter('surveyURL'); + +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [{start: +Date, end: +Date}]]], console.log) +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [""]]], console.log) +commands.SET_MAINTENANCE = makeBroadcastSetter('maintenance'); + var Quota = require("./commands/quota"); var Keys = require("./keys"); var Util = require("./common-util"); diff --git a/lib/env.js b/lib/env.js index 97d3893f9..b879d102f 100644 --- a/lib/env.js +++ b/lib/env.js @@ -19,8 +19,10 @@ module.exports.create = function (config) { FRESH_MODE: true, DEV_MODE: false, configCache: {}, + broadcastCache: {}, flushCache: function () { Env.configCache = {}; + Env.broadcastCache = {}; Env.FRESH_KEY = +new Date(); if (!(Env.DEV_MODE || Env.FRESH_MODE)) { Env.FRESH_MODE = true; } if (!Env.Log) { return; } @@ -65,6 +67,11 @@ module.exports.create = function (config) { paths: {}, //msgStore: config.store, + // /api/broadcast + lastBroadcastHash: '', + surveyURL: undefined, + maintenance: undefined, + netfluxUsers: {}, pinStore: undefined, diff --git a/lib/hk-util.js b/lib/hk-util.js index 21ccb4875..cb0d6df9c 100644 --- a/lib/hk-util.js +++ b/lib/hk-util.js @@ -34,6 +34,7 @@ const getHash = HK.getHash = function (msg, Log) { // historyKeeper should explicitly store any channel // with a 32 character id const STANDARD_CHANNEL_LENGTH = HK.STANDARD_CHANNEL_LENGTH = 32; +const ADMIN_CHANNEL_LENGTH = HK.ADMIN_CHANNEL_LENGTH = 33; // historyKeeper should not store messages sent to any channel // with a 34 character id @@ -902,6 +903,11 @@ HK.onChannelMessage = function (Env, Server, channel, msgStruct, cb) { // don't store messages if the channel id indicates that it's an ephemeral message if (!channel.id || channel.id.length === EPHEMERAL_CHANNEL_LENGTH) { return void cb(); } + // Admin channel. We can only write to this one from private message (RPC) + if (channel.id.length === ADMIN_CHANNEL_LENGTH && msgStruct[1] !== null) { + return void cb('ERESTRICTED_ADMIN'); + } + const isCp = /^cp\|/.test(msgStruct[4]); let id; if (isCp) { diff --git a/lib/storage/file.js b/lib/storage/file.js index d890cb0b9..825f14066 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -567,7 +567,7 @@ var listChannels = function (root, handler, cb, fast) { var metadataName; // if the current file is not the channel data, then it must be metadata - if (!/^[0-9a-fA-F]{32}\.ndjson$/.test(item)) { + if (!/^[0-9a-fA-F]{32, 33}\.ndjson$/.test(item)) { metadataName = item; channelName = item.replace(/\.metadata/, ''); @@ -584,7 +584,7 @@ var listChannels = function (root, handler, cb, fast) { } var channel = metadataName.replace(/\.metadata.ndjson$/, ''); - if ([32, 34, 44].indexOf(channel.length) === -1) { return; } + if ([32, 33, 34, 44].indexOf(channel.length) === -1) { return; } // otherwise throw it on the pile sema.take(function (give) { diff --git a/lib/workers/db-worker.js b/lib/workers/db-worker.js index 5274445eb..65f13d23b 100644 --- a/lib/workers/db-worker.js +++ b/lib/workers/db-worker.js @@ -391,7 +391,8 @@ const getPinState = function (data, cb) { const _getFileSize = function (channel, _cb) { var cb = Util.once(Util.mkAsync(_cb)); if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); } - if (channel.length === 32) { + if (channel.length === HK.STANDARD_CHANNEL_LENGTH || + channel.length === HK.ADMIN_CHANNEL_LENGTH) { return void store.getChannelSize(channel, function (e, size) { if (e) { if (e.code === 'ENOENT') { return void cb(void 0, 0); } diff --git a/server.js b/server.js index a7ce63a79..7f4c1fcbf 100644 --- a/server.js +++ b/server.js @@ -113,6 +113,7 @@ var setHeaders = (function () { // Don't set CSP headers on /api/config because they aren't necessary and they cause problems // when duplicated by NGINX in production environments + // XXX /api/broadcast too? if (/^\/api\/config/.test(req.url)) { return; } // targeted CSP, generic policies, maybe custom headers const h = [ @@ -272,7 +273,59 @@ var serveConfig = (function () { }; }()); +var serveBroadcast = (function () { + var cacheString = function () { + return (Env.FRESH_KEY? '-' + Env.FRESH_KEY: '') + (Env.DEV_MODE? '-' + (+new Date()): ''); + }; + + var template = function (host) { + var maintenance = Env.maintenance; + if (maintenance && maintenance.end && maintenance.end < (+new Date())) { + maintenance = undefined; + } + return [ + 'define(function(){', + 'return ' + JSON.stringify({ + lastBroadcastHash: Env.lastBroadcastHash, + surveyURL: Env.surveyURL, + maintenance: maintenance + }, null, '\t'), + '});' + ].join(';\n') + }; + + var cleanUp = {}; + + return function (req, res) { + var host = req.headers.host.replace(/\:[0-9]+/, ''); + res.setHeader('Content-Type', 'text/javascript'); + // don't cache anything if you're in dev mode + if (Env.DEV_MODE) { + return void res.send(template(host)); + } + // generate a lookup key for the cache + var cacheKey = host + ':' + cacheString(); + + // XXX do we need a cache for /api/broadcast? + if (!Env.broadcastCache[cacheKey]) { + // generate the response and cache it in memory + Env.broadcastCache[cacheKey] = template(host); + // and create a function to conditionally evict cache entries + // which have not been accessed in the last 20 seconds + cleanUp[cacheKey] = Util.throttle(function () { + delete cleanUp[cacheKey]; + delete Env.broadcastCache[cacheKey]; + }, 20000); + } + + // successive calls to this function + cleanUp[cacheKey](); + return void res.send(Env.broadcastCache[cacheKey]); + }; +}()); + app.get('/api/config', serveConfig); +app.get('/api/broadcast', serveBroadcast); var four04_path = Path.resolve(__dirname + '/customize.dist/404.html'); var custom_four04_path = Path.resolve(__dirname + '/customize/404.html'); diff --git a/www/admin/app-admin.less b/www/admin/app-admin.less index c9b8bdc02..c3556c869 100644 --- a/www/admin/app-admin.less +++ b/www/admin/app-admin.less @@ -15,7 +15,7 @@ display: flex; flex-flow: column; - .cp-admin-setlimit-form { + .cp-admin-setlimit-form, .cp-admin-broadcast-form { label { font-weight: normal !important; } @@ -199,5 +199,85 @@ } } + .cp-admin-broadcast-form { + input.flatpickr-input { + width: 307.875px !important; // same width as flatpickr calendar + } + .cp-broadcast-active { + display: flex; + flex-flow: column; + align-items: start; + padding: 10px; + background-color: @cp_sidebar-left-bg; + color: @cp_sidebar-left-fg; + p { + margin: 0; + } + } + .cp-broadcast-form-submit { + margin-top: 30px; + button { + margin-bottom: 10px !important; + } + } + .cp-broadcast-container { + display: flex; + flex-flow: column; + } + .cp-broadcast-lang { + margin: 30px; + margin-bottom: 0; + display: flex; + flex-flow: column; + align-items: baseline; + .cp-checkmark { + margin: 5px 0; + } + } + div.cp-broadcast-languages { + & > label.cp-checkmark:not(:last-child) { + margin-right: 20px; + } + } + .cp-broadcast-preview { + vertical-align: bottom !important; + } + .cp-broadcast-delete { + width: 100%; + min-width: 600px; + tbody { + tr { + background-color: @cp_support-msg-bg; + padding: 5px; + td { + padding: 5px; + button { + margin: 0 !important; + } + } + } + } + .cp-notification { + display: flex; + align-items: center; + .cp-avatar, .cp-broadcast, .cp-notification-dismiss { + display: none; + } + p { + margin: 0 !important; + } + .cp-notification-content { + width: 100%; + padding: 10px; + } + .cp-clickable { + cursor: pointer; + &:hover { + background-color: @cp_dropdown-bg-hover; + } + } + } + } + } } diff --git a/www/admin/inner.js b/www/admin/inner.js index 9a97539a5..a7517e8d3 100644 --- a/www/admin/inner.js +++ b/www/admin/inner.js @@ -1,6 +1,7 @@ define([ 'jquery', '/api/config', + '/customize/application_config.js', '/bower_components/chainpad-crypto/crypto.js', '/common/toolbar.js', '/bower_components/nthen/index.js', @@ -14,12 +15,16 @@ define([ '/common/common-signing-keys.js', '/support/ui.js', + '/lib/datepicker/flatpickr.js', + + 'css!/lib/datepicker/flatpickr.min.css', 'css!/bower_components/bootstrap/dist/css/bootstrap.min.css', 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', 'less!/admin/app-admin.less', ], function ( $, ApiConfig, + AppConfig, Crypto, Toolbar, nThen, @@ -31,7 +36,8 @@ define([ Util, Hash, Keys, - Support + Support, + Flatpickr ) { var APP = { @@ -67,6 +73,11 @@ define([ 'cp-admin-support-list', 'cp-admin-support-init' ], + 'broadcast': [ // Msg.admin_cat_broadcast + 'cp-admin-maintenance', + 'cp-admin-survey', + 'cp-admin-broadcast', + ], 'performance': [ // Msg.admin_cat_performance 'cp-admin-refresh-performance', 'cp-admin-performance-profiling', @@ -930,6 +941,551 @@ define([ return; }; + Messages.admin_cat_broadcast = "Broadcast"; // XXX + + Messages.admin_maintenanceTitle = "Maintenance"; // XXX + Messages.admin_maintenanceHint = "Plan, remove or update a maintenance. You can only have one active maintenance at a time."; // XXX + Messages.admin_maintenanceButton = "Plan maintenance"; // XXX + Messages.admin_maintenanceCancel = "Cancel planned maintenance"; // XXX + Messages.broadcast_start = 'Start time'; + Messages.broadcast_end = 'End time'; + + + Messages.admin_surveyTitle = "Survey"; // XXX + Messages.admin_surveyHint = "Add, update or remove the active survey accessible from the user menu"; // XXX + Messages.admin_surveyButton = "Apply survey"; // XXX + Messages.admin_surveyCancel = "Cancel active survey"; // XXX + Messages.admin_surveyActive = "View the active survey"; // XXX + Messages.broadcast_surveyURL = 'Survey URL'; + + Messages.admin_broadcastTitle = "Broadcast a message"; // XXX + Messages.admin_broadcastHint = "Send a message to all the existing and future users as a notification"; // XXX + Messages.admin_broadcastButton = "Send"; // XXX + Messages.admin_broadcastActive = "Active message"; // XXX + Messages.admin_broadcastCancel = "Delete active message"; // XXX + Messages.broadcast_translations = 'Translations'; + Messages.broadcast_defaultLanguage = 'Fallback to this language'; + Messages.broadcast_preview = "Preview in a fake notification"; + + var getApi = function (cb) { + return function () { + require(['/api/broadcast?'+ (+new Date())], function (Broadcast) { + cb(Broadcast); + }); + }; + }; + + // Update the lastBroadcastHash in /api/broadcast if we can do it. + // To do so, find the last "BROADCAST_CUSTOM" in the current history and use the previous + // message's hash. + // If the last BROADCAST_CUSTOM has been deleted by an admin, we can use the most recent + // message's hash. + var checkLastBroadcastHash = function () { + var deleted = []; + + require(['/api/broadcast?'+ (+new Date())], function (BCast) { + var hash = BCast.lastBroadcastHash || '1'; // Truthy value if no lastKnownHash + common.mailbox.getNotificationsHistory('broadcast', null, hash, function (e, msgs) { + if (e) { return void console.error(e); } + + // No history, nothing to change + if (!Array.isArray(msgs)) { return; } + if (!msgs.length) { return; } + + var lastHash; + var next = false; + + // Start from the most recent messages until you find a CUSTOM message and + // check if it has been deleted + msgs.reverse().some(function (data) { + var c = data.content; + + // This is the hash we want to keep + if (next) { + if (!c || !c.hash) { return; } + lastHash = c.hash; + next = false; + return true; + } + + // initialize with the most recent hash + if (!lastHash && c && c.hash) { lastHash = c.hash; } + + var msg = c && c.msg; + if (!msg) { return; } + + // Remember all deleted messages + if (msg.type === "BROADCAST_DELETE") { + deleted.push(Util.find(msg, ['content', 'uid'])); + } + + // Only check custom messages + if (msg.type !== "BROADCAST_CUSTOM") { return; } + + // If the most recent CUSTOM message has been deleted, it means we don't + // need to keep any message and we can continue with lastHash as the most + // recent broadcast message. + if (deleted.indexOf(msg.uid) !== -1) { return true; } + + // We just found the oldest message we want to keep, move one iteration + // further into the loop to get the next message's hash. + // If this is the end of the loop, don't bump lastBroadcastHash at all. + next = true; + }); + + // If we don't have to bump our lastBroadcastHash, abort + if (next) { return; } + + // Otherwise, bump to lastHash + console.warn('Updating last broadcast hash to', lastHash); + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['SET_LAST_BROADCAST_HASH', [lastHash]] + }, function (e) { + if (e) { + console.error(e); + return; + } + console.log('lastBroadcastHash updated'); + }); + }); + }); + + }; + + create['broadcast'] = function () { + var key = 'broadcast'; + var $div = makeBlock(key); + + var form = h('div.cp-admin-broadcast-form'); + var $form = $(form).appendTo($div); + + var refresh = getApi(function (Broadcast) { + var button = h('button.btn.btn-primary', Messages.admin_broadcastButton); + var $button = $(button); + var removeButton = h('button.btn.btn-danger', Messages.admin_broadcastCancel); + var active = h('div.cp-broadcast-active', h('p', Messages.admin_broadcastActive)); + var $active = $(active); + var activeUid; + var deleted = []; + + // Render active message (if there is one) + require(['/api/broadcast?'+ (+new Date())], function (BCast) { + var hash = BCast.lastBroadcastHash || '1'; // Truthy value if no lastKnownHash + common.mailbox.getNotificationsHistory('broadcast', null, hash, function (e, msgs) { + if (e) { return void console.error(e); } + if (!Array.isArray(msgs)) { return; } + if (!msgs.length) { + $active.hide(); + } + msgs.reverse().some(function (data) { + var c = data.content; + var msg = c && c.msg; + if (!msg) { return; } + if (msg.type === "BROADCAST_DELETE") { + deleted.push(Util.find(msg, ['content', 'uid'])); + } + if (msg.type !== "BROADCAST_CUSTOM") { return; } + if (deleted.indexOf(msg.uid) !== -1) { return true; } + + // We found an active custom message, show it + var el = common.mailbox.createElement(data); + var table = h('table.cp-broadcast-delete'); + var $table = $(table); + var uid = Util.find(data, ['content', 'msg', 'uid']); + var time = Util.find(data, ['content', 'msg', 'content', 'time']); + var tr = h('tr', { 'data-uid': uid }, [ + h('td', 'ID: '+uid), + h('td', new Date(time || 0).toLocaleString()), + h('td', el), + h('td.delete', removeButton), + ]); + $table.append(tr); + $active.append(table); + activeUid = uid; + + return true; + }); + if (!activeUid) { $active.hide(); } + }); + }); + + // Custom message + var container = h('div.cp-broadcast-container'); + var $container = $(container); + var languages = Messages._languages; + var keys = Object.keys(languages).sort(); + + // Always keep the textarea ordered by language code + var reorder = function () { + $container.find('.cp-broadcast-lang').each(function (i, el) { + var $el = $(el); + var l = $el.attr('data-lang'); + $el.css('order', keys.indexOf(l)); + }); + }; + + // Remove a textarea + var removeLang = function (l) { + $container.find('.cp-broadcast-lang[data-lang="'+l+'"]').remove(); + + var hasDefault = $container.find('.cp-broadcast-lang .cp-checkmark input:checked').length; + if (!hasDefault) { + $container.find('.cp-broadcast-lang').first().find('.cp-checkmark input').prop('checked', 'checked'); + } + }; + + var getData = function () { return false; }; + var onPreview = function (l) { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + + var msg = { + uid: Util.uid(), + type: 'BROADCAST_CUSTOM', + content: data + }; + common.mailbox.onMessage({ + lang: l, + type: 'broadcast', + content: { + msg: msg, + hash: 'LOCAL|' + JSON.stringify(msg).slice(0,58) + } + }, function () { + UI.log(Messages.saved); + }); + }; + + // Add a textarea + var addLang = function (l) { + if ($container.find('.cp-broadcast-lang[data-lang="'+l+'"]').length) { return; } + var preview = h('button.btn.btn-secondary', Messages.broadcast_preview); + $(preview).click(function () { + onPreview(l); + }); + var bcastDefault = Messages.broadcast_defaultLanguage; + var first = !$container.find('.cp-broadcast-lang').length; + var radio = UI.createRadio('broadcastDefault', null, bcastDefault, first, { + 'data-lang': l, + label: {class: 'noTitle'} + }); + $container.append(h('div.cp-broadcast-lang', { 'data-lang': l }, [ + h('h4', languages[l]), + h('label', Messages.kanban_body), + h('textarea'), + radio, + preview + ])); + reorder(); + }; + + // Checkboxes to select translations + var boxes = keys.map(function (l) { + var $cbox = $(UI.createCheckbox('cp-broadcast-custom-lang-'+l, + languages[l], false, { label: { class: 'noTitle' } })); + var $check = $cbox.find('input').on('change', function () { + var c = $check.is(':checked'); + if (c) { return void addLang(l); } + removeLang(l); + }); + if (l === 'en') { + setTimeout(function () { + $check.click(); + }); + } + return $cbox[0]; + }); + + // Extract form data + getData = function () { + var map = {}; + var defaultLanguage; + var error = false; + $container.find('.cp-broadcast-lang').each(function (i, el) { + var $el = $(el); + var l = $el.attr('data-lang'); + if (!l) { error = true; return; } + var text = $el.find('textarea').val(); + if (!text.trim()) { error = true; return; } + if ($el.find('.cp-checkmark input').is(':checked')) { + defaultLanguage = l; + } + map[l] = text; + }); + if (!Object.keys(map).length) { + console.error('You must select at least one language'); + return false; + } + if (error) { + console.error('One of the selected languages has no data'); + return false; + } + return { + defaultLanguage: defaultLanguage, + content: map + }; + }; + + var send = function (data) { + $button.prop('disabled', 'disabled'); + data.time = +new Date(); + common.mailbox.sendTo('BROADCAST_CUSTOM', data, {}, function (err, data) { + if (err) { + $button.prop('disabled', ''); + console.error(err); + return UI.warn(Messages.error); + } + UI.log(Messages.saved); + refresh(); + + checkLastBroadcastHash(); + }); + }; + + $button.click(function () { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + send(data); + }); + + UI.confirmButton(removeButton, { + classes: 'btn-danger', + }, function () { + if (!activeUid) { return; } + common.mailbox.sendTo('BROADCAST_DELETE', { + uid: activeUid + }, {}, function (err, data) { + if (err) { return UI.warn(Messages.error); } + UI.log(Messages.saved); + refresh(); + checkLastBroadcastHash(); + }); + }); + + // Make the form + $form.empty().append([ + active, + h('label', Messages.broadcast_translations), + h('div.cp-broadcast-languages', boxes), + container, + h('div.cp-broadcast-form-submit', [ + h('br'), + button + ]) + ]); + }); + refresh(); + + return $div; + }; + + create['maintenance'] = function () { + var key = 'maintenance'; + var $div = makeBlock(key); + + var form = h('div.cp-admin-broadcast-form'); + var $form = $(form).appendTo($div); + + var refresh = getApi(function (Broadcast) { + var button = h('button.btn.btn-primary', Messages.admin_maintenanceButton); + var $button = $(button); + var removeButton = h('button.btn.btn-danger', Messages.admin_maintenanceCancel); + var active; + + if (Broadcast && Broadcast.maintenance) { + var m = Broadcast.maintenance; + if (m.start && m.end && m.end >= (+new Date())) { + active = h('div.cp-broadcast-active', [ + UI.setHTML(h('p'), Messages._getKey('broadcast_maintenance', [ + new Date(m.start).toLocaleString(), + new Date(m.end).toLocaleString(), + ])), + removeButton + ]); + } + } + + // Start and end date pickers + var start = h('input'); + var end = h('input'); + var $start = $(start); + var $end = $(end); + var endPickr = Flatpickr(end, { + enableTime: true, + minDate: new Date() + }); + Flatpickr(start, { + enableTime: true, + minDate: new Date(), + onChange: function () { + endPickr.set('minDate', new Date($start.val())); + } + }); + + // Extract form data + var getData = function () { + var start = +new Date($start.val()); + var end = +new Date($end.val()); + if (isNaN(start) || isNaN(end)) { + console.error('Invalid dates'); + return false; + } + return { + start: start, + end: end + }; + }; + + var send = function (data) { + $button.prop('disabled', 'disabled'); + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['SET_MAINTENANCE', [data]] + }, function (e) { + if (e) { + UI.warn(Messages.error); console.error(e); + $button.prop('disabled', ''); + return; + } + // Maintenance applied, send notification + common.mailbox.sendTo('BROADCAST_MAINTENANCE', {}, {}, function (err, data) { + refresh(); + checkLastBroadcastHash(); + }); + }); + + }; + $button.click(function () { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + send(data); + }); + UI.confirmButton(removeButton, { + classes: 'btn-danger', + }, function () { + send(""); + }); + + $form.empty().append([ + active, + h('label', Messages.broadcast_start), + start, + h('label', Messages.broadcast_end), + end, + h('br'), + h('div.cp-broadcast-form-submit', [ + button + ]) + ]); + }); + refresh(); + + common.makeUniversal('broadcast', { + onEvent: function (obj) { + var cmd = obj.ev; + if (cmd !== "MAINTENANCE") { return; } + refresh(); + } + }); + + return $div; + }; + create['survey'] = function () { + var key = 'survey'; + var $div = makeBlock(key); + + var form = h('div.cp-admin-broadcast-form'); + var $form = $(form).appendTo($div); + + var refresh = getApi(function (Broadcast) { + var button = h('button.btn.btn-primary', Messages.admin_surveyButton); + var $button = $(button); + var removeButton = h('button.btn.btn-danger', Messages.admin_surveyCancel); + var active; + + if (Broadcast && Broadcast.surveyURL) { + var a = h('a', {href: Broadcast.surveyURL}, Messages.admin_surveyActive); + $(a).click(function (e) { + e.preventDefault(); + common.openUnsafeURL(Broadcast.surveyURL); + }); + active = h('div.cp-broadcast-active', [ + h('p', a), + removeButton + ]); + } + + // Survey form + var label = h('label', Messages.broadcast_surveyURL); + var input = h('input'); + var $input = $(input); + + // Extract form data + var getData = function () { + var url = $input.val(); + if (!Util.isValidURL(url)) { + console.error('Invalid URL'); + return false; + } + return url; + }; + + var send = function (data) { + $button.prop('disabled', 'disabled'); + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['SET_SURVEY_URL', [data]] + }, function (e) { + if (e) { + $button.prop('disabled', ''); + UI.warn(Messages.error); console.error(e); + return; + } + // Maintenance applied, send notification + common.mailbox.sendTo('BROADCAST_SURVEY', { + url: data + }, {}, function (err, data) { + refresh(); + checkLastBroadcastHash(); + }); + }); + + }; + $button.click(function () { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + send(data); + }); + UI.confirmButton(removeButton, { + classes: 'btn-danger', + }, function () { + send(""); + }); + + $form.empty().append([ + active, + label, + input, + h('br'), + h('div.cp-broadcast-form-submit', [ + button + ]) + ]); + }); + refresh(); + + common.makeUniversal('broadcast', { + onEvent: function (obj) { + var cmd = obj.ev; + if (cmd !== "SURVEY") { return; } + refresh(); + } + }); + + return $div; + }; + var onRefreshPerformance = Util.mkEvent(); create['refresh-performance'] = function () { @@ -1010,6 +1566,7 @@ define([ stats: 'fa fa-line-chart', quota: 'fa fa-hdd-o', support: 'fa fa-life-ring', + broadcast: 'fa fa-bullhorn', performance: 'fa fa-heartbeat', }; @@ -1094,8 +1651,7 @@ define([ var privateData = metadataMgr.getPrivateData(); common.setTabTitle(Messages.adminPage || 'Administration'); - if (!privateData.edPublic || !ApiConfig.adminKeys || !Array.isArray(ApiConfig.adminKeys) - || ApiConfig.adminKeys.indexOf(privateData.edPublic) === -1) { + if (!common.isAdmin()) { return void UI.errorLoadingScreen(Messages.admin_authError || '403 Forbidden'); } diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 1cef92b51..92ecb4b4e 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1,6 +1,7 @@ define([ 'jquery', '/api/config', + '/api/broadcast', '/common/common-util.js', '/common/common-hash.js', '/common/common-language.js', @@ -17,7 +18,7 @@ define([ '/common/visible.js', 'css!/customize/fonts/cptools/style.css', -], function ($, Config, Util, Hash, Language, UI, Constants, Feedback, h, Clipboard, +], function ($, Config, Broadcast, Util, Hash, Language, UI, Constants, Feedback, h, Clipboard, Messages, AppConfig, Pages, NThen, InviteInner, Visible) { var UIElements = {}; var urlArgs = Config.requireConf.urlArgs; @@ -1332,7 +1333,7 @@ define([ // Button var $button = $('