DevLearningTools

MODULE 18 · LESSON 06

Blog CMS

A complete blog content management system in ColdFusion: a posts table with a unique slug, a public list and post page, an admin login with a password hash and a rotated session, and an admin page to create, edit, publish, and delete posts, plus a security review.

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.

db/schema.sql
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.

Application.cfc
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;
    }
}
NOTE

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.

index.cfm
<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.

post.cfm
<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>
NOTE

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.

login.cfm
<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>
NOTE

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.

admin.cfm
<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>
NOTE

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

GapWhereWhat to add
No limit on failed login attemptslogin.cfmCount failures per IP and delay or block after a few, otherwise the password can be guessed
No CSRF token on admin actionsadmin.cfm formsIssue 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 onlypost.cfmIf authors need formatting, sanitize HTML on save with getSafeHTML() rather than trusting it
No audit trail of changesadmin.cfmLog who published, edited, or deleted each post, and when
Admin session has no expiry check on each requestadmin.cfmThe 30-minute session timeout ends idle sessions, but a fixed absolute lifetime is also worth adding
NOTE

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.