From 11055d814751818bf6a791d73f7f5d843195a85d Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Tue, 19 Jul 2022 12:17:57 +0000 Subject: [PATCH 01/39] chore(sql): Add external_service_oauth table --- sql/migrations/2022-07-19/up.sql | 17 +++++++++++++++++ sql/schemas/bookbrainz.sql | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 sql/migrations/2022-07-19/up.sql diff --git a/sql/migrations/2022-07-19/up.sql b/sql/migrations/2022-07-19/up.sql new file mode 100644 index 000000000..a61e6553d --- /dev/null +++ b/sql/migrations/2022-07-19/up.sql @@ -0,0 +1,17 @@ +CREATE TYPE bookbrainz.external_service_oauth_type AS ENUM ( + 'critiquebrainz' +); + +CREATE TABLE bookbrainz.external_service_oauth ( + id SERIAL, + editor_id INTEGER NOT NULL, + service bookbrainz.external_service_oauth_type NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_expires TIMESTAMP, + scopes TEXT[] +); + +ALTER TABLE bookbrainz.external_service_oauth ADD CONSTRAINT external_service_oauth_editor_id_service UNIQUE (editor_id, service); + +ALTER TABLE bookbrainz.external_service_oauth ADD FOREIGN KEY (editor_id) REFERENCES bookbrainz.editor (id); diff --git a/sql/schemas/bookbrainz.sql b/sql/schemas/bookbrainz.sql index 51c5fbe0d..e0fdbe475 100644 --- a/sql/schemas/bookbrainz.sql +++ b/sql/schemas/bookbrainz.sql @@ -16,6 +16,10 @@ CREATE TYPE bookbrainz.entity_type AS ENUM ( 'Series' ); +CREATE TYPE bookbrainz.external_service_oauth_type AS ENUM ( + 'critiquebrainz' +); + CREATE TABLE bookbrainz.editor_type ( id SERIAL PRIMARY KEY, label VARCHAR(255) NOT NULL CHECK (label <> '') @@ -806,6 +810,20 @@ CREATE TABLE bookbrainz.user_collection_collaborator ( ALTER TABLE bookbrainz.user_collection_collaborator ADD FOREIGN KEY (collection_id) REFERENCES bookbrainz.user_collection (id) ON DELETE CASCADE; ALTER TABLE bookbrainz.user_collection_collaborator ADD FOREIGN KEY (collaborator_id) REFERENCES bookbrainz.editor (id); +CREATE TABLE bookbrainz.external_service_oauth ( + id SERIAL, + editor_id INTEGER NOT NULL, + service bookbrainz.external_service_oauth_type NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_expires TIMESTAMP, + scopes TEXT[] +); + +ALTER TABLE bookbrainz.external_service_oauth ADD CONSTRAINT external_service_oauth_editor_id_service UNIQUE (editor_id, service); + +ALTER TABLE bookbrainz.external_service_oauth ADD FOREIGN KEY (editor_id) REFERENCES bookbrainz.editor (id); + -- Views -- CREATE VIEW bookbrainz.author AS From e348d42660a0c5e6b5887d7e3e69c7b103e36a5e Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Tue, 19 Jul 2022 12:24:27 +0000 Subject: [PATCH 02/39] feat: Add functions for connecting CB --- config/config.json.example | 5 ++ src/server/helpers/critiquebrainz.ts | 114 +++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/config/config.json.example b/config/config.json.example index bdbbf1249..5c8a0be4d 100644 --- a/config/config.json.example +++ b/config/config.json.example @@ -9,6 +9,11 @@ "clientSecret": null, "callbackURL": "http://localhost:9099/cb" }, + "critiquebrainz": { + "clientID": null, + "clientSecret": null, + "redirectURL": "http://localhost:9099/external-service/critiquebrainz/callback" + }, "session": { "maxAge": 2592000000, "secret": "Something here!", diff --git a/src/server/helpers/critiquebrainz.ts b/src/server/helpers/critiquebrainz.ts index 0673b4ad9..29989177f 100644 --- a/src/server/helpers/critiquebrainz.ts +++ b/src/server/helpers/critiquebrainz.ts @@ -16,10 +16,124 @@ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ +/* eslint-disable camelcase */ + +import config from '../../common/helpers/config'; import log from 'log'; import request from 'superagent'; +const OAUTH_AUTHORIZE_URL = 'https://critiquebrainz.org/oauth/authorize'; +const OAUTH_TOKEN_URL = 'https://critiquebrainz.org/ws/1/oauth/token'; +const critiquebrainzScopes = ['review']; +const cbConfig = config.critiquebrainz; + +export async function addNewUser( + editorId: number, + token: Record, + orm: Record +): Promise { + const expires = Math.floor(new Date().getTime() / 1000.0) + token.tokenExpires; + + try { + const newUser = await orm.func.externalServiceOauth.saveOauthToken( + editorId, + 'critiquebrainz', + token.accessToken, + token.refreshToken, + expires, + critiquebrainzScopes, + orm + ); + return newUser; + } + catch (error) { + log.error(error); + return null; + } +} + + +export function getAuthURL(): string { + const authURL = new URL(OAUTH_AUTHORIZE_URL); + authURL.searchParams.set('client_id', cbConfig.clientID); + authURL.searchParams.set('redirect_uri', cbConfig.redirectURL); + authURL.searchParams.set('response_type', 'code'); + authURL.searchParams.set('scope', critiquebrainzScopes.join(',')); + return authURL.href; +} + + +export async function fetchAccessToken( + code: string, + editorId: number, + orm: Record +) : Promise { + try { + const data = await request.post(OAUTH_TOKEN_URL) + .type('form') + .send({ + client_id: cbConfig.clientID, + client_secret: cbConfig.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: cbConfig.redirectURL + }); + + const token = await data.body; + const expires = Math.floor(new Date().getTime() / 1000.0) + token.expires_in; + const newUser = await orm.func.externalServiceOauth.saveOauthToken( + editorId, + 'critiquebrainz', + token.access_token, + token.refresh_token, + expires, + critiquebrainzScopes, + orm + ); + return newUser; + } + catch (error) { + log.error(error); + return null; + } +} + + +export async function refreshAccessToken( + editorId: number, + refreshToken: string, + orm: Record +): Promise { + try { + const data = await request.post(OAUTH_TOKEN_URL) + .type('form') + .send({ + client_id: cbConfig.clientID, + client_secret: cbConfig.clientSecret, + grant_type: 'refresh_token', + redirect_uri: cbConfig.redirectURL, + refresh_token: refreshToken + }); + const token = await data.body; + const expires = Math.floor(new Date().getTime() / 1000.0) + token.expires_in; + const updatedToken = await orm.func.externalServiceOauth.updateOauthToken( + editorId, + 'critiquebrainz', + token.access_token, + token.refresh_token, + expires, + orm + ); + return updatedToken; + } + catch (error) { + log.error(error); + return null; + } +} + + export async function getReviewsFromCB(bbid: string, entityType: string): Promise { const mapEntityType = { From 1fede20b7de37a1908ef2f950ff4d981b02f7a7c Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Tue, 19 Jul 2022 12:29:38 +0000 Subject: [PATCH 03/39] feat: Add routes and frontend components for external-service page --- .../components/pages/externalService.js | 144 +++++++++++++++++ src/client/controllers/externalService.js | 51 ++++++ src/client/stylesheets/style.scss | 63 ++++++++ src/server/routes.js | 2 + src/server/routes/externalService.js | 146 ++++++++++++++++++ webpack.client.js | 1 + 6 files changed, 407 insertions(+) create mode 100644 src/client/components/pages/externalService.js create mode 100644 src/client/controllers/externalService.js create mode 100644 src/server/routes/externalService.js diff --git a/src/client/components/pages/externalService.js b/src/client/components/pages/externalService.js new file mode 100644 index 000000000..0aef192f8 --- /dev/null +++ b/src/client/components/pages/externalService.js @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2022 Ansh Goyal + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as bootstrap from 'react-bootstrap'; +import React from 'react'; +import request from 'superagent'; + + +const {Alert} = bootstrap; +class ExternalServices extends React.Component { + constructor(props) { + super(props); + this.alertType = false; + this.state = { + cbPermission: props.cbPermission + }; + this.handleClick = this.handleClick.bind(this); + } + + + handleClick = async (event) => { + if (event.target.value === 'review') { + const data = await request.post('/external-service/critiquebrainz/connect'); + if (data.statusCode === 200) { + window.location.href = data.text; + } + else { + this.alertType = 'danger'; + } + } + else { + const data = request.post('/external-service/critiquebrainz/disconnect'); + + if (data.statusCode === 200) { + this.setState({ + cbPermission: 'review' + }); + } + else { + this.alertType = 'danger'; + } + } + }; + + + render() { + const ShowServiceOption = (optionData) => { + const { + service, value, title, details + } = optionData; + return ( +
+ + +
+ ); + }; + + function showAlert(alertType) { + if (alertType === 'success') { + return ( + +

Success!

+ You have successfully linked your account with CritiqueBrainz. +
+ ); + } + else if (alertType === 'danger') { + return ( + + Error! + Unable to connect to CritiqueBrainz. Please try again. + + ); + } + return null; + } + + return ( +
+ {showAlert(this.alertType)} +

Connect with third-party services

+
+
+

CritiqueBrainz

+
+
+

+ Connect to your CritiqueBrainz account to publish reviews directly from BookBrainz. + Your reviews will be independently visible on CritiqueBrainz and appear publicly + on your CritiqueBrainz profile unless removed. To view or delete your reviews, visit your + CritiqueBrainz profile. +

+
+ + +
+
+
+ ); + } +} + +export default ExternalServices; diff --git a/src/client/controllers/externalService.js b/src/client/controllers/externalService.js new file mode 100644 index 000000000..f37b30be5 --- /dev/null +++ b/src/client/controllers/externalService.js @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2022 Ansh Goyal + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import {extractChildProps, extractLayoutProps} from '../helpers/props'; +import {AppContainer} from 'react-hot-loader'; +import ExternalServices from '../components/pages/externalService'; +import Layout from '../containers/layout'; +import React from 'react'; +import ReactDOM from 'react-dom'; + + +const propsTarget = document.getElementById('props'); +const props = propsTarget ? JSON.parse(propsTarget.innerHTML) : {}; + +const markup = ( + + + + + +); + +ReactDOM.hydrate(markup, document.getElementById('target')); + +/* + * As we are not exporting a component, + * we cannot use the react-hot-loader module wrapper, + * but instead directly use webpack Hot Module Replacement API + */ + +if (module.hot) { + module.hot.accept(); +} diff --git a/src/client/stylesheets/style.scss b/src/client/stylesheets/style.scss index 0e0e8b101..f84c95eca 100644 --- a/src/client/stylesheets/style.scss +++ b/src/client/stylesheets/style.scss @@ -726,4 +726,67 @@ ul { } .series-editor-select { margin-top: 1.7em; +} + +.external-service-option { + input[type="radio"] { + display: none; + &:not(:disabled) ~ label { + cursor: pointer; + } + &:disabled ~ label { + color: hsla(150, 5%, 75%, 1); + border-color: hsla(150, 5%, 75%, 1); + cursor: not-allowed; + } + } + label { + // pointer-events: none; + display: flex; + flex-wrap: wrap; + align-items: center; + + background: white; + border: 2px solid #5aa854; + border-radius: 5px; + padding: 0.5rem; + + text-align: center; + font-weight: normal; + position: relative; + + > .title { + flex: 1; + flex-basis: 120px; + font-size: 1.2em; + } + > .details { + flex: 2; + text-align: justify; + flex-basis: 200px; + } + } + input[type="radio"]:checked + label { + background: #5aa854; + color: white; + + > .details { + font-weight: bold; + } + &::after { + position: absolute; + left: 0; + top: 50%; + border-radius: 50%; + transform: translate(-50%, -50%); + + content: "✔"; + color: #5aa854; + font-size: 24px; + text-align: center; + + border: 2px solid #5aa854; + background: white; + } + } } \ No newline at end of file diff --git a/src/server/routes.js b/src/server/routes.js index 12daf6113..a577ac478 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -24,6 +24,7 @@ import collectionsRouter from './routes/collections'; import editionGroupRouter from './routes/entity/edition-group'; import editionRouter from './routes/entity/edition'; import editorRouter from './routes/editor'; +import externalServiceRouter from './routes/externalService'; import indexRouter from './routes/index'; import mergeRouter from './routes/merge'; import publisherRouter from './routes/entity/publisher'; @@ -46,6 +47,7 @@ function initRootRoutes(app) { app.use('/revisions', revisionsRouter); app.use('/collections', collectionsRouter); app.use('/statistics', statisticsRouter); + app.use('/external-service', externalServiceRouter); } function initEditionGroupRoutes(app) { diff --git a/src/server/routes/externalService.js b/src/server/routes/externalService.js new file mode 100644 index 000000000..cce251941 --- /dev/null +++ b/src/server/routes/externalService.js @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2022 Ansh Goyal + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + + +import * as auth from '../helpers/auth'; +import * as cbHelper from '../helpers/critiquebrainz.ts'; +import * as propHelpers from '../../client/helpers/props'; +import {escapeProps, generateProps} from '../helpers/props'; +import ExternalServices from '../../client/components/pages/externalService'; +import Layout from '../../client/containers/layout'; +import React from 'react'; +import ReactDOMServer from 'react-dom/server'; +import express from 'express'; +import target from '../templates/target'; + + +const marginTime = 5 * 60 * 1000; +const router = express.Router(); + + +router.get('/', async (req, res) => { + if (!req.user) { + return res.redirect('/register'); + } + const {orm} = req.app.locals; + const editorId = req.user.id; + const cbUser = await orm.func.externalServiceOauth.getOauthToken( + editorId, + 'critiquebrainz', + orm + ); + let cbPermission = 'disable'; + if (cbUser?.length) { + cbPermission = 'review'; + } + const props = generateProps(req, res, { + cbPermission + }); + + const markup = ReactDOMServer.renderToString( + + + + ); + + return res.send(target({ + markup, + props: escapeProps(props), + script: '/js/externalService.js', + title: 'External Services' + })); +}); + + +router.get('/critiquebrainz/callback', auth.isAuthenticated, async (req, res) => { + const {orm} = req.app.locals; + const {code} = req.query; + const editorId = req.user.id; + if (!code) { + res.send('No code provided'); + } + const token = await cbHelper.fetchAccessToken(code, editorId, orm); + + if (token) { + res.redirect('/external-service'); + } + res.send('Failed to fetch token'); +}); + + +router.post('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res) => { + const editorId = req.user.id; + const {orm} = req.app.locals; + let token = await orm.func.externalServiceOauth.getOauthToken( + editorId, + 'critiquebrainz', + orm + ); + if (!token?.length) { + res.send('User has not connected to CB'); + } + token = token[0]; + const tokenExpired = new Date(token.token_expires).getTime() <= new Date(new Date().getTime() + marginTime).getTime(); + if (tokenExpired) { + try { + token = await cbHelper.refreshAccessToken(editorId, token.refresh_token, orm); + } + catch (error) { + return res.json({error: error.message}); + } + } + return res.json({accessToken: token.access_token}); +}); + + +router.post('/critiquebrainz/connect', auth.isAuthenticated, async (req, res) => { + const editorId = req.user.id; + const {orm} = req.app.locals; + const token = await orm.func.externalServiceOauth.getOauthToken( + editorId, + 'critiquebrainz', + orm + ); + if (token?.length) { + await orm.func.externalServiceOauth.deleteOauthToken( + editorId, + 'critiquebrainz', + orm + ); + } + const redirectUrl = cbHelper.getAuthURL(); + + return res.send(redirectUrl); +}); + + +router.post('/critiquebrainz/disconnect', auth.isAuthenticated, async (req, res) => { + const editorId = req.user.id; + const {orm} = req.app.locals; + await orm.func.externalServiceOauth.deleteOauthToken( + editorId, + 'critiquebrainz', + orm + ); + return res.send('Successfully disconnected'); +}); + +export default router; diff --git a/webpack.client.js b/webpack.client.js index 4596dcd83..48d4b6cba 100644 --- a/webpack.client.js +++ b/webpack.client.js @@ -25,6 +25,7 @@ const clientConfig = { deletion: ['./controllers/deletion.js'], preview: ['./controllers/preview.js'], error: ['./controllers/error.js'], + externalService: ['./controllers/externalService.js'], index: ['./controllers/index.js'], registrationDetails: ['./controllers/registrationDetails.js'], revision: ['./controllers/revision.js'], From 0b18efba8566b38720253f3de104df2065fd3dd8 Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Tue, 19 Jul 2022 13:52:17 +0000 Subject: [PATCH 04/39] feat: Add propTypes --- src/client/components/pages/externalService.js | 12 +++++++++--- src/client/stylesheets/style.scss | 1 - 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/client/components/pages/externalService.js b/src/client/components/pages/externalService.js index 0aef192f8..14b78a4a6 100644 --- a/src/client/components/pages/externalService.js +++ b/src/client/components/pages/externalService.js @@ -17,6 +17,7 @@ */ import * as bootstrap from 'react-bootstrap'; +import PropTypes from 'prop-types'; import React from 'react'; import request from 'superagent'; @@ -44,11 +45,10 @@ class ExternalServices extends React.Component { } } else { - const data = request.post('/external-service/critiquebrainz/disconnect'); - + const data = await request.post('/external-service/critiquebrainz/disconnect'); if (data.statusCode === 200) { this.setState({ - cbPermission: 'review' + cbPermission: 'disable' }); } else { @@ -141,4 +141,10 @@ class ExternalServices extends React.Component { } } +ExternalServices.displayName = 'ExternalServices'; +ExternalServices.propTypes = { + cbPermission: PropTypes.string.isRequired +}; + + export default ExternalServices; diff --git a/src/client/stylesheets/style.scss b/src/client/stylesheets/style.scss index f84c95eca..dc4db4139 100644 --- a/src/client/stylesheets/style.scss +++ b/src/client/stylesheets/style.scss @@ -741,7 +741,6 @@ ul { } } label { - // pointer-events: none; display: flex; flex-wrap: wrap; align-items: center; From 35eab0721dc051202db098542cb50cb633fa680d Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Wed, 20 Jul 2022 12:39:40 +0000 Subject: [PATCH 05/39] feat: Add error handling for external services --- .../components/pages/externalService.js | 25 +++++---- src/server/routes/externalService.js | 54 ++++++++++++++----- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/client/components/pages/externalService.js b/src/client/components/pages/externalService.js index 14b78a4a6..d88f5e4b6 100644 --- a/src/client/components/pages/externalService.js +++ b/src/client/components/pages/externalService.js @@ -26,7 +26,8 @@ const {Alert} = bootstrap; class ExternalServices extends React.Component { constructor(props) { super(props); - this.alertType = false; + this.alertDetails = props.alertDetails; + this.alertType = props.alertType; this.state = { cbPermission: props.cbPermission }; @@ -42,18 +43,18 @@ class ExternalServices extends React.Component { } else { this.alertType = 'danger'; + this.alertDetails = 'Something went wrong. Please try again.'; } } else { const data = await request.post('/external-service/critiquebrainz/disconnect'); + this.alertDetails = data.body.alertDetails; + this.alertType = data.body.alertType; if (data.statusCode === 200) { this.setState({ cbPermission: 'disable' }); } - else { - this.alertType = 'danger'; - } } }; @@ -86,25 +87,23 @@ class ExternalServices extends React.Component { ); }; - function showAlert(alertType) { + const showAlert = (alertType) => { if (alertType === 'success') { return ( -

Success!

- You have successfully linked your account with CritiqueBrainz. + Success! {this.alertDetails}
); } else if (alertType === 'danger') { return ( - Error! - Unable to connect to CritiqueBrainz. Please try again. + Error! {this.alertDetails} ); } return null; - } + }; return (
@@ -143,8 +142,14 @@ class ExternalServices extends React.Component { ExternalServices.displayName = 'ExternalServices'; ExternalServices.propTypes = { + alertDetails: PropTypes.string, + alertType: PropTypes.string, cbPermission: PropTypes.string.isRequired }; +ExternalServices.defaultProps = { + alertDetails: '', + alertType: false +}; export default ExternalServices; diff --git a/src/server/routes/externalService.js b/src/server/routes/externalService.js index cce251941..af4392ceb 100644 --- a/src/server/routes/externalService.js +++ b/src/server/routes/externalService.js @@ -20,6 +20,7 @@ import * as auth from '../helpers/auth'; import * as cbHelper from '../helpers/critiquebrainz.ts'; import * as propHelpers from '../../client/helpers/props'; +import {BadRequestError, NotFoundError} from '../../common/helpers/error'; import {escapeProps, generateProps} from '../helpers/props'; import ExternalServices from '../../client/components/pages/externalService'; import Layout from '../../client/containers/layout'; @@ -27,6 +28,7 @@ import React from 'react'; import ReactDOMServer from 'react-dom/server'; import express from 'express'; import target from '../templates/target'; +import url from 'url'; const marginTime = 5 * 60 * 1000; @@ -37,6 +39,7 @@ router.get('/', async (req, res) => { if (!req.user) { return res.redirect('/register'); } + const {alertType, alertDetails} = req.query; const {orm} = req.app.locals; const editorId = req.user.id; const cbUser = await orm.func.externalServiceOauth.getOauthToken( @@ -49,6 +52,8 @@ router.get('/', async (req, res) => { cbPermission = 'review'; } const props = generateProps(req, res, { + alertDetails, + alertType, cbPermission }); @@ -56,6 +61,8 @@ router.get('/', async (req, res) => { @@ -70,23 +77,34 @@ router.get('/', async (req, res) => { }); -router.get('/critiquebrainz/callback', auth.isAuthenticated, async (req, res) => { +router.get('/critiquebrainz/callback', auth.isAuthenticated, async (req, res, next) => { const {orm} = req.app.locals; const {code} = req.query; const editorId = req.user.id; if (!code) { - res.send('No code provided'); + return next(new BadRequestError('Response type must be code')); } const token = await cbHelper.fetchAccessToken(code, editorId, orm); - if (token) { - res.redirect('/external-service'); + let alertType = 'success'; + let alertDetails = 'You have successfully linked your account with CritiqueBrainz.'; + if (!token) { + alertType = 'danger'; + alertDetails = 'Failed to connect to CritiqueBrainz'; } - res.send('Failed to fetch token'); + return res.redirect( + url.format({ + pathname: '/external-service', + query: { + alertDetails, + alertType + } + }) + ); }); -router.post('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res) => { +router.get('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res, next) => { const editorId = req.user.id; const {orm} = req.app.locals; let token = await orm.func.externalServiceOauth.getOauthToken( @@ -95,7 +113,7 @@ router.post('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res) => orm ); if (!token?.length) { - res.send('User has not connected to CB'); + return next(new NotFoundError('User has not authenticated to CritiqueBrainz')); } token = token[0]; const tokenExpired = new Date(token.token_expires).getTime() <= new Date(new Date().getTime() + marginTime).getTime(); @@ -135,12 +153,22 @@ router.post('/critiquebrainz/connect', auth.isAuthenticated, async (req, res) => router.post('/critiquebrainz/disconnect', auth.isAuthenticated, async (req, res) => { const editorId = req.user.id; const {orm} = req.app.locals; - await orm.func.externalServiceOauth.deleteOauthToken( - editorId, - 'critiquebrainz', - orm - ); - return res.send('Successfully disconnected'); + + let alertType = 'success'; + let alertDetails = 'You have successfully disconnected your account with CritiqueBrainz.'; + try { + await orm.func.externalServiceOauth.deleteOauthToken( + editorId, + 'critiquebrainz', + orm + ); + } + catch { + alertType = 'danger'; + alertDetails = 'Failed to disconnect from CritiqueBrainz'; + } + + res.send({alertDetails, alertType}); }); export default router; From cb3757fe6cf4b8fecf03a8fbc68492f634e04952 Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Wed, 20 Jul 2022 12:50:46 +0000 Subject: [PATCH 06/39] feat: Add some comments --- src/server/routes/externalService.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/routes/externalService.js b/src/server/routes/externalService.js index af4392ceb..1389896c4 100644 --- a/src/server/routes/externalService.js +++ b/src/server/routes/externalService.js @@ -132,6 +132,10 @@ router.get('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res, nex router.post('/critiquebrainz/connect', auth.isAuthenticated, async (req, res) => { const editorId = req.user.id; const {orm} = req.app.locals; + + // First we check if the user has already connected to CritiqueBrainz + // If so, we first delete the existing tokens and then try to connect again. + const token = await orm.func.externalServiceOauth.getOauthToken( editorId, 'critiquebrainz', From 4ecaa132a81fdf9ef7b138ec15b94ef3895620f3 Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Thu, 28 Jul 2022 06:54:17 +0000 Subject: [PATCH 07/39] chore: Add react-tooltip and i18n-iso-languages --- package.json | 2 ++ yarn.lock | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/package.json b/package.json index 73709d56b..4c13b0d53 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "browserslist": "> 0.25%, not dead", "dependencies": { "@babel/runtime": "^7.17.7", + "@cospired/i18n-iso-languages": "^4.0.0", "@elastic/elasticsearch": "^5.6.22", "@fortawesome/fontawesome-svg-core": "^1.2.30", "@fortawesome/free-brands-svg-icons": "^6.1.1", @@ -75,6 +76,7 @@ "react-simple-star-rating": "^4.0.5", "react-sortable-hoc": "^2.0.0", "react-sticky": "^6.0.1", + "react-tooltip": "^4.2.21", "redis": "^3.1.2", "redux": "^3.7.2", "redux-debounce": "^1.0.1", diff --git a/yarn.lock b/yarn.lock index ebfc9e520..52ccb29d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1156,6 +1156,11 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@cospired/i18n-iso-languages@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@cospired/i18n-iso-languages/-/i18n-iso-languages-4.0.0.tgz#fbd54bd046e28295a2ab8f3806c77b0d92852b29" + integrity sha512-8dKE8TJIhb6/JpwpshTV96Q/fT8kAohnAD2KnOuKkVemhDcWDjurPmzj8NnA25YW4gcKfvJYq/iDbXyB0gZ2jg== + "@discoveryjs/json-ext@^0.5.0": version "0.5.6" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" @@ -6918,6 +6923,14 @@ react-sticky@^6.0.1: prop-types "^15.5.8" raf "^3.3.0" +react-tooltip@^4.2.21: + version "4.2.21" + resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-4.2.21.tgz#840123ed86cf33d50ddde8ec8813b2960bfded7f" + integrity sha512-zSLprMymBDowknr0KVDiJ05IjZn9mQhhg4PRsqln0OZtURAJ1snt1xi5daZfagsh6vfsziZrc9pErPTDY1ACig== + dependencies: + prop-types "^15.7.2" + uuid "^7.0.3" + react-transition-group@^4.3.0, react-transition-group@^4.4.1: version "4.4.2" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" @@ -8292,6 +8305,11 @@ utils-merge@1.0.1, utils-merge@1.x.x: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= +uuid@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" From 41ae4efd0a4bb477bc74dbef49f4c30f8e48a90a Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Thu, 28 Jul 2022 06:56:18 +0000 Subject: [PATCH 08/39] feat: Add function to count words for reviews --- src/client/helpers/utils.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/client/helpers/utils.tsx b/src/client/helpers/utils.tsx index 1ffa92395..28f3f2ef0 100644 --- a/src/client/helpers/utils.tsx +++ b/src/client/helpers/utils.tsx @@ -219,3 +219,12 @@ export function getEntityKey(entityType:string) { }; return keys[entityType]; } + +export function countWords(text: string) : number { + // Credit goes to iamwhitebox https://stackoverflow.com/a/39125279/14911205 + const words = text.match(/\w+/g); + if (words === null) { + return 0; + } + return words.length; +} From 22381f551da0233d168dd171ff63fa91fc3e77f0 Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Thu, 28 Jul 2022 08:46:57 +0000 Subject: [PATCH 09/39] fix: use post route for refreshing token --- src/server/routes/externalService.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/server/routes/externalService.js b/src/server/routes/externalService.js index 1389896c4..b27376452 100644 --- a/src/server/routes/externalService.js +++ b/src/server/routes/externalService.js @@ -18,7 +18,7 @@ import * as auth from '../helpers/auth'; -import * as cbHelper from '../helpers/critiquebrainz.ts'; +import * as cbHelper from '../helpers/critiquebrainz'; import * as propHelpers from '../../client/helpers/props'; import {BadRequestError, NotFoundError} from '../../common/helpers/error'; import {escapeProps, generateProps} from '../helpers/props'; @@ -42,15 +42,18 @@ router.get('/', async (req, res) => { const {alertType, alertDetails} = req.query; const {orm} = req.app.locals; const editorId = req.user.id; + const cbUser = await orm.func.externalServiceOauth.getOauthToken( editorId, 'critiquebrainz', orm ); + let cbPermission = 'disable'; - if (cbUser?.length) { + if (cbUser) { cbPermission = 'review'; } + const props = generateProps(req, res, { alertDetails, alertType, @@ -104,7 +107,7 @@ router.get('/critiquebrainz/callback', auth.isAuthenticated, async (req, res, ne }); -router.get('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res, next) => { +router.post('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res, next) => { const editorId = req.user.id; const {orm} = req.app.locals; let token = await orm.func.externalServiceOauth.getOauthToken( @@ -112,10 +115,10 @@ router.get('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res, nex 'critiquebrainz', orm ); - if (!token?.length) { + + if (!token) { return next(new NotFoundError('User has not authenticated to CritiqueBrainz')); } - token = token[0]; const tokenExpired = new Date(token.token_expires).getTime() <= new Date(new Date().getTime() + marginTime).getTime(); if (tokenExpired) { try { From bc1b28eb1599244f33953d5e10291c0ba115a4e6 Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Thu, 28 Jul 2022 08:50:25 +0000 Subject: [PATCH 10/39] feat: Add routes and functions to post CB Reviews --- src/server/helpers/critiquebrainz.ts | 49 +++++++++++++++++++++++++++- src/server/routes/reviews.js | 41 +++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/server/helpers/critiquebrainz.ts b/src/server/helpers/critiquebrainz.ts index 29989177f..e0d0444a2 100644 --- a/src/server/helpers/critiquebrainz.ts +++ b/src/server/helpers/critiquebrainz.ts @@ -25,6 +25,7 @@ import request from 'superagent'; const OAUTH_AUTHORIZE_URL = 'https://critiquebrainz.org/oauth/authorize'; const OAUTH_TOKEN_URL = 'https://critiquebrainz.org/ws/1/oauth/token'; +const REVIEW_URL = 'https://critiquebrainz.org/ws/1/review/'; const critiquebrainzScopes = ['review']; const cbConfig = config.critiquebrainz; @@ -145,7 +146,7 @@ export async function getReviewsFromCB(bbid: string, } try { const res = await request - .get('https://critiquebrainz.org/ws/1/review') + .get(REVIEW_URL) .query({ // eslint-disable-next-line camelcase entity_id: bbid, @@ -161,3 +162,49 @@ export async function getReviewsFromCB(bbid: string, return {reviews: [], successfullyFetched: false}; } } + +export async function submitReviewToCB( + accessToken: string, + review: Record +): Promise { + const mapEntityType = { + EditionGroup: 'bb_edition_group' + }; + const cbEntityType = mapEntityType[review.entityType]; + + if (!cbEntityType) { + return { + message: 'Entity type not supported', + reviewID: null, + successfullySubmitted: false + }; + } + + try { + const res = await request + .post(REVIEW_URL) + .set('Content-Type', 'application/json') + .auth(accessToken, {type: 'bearer'}) + .send({ + entity_id: review.entityBBID, + entity_type: cbEntityType, + lang: review.language, + license_choice: 'CC BY-SA 3.0', + rating: review.rating, + text: review.textContent + }); + return { + message: res.body.message, + reviewID: res.body.id, + successfullySubmitted: true + }; + } + + catch (error) { + return { + message: error.response?.body?.description, + reviewID: null, + successfullySubmitted: false + }; + } +} diff --git a/src/server/routes/reviews.js b/src/server/routes/reviews.js index e47aa643c..b294fbb1b 100644 --- a/src/server/routes/reviews.js +++ b/src/server/routes/reviews.js @@ -17,6 +17,7 @@ */ +import * as auth from '../helpers/auth'; import * as cbHelper from '../helpers/critiquebrainz'; import express from 'express'; @@ -29,4 +30,44 @@ router.get('/:entityType/:bbid/reviews', async (req, res) => { res.json(reviews); }); +router.post('/:entityType/:bbid/reviews', auth.isAuthenticated, async (req, res) => { + const editorId = req.user.id; + const {orm} = req.app.locals; + + const {accessToken} = req.body; + const {review} = req.body; + + let response = await cbHelper.submitReviewToCB( + accessToken, + review + ); + + // If the token has expired, we try to refresh it and then submit the review again. + if (response.message === 'The provided authorization token is invalid, expired, revoked, or was issued to another client.') { + try { + const token = await orm.func.externalServiceOauth.getOauthToken( + editorId, + 'critiquebrainz', + orm + ); + + const newAccessToken = await cbHelper.refreshAccessToken( + editorId, + token.refresh_token, + orm + ); + + response = await cbHelper.submitReviewToCB( + newAccessToken.access_token, + review + ); + } + catch (error) { + return res.json({error: error.message}); + } + } + + return res.json(response); +}); + export default router; From 672825692d6c5c825eea75481988baa5fbcc355f Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Thu, 28 Jul 2022 10:42:51 +0000 Subject: [PATCH 11/39] feat: Changed Error type --- src/server/routes/externalService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/routes/externalService.js b/src/server/routes/externalService.js index b27376452..11ce51282 100644 --- a/src/server/routes/externalService.js +++ b/src/server/routes/externalService.js @@ -20,7 +20,7 @@ import * as auth from '../helpers/auth'; import * as cbHelper from '../helpers/critiquebrainz'; import * as propHelpers from '../../client/helpers/props'; -import {BadRequestError, NotFoundError} from '../../common/helpers/error'; +import {AuthenticationFailedError, BadRequestError} from '../../common/helpers/error'; import {escapeProps, generateProps} from '../helpers/props'; import ExternalServices from '../../client/components/pages/externalService'; import Layout from '../../client/containers/layout'; @@ -117,7 +117,7 @@ router.post('/critiquebrainz/refresh', auth.isAuthenticated, async (req, res, ne ); if (!token) { - return next(new NotFoundError('User has not authenticated to CritiqueBrainz')); + return next(new AuthenticationFailedError('User has not authenticated to CritiqueBrainz')); } const tokenExpired = new Date(token.token_expires).getTime() <= new Date(new Date().getTime() + marginTime).getTime(); if (tokenExpired) { From c123dcb86f8dd075ddc83d683a7731527aaab724 Mon Sep 17 00:00:00 2001 From: Ansh Goyal Date: Thu, 28 Jul 2022 10:44:35 +0000 Subject: [PATCH 12/39] feat: Add CB Review Modal --- .../components/pages/entities/cb-review.js | 6 +- .../pages/entities/cbReviewModal.tsx | 549 ++++++++++++++++++ .../pages/entities/edition-group.js | 28 +- src/client/components/pages/entities/title.js | 21 +- 4 files changed, 595 insertions(+), 9 deletions(-) create mode 100644 src/client/components/pages/entities/cbReviewModal.tsx diff --git a/src/client/components/pages/entities/cb-review.js b/src/client/components/pages/entities/cb-review.js index 70dbe85cd..0b50f6028 100644 --- a/src/client/components/pages/entities/cb-review.js +++ b/src/client/components/pages/entities/cb-review.js @@ -78,6 +78,7 @@ class EntityReviews extends React.Component { }; this.entityType = props.entityType; this.entityBBID = props.entityBBID; + this.handleModalToggle = props.handleModalToggle; } async handleClick() { @@ -118,8 +119,8 @@ class EntityReviews extends React.Component {

No reviews yet.