Blog: i18n, authentication, authorization, and database

  1. This is a simple blog app. It allows an admin to add blog posts via a rich text
  2. editor (nicedit), allows logged-in users to comment, and has full i18n support.
  3. It is also a good example of using a Persistent database, leveraging Yesod's
  4. authorization system, and templates.
  5. While in general we recommend placing templates, Persist entity definitions,
  6. and routing in separate files, we'll keep it all in one file here for
  7. convenience. The one exception you'll see below will be i18n messages.
  8. We'll start off with our language extensions. In scaffolded code, the language
  9. extensions are specified in the cabal file, so you won't need to put this in
  10. your individual Haskell files.
  11. > {-# LANGUAGE OverloadedStrings, TypeFamilies, QuasiQuotes,
  12. > TemplateHaskell, GADTs, FlexibleContexts,
  13. > MultiParamTypeClasses #-}
  14. Now our imports.
  15. > import Yesod
  16. > import Yesod.Auth
  17. > import Yesod.Form.Nic (YesodNic, nicHtmlField)
  18. > import Yesod.Auth.BrowserId (authBrowserId)
  19. > import Data.Text (Text)
  20. > import Network.HTTP.Conduit (Manager, newManager, def)
  21. > import Database.Persist.Sqlite
  22. > ( ConnectionPool, SqlPersist, runSqlPool, runMigration
  23. > , createSqlitePool
  24. > )
  25. > import Data.Time (UTCTime, getCurrentTime)
  26. > import Control.Applicative ((<$>), (<*>), pure)
  27. First we'll set up our Persistent entities. We're going to both create our data
  28. types (via mkPersist) and create a migration function, which will automatically
  29. create and update our SQL schema. If you were using the MongoDB backend,
  30. migration would not be needed.
  31. > share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
  32. Keeps track of users. In a more robust application, we would also keep account
  33. creation date, display name, etc.
  34. > User
  35. > email Text
  36. > UniqueUser email
  37. An individual blog entry (I've avoided using the word "post" due to the
  38. confusion with the request method POST).
  39. > Entry
  40. > title Text
  41. > posted UTCTime
  42. > content Html
  43. We need to tack on this "deriving" line since Html doesn't specify instances
  44. for Read, Show or Eq. If you get an error message about "cannot derive" in your
  45. own code, try adding the deriving statement.
  46. > deriving
  47. And a comment on the blog post.
  48. > Comment
  49. > entry EntryId
  50. > posted UTCTime
  51. > user UserId
  52. > name Text
  53. > text Textarea
  54. > |]
  55. Every site has a foundation datatype. This value is initialized before
  56. launching your application, and is available throughout. We'll store a database
  57. connection pool and HTTP connection manager in ours. See the very end of this
  58. file for how those are initialized.
  59. > data Blog = Blog
  60. > { connPool :: ConnectionPool
  61. > , httpManager :: Manager
  62. > }
  63. To make i18n easy and translator friendly, we have a special file format for
  64. translated messages. There is a single file for each language, and each file is
  65. named based on the language code (e.g., en, es, de-DE) and placed in that
  66. folder. We also specify the main language file (here, "en") as a default
  67. language.
  68. > mkMessage "Blog" "../messages-blog" "en"
  69. Our en message file contains the following content:
  70. NotAnAdmin: You must be an administrator to access this page.
  71. WelcomeHomepage: Welcome to the homepage
  72. SeeArchive: See the archive
  73. NoEntries: There are no entries in the blog
  74. LoginToPost: Admins can login to post
  75. NewEntry: Post to blog
  76. NewEntryTitle: Title
  77. NewEntryContent: Content
  78. PleaseCorrectEntry: Your submitted entry had some errors, please correct and try again.
  79. EntryCreated title@Text: Your new blog post, #{title}, has been created
  80. EntryTitle title@Text: Blog post: #{title}
  81. CommentsHeading: Comments
  82. NoComments: There are no comments
  83. AddCommentHeading: Add a Comment
  84. LoginToComment: You must be logged in to comment
  85. AddCommentButton: Add comment
  86. CommentName: Your display name
  87. CommentText: Comment
  88. CommentAdded: Your comment has been added
  89. PleaseCorrectComment: Your submitted comment had some errors, please correct and try again.
  90. HomepageTitle: Yesod Blog Demo
  91. BlogArchiveTitle: Blog Archive
  92. Now we're going to set up our routing table. We have four entries: a homepage,
  93. an entry list page (BlogR), an individual entry page (EntryR) and our
  94. authentication subsite. Note that BlogR and EntryR both accept GET and POST
  95. methods. The POST methods are for adding a new blog post and adding a new
  96. comment, respectively.
  97. > mkYesod "Blog" [parseRoutes|
  98. > / RootR GET
  99. > /blog BlogR GET POST
  100. > /blog/#EntryId EntryR GET POST
  101. > /auth AuthR Auth getAuth
  102. > |]
  103. Every foundation needs to be an instance of the Yesod typeclass. This is where
  104. we configure various settings.
  105. > instance Yesod Blog where
  106. The base of our application. Note that in order to make BrowserID work
  107. properly, this must be a valid URL.
  108. > approot = ApprootStatic "http://localhost:3000"
  109. Our authorization scheme. We want to have the following rules:
  110. * Only admins can add a new entry.
  111. * Only logged in users can add a new comment.
  112. * All other pages can be accessed by anyone.
  113. We set up our routes in a RESTful way, where the actions that could make
  114. changes are always using a POST method. As a result, we can simply check for
  115. whether or not a request is a write request, given by the True in the second
  116. field.
  117. First, we'll authorize requests to add a new entry.
  118. > isAuthorized BlogR True = do
  119. > mauth <- maybeAuth
  120. > case mauth of
  121. > Nothing -> return AuthenticationRequired
  122. > Just (Entity _ user)
  123. > | isAdmin user -> return Authorized
  124. > | otherwise -> unauthorizedI MsgNotAnAdmin
  125. Now we'll authorize requests to add a new comment.
  126. > isAuthorized (EntryR _) True = do
  127. > mauth <- maybeAuth
  128. > case mauth of
  129. > Nothing -> return AuthenticationRequired
  130. > Just _ -> return Authorized
  131. And for all other requests, the result is always authorized.
  132. > isAuthorized _ _ = return Authorized
  133. Where a user should be redirected to if they get an AuthenticationRequired.
  134. > authRoute _ = Just (AuthR LoginR)
  135. This is where we define our site look-and-feel. The function is given the
  136. content for the individual page, and wraps it up with a standard template.
  137. > defaultLayout inside = do
  138. Yesod encourages the get-following-post pattern, where after a POST, the user
  139. is redirected to another page. In order to allow the POST page to give the user
  140. some kind of feedback, we have the getMessage and setMessage functions. It's a
  141. good idea to always check for pending messages in your defaultLayout function.
  142. > mmsg <- getMessage
  143. We use widgets to compose together HTML, CSS and Javascript. At the end of the
  144. day, we need to unwrap all of that into simple HTML. That's what the
  145. widgetToPageContent function is for. We're going to give it a widget consisting
  146. of the content we received from the individual page (inside), plus a standard
  147. CSS for all pages. We'll use the Lucius template language to create the latter.
  148. > pc <- widgetToPageContent $ do
  149. > toWidget [lucius|
  150. > body {
  151. > width: 760px;
  152. > margin: 1em auto;
  153. > font-family: sans-serif;
  154. > }
  155. > textarea {
  156. > width: 400px;
  157. > height: 200px;
  158. > }
  159. > #message {
  160. > color: #900;
  161. > }
  162. > |]
  163. > inside
  164. And finally we'll use a new Hamlet template to wrap up the individual
  165. components (title, head data and body data) into the final output.
  166. > hamletToRepHtml [hamlet|
  167. > $doctype 5
  168. > <html>
  169. > <head>
  170. > <title>#{pageTitle pc}
  171. > ^{pageHead pc}
  172. > <body>
  173. > $maybe msg <- mmsg
  174. > <div #message>#{msg}
  175. > ^{pageBody pc}
  176. > |]
  177. This is a simple function to check if a user is the admin. In a real
  178. application, we would likely store the admin bit in the database itself, or
  179. check with some external system. For now, I've just hard-coded my own email
  180. address.
  181. > isAdmin :: User -> Bool
  182. > isAdmin user = userEmail user == "[email protected]"
  183. In order to access the database, we need to create a YesodPersist instance,
  184. which says which backend we're using and how to run an action.
  185. > instance YesodPersist Blog where
  186. > type YesodPersistBackend Blog = SqlPersist
  187. > runDB f = do
  188. > master <- getYesod
  189. > let pool = connPool master
  190. > runSqlPool f pool
  191. This is a convenience synonym. It is defined automatically for you in the
  192. scaffolding.
  193. > type Form x = Html -> MForm Blog Blog (FormResult x, Widget)
  194. In order to use yesod-form and yesod-auth, we need an instance of RenderMessage
  195. for FormMessage. This allows us to control the i18n of individual form
  196. messages.
  197. > instance RenderMessage Blog FormMessage where
  198. > renderMessage _ _ = defaultFormMessage
  199. In order to use the built-in nic HTML editor, we need this instance. We just
  200. take the default values, which use a CDN-hosted version of Nic.
  201. > instance YesodNic Blog
  202. In order to use yesod-auth, we need a YesodAuth instance.
  203. > instance YesodAuth Blog where
  204. > type AuthId Blog = UserId
  205. > loginDest _ = RootR
  206. > logoutDest _ = RootR
  207. > authHttpManager = httpManager
  208. We'll use [BrowserID](https://browserid.org/), which is a third-party system
  209. using email addresses as your identifier. This makes it easy to switch to other
  210. systems in the future, locally authenticated email addresses (also included
  211. with yesod-auth).
  212. > authPlugins _ = [authBrowserId]
  213. This function takes someone's login credentials (i.e., his/her email address)
  214. and gives back a UserId.
  215. > getAuthId creds = do
  216. > let email = credsIdent creds
  217. > user = User email
  218. > res <- runDB $ insertBy user
  219. > return $ Just $ either entityKey id res
  220. Homepage handler. The one important detail here is our usage of `setTitleI`,
  221. which allows us to use i18n messages for the title. We also use this message
  222. with a `_{Msg...}` interpolation in Hamlet.
  223. > getRootR :: Handler RepHtml
  224. > getRootR = defaultLayout $ do
  225. > setTitleI MsgHomepageTitle
  226. > [whamlet|
  227. > <p>_{MsgWelcomeHomepage}
  228. > <p>
  229. > <a href=@{BlogR}>_{MsgSeeArchive}
  230. > |]
  231. Define a form for adding new entries. We want the user to provide the title and
  232. content, and then fill in the post date automatically via `getCurrentTime`.
  233. > entryForm :: Form Entry
  234. > entryForm = renderDivs $ Entry
  235. > <$> areq textField (fieldSettingsLabel MsgNewEntryTitle) Nothing
  236. > <*> aformM (liftIO getCurrentTime)
  237. > <*> areq nicHtmlField (fieldSettingsLabel MsgNewEntryContent) Nothing
  238. Get the list of all blog entries, and present an admin with a form to create a
  239. new entry.
  240. > getBlogR :: Handler RepHtml
  241. > getBlogR = do
  242. > muser <- maybeAuth
  243. > entries <- runDB $ selectList [] [Desc EntryPosted]
  244. > (entryWidget, enctype) <- generateFormPost entryForm
  245. > defaultLayout $ do
  246. > setTitleI MsgBlogArchiveTitle
  247. > [whamlet|
  248. > $if null entries
  249. > <p>_{MsgNoEntries}
  250. > $else
  251. > <ul>
  252. > $forall Entity entryId entry <- entries
  253. > <li>
  254. > <a href=@{EntryR entryId}>#{entryTitle entry}
  255. We have three possibilities: the user is logged in as an admin, the user is
  256. logged in and is not an admin, and the user is not logged in. In the first
  257. case, we should display the entry form. In the second, we'll do nothing. In the
  258. third, we'll provide a login link.
  259. > $maybe Entity _ user <- muser
  260. > $if isAdmin user
  261. > <form method=post enctype=#{enctype}>
  262. > ^{entryWidget}
  263. > <div>
  264. > <input type=submit value=_{MsgNewEntry}>
  265. > $nothing
  266. > <p>
  267. > <a href=@{AuthR LoginR}>_{MsgLoginToPost}
  268. > |]
  269. Process an incoming entry addition. We don't do any permissions checking, since
  270. isAuthorized handles it for us. If the form submission was valid, we add the
  271. entry to the database and redirect to the new entry. Otherwise, we ask the user
  272. to try again.
  273. > postBlogR :: Handler RepHtml
  274. > postBlogR = do
  275. > ((res, entryWidget), enctype) <- runFormPost entryForm
  276. > case res of
  277. > FormSuccess entry -> do
  278. > entryId <- runDB $ insert entry
  279. > setMessageI $ MsgEntryCreated $ entryTitle entry
  280. > redirect $ EntryR entryId
  281. > _ -> defaultLayout $ do
  282. > setTitleI MsgPleaseCorrectEntry
  283. > [whamlet|
  284. > <form method=post enctype=#{enctype}>
  285. > ^{entryWidget}
  286. > <div>
  287. > <input type=submit value=_{MsgNewEntry}>
  288. > |]
  289. A form for comments, very similar to our entryForm above. It takes the
  290. EntryId of the entry the comment is attached to. By using pure, we embed
  291. this value in the resulting Comment output, without having it appear in the
  292. generated HTML.
  293. > commentForm :: EntryId -> Form Comment
  294. > commentForm entryId = renderDivs $ Comment
  295. > <$> pure entryId
  296. > <*> aformM (liftIO getCurrentTime)
  297. > <*> aformM requireAuthId
  298. > <*> areq textField (fieldSettingsLabel MsgCommentName) Nothing
  299. > <*> areq textareaField (fieldSettingsLabel MsgCommentText) Nothing
  300. Show an individual entry, comments, and an add comment form if the user is
  301. logged in.
  302. > getEntryR :: EntryId -> Handler RepHtml
  303. > getEntryR entryId = do
  304. > (entry, comments) <- runDB $ do
  305. > entry <- get404 entryId
  306. > comments <- selectList [] [Asc CommentPosted]
  307. > return (entry, map entityVal comments)
  308. > muser <- maybeAuth
  309. > (commentWidget, enctype) <-
  310. > generateFormPost (commentForm entryId)
  311. > defaultLayout $ do
  312. > setTitleI $ MsgEntryTitle $ entryTitle entry
  313. > [whamlet|
  314. > <h1>#{entryTitle entry}
  315. > <article>#{entryContent entry}
  316. > <section .comments>
  317. > <h1>_{MsgCommentsHeading}
  318. > $if null comments
  319. > <p>_{MsgNoComments}
  320. > $else
  321. > $forall Comment _entry posted _user name text <- comments
  322. > <div .comment>
  323. > <span .by>#{name}
  324. > <span .at>#{show posted}
  325. > <div .content>#{text}
  326. > <section>
  327. > <h1>_{MsgAddCommentHeading}
  328. > $maybe _ <- muser
  329. > <form method=post enctype=#{enctype}>
  330. > ^{commentWidget}
  331. > <div>
  332. > <input type=submit value=_{MsgAddCommentButton}>
  333. > $nothing
  334. > <p>
  335. > <a href=@{AuthR LoginR}>_{MsgLoginToComment}
  336. > |]
  337. Receive an incoming comment submission.
  338. > postEntryR :: EntryId -> Handler RepHtml
  339. > postEntryR entryId = do
  340. > ((res, commentWidget), enctype) <-
  341. > runFormPost (commentForm entryId)
  342. > case res of
  343. > FormSuccess comment -> do
  344. > _ <- runDB $ insert comment
  345. > setMessageI MsgCommentAdded
  346. > redirect $ EntryR entryId
  347. > _ -> defaultLayout $ do
  348. > setTitleI MsgPleaseCorrectComment
  349. > [whamlet|
  350. > <form method=post enctype=#{enctype}>
  351. > ^{commentWidget}
  352. > <div>
  353. > <input type=submit value=_{MsgAddCommentButton}>
  354. > |]
  355. Finally our main function.
  356. > main :: IO ()
  357. > main = do
  358. > pool <- createSqlitePool "blog.db3" 10 -- create a new pool
  359. > -- perform any necessary migration
  360. > runSqlPool (runMigration migrateAll) pool
  361. > manager <- newManager def -- create a new HTTP manager
  362. > warpDebug 3000 $ Blog pool manager -- start our server