PHP Membership Provider

I came across a post made a little while ago by Steven Benner on PHP’s notable lack of a consistent Membership Provider as in ASP.Net. All good points, but I think the hard bit isn’t so much session management since there are a million examples out there on how to provide a login interface.

The tough part seems to be a consistent method of accessing, creating and maintaining users in a database. Also a lot of those examples tend to be MySQL specific so if you need something easily customized for something else, like say PostgreSQL, you’re pretty much stuck customizing to no end, at which point you might as well write your own.

And that seems to be what an overwhelming majority of people are doing if they’re not getting an off-the-shelf CMS to start with. Of the few frameworks for membership management that are available out there, most are a bit over complicated and have licenses, fees and other strings attached.

So most people are stuck writing a user management system from scratch or getting a CMS off the shelf with its own unique (and usually incompatible) user management system.

I got home from work early on Friday and have this weekend off for a pretty good rest, so early this morning, I thought I’d write up a Membership Provider for PHP that anyone can use with no strings attached (and hopefully doesn’t suck too much). The idea is to have a simple drop-in user management interface.

Keep in mind that I haven’t actually tested any of this and it all may in fact blow up spectacularly (since I didn’t do any testing, everything is as-is and I’m not a professional PHP programmer). It’s a bit late for Steven’s post, but better late than never eh? ;)

AND YOU NEED TO VALIDATE ALL INPUTS BEFORE SENDING THEM… ahem.

I didn’t want to mimic everything in the Membership Provider for ASP.Net in that there are a lot of things that I don’t like, some features that could be improved and others that are completely missing.

Notably, I don’t see the need for password questions and answers since resetting by email is often a better solution than lowering the security barrier by introducing (essentially) a weaker password in the form of an answer to a known question. Also, people often forget the answers to questions too or repeat the same questions and answers on different sites, which defeats the purpose of a strong password anyway.

I’d also like to have a way to find and delete users by either Id, Name or Email and easily lock/unlock, approve/unapprove users and provide strong passwords with Blowfish. For this last option, I downloaded the Portable PHP password hashing framework, which I think is the best implementation for PHP I’ve found so far.

Now I need a table…

Although this example table is in MySQL, you could modify this for any other database. In the queries, I avoided any MySQL specific commands like sql_calc_found_rows and the like and because I’m using PDO the code itself should work with no modifications on PostgreSQL and SQLite provided it’s the same table structure.

