This is the last Practice Project, a small blog with a public side and an admin side. Visitors read published posts by their slug, and an admin logs in to write, publish, and delete them. Every code block is a complete file, so you can build the whole site from this page without another project.
Learning Objectives
After working through this project, you'll be able to:
- Store posts with a unique, URL-friendly slug generated from the title.
- Show only published posts to visitors, and return a real 404 for anything else.
- Protect an admin area with a password hash and a rotated session.
- Write, publish, and delete posts with POST requests and redirects.
- Render post bodies safely while keeping their line breaks.
How the Blog Fits Together
Posts Table
unique slug, is_published flag
Public Pages Read
index.cfm, post.cfm?slug=
Admin Logs In
login.cfm, passwordHashVerify, sessionRotate
Admin Writes Posts
admin.cfm, POST then redirect
Step 1: The Schema
The slug is UNIQUE, so two posts can't share a URL. is_published defaults to 0, which means a new post is a draft until it's explicitly published. This is SQL Server syntax.
CREATE TABLE posts (
id INT IDENTITY(1,1) PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL UNIQUE,
body NVARCHAR(MAX) NOT NULL,
is_published BIT NOT NULL DEFAULT 0,
created_at DATETIME2 NOT NULL DEFAULT SYSDATETIME()
);Step 2: The Application Settings
Application.cfc sets the datasource and the admin password hash once. The hash is produced by PasswordHashGenerate() one time, from a password you choose, and pasted here. The plain password never appears in code.
component {
this.name = "BlogCMS";
this.sessionManagement = true;
this.sessionTimeout = createTimeSpan(0, 0, 30, 0);
function onApplicationStart() {
application.datasource = "blogdb";
application.adminPasswordHash = "PASTE_HASH_FROM_PasswordHashGenerate_HERE";
return true;
}
}To create the hash, run passwordHashGenerate("your-password") once in the CFML REPL or a temporary page, copy the output into this line, then delete the temporary page.
Step 3: The Public List
index.cfm shows only published posts, newest first. The query filters on is_published, so a draft can't appear here even if its slug is known.
<cfset posts = queryExecute(
"SELECT title, slug, created_at FROM posts WHERE is_published = 1 ORDER BY created_at DESC",
{},
{ datasource: application.datasource, maxrows: 50 }
)>
<h1>Blog</h1>
<cfoutput query="posts">
<article>
<h2><a href="post.cfm?slug=#urlEncodedFormat(slug)#">#encodeForHTML(title)#</a></h2>
<p>#dateFormat(created_at, "yyyy-mm-dd")#</p>
</article>
</cfoutput>Step 4: The Post Page, With a Real 404
post.cfm loads one post by slug. A missing slug, or a draft, gets a 404 status code, not a 200 page that says "not found", so search engines and link checkers treat it correctly. The body keeps its line breaks, but the text itself is encoded first.
<cfparam name="url.slug" default="">
<cfset post = queryExecute(
"SELECT title, body, created_at FROM posts WHERE slug = :slug AND is_published = 1",
{ slug: { value: url.slug, cfsqltype: "cf_sql_varchar" } },
{ datasource: application.datasource }
)>
<cfif post.recordCount EQ 0>
<cfheader statuscode="404">
<h1>Post not found</h1>
<p><a href="index.cfm">Back to the blog</a></p>
<cfabort>
</cfif>
<cfoutput>
<h1>#encodeForHTML(post.title)#</h1>
<p>#dateFormat(post.created_at, "yyyy-mm-dd")#</p>
<div>#replace(encodeForHTML(post.body), chr(10), "<br>", "all")#</div>
</cfoutput>The body is encoded before the line breaks are turned into <br> tags, so the only HTML in the output is the tags this code adds. Allowing authors to write their own HTML would need a sanitizer such as getSafeHTML(), see the XSS Prevention lesson.
Step 5: Admin Login
login.cfm checks the password against the stored hash and rotates the session before marking it as an admin session. A failed login gets one generic message, the same whether the password was wrong or missing.
<cfparam name="form.password" default="">
<cfif cgi.request_method EQ "POST">
<cfif len(form.password) AND passwordHashVerify(form.password, application.adminPasswordHash)>
<cfset sessionRotate()>
<cfset session.isAdmin = true>
<cflocation url="admin.cfm" addtoken="false">
</cfif>
<cfset loginError = "Incorrect password.">
</cfif>
<h1>Admin login</h1>
<cfif structKeyExists(variables, "loginError")>
<cfoutput><p>#encodeForHTML(loginError)#</p></cfoutput>
</cfif>
<form method="post" action="login.cfm">
<input type="password" name="password">
<button type="submit">Log in</button>
</form>sessionRotate() runs before the admin flag is set, so the authenticated session never reuses an ID an attacker could have planted. The Secure Sessions lesson covers why the order matters.
Step 6: The Admin Page
admin.cfm does everything an author needs: it lists all posts including drafts, creates and edits them, toggles publishing, and deletes. Every change is a POST, followed by a redirect back to this page. The slug is generated from the title, and a number is added if the slug is already taken.
<cfif NOT (structKeyExists(session, "isAdmin") AND session.isAdmin)>
<cflocation url="login.cfm" addtoken="false">
</cfif>
<cfparam name="form.action" default="">
<cfparam name="form.id" default="0">
<cfparam name="form.title" default="">
<cfparam name="form.body" default="">
<cfif cgi.request_method EQ "POST">
<cfset postId = val(form.id)>
<cfif form.action EQ "delete" AND postId GT 0>
<cfset queryExecute("DELETE FROM posts WHERE id = :id",
{ id: { value: postId, cfsqltype: "cf_sql_integer" } },
{ datasource: application.datasource })>
<cfelseif form.action EQ "toggle" AND postId GT 0>
<cfset queryExecute("UPDATE posts SET is_published = CASE WHEN is_published = 1 THEN 0 ELSE 1 END WHERE id = :id",
{ id: { value: postId, cfsqltype: "cf_sql_integer" } },
{ datasource: application.datasource })>
<cfelseif form.action EQ "save" AND len(trim(form.title)) AND len(trim(form.body))>
<cfset baseSlug = lCase(reReplace(trim(form.title), "[^a-zA-Z0-9]+", "-", "all"))>
<cfset baseSlug = reReplace(baseSlug, "^-+|-+$", "", "all")>
<cfset slug = baseSlug>
<cfset n = 2>
<cfloop condition="true">
<cfset clash = queryExecute("SELECT id FROM posts WHERE slug = :slug AND id <> :id",
{ slug: { value: slug, cfsqltype: "cf_sql_varchar" },
id: { value: postId, cfsqltype: "cf_sql_integer" } },
{ datasource: application.datasource })>
<cfif clash.recordCount EQ 0><cfbreak></cfif>
<cfset slug = baseSlug & "-" & n>
<cfset n = n + 1>
</cfloop>
<cfif postId GT 0>
<cfset queryExecute("UPDATE posts SET title = :t, slug = :s, body = :b WHERE id = :id",
{ t: { value: left(trim(form.title), 200), cfsqltype: "cf_sql_varchar" },
s: { value: slug, cfsqltype: "cf_sql_varchar" },
b: { value: form.body, cfsqltype: "cf_sql_longvarchar" },
id: { value: postId, cfsqltype: "cf_sql_integer" } },
{ datasource: application.datasource })>
<cfelse>
<cfset queryExecute("INSERT INTO posts (title, slug, body) VALUES (:t, :s, :b)",
{ t: { value: left(trim(form.title), 200), cfsqltype: "cf_sql_varchar" },
s: { value: slug, cfsqltype: "cf_sql_varchar" },
b: { value: form.body, cfsqltype: "cf_sql_longvarchar" } },
{ datasource: application.datasource })>
</cfif>
</cfif>
<cflocation url="admin.cfm" addtoken="false">
</cfif>
<cfset allPosts = queryExecute(
"SELECT id, title, slug, is_published FROM posts ORDER BY created_at DESC",
{},
{ datasource: application.datasource, maxrows: 200 }
)>
<h1>Posts</h1>
<cfoutput query="allPosts">
<div>
#encodeForHTML(title)# (<cfif is_published>published<cfelse>draft</cfif>)
<form method="post" action="admin.cfm" style="display:inline">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="#id#">
<button type="submit"><cfif is_published>Unpublish<cfelse>Publish</cfif></button>
</form>
<form method="post" action="admin.cfm" style="display:inline"
onsubmit="return confirm('Delete this post?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="#id#">
<button type="submit">Delete</button>
</form>
</div>
</cfoutput>
<h2>New post</h2>
<form method="post" action="admin.cfm">
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" value="0">
<input type="text" name="title" maxlength="200" required>
<textarea name="body" required></textarea>
<button type="submit">Save</button>
</form>Publishing is a toggle on is_published, so a post can be taken down again without being deleted. The toggle uses a CASE expression rather than arithmetic, because arithmetic on a SQL Server BIT column isn't a dependable pattern.
Security Review: What This Blog Still Lacks
| Gap | Where | What to add |
|---|---|---|
| No limit on failed login attempts | login.cfm | Count failures per IP and delay or block after a few, otherwise the password can be guessed |
| No CSRF token on admin actions | admin.cfm forms | Issue a per-session token in each form and check it on POST, so another site can't publish or delete for a logged-in admin |
| Post bodies are plain text only | post.cfm | If authors need formatting, sanitize HTML on save with getSafeHTML() rather than trusting it |
| No audit trail of changes | admin.cfm | Log who published, edited, or deleted each post, and when |
| Admin session has no expiry check on each request | admin.cfm | The 30-minute session timeout ends idle sessions, but a fixed absolute lifetime is also worth adding |
The blog already does the things that are easy to miss: drafts never reach the public pages, the admin password is stored only as a hash, the session rotates on login, and every query takes its values as parameters.
Common Beginner Mistakes
Filtering drafts only on the admin side
If the public page doesn't filter on is_published, anyone who guesses a draft's slug can read it. Filter every public query.
Returning a 200 page for a missing post
Search engines then index the "not found" text as a real page. Return a 404 status code.
Storing the admin password in code
Anyone who reads the code has the password. Store only the hash, and generate it once with passwordHashGenerate().
Interview Questions
Why is a slug unique, and what happens when two titles produce the same slug?
A slug is the URL for the post, so two posts can't share one. The page appends a number to the second title's slug, and the UNIQUE constraint in the database enforces it.
Why return a 404 status code for a draft instead of a page saying it's private?
A private-page message confirms that a draft exists at that slug. A 404 reveals nothing, the same as a slug that never existed.
Why rotate the session on admin login?
So the admin session gets a new ID that an attacker couldn't have planted or known beforehand.
Summary
You built a complete blog: a schema with a unique slug and a draft flag, a public list and post page that show only published posts and return a real 404, an admin login backed by a password hash and a rotated session, and an admin page that creates, edits, publishes, and deletes posts with POST requests. The security review lists the remaining work, login throttling, CSRF tokens, and an audit trail.
What's Next?
All Module 18 Practice Projects are now complete. The course has no further modules, so the next step is building something of your own with the same patterns.