2

I created a project using Apostrophe CMS and have an ongoing development of new pages and articles. The content (articles and pages) we publish will be available to the public (non-logged-in users) and also to logged-in users (admins).

I already understand the basics of schema in Apostrophe CMS and have been trying to debug the following behavior, topic of this question: the author of articles is not shown to non-logged in users.

I am not sure whether this is a bug or a default behavior, but I really need some help, as I've already spent some hours looking on how to change/fix this.


What I've debugged so far

  1. This is how the article thumb is shown for a logged-in user:

<logged-in article thumb>

  1. And this is how it is shown for a non-logged-in user:

<non-logged-in article thumb>

Notice above how the author is not shown if I'm not logged in. This is the part of the HTML that renders the author thumb:

<div class="wrap-content">
                        
  {% if piece._author.thumbnail.items.length %}
    <img src="{{apos.attachments.url(apos.images.first(piece._author.thumbnail))}}" class="image-user" alt="">
  {% endif %}

  <h2 class="title">{{piece.title}}</h2>
  <p class="description">{{ piece.description }} </p>
  1. I found out that the req.user is set in passport package, shown in the image below:

node_modules/passport/lib/strategies/session.js

<passport-set-req-user>

  1. I came down to one "if" condition that checks whether the user is logged in. In the image below I'm not logged in so the req.user is undefined.

node_modules/apostrophe/lib/modules/apostrophe-schemas/index.js

<apostrophe-cms-logged-off>

  1. And when logged in, the user is set:

node_modules/apostrophe/lib/modules/apostrophe-schemas/index.js

<apostrophe-cms-logged-in>

  1. When logged in, apostrophe-schemas does the joinByOne and other types of joins. What I have to join is in:

lib/modules/apostrophe-blog/index.js