CREATE TABLE `users` (
  `userId` int(20) unsigned NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL,
  `hash` varchar(10) NOT NULL,
  `password` varchar(200) NOT NULL,
  `passwordSalt` varchar(100) NOT NULL,
  `email` varchar(100) NOT NULL,
  `avatar` varchar(200) DEFAULT NULL,
  `createdDate` datetime NOT NULL,
  `modifiedDate` datetime NOT NULL,
  `lastActivity` datetime NOT NULL,
  `bio` text DEFAULT NULL,
  `isApproved` tinyint(1) NOT NULL DEFAULT '1',
  `isLocked` tinyint(1) NOT NULL DEFAULT '0',

  PRIMARY KEY (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Users' AUTO_INCREMENT=1 ;

Pretty typical for most websites. The “hash” there is just a unique key made by combining the username and email in a hash that will set apart users even with similar looking usernames. Think of it as a very primitive Tripcode shown alongside a username.

Configuration

If there’s one thing that makes configuring an ASP.Net web page consistent is the web.config file. It’s only appropriate then to put all the configuation stuff into one file, say a .ini, like so :

;*** Website configuration file ***
;
; All comments start with a semicolon (;), and this file can be something other than config.ini
; as long as the new file name is specified in index.php .

;*** Globally accessible settings ***

[Database]
dsn = "mysql:host=localhost;dbname=testdb"
username = "testuser"
password = "BKjSheQubKEufqHC"

[Tables]
usersTable = "users"
rolesTable = "roles"
tablePrefix = ""

;*** Membership specific settings ***

[MembershipProvider]
minRequiredPasswordLength = 5
maxInvalidPasswordAttempts = 3
passwordAttemptWindow = 10
userIsOnlineTimeWindow = 20
autoApproveUsers = "true"

Of course, for this example, we won’t be using all of that, but I just created a file called config.ini and put all that in there. Now how do we load all of this? First we need an index.php file.

index.php

<?php
ini_set( "display_errors", true );

// Set the application configuration file. You may store this outside the web root
// if your web host allows it.
define('INI', 'config.ini');


/*******************************

	End editing

********************************/


define('BASE_PATH', dirname(__FILE__));

// Platform specific include path separator
if ( strtolower(substr(PHP_OS, 0, 3)) === 'win') 
	define('SEP', ';');
else
	define('SEP', ':');

// There must be an /app folder in root with all the class files
define('CLASS_DIR', BASE_PATH . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR);

// Optionally there should also be a /mod folder in root where you can include modules/plugins in the future
define('MODULE_DIR', BASE_PATH . DIRECTORY_SEPARATOR . 'mod' . DIRECTORY_SEPARATOR);

// Modify include path
set_include_path( CLASS_DIR  . SEP . MODULE_DIR );


spl_autoload_extensions(".php");
spl_autoload_register();

?>

The down side to this approach is that all your file names must be lowercase, but it should be slightly faster than any other autoload implementation and I’m reasonably confident it should run on a *nix platform with no hiccups as well as on Windows.

For now, that autoload would force the application to look in the /app folder where all the magic happens and optionally in /mod for any future modules. And for that magic we’ll create a base class that all “action” classes would inherit from. This is basically to provide dynamic class properties.

<?php

/**
 * Base class provides dynamic class property functionality
 *
 * @package Base
 */
class Base {
	
	
	/**
	 * @var array Config properties storage
	 */
	protected $props = array();
	
	
	/**
	 * Get class accessible settings
	 */
	protected function __get( $key ) {
		return array_key_exists( $key, $this->props ) ? 
			$this->props[$key] : null;
	}
	
	
	/**
	 * Set class accessible settings
	 */
	protected function __set( $key, $value ) {
		$this->props[$key] = $value;
	}
	
	
	/**
	 * Checks whether accessible setting is available
	 */
	protected function __isset( $key ) {
		return isset( $this->props[$key] );
	}
}
?>

Now we need a configuration class that will load the settings from the defined .ini file into itself and make the settings accessible to all classes calling it.

<?php

/**
 * Application configuration file.
 * All settings are imported from the .ini file defined in index.php
 *
 * @package Config
 */

class Config extends Base {
	
	
	/**
	 * Class instance
	 */
	private static $instance;
	
	
	private function __construct() {
		$this->props = parse_ini_file(INI, true);
	}
	
	
	public static function getInstance() {
		if(!self::$instance)
			self::$instance = new Config();
		
		return self::$instance;
	}
}
?>

This is starting to get a bit long so onward to the next page

Simple CMS with Linq to SQL part II-a

This isn’t a new post per-se, but a few refinements to our existing project, including added comments in the new classes, before proceeding further. I pretty much rushed through to getting content adding, editing deleting functionality so let’s see how much we can smooth the edges before moving on…

First, I added a simple code file called Summaries.cs to the Models folder with the following…

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SimpleCMS.Models;

namespace SimpleCMS.Models
{
	public class PageSummary : ContentPage { }

	public class CommentSummary : ContentComment { }
}

All this does is allow us to create an anonymous type wrapper for use in page and content summaries without getting all the associated fields in the object. It’s a performance thing.

Next, the modified PageView class in the Models folder.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SimpleCMS.Helpers;

namespace SimpleCMS.Models
{
    public class PageView
    {
		/// <summary>
		/// Current viewing page
		/// </summary>
		public ContentPage Page { get; set; }

		/// <summary>
		/// Paged index list of pages
		/// </summary>
		public PagedList<ContentPage> Pages { get; set; }

		/// <summary>
		/// Paged index list of pages
		/// </summary>
		public PagedList<PageSummary> PageSummary { get; set; }


		/// <summary>
		/// Paged list of comments in the current page
		/// </summary>
		public PagedList<ContentComment> Comments { get; set; }

		/// <summary>
		/// Paged list of comments in the current page
		/// </summary>
		public PagedList<CommentSummary> CommentSummary { get; set; }

		/// <summary>
		/// This is for breadcrumb navigation use
		/// </summary>
		public Dictionary<int, string> Parents { get; set; }

		/// <summary>
		/// Constructor
		/// </summary>
		public PageView() { }

		/// <summary>
		/// Plain view of a content page
		/// </summary>
		/// <param name="_page">Current content page</param>
		public PageView(ContentPage _page)
		{
			Page = _page;
		}

		/// <summary>
		/// Plain view with comments
		/// </summary>
		/// <param name="_page">Current content page</param>
		/// <param name="_comments">List of commments in the current page</param>
		public PageView(ContentPage _page, PagedList<ContentComment> _comments)
		{
			Page = _page;
			Comments = _comments;
		}

		/// <summary>
		/// If no comments or sub pages are present, then this is an index view
		/// </summary>
		/// <param name="_pages">List of pages in current index</param>
		public PageView(PagedList<ContentPage> _pages)
		{
			Pages = _pages;
		}

		/// <summary>
		/// Full content page view with sub page summaries
		/// </summary>
		/// <param name="_page">Current content page</param>
		/// <param name="_pages">Paged list of sub pages</param>
		/// <param name="_comments">Paged list of comments</param>
		public PageView(ContentPage _page, PagedList<PageSummary> _pages, 
			PagedList<ContentComment> _comments)
		{
			Page = _page;
			PageSummary = _pages;
			Comments = _comments;
		}

		/// <summary>
		/// Full content page view with expanded pages
		/// </summary>
		/// <param name="_page">Current content page</param>
		/// <param name="_pages">Paged list of sub pages</param>
		/// <param name="_comments">Paged list of comments</param>
		public PageView(ContentPage _page, PagedList<ContentPage> _pages, 
			PagedList<ContentComment> _comments)
		{
			Page = _page;
			Pages = _pages;
			Comments = _comments;
		}

		/// <summary>
		/// Checks if this view has a page to display. If not, then 
		/// it's probably a main index.
		/// </summary>
		public bool HasPage {
			get { return (Page != null); }
		}
    }
}

Our new PagesController

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SimpleCMS.Models;
using SimpleCMS.Helpers;
using MarkdownSharp;

namespace SimpleCMS.Controllers
{
	public class PagesController : Controller
	{
		/// <summary>
		/// Page index view. Lists the pages under the given parent Id
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="page">Current page index</param>
		/// <returns>Page view</returns>
		public ActionResult Index(int? id, int? page)
		{
			id = id ?? 0;
			page = page ?? 1;

			ContentPage content = new ContentPage();
			PagedList<ContentPage> pages;

			using (SimpleCMSDataContext db = new SimpleCMSDataContext())
			{
				// Get the published pages
				pages = (from p in db.ContentPages
						 where p.ParentId == id.Value
						 orderby p.PubDate descending
						 select p).ToPagedList(page.Value, 10);

				if (id > 0)
				{
					content = (from p in db.ContentPages
							   where p.PageId == id.Value
							   select p).Single();
				}


			}

			PageView index = new PageView(pages);

			if (content.PageId > 0)
				index.Page = content;

			ViewData.Model = index;
			return View();
		}

		/// <summary>
		/// Basically the Details view
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="page">Current page index (for comments)</param>
		/// <returns>Read view or the index if no Id was found</returns>
		public ActionResult Read(int? id, int? page)
		{
			// Page Id
			id = id ?? 0;
			page = page ?? 1;

			if (id.Value > 0)
			{
				ContentPage content;
				PagedList<ContentComment> comments;

				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					content = (from p in db.ContentPages
							   where p.PageId == id.Value
							   select p).Single();

					// Increment view count
					content.ViewCount = Util.DefaultInt(content.ViewCount, 0) + 1;

					comments = (from c in db.ContentComments
								where c.PageId == id.Value
								orderby c.CreatedDate ascending
								select c).ToPagedList(page.Value, 20);

					db.SubmitChanges();
				}

				ViewData.Model = new PageView(content, comments);
				return View();
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Creates a new page under the given parent Id
		/// </summary>
		/// <param name="id">Parent Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns></returns>
		[HttpPost]
		public ActionResult Create(int? id, FormCollection collection)
		{
			// Parent Id
			id = id ?? 0;

			int newpageid = 0;

			ContentPage content;
			using (SimpleCMSDataContext db = new SimpleCMSDataContext())
			{
				// Gets the page data from form
				content = GetPageFromCollection(id.Value, collection, true);

				// Insert the new page
				db.ContentPages.InsertOnSubmit(content);

				if (id.Value > 0)
				{
					// Update parent page count if this page has a parent
					ContentPage parent = (from p in db.ContentPages
										  where p.PageId == id.Value
										  select p).Single();

					parent.PageCount = (from p in db.ContentPages
										where p.ParentId == id
										select p.PageId).Count() + 1;
				}

				// Save the page
				db.SubmitChanges();

				// Store the new Id for redirect
				newpageid = content.PageId;
			}

			// We have an Id
			if (newpageid > 0)
				return RedirectToAction("Read", new { id = newpageid });
			else
				return RedirectToAction("Index");
		}

		/// <summary>
		/// Edits a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <returns>Page edit view or the index if no Id is found</returns>
		public ActionResult Edit(int? id)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					ContentPage content = (from p in db.ContentPages
										   where p.PageId == id.Value
										   select p).Single();

					ViewData.Model = content;
					return View();
				}
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Edits a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns>Page read view</returns>
		[HttpPost]
		public ActionResult Edit(int? id, FormCollection collection)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					ContentPage content = (from p in db.ContentPages
										   where p.PageId == id.Value
										   select p).Single();

					ContentPage edited = GetPageFromCollection(id.Value, collection, false);

					// Basics
					content.Title = edited.Title;
					content.Description = edited.Description;
					content.AbstractText = edited.AbstractText;
					content.AbstractHtml = edited.AbstractHtml;
					content.BodyHtml = edited.BodyHtml;
					content.BodyText = edited.BodyText;

					//Edited date
					content.LastModified = edited.LastModified;

					// Moderation
					content.Approved = edited.Approved;
					content.AnonComments = edited.AnonComments;
					content.Moderated = edited.Moderated;

					db.SubmitChanges();
				}

				return RedirectToAction("Read", new { id = id });
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Deletes a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <returns>Delete page view or the index if no id is given</returns>
		public ActionResult Delete(int? id)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				ContentPage page;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					page = (from p in db.ContentPages
							where p.PageId == id.Value
							select p).Single();
				}
				ViewData.Model = page;
				return View();
			}
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Deletes a given page
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns>Redirects to the page index</returns>
		[HttpPost]
		public ActionResult Delete(int? id, FormCollection collection)
		{
			id = id ?? 0;
			if (id.Value > 0)
			{
				try
				{
					using (SimpleCMSDataContext db = new SimpleCMSDataContext())
					{
						ContentPage page = (from p in db.ContentPages
											where p.PageId == id
											select p).Single();

						db.ContentPages.DeleteOnSubmit(page);
						db.SubmitChanges();
					}
				}
				catch { }
			}
			return RedirectToAction("Index");
		}


		/// <summary>
		/// Creates a page comment under the given page Id
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form input data</param>
		/// <returns>Read page view</returns>
		[HttpPost]
		public ActionResult CreateComment(int? id, FormCollection collection)
		{
			// Page Id
			id = id ?? 0;

			if (id.Value > 0)
			{
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					// Get our page
					ContentPage content = (from p in db.ContentPages
										   where p.PageId == id
										   select p).Single();

					// If comments are enabled
					if (Util.DefaultBool(content.EnableComments, true))
					{
						// Create comment from the form data
						ContentComment comment =
							GetCommentFromCollection(id.Value, collection, true);

						// If the moderation is enabled, we need to 
						// disapprove this post first
						if(Util.DefaultBool(content.Moderated, false))
							comment.Approved = false;

						// Insert the new comment
						db.ContentComments.InsertOnSubmit(comment);

						// Update the comment count
						content.CommentCount = Util.DefaultInt(content.CommentCount, 0) + 1;

						db.SubmitChanges();
					}
				}
			}

			// There was no page or something went wrong
			if (id == 0)
				return RedirectToAction("Index");

			return RedirectToAction("Read", new { id = id.Value });
		}

		/// <summary>
		/// Edits a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <returns>Edit view</returns>
		public ActionResult EditComment(int? id)
		{
			// Comment Id
			id = id ?? 0;

			// Stores the page Id
			int pageid = 0;

			if (id.Value > 0)
			{
				ContentComment comment;
				ContentPage contentpage;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					// Get the comment to be edited
					comment = (from c in db.ContentComments
							   where c.CommentId == id.Value
							   select c).Single();

					// Get the contnt page for this comment 
					// (we need some elements of this to show the comment)
					contentpage = (from p in db.ContentPages
								   where p.PageId == comment.PageId
								   select p).Single();

					// Attach the page to the comment
					comment.ContentPage = contentpage;

					pageid = comment.PageId;

					ViewData.Model = comment;
					return View();
				}
			}

			// There was no page or something went wrong
			if (pageid == 0)
				return RedirectToAction("Index");

			return RedirectToAction("Read", new { id = pageid });
		}


		/// <summary>
		/// Edits a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <param name="collection">Form data input</param>
		/// <returns>Page read view</returns>
		[HttpPost]
		public ActionResult EditComment(int? id, FormCollection collection)
		{
			// Comment Id
			id = id ?? 0;

			// Stores the page Id
			int pageid = 0;

			if (id.Value > 0)
			{
				ContentComment comment;
				ContentPage contentpage;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					comment = (from c in db.ContentComments
							   where c.CommentId == id.Value
							   select c).Single();

					contentpage = (from p in db.ContentPages
								   where p.PageId == comment.PageId
								   select p).Single();

					ContentComment edited = GetCommentFromCollection(id.Value, collection, false);

					comment.Approved = edited.Approved;
					comment.Author = edited.Author;
					comment.AuthorEmail = edited.AuthorEmail;

					comment.BodyHtml = edited.BodyHtml;
					comment.BodyText = edited.BodyText;

					// Important to save changes here as the next step alters the model.
					db.SubmitChanges();

					comment.ContentPage = contentpage;

					pageid = comment.PageId;

					ViewData.Model = comment;
				}
			}

			// There was no page or something went wrong
			if (pageid == 0)
				return RedirectToAction("Index");

			return RedirectToAction("Read", new { id = pageid });
		}

		/// <summary>
		/// Deletes a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <returns>Delete comment view</returns>
		public ActionResult DeleteComment(int? id)
		{
			int pageid = 0;
			if (id.HasValue)
			{
				ContentComment comment;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					comment = (from c in db.ContentComments
							   where c.CommentId == id.Value
							   select c).Single();

					pageid = comment.PageId;
				}
				ViewData.Model = comment;
				return View();
			}
			return RedirectToAction("Read", new { id = pageid });
		}

		/// <summary>
		/// Deletes a given comment
		/// </summary>
		/// <param name="id">Comment Id</param>
		/// <param name="collection">Form input data</param>
		/// <returns>Back to read page index</returns>
		[HttpPost]
		public ActionResult DeleteComment(int id, FormCollection collection)
		{
			try
			{
				int pageid = 0;
				using (SimpleCMSDataContext db = new SimpleCMSDataContext())
				{
					ContentComment comment = (from c in db.ContentComments
											  where c.CommentId == id
											  select c).Single();

					pageid = comment.PageId;
					db.ContentComments.DeleteOnSubmit(comment);
					db.SubmitChanges();
				}
				return RedirectToAction("Read", new { id = pageid });
			}
			catch { }
			return RedirectToAction("Index");
		}

		/// <summary>
		/// Gets a ContentPage object from the given form data
		/// </summary>
		/// <param name="id">Parent Id</param>
		/// <param name="collection">Form data input</param>
		/// <param name="newpage">True if creating a new page, false if editing</param>
		/// <returns>New ContentPage</returns>
		private ContentPage GetPageFromCollection(int id, FormCollection collection,
			bool newpage)
		{
			// Setup markdown
			Markdown m = new Markdown();

			// New page
			ContentPage content = new ContentPage();

			// Trigger date
			DateTime dt = DateTime.Now;

			// Basics
			content.ParentId = id;
			content.Title = Util.DefaultString(collection["title"], "No title");
			content.Description = Util.DefaultString(collection["description"], "");

			if (newpage)
			{
				content.Author = Util.DefaultString(collection["author"], "Anonymous"); // Just for now
				content.AuthorId = 0; // Just for now
			}

			// Page content
			content.AbstractText = Util.DefaultString(collection["abstracttext"], "");
			content.AbstractHtml = m.Transform(content.AbstractText);

			content.BodyText = Util.DefaultString(collection["bodytext"], "");
			content.BodyHtml = m.Transform(content.BodyText);


			// Eenable publishing
			content.Approved = Util.DefaultBool(collection["approved"], true);

			if (newpage)
			{
				// Nested
				content.ViewCount = 0;
				content.CommentCount = 0;
				content.PageCount = 0;

				// Times
				content.CreatedDate = dt;
			}

			// Pubdate
			content.PubDate = Util.DefaultDate(collection["pubdate"], dt);

			// Every time this function is called, we're doing something to the page.
			content.LastModified = dt;

			// Feedback
			content.EnableComments = Util.DefaultBool(collection["enablecomments"], true);
			content.AnonComments = Util.DefaultBool(collection["anoncomments"], true);
			content.Moderated = Util.DefaultBool(collection["moderated"], false);

			return content;
		}

		/// <summary>
		/// Gets a ContentComment object from the given form data
		/// </summary>
		/// <param name="id">Page Id</param>
		/// <param name="collection">Form data input</param>
		/// <param name="newpage">True if creating a new comment, false if editing</param>
		/// <returns>New ContentComment</returns>
		private ContentComment GetCommentFromCollection(int id, FormCollection collection,
			bool newcomment)
		{
			// Setup markdown
			Markdown m = new Markdown();

			// New comment
			ContentComment comment = new ContentComment();

			// Trigger date
			DateTime dt = DateTime.Now;


			// Author name
			comment.Author = Util.DefaultString(collection["author"], "Anonymous");

			if (newcomment)
			{
				// Basics
				comment.PageId = id;
				comment.AuthorId = 0; // Just for now
				comment.AuthorIP = Util.GetUserIP();
				comment.CreatedDate = dt;
			}

			comment.LastModified = dt;

			// We don't have an approval yet
			comment.Approved = true;
			comment.AuthorEmail = Util.DefaultString(collection["authoremail"], "");

			// Content
			comment.BodyText = Util.DefaultString(collection["bodytext"], "");
			comment.BodyHtml = m.Transform(comment.BodyText);

			return comment;
		}
	}
}

