diff --git a/README.md b/README.md index d2c899dd..b87335df 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,13 @@ $ node build/scripts/blockUser.js --userId= --blockedReason= --url= +``` + ### Generating a spreadsheet of new article-categories for human review - To retrieve a spreadsheet of article categories of interest after a specific timestamp, run: ``` diff --git a/src/graphql/__fixtures__/media-integration/replaced.jpg b/src/graphql/__fixtures__/media-integration/replaced.jpg new file mode 100644 index 00000000..fb38835e Binary files /dev/null and b/src/graphql/__fixtures__/media-integration/replaced.jpg differ diff --git a/src/graphql/__tests__/media-integration.js b/src/graphql/__tests__/media-integration.js index 44de2c59..25b9f122 100644 --- a/src/graphql/__tests__/media-integration.js +++ b/src/graphql/__tests__/media-integration.js @@ -8,6 +8,7 @@ import gql from 'util/GraphQL'; import client from 'util/client'; import delayForMs from 'util/delayForMs'; import { getReplyRequestId } from 'graphql/mutations/CreateOrUpdateReplyRequest'; +import replaceMedia from 'scripts/replaceMedia'; if (process.env.GCS_CREDENTIALS && process.env.GCS_BUCKET_NAME) { // File server serving test input file in __fixtures__/media-integration @@ -163,4 +164,92 @@ if (process.env.GCS_CREDENTIALS && process.env.GCS_BUCKET_NAME) { }, 15000 ); + + it( + 'can replace media for article', + async () => { + // Simulates user login + const context = { + user: { id: 'foo', appId: 'WEBSITE' }, + }; + + const createMediaArticleResult = await gql` + mutation($mediaUrl: String!) { + CreateMediaArticle( + mediaUrl: $mediaUrl + articleType: IMAGE + reference: { type: LINE } + ) { + id + } + } + `( + { + mediaUrl: `${serverUrl}/small.jpg`, + }, + context + ); + + const articleId = createMediaArticleResult.data.CreateMediaArticle.id; + + const articleBeforeReplace = await gql` + query($articleId: String!) { + GetArticle(id: $articleId) { + thumbnailUrl: attachmentUrl(variant: THUMBNAIL) + } + } + `({ articleId }, context); + + // Wait until thumbnail is fully uploaded + { + let resp; + while ( + !resp || + resp.headers.get('Content-Type').startsWith('application/xml') // GCS returns XML for error + ) { + resp = await fetch(articleBeforeReplace.data.GetArticle.thumbnailUrl); + await delayForMs(1000); // Wait for upload to finish + } + } + + await replaceMedia({ articleId, url: `${serverUrl}/replaced.jpg` }); + + const resp = await fetch( + articleBeforeReplace.data.GetArticle.thumbnailUrl + ); + expect(resp.status).toBe(404); + + const articleAfterReplace = await gql` + query($articleId: String!) { + GetArticle(id: $articleId) { + attachmentHash + } + } + `({ articleId }, context); + + expect( + articleAfterReplace.data.GetArticle.attachmentHash + ).toMatchInlineSnapshot( + `"image.vDjh4g.__-AD6SDgAeTEcED___AA_Ej8y_Dg8EDgAOAA4P_8_8"` + ); + + // Cleanup + await client.delete({ + index: 'articles', + type: 'doc', + id: articleId, + }); + + await client.delete({ + index: 'replyrequests', + type: 'doc', + id: getReplyRequestId({ + articleId, + userId: context.user.id, + appId: context.user.appId, + }), + }); + }, + 15000 + ); } diff --git a/src/graphql/mutations/CreateMediaArticle.js b/src/graphql/mutations/CreateMediaArticle.js index 07f23c55..cf47778d 100644 --- a/src/graphql/mutations/CreateMediaArticle.js +++ b/src/graphql/mutations/CreateMediaArticle.js @@ -24,24 +24,14 @@ const VALID_ARTICLE_TYPE_TO_MEDIA_TYPE = { }; /** - * Creates a new article in ElasticSearch, - * or return an article which attachment.hash is similar to mediaUrl + * Upload media of specified article type from the given mediaUrl * * @param {object} param * @param {string} param.mediaUrl * @param {ArticleTypeEnum} param.articleType - * @param {ArticleReferenceInput} param.reference - * @param {string} param.userId - * @param {string} param.appId - * @returns {Promise} the new article's ID + * @returns {Promise} */ -async function createNewMediaArticle({ - mediaUrl, - articleType, - reference: originalReference, - userId, - appId, -}) { +export async function uploadMedia({ mediaUrl, articleType }) { const mappedMediaType = VALID_ARTICLE_TYPE_TO_MEDIA_TYPE[articleType]; const mediaEntry = await mediaManager.insert({ url: mediaUrl, @@ -93,6 +83,28 @@ async function createNewMediaArticle({ }, }); + return mediaEntry; +} + +/** + * Creates a new article in ElasticSearch, + * or return an article which attachment.hash is similar to mediaUrl + * + * @param {object} param + * @param {MediaEntry} param.mediaEntry + * @param {ArticleTypeEnum} param.articleType + * @param {ArticleReferenceInput} param.reference + * @param {string} param.userId + * @param {string} param.appId + * @returns {Promise} the new article's ID + */ +async function createNewMediaArticle({ + mediaEntry, + articleType, + reference: originalReference, + userId, + appId, +}) { const attachmentHash = mediaEntry.id; const text = ''; const now = new Date().toISOString(); @@ -168,9 +180,15 @@ export default { { user } ) { assertUser(user); - const articleId = await createNewMediaArticle({ + + const mediaEntry = await uploadMedia({ mediaUrl, articleType, + }); + + const articleId = await createNewMediaArticle({ + mediaEntry, + articleType, reference, userId: user.id, appId: user.appId, diff --git a/src/scripts/blockUser.js b/src/scripts/blockUser.js index 059653a8..0b2d981e 100644 --- a/src/scripts/blockUser.js +++ b/src/scripts/blockUser.js @@ -285,6 +285,7 @@ async function main({ userId, blockedReason } = {}) { export default main; +/* istanbul ignore if */ if (require.main === module) { const argv = yargs .options({ diff --git a/src/scripts/replaceMedia.js b/src/scripts/replaceMedia.js new file mode 100644 index 00000000..1851ec31 --- /dev/null +++ b/src/scripts/replaceMedia.js @@ -0,0 +1,87 @@ +/** + * Given articleId and URL, replace the media with the content the URL points to. + */ + +import 'dotenv/config'; +import yargs from 'yargs'; +import client from 'util/client'; +import mediaManager from 'util/mediaManager'; +import { uploadMedia } from 'graphql/mutations/CreateMediaArticle'; + +/** + * @param {object} args + */ +async function replaceMedia({ articleId, url } = {}) { + const { + body: { _source: article }, + } = await client.get({ index: 'articles', type: 'doc', id: articleId }); + + /* istanbul ignore if */ + if (!article) throw new Error(`Article ${articleId} is not found`); + + const oldMediaEntry = await mediaManager.get(article.attachmentHash); + + /* istanbul ignore if */ + if (!oldMediaEntry) + throw new Error( + `Article ${articleId}'s attachment hash "${ + article.attachmentHash + }" has no corresponding media entry` + ); + + const newMediaEntry = await uploadMedia({ + mediaUrl: url, + articleType: article.articleType, + }); + + console.info( + `Article ${articleId} attachment hash: ${oldMediaEntry.id} --> ${ + newMediaEntry.id + }` + ); + await client.update({ + index: 'articles', + type: 'doc', + id: articleId, + body: { + doc: { + attachmentHash: newMediaEntry.id, + }, + }, + }); + + return Promise.all( + oldMediaEntry.variants.map(variant => + oldMediaEntry + .getFile(variant) + .delete() + .then(() => { + console.info(`Old media entry variant=${variant} deleted`); + }) + ) + ); +} + +export default replaceMedia; + +/* istanbul ignore if */ +if (require.main === module) { + const argv = yargs + .options({ + articleId: { + alias: 'a', + description: 'The article ID', + type: 'string', + demandOption: true, + }, + url: { + alias: 'u', + description: 'The URL to the content to replace', + type: 'string', + demandOption: true, + }, + }) + .help('help').argv; + + replaceMedia(argv).catch(console.error); +}