addFields: [
  {
    name: '_author',
    label: 'Author',
    type: 'joinByOne',
    withType: 'apostrophe-user',
    idField: 'userId',
  },

Where userId is the logged in user id when creating the article. It is joined as the _author.

  1. In the html file I showed above, if there is a "piece._author" it shows the author, otherwise it does not. Not as we never join, there is never an author, hence not showing the author if I'm not logged in.

I've already tried commenting the "if" condition, with no effect whatsoever, so no success (I could see the articles listed normally, but without the author).

We really need to show to author to everyone. How do I do this? Thank you in advance.

Community
  • 1
  • 1
duartealexf
  • 131
  • 1
  • 9

2 Answers2

3

So with the help of @boutell and researching in the docs I solved the problem.

This tutorial isn't too long and it's quite simple.

Overview

  1. Created a new profiles piece type - 1 profile per user.
  2. Each profile has a description field (like an 'about the author' section)
  3. Each apostrophe-blog entity has 1 _author (the profile id)
  4. Set up a hook for after inserting user to create and associate its profile.
  5. Set up a hook for before inserting apostrophe-blogs to associate the profile of logged in user.
  6. Create a migration to generate profile for each already-existing user.
  7. Create migration to swap fields and values in apostrophe-blog:
    • When finding out about the problem, I already had an userId field in apostrophe-blog, so the migration changes from userId to userProfileId, which value is self-explanatory. If you don't have this field or don't have any apostrophe-blogs yet, you won't need this migration.

The version of apostrophe modules I was using are as follows:

"apostrophe": "^2.37.2",
"apostrophe-blog": "^2.1.1",

Solution

  1. Created a new profiles piece type.

/app.js

    modules: {
        'apostrophe-blog': {},
        'apostrophe-tags': {},
        'profiles': {}, // <-- add this
  1. Create the folder and file below:

/lib/modules/profiles/index.js

    /**
     * Module for adding "profile" entity. Every user has its profile. Before article (apostrophe-blog)
     * entities are inserted, they have the logged in user's profile id userprofile._id attached to
     * the article, as userProfileId field. This way, author of articles are always shown, even to
     * non-logged in users. In this file two migrations are included - add profile to existing
     * users and change from old "userId" that were in articles to new "userProfileId".
     * 
     * Run migration with node app.js apostrophe-migrations:migrate.
     * 
     * @author Alexandre Duarte (github.com/duartealexf)
     */

    const async = require('async');

    module.exports = {

        /**
         * Default stuff required by ApostropheCMS.
         */
        extend: 'apostrophe-pieces',
        name: 'profile',
        label: 'Profile',
        pluralLabel: 'Profiles',
        searchable: false,

        afterConstruct: function(self, callback) {

            /**
             * Ensure collection is set and add migrations to DB.
             */
            return async.series([
                self.ensureCollection,
                self.addUserProfileMigration,
                self.addBlogPageAuthorMigration,
            ], callback);
        },

        beforeConstruct: function(self, options) {

            options.addFields = [
                /**
                 * User of profile.
                 */
                {
                    name: '_user',
                    label: 'User',
                    type: 'joinByOne',
                    withType: 'apostrophe-user',
                    idField: 'userId',
                },

                /**
                 * Optional profile description.
                 */
                {
                    type: 'string',
                    textarea: true,
                    name: 'description',
                    label: 'Description',
                },

                /**
                 * Whether profile is published.
                 * Does not affect whether author is shown.
                 */
                {
                    type: 'boolean',
                    name: 'published',
                    label: 'Published',
                    def: true,
                },

                /**
                 * Profile thumbnail.
                 */
                {
                    name: 'thumbnail',
                    type: 'singleton',
                    widgetType: 'apostrophe-images',
                    label: 'Picture',
                    options: {
                        limit: 1,
                        aspectRatio: [100,100]
                    }
                }
            ].concat(options.addFields || []);
        },

        construct: function(self, options) {

            /**
             * Ensure collection variable is set.
             * 
             * @param {Function} callback 
             */
            self.ensureCollection = function(callback) {
                return self.apos.db.collection('aposDocs', function(err, collection) {
                    self.db = collection;
                    return callback(err);
                });
            };

            /**
             * Hook after inserting user. Actually watches on any doc insert,
             * so we need the 'if' statement below.
             *
             * @param {any} req Request.
             * @param {any} doc Doc being inserted.
             * @param {any} options Options from hook.
             * @param {any} callback
             */
            self.docAfterInsert = function(req, doc, options, callback) {

                /**
                 * No doc id, no change.
                 */
                if (!doc._id) {
                    return setImmediate(callback);
                }

                /**
                 * If it is an user, we add the profile.
                 */
                if (doc.type === 'apostrophe-user') {
                    return self.addUserProfile(req, doc, options, callback);
                }
                return setImmediate(callback);
            }

            /**
             * Hook before inserting article.
             * 
             * @param {any} req Request.
             * @param {any} doc Doc being inserted.
             * @param {any} options Options from hook.
             * @param {any} callback
             */
            self.docBeforeInsert = function(req, doc, options, callback) {

                /**
                 * No doc id, no change.
                 */
                if (!doc._id) {
                    return setImmediate(callback);
                }

                /**
                 * If it is a apostrophe-blog, we associate the profile
                 */
                if (doc.type === 'apostrophe-blog') {
                    return self.addProfileToArticle(req, doc, options, callback);
                }
                return setImmediate(callback);
            }

            /**
             * Method for creating user profile.
             * 
             * @param {any} req Request.
             * @param {any} user User having profile added.
             * @param {any} options Options from hook.
             * @param {any} callback
             */
            self.addUserProfile = function(req, user, options, callback) {

                /**
                 * Our profile entity.
                 */
                const profile = {
                    description: '',
                    published: true,
                    userId: user._id,
                    title: user.title,
                    slug: user.slug.replace(/^(user\-)?/, 'profile-'),
                    thumbnail: user.thumbnail
                }

                /**
                 * Insert async.
                 */
                return async.series({
                    save: function(callback) {
                        return self.insert(req, profile, {}, callback);
                    }
                });
            }

            /**
             * Method to add userProfileId to article.
             * 
             * @param {any} req Request.
             * @param {any} article Article having profile associated.
             * @param {any} options Options from hook.
             * @param {any} callback
             */
            self.addProfileToArticle = async function(req, article, options, callback) {

                /**
                 * Currently logged in user.
                 */
                const user = req.user;

                /**
                 * Extra check.
                 */
                if (!user) {
                    return setImmediate(callback);
                }

                /**
                 * This promise should resolve to the
                 * currently logged in user's profile id.
                 */
                const profileId = await new Promise(resolve => {

                    // Get profile of logged in user.
                    self.db.find({ type: self.name, userId: user._id }, async function(err, cursor) {
                        if (err) {
                            resolve();
                        }

                        const profile = await cursor.next();

                        resolve(profile ? profile._id : undefined);
                    });
                });

                /**
                 * No profile, no association.
                 */
                if (!profileId) {
                    return setImmediate(callback);
                }

                /**
                 * Attach the userProfileId and callback (ApostropheCMS will save the entity).
                 */
                article.userProfileId = profileId;

                return setImmediate(callback);
            }

            /**
             * Method to add migration that adds profile to already existing users.
             * 
             * @param {Function} callback 
             */
            self.addUserProfileMigration = function(callback) {

                /**
                 * Add migration to DB. The callback function will be called
                 * when running ApostropheCMS's CLI 'migration' command.
                 */
                self.apos.migrations.add(self.__meta.name + '.addUserProfile', function(callback) {

                    /**
                     * The users that need migrating.
                     */
                    let usersToMigrate = [];

                    /**
                     * Run 'docs' and 'migrate' functions async.
                     */
                    return async.series([ docs, migrate ], callback);

                    /**
                     * Get the users that need migrating.
                     */
                    function docs(callback) {

                        /**
                         * Get all profiles.
                         */
                        return self.db.find({ type: self.name }, async function(err, profiles) {
                            if (err) {
                                return callback(err);
                            }

                            let userIds = [], profile;

                            /**
                             * Fill array of userIds from already existing profiles.
                             */
                            while (profile = await profiles.next()) {
                                userIds.push(profile.userId);
                            }

                            /**
                             * Get all users not in userIds (users that have no profile).
                             * These are the usersToMigrate.
                             */
                            self.db.find({ type: 'apostrophe-user', _id: { $nin: userIds } }, async function(err, users) {
                                if (err) {
                                    return callback(err);
                                }

                                while (user = await users.next()) {
                                    usersToMigrate.push(user);
                                }

                                return callback(null);
                            });
                        })
                    }

                    /**
                     * Run migration.
                     * 
                     * @param {Function} callback 
                     */
                    async function migrate(callback) {

                        /**
                         * Iterate on usersToMigrate and create a profile for each user.
                         */
                        for (let i = 0; i < usersToMigrate.length; i++) {
                            const user = usersToMigrate[i];

                            /**
                             * Our profile entity.
                             */
                            const profile = {
                                _id: self.apos.utils.generateId(),
                                description: '',
                                published: true,
                                userId: user._id,
                                title: user.title,
                                type: self.name,
                                createdAt: user.updatedAt,
                                slug: user.slug.replace(/^(user\-)?/, 'profile-'),
                                docPermissions: [],
                                thumbnail: user.thumbnail,
                            }

                            await new Promise(resolve => self.db.insert(profile, resolve));
                        }

                        return setImmediate(callback);
                    }
                }, {
                    safe: true
                });

                return setImmediate(callback);

            }


            /**
             * Migration to swap from userId to userProfileId to
             * already existing apostrophe-blog entities.
             */
            self.addBlogPageAuthorMigration = function(callback) {

                /**
                 * Add migration to DB. The callback function will be called
                 * when running ApostropheCMS's CLI 'migration' command.
                 */
                self.apos.migrations.add(self.__meta.name + '.addBlogPageAuthor', function(callback) {

                    /**
                     * Mapping of profile id by user id.
                     */
                    const profileMapByUserId = new Map();

                    /**
                     * Posts (apostrophe-blog entities) that need migrating.
                     */
                    const postsToMigrate = [];

                    /**
                     * Run 'posts', 'profiles' and 'migrate' functions async.
                     */
                    return async.series([ posts, profiles, migrate ], callback);

                    /**
                     * Get the posts that need migrating.
                     * 
                     * @param {Function} callback
                     */
                    function posts(callback) {

                        /**
                         * Get all posts having an userId set (not yet migrated ones).
                         */
                        return self.db.find({ type: 'apostrophe-blog', userId: { $exists: true }}, async function(err, blogPosts) {
                            if (err) {
                                return callback(err);
                            }

                            let post;

                            /**
                             * Add found posts to postsToMigrate.
                             */
                            while (post = await blogPosts.next()) {
                                postsToMigrate.push(post);
                            }

                            return callback(null);
                        });
                    }

                    /**
                    * Create the profiles mapping by user id.
                    * 
                    * @param {Function} callback
                    */
                    function profiles(callback) {

                        /**
                        * As this function is running async, we need to set immediate
                        * callback to not migrate if there are no posts to migrate.
                        */
                        if (!postsToMigrate.length) {
                            setImmediate(callback);
                        }

                        /**
                        * Get all profiles.
                        */
                        return self.db.find({ type: self.name }, async function(err, profiles) {
                            if (err) {
                                return callback(err);
                            }

                            let profile;

                            /**
                            * Build mapping.
                            */
                            while (profile = await profiles.next()) {
                                profileMapByUserId.set(profile.userId, profile);
                            }

                            return callback(null);
                        });
                    }

                    /**
                    * Run migration.
                    * 
                    * @param {Function} callback 
                    */
                    async function migrate(callback) {

                        let userId, profile, post;

                        for (let i = 0; i < postsToMigrate.length; i++) {

                            /**
                            * Get userId of post.
                            */
                            post = postsToMigrate[i];
                            userId = post.userId;

                            if (!userId) {
                                continue;
                            }

                            /**
                            * Get profile of user.
                            */
                            profile = profileMapByUserId.get(userId);

                            if (!profile) {
                                continue;
                            }

                            /**
                            * Swap from userId to userProfileId.
                            */
                            delete post.userId;
                            post.userProfileId = profile._id;

                            /**
                            * Replace the post to the new one having userProfileId.
                            */
                            await new Promise(resolve => self.db.replaceOne({ _id: post._id }, post, resolve));
                        }

                        return callback(null);
                    }
                }, {
                    safe: true
                });

                return setImmediate(callback);

            }
        }
    }

Note: the file above contains a migration that assumes you already have userId field in apostrophe-blog entities. Again, if you don't have this field or don't have any apostrophe-blogs yet, you won't need this migration. Also, it assumes you have thumbnail for users.

The code is well documented, so you know where to change if needed.

  1. Add _author field to apostrophe-blog, remove userId field if you had any (it's safe to remove before the migration).

/lib/modules/apostrophe-blog/index.js

    module.exports = {

      addFields: [
        {                           // <-- add this
          name: '_author',          // <-- add this
          label: 'Author',          // <-- add this
          type: 'joinByOne',        // <-- add this
          withType: 'profile',      // <-- add this
          idField: 'userProfileId'  // <-- add this
        }                           // <-- add this
        // ...
      ],
      beforeConstruct: function(self, options) {
        // ...
  1. Start / restart ApostropheCMS process to add the migration.
  2. Run the migration in the CLI (can't remember if you need to have ApostropheCMS running to do this).

From the project's root folder:

node app.js apostrophe-migrations:migrate

  1. Make necessary changes to your html files to show the author's name and thumbnail, if applicable.

Voilà! You should now be able to see the author.

Note: this tutorial does not take any notes on whether you need to have your app running 100% of the time. You will eventually need to take it down / restart to add the migration. Run your tests in dev environment first.

duartealexf
  • 131
  • 1
  • 9
0

You don't specify, but since apostrophe-blog has no _author join by default, I assume you added one, and that it joins with apostrophe-user.

In Apostrophe 0.5, "users" (who can do stuff on the website) and "people" (who are talked about on the site, in directories of public information about employees, etc.) used to be the same type. In 2.x we stopped doing that because it's way too common to have an "author" who is not actually a user, or a user whose information should never be shown to the public.

So in 2.x apostrophe-users never sets the published flag and non-admins simply cannot find other users; the public can't find users at all.

We recommend creating a profiles piece type instead, and joining with that. This allows you to maintain a separation between "who can do stuff to the website" and "what the public knows about who wrote stuff."

You could create profiles automatically using a beforeSave handler for apostrophe-users.

Tom Boutell
  • 7,281
  • 1
  • 26
  • 23
  • I am doing the changes you suggested and am 90% done and it's working now. Thank you very much. I will share once it's 100%. I just have one question: when editing the article (in the modal), the Author field is showing and I don't need it to be there, as I will automatically associate the author when `afterSave` of `apostrophe-blog`. How do I hide a field from the modal? Thanks. – duartealexf Dec 22 '17 at 19:09
  • You can set `contextual: true` on that field. This is typically used when you're gong to edit it "in context" (i.e. on the page or by some means other than the modal). – Tom Boutell Jan 02 '18 at 16:57
  • Cheers, I created the 'profiles' piece type and everything is working now. What is the best way to share what I've done? – duartealexf Jan 03 '18 at 13:50
  • Oh, good question. You could create a public npm module: http://apostrophecms.org/docs/more-modules.html#publishing-your-own-npm-modules-for-apostrophe – Tom Boutell Jan 04 '18 at 14:26
  • @axelraden - did you make the npm module? If not, would you be able to add your solution as an answer on this question? I'm trying to do exactly the same thing and so it would really help me to see what you did. Thanks! – Blen Feb 05 '18 at 17:32
  • 1
    @Blen sorry, never got around to create the module. Good idea, I will post he solution here. – duartealexf Feb 06 '18 at 18:28