Onward to page 2…

Simple CMS with Linq to SQL part II

In our last installment, we looked at the basics of creating and deleting pages and comments. Now we’re going to dive into the views and editing portion including listing pages and comments in index (list) form and paging.

First, we can compile our project to make sure there no compile errors. If all went well, and if you used the mod portal modifications, you will be greeted with the following :

Welcome to your project

We’ll worry about the front page later. First let’s go into our PagesController and add a few views. I don’t want limit our flexibility with multiple full page views, so we’ll only have them for the Index, Read, Edit, Delete, EditComment and finally DeleteComment views. The rest will be strongly typed partial views (ViewUserControls).

Remember that the last time we didn’t include any programming to accomplish editing, but don’t worry. It’s fairly simple.

First, let’s create a control to display an index list of paged… er… content pages. You can create an empty control first called PageList, but we’ll need to modify the Inherits flag to match our PagedList class so we can access that model.

<%@ Control Language="C#" 
Inherits="System.Web.Mvc.ViewUserControl<PagedList<SimpleCMS.Models.ContentPage>>" %>
    <% foreach (var item in Model) { %>
    <div class="post">
           <h4><%: Html.ActionLink(item.Title, "Read", new { id = item.PageId }, 
                	new { title = Html.AltTitle(item.Title, item.Description) }) %></h4>

			<p class="data"><span><%: Html.FormalDate(item.PubDate) %> | 
			by <%: Html.AuthorLink(item.Author, item.AuthorId) %>.</span> 
			<span><%: Html.EditedDate(item.PubDate, item.LastModified) %>
			<%: Html.ActionLink("Edit", "Edit", new { id=item.PageId }) %> | 
			<%: Html.ActionLink("Delete", "Delete", new { id=item.PageId })%> | 
			<%: Html.CommentsLink(item.PageId, item.CommentCount) %> | 
			Viewed <%: item.ViewCount %> times</span></p>

                <% if (!String.IsNullOrEmpty(item.Abstract)) { %>
				<%= item.Abstract %>
				<%  } else { %>
				<%= item.BodyHtml %><% } %>			
    </div>
    <% } %>

