diff --git a/app/api/server/v1/rooms.js b/app/api/server/v1/rooms.js index 7cab7a10977b..c4892b409ad0 100644 --- a/app/api/server/v1/rooms.js +++ b/app/api/server/v1/rooms.js @@ -222,7 +222,7 @@ API.v1.addRoute('rooms.leave', { authRequired: true }, { API.v1.addRoute('rooms.createDiscussion', { authRequired: true }, { post() { - const { prid, pmid, reply, t_name, users } = this.bodyParams; + const { prid, pmid, reply, t_name, users, t } = this.bodyParams; if (!prid) { return API.v1.failure('Body parameter "prid" is required.'); } @@ -238,6 +238,7 @@ API.v1.addRoute('rooms.createDiscussion', { authRequired: true }, { pmid, t_name, reply, + t, users: users || [], })); diff --git a/app/articles/README.md b/app/articles/README.md new file mode 100644 index 000000000000..f117eed00aab --- /dev/null +++ b/app/articles/README.md @@ -0,0 +1 @@ +Readme file for articles. diff --git a/app/articles/server/api/api.js b/app/articles/server/api/api.js new file mode 100644 index 000000000000..9f6de329926b --- /dev/null +++ b/app/articles/server/api/api.js @@ -0,0 +1,103 @@ +import { Restivus } from 'meteor/nimble:restivus'; +import _ from 'underscore'; + +import { processWebhookMessage } from '../../../lib'; +import { API } from '../../../api'; +import { settings } from '../../../settings'; +import * as Models from '../../../models'; + +const Api = new Restivus({ + enableCors: true, + apiPath: 'ghooks/', + auth: { + user() { + const payloadKeys = Object.keys(this.bodyParams); + const payloadIsWrapped = (this.bodyParams && this.bodyParams.payload) && payloadKeys.length === 1; + if (payloadIsWrapped && this.request.headers['content-type'] === 'application/x-www-form-urlencoded') { + try { + this.bodyParams = JSON.parse(this.bodyParams.payload); + } catch ({ message }) { + return { + error: { + statusCode: 400, + body: { + success: false, + error: message, + }, + }, + }; + } + } + + this.announceToken = settings.get('Announcement_Token'); + const token = decodeURIComponent(this.request.params.token); + + if (this.announceToken !== token) { + return { + error: { + statusCode: 404, + body: { + success: false, + error: 'Invalid token provided.', + }, + }, + }; + } + + const user = this.bodyParams.userId ? Models.Users.findOne({ _id: this.bodyParams.userId }) : Models.Users.findOne({ username: this.bodyParams.username }); + + return { user }; + }, + }, +}); + +function executeAnnouncementRest() { + const defaultValues = { + channel: this.bodyParams.channel, + alias: this.bodyParams.alias, + avatar: this.bodyParams.avatar, + emoji: this.bodyParams.emoji, + }; + + // TODO: Turn this into an option on the integrations - no body means a success + // TODO: Temporary fix for https://github.com/RocketChat/Rocket.Chat/issues/7770 until the above is implemented + if (!this.bodyParams || (_.isEmpty(this.bodyParams) && !this.integration.scriptEnabled)) { + // return RocketChat.API.v1.failure('body-empty'); + return API.v1.success(); + } + + try { + const message = processWebhookMessage(this.bodyParams, this.user, defaultValues); + if (_.isEmpty(message)) { + return API.v1.failure('unknown-error'); + } + + return API.v1.success(); + } catch ({ error, message }) { + return API.v1.failure(error || message); + } +} + +function executefetchUserRest() { + try { + const { _id, name, username, emails } = this.user; + const user = { _id, name, username, emails }; + + return API.v1.success({ user }); + } catch ({ error, message }) { + return API.v1.failure(error || message); + } +} + +Api.addRoute(':token', { authRequired: true }, { + post: executeAnnouncementRest, + get: executeAnnouncementRest, +}); + +// If a user is editor/admin in Ghost but is not an admin in RC, +// then the e-mail will not be provided to that user +// This method will allow user to fetch user with email. +Api.addRoute(':token/getUser', { authRequired: true }, { + post: executefetchUserRest, + get: executefetchUserRest, +}); diff --git a/app/articles/server/index.js b/app/articles/server/index.js new file mode 100644 index 000000000000..b9804b9805f2 --- /dev/null +++ b/app/articles/server/index.js @@ -0,0 +1,6 @@ +import './settings'; +import './methods/admin'; +import './methods/user'; +import './api/api'; +import './lib/triggerHandler'; +import './triggers'; diff --git a/app/articles/server/lib/triggerHandler.js b/app/articles/server/lib/triggerHandler.js new file mode 100644 index 000000000000..3a198f4312ee --- /dev/null +++ b/app/articles/server/lib/triggerHandler.js @@ -0,0 +1,90 @@ +import { ghostAPI } from '../utils/ghostAPI'; + +export const triggerHandler = new class ArticlesSettingsHandler { + eventNameArgumentsToObject(...args) { + const argObject = { + event: args[0], + }; + switch (argObject.event) { + case 'userEmail': + case 'userRealname': + case 'userName': + case 'deleteUser': + if (args.length >= 2) { + argObject.user = args[1]; + } + break; + case 'roomType': + case 'roomName': + if (args.length >= 2) { + argObject.room = args[1]; + } + break; + case 'siteTitle': + argObject.article = args[1]; + break; + default: + argObject.event = undefined; + break; + } + return argObject; + } + + mapEventArgsToData(data, { event, room, user, article }) { + data.event = event; + switch (event) { + case 'userEmail': + case 'userRealname': + case 'userName': + case 'deleteUser': + data.user_id = user._id; + + if (user.name) { + data.name = user.name; + } + + if (user.email) { + data.email = user.email; + } + + if (user.username) { + data.username = user.username; + } + break; + case 'roomType': + case 'roomName': + data.room_id = room.rid; + + if (room.name) { + data.name = room.name; + } + + if (room.type) { + data.type = room.type; + } + break; + case 'siteTitle': + if (article && article.title) { + data.title = article.title; + } + break; + default: + break; + } + } + + executeTrigger(...args) { + const argObject = this.eventNameArgumentsToObject(...args); + const { event } = argObject; + + if (!event) { + return; + } + + const data = {}; + + this.mapEventArgsToData(data, argObject); + + ghostAPI.executeTriggerUrl(data, 0); + } +}(); diff --git a/app/articles/server/logoutCleanUp.js b/app/articles/server/logoutCleanUp.js new file mode 100644 index 000000000000..688e80c205f9 --- /dev/null +++ b/app/articles/server/logoutCleanUp.js @@ -0,0 +1,13 @@ +import { settings } from '../../settings'; +import { ghostAPI } from './utils/ghostAPI'; + +export function ghostCleanUp(cookie) { + try { + if (settings.get('Articles_Enabled')) { + ghostAPI.deleteSesseion(cookie); + } + } catch (e) { + // Do nothing if failed to logout from Ghost. + // Error will be because user has not logged in to Ghost. + } +} diff --git a/app/articles/server/methods/admin.js b/app/articles/server/methods/admin.js new file mode 100644 index 000000000000..dee3a8785e21 --- /dev/null +++ b/app/articles/server/methods/admin.js @@ -0,0 +1,51 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { ghostAPI } from '../utils/ghostAPI'; +import { settings } from '../../../settings'; + +Meteor.methods({ + articlesAdminPanel(loginToken) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'articlesAdminPanel', + }); + } + + const enabled = settings.get('Articles_Enabled'); + + if (!enabled) { + throw new Meteor.Error('error-articles-disabled', 'Articles are disabled', { + method: 'articlesAdminPanel', + }); + } + let errMsg = 'Unable to connect to Ghost. Make sure Ghost is running'; + + try { + let response = ghostAPI.isSetup(); + + if (response.data.setup[0].status) { // Ghost site is already setup + return ghostAPI.redirectToGhostLink; + } + + if (!hasPermission(Meteor.userId(), 'setup-ghost')) { + throw new Meteor.Error('error-action-not-allowed', 'Setting up Ghost is not allowed', { + method: 'articlesAdminPanel', + }); + } + + // Setup Ghost Site and set title + response = ghostAPI.setupGhost(loginToken); + + errMsg = 'Unable to setup. Make sure Ghost is running'; + + if (response.statusCode === 201 && response.content) { + return ghostAPI.redirectToGhostLink; + } + + throw new Meteor.Error(errMsg); + } catch (e) { + throw new Meteor.Error(e.error || errMsg); + } + }, +}); diff --git a/app/articles/server/methods/user.js b/app/articles/server/methods/user.js new file mode 100644 index 000000000000..092fa4280c8e --- /dev/null +++ b/app/articles/server/methods/user.js @@ -0,0 +1,79 @@ +import { Meteor } from 'meteor/meteor'; + +import { ghostAPI } from '../utils/ghostAPI'; +import { settings } from '../../../settings'; + +Meteor.methods({ + redirectUserToArticles(loginToken) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'redirectUserToArticles', + }); + } + + const enabled = settings.get('Articles_Enabled'); + + if (!enabled) { + throw new Meteor.Error('error-articles-disabled', 'Articles are disabled', { + method: 'redirectUserToArticles', + }); + } + + let errMsg = 'Ghost is not set up. Setup can be done from Admin Panel'; + + try { + const response = ghostAPI.isSetup(); + + if (response.data.setup[0].status) { // Ghost site is already setup + const u = ghostAPI.userExistInGhost(Meteor.userId()).users[0]; + + if (u.exist && u.status === 'active') { + return ghostAPI.redirectToGhostLink; + } + + if (u.exist) { // user exist but suspended + throw new Meteor.Error('error-articles-user-suspended', 'You are suspended from Ghost', { + method: 'redirectUserToArticles', + }); + } + + const inviteOnly = ghostAPI.inviteSettingInGhost(); + + // create user account in ghost + if (!inviteOnly && ghostAPI.createUserAccount(loginToken).statusCode === 200) { + return ghostAPI.redirectToGhostLink; + } + + errMsg = inviteOnly ? 'You are not a member of Ghost. Ask admin to add' : 'Unable to setup your account'; + } + + // Cannot setup Ghost from sidenav + throw new Meteor.Error(errMsg); + } catch (e) { + throw new Meteor.Error(e.error || 'Unable to connect to Ghost.'); + } + }, + + redirectToUsersArticles(_id) { + const enabled = settings.get('Articles_Enabled'); + + if (!enabled) { + throw new Meteor.Error('error-articles-disabled', 'Articles are disabled', { + method: 'redirectToUsersArticles', + }); + } + const errMsg = 'User is not a member of Ghost'; + + try { + const response = ghostAPI.userExistInGhost(_id).users[0]; + + if (response.exist) { + return ghostAPI.redirectToPublicLink(response.slug); + } + + throw new Meteor.Error(errMsg); + } catch (e) { + throw new Meteor.Error(e.error || errMsg); + } + }, +}); diff --git a/app/articles/server/settings.js b/app/articles/server/settings.js new file mode 100644 index 000000000000..cfa9c7d8a794 --- /dev/null +++ b/app/articles/server/settings.js @@ -0,0 +1,66 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(() => { + settings.addGroup('Articles', function() { + this.add('Articles_Enabled', false, { + type: 'boolean', + i18nLabel: 'Enable', + public: true, + }); + + this.add('Article_Site_Title', 'Rocket.Chat', { + type: 'string', + enableQuery: { + _id: 'Articles_Enabled', + value: true, + }, + i18nLabel: 'Article_Site_Title', + public: true, + }); + + this.add('Article_Site_Url', 'http://localhost:2368', { + type: 'string', + enableQuery: { + _id: 'Articles_Enabled', + value: true, + }, + i18nLabel: 'Article_Site_Url', + public: true, + }); + + this.add('Announcement_Token', 'announcement_token', { + type: 'string', + readonly: true, + enableQuery: { + _id: 'Articles_Enabled', + value: true, + }, + i18nLabel: 'Announcement_Token', + public: true, + }); + + this.add('Settings_Token', 'articles_settings_token', { + type: 'string', + readonly: true, + enableQuery: { + _id: 'Articles_Enabled', + value: true, + }, + i18nLabel: 'Settings_Token', + public: true, + }); + + this.add('Articles_Admin_Panel', 'articlesAdminPanel', { + type: 'link', + enableQuery: { + _id: 'Articles_Enabled', + value: true, + }, + linkText: 'Article_Admin_Panel', + i18nLabel: 'Article_Admin_Panel', + i18nDescription: 'Article_Admin_Panel_Description', + }); + }); +}); diff --git a/app/articles/server/triggers.js b/app/articles/server/triggers.js new file mode 100644 index 000000000000..045c1c292f25 --- /dev/null +++ b/app/articles/server/triggers.js @@ -0,0 +1,23 @@ +import { callbacks } from '../../callbacks'; +import { triggerHandler } from './lib/triggerHandler'; +import { settings } from '../../settings'; + +const callbackHandler = function _callbackHandler(eventType) { + return function _wrapperFunction(...args) { + return triggerHandler.executeTrigger(eventType, ...args); + }; +}; + +const priority = settings.get('Articles_Enabled') ? callbacks.priority.HIGH : callbacks.priority.LOW; + +callbacks.add('afterUserEmailChange', callbackHandler('userEmail'), priority); +callbacks.add('afterUserRealNameChange', callbackHandler('userRealname'), priority); +callbacks.add('afterUsernameChange', callbackHandler('userName'), priority); +callbacks.add('afterRoomTypeChange', callbackHandler('roomType'), priority); +callbacks.add('afterDeleteUser', callbackHandler('deleteUser'), priority); +// TODO: find why roomName have no arg passed even with High priority. +callbacks.add('afterRoomNameChange', callbackHandler('roomName'), priority); + +settings.get('Article_Site_Title', (key, value) => { + triggerHandler.executeTrigger('siteTitle', { title: value }); +}); diff --git a/app/articles/server/utils/ghostAPI.js b/app/articles/server/utils/ghostAPI.js new file mode 100644 index 000000000000..561341bb9a29 --- /dev/null +++ b/app/articles/server/utils/ghostAPI.js @@ -0,0 +1,139 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import { Random } from 'meteor/random'; + +import { settings } from '../../../settings'; + +export const ghostAPI = new class { + constructor() { + this._getBaseUrl = settings.get('Article_Site_Url').replace(/\/$/, ''); + this.adminApi = '/ghost/api/v2/admin'; + this.rhooks = this.buildAPIUrl('rhooks', settings.get('Settings_Token')); + this.setupUrl = this.buildAPIUrl('authentication', 'setup'); + this.sessionUrl = this.buildAPIUrl('session'); + this.inviteUrl = this.buildAPIUrl('invitesetting'); + this.createUserAccountUrl = this.buildAPIUrl('authentication', 'adduser'); + this.userExistUrl = this.buildAPIUrl('userexist'); + this.siteUrl = `${ this._getBaseUrl }/ghost`; + this.redirectToGhostLink = { link: this.siteUrl }; + } + + buildAPIUrl(type, subtype = '') { + const dir = `/${ type }/${ subtype }`; + return this._getBaseUrl + this.adminApi + dir; + } + + authorUrl(slug) { + return `${ this._getBaseUrl }/author/${ slug }`; + } + + isSetup() { + return HTTP.call('GET', this.setupUrl); + } + + redirectToPublicLink(slug) { + return { + link: this.authorUrl(slug), + }; + } + + setupGhost(loginToken) { + const rcUrl = Meteor.absoluteUrl().replace(/\/$/, ''); + const blogTitle = settings.get('Article_Site_Title'); + const announceToken = Random.secret(30); + const settingsToken = Random.secret(30); + settings.updateById('Announcement_Token', announceToken); + settings.updateById('Settings_Token', settingsToken); + const data = { + setup: [{ + rc_url: rcUrl, + rc_id: Meteor.userId(), + rc_token: loginToken, + announce_token: announceToken, + settings_token: settingsToken, + blogTitle, + }], + }; + return HTTP.call('POST', this.setupUrl, { data, headers: { 'Content-Type': 'application/json' } }); + } + + createUserAccount(loginToken) { + const { username } = Meteor.user(); + const data = { + user: [{ + rc_username: username, + role: 'Author', // User can add itself only as Author, even if he/she is admin in RC + rc_uid: Meteor.userId(), + rc_token: loginToken, + }], + }; + return HTTP.call('POST', this.createUserAccountUrl, { data, headers: { 'Content-Type': 'application/json' } }); + } + + userExistInGhost(_id) { + const data = { + user: [{ + rc_uid: _id, + }], + }; + const response = HTTP.call('GET', this.userExistUrl, { data, headers: { 'Content-Type': 'application/json' } }); + return response.data; + } + + inviteSettingInGhost() { + const response = HTTP.call('GET', this.inviteUrl); + const { settings } = response.data; + + if (settings && settings[0] && settings[0].key === 'invite_only') { + return settings[0].value; + } + // default value in Ghost + return false; + } + + executeTriggerUrl(data, tries = 0) { + if (!settings.get('Articles_Enabled')) { + return; + } + + const retryCount = 5; + + const opts = { + params: {}, + method: 'POST', + url: this.rhooks, + data, + auth: undefined, + npmRequestOptions: { + rejectUnauthorized: !settings.get('Allow_Invalid_SelfSigned_Certs'), + strictSSL: !settings.get('Allow_Invalid_SelfSigned_Certs'), + }, + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.0 Safari/537.36', + }, + }; + + if (!opts.url || !opts.method) { + return; + } + + HTTP.call(opts.method, opts.url, opts, (error, result) => { + // if the result contained nothing or wasn't a successful statusCode + if (!result) { + if (tries < retryCount) { + // 2 seconds, 4 seconds, 8 seconds + const waitTime = Math.pow(2, tries + 1) * 1000; + + Meteor.setTimeout(() => { + this.executeTriggerUrl(data, tries + 1); + }, waitTime); + } + } + }); + } + + deleteSesseion(cookie) { + const rcUrl = Meteor.absoluteUrl().replace(/\/$/, ''); + HTTP.call('DELETE', this.sessionUrl, { headers: { cookie, referer: rcUrl } }); + } +}(); diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index 8d57052b66db..dfe7442ef4ef 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -60,6 +60,7 @@ Meteor.startup(function() { { _id: 'set-owner', roles: ['admin', 'owner'] }, { _id: 'send-many-messages', roles: ['admin', 'bot'] }, { _id: 'set-leader', roles: ['admin', 'owner'] }, + { _id: 'setup-ghost', roles: ['admin', 'owner'] }, { _id: 'unarchive-room', roles: ['admin'] }, { _id: 'view-c-room', roles: ['admin', 'user', 'bot', 'anonymous'] }, { _id: 'user-generate-access-token', roles: ['admin'] }, diff --git a/app/channel-settings/server/functions/saveRoomType.js b/app/channel-settings/server/functions/saveRoomType.js index 9bf6cb4503ba..6831e239e9d8 100644 --- a/app/channel-settings/server/functions/saveRoomType.js +++ b/app/channel-settings/server/functions/saveRoomType.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Match } from 'meteor/check'; import { TAPi18n } from 'meteor/tap:i18n'; +import { callbacks } from '../../../callbacks'; import { Rooms, Subscriptions, Messages } from '../../../models'; import { settings } from '../../../settings'; @@ -43,5 +44,6 @@ export const saveRoomType = function(rid, roomType, user, sendMessage = true) { } Messages.createRoomSettingsChangedWithTypeRoomIdMessageAndUser('room_changed_privacy', rid, message, user); } + callbacks.run('afterRoomTypeChange', { rid, type: roomType }); return result; }; diff --git a/app/discussion/server/methods/createDiscussion.js b/app/discussion/server/methods/createDiscussion.js index 78535311ad9d..63cd7470483d 100644 --- a/app/discussion/server/methods/createDiscussion.js +++ b/app/discussion/server/methods/createDiscussion.js @@ -33,7 +33,7 @@ const mentionMessage = (rid, { _id, username, name }, message_embedded) => { return Messages.insert(welcomeMessage); }; -const create = ({ prid, pmid, t_name, reply, users }) => { +const create = ({ prid, pmid, t_name, reply, t, users }) => { // if you set both, prid and pmid, and the rooms doesnt match... should throw an error) let message = false; if (pmid) { @@ -86,8 +86,10 @@ const create = ({ prid, pmid, t_name, reply, users }) => { // auto invite the replied message owner const invitedUsers = message ? [message.u.username, ...users] : users; - // discussions are always created as private groups - const discussion = createRoom('p', name, user.username, [...new Set(invitedUsers)], false, { + // discussions are created as private groups, if t is not given as 'c' + const type = t === 'c' ? 'c' : 'p'; + + const discussion = createRoom(type, name, user.username, [...new Set(invitedUsers)], false, { fname: t_name, description: message.msg, // TODO discussions remove topic: p_room.name, // TODO discussions remove @@ -121,7 +123,7 @@ Meteor.methods({ * @param {string} t_name - discussion name * @param {string[]} users - users to be added */ - createDiscussion({ prid, pmid, t_name, reply, users }) { + createDiscussion({ prid, pmid, t_name, reply, t, users }) { if (!settings.get('Discussion_enabled')) { throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } @@ -135,6 +137,6 @@ Meteor.methods({ throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } - return create({ uid, prid, pmid, t_name, reply, users }); + return create({ uid, prid, pmid, t_name, reply, t, users }); }, }); diff --git a/app/file-upload/client/lib/fileUploadHandler.js b/app/file-upload/client/lib/fileUploadHandler.js index fe13fdcbb965..b584a98da269 100644 --- a/app/file-upload/client/lib/fileUploadHandler.js +++ b/app/file-upload/client/lib/fileUploadHandler.js @@ -27,7 +27,12 @@ export const fileUploadHandler = (directive, meta, file) => { Tracker.autorun(function() { if (Meteor.userId()) { - document.cookie = `rc_uid=${ escape(Meteor.userId()) }; path=/`; - document.cookie = `rc_token=${ escape(Accounts._storedLoginToken()) }; path=/`; + let domain = Meteor.absoluteUrl().replace(/.*\/\//, ''); + domain = domain.replace(/:.*$/, ''); + domain = domain.replace(/\/$/, ''); + domain = `.${ domain }`; + + document.cookie = `rc_uid=${ escape(Meteor.userId()) }; domain=${ domain }; path=/`; + document.cookie = `rc_token=${ escape(Accounts._storedLoginToken()) }; domain=${ domain }; path=/`; } }); diff --git a/app/lib/server/functions/deleteUser.js b/app/lib/server/functions/deleteUser.js index 537732d1617f..9bf5b96922be 100644 --- a/app/lib/server/functions/deleteUser.js +++ b/app/lib/server/functions/deleteUser.js @@ -6,6 +6,7 @@ import { Users, Subscriptions, Messages, Rooms, Integrations, FederationPeers } import { hasRole, getUsersInRole } from '../../../authorization'; import { settings } from '../../../settings'; import { Notifications } from '../../../notifications'; +import { callbacks } from '../../../callbacks'; export const deleteUser = function(userId) { const user = Users.findOneById(userId, { @@ -97,6 +98,7 @@ export const deleteUser = function(userId) { } Users.removeById(userId); // Remove user from users database + callbacks.run('afterDeleteUser', { _id: userId }); // Refresh the peers list FederationPeers.refreshPeers(); diff --git a/app/lib/server/functions/insertMessage.js b/app/lib/server/functions/insertMessage.js index 35c7ccec1501..c673c130ee8c 100644 --- a/app/lib/server/functions/insertMessage.js +++ b/app/lib/server/functions/insertMessage.js @@ -40,6 +40,8 @@ const validateAttachmentsActions = (attachmentActions) => { webview_height_ratio: String, msg: String, msg_in_chat_window: Boolean, + open_room_by_id: Boolean, + rid: String, })); }; diff --git a/app/lib/server/functions/sendMessage.js b/app/lib/server/functions/sendMessage.js index 290218f14e3d..0335dbac0943 100644 --- a/app/lib/server/functions/sendMessage.js +++ b/app/lib/server/functions/sendMessage.js @@ -63,6 +63,8 @@ const validateAttachmentsActions = (attachmentActions) => { webview_height_ratio: String, msg: String, msg_in_chat_window: Boolean, + open_room_by_id: Boolean, + rid: String, })); }; diff --git a/app/lib/server/functions/setEmail.js b/app/lib/server/functions/setEmail.js index 982194ae78e5..4a06a5622d3b 100644 --- a/app/lib/server/functions/setEmail.js +++ b/app/lib/server/functions/setEmail.js @@ -4,6 +4,7 @@ import s from 'underscore.string'; import { Users } from '../../../models'; import { hasPermission } from '../../../authorization'; import { RateLimiter, validateEmailDomain } from '../lib'; +import { callbacks } from '../../../callbacks'; import { checkEmailAvailability } from '.'; @@ -21,7 +22,7 @@ const _setEmail = function(userId, email, shouldSendVerificationEmail = true) { const user = Users.findOneById(userId); - // User already has desired username, return + // User already has desired email, return if (user.emails && user.emails[0] && user.emails[0].address === email) { return user; } @@ -37,6 +38,7 @@ const _setEmail = function(userId, email, shouldSendVerificationEmail = true) { if (shouldSendVerificationEmail === true) { Meteor.call('sendConfirmationEmail', user.email); } + callbacks.run('afterUserEmailChange', { _id: user._id, email }); return user; }; diff --git a/app/lib/server/functions/setRealName.js b/app/lib/server/functions/setRealName.js index 40262fd612bf..23efbc52f085 100644 --- a/app/lib/server/functions/setRealName.js +++ b/app/lib/server/functions/setRealName.js @@ -6,6 +6,7 @@ import { settings } from '../../../settings'; import { Notifications } from '../../../notifications'; import { hasPermission } from '../../../authorization'; import { RateLimiter } from '../lib'; +import { callbacks } from '../../../callbacks'; export const _setRealName = function(userId, name) { name = s.trim(name); @@ -41,6 +42,7 @@ export const _setRealName = function(userId, name) { username: user.username, }); } + callbacks.run('afterUserRealNameChange', { _id: user._id, name }); return user; }; diff --git a/app/lib/server/functions/setUsername.js b/app/lib/server/functions/setUsername.js index baed79351cf8..cf2e0a2a1be4 100644 --- a/app/lib/server/functions/setUsername.js +++ b/app/lib/server/functions/setUsername.js @@ -8,6 +8,7 @@ import { Users, Messages, Subscriptions, Rooms, LivechatDepartmentAgents } from import { hasPermission } from '../../../authorization'; import { RateLimiter } from '../lib'; import { Notifications } from '../../../notifications/server'; +import { callbacks } from '../../../callbacks'; import { checkUsernameAvailability, setUserAvatar, getAvatarSuggestionForUser } from '.'; @@ -92,6 +93,7 @@ export const _setUsername = function(userId, u) { name: user.name, username: user.username, }); + callbacks.run('afterUsernameChange', { _id: user._id, username }); return user; }; diff --git a/app/message-action/client/messageAction.html b/app/message-action/client/messageAction.html index 8d6c73566772..b212c51b062c 100644 --- a/app/message-action/client/messageAction.html +++ b/app/message-action/client/messageAction.html @@ -13,6 +13,11 @@ {{/if}} + {{#if open_room_by_id}} + + {{/if}} {{/if}} {{#if text}} {{#if url}} @@ -25,6 +30,11 @@ {{text}} {{/if}} + {{#if open_room_by_id}} + + {{/if}} {{/if}} {{/if}} diff --git a/app/ui-admin/client/admin.html b/app/ui-admin/client/admin.html index 1eaa5c0c81a0..25d7118ba457 100644 --- a/app/ui-admin/client/admin.html +++ b/app/ui-admin/client/admin.html @@ -153,6 +153,14 @@ {{/if}} {{/if}} + {{#if $eq type 'link'}} + {{#if hasChanges section}} + {{_ "Save_to_enable_this_action"}} + {{else}} + + {{/if}} + {{/if}} + {{#if $eq type 'asset'}} {{#if value.url}}
diff --git a/app/ui-admin/client/admin.js b/app/ui-admin/client/admin.js index 0167a036e680..2cec0620b5bc 100644 --- a/app/ui-admin/client/admin.js +++ b/app/ui-admin/client/admin.js @@ -571,6 +571,25 @@ Template.admin.events({ toastr.success(TAPi18n.__.apply(TAPi18n, args), TAPi18n.__('Success')); }); }, + 'click button.link'() { + if (this.type !== 'link') { + return; + } + const loginToken = localStorage.getItem('Meteor.loginToken'); + Meteor.call(this.value, loginToken, function(err, data) { + if (err != null) { + err.details = _.extend(err.details || {}, { + errorTitle: 'Error', + }); + handleError(err); + return; + } + if (data.link) { + const redirectWindow = window.open(data.link, '_blank'); + redirectWindow.location; + } + }); + }, 'click .button-fullscreen'() { const codeMirrorBox = $(`.code-mirror-box[data-editor-id="${ this._id }"]`); codeMirrorBox.addClass('code-mirror-box-fullscreen content-background-color'); diff --git a/app/ui-flextab/client/tabs/userActions.js b/app/ui-flextab/client/tabs/userActions.js index 2e5657cd0809..1a4164d7ca8f 100644 --- a/app/ui-flextab/client/tabs/userActions.js +++ b/app/ui-flextab/client/tabs/userActions.js @@ -111,6 +111,23 @@ export const getActions = ({ user, directActions, hideAdminControls }) => { }, }, + { + icon: 'articles', + name: t('Articles'), + action: prevent(getUser, ({ _id }) => + Meteor.call('redirectToUsersArticles', _id, (error, result) => { + if (error) { + return handleError(error); + } + const redirectWindow = window.open(result.link, '_blank'); + redirectWindow.location; + }) + ), + condition() { + return settings.get('Articles_Enabled'); + }, + }, + function() { if (isSelf(this.username) || !directActions) { return; diff --git a/app/ui-master/public/icons/articles.svg b/app/ui-master/public/icons/articles.svg new file mode 100644 index 000000000000..f97bc75e92c5 --- /dev/null +++ b/app/ui-master/public/icons/articles.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ui-sidenav/client/sidebarHeader.js b/app/ui-sidenav/client/sidebarHeader.js index a35519ed0c62..6adb187f3cbc 100644 --- a/app/ui-sidenav/client/sidebarHeader.js +++ b/app/ui-sidenav/client/sidebarHeader.js @@ -217,6 +217,22 @@ const toolbarButtons = (user) => [{ popover.open(config); }, }, +{ + name: t('Articles'), + icon: 'articles', + condition: () => settings.get('Articles_Enabled'), + action: () => { + const loginToken = localStorage.getItem('Meteor.loginToken'); + + Meteor.call('redirectUserToArticles', loginToken, (error, result) => { + if (error) { + return handleError(error); + } + const redirectWindow = window.open(result.link, '_blank'); + redirectWindow.location; + }); + }, +}, { name: t('Options'), icon: 'menu', @@ -371,7 +387,7 @@ Template.sidebarHeader.events({ action: () => { Meteor.logout(() => { callbacks.run('afterLogoutCleanUp', user); - Meteor.call('logoutCleanUp', user); + Meteor.call('logoutCleanUp', user, document.cookie); FlowRouter.go('home'); popover.close(); }); diff --git a/app/ui/client/lib/iframeCommands.js b/app/ui/client/lib/iframeCommands.js index 83dfe611db0b..b2e8b3d39c41 100644 --- a/app/ui/client/lib/iframeCommands.js +++ b/app/ui/client/lib/iframeCommands.js @@ -57,7 +57,7 @@ const commands = { const user = Meteor.user(); Meteor.logout(() => { callbacks.run('afterLogoutCleanUp', user); - Meteor.call('logoutCleanUp', user); + Meteor.call('logoutCleanUp', user, document.cookie); return FlowRouter.go('home'); }); }, diff --git a/app/ui/client/views/app/room.js b/app/ui/client/views/app/room.js index 95113da33759..d25dd6fe6319 100644 --- a/app/ui/client/views/app/room.js +++ b/app/ui/client/views/app/room.js @@ -944,6 +944,14 @@ Template.room.events({ await call('sendMessage', msgObject); }, + 'click .js-actionButton-openRoom'(event) { + const rid = event.currentTarget.value; + if (!rid) { + return; + } + + FlowRouter.goToRoomById(rid); + }, 'click .js-actionButton-respondWithMessage'(event, instance) { const rid = instance.data._id; const msg = event.currentTarget.value; diff --git a/app/utils/lib/RoomTypeConfig.js b/app/utils/lib/RoomTypeConfig.js index ac87d16863cd..02638f39e1ce 100644 --- a/app/utils/lib/RoomTypeConfig.js +++ b/app/utils/lib/RoomTypeConfig.js @@ -258,7 +258,7 @@ export class RoomTypeConfig { const title = `#${ this.roomName(room) }`; - const text = `${ settings.get('UI_Use_Real_Name') ? user.name : user.username }: ${ notificationMessage }`; + const text = `${ settings && settings.get('UI_Use_Real_Name') ? user.name : user.username }: ${ notificationMessage }`; return { title, text }; } diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 64bf88bd3887..4c4a374de98c 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -268,6 +268,7 @@ "And_more": "And __length__ more", "Animals_and_Nature": "Animals & Nature", "Announcement": "Announcement", + "Announcement_Token": "Announcement Token", "API": "API", "API_Add_Personal_Access_Token": "Add new Personal Access Token", "API_Allow_Infinite_Count": "Allow Getting Everything", @@ -368,6 +369,11 @@ "Are_you_sure": "Are you sure?", "Are_you_sure_you_want_to_delete_your_account": "Are you sure you want to delete your account?", "Are_you_sure_you_want_to_disable_Facebook_integration": "Are you sure you want to disable Facebook integration?", + "Articles": "Articles", + "Article_Admin_Panel": "Article Admin Panel", + "Article_Admin_Panel_Description": "Will setup and redirect to Ghost Admin Panel", + "Article_Site_Title": "Article Site Title", + "Article_Site_Url": "Article Site Url", "Assets": "Assets", "assign-admin-role": "Assign Admin Role", "assign-admin-role_description": "Permission to assign the admin role to other users", @@ -1188,8 +1194,10 @@ "Entertainment": "Entertainment", "Error": "Error", "error-action-not-allowed": "__action__ is not allowed", + "error-articles-user-suspended": "You are suspended from Ghost", "error-application-not-found": "Application not found", "error-archived-duplicate-name": "There's an archived channel with name '__room_name__'", + "error-articles-disabled": "Articles are disabled", "error-avatar-invalid-url": "Invalid avatar URL: __url__", "error-avatar-url-handling": "Error while handling avatar setting from a URL (__url__) for __username__", "error-cant-invite-for-direct-room": "Can't invite user to direct rooms", @@ -2647,6 +2655,7 @@ "Set_as_owner": "Set as owner", "Settings": "Settings", "Settings_updated": "Settings updated", + "setup-ghost": "Setup Ghost", "Setup_Wizard": "Setup Wizard", "Setup_Wizard_Info": "We'll guide you through setting up your first admin user, configuring your organisation and registering your server to receive free push notifications and more.", "Share_Location_Title": "Share Location?", @@ -2780,6 +2789,7 @@ "Stream_Cast_Address": "Stream Cast Address", "Stream_Cast_Address_Description": "IP or Host of your Rocket.Chat central Stream Cast. E.g. `192.168.1.1:3000` or `localhost:4000`", "strike": "strike", + "Settings_Token": "Settings Token", "Style": "Style", "Subject": "Subject", "Submit": "Submit", diff --git a/private/public/icons.svg b/private/public/icons.svg index 6ae97ffaea94..2e0c613ab60c 100644 --- a/private/public/icons.svg +++ b/private/public/icons.svg @@ -5,6 +5,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/public/icons.html b/public/public/icons.html index 050d518c5196..c0d77c7f6c7a 100644 --- a/public/public/icons.html +++ b/public/public/icons.html @@ -22,13 +22,80 @@ height: 20px; color: blue; } -
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/importPackages.js b/server/importPackages.js index 40daed01b3de..646d2864a492 100644 --- a/server/importPackages.js +++ b/server/importPackages.js @@ -116,3 +116,4 @@ import '../app/ui-utils'; import '../app/action-links'; import '../app/reactions/server'; import '../app/livechat/server'; +import '../app/articles/server'; diff --git a/server/methods/logoutCleanUp.js b/server/methods/logoutCleanUp.js index db6cba739aa3..d8dbd6a7a856 100644 --- a/server/methods/logoutCleanUp.js +++ b/server/methods/logoutCleanUp.js @@ -1,12 +1,15 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; +import { ghostCleanUp } from '../../app/articles/server/logoutCleanUp'; import { callbacks } from '../../app/callbacks'; Meteor.methods({ - logoutCleanUp(user) { + logoutCleanUp(user, cookie = '') { check(user, Object); + ghostCleanUp(cookie); + Meteor.defer(function() { callbacks.run('afterLogoutCleanUp', user); });