├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── .stylelintrc ├── package.json ├── LICENSE.md ├── lib └── findJoins.js ├── README.md └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | 7 | # Never commit a CSS map file, anywhere 8 | *.css.map 9 | 10 | # vim swp files 11 | .*.sw* 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0-alpha.3 4 | 5 | * Removes `apostrophe` as a peer dependency. 6 | 7 | ## 1.0.0-alpha.2 8 | 9 | * Bug fix for the case where a single locale is synced with `--workflow-locale` to ensure committing is possible afterwards, and a check for a common user error at startup. 10 | 11 | ## 1.0.0-alpha 12 | 13 | * Initial release. 14 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-apostrophe", 3 | "rules": { 4 | "scale-unlimited/declaration-strict-value": null, 5 | "scss/at-import-partial-extension": null, 6 | "scss/at-mixin-named-arguments": null, 7 | "scss/dollar-variable-first-in-block": null, 8 | "scss/dollar-variable-pattern": null, 9 | "scss/selector-nest-combinators": null, 10 | "scss/no-duplicate-mixins": null, 11 | "property-no-vendor-prefix": [ 12 | true, 13 | { 14 | "ignoreProperties": ["appearance"] 15 | } 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/sync-content", 3 | "version": "1.0.0-alpha.3", 4 | "description": "Content sync utility for ApostropheCMS sites", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/apostrophecms/sync-content.git" 12 | }, 13 | "keywords": [ 14 | "backup", 15 | "apostrophecms" 16 | ], 17 | "author": "Apostrophe Technologies", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/apostrophecms/sync-content/issues" 21 | }, 22 | "homepage": "https://github.com/apostrophecms/sync-content#readme", 23 | "dependencies": { 24 | "bson": "^4.6.1", 25 | "compression": "^1.7.4", 26 | "lodash": "^4.17.21", 27 | "node-fetch": "^2.6.7", 28 | "qs": "^6.10.3", 29 | "stream-json": "^1.7.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /lib/findJoins.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | // Code borrowed verbatim from the workflow module 4 | 5 | module.exports = (self, options) => { 6 | // Given a doc, find all joins related to that doc: those in its own schema, 7 | // or in the schemas of its own widgets. These are returned as an array of 8 | // objects with `doc` and `field` properties, where `doc` may be the doc 9 | // itself or a widget within it, and `field` is the schema field definition 10 | // of the join. Only forward joins are returned. 11 | 12 | self.findJoinsInDoc = function(doc) { 13 | return self.findJoinsInDocSchema(doc).concat(self.findJoinsInAreas(doc)); 14 | }; 15 | 16 | // Given a doc, invoke `findJoinsInSchema` with that doc and its schema according to 17 | // its doc type manager, and return the result. 18 | 19 | self.findJoinsInDocSchema = function(doc) { 20 | if (!doc.type) { 21 | // Cannot determine schema, so we cannot fetch joins; 22 | // don't crash, so we behave reasonably if very light 23 | // projections are present 24 | return []; 25 | } 26 | var schema = self.apos.docs.getManager(doc.type).schema; 27 | return self.findJoinsInSchema(doc, schema); 28 | }; 29 | 30 | // Given a doc, find joins in the schemas of widgets contained in the 31 | // areas of that doc and return an array in which each element is an object with 32 | // `doc` and `field` properties. `doc` is a reference to the individual widget 33 | // in question, and `field` is the join field definition for that widget. 34 | // Only forward joins are returned. 35 | 36 | self.findJoinsInAreas = function(doc) { 37 | var widgets = []; 38 | self.apos.areas.walk(doc, function(area, dotPath) { 39 | widgets = widgets.concat(area.items); 40 | }); 41 | var joins = []; 42 | _.each(widgets, function(widget) { 43 | if (!widget.type) { 44 | // Don't crash on bad data or strange projections etc. 45 | return; 46 | } 47 | var manager = self.apos.areas.getWidgetManager(widget.type); 48 | if (!manager) { 49 | // We already warn about obsolete widgets elsewhere, don't crash 50 | return; 51 | } 52 | var schema = manager.schema; 53 | joins = joins.concat(self.findJoinsInSchema(widget, schema)); 54 | }); 55 | return joins; 56 | }; 57 | 58 | // Given a doc (or widget) and a schema, find joins described by that schema and 59 | // return an array in which each element is an object with 60 | // `doc`, `field` and `value` properties. `doc` is a reference to the doc 61 | // passed to this method, `field` is a field definition, and `value` is the 62 | // value of the join if available (the doc was loaded with joins). 63 | // 64 | // Only forward joins are returned. 65 | 66 | self.findJoinsInSchema = function(doc, schema) { 67 | var fromArrays = []; 68 | return _.map( 69 | _.filter( 70 | schema, function(field) { 71 | if ((field.type === 'joinByOne') || (field.type === 'joinByArray')) { 72 | return true; 73 | } 74 | if (field.type === 'array') { 75 | _.each(doc[field.name] || [], function(doc) { 76 | fromArrays = fromArrays.concat(self.findJoinsInSchema(doc, field.schema)); 77 | }); 78 | } 79 | if (field.type === 'object' && typeof doc[field.name] === 'object') { 80 | fromArrays = fromArrays.concat(self.findJoinsInSchema(doc[field.name], field.schema)); 81 | } 82 | } 83 | ), function(field) { 84 | return { doc: doc, field: field, value: doc[field.name] }; 85 | } 86 | ).concat(fromArrays); 87 | }; 88 | }; 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | ApostropheCMS logo 5 | 6 | 7 |