Notice that there are multiple inheritences/implementations here. Our PagedList implements ContentPage, since that’s what we’re displaying in this control. Also note that we’re making use of a couple of helper functions, EditedDate and CommentsLink in the MvcHelpers class we created the last time.

Now we can view our pages, let’s also make another control that allows us to create them… Note, this has to be a strongly typed control called CreatePage that uses the ContentPage model.

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<SimpleCMS.Models.ContentPage>" %>
<div class="postform">
	<%
		using (Html.BeginForm("Create", "Pages", new { id = ViewData.Model.PageId }, FormMethod.Post))
		{%>
	<%: Html.ValidationSummary(true)%>
	<fieldset>
		<legend>Create new page</legend>
		<p>
			<label for="Title">Title</label>
			<%: Html.TextBoxFor(model => model.Title)%><%: Html.ValidationMessageFor(model => model.Title, "*")%></p>
		<p>
			<label for="Description">Description</label>
			<%: Html.TextBoxFor(model => model.Description)%>
		</p>
		<p>
			<label for="Abstract">Abstract</label>
			<%: Html.TextAreaFor(model => model.Abstract, new { rows = 5, cols = 60 })%>
		</p>
		<p>
			<label for="Author">Author</label>
			<%: Html.TextBoxFor(model => model.Author)%>
		</p>
		<p>
			<label for="BodyText">Content</label>
			<%: Html.TextAreaFor(model => model.BodyText, new { rows = 5, cols = 60 })%>
			<%: Html.ValidationMessageFor(model => model.BodyText)%>
		</p>
		<p>
			<label for="PubDate">Publish Date</label>
			<%: Html.TextBoxFor(model => model.PubDate)%>
		</p>
		<ul class="feedback options">
			<li><label><input type="checkbox" value="true" name="EnableComments" 
			<%= Html.CheckBoxChecked(ViewData.Model.EnableComments, true) %>/> Enable Comments</label></li>
			<li><label><input type="checkbox" value="true" name="AnonComments" 
			<%= Html.CheckBoxChecked(ViewData.Model.AnonComments, true) %>/> Anonymous Comments</label></li>
			<li><label><input type="checkbox" value="true" name="ModeratedComments" 
			<%= Html.CheckBoxChecked(ViewData.Model.Moderated, false) %>/> Moderated</label></li>
			<li><label><input type="checkbox" value="true" name="Approved" 
			<%= Html.CheckBoxChecked(ViewData.Model.Approved, true) %>/> Approved</label></li>
		</ul>
		<p>
			<input type="submit" value="Create new page" />
		</p>
	</fieldset>
</div>
<% } %>

