@hackage lambdacms-core0.0.8.0

Core CMS extension for Yesod apps

                           ,                     _
                          /   _, _   /  _/ _,   / ) _  _,
                         (__ (/ //) () (/ (/   (__ //)_)

                   developer friendly :: type safe :: performant

Rationale

LambdaCms is a bunch of packaged libraries, containing sub-sites for the Yesod application framework, which allow rapid development of robust and highly performant websites with content management functionality.

The lambdacms-* packages each provide some functionality and can depend on eachother as they depend on other packages. The only mandatory package is lambdacms-core (this package), it provides functionality that all other lambdacms-* packages can rely on.

As mentioned, each lambdacms-* package contains a sub-site which is "mounted" in a standard Yesod application, which we will refer to as "the base application" or simply "base". Before a packaged sub-site can be mounted, the package needs to be included as a dependency to the base app's .cabal file. After that some glue code needs to be added to the base app, as explained below.

In the base app we have to:

  • organize the main menu of the admin backend,
  • configure a the database connection,
  • specify the authentication strategies, and
  • define admin user roles and their permissions.

In the base app we may optionally also:

  • override default behavior,
  • override UI texts,
  • provide a means to send email notifications, and last but not least,
  • write the themes so the website can actually be visited (recommended).

Getting started

Using this guide you will create a CMS website named mysite. For a real project you want to substitute this name, but for trying out LambdaCms it is recommended to keep it for the convenience of copy-pasting the instructions that follow.

Prerequisites

You need to be reasonably acquinted with Haskell in order to follow along with this guide. To learn basic Haskell skill we recommend Brent Yorgey's excellent Introduction to Haskell course.

Besides Haskell you need to be somewhat familliar with:

  • the web technologies (HTTP, HTML, CSS, JS, REST),
  • SQL (as LambdaCms makes use of a relational database), and
  • the Yesod web application framework (book).

The tool chain

Make sure to have GHC 7.8.3+, cabal-install 1.20+, happy, alex , yesod-bin 1.4.3.3+ installed, and their binaries available from your shell's $PATH.

To check that you are good to go, you can use these commands.

ghc -V
cabal -V
happy -V
alex -V
yesod version

In case you are not good to go, you may want to follow the installation guide on the Stackage website which provides instructions for all dependencies except yesod-bin.

Once you meet all the requirements except yesod-bin, install it.

cabal install "yesod-bin >= 1.4.3.3"

Required non-Haskell dependencies

For the connection with the database, Haskell libraries typically compile against non-Haskell libraries. One of the following libraries needs to be available:

  • For Postgres:
  • Ubuntu: libpq-dev
  • Homebrew on OSX: postgres
  • For Mysql:
    • Ubuntu: libmysqlclient-dev
    • Homebrew on OSX: mysql
  • For Sqlite
    • Ubuntu: libsqlite3-dev
    • Homebrew on OSX: sqlite

On other platforms these packages might have different names, but are most likely available.

If you are going to use a database other than Sqlite, you also need to install that.

Create the base application

With the following command you create a "scaffolded" Yesod application. The command is interactive; you need to supply some configuration values. Pick the database of your choice, and choose a project name

yesod init

After scaffolding cd into the project folder.

If you have chosen a database other than Sqlite, you need to create a database and a sufficiently priviledged user, and supply the credentials to the config/setting.yml file.

Using LTS Haskell

To avoid spending too much time on build issues we use and recommend LTS Haskell.

Currently we develop and test LambdaCms only against the lastest LTS Haskell release. As minor releases of LTS Haskell should never contain breaking changes we only provide the major release number, thereby automatically using the most recent minor release in that series.

Run the following commands from within your project's root folder, to install the most recent LTS Haskell package set in the 1.x series.

wget http://www.stackage.org/lts/1/cabal.config
cabal update

The install all dependencies and build your application with (this may take a while the first time you run it).

cabal install --enable-tests . --max-backjumps=-1 --reorder-goals

In case you experience problems with cabal install try adding -j1 as a flag (prevents concurrent building).

When you experience problems during builds, while using LTS 1.x, we consider this a bug. Please raise an issue.

The following commands will run your scaffolded Yesdo application in development mode.

yesod devel

Now test it by pointing the browser to localhost:3000.

If all went well you are ready to add LambdaCms to your app.

Add LambdaCms

LambdaCms is on Hackage! Install with: cabal install lambdacms-core

In the following sub-sections we explain how to install lambdacms-core into the base application. Much of what we show here can be accomplished in many different ways, in this guide we merely provide a way to get you started.

Patching a new Yesod application

To setup a new LambdaCms website the easy way we created patch files to convert a new Yesod application to a LambdaCms website. Those patches can be found in lambdamcs-patches. Either clone the repository or copy only the required patches to your local environment and run the following command (replace /path/to with the actual path to the patch file):

patch -p1 < /path/to/lambdacms.patch

This patches all files at the same time. Documentation about how to patch files individually can be found in the lambdacms-patches README.

To manually add LambdaCms to a Yesod application follow the steps below.

Modify the .cabal file

First add lambdacms-core to the dependencies list of the .cabal file (name of the file depends on the name of your project).

Add the follwing to the end of the build-depends section:

, lambdacms-core                >= 0.0.7      && < 0.1
, wai                           >= 3.0.2      && < 3.1

And add the following line to the library/exposed-modules section:

Roles

Modify the config/routes file

Replace all of the file's content with:

/static StaticR Static appStatic

/favicon.ico FaviconR GET
/robots.txt RobotsR GET

/ HomeR GET

/admin/auth        AuthR                 Auth            getAuth
/admin/core        CoreAdminR            CoreAdmin       getLambdaCms
/admin             AdminHomeRedirectR    GET

Modify Settings

There are two files to modify here. One is config/settings.yml and the the other is Settings.hs.

In config/settings.yml, add the following line to the bottom of the file

admin: "_env:LAMBDACMS_ADMIN:<your email address>"

Then, in Settings.hs, append the following record to the AppSettings data type:

    , appAdmin                  :: Text

In instance FromJSON AppSettings, find this line:

Add the following line just before the line containing return AppSettings {..}:

        appAdmin                  <- o .: "admin"

Modify the config/models file

Replace all of the file's content with the following UserRole definition:

UserRole
    userId UserId
    roleName RoleName
    UniqueUserRole userId roleName
    deriving Typeable Show

Modify the Model.hs file

Add the following imports:

import Roles
import LambdaCms.Core

Modify the Application.hs file

Add the following imports:

import LambdaCms.Core
import LambdaCms.Core.Settings (generateUUID)
import Network.Wai.Middleware.MethodOverridePost

Add the following function:

getAdminHomeRedirectR :: Handler Html
getAdminHomeRedirectR = do
    redirect $ CoreAdminR AdminHomeR

In the makeFoundation function, add this line to beginning:

let getLambdaCms = CoreAdmin

Then, find this code:

    -- Perform database migration using our application's logging settings.
    runLoggingT (runSqlPool (runMigration migrateAll) pool) logFunc

    -- Return the foundation
    return $ mkFoundation pool

And replace it with:

    -- Perform database migration using our application's logging settings.
    let theFoundation = mkFoundation pool
    runLoggingT
        (runSqlPool (mapM_ runMigration [migrateAll, migrateLambdaCmsCore]) pool)
        (messageLoggerSource theFoundation appLogger)

    -- Create a user if no user exists yet
    let admin = appAdmin appSettings
    madmin <- runSqlPool (getBy (UniqueEmail admin)) pool
    case madmin of
        Nothing -> do
            timeNow <- getCurrentTime
            uuid <- generateUUID
            flip runSqlPool pool $ do
                uid <- insert User { userIdent     = uuid
                                   , userPassword  = Nothing
                                   , userName      = takeWhile (/= '@') admin
                                   , userEmail     = admin
                                   , userActive    = True
                                   , userToken     = Nothing
                                   , userCreatedAt = timeNow
                                   , userLastLogin = Nothing
                                   , userDeletedAt = Nothing
                                   }
                -- assign all roles to the first user
                mapM_ (insert_ . UserRole uid) [minBound .. maxBound]
        _ -> return ()

    -- Return the foundation
    return theFoundation

In the function makeApplication replace this line:

    return $ logWare $ defaultMiddlewaresNoLogging appPlain

With this line (adding a WAI middleware is needed to make RESTful forms work on older browsers):

    return $ logWare $ methodOverridePost appPlain

Create the Roles.hs file

Create the Roles.hs file (in the root directory of your application) and add the following content to it:

module Roles where

import ClassyPrelude.Yesod

data RoleName = Admin
              | Blogger
              deriving (Eq, Ord, Show, Read, Enum, Bounded, Typeable)

derivePersistField "RoleName"

Modify the Foundation.hs file

Add the following imports:

import qualified Data.Set                    as S
import qualified Network.Wai                 as W
import LambdaCms.Core
import Roles

Append the following record to the App data type:

    , getLambdaCms   :: CoreAdmin

Change the implementation of isAuthorized (in instance Yesod App) to the following, which allows fine-grained authorization based on UserRoles:

    isAuthorized theRoute _ =
        case theRoute of
            (StaticR _)                   -> return Authorized
            (CoreAdminR (AdminStaticR _)) -> return Authorized
            _                             -> do
                mauthId <- maybeAuthId
                wai     <- waiRequest
                y       <- getYesod
                murs    <- mapM getUserRoles mauthId
                return $ isAuthorizedTo y murs $ actionAllowedFor theRoute (W.requestMethod wai)

Change the implementation of getAuthId (in instance YesodAuth App) to:

    getAuthId = getLambdaCmsAuthId

In instance YesodAuth App replace:

    loginDest _ = HomeR
    logoutDest _ = HomeR

With:

    loginDest _ = CoreAdminR AdminHomeR
    logoutDest _ = AuthR LoginR

And add:

    authLayout = adminAuthLayout

Add the following instance to allow Unauthenticated GET requests for the HomeR route (likely to be /) and other common routes such as /robots.txt. The last pattern allows access to any unspecified routes for just Admins -- which is a role defined in Roles.hs. It is in actionAllowedFor that you will setup permissions for the roles.

instance LambdaCmsAdmin App where
    type Roles App = RoleName

    actionAllowedFor (FaviconR) "GET" = Unauthenticated
    actionAllowedFor (RobotsR)  "GET" = Unauthenticated
    actionAllowedFor (HomeR)    "GET" = Unauthenticated
    actionAllowedFor (AuthR _)  _     = Unauthenticated
    actionAllowedFor _          _     = Roles $ S.fromList [Admin]

    coreR = CoreAdminR
    authR = AuthR
    masterHomeR = HomeR

    -- cache user roles to reduce the amount of DB calls
    getUserRoles userId = cachedBy cacheKey . fmap toRoleSet . runDB $ selectList [UserRoleUserId ==. userId] []
        where
            cacheKey = encodeUtf8 $ toPathPiece userId
            toRoleSet = S.fromList . map (userRoleRoleName . entityVal)

    setUserRoles userId rs = runDB $ do
        deleteWhere [UserRoleUserId ==. userId]
        mapM_ (insert_ . UserRole userId) $ S.toList rs

    adminMenu =  (defaultCoreAdminMenu CoreAdminR)
    renderLanguages _ = ["en", "nl"]

    mayAssignRoles = do
        authId <- requireAuthId
        roles <- getUserRoles authId
        return $ isAdmin roles

Add an isAdmin function to the bottom of Foundation.hs

Remember that the Admin role is defined in Roles.hs

isAdmin :: S.Set RoleName -> Bool
isAdmin = S.member Admin

Give it a try

LambdaCms should now be installed. You can give it a try.

yesod devel

Now point your browser to http://localhost:3000/admin and you will be prompted to login. The setup as described above has selected Mozilla's Persona as the only means of authentication. In config/settings.yml you have provided an email address for the admin user that is created if no users exist. If this email address is known to Mozilla Persona then you can procede to log in.