Sync Content for ApostropheCMS

8 |

9 | 10 | The Sync Content module allows syncing ApostropheCMS site content between different server environments, without the need for direct access to remote databases, directories, S3 buckets, etc. 11 | 12 | **Status:** ⚠️ In use, but still an alpha release. Not all planned features are implemented, but those discussed here are available. 13 | 14 | ## Purpose 15 | 16 | This module is useful when migrating content between development, staging and production environments without direct access to the underlying database and media storage. It is also useful when migrating between projects, however bear in mind that the new project must have the same doc and widget types available with the same fields in order to function properly. 17 | 18 | ## Installation 19 | 20 | To install the module, use the command line to run this command in an Apostrophe project's root directory: 21 | 22 | ``` 23 | npm install @apostrophecms/sync-content 24 | ``` 25 | 26 | ## ⚠️ Warnings 27 | 28 | This tool makes big changes to your database. There are no confirmation prompts in the current command line interface. Syncing "from" staging or production to your local development environment is generally safe, but take care to think about what you are doing. 29 | 30 | ## Usage 31 | 32 | Configure the `@apostrophecms/sync-content` module in the `app.js` file: 33 | 34 | ```javascript 35 | require('apostrophe')({ 36 | shortName: 'my-project', 37 | modules: { 38 | '@apostrophecms/sync-content': { 39 | // Our API key, for incoming sync requests 40 | apiKey: 'choose-a-very-secure-random-key', 41 | environments: { 42 | staging: { 43 | label: 'Staging', 44 | url: 'https://mysite.staging.mycompany.com', 45 | // Their API key, for outgoing sync requests 46 | apiKey: 'choose-a-very-secure-random-key' 47 | } 48 | } 49 | } 50 | } 51 | }); 52 | ``` 53 | 54 | ### Syncing via command line tasks 55 | 56 | #### Syncing from another site 57 | 58 | ```bash 59 | # sync all content from another environment 60 | node app @apostrophecms/sync-content:sync --from=staging 61 | 62 | # Pass a site's base URL and api key directly, bypassing `environments` 63 | node app @apostrophecms/sync-content:sync --from=https://site.com --api-key=xyz 64 | 65 | # sync content of one piece type only, plus any related 66 | # documents. If other content already exists locally, purge it 67 | node app @apostrophecms/sync-content:sync --from=staging --type=article 68 | 69 | # Same, but keep existing content of this type too 70 | node app @apostrophecms/sync-content:sync --from=staging --type=article --keep 71 | 72 | # sync content of one piece type only, without related documents 73 | node app @apostrophecms/sync-content:sync --from=staging --type=article --related=false 74 | 75 | # sync content of one piece type only, matching a query 76 | node app @apostrophecms/sync-content:sync --from=staging --type=article --query=tags[]=blue 77 | ``` 78 | 79 | * You must specify `--from` to specify the environment to sync with, as seen in your configuration above, where `staging` is an example. You can also specify the base URL of the other environment directly for `--from`, in which case you must pass `--api-key` as well. At a later date support for `--to` may also be added. 80 | * You may specify `--type=typename` to specify one content type only. This must be a piece type, and must match the `name` option of the type (**not** the module name, unless they are the same). 81 | * When using `--type`, you may also specify `--keep` to keep preexisting pieces whose `_id` does not appear in the synced content. **For data integrity reasons, this is not available when syncing an entire site.** 82 | * By default, syncing a piece type will also sync directly related documents, such as images and other pieces referenced by joins in the document's own schema or those of its own array fields, object fields, and widgets. If you do not want this, specify `--related=false`. 83 | * The `--query` option is best used by observing the query string while on a pieces page with various filters applied. Any valid Apostrophe cursor filter may be used. 84 | 85 | Note that the `--keep`, `--related` and `--query` options are only valid with `--type`. They may be combined with each other. 86 | 87 | ### Syncing via the UI 88 | 89 | Currently no UI is available, however at least some UI functionality is planned. 90 | 91 | ### Security restrictions 92 | 93 | For security reasons, and to avoid chicken and egg problems when using the UI, users and groups are **not** synced. You will have the same users and groups as before the sync operation. 94 | 95 | ### Additional notes 96 | 97 | Syncing a site takes time, especially if the site has media. Get a cup of coffee. 98 | 99 | It is not uncommon to see quite a few warnings about missing attachments at the end, particularly if another image size was added to the project without running the `apostrophe-attachments:rescale` task. 100 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { EJSON } = require('bson'); 3 | const util = require('util'); 4 | const unlink = util.promisify(fs.unlink); 5 | const fetch = require('node-fetch'); 6 | const compression = require('compression'); 7 | const pipeline = require('util').promisify(require('stream').pipeline); 8 | const Stream = require('stream'); 9 | const { parser } = require('stream-json'); 10 | const { streamValues } = require('stream-json/streamers/StreamValues'); 11 | const qs = require('qs'); 12 | 13 | module.exports = { 14 | construct(self, options) { 15 | require('./lib/findJoins')(self, options); 16 | self.neverCollections = [ 'aposUsersSafe', 'sessions', 'aposCache', 'aposLocks', 'aposNotifications', 'aposBlessings', 'aposDocVersions' ]; 17 | self.neverTypes = [ 'apostrophe-user', 'apostrophe-group' ]; 18 | self.apos.on('csrfExceptions', function(list) { 19 | // For syncTo and the POST routes 20 | list.push(`${self.action}/content`); 21 | list.push(`${self.action}/uploadfs`); 22 | }); 23 | self.addTask('sync', 'Syncs content to or from another server environment', async (apos, argv) => { 24 | const peer = argv.from || argv.to; 25 | if (!peer) { 26 | throw 'You must specify either --from or --to.'; 27 | } 28 | if (argv.from && argv.to) { 29 | throw 'You must not specify both --from and --to (one end is always local).'; 30 | } 31 | if (argv.type) { 32 | argv.related = self.apos.launder.boolean(argv.related, true); 33 | argv.keep = self.apos.launder.boolean(argv.keep); 34 | argv.query = qs.parse(self.apos.launder.string(argv.query)); 35 | } else { 36 | if (argv.related) { 37 | throw '--related not available without --type'; 38 | } 39 | if (argv.keep) { 40 | throw '--keep not available without --type'; 41 | } 42 | if (argv.query) { 43 | throw '--query not available without --type'; 44 | } 45 | } 46 | if (argv.from) { 47 | const env = self.getEnv(argv.from, argv); 48 | await self.syncFrom(env, argv); 49 | } else if (argv.to) { 50 | throw '--to is not yet implemented'; 51 | } else { 52 | throw '--from is required'; 53 | } 54 | }); 55 | self.route('get', 'content', compression({ 56 | filter: (req) => true 57 | }), async (req, res) => { 58 | try { 59 | if (!self.options.apiKey) { 60 | throw 'API key not configured'; 61 | } 62 | const apiKey = getAuthorizationApiKey(req); 63 | if (apiKey !== self.options.apiKey) { 64 | throw 'Invalid API key'; 65 | } 66 | const type = self.apos.launder.string(req.query.type); 67 | const related = self.apos.launder.boolean(req.query.related); 68 | // Naming it req.query.workflowLocale causes it to be stolen by 69 | // the workflow module, so we don't do that 70 | const workflowLocale = self.apos.launder.string(req.query.locale); 71 | const query = (typeof req.query.query === 'object') ? req.query.query : null; 72 | if (!type) { 73 | if (related) { 74 | throw 'related is only permitted with type'; 75 | } 76 | if (query) { 77 | throw 'query is only permitted with type'; 78 | } 79 | } 80 | // Ask nginx not to buffer this large response, better that it 81 | // flow continuously to the other end 82 | res.setHeader('X-Accel-Buffering', 'no'); 83 | res.write(JSON.stringify({ 84 | '@apostrophecms/sync-content': true, 85 | version: 1 86 | })); 87 | const never = self.neverCollections; 88 | const docs = self.getCollection('aposDocs'); 89 | const collections = type ? [ docs ] : (await self.apos.db.collections()).filter(collection => { 90 | if (collection.collectionName.match(/^system\./)) { 91 | return false; 92 | } 93 | if (never.includes(collection.collectionName)) { 94 | return false; 95 | } 96 | return true; 97 | }); 98 | let attachmentIds = []; 99 | for (const collection of collections) { 100 | await handleCollection(collection); 101 | } 102 | if (attachmentIds.length) { 103 | await handleCollection(self.getCollection('aposAttachments'), attachmentIds); 104 | } 105 | async function handleCollection(collection, ids) { 106 | const seen = new Set(); 107 | if (query && (collection.collectionName === 'aposDocs') && !ids) { 108 | const manager = self.apos.docs.getManager(type); 109 | const reqParams = {}; 110 | if (workflowLocale) { 111 | reqParams.locale = workflowLocale; 112 | } 113 | const criteria = {}; 114 | ids = (await manager.find(self.apos.tasks.getReq(reqParams), {}, { _id: 1 }) 115 | .queryToFilters(query, 'manage') 116 | .toArray()) 117 | .map(doc => doc._id); 118 | } 119 | const criteria = ids ? { 120 | _id: { 121 | $in: ids 122 | } 123 | } : ((collection.collectionName === 'aposDocs') && type) ? { 124 | type 125 | } : (collection.collectionName === 'aposDocs') ? { 126 | type: { 127 | $nin: self.neverTypes 128 | } 129 | } : {}; 130 | if ((collection.collectionName === 'aposDocs') && workflowLocale) { 131 | criteria.workflowLocale = { 132 | $in: [ null, workflowLocale ] 133 | }; 134 | } 135 | await self.apos.migrations.each(collection, criteria, async (doc) => { 136 | // "sent" keeps track of whether we have started to buffer 137 | // output in RAM, if we have then after this batch we'll 138 | // wait for the output to drain 139 | await write(doc); 140 | if (type && (collection.collectionName === 'aposDocs')) { 141 | attachmentIds = attachmentIds.concat(self.apos.attachments.all(doc).map(attachment => attachment._id)); 142 | } 143 | if (related && (collection.collectionName === 'aposDocs')) { 144 | const joins = self.findJoinsInDoc(doc); 145 | let ids = []; 146 | for (const join of joins) { 147 | const id = join.field.idField && join.doc[join.field.idField]; 148 | if (id) { 149 | ids.push(id); 150 | } 151 | const joinIds = join.field.idsField && join.doc[join.field.idsField]; 152 | if (joinIds) { 153 | ids = ids.concat(joinIds); 154 | } 155 | } 156 | const relatedDocs = await collection.find({ 157 | _id: { 158 | $in: ids 159 | }, 160 | type: { 161 | $nin: self.neverTypes 162 | }, 163 | // Pages are never considered related for this purpose 164 | // because it leads to data integrity issues 165 | slug: /^[^\/]/ 166 | }).toArray(); 167 | for (const relatedDoc of relatedDocs) { 168 | if (type) { 169 | attachmentIds = attachmentIds.concat(self.apos.attachments.all(relatedDoc).map(attachment => attachment._id)); 170 | } 171 | await write(relatedDoc); 172 | } 173 | } 174 | }); 175 | async function write(doc) { 176 | if (seen.has(doc._id)) { 177 | // Don't send a related doc twice 178 | return; 179 | } 180 | seen.add(doc._id); 181 | return new Promise((resolve, reject) => { 182 | try { 183 | const result = res.write(EJSON.stringify({ 184 | collection: collection.collectionName, 185 | doc 186 | })); 187 | if (!result) { 188 | // Node streams backpressure is fussy, you don't get a 189 | // drain event for the second of two consecutive false returns, 190 | // we must wait every time we do get one 191 | res.once('drain', () => { 192 | return resolve(); 193 | }); 194 | } else { 195 | return resolve(); 196 | } 197 | } catch (e) { 198 | return reject(e); 199 | } 200 | }); 201 | } 202 | } 203 | res.write(JSON.stringify({ 204 | 'end': true 205 | })); 206 | res.end(); 207 | } catch (e) { 208 | self.apos.utils.error(e); 209 | return res.status(400).send('invalid'); 210 | } 211 | }); 212 | 213 | self.route('get', 'uploadfs', async (req, res) => { 214 | const disable = util.promisify(self.apos.attachments.uploadfs.disable); 215 | const enable = util.promisify(self.apos.attachments.uploadfs.enable); 216 | const copyOut = util.promisify(self.apos.attachments.uploadfs.copyOut); 217 | try { 218 | self.checkAuthorizationApiKey(req); 219 | const path = self.apos.launder.string(req.query.path); 220 | const disabled = self.apos.launder.boolean(req.query.disabled); 221 | if (!path.length) { 222 | throw self.apos.utils.error('invalid'); 223 | } 224 | if (!disabled) { 225 | let url = self.apos.attachments.uploadfs.getUrl() + path; 226 | if (url.startsWith('/') && !url.startsWith('//') && req.baseUrl) { 227 | url = req.baseUrl + url; 228 | } 229 | return res.redirect(url); 230 | } else { 231 | const tempPath = self.getTempPath(path); 232 | // This workaround is not ideal, in future uploadfs will provide 233 | // better guarantees that copyOut works when direct URL access does not 234 | await enable(path); 235 | await copyOut(path, tempPath); 236 | await disable(path); 237 | res.on('finish', async () => { 238 | await unlink(tempPath); 239 | }); 240 | return res.download(tempPath); 241 | } 242 | } catch (e) { 243 | self.apos.utils.error(e); 244 | return res.status(400).send('invalid'); 245 | } 246 | }); 247 | 248 | self.syncFrom = async (env, options) => { 249 | if (env.url.endsWith('/')) { 250 | throw 'URL for environment must not end with /, should be a base URL for the site'; 251 | } 252 | const updatePermissions = util.promisify(self.apos.attachments.updatePermissions); 253 | let ended = false; 254 | if (!options.type) { 255 | if (options.keep) { 256 | throw 'keep option not available without type option'; 257 | } 258 | if (options.related) { 259 | throw 'related option not available without type option'; 260 | } 261 | } 262 | const query = qs.stringify({ 263 | type: options.type, 264 | keep: !!options.keep, 265 | related: !!options.related, 266 | // We have to rename this one in the query string to work around 267 | // the fact that the workflow module steals it otherwise 268 | locale: options.workflowLocale, 269 | query: options.query 270 | }); 271 | const response = await fetch(`${env.url}/modules/@apostrophecms/sync-content/content?${query}`, { 272 | headers: { 273 | 'Authorization': `ApiKey ${env.apiKey}` 274 | } 275 | }); 276 | if (response.status >= 400) { 277 | throw await response.text(); 278 | } 279 | let version = null; 280 | const collections = {}; 281 | let docIds = []; 282 | 283 | // This solution with a custom writable stream appears to handle backpressure properly, 284 | // several others appeared prettier but did not do that, so they would exhaust RAM 285 | // on a large site 286 | 287 | const sink = new Stream.Writable({ 288 | objectMode: true 289 | }); 290 | let attachmentIds = []; 291 | sink._write = async (value, encoding, callback) => { 292 | try { 293 | await handleObject(value); 294 | return callback(null); 295 | } catch (e) { 296 | return callback(e); 297 | } 298 | }; 299 | 300 | await pipeline( 301 | response.body, 302 | parser({ 303 | jsonStreaming: true 304 | }), 305 | streamValues(), 306 | sink 307 | ); 308 | 309 | if (!ended) { 310 | throw 'Incomplete stream'; 311 | } 312 | 313 | await self.syncUploadfsFrom(env, { attachmentIds }); 314 | // Fix attachment permissions once all the facts are in 315 | await updatePermissions(); 316 | 317 | async function handleObject(value) { 318 | value = value.value; 319 | if (!version) { 320 | if (!(value && (value['@apostrophecms/sync-content'] === true))) { 321 | throw 'This response does not contain an @apostrophecms/sync-content stream'; 322 | } 323 | if (value.version !== 1) { 324 | throw `This site does not support stream version ${value.version}`; 325 | } 326 | version = value.version; 327 | const never = self.neverCollections; 328 | // Purge 329 | if (!options.type) { 330 | const collections = (await self.apos.db.collections()).filter(collection => !collection.collectionName.match(/^system\./) && !never.includes(collection.collectionName)); 331 | for (const collection of collections) { 332 | const criteria = (collection.collectionName === 'aposDocs') ? { 333 | type: { 334 | $nin: self.neverTypes 335 | } 336 | } : {}; 337 | await collection.removeMany(criteria); 338 | } 339 | } else if (!options.keep) { 340 | // TODO attachments need to update their references when this happens 341 | await self.apos.docs.db.removeMany({ 342 | type: options.type 343 | }); 344 | } 345 | } else if (value.collection) { 346 | if (!collections[value.collection]) { 347 | collections[value.collection] = self.getCollection(value.collection); 348 | } 349 | const collection = collections[value.collection]; 350 | try { 351 | value.doc = EJSON.parse(JSON.stringify(value.doc)); 352 | if (value.collection === 'aposDocs') { 353 | docIds.push(value.doc._id); 354 | } 355 | if ((value.collection === 'aposAttachments') && options.type) { 356 | await self.mergeAttachment(value.doc, docIds); 357 | attachmentIds.push(value.doc._id); 358 | } else { 359 | for (let attempt = 0; (attempt < 10); attempt++) { 360 | try { 361 | if (options.keep) { 362 | await collection.replaceOne( 363 | { 364 | _id: value.doc._id 365 | }, 366 | value.doc, 367 | { 368 | upsert: true 369 | } 370 | ); 371 | } else { 372 | await collection.insertOne(value.doc); 373 | } 374 | // This is A2, so when only one locale is synced 375 | // we must always match draft with live or vice versa 376 | if (query.locale && value.doc.workflowGuid) { 377 | const isDraft = query.locale.includes('-draft'); 378 | const peerLocale = isDraft ? query.locale.replace('-draft', '') : (query.locale + '-draft'); 379 | const existing = await collections.findOne({ 380 | workflowGuid: value.doc.workflowGuid, 381 | workflowLocale: peerLocale 382 | }); 383 | if (existing) { 384 | await collections.replaceOne({ 385 | workflowGuid: value.doc.workflowGuid, 386 | workflowLocale: peerLocale 387 | }, { 388 | ...value.doc, 389 | _id: existing._id, 390 | workflowLocale: value.doc.workflowLocale 391 | }); 392 | } else { 393 | await collections.insertOne({ 394 | ...value.doc, 395 | _id: self.apos.utils.generateId(), 396 | workflowLocale: value.doc.workflowLocale 397 | }); 398 | } 399 | } 400 | } catch (e) { 401 | if ((collection.collectionName === 'aposDocs') && self.apos.docs.isUniqueError(e)) { 402 | value.doc.slug += Math.floor(Math.random() * 10); 403 | continue; 404 | } else { 405 | throw e; 406 | } 407 | } 408 | break; 409 | } 410 | } 411 | } catch (e) { 412 | console.error(JSON.stringify(value, null, ' ')); 413 | throw e; 414 | } 415 | } else if (value.end) { 416 | ended = true; 417 | } else { 418 | console.error(value); 419 | throw 'Unexpected object in JSON stream'; 420 | } 421 | return true; 422 | } 423 | }; 424 | 425 | self.mergeAttachment = async (attachment, docIds) => { 426 | // Merge what the sending and receiving sites know about docIds and trashDocIds to 427 | // ensure updatePermissions does the right thing for this attachment 428 | const existing = await self.apos.attachments.db.findOne({ 429 | _id: attachment._id 430 | }); 431 | const newDocIds = attachment.docIds.filter(id => docIds.includes(id)); 432 | const newTrashDocIds = attachment.trashDocIds.filter(id => docIds.includes(id)); 433 | if (!existing) { 434 | attachment.docIds = newDocIds; 435 | attachment.trashDocIds = newTrashDocIds; 436 | return self.apos.attachments.db.insertOne(attachment); 437 | } 438 | const oldDocIds = existing.docIds.filter(id => !docIds.includes(id)); 439 | const oldTrashDocIds = attachment.trashDocIds.filter(id => !docIds.includes(id)); 440 | attachment.docIds = newDocIds.concat(oldDocIds); 441 | attachment.trashDocIds = newTrashDocIds.concat(oldTrashDocIds); 442 | await self.apos.attachments.db.replaceOne({ 443 | _id: attachment._id 444 | }, attachment); 445 | }; 446 | 447 | self.syncUploadfsFrom = async (env, { attachmentIds }) => { 448 | 449 | const disable = util.promisify(self.apos.attachments.uploadfs.disable); 450 | const remove = util.promisify(self.apos.attachments.uploadfs.remove); 451 | const copyIn = util.promisify(self.apos.attachments.uploadfs.copyIn); 452 | const criteria = (options.type && attachmentIds) ? { 453 | _id: { 454 | $in: attachmentIds 455 | } 456 | } : {}; 457 | await self.apos.migrations.each(self.apos.attachments.db, criteria, 5, async (attachment) => { 458 | const files = []; 459 | push(attachment, 'original', null); 460 | for (const size of self.apos.attachments.imageSizes) { 461 | push(attachment, size.name, null); 462 | } 463 | for (const crop of (attachment.crops || [])) { 464 | push(attachment, 'original', crop); 465 | for (const size of self.apos.attachments.imageSizes) { 466 | push(attachment, size.name, crop); 467 | } 468 | } 469 | for (const file of files) { 470 | const tempPath = self.getTempPath(file.path); 471 | await attempt(false); 472 | async function attempt(retryingDisabled) { 473 | try { 474 | const params = qs.stringify({ 475 | ...file, 476 | disabled: retryingDisabled ? !file.disabled : file.disabled 477 | }); 478 | const response = await fetch(`${env.url}/modules/@apostrophecms/sync-content/uploadfs?${params}`, { 479 | headers: { 480 | 'Authorization': `ApiKey ${env.apiKey}` 481 | } 482 | }); 483 | if (response.status !== 200) { 484 | throw response.status; 485 | } 486 | await pipeline( 487 | response.body, 488 | fs.createWriteStream(tempPath) 489 | ); 490 | try { 491 | await remove(file.path); 492 | } catch (e) { 493 | // Nonfatal, we are just making sure we don't get into conflict 494 | // with previous permissions settings if the receiving site 495 | // did have this file 496 | } 497 | await copyIn(tempPath, file.path); 498 | if (file.disabled) { 499 | await disable(file.path); 500 | } 501 | } catch (e) { 502 | if (!retryingDisabled) { 503 | // Work around the fact that the disabled state of the file 504 | // sometimes does not match what is expected on the sending end, 505 | // possibly due to an A2 bug. This way the receiving end gets 506 | // to the right outcome either way 507 | return await attempt(true); 508 | } 509 | // Missing attachments are not unusual and should not flunk the entire process 510 | self.apos.utils.error(`Error fetching uploadfs path ${file.path}, continuing:`); 511 | self.apos.utils.error(e); 512 | } finally { 513 | if (fs.existsSync(tempPath)) { 514 | await unlink(tempPath); 515 | } 516 | } 517 | } 518 | } 519 | function push(attachment, size, crop) { 520 | files.push({ 521 | path: self.apos.attachments.url(attachment, { 522 | size, 523 | uploadfsPath: true, 524 | crop 525 | }), 526 | disabled: attachment.trash && (size !== self.apos.attachments.sizeAvailableInTrash) 527 | }); 528 | } 529 | }); 530 | }; 531 | 532 | self.getTempPath = (file) => { 533 | return self.apos.attachments.uploadfs.getTempPath() + '/' + self.apos.utils.generateId() + require('path').extname(file); 534 | }; 535 | 536 | self.getEnv = (envName, argv) => { 537 | if (envName.match(/^https?:/)) { 538 | if (!argv['api-key']) { 539 | throw '--api-key is required if --from specifies a URL'; 540 | } 541 | return { 542 | label: envName, 543 | url: envName, 544 | apiKey: argv['api-key'] 545 | }; 546 | } 547 | 548 | const env = self.options.environments && self.options.environments[envName]; 549 | if (!env) { 550 | throw new Error(`${envName} does not appear as a subproperty of the environments option`); 551 | } 552 | return env; 553 | }; 554 | self.checkAuthorizationApiKey = (req) => { 555 | if (!self.options.apiKey) { 556 | throw 'API key not configured'; 557 | } 558 | const apiKey = getAuthorizationApiKey(req); 559 | if (apiKey !== self.options.apiKey) { 560 | throw 'Invalid API key'; 561 | } 562 | }; 563 | self.getCollection = (name) => { 564 | const collection = self.apos.db.collection(name); 565 | // Should not be necessary according to the mongodb docs, but when 566 | // this method is used to obtain a collection object we don't get 567 | // a collectionName property 568 | collection.collectionName = name; 569 | return collection; 570 | }; 571 | } 572 | }; 573 | 574 | function getAuthorizationApiKey(req) { 575 | const header = req.headers.authorization; 576 | if (!header) { 577 | return null; 578 | } 579 | const matches = header.match(/^ApiKey\s+(\S.*)$/i); 580 | if (!matches) { 581 | return null; 582 | } 583 | return matches[1]; 584 | } 585 | --------------------------------------------------------------------------------