Now we’re finally ready to create the Index View. Remember, this is going to be a full page strongly typed view, not just an .ascx. We just have to make sure that the strongly typed model is going to be PageView. You can leave View Content option as Empty since we’re going to be writing in our own functionality.

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" 
Inherits="System.Web.Mvc.ViewPage<SimpleCMS.Models.PageView>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
Index
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
    <h2>Index</h2>
	<% 		
		if (Model.Pages.Count > 0)
		{
			// We pages in this PageView, so we can start listing them.
			Html.RenderPartial("PageList", ViewData.Model.Pages);
		}
		if (Model.HasPage)
		{
			// We're adding a new page to an existing one.
			Html.RenderPartial("CreatePage", ViewData.Model.Page);
		}
		else
		{
			// We're creating a new content page from scratch
			Html.RenderPartial("CreatePage", new SimpleCMS.Models.ContentPage());
		}
		%>
</asp:Content>

See how uncluttered our page views are when we delegate all the “stuff” to user controls. Also note that we’re not fiddling with ViewData[“somestuff”] and such.

Go ahead and try it out.. You should be able to navigate to the /Pages/ section of your project after a compilation and create new pages.

Onward to the next step…

Simple CMS with Linq to SQL

A very basic content management system with multiple nested pages, comments and maybe a basic user management interface. Everything will be in ASP.Net MVC 2 in C#.

