A Forum
Running on https://forum.kuljet.com
---
Basic user/session tables and helpers for handling users.
---
table users { user_id: text, username: text, password: password }
table sessions { session_id: text, user_id: text }
let user =
let lookupSession =
fun sessionId ->
let query =
natJoin users
sessions where session_id = sessionId
select { user_id, username }
in listHead (query -> { user_id, username })
in
"forumSessionId") lookupSession
bindMaybe (cookie
let showWhenLoggedIn =
fun content ->
fun _ -> content)
maybe user emptyHtml (
---
The HTML template for forum pages.
---
let template =
let newUserLinks =
<li> (<a> { href = "/users/new" } "Create User")
[ <li> (<a> { href = "/sessions/new" } "Log in")
,
] : htmlin
let userLinks =
fun user ->
let logOutForm =
<form> { method = "POST", action = "/sessions/delete" }
<input> { type = "submit", value = ("Log Out (" || user.username || ")") } ]
[ in
<li> logOutForm
in
let siteNav =
let navLinks =
<li> (<a> { href = "/" } "Home")
[
, maybe user newUserLinks userLinks
]in
<nav> (<ul> navLinks)
in
fun content ->
[ docType<html> [ <head> [ <link> { href = "/style.css", rel = "stylesheet" }
, <meta> { name = "viewport", content = "width=device-width, intial-scale=1" }
,
]<body> [ siteNav, <main> content ]
,
]
]
let permissionDenied =
"permission denied!" : response
---
Tables for forum posts.
---
table topics { topic_id: text, user_id: text, title: text, created: timestamp }
table posts { topic_id: text, post_id: text, user_id : text, body: text, created: timestamp }
---
The index shows a list of forum topics.
---
serve get / =
<a> { href = "/topics/new" } "Create new topic")
template [ showWhenLoggedIn ("Latest Topics"
, <h1> <ul> { class = "topics" }
, natJoin users order created desc select { topic_id, title, username, created } ->
(topics <li> [ <a> {href = "/topics/" || topic_id} title
<span> { class = "author" } (" by " || username || " ")
, <span> { class = "timestamp" } ("(" || relativeTime created || ")")
,
])
]
---
Creating a new topic.
---
serve get /topics/new =
template"Create New Topic"
[ <h1> <form> { method = "POST", action = "/topics/" }
, <input> { name = "title", placeholder = "Title", required = "true", autofocus = "true" }
[ <textarea> { name = "body", placeholder = "Body", required = "true" }
, <input> { type = "submit", value = "Create Topic" }
,
]
]
serve post /topics/ =
fun postData : { title : text, body : text } ->
let insertTopic =
fun user ->
as topic_id then
genUUID as post_id then
genUUID insert topics { topic_id, user_id = user.user_id, title = postData.title, created = now } then
insert posts { topic_id, post_id, user_id = user.user_id, body = postData.body, created = now } then
"/"
redirect in
maybe user (liftIO permissionDenied) insertTopic
---
Displaying a topic and creating new posts.
---
serve get /topics/:id =
where topic_id = id -> title)
template [ <h1> (topics <ul> { class = "posts" }
, natJoin users where topic_id = id ->
(posts <li> [ <span> { class = "content" } (commonMark body)
<footer> [ <span> { class = "author" } (" by " || username || " ")
, <span> { class = "timestamp" } ("(" || relativeTime created || ")")
,
]
])
, showWhenLoggedIn<form> { method = "POST", action = "/posts/", class = "mt-2" }
(<textarea> { name = "body", required = "true" }
[ <input> { name = "topic_id", type = "hidden", value = id }
, <div> { class = "small mb-1" } [ <a> { href = "https://commonmark.org/help" } "CommonMark", " formatting accepted." ]
, <input> { type = "submit", value = "Create Post" }
,
])
]
serve post /posts/ =
fun postData : { topic_id : text, body : text } ->
let insertPost =
fun user ->
as post_id then
genUUID insert posts { topic_id = postData.topic_id, post_id, user_id = user.user_id, body = postData.body, created = now } then
"/topics/" || postData.topic_id)
redirect (in
maybe user (liftIO permissionDenied) insertPost
---
User handling.
---
let createUserForm =
<form> { method = "POST", action = "/users/" }
<input> { name = "username"
[ "Username"
, placeholder = "ASCII characters (A-Z, a-z, 0-9) only - no spaces"
, title = "^[a-zA-Z0-9]+$"
, pattern = "true"
, required = "true"
, autofocus =
}<input> { name = "password", placeholder = "Password", type = "password", required = "true" }
, <input> { type = "submit", value = "Create User" }
,
]
serve get /users/new =
template createUserForm
---
User creation includes validation checks:
- Username is a reasonable length
- Username has not already been taken
---
serve post /users/ =
fun postData : { username : text, password : text } ->
let userExists =
where username = postData.username -> 1)) false (fun _ -> true)
maybe (listHead (users in
let validUsername =
"^[a-zA-Z0-9]+$" postData.username and
regexpMatch
textLength postData.username < 15in
let validUser =
and
validUsername and
textLength postData.password > 0
not userExistsin
let createUser =
as user_id then
genUUID insert users { user_id, username = postData.username, password = hashPassword postData.password } then
as session_id then
genUUID insert sessions { session_id, user_id } then
"/") "forumSessionId" session_id
addCookie (redirect in
if validUser
then createUser
else
let errors =
if userExists
then <p> "User Already Exists"
else if not validUsername
then <p> "Invalid username"
else emptyHtml
in
liftIO (template [ errors, createUserForm ] : response)
---
Session handling.
---
let loginForm =
<form> { method = "POST", action = "/sessions/" }
<input> { name = "username", placeholder = "Username", required = "true", autofocus = "true" }
[ <input> { name = "password", placeholder = "Password", type = "password", required = "true" }
, <input> { type = "submit", value = "Log in" }
,
]
serve get /sessions/new =
template loginForm
serve post /sessions/ =
fun postData : { username : text, password : text } ->
let userCreds =
where username = postData.username
listHead (users select { user_id, password } -> { user_id, password })
in
let badLogin =
<div> "Login failed!", loginForm] : response
template [in
let createSession =
fun user_id ->
as session_id then
randomBytes 20 insert sessions { session_id, user_id } then
"/") "forumSessionId" session_id
addCookie (redirect in
let login =
fun creds ->
if validatePassword postData.password creds.password
then createSession creds.user_id
else liftIO badLogin
in
maybe userCreds (liftIO badLogin) login
serve post /sessions/delete =
maybe user
"/"))
(liftIO (redirect fun user -> delete sessions where user_id = user.user_id then redirect "/")
(
---
Style for forums.
---
serve get /style.css =
"text/css" "style.css" file