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/client/index.js b/app/articles/client/index.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/app/articles/index.js b/app/articles/index.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/app/articles/server/api/api.js b/app/articles/server/api/api.js new file mode 100644 index 000000000000..634378c80a75 --- /dev/null +++ b/app/articles/server/api/api.js @@ -0,0 +1,89 @@ +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 { blogId } = this.request.params; + const token = decodeURIComponent(this.request.params.token); + + if (this.announceToken !== `${ blogId }/${ token }`) { + return { + error: { + statusCode: 404, + body: { + success: false, + error: 'Invalid token provided.', + }, + }, + }; + } + + const user = Models.Users.findOne({ + _id: this.bodyParams.userId, + }); + + 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(); + } + + // this.bodyParams.bot = { i: this.integration._id }; + + 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); + } +} + +Api.addRoute(':blogId/:token', { authRequired: true }, { + post: executeAnnouncementRest, + get: executeAnnouncementRest, +}); diff --git a/app/articles/server/index.js b/app/articles/server/index.js new file mode 100644 index 000000000000..eb672b4731f1 --- /dev/null +++ b/app/articles/server/index.js @@ -0,0 +1,4 @@ +import './settings'; +import './methods/admin'; +import './methods/user'; +import './api/api'; diff --git a/app/articles/server/logoutCleanUp.js b/app/articles/server/logoutCleanUp.js new file mode 100644 index 000000000000..9c527585ac91 --- /dev/null +++ b/app/articles/server/logoutCleanUp.js @@ -0,0 +1,19 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; + +import { settings } from '../../settings'; +import { API } from './utils/url'; + +const api = new API(); + +export function ghostCleanUp(cookie) { + const rcUrl = Meteor.absoluteUrl().replace(/\/$/, ''); + try { + if (settings.get('Articles_enabled')) { + HTTP.call('DELETE', api.session(), { headers: { cookie, referer: rcUrl } }); + } + } 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..660a87efe019 --- /dev/null +++ b/app/articles/server/methods/admin.js @@ -0,0 +1,77 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import _ from 'underscore'; +import { Random } from 'meteor/random'; + +import { API } from '../utils/url'; +import { settings } from '../../../settings'; + +const api = new API(); + +// Try to get a verified email, if available. +function getVerifiedEmail(emails) { + const email = _.find(emails, (e) => e.verified); + return email || emails[0].address; +} + +function setupGhost(user, token) { + const rcUrl = Meteor.absoluteUrl().replace(/\/$/, ''); + const blogTitle = settings.get('Article_Site_title'); + const blogToken = Random.id(17); + const announceToken = `${ blogToken }/${ Random.id(24) }`; + const collabToken = `${ blogToken }/${ Random.id(24) }`; + settings.updateById('Announcement_Token', announceToken); + settings.updateById('Collaboration_Token', collabToken); + const data = { + setup: [{ + rc_url: rcUrl, + rc_id: user._id, + rc_token: token, + name: user.name, + email: getVerifiedEmail(user.emails), + announce_token: announceToken, + collaboration_token: collabToken, + blogTitle, + }], + }; + return HTTP.call('POST', api.setup(), { data, headers: { 'Content-Type': 'application/json' } }); +} + +function redirectGhost() { + return { + link: api.siteUrl(), + message: 'Ghost is Set up. Redirecting.', + }; +} + +Meteor.methods({ + Articles_admin_panel(token) { + const enabled = settings.get('Articles_enabled'); + + if (!enabled) { + throw new Meteor.Error('Articles are disabled'); + } + const user = Meteor.users.findOne(Meteor.userId()); + let errMsg = 'Unable to connect to Ghost. Make sure Ghost is running'; + + try { + let response = HTTP.call('GET', api.setup()); + + if (response.data.setup[0].status) { // Ghost site is already setup + return redirectGhost(); + } + + // Setup Ghost Site and set title + response = setupGhost(user, token); + errMsg = 'Unable to setup. Make sure Ghost is running'; + + if (response.statusCode === 201 && response.content) { + return redirectGhost(); + } + + 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..1a4709595fb3 --- /dev/null +++ b/app/articles/server/methods/user.js @@ -0,0 +1,85 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; + +import { API } from '../utils/url'; +import { settings } from '../../../settings'; + +const api = new API(); + +function addUser(user, accessToken) { + const data = { + user: [{ + rc_username: user.username, + role: 'Author', // User can add itself only as Author, even if he/she is admin in RC + rc_uid: user._id, + rc_token: accessToken, + }], + }; + return HTTP.call('POST', api.createAccount(), { data, headers: { 'Content-Type': 'application/json' } }); +} + +function userExist(user, accessToken) { + const data = { + user: [{ + rc_uid: user._id, + rc_token: accessToken, + }], + }; + const response = HTTP.call('GET', api.userExist(), { data, headers: { 'Content-Type': 'application/json' } }); + return response.data && response.data.users[0] && response.data.users[0].exist; +} + +function inviteSetting() { + const response = HTTP.call('GET', api.invite()); + const { settings } = response.data; + + if (settings && settings[0] && settings[0].key === 'invite_only') { + return settings[0].value; + } + // default value in Ghost + return false; +} + +function redirectGhost() { + return { + link: api.siteUrl(), + message: 'Ghost is Set up. Redirecting.', + }; +} + +Meteor.methods({ + redirectUserToArticles(accessToken) { + const enabled = settings.get('Articles_enabled'); + + if (!enabled) { + throw new Meteor.Error('Articles are disabled'); + } + const user = Meteor.users.findOne(Meteor.userId()); + let errMsg = 'Ghost is not set up. Setup can be done from Admin Panel'; + + try { + const response = HTTP.call('GET', api.setup()); + + if (response.data.setup[0].status) { // Ghost site is already setup + // user exist in ghost + if (userExist(user, accessToken)) { + return redirectGhost(); + } + + const inviteOnly = inviteSetting(); + + // create user account in ghost + if (!inviteOnly && addUser(user, accessToken).statusCode === 200) { + return redirectGhost(); + } + + 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.'); + } + }, +}); diff --git a/app/articles/server/settings.js b/app/articles/server/settings.js new file mode 100644 index 000000000000..62f6e2080335 --- /dev/null +++ b/app/articles/server/settings.js @@ -0,0 +1,64 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +const defaults = { + enable: false, +}; + +Meteor.startup(() => { + settings.addGroup('Articles', function() { + this.add('Articles_enabled', defaults.enable, { + type: 'boolean', + i18nLabel: 'Enable', + public: true, + }); + + this.add('Article_Site_title', 'Rocket.Chat', { + type: 'string', + enableQuery: { + _id: 'Articles_enabled', + value: true, + }, + public: true, + }); + + this.add('Article_Site_Url', 'http://localhost:2368', { + type: 'string', + enableQuery: { + _id: 'Articles_enabled', + value: true, + }, + public: true, + }); + + this.add('Announcement_Token', 'announcement_token', { + type: 'string', + readonly: true, + enableQuery: { + _id: 'Articles_enabled', + value: true, + }, + public: true, + }); + + this.add('Collaboration_Token', 'collaboration_token', { + type: 'string', + readonly: true, + enableQuery: { + _id: 'Articles_enabled', + value: true, + }, + public: true, + }); + + this.add('Articles_admin_panel', 'Articles_admin_panel', { + type: 'link', + enableQuery: { + _id: 'Articles_enabled', + value: true, + }, + linkText: 'Article_Admin_Panel', + }); + }); +}); diff --git a/app/articles/server/utils/url.js b/app/articles/server/utils/url.js new file mode 100644 index 000000000000..ba2be4c72c0c --- /dev/null +++ b/app/articles/server/utils/url.js @@ -0,0 +1,38 @@ +import { settings } from '../../../settings'; + +export class API { + constructor() { + this.adminApi = '/ghost/api/v2/admin'; + } + + buildAPIUrl(type, subtype = '') { + const base = settings.get('Article_Site_Url').replace(/\/$/, ''); + const dir = `/${ type }/${ subtype }`; + return base + this.adminApi + dir; + } + + siteUrl() { + const base = settings.get('Article_Site_Url').replace(/\/$/, ''); + return `${ base }/ghost`; + } + + setup() { + return this.buildAPIUrl('authentication', 'setup'); + } + + session() { + return this.buildAPIUrl('session'); + } + + invite() { + return this.buildAPIUrl('invitesetting'); + } + + createAccount() { + return this.buildAPIUrl('authentication', 'adduser'); + } + + userExist() { + return this.buildAPIUrl('userexist'); + } +} 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/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..d65e6fc4cb12 100644 --- a/app/ui-admin/client/admin.js +++ b/app/ui-admin/client/admin.js @@ -571,6 +571,27 @@ 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; + } + const args = [data.message].concat(data.params); + toastr.success(TAPi18n.__.apply(TAPi18n, args), TAPi18n.__('Success')); + }); + }, '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-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..b0cb6fcec865 100644 --- a/app/ui-sidenav/client/sidebarHeader.js +++ b/app/ui-sidenav/client/sidebarHeader.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; +import toastr from 'toastr'; import { popover, AccountBox, menu, SideNav, modal } from '../../ui-utils'; import { t, getUserPreference, handleError } from '../../utils'; @@ -217,6 +218,23 @@ 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'); + toastr.success(result.message, 'Success'); + redirectWindow.location; + }); + }, +}, { name: t('Options'), icon: 'menu', @@ -371,7 +389,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/client/importPackages.js b/client/importPackages.js index 32e492e5d21d..f9044032e831 100644 --- a/client/importPackages.js +++ b/client/importPackages.js @@ -108,3 +108,4 @@ import '../app/ui-cached-collection'; import '../app/action-links'; import '../app/reactions/client'; import '../app/livechat/client'; +import '../app/articles/client'; 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); });