I’ve been meaning to do this for quite some time, but work and life kept getting in the way. I thought if I’m going to update for real, I’d at least post something useful.

I’ll be making use of my previous Linq to SQL Membership and Role providers for this to save time and effort. There are some minor alterations in the code, but those should be easy to make.

It would be helpful to have some prior knowledge of MVC basics, but not an absolute prerequisite. Things will go a bit smoother if you’re already familiar with it and Visual Web Developer Express, which my IDE of choice for this one. I’m also going to be using Sql Server Express 2008 and I won’t be adding an MDF to the App_Data folder as most quick examples would show, but create a database in SQL Server using the Management Studio instead. I think it’s far simpler to deploy to a hosted service with this starting point.

Because I’m going to be posting a lot of code in this, there is the potential for mistakes in my train of thought or during formatting. Please point them out if you happen to come across them or if you have improvements. But please keep in mind that this is a SimpleCMS, so we’re not going to get all that fancy ;)

Let’s start with a ContentPage table in your database :

Don't forget to set PageId as the primary key and set properties to auto increment.

Note that only three fields are required and one is by default because it’s the primary key. This is because I thought of having the most flexible arrangement program-wise without being concerned too much about required fields in the table. And because I wanted the CMS to double as a forum at some point… More on that later.

Now we need a ContentComments table :

