├── LICENSE ├── README.md ├── client └── methods.js ├── collections └── taxonomies.js ├── history.md ├── package.js ├── server ├── config.js └── methods.js └── tests.js /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 hb5 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Drupal-Ddp 2 | Drupal and Meteor integration over DDP. This meteor package requires the [Drupal DDP drupal module](https://www.drupal.org/sandbox/bfodeke/2354859) to function. 3 | 4 | Once set up, it allows Drupal's Node, Taxonomy, and User objects to be synced over to your meteor application. 5 | 6 | 7 | 8 | ### Installation 9 | Install package `meteor add hb5:drupal-ddp` 10 | 11 | ### Settings.json 12 | A settings.json file is required during the startup of your meteor app. 13 | 14 | Below is a sample `settings.json` file to be included in the root of your Meteor project. 15 | 16 | { 17 | "public": { 18 | "drupal_ddp_access_private_files": true 19 | }, 20 | "drupal_ddp": { 21 | "debug_data": true, 22 | "drupal_url": "http://your_drupal_url", 23 | "restws_user": "restws_xxxxx", 24 | "restws_pass": "your_password", 25 | "restws_read_user": "restws_readonly_xxxxx", 26 | "restws_read_pass": "your_password", 27 | "simple_security": true, 28 | "simple_security_token": "your_security_token" 29 | } 30 | } 31 | 32 | **Notes** 33 | 34 | - `debug_data: true` will enable you to see some debug data in the console. 35 | - `ddp_url` should be the url of your Drupal website (no trailing slash). 36 | - To enable writing data back to Drupal, a user must be created in Drupal prefixed with `restws_`, and should have read and write access to the content types you wish to write back to. 37 | - Create your own restws user and add that in place of `restws_xxxxx` to your settings.json file. 38 | - Add the restws password from Drupal in place of `your_password`. 39 | - To enable simple security to prevent unauthorized ddp requests to your Meteor app, set `simple_security` to `true`. 40 | - Simple Security Token (`simple_security_token`) is a token that should be set within your Drupal site. 41 | 42 | **Private Files** 43 | 44 | - To enable drupal's private files to be accessible to Meteor, your **Drupal** and your **Meteor** sites must be on the same domain. 45 | - Create a new user in Drupal with the `restws_readonly_` prefix that ONLY has rights to view private files. 46 | - Add your new username and password to your settings.json file. 47 | - Set `drupal_ddp_access_private_files` to `true` in the public section in your settings.json file. 48 | 49 | _Run your meteor using the settings.json file by running `meteor --settings settings.json` at the root of your app._ 50 | 51 | 52 | ### Registering Content Types 53 | In order to save content from Drupal into your MongoDB, you must create a collection and register it. 54 | 55 | 56 | Articles = new Mongo.Collection('article'); 57 | Meteor(isServer()) { 58 | DrupalDdp.registerType('article', Articles); 59 | } 60 | 61 | Where ***`article`*** corresponds to your Drupal content type machine name. 62 | 63 | ### Writing data back to Drupal 64 | Currently, only nodes are supported for writing back to Drupal. 65 | 66 | In order to write node data back to Drupal, pass a single (node) object to the `updateNodeInDrupal` method: 67 | 68 | `Meteor.call('updateNodeInDrupal', object);` 69 | 70 | ### Users 71 | If the `drupal_ddp_users` module is enabled, user accounts can sync over to Meteor from Drupal. Once synced, a user can login to Drupal and Meteor with the same credentials. 72 | 73 | ### Syncing Existing Content 74 | Existing Node, User and Taxonomy data can be synced to Meteor from the Drupal Module settings page. 75 | 76 | Users can be synced from Drupal, but their accounts won't be verified in Meteor until a password change happens from Drupal. Once a user password is updated in Drupal, then you can login to Drupal & Meteor with the same password. 77 | 78 | -------------------------------------------------------------------------------- /client/methods.js: -------------------------------------------------------------------------------- 1 | if (Meteor.isClient) { 2 | Meteor.startup(function () { 3 | if (Meteor.settings.public.drupal_ddp_access_private_files === true) { 4 | Meteor.call('getDrupalSessionToken', 'read', function(err, response) { 5 | if (!err) { 6 | // Getting the upper level domain of the current site. 7 | var urlParts = location.hostname.split('.'); 8 | var domain = _.last(urlParts, 2).join('.'); 9 | 10 | // Set a session cookie. 11 | var cookieParts = response.cookie.split(';'); 12 | 13 | // Loop through parts of the cookie to make it 14 | // work across subdomains. 15 | _.each(cookieParts, function(num, index){ 16 | cookieSegment = num.split('='); 17 | if ($.trim(cookieSegment[0]) === 'domain') { 18 | // Set the domain equal to the tld. 19 | var cookieDomain = ' domain=' + domain; 20 | cookieParts[index] = cookieDomain; 21 | } 22 | 23 | // If the 'HttpOnly' flag is set in the cookie, 24 | // remove it so the cookie can be set via javascript. 25 | if ($.trim(cookieSegment[0]) === 'HttpOnly') { 26 | cookieParts.splice(index, 1); 27 | } 28 | }); 29 | 30 | // Join all the cookie parts back together. 31 | var sessionCookie = cookieParts.join(';'); 32 | document.cookie = sessionCookie; 33 | } 34 | }); 35 | } 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /collections/taxonomies.js: -------------------------------------------------------------------------------- 1 | drupalDdpTaxonomies = new Mongo.Collection('drupal_ddp_taxonomies'); 2 | -------------------------------------------------------------------------------- /history.md: -------------------------------------------------------------------------------- 1 | v.0.1.3 2 | ---- 3 | - Changed settings.json variable name from `ddp_url` to `drupal_ddp` for added 4 | clarity of its function. 5 | - Added more debugging output when `debug` is set to `true`. 6 | - `DrupalSaveNode` method now returns `nid/uid/tid` and `timestamp` to help with 7 | queueing of posts from Drupal -> Meteor. 8 | - Added dependency on `matteodem:server-session` package. Sesson tokens are now 9 | stored in server session to reduce the amount of concurrent active sessions 10 | for the `restws_` user in Drupal when data is updated from Meteor -> Drupal. 11 | 12 | v.0.1.4 13 | ---- 14 | - Throwing a Meteor error if user tries to sync a term in a vocab that hasn't been registered yet. 15 | - Returning FALSE on `updateNodeInDrupal` when an error occurs so code that call that method can better use the return data. 16 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'hb5:drupal-ddp', 3 | summary: 'Drupal and Meteor integration over DDP', 4 | git: 'https://github.com/hb5co/drupal-ddp', 5 | version: '0.1.4' 6 | }); 7 | 8 | Package.onUse(function(api) { 9 | api.versionsFrom('1.0.3.1'); 10 | api.use('npm-bcrypt@=0.7.8_2'); 11 | 12 | var both = ['client', 'server']; 13 | 14 | // Packages for Client & Server. 15 | api.use([ 16 | 'mongo', 17 | 'accounts-password', 18 | 'http' 19 | ], both); 20 | 21 | // Packages for Server. 22 | api.use('matteodem:server-session@0.4.2', 'server'); 23 | 24 | // Files for Client. 25 | api.addFiles([ 26 | 'client/methods.js', 27 | ], both); 28 | 29 | // Files for Client & Server. 30 | api.addFiles([ 31 | 'collections/taxonomies.js', 32 | ], both); 33 | 34 | // Files for Server. 35 | api.addFiles([ 36 | 'server/methods.js', 37 | 'server/config.js' 38 | ], 'server'); 39 | 40 | // Publish Collections to Client. 41 | api.export('drupalDdpTaxonomies'); 42 | api.export('DrupalDdp'); 43 | }); 44 | 45 | Package.onTest(function(api) { 46 | api.use('tinytest'); 47 | api.addFiles('server/config.js'); 48 | api.addFiles('server/methods.js'); 49 | api.addFiles('tests.js'); 50 | }); 51 | -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | DrupalDdp = { 2 | collections: {}, 3 | taxonomies: {}, 4 | registerType: function(key, collection) { 5 | this.collections[key] = collection; 6 | }, 7 | registerTaxonomy: function(key, collection) { 8 | this.taxonomies[key] = collection; 9 | } 10 | }; 11 | 12 | -------------------------------------------------------------------------------- /server/methods.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | base64Encode: function (unencoded) { 3 | return new Buffer(unencoded || '').toString('base64'); 4 | }, 5 | // Accept node inserts and updates from Drupal. 6 | DrupalSaveNode: function (data) { 7 | var currentTime = Math.floor(new Date().getTime() / 1000); 8 | // Implementation of simple security. 9 | if (Meteor.settings.drupal_ddp.simple_security === true) { 10 | if (Meteor.settings.drupal_ddp.simple_security_token !== data.simple_security) { 11 | throw new Meteor.Error("Security token does not match!"); 12 | } 13 | } 14 | 15 | if (Meteor.settings.drupal_ddp.debug_data === true) { console.log(data); } 16 | 17 | // Handle Nodes. 18 | if (data.content.ddp_type == 'node') { 19 | var actualColl = DrupalDdp.collections[data.content.type]; 20 | if (!actualColl) { 21 | throw new Meteor.Error("You haven't registered this type of collection yet."); 22 | } 23 | 24 | // If content is flagged for deletion, remove. 25 | if (data.content.delete_content) { 26 | // Delete existing posts. 27 | actualColl.remove({nid: data.content.nid}); 28 | } 29 | // Otherwise, insert/update. 30 | else { 31 | // Update existing posts. 32 | actualColl.upsert({nid: data.content.nid},{$set: data.content}); 33 | } 34 | 35 | // Return object as an acknowledgement of Meteor getting data. 36 | var returnMessage = { 37 | 'nid': data.content.nid, 38 | 'type': data.content.type, 39 | 'timestamp': currentTime 40 | }; 41 | return returnMessage; 42 | } 43 | 44 | // Handle Taxonomies. 45 | if (data.content.ddp_type == 'taxonomy') { 46 | var actualTax = DrupalDdp.taxonomies[data.content.vocabulary_machine_name]; 47 | if (!actualTax) { 48 | throw new Meteor.Error("You haven't registered this taxonomy vocabulary yet."); 49 | } 50 | if (data.content.delete_content) { 51 | // Delete existing taxonomies. 52 | actualTax.remove({tid: data.content.tid}); 53 | } 54 | else { 55 | actualTax.upsert({ 56 | tid: data.content.tid 57 | },{ 58 | $set: data.content 59 | }); 60 | } 61 | 62 | // Return object as an acknowledgement of Meteor getting data. 63 | var returnMessage = { 64 | 'tid': data.content.tid, 65 | 'vocab': data.content.vocabulary_machine_name, 66 | 'timestamp': currentTime 67 | }; 68 | return returnMessage; 69 | } 70 | 71 | // Handle Users. 72 | if (data.content.ddp_type == 'user') { 73 | // Clean up data and prepare profile information 74 | cleanUpProfile = [ 75 | 'rdf_mapping', 76 | 'original', 77 | 'data', 78 | 'name', 79 | 'mail', 80 | 'pass', 81 | 'ddp_type' 82 | ]; 83 | profileData = _.omit(data.content, cleanUpProfile); 84 | 85 | if (data.content.delete_content) { 86 | // Delete existing user. 87 | userId = Meteor.users.findOne({'profile.uid' : data.content.uid})._id; 88 | Meteor.users.remove(userId); 89 | } 90 | else if (!(Meteor.users.findOne({'profile.uid' : data.content.uid}))) { 91 | // Create User. 92 | Accounts.createUser({ 93 | username: data.content.name, 94 | email : data.content.mail, 95 | password : data.content.pass, 96 | profile : profileData 97 | }); 98 | } 99 | else { 100 | Meteor.users.update( 101 | {'profile.uid' : data.content.uid}, 102 | {$set: 103 | { 104 | 'emails.0.address' : data.content.mail, 105 | 'username' : data.content.name, 106 | 'profile' : profileData 107 | }, 108 | } 109 | ); 110 | } 111 | 112 | // Return object as an acknowledgement of Meteor getting data. 113 | var returnMessage = { 114 | 'uid': data.content.uid, 115 | 'timestamp': currentTime 116 | }; 117 | return returnMessage; 118 | } 119 | 120 | if (data.content.ddp_type === 'update_user_password') { 121 | var bcrypt = NpmModuleBcrypt; 122 | var bcryptHash = Meteor.wrapAsync(bcrypt.hash); 123 | var passwordHash = bcryptHash(data.content.sha_pass, 10); 124 | 125 | var userId = null; 126 | 127 | // In the event that the user doesn't exist yet 128 | // (very rare) AND the 'update_user_password' request 129 | // arrives, then create the user with basic info. 130 | if (!(Meteor.users.findOne({'profile.uid' : data.content.uid}))) { 131 | userId = Accounts.createUser({ 132 | username: data.content.name, 133 | email : data.content.mail, 134 | password : data.content.sha_pass, 135 | profile: { 136 | uid : data.content.uid 137 | } 138 | }); 139 | } else { 140 | userId = Meteor.users.findOne({ 141 | 'profile.uid': data.content.uid 142 | })._id; 143 | } 144 | 145 | // Set user password and 'verify' their account. 146 | Meteor.users.update({_id : userId}, {$set: {'services.password.bcrypt' : passwordHash}}); 147 | Meteor.users.update({_id : userId}, {$set: {'emails.0.verified' : true}}); 148 | 149 | // Return object as an acknowledgement of Meteor getting data. 150 | var returnMessage = { 151 | 'uid': data.content.uid, 152 | 'timestamp': currentTime 153 | }; 154 | return returnMessage; 155 | } 156 | }, 157 | getDrupalSessionToken: function(type) { 158 | if (type === 'read') { 159 | var options = { 160 | url: Meteor.settings.drupal_ddp.drupal_url + "/restws/session/token", 161 | username : Meteor.settings.drupal_ddp.restws_read_user, 162 | password : Meteor.settings.drupal_ddp.restws_read_pass, 163 | }; 164 | } else { 165 | var options = { 166 | url: Meteor.settings.drupal_ddp.drupal_url + "/restws/session/token", 167 | username : Meteor.settings.drupal_ddp.restws_user, 168 | password : Meteor.settings.drupal_ddp.restws_pass, 169 | }; 170 | } 171 | 172 | if (Meteor.settings.drupal_ddp.debug_data === true) { 173 | console.log('== Connection Options =='); 174 | console.log(options); 175 | } 176 | 177 | var auth = 'Basic ' + Meteor.call('base64Encode', options.username + ':' + options.password); 178 | 179 | try { 180 | var result = HTTP.post(options.url, { 181 | headers: { 182 | Authorization: auth 183 | } 184 | }); 185 | 186 | tokenResponse = { 187 | token: result.content, 188 | cookie: result.headers['set-cookie'][0], 189 | }; 190 | 191 | if (Meteor.settings.drupal_ddp.debug_data === true) { 192 | console.log('== Connection Successful: Token =='); 193 | console.log(tokenResponse); 194 | } 195 | 196 | return tokenResponse; 197 | } catch (e) { 198 | if (Meteor.settings.drupal_ddp.debug_data === true) { 199 | console.log('== Error Creating Token =='); 200 | console.log(e); 201 | } else { 202 | return false; 203 | } 204 | } 205 | }, 206 | /** 207 | * Function to update node content in Drupal. 208 | * 209 | * @parame node 210 | * JSON node content to write back to Drupal. 211 | * @param numTries 212 | * Number of tries to write to Drupal. Accounts for the stored session token 213 | * being stale the first try around. 214 | */ 215 | updateNodeInDrupal: function(node, numTries) { 216 | // Setting default for numTries to 1. 217 | numTries = typeof numTries !== 'undefined' ? numTries : 1; 218 | // If session token exist via ServerSession, then return the token. 219 | var tokenCookie = ServerSession.get('restws_write_token'); 220 | 221 | if (Meteor.settings.drupal_ddp.debug_data === true) { 222 | console.log('== Base URL and Endpoint =='); 223 | console.log(Meteor.settings.drupal_ddp.drupal_url); 224 | console.log(Meteor.settings.drupal_ddp.drupal_url + '/node/' + node.nid); 225 | 226 | console.log('== Token (writing to Drupal) =='); 227 | console.log(tokenCookie); 228 | } 229 | 230 | // Clean up node to remove some unsupported fields in Drupal. 231 | node = Meteor.call('cleanUpNode', node); 232 | 233 | if (Meteor.settings.drupal_ddp.debug_data === true) { 234 | console.log('== Content Going back to drupal =='); 235 | console.log(node); 236 | } 237 | 238 | // Try to make connection and send data back to Drupal. 239 | try { 240 | baseUrl = Meteor.settings.drupal_ddp.drupal_url; 241 | endpoint = baseUrl + '/node/' + node.nid; 242 | 243 | var result = HTTP.put( 244 | endpoint, 245 | { 246 | headers: { 247 | 'Content-type': 'application/json', 248 | 'X-CSRF-Token': tokenCookie.token, 249 | 'Accept': 'application/json', 250 | 'Cookie': tokenCookie.cookie, 251 | }, 252 | data: node 253 | } 254 | ); 255 | return result; 256 | } catch (e) { 257 | // Try to send data with cached token, if that fails, 258 | // get new token and try again. If THAT fails, display 259 | // error. 260 | if (numTries < 2) { 261 | sessionToken = Meteor.call('getDrupalSessionToken', 'write'); 262 | ServerSession.set('restws_write_token', sessionToken); 263 | 264 | console.log('== Cached session token invalid, fetching new session token. =='); 265 | console.log(node); 266 | 267 | // After new SessionToken is set, recursively call function. 268 | numTries++; 269 | Meteor.call('updateNodeInDrupal', node, numTries); 270 | } else { 271 | if (Meteor.settings.drupal_ddp.debug_data === true) { 272 | console.log('== Server Error Response =='); 273 | console.log(e); 274 | } 275 | return false; 276 | } 277 | } 278 | }, 279 | cleanUpNode: function(node) { 280 | // These are items in a node that aren't supported for writing 281 | // via restws in Drupal. 282 | cleanUpNode = [ 283 | 'is_new', 284 | 'vid', 285 | 'ddp_type', 286 | 'comment', 287 | 'comments', 288 | 'changed', 289 | 'url', 290 | 'edit_url', 291 | 'comment_count', 292 | 'comment_count_new', 293 | 'revision', 294 | 'language', 295 | 'author', 296 | 'field_tags', 297 | 'field_image', 298 | 'created', 299 | 'status', 300 | 'promote', 301 | 'sticky', 302 | ]; 303 | 304 | // Fix for any early adopters. content structure in meteor changed. 305 | // Preparing the node to be sent back to Drupal. 306 | if (node.hasOwnProperty('content')) { 307 | node = node.content; 308 | } else { 309 | // Add '_id' to the list of fields to be removed from 310 | // the node. 311 | cleanUpNode.push('_id'); 312 | } 313 | 314 | // Check for File fields and Taxonomy fields to 315 | // remove because restws can't handle the heat. 316 | _.each(node, function(value, key, obj){ 317 | // If obj is array 318 | if (_.isArray(value) && !_.isNull(value) && !_.isEmpty(value)) { 319 | // If 'file' exists here, then it's a file_field, 320 | // add key cleanUpNode array. 321 | if (_.has(value[0], 'file')) { 322 | cleanUpNode.push(key); 323 | } 324 | 325 | // If 'tid' exists here, then it's a taxonomy term, 326 | // add key to cleanUpNode array. 327 | if (_.has(value[0], 'tid')) { 328 | cleanUpNode.push(key); 329 | } 330 | } 331 | }); 332 | 333 | // Remove fields from node object that aren't supported 334 | // for writing back to drupal. 335 | node = _.omit(node, cleanUpNode); 336 | 337 | return node; 338 | } 339 | }); 340 | 341 | -------------------------------------------------------------------------------- /tests.js: -------------------------------------------------------------------------------- 1 | // Write your tests here! 2 | // Here is an example. 3 | Tinytest.add('example', function (test) { 4 | test.equal(true, true); 5 | }); 6 | --------------------------------------------------------------------------------