Very important not to confuse AuthorId with AuthorIP. Seems silly to point that out, but it's happened to me ;)

Now we need to add a parent-child relationship by adding a foreign key to the ContentComments table. In this case, the PageId of the ContentComments table is going to be the foreign key to the ContentPages table’s PageId primary.

When adding relationships, it's better to keep the Primary and Foreign key field names the same.

Now back in your solution explorer, right click on the Models folder and Add > New Item. We’re going to add the Linq to SQL data classes here and call it SimpleCMS (you can call it any name of your choice, but just remember to keep it matching the project name for simplicity.

You’ll see the classes designer open with two sections exposed. You need to drag and drop the ContentPages and ContentComments tables into the class designer view.

Just remember to compile your project after adding these tables (or making any changes to the dbml) or else the next steps won't work.

Now that the database is set, we’re on our way to the fun part; the classes. But first, I’m going to break one of my own rules here and develop the template. This is because the default MVC theme is a bit too bare and, I believe, not very flexible even if you decide to hack it to pieces.

We’ll be using a version of the mod portal variation of one of my old template tutorials.

Onward to the next step…

Dev Team : Need more monkeys

Sorry we’re out. Will trap recruit more at next Developer Conference.

– Human Resources

And they think I’m grouchy on weekends for no reason. Let’s bring in some perspective, shall we?

Needed :

  • User management
  • Article management
    • Comment management
  • Discussion board
    • Forum management
    • Topic/Post management
  • Document management
    • Granular privileges/Edit restrictions

Most mortals will look at this list and think “Portal”. Well, that’s essentially what it is with some minor tweaks. It was for an intranet and the deadline is a week.

Are we using something off the shelf?
Nope.
Are we using something appropriate?
Nope.

Are we going to waste time/money/people (literally) trying to build something from scratch that will try to do things 10x longer than that list?
You betcha!

2 Programmers alone could have finished all of this in a day with time to spare for meals and coffee, but since we’re a “team”, I have to put up with the most inane BS ever to come out of an orifice… on a face. I feel lucky though, since I’m only handling data access, I won’t be dealing with the brunt of the feature flood though I’m sure I’ll have to write some OR/Mapper because “off-the-shelf” is “untrustworthy”. Yay me!

I swear, the vast majority of projects that fail are directly the result of bosses and project managers not knowing what the devil it is they are doing. I propose mandatory Ritalin® prescriptions for these people so we don’t get interrupted every hour by “Ooh shiny new feature. We must implement it!!”

I’ve been trying to convince people to stay focused for most of this year with no effect.

The next time you’re given a list of requirements and a programmer and/or project manager stands up to insist on a new feature before the core is complete, put him in a straight jacket and throw him out the window.

I will guarantee the liability and injury costs will be nothing compared to how much money will be saved by not having him aboard.

Disclaimer

Eksith Rodrigo isn’t actually a licensed lawyer, but he does know throwing someone out of a window in a straight jacket might constitute grounds for a lawsuit.

Reader discretion is advised.