summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDimitri Sokolyuk <demon@dim13.org>2012-09-05 18:34:48 +0000
committerDimitri Sokolyuk <demon@dim13.org>2012-09-05 18:34:48 +0000
commitacb38afcd4780191569ee809f3e8bdb550a634bc (patch)
treed54e4354f5758ee547c14c04bed307c5a473c854
blogsum
-rw-r--r--Blogsum/Config.pm.dist38
-rwxr-xr-xadmin.cgi252
-rw-r--r--docs/LICENSE29
-rw-r--r--docs/LICENSE.images14
-rw-r--r--docs/README6
-rw-r--r--examples/create_sqlite.sql36
-rw-r--r--examples/httpd-blogsum.conf50
-rw-r--r--examples/httpd2-blogsum.conf49
-rwxr-xr-xexamples/wp2blogsum.pl75
-rwxr-xr-xindex.cgi460
-rw-r--r--startup.pl42
-rw-r--r--themes/default/admin.tmpl107
-rw-r--r--themes/default/images/asterisk-green.gifbin0 -> 4255 bytes
-rw-r--r--themes/default/images/asterisk-red.gifbin0 -> 4310 bytes
-rw-r--r--themes/default/images/check.gifbin0 -> 349 bytes
-rw-r--r--themes/default/images/delete.gifbin0 -> 394 bytes
-rw-r--r--themes/default/images/draft-disabled.gifbin0 -> 4074 bytes
-rw-r--r--themes/default/images/draft.gifbin0 -> 350 bytes
-rw-r--r--themes/default/images/play-disabled.gifbin0 -> 4062 bytes
-rw-r--r--themes/default/images/play.gifbin0 -> 340 bytes
-rw-r--r--themes/default/images/plus.gifbin0 -> 233 bytes
-rw-r--r--themes/default/images/xml.gifbin0 -> 585 bytes
-rw-r--r--themes/default/index.tmpl179
-rw-r--r--themes/default/style.css87
24 files changed, 1424 insertions, 0 deletions
diff --git a/Blogsum/Config.pm.dist b/Blogsum/Config.pm.dist
new file mode 100644
index 0000000..fb4cf6e
--- /dev/null
+++ b/Blogsum/Config.pm.dist
@@ -0,0 +1,38 @@
+
+# Blogsum
+# Copyright (c) 2009 Jason Dixon <jason@dixongroup.net>
+# All rights reserved.
+
+
+###########################
+# pragmas #
+###########################
+package Blogsum::Config;
+use strict;
+
+
+###########################
+# user options #
+###########################
+our $database = 'data/site.db';
+our $blog_theme = 'default';
+our $blog_title = 'example.com';
+our $blog_subtitle = 'My New Blog';
+our $blog_url = 'http://www.example.com/';
+our $blog_owner = 'user@example.com';
+our $blog_rights = 'Copyright 2009, Example User';
+our $feed_updates = 'hourly';
+our $captcha_pubkey = '';
+our $captcha_seckey = '';
+our $comment_max_length = '1000';
+our $comments_allowed = 0;
+our $smtp_server = 'localhost:25';
+our $smtp_sender = 'blogsum@example.com';
+our $articles_per_page = '10';
+our $google_analytics_id = '';
+our $google_webmaster_id = '';
+our $max_tags_in_cloud = 20;
+our $page_not_found_error = '';
+
+1;
+
diff --git a/admin.cgi b/admin.cgi
new file mode 100755
index 0000000..21de7c0
--- /dev/null
+++ b/admin.cgi
@@ -0,0 +1,252 @@
+
+# Blogsum
+#
+# Copyright (c) 2010 DixonGroup Consulting
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# - Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# - Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following
+# disclaimer in the documentation and/or other materials provided
+# with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+###########################
+# pragmas and vars #
+###########################
+use strict;
+use Blogsum::Config;
+my $database = $Blogsum::Config::database;
+my $blog_theme = $Blogsum::Config::blog_theme;
+my $blog_title = $Blogsum::Config::blog_title;
+
+
+###########################
+# main execution #
+###########################
+my $cgi = CGI->new;
+my $dbh = DBI->connect("DBI:SQLite:dbname=$database", '', '', { RaiseError => 1 }) || die $DBI::errstr;
+my $template = HTML::Template->new(filename => "themes/${blog_theme}/admin.tmpl", die_on_bad_params => 0);
+$template->param( theme => $blog_theme );
+my $view;
+
+if ($cgi->param('view')) {
+ if ($cgi->param('view') eq 'moderate') {
+ $view = 'moderate';
+ manage_comments();
+ } elsif ($cgi->param('view') eq 'edit') {
+ $view = 'create';
+ edit_article();
+ } else {
+ $view = 'administrate';
+ manage_articles();
+ }
+} else {
+ $view = 'administrate';
+ manage_articles();
+}
+
+$dbh->disconnect;
+
+
+###########################
+# subfunctions #
+###########################
+
+sub manage_articles {
+
+ my $article_id;
+ my $status=2;
+
+ if ($cgi->param('delete') =~ /\d+/) {
+ $article_id = $cgi->param('delete');
+ $status=-1;
+ }
+ if ($cgi->param('draft') =~ /\d+/) {
+ $article_id = $cgi->param('draft');
+ $status=0;
+ }
+ if ($cgi->param('publish') =~ /\d+/) {
+ $article_id = $cgi->param('publish');
+ $status=1;
+ }
+ if ($status < 2) {
+ my $stmt = "UPDATE articles SET enabled=? WHERE id=?";
+ my $sth = $dbh->prepare($stmt);
+ $sth->execute($status, $article_id) || die $dbh->errstr;
+ }
+ if ($status == 1) {
+ my $stmt = "UPDATE articles SET date = datetime('now', 'localtime') WHERE id=?";
+ my $sth = $dbh->prepare($stmt);
+ $sth->execute($article_id) || die $dbh->errstr;
+ }
+
+ if (@{get_comments()} > 0) {
+ $template->param( comments_to_moderate => 1);
+ }
+ $template->param( view => $view, blog_title => $blog_title, articles => get_articles() );
+ print $cgi->header(), $template->output;
+}
+
+sub manage_comments {
+
+ my $comment_id;
+ my $status=2;
+
+ if ($cgi->param('delete') =~ /\d+/) {
+ $comment_id = $cgi->param('delete');
+ $status=-1;
+ }
+ if ($cgi->param('publish') =~ /\d+/) {
+ $comment_id = $cgi->param('publish');
+ $status=1;
+ }
+ if ($status < 2) {
+ my $stmt = "UPDATE comments SET enabled=? WHERE id=?";
+ my $sth = $dbh->prepare($stmt);
+ $sth->execute($status, $comment_id) || die $dbh->errstr;
+ }
+
+ $template->param( view => $view, blog_title => $blog_title, comments => get_comments() );
+ print $cgi->header(), $template->output;
+}
+
+sub edit_article {
+
+ # preview, pass through all input
+ if ($cgi->param('preview')) {
+ my $uri = $cgi->param('uri') || $cgi->param('title') || undef;
+ $uri =~ s/\ /\-/g if ($uri);
+ $template->param( view => $view, blog_title => $blog_title, preview => 1, edit => 1 );
+ $template->param( id => $cgi->param('id') ) if ($cgi->param('id'));
+ $template->param( title => $cgi->param('title') ) if ($cgi->param('title'));
+ $template->param( uri => $uri ) if ($uri);
+ $template->param( preview => $cgi->param('body') ) if ($cgi->param('body'));
+ $template->param( body => HTML::Entities::encode($cgi->param('body')) ) if ($cgi->param('body'));
+ $template->param( tags => $cgi->param('tags') ) if ($cgi->param('tags'));
+ print $cgi->header(), $template->output;
+
+ # save edits, with id (update)
+ } elsif ($cgi->param('save') && $cgi->param('id')) {
+ if ($cgi->param('title') && $cgi->param('uri') && $cgi->param('body')) {
+ my $uri = $cgi->param('uri');
+ $uri =~ s/\ /\-/g;
+ my $stmt = "UPDATE articles SET title=?, uri=?, body=?, tags=? WHERE id=?";
+ my $sth = $dbh->prepare($stmt);
+ $sth->execute($cgi->param('title'), $uri, $cgi->param('body'), $cgi->param('tags'), $cgi->param('id')) || die $dbh->errstr;
+ manage_articles();
+ # if missing data, push back to preview
+ } else {
+ $template->param( error => 'required fields: title, uri, body' );
+ $template->param( view => $view, blog_title => $blog_title, edit => 1 );
+ $template->param( id => $cgi->param('id') ) if ($cgi->param('id'));
+ $template->param( title => $cgi->param('title') ) if ($cgi->param('title'));
+ $template->param( uri => $cgi->param('uri') ) if ($cgi->param('uri'));
+ $template->param( preview => $cgi->param('body') ) if ($cgi->param('body'));
+ $template->param( body => HTML::Entities::encode($cgi->param('body')) ) if ($cgi->param('body'));
+ $template->param( tags => $cgi->param('tags') ) if ($cgi->param('tags'));
+ print $cgi->header(), $template->output;
+ }
+
+ # save new, no id (insert)
+ } elsif ($cgi->param('save')) {
+ if ($cgi->param('title') && $cgi->param('body')) {
+ my $uri = $cgi->param('uri') || $cgi->param('title');
+ $uri =~ s/\ /\-/g;
+ my $author = $ENV{'REMOTE_USER'} || 'author';
+ my $stmt = "INSERT INTO articles VALUES (NULL, datetime('now', 'localtime'), ?, ?, ?, ?, 0, ?)";
+ my $sth = $dbh->prepare($stmt);
+ $sth->execute($cgi->param('title'), $uri, $cgi->param('body'), $cgi->param('tags'), $author) || die $dbh->errstr;
+ manage_articles();
+ # if missing data, push back to preview
+ } else {
+ $template->param( error => 'required fields: title, body' );
+ $template->param( view => $view, blog_title => $blog_title, edit => 1 );
+ $template->param( id => $cgi->param('id') ) if ($cgi->param('id'));
+ $template->param( title => $cgi->param('title') ) if ($cgi->param('title'));
+ $template->param( uri => $cgi->param('uri') ) if ($cgi->param('uri'));
+ $template->param( preview => $cgi->param('body') ) if ($cgi->param('body'));
+ $template->param( body => HTML::Entities::encode($cgi->param('body')) ) if ($cgi->param('body'));
+ $template->param( tags => $cgi->param('tags') ) if ($cgi->param('tags'));
+ print $cgi->header(), $template->output;
+ }
+
+ # edit an existing
+ } elsif ($cgi->param('id')) {
+ my $query = "SELECT * FROM articles WHERE id=?";
+ my $sth = $dbh->prepare($query);
+ $sth->execute($cgi->param('id')) || die $dbh->errstr;
+ my $result = $sth->fetchrow_hashref;
+ if ($result) {
+ $template->param( view => $view, blog_title => $blog_title, edit => 1 );
+ $template->param( preview => $result->{'body'} );
+ $result->{'body'} = HTML::Entities::encode($result->{'body'});
+ $template->param( $result );
+ print $cgi->header(), $template->output;
+ } else {
+ $template->param( error => 'no results found' );
+ manage_articles();
+ }
+
+ # brand new, show form
+ } else {
+ $template->param( view => $view, blog_title => $blog_title, edit => 1 );
+ print $cgi->header(), $template->output;
+ }
+}
+
+sub get_articles {
+
+ my $query = 'SELECT * FROM articles WHERE enabled !=-1 ORDER BY date DESC';
+ my $sth = $dbh->prepare($query);
+ $sth->execute() || die $dbh->errstr;
+
+ my @articles;
+ while (my $result = $sth->fetchrow_hashref) {
+ $result->{'date'} =~ /(\d{4})\-(\d{2})\-\d{2} \d{2}\:\d{2}\:\d{2}/;
+ ($result->{'year'}, $result->{'month'}) = ($1, $2);
+ $result->{'date'} =~ s/(\d{4}\-\d{2}\-\d{2}) \d{2}\:\d{2}\:\d{2}/$1/;
+ delete $result->{'enabled'} if ($result->{'enabled'} == 0);
+ $result->{'theme'} = $blog_theme;
+ push(@articles, $result);
+ }
+
+ return \@articles;
+}
+
+sub get_comments {
+
+ my $query = 'SELECT a.title AS article_title, a.uri AS article_uri, a.date AS article_date, c.* FROM articles a, comments c WHERE a.id=c.article_id AND c.enabled=0 ORDER BY c.date DESC';
+ my $sth = $dbh->prepare($query);
+ $sth->execute() || die $dbh->errstr;
+
+ my @comments;
+ while (my $result = $sth->fetchrow_hashref) {
+ $result->{'article_date'} =~ /(\d{4})\-(\d{2})\-\d{2} \d{2}\:\d{2}\:\d{2}/;
+ ($result->{'article_year'}, $result->{'article_month'}) = ($1, $2);
+ $result->{'theme'} = $blog_theme;
+ push(@comments, $result);
+ }
+
+ return \@comments;
+}
+
+
diff --git a/docs/LICENSE b/docs/LICENSE
new file mode 100644
index 0000000..55d5bab
--- /dev/null
+++ b/docs/LICENSE
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2010 DixonGroup Consulting
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following
+ * disclaimer in the documentation and/or other materials provided
+ * with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ * COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+ * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
diff --git a/docs/LICENSE.images b/docs/LICENSE.images
new file mode 100644
index 0000000..51b415a
--- /dev/null
+++ b/docs/LICENSE.images
@@ -0,0 +1,14 @@
+The images included in portions of Blogsum were provided from the Proxal
+Icon Set v2 under an "Open Source" license by user 'valkyre' at
+deviantart.com.
+
+From his original announcement:
+
+"This package is open source. Do what you want with it, use it how you
+want. I don't care if you take the icons and use them on your website, or
+in another application. The only restriction is you can't sell them, or
+sell a product that includes them, and blah blah blah. Enough of the
+legalities."
+
+http://valkyre.deviantart.com/art/Proxal-Icon-Set-v2-17102198
+
diff --git a/docs/README b/docs/README
new file mode 100644
index 0000000..d840a41
--- /dev/null
+++ b/docs/README
@@ -0,0 +1,6 @@
+# Blogsum README
+
+Please refer to the online documentation
+for manual installation instructions.
+
+http://blogsum.obfuscurity.com/
diff --git a/examples/create_sqlite.sql b/examples/create_sqlite.sql
new file mode 100644
index 0000000..8bc8850
--- /dev/null
+++ b/examples/create_sqlite.sql
@@ -0,0 +1,36 @@
+BEGIN TRANSACTION;
+CREATE TABLE articles (id integer primary key, date date, title text, uri text, body text, tags text, enabled boolean, author text);
+INSERT INTO "articles" VALUES(1,'2009-11-16 18:07:10','Welcome to Blogsum','Welcome-to-Blogsum','<h3>Introduction</h3>
+
+<p>Blogsum is a very basic blogging application. It was written from scratch with a focus on simplicity and security, favoring practicality over feature bloat.</p>
+
+<h3>Writing Articles</h3>
+
+<p>The Administration interface is straightforward and bereft of unnecessary features. Three views allow you to <a href="/admin.cgi?view=edit">create</a> posts, <a href="/admin.cgi?view=moderate">moderate</a> comments and <a href="/admin.cgi">administrate</a> (manage posts). Blogsum uses HTML markup for article formatting. New articles are saved as a <em>draft</em> <img align="absmiddle" src="/themes/default/images/draft.gif">.</p>
+
+<p>There''s one other useful thing to remember about editing your articles. If you have a lengthy post you might want to split it up with the <b><tt>&lt;!--readmore--&gt;</tt></b> tag. Anything that appears after this HTML comment will appear in the full article view, but will be hidden from your blog''s front page. Here comes one now...</p>
+
+<!--readmore-->
+
+<h3>Managing Articles</h3>
+
+<p>As mentioned above, new articles are saved as a draft. Click the <em>publish</em> <img align="absmiddle" src="/themes/default/images/play.gif"> button to see your post go live. You can make <em>edits</em> <img align="absmiddle" src="/themes/default/images/plus.gif"> to a live article, but the timestamp won''t be updated unless you re-draft and re-publish the story. If you decide that you really want to remove an article from the administrate view, you can <em>delete</em> <img align="absmiddle" src="/themes/default/images/delete.gif"> it.</p>
+
+<h3>Comments and Moderation</h3>
+
+<p>Article comments are moderated and must be accompanied with a successful Captcha challenge. All user input is encoded to avoid XSS issues. Click on the <em>moderate</em> view to <em>approve</em> <img align="absmiddle" src="/themes/default/images/check.gif"> or <em>deny</em> <img align="absmiddle" src="/themes/default/images/delete.gif"> a comment submission.</p>
+
+<h3>Using Tags</h3>
+
+<p>Tags are used liberally throughout Blogsum. Besides the tags defined in an article, Blogsum also uses the <tt>/Tags/</tt> path to search for authors. It also favors the use of tags as a conventional replacement to <em>categories</em>. Anyone can use the <tt>/Tags/</tt> path to search for articles in Blogsum (<a href="/Tags/jdixon">example</a>).</p>
+
+<h3>Themes</h3>
+
+<p>Blogsum includes a default theme (what you''re viewing now). If you wish to modify it according to your tastes, you should create your own theme. To create a theme, copy the <tt>themes/default</tt> directory to your own directory (e.g. <tt>themes/foobar</tt>) and modify accordingly. Changes can be made to any of the files in a theme, but the path and filenames should not change. When you are finished, edit the <tt>$blog_theme</tt> setting in <tt>Blogsum/Config.pm</tt> and restart your webserver.</p>
+
+<h3>Go Forth and Blog!</h3>
+
+<p>As you can see, Blogsum is a very simple application designed for <b><em>less maintenance, more writing</em></b>. We hope you enjoy publishing your works within Blogsum. If you create your own themes, please consider donating those back to the <a href="http://blogsum.obfuscurity.com/">project</a>. Enjoy!</p>
+','Blogsum,Welcome',1,'jdixon');
+CREATE TABLE comments (id integer primary key, article_id integer, date date, name text, email text, url text, comment text, enabled boolean);
+COMMIT;
diff --git a/examples/httpd-blogsum.conf b/examples/httpd-blogsum.conf
new file mode 100644
index 0000000..f7f669c
--- /dev/null
+++ b/examples/httpd-blogsum.conf
@@ -0,0 +1,50 @@
+<VirtualHost *:80>
+ ServerName www.example.com
+ DocumentRoot /var/www/blogsum
+ DirectoryIndex index.cgi
+
+ Options +FollowSymlinks
+ RewriteEngine On
+ RewriteRule ^/rss.xml$ /index.cgi?rss=1 [PT,QSA]
+ RewriteRule ^/rss2.xml$ /index.cgi?rss=2 [PT,QSA]
+ RewriteRule ^/Page/([^/]+)$ /index.cgi?page=$1 [PT,QSA]
+ RewriteRule ^/Tags/([^/]+)$ /index.cgi?search=$1 [PT,QSA]
+ RewriteRule ^/([0-9]{4})/([0-9]{2})/([^/]+)$ /index.cgi?view=article&year=$1&month=$2&uri=$3 [PT,QSA]
+ RewriteRule ^/([0-9]{4})/([0-9]{2})/?$ /index.cgi?view=article&year=$1&month=$2 [PT,QSA]
+ RewriteRule ^/([0-9]{4})/?$ /index.cgi?view=article&year=$1 [PT,QSA]
+
+ PerlModule Apache::PerlRun
+ <LocationMatch ^/index.cgi>
+ SetHandler perl-script
+ PerlHandler Apache::PerlRun
+ PerlRequire /var/www/blogsum/startup.pl
+ Options ExecCGI
+ Order deny,allow
+ Allow from all
+ </LocationMatch>
+ <LocationMatch ^/admin.cgi>
+ SetHandler perl-script
+ PerlHandler Apache::PerlRun
+ PerlRequire /var/www/blogsum/startup.pl
+ Options ExecCGI
+ Order deny,allow
+ Allow from all
+ AuthUserFile /var/www/conf/blogsum.htpasswd
+ AuthName "Blogsum Admin - example.com"
+ AuthType Basic
+ <limit GET POST>
+ require valid-user
+ </limit>
+ </LocationMatch>
+ <LocationMatch ^/Blogsum/>
+ SetHandler perl-script
+ PerlHandler Apache::PerlRun
+ Options -ExecCGI
+ Order deny,allow
+ Allow from all
+ </LocationMatch>
+ <LocationMatch ^/data/>
+ Order deny,allow
+ Deny from all
+ </LocationMatch>
+</VirtualHost>
diff --git a/examples/httpd2-blogsum.conf b/examples/httpd2-blogsum.conf
new file mode 100644
index 0000000..a887d0d
--- /dev/null
+++ b/examples/httpd2-blogsum.conf
@@ -0,0 +1,49 @@
+<VirtualHost *:80>
+ ServerName www.example.com
+ DocumentRoot /var/www/blogsum
+ DirectoryIndex index.cgi
+ PerlRequire /www/blogsum/startup.pl
+
+ Options +FollowSymlinks
+ RewriteEngine On
+ RewriteRule ^/rss.xml$ /index.cgi?rss=1 [PT,QSA]
+ RewriteRule ^/rss2.xml$ /index.cgi?rss=2 [PT,QSA]
+ RewriteRule ^/Page/([^/]+)$ /index.cgi?page=$1 [PT,QSA]
+ RewriteRule ^/Tags/([^/]+)$ /index.cgi?search=$1 [PT,QSA]
+ RewriteRule ^/([0-9]{4})/([0-9]{2})/([^/]+)$ /index.cgi?view=article&year=$1&month=$2&uri=$3 [PT,QSA]
+ RewriteRule ^/([0-9]{4})/([0-9]{2})/?$ /index.cgi?view=article&year=$1&month=$2 [PT,QSA]
+ RewriteRule ^/([0-9]{4})/?$ /index.cgi?view=article&year=$1 [PT,QSA]
+
+ PerlModule ModPerl::PerlRun
+ <LocationMatch ^/index.cgi>
+ SetHandler perl-script
+ PerlResponseHandler ModPerl::PerlRunPrefork
+ Options ExecCGI
+ Order deny,allow
+ Allow from all
+ </LocationMatch>
+ <LocationMatch ^/admin.cgi>
+ SetHandler perl-script
+ PerlResponseHandler ModPerl::PerlRunPrefork
+ Options ExecCGI
+ Order deny,allow
+ Allow from all
+ AuthUserFile /var/www/conf/blogsum.htpasswd
+ AuthName "Blogsum Admin - example.com"
+ AuthType Basic
+ <limit GET POST>
+ require valid-user
+ </limit>
+ </LocationMatch>
+ <LocationMatch ^/Blogsum/>
+ SetHandler perl-script
+ PerlResponseHandler ModPerl::PerlRunPrefork
+ Options -ExecCGI
+ Order deny,allow
+ Allow from all
+ </LocationMatch>
+ <LocationMatch ^/data/>
+ Order deny,allow
+ Deny from all
+ </LocationMatch>
+</VirtualHost>
diff --git a/examples/wp2blogsum.pl b/examples/wp2blogsum.pl
new file mode 100755
index 0000000..e11f1eb
--- /dev/null
+++ b/examples/wp2blogsum.pl
@@ -0,0 +1,75 @@
+#!/usr/bin/perl
+
+# Blogsum
+# Copyright (c) 2009 Jason Dixon <jason@dixongroup.net>
+# All rights reserved.
+
+use strict;
+use DBI;
+use XML::Simple;
+
+die "Usage: wp2blogsum.pl <file.xml> <file.db>\n\n" unless (@ARGV == 2);
+
+my $wpxml = $ARGV[0];
+my $database = $ARGV[1];
+my $xs = XML::Simple->new();
+my $ref = $xs->XMLin($wpxml);
+my $dbh = DBI->connect("DBI:SQLite:dbname=$database",'','', { RaiseError => 1 }) || die $DBI::errstr;
+my $stmt = "INSERT INTO articles VALUES (NULL, ?, ?, ?, ?, ?, ?, ?)";
+my $sth = $dbh->prepare($stmt);
+my $stmt2 = "INSERT INTO comments VALUES (NULL, ?, ?, ?, ?, ?, ?, ?)";
+my $sth2 = $dbh->prepare($stmt2);
+
+foreach my $item ( @{$ref->{'channel'}->{'item'}} ) {
+ next unless ($item->{'wp:post_type'} eq 'post');
+ my $title = $item->{'title'};
+ my $date = $item->{'wp:post_date'};
+ my $uri = $item->{'wp:post_name'};
+ my $author = $item->{'dc:creator'};
+ my $enabled = ($item->{'wp:status'} eq 'publish') ? 1 : 0;
+ my $content = $item->{'content:encoded'};
+ $content =~ s/ //g; # remove
+ unless (($content =~ /<pre>/) || ($content =~ <ul>) || ($content =~ <ol>)) {
+ $content =~ s/<\!\-\-more\-\->/<\!\-\-readmore\-\->/mg; # convert more to readmore
+ $content =~ s/^/<p>/mg; # add <p> to beginning of line
+ $content =~ s/\r\n/<\/p>\r\n/mg; # add </p> to end of line
+ $content =~ s/$/<\/p>/mg; # add </p> to end of story (no \r\n)
+ $content =~ s/^<p><\/p>$//mg; # remove <p></p> (empty lines)
+ $content =~ s/^<p>(<\!\-\-\w+\-\->)<\/p>/$1/mg; # remove <p></p> (comment lines)
+ $content =~ s/^<\/p>$//mg; # remove extra </p> from end of story
+ $content =~ s/<p><ul>/<ul>/mg; # remove <p> before <ul>
+ $content =~ s/<ul><\/p>/<ul>/mg; # remove </p> after <ul>
+ $content =~ s/<p><\/ul>/<\/ul>/mg; # remove <p> before </ul>
+ $content =~ s/<\/ul><\/p>/<\/ul>/mg; # remove </p> after </ul>
+ $content =~ s/<p><li>/<li>/mg; # remove <p> before <li>
+ $content =~ s/<li><\/p>/<li>/mg; # remove </p> after <li>
+ $content =~ s/<p><\/li>/<\/li>/mg; # remove <p> before </li>
+ $content =~ s/<\/li><\/p>/<\/li>/mg; # remove </p> after </li>
+ }
+ my @tags;
+ if ($item->{'category'}) {
+ for my $category (@{$item->{'category'}}) {
+ if (ref($category) eq 'HASH') {
+ if ($category->{'nicename'}) {
+ push(@tags, $category->{'content'});
+ }
+ }
+ }
+ }
+ $sth->execute($date, $title, $uri, $content, join(',', @tags), $enabled, $author) || die $dbh->errstr;
+ my $article_id = $dbh->func('last_insert_rowid');
+ if ($item->{'wp:comment'}) {
+ if (ref($item->{'wp:comment'}) eq 'ARRAY') {
+ for my $comment (@{$item->{'wp:comment'}}) {
+ $sth2->execute($article_id, $comment->{'wp:comment_date'}, $comment->{'wp:comment_author'}, $comment->{'wp:comment_author_email'}, $comment->{'wp:comment_author_url'}, $comment->{'wp:comment_content'}, $comment->{'wp:comment_approved'}) || die $dbh->errstr;
+ }
+ } else {
+ my $comment = $item->{'wp:comment'};
+ $sth2->execute($article_id, $comment->{'wp:comment_date'}, $comment->{'wp:comment_author'}, $comment->{'wp:comment_author_email'}, $comment->{'wp:comment_author_url'}, $comment->{'wp:comment_content'}, $comment->{'wp:comment_approved'}) || die $dbh->errstr;
+ }
+ }
+}
+
+$dbh->disconnect;
+
+
diff --git a/index.cgi b/index.cgi
new file mode 100755
index 0000000..9c2321c
--- /dev/null
+++ b/index.cgi
@@ -0,0 +1,460 @@
+
+# Blogsum
+#
+# Copyright (c) 2010 DixonGroup Consulting
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# - Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# - Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following
+# disclaimer in the documentation and/or other materials provided
+# with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+###########################
+# pragmas and vars #
+###########################
+use strict;
+use Blogsum::Config;
+my $database = $Blogsum::Config::database;
+my $blog_theme = $Blogsum::Config::blog_theme;
+my $blog_title = $Blogsum::Config::blog_title;
+my $blog_subtitle = $Blogsum::Config::blog_subtitle;
+my $blog_url = $Blogsum::Config::blog_url;
+$blog_url .= '/' unless ($blog_url =~ /^.*\/$/);
+my $blog_owner = $Blogsum::Config::blog_owner;
+my $blog_rights = $Blogsum::Config::blog_rights;
+my $feed_updates = $Blogsum::Config::feed_updates;
+my $captcha_pubkey = $Blogsum::Config::captcha_pubkey;
+my $captcha_seckey = $Blogsum::Config::captcha_seckey;
+my $comment_max_length = $Blogsum::Config::comment_max_length;
+my $comments_allowed = $Blogsum::Config::comments_allowed;
+my $smtp_server = $Blogsum::Config::smtp_server;
+my $smtp_sender = $Blogsum::Config::smtp_sender;
+my $articles_per_page = $Blogsum::Config::articles_per_page;
+my $google_analytics_id = $Blogsum::Config::google_analytics_id;
+my $google_webmaster_id = $Blogsum::Config::google_webmaster_id;
+my $max_tags_in_cloud = $Blogsum::Config::max_tags_in_cloud;
+my $page_not_found_error = $Blogsum::Config::page_not_found_error;
+$page_not_found_error ||= '404 page not found';
+
+
+###########################
+# main execution #
+###########################
+my $cgi = CGI->new;
+my $dbh = DBI->connect("DBI:SQLite:dbname=$database", '', '', { RaiseError => 1 }) || die $DBI::errstr;
+my $template = HTML::Template->new(filename => "themes/${blog_theme}/index.tmpl", die_on_bad_params => 0);
+if ($cgi->param('rss')) {
+ output_rss();
+} else {
+ read_comment() if $comments_allowed;
+ my $articles = get_articles();
+ my $archives = get_archives();
+ my $tagcloud = get_tag_cloud();
+ $template->param( archives => $archives );
+ $template->param( tagcloud => $tagcloud );
+ $template->param( theme => $blog_theme );
+ $template->param( blog_url => $blog_url );
+ $template->param( title => $blog_title );
+ $template->param( subtitle => $blog_subtitle );
+ $template->param( copyright => $blog_rights );
+ $template->param( google_analytics_id => $google_analytics_id );
+ $template->param( google_webmaster_id => $google_webmaster_id );
+ if (@{$articles}) {
+ $template->param( articles => $articles );
+ if ($cgi->param('uri') && $comments_allowed) {
+ $template->param( comment_form => 1 );
+ $template->param( comment_max_length => $comment_max_length );
+ $template->param( id => $articles->[0]->{'id'} );
+ }
+ } else {
+ $template->param( error => $page_not_found_error );
+ }
+ print $cgi->header(), $template->output;
+}
+$dbh->disconnect;
+
+
+###########################
+# subfunctions #
+###########################
+
+sub output_rss {
+
+ my $version = ($cgi->param('rss') == 2) ? '2.0' : '1.0';
+ my $rss = XML::RSS->new( version => $version );
+
+ $rss->channel (
+ title => $blog_title,
+ link => $blog_url,
+ description => $blog_subtitle,
+ dc => {
+ subject => $blog_title,
+ creator => $blog_owner,
+ publisher => $blog_owner,
+ rights => $blog_rights,
+ language => 'en-us',
+ },
+ syn => {
+ updatePeriod => $feed_updates,
+ updateFrequency => 1,
+ updateBase => '1901-01-01T00:00+00:00',
+ }
+ );
+
+ my $articles = get_articles();
+ for my $item (@{$articles}) {
+ $item->{'date'} =~ /(\d{4})\-(\d{2})\-\d{2} \d{2}\:\d{2}\:\d{2}/;
+ ($item->{'year'}, $item->{'month'}) = ($1, $2);
+ my $link = sprintf("%s%s/%s/%s", $blog_url, $item->{'year'}, $item->{'month'}, $item->{'uri'});
+
+ if ($version eq '2.0') {
+ $rss->add_item (
+ title => $item->{'title'},
+ link => $link,
+ description => $item->{'body'},
+ author => $item->{'author'},
+ comments => "${link}#comments",
+ pubDate => POSIX::strftime("%a, %d %b %Y %H:%M:%S %Z", gmtime($item->{'epoch'})),
+ category => @{[split(/, */, $item->{'tags'})]},
+ );
+ } else {
+ $rss->add_item (
+ title => $item->{'title'},
+ link => $link,
+ description => $item->{'body'},
+ dc => {
+ subject => $blog_title,
+ creator => $item->{'author'},
+ date => POSIX::strftime("%a, %d %b %Y %H:%M:%S %Z", gmtime($item->{'epoch'})),
+ },
+ );
+ }
+ }
+ print $cgi->header('application/rss+xml'), $rss->as_string;
+}
+
+sub get_articles {
+
+ my $page;
+ my $offset;
+ my $limit_clause;
+ my $where_clause;
+ my $j = 0;
+ my $show_comments = 0;
+
+ $articles_per_page = ($articles_per_page > 0) ? $articles_per_page : -1;
+ if ($cgi->param('page') && POSIX::isdigit($cgi->param('page'))) {
+ $page = $cgi->param('page');
+ $offset = ($page - 1) * $articles_per_page;
+ } else {
+ $page = 1;
+ $offset = 0;
+ }
+
+ if (($cgi->param('year') =~ /\d{4}/)&& (1900 < $cgi->param('year')) && ($cgi->param('year') < 2036)) {
+ $where_clause .= 'WHERE date LIKE \'%' . $cgi->param('year');
+ $j++;
+ if (($cgi->param('month') =~ /\d{2}/) && (0 < $cgi->param('month')) && ($cgi->param('month') <= 12)) {
+ $where_clause .= '-' . $cgi->param('month') . '%\' AND enabled=1 ';
+ $j++;
+ if ($cgi->param('uri') =~ /\w+/) {
+ $where_clause .= 'AND uri=? AND enabled=1 ';
+ $j++;
+ $show_comments=1;
+ }
+ } else {
+ $where_clause .= "\%' AND enabled=1 ";
+ }
+ } elsif ($cgi->param('search')) {
+ $where_clause .= "WHERE (tags LIKE ? OR author LIKE ?) AND enabled=1 ";
+ $j++;
+
+ } elsif ($cgi->param('id')) {
+ $where_clause .= 'WHERE id=? AND enabled=1 ';
+ $show_comments=1;
+
+ } else {
+ $where_clause .= 'WHERE enabled=1 ';
+ $limit_clause = " LIMIT $articles_per_page OFFSET $offset";
+ }
+
+ my $query = 'SELECT *, strftime("%s", date) AS epoch FROM articles ' . $where_clause . 'ORDER BY date DESC' . $limit_clause;
+ my $sth = $dbh->prepare($query);
+
+ if ($j == 3) {
+ $sth->execute($cgi->param('uri')) || die $dbh->errstr;
+ } elsif ($cgi->param('search')) {
+ my $search_tag = sprintf("%%%s%%", $cgi->param('search'));
+ $sth->execute($search_tag, $search_tag) || die $dbh->errstr;
+ } elsif ($cgi->param('id')) {
+ $sth->execute($cgi->param('id')) || die $dbh->errstr;
+ } else {
+ $sth->execute() || die $dbh->errstr;
+ }
+
+ my @articles;
+ while (my $result = $sth->fetchrow_hashref) {
+ $result->{'date'} =~ /(\d{4})\-(\d{2})\-\d{2} \d{2}\:\d{2}\:\d{2}/;
+ ($result->{'year'}, $result->{'month'}) = ($1, $2);
+ # cut off readmore if we're on the front page
+ if (($result->{'body'} =~ /<!--readmore-->/) && ($j < 3) && !($cgi->param('rss'))) {
+ $result->{'body'} =~ /(.*)\<\!\-\-readmore\-\-\>/s;
+ $result->{'body'} = $1;
+ $result->{'readmore'}++;
+ }
+ $result->{'tag_loop'} = format_tags($result->{'tags'}) if ($result->{'tags'});
+ my $comments = get_comments(article_id => $result->{'id'}, enabled => 1);
+ $result->{'comments_count'} = scalar(@{$comments});
+ if ($show_comments) {
+ $result->{'comments'} = $comments;
+ }
+ push(@articles, $result);
+ }
+
+ my $query2 = 'SELECT count(*) as total FROM articles WHERE enabled=1';
+ my $sth2 = $dbh->prepare($query2);
+ $sth2->execute || die $dbh->errstr;
+ my $article_count = $sth2->fetchrow_hashref->{'total'};
+ $template->param( page_prev => ($page - 1) ) if (($j == 0) && ($article_count > ($offset - $articles_per_page)));
+ $template->param( page_next => ($page + 1) ) if (($j == 0) && ($article_count > ($offset + $articles_per_page)));
+
+ return (\@articles);
+}
+
+sub get_archives {
+
+ my %history;
+ my @archives;
+ my @archives_compressed;
+ my $current_month = $cgi->param('month') || sprintf("%0.2d", ((localtime)[4] + 1));
+ my $current_year = $cgi->param('year') || ((localtime)[5] + 1900);
+ my %months = (
+ '01' => 'January',
+ '02' => 'February',
+ '03' => 'March',
+ '04' => 'April',
+ '05' => 'May',
+ '06' => 'June',
+ '07' => 'July',
+ '08' => 'August',
+ '09' => 'September',
+ '10' => 'October',
+ '11' => 'November',
+ '12' => 'December',
+ );
+
+ my $query = 'SELECT * FROM articles WHERE enabled=1 ORDER BY date DESC';
+ my $sth = $dbh->prepare($query);
+ $sth->execute || die $dbh->errstr;
+ while (my $result = $sth->fetchrow_hashref) {
+ $result->{'date'} =~ /(\d{4})\-(\d{2})\-\d{2} \d{2}\:\d{2}\:\d{2}/;
+ ($result->{'year'}, $result->{'month'}) = ($1, $2);
+ my $title = my $full_title = $result->{'title'};
+ if (length($title) > 28) {
+ $title = substr($title, 0, 25) . '...';
+ }
+
+ if (($result->{'year'} eq $current_year) && ($result->{'month'} eq $current_month) && $result->{'uri'}) {
+ push(@{$history{$result->{'year'}}{$result->{'month'}}->{'uri_loop'}},
+ {
+ year => $result->{'year'},
+ month => $result->{'month'},
+ month_name => $months{$result->{'month'}},
+ title => $title,
+ full_title => $full_title,
+ uri => $result->{'uri'},
+ }
+ );
+ } else {
+ $history{$result->{'year'}}->{$result->{'month'}}->{'count'}++;
+ }
+ $history{$result->{'year'}}->{'count'}++;
+
+ }
+
+ for my $year (sort {$b <=> $a} keys %history) {
+ no strict "refs";
+ for my $month (sort {$b <=> $a} keys %{$history{$year}}) {
+ my $m = {
+ 'year' => $year,
+ 'month' => $month,
+ 'month_name' => $months{$month},
+ 'count' => $history{$year}->{$month}->{'count'},
+ };
+ # check to see if uri_loop exists first
+ if ($history{$year}->{$month}->{'uri_loop'}) {
+ $m->{'uri_loop'} = $history{$year}->{$month}->{'uri_loop'};
+ }
+ push(@{$history{$year}->{'month_loop'}}, $m) unless ($month eq 'count');
+ }
+ my $y = {
+ 'year' => $year,
+ 'count' => $history{$year}->{'count'},
+ };
+ # check to see if we're showing this year, and that month_loop exists
+ if (($year eq $current_year) && $history{$year}->{'month_loop'}) {
+ $y->{'month_loop'} = $history{$year}->{'month_loop'};
+ }
+ push(@{$history{'year_loop'}}, $y) unless ($year eq 'count');
+ }
+
+ return \@{$history{'year_loop'}};
+}
+
+sub format_tags {
+
+ my $tags = shift;
+ my @tags;
+
+ foreach (split(/, */, $tags)) {
+ push(@tags, { 'tag' => $_ });
+ }
+
+ return \@tags;
+}
+
+sub read_comment {
+
+ if ($cgi->param('recaptcha_challenge_field') && $cgi->param('recaptcha_response_field') && $cgi->param('comment') && $cgi->param('id')) {
+
+ # test our captcha
+ my $result = verify_captcha( $captcha_seckey, $ENV{'REMOTE_ADDR'}, $cgi->param('recaptcha_challenge_field'), $cgi->param('recaptcha_response_field') );
+
+ if ($result->{'success'}) {
+
+ # save comment
+ my $comment = HTML::Entities::encode($cgi->param('comment'));
+ my $stmt = "INSERT INTO comments VALUES (NULL, ?, datetime('now', 'localtime'), ?, ?, ?, ?, 0)";
+ my $sth = $dbh->prepare($stmt);
+ my $comment_name = $cgi->param('name') ? substr($cgi->param('name'), 0, 100) : 'anonymous';
+ my $comment_email = $cgi->param('email') ? substr($cgi->param('email'), 0, 100) : undef;
+ my $comment_url = $cgi->param('url') ? substr($cgi->param('url'), 0, 100) : undef;
+ my $comment_body = substr(HTML::Entities::encode($cgi->param('comment')), 0, $comment_max_length);
+ $sth->execute($cgi->param('id'), $comment_name, $comment_email, $comment_url, $comment_body) || die $dbh->errstr;
+ $template->param( message => 'comment awaiting moderation, thank you' );
+
+ # send email notification
+ my $smtp = Net::SMTP->new($smtp_server);
+ $smtp->mail($ENV{USER});
+ $smtp->to("$blog_owner\n");
+ $smtp->data();
+ $smtp->datasend("From: $smtp_sender\n");
+ $smtp->datasend("To: $blog_owner\n");
+ $smtp->datasend("Subject: $blog_title comment submission\n\n");
+ $smtp->datasend("You have received a new comment submission.\n\n");
+ $smtp->datasend(sprintf("From: %s\n", $comment_name));
+ $smtp->datasend(sprintf("Date: %s\n", scalar(localtime)));
+ $smtp->datasend(sprintf("Comment:\n\"%s\"\n\n", $comment_body));
+ $smtp->datasend("Moderate comments at ${blog_url}admin.cgi?view=moderate\n");
+ $smtp->dataend();
+ $smtp->quit;
+ } else {
+ my $error;
+ if ($result->{'error'} eq 'incorrect-captcha-sol') {
+ $error = 'failed challenge, please try again';
+ } else {
+ $error = 'Error: ' . $result->{'error'} . ', please report to site admin';
+ }
+ $template->param( error => $error );
+ $template->param( name => $cgi->param('name') );
+ $template->param( email => $cgi->param('email') );
+ $template->param( url => $cgi->param('url') );
+ $template->param( comment => $cgi->param('comment') );
+ $template->param( id => $cgi->param('id') );
+ $template->param( comment_form => 1 );
+ }
+ }
+
+ # present the challenge
+ $template->param( captcha_api_server => 'http://api.recaptcha.net', captcha_pubkey => $captcha_pubkey );
+}
+
+sub verify_captcha {
+
+ my ( $privkey, $remoteip, $challenge, $response ) = @_;
+
+ my $http = HTTP::Lite->new();
+ $http->prepare_post(
+ {
+ privatekey => $privkey,
+ remoteip => $remoteip,
+ challenge => $challenge,
+ response => $response
+ }
+ );
+ $http->request( 'http://api-verify.recaptcha.net/verify' );
+
+ if ( $http->status eq '200' ) {
+ my ( $answer, $message ) = split( /\n/, $http->body, 2 );
+ return { success => 1 } if ( $answer =~ /true/ );
+ return { success => 0, error => $message };
+ } else {
+ return { success => 0, error => 'recaptcha-not-reachable' };
+ }
+}
+
+sub get_comments {
+
+ my %args = @_;
+
+ my $query = 'SELECT * FROM comments WHERE article_id=? AND enabled=? ORDER BY date ASC';
+ my $sth = $dbh->prepare($query);
+ $sth->execute($args{'article_id'}, $args{'enabled'}) || die $dbh->errstr;
+ my @comments;
+ while (my $result = $sth->fetchrow_hashref) {
+ push(@comments, $result);
+ }
+ return \@comments;
+}
+
+sub get_tag_cloud {
+
+ my $query = 'SELECT tags FROM articles WHERE enabled=1';
+ my $sth = $dbh->prepare($query);
+ $sth->execute || die $dbh->errstr;
+
+ # create a frequency table keyed by tag
+ my %freq;
+ while (my $result = $sth->fetchrow_hashref) {
+ map { $freq{$_}++ } split(/, */, $result->{'tags'});
+ }
+
+ # calculate the scaling denominator
+ my @tags = sort { $freq{$b} <=> $freq{$a} } keys %freq;
+ @tags = splice @tags, 0, $max_tags_in_cloud;
+ my $denominator = $freq{ $tags[0] } == $freq{ $tags[-1] }
+ ? ( 1 / 5 )
+ : ( $freq{ $tags[0] } - $freq{ $tags[-1] } ) / 5;
+
+ # build an HTML::Template friendly data structure
+ my @tag_cloud_data = ();
+ for my $tag (sort { lc $a cmp lc $b } @tags) {
+ my %row;
+ $row{'tag'} = $tag;
+ $row{'scale'} = int( ( $freq{$tag} - $freq{ $tags[-1] } ) / $denominator );
+ push(@tag_cloud_data, \%row);
+ }
+
+ return( \@tag_cloud_data );
+
+}
diff --git a/startup.pl b/startup.pl
new file mode 100644
index 0000000..03083ad
--- /dev/null
+++ b/startup.pl
@@ -0,0 +1,42 @@
+
+# Blogsum
+#
+# Copyright (c) 2010 DixonGroup Consulting
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# - Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# - Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following
+# disclaimer in the documentation and/or other materials provided
+# with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+
+use POSIX;
+use CGI;
+use DBI;
+use DBD::SQLite;
+use HTML::Template;
+use XML::RSS;
+use Net::SMTP;
+use HTML::Entities;
+use HTTP::Lite;
+
+1;
diff --git a/themes/default/admin.tmpl b/themes/default/admin.tmpl
new file mode 100644
index 0000000..0ecd1d7
--- /dev/null
+++ b/themes/default/admin.tmpl
@@ -0,0 +1,107 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+<head>
+ <title><TMPL_VAR name="blog_title"></title>
+ <link rel="stylesheet" type="text/css" href="/themes/<TMPL_VAR name="theme">/style.css" title="Default">
+</head>
+<body>
+
+<div id="wrapper">
+ <div id="header">
+ <h1>
+ <a id="admin" href="/"><TMPL_VAR name="blog_title"></a>
+ <a id="view" href="/admin.cgi?view=<TMPL_VAR name="view">"><TMPL_VAR name="view">.</a>
+ </h1>
+ <h3>
+ <a href="?view=edit">create</a> / <a <TMPL_IF name="comments_to_moderate">style="color: #f00;"</TMPL_IF> href="?view=moderate">moderate</a> / <a href="?view=administrate">administrate</a>
+ </h3>
+ </div>
+
+ <TMPL_IF name="error">
+ <h3 id="error">
+ <img src="/themes/<TMPL_VAR name="theme">/images/asterisk-red.gif" style="height: 20px; padding-right: 5px;"> <TMPL_VAR name="error">
+ </h3>
+ </TMPL_IF>
+
+ <TMPL_IF name="preview">
+ <!-- preview articles -->
+ <div id="preview">
+ <h2><TMPL_VAR name="title"></h2>
+ <h3><TMPL_VAR name="date"></h3>
+ <div><TMPL_VAR name="preview"></div>
+ </div>
+ </TMPL_IF>
+
+ <TMPL_IF name="edit">
+ <!-- create or edit articles -->
+ <form id="edit" action="/admin.cgi" method="post">
+ <p><input size="80" name="title" value="<TMPL_VAR name="title">"> &nbsp; title</p>
+ <p><input size="80" name="uri" value="<TMPL_VAR name="uri">"> &nbsp; uri</p>
+ <p><input size="80" name="tags" value="<TMPL_VAR name="tags">"> &nbsp; tags (e.g. <em>foo,bar,baz</em>)</p>
+ <p><textarea rows="15" cols="80" name="body"><TMPL_VAR name="body"></textarea></p>
+ <p><input type="submit" name="preview" value="preview"> &nbsp;&nbsp; <input type="submit" name="save" value="save"></p>
+ <input type="hidden" name="view" value="edit">
+ <TMPL_IF name="id">
+ <input type="hidden" name="id" value="<TMPL_VAR name="id">">
+ </TMPL_IF>
+ </form>
+ </TMPL_IF>
+
+ <TMPL_IF name="articles">
+ <!-- manage articles -->
+ <TMPL_LOOP name="articles">
+ <p>
+ <TMPL_UNLESS name="enabled">
+ <a title="Publish" href="/admin.cgi?view=administrate&publish=<TMPL_VAR name="id">"><img src="/themes/<TMPL_VAR name="theme">/images/play.gif" alt="Publish"></a>
+ <TMPL_ELSE>
+ <img src="/themes/<TMPL_VAR name="theme">/images/play-disabled.gif" alt="Publish">
+ </TMPL_UNLESS>
+ <a title="Edit" href="/admin.cgi?view=edit&id=<TMPL_VAR name="id">"><img src="/themes/<TMPL_VAR name="theme">/images/plus.gif" alt="Edit"></a>
+ <TMPL_IF name="enabled">
+ <a title="Draft" href="/admin.cgi?view=administrate&draft=<TMPL_VAR name="id">"><img src="/themes/<TMPL_VAR name="theme">/images/draft.gif" alt="Draft"></a>
+ <TMPL_ELSE>
+ <img src="/themes/<TMPL_VAR name="theme">/images/draft-disabled.gif" alt="Draft">
+ </TMPL_IF>
+ <a title="Delete" href="/admin.cgi?view=administrate&delete=<TMPL_VAR name="id">"><img src="/themes/<TMPL_VAR name="theme">/images/delete.gif" alt="Delete"></a> &nbsp;&nbsp;&nbsp;
+ <TMPL_IF name="enabled">
+ <a id="mod_story" href="/<TMPL_VAR name="year">/<TMPL_VAR name="month">/<TMPL_VAR name="uri">" target="_new">
+ <TMPL_ELSE>
+ <span id="mod_story_disabled">
+ </TMPL_IF>
+ <TMPL_VAR name="title">
+ <TMPL_IF name="enabled">
+ </a>
+ <TMPL_ELSE>
+ </span>
+ </TMPL_IF>
+ &nbsp; submitted by <span id="mod_user"><TMPL_VAR name="author"></span>
+ on <span id="mod_date"><TMPL_VAR name="date"></span>
+ </p>
+ </TMPL_LOOP>
+ </TMPL_IF>
+
+ <TMPL_IF name="comments">
+ <!-- manage comments -->
+ <TMPL_LOOP name="comments">
+ <hr style="height: 1px; width: 100%; color: #999; background-color: #999; border: 0;"></hr>
+ <p>
+ <a id="publish" href="/admin.cgi?view=moderate&publish=<TMPL_VAR name="id">"><img src="/themes/<TMPL_VAR name="theme">/images/check.gif" alt="Publish"></a>
+ <a id="delete" href="/admin.cgi?view=moderate&delete=<TMPL_VAR name="id">"><img src="/themes/<TMPL_VAR name="theme">/images/delete.gif" alt="Delete"></a>
+ &nbsp; comment in article &nbsp;
+ <a id="mod_story" href="/<TMPL_VAR name="article_year">/<TMPL_VAR name="article_month">/<TMPL_VAR name="article_uri">" target="_new"><TMPL_VAR name="article_title"></a>
+ <br>
+ submitted by <span id="mod_user"><TMPL_VAR name="name"></span>
+ at <TMPL_VAR name="date">
+ </p>
+ <p>
+ &quot;<TMPL_VAR name="comment">&quot;
+ </p>
+ </TMPL_LOOP>
+ </TMPL_IF>
+</div>
+
+<pre id="dump"><TMPL_VAR name="dump"></pre>
+
+</body>
+</html>
diff --git a/themes/default/images/asterisk-green.gif b/themes/default/images/asterisk-green.gif
new file mode 100644
index 0000000..cf57939
--- /dev/null
+++ b/themes/default/images/asterisk-green.gif
Binary files differ
diff --git a/themes/default/images/asterisk-red.gif b/themes/default/images/asterisk-red.gif
new file mode 100644
index 0000000..01c2341
--- /dev/null
+++ b/themes/default/images/asterisk-red.gif
Binary files differ
diff --git a/themes/default/images/check.gif b/themes/default/images/check.gif
new file mode 100644
index 0000000..06750c7
--- /dev/null
+++ b/themes/default/images/check.gif
Binary files differ
diff --git a/themes/default/images/delete.gif b/themes/default/images/delete.gif
new file mode 100644
index 0000000..b725248
--- /dev/null
+++ b/themes/default/images/delete.gif
Binary files differ
diff --git a/themes/default/images/draft-disabled.gif b/themes/default/images/draft-disabled.gif
new file mode 100644
index 0000000..1aa73ad
--- /dev/null
+++ b/themes/default/images/draft-disabled.gif
Binary files differ
diff --git a/themes/default/images/draft.gif b/themes/default/images/draft.gif
new file mode 100644
index 0000000..0eee31b
--- /dev/null
+++ b/themes/default/images/draft.gif
Binary files differ
diff --git a/themes/default/images/play-disabled.gif b/themes/default/images/play-disabled.gif
new file mode 100644
index 0000000..7849e9d
--- /dev/null
+++ b/themes/default/images/play-disabled.gif
Binary files differ
diff --git a/themes/default/images/play.gif b/themes/default/images/play.gif
new file mode 100644
index 0000000..506a7a1
--- /dev/null
+++ b/themes/default/images/play.gif
Binary files differ
diff --git a/themes/default/images/plus.gif b/themes/default/images/plus.gif
new file mode 100644
index 0000000..adc2e7f
--- /dev/null
+++ b/themes/default/images/plus.gif
Binary files differ
diff --git a/themes/default/images/xml.gif b/themes/default/images/xml.gif
new file mode 100644
index 0000000..8f7eb6a
--- /dev/null
+++ b/themes/default/images/xml.gif
Binary files differ
diff --git a/themes/default/index.tmpl b/themes/default/index.tmpl
new file mode 100644
index 0000000..dbca045
--- /dev/null
+++ b/themes/default/index.tmpl
@@ -0,0 +1,179 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <title><TMPL_VAR name="title"></title>
+ <meta name="generator" content="Blogsum">
+ <link rel="stylesheet" type="text/css" href="/themes/<TMPL_VAR name="theme">/style.css" title="Default">
+ <link rel="alternate" type="application/rss+xml" href="/rss.xml" title="RSS 1.0">
+ <link rel="alternate" type="application/rss+xml" href="/rss2.xml" title="RSS 2.0">
+ <TMPL_IF name="google_webmaster_id">
+ <meta name="verify-v1" content="<TMPL_VAR name="google_webmaster_id">">
+ </TMPL_IF>
+</head>
+<body>
+<div id="wrapper">
+ <div id="header">
+ <h1>
+ <a href="/"><TMPL_VAR name="title"></a><br>
+ <span><TMPL_VAR name="subtitle"></span>
+ </h1>
+ </div>
+ <div id="main">
+ <TMPL_LOOP name="articles">
+ <div class="article">
+ <h2>
+ <a href="/<TMPL_VAR name="year">/<TMPL_VAR name="month">/<TMPL_VAR name="uri">">
+ <TMPL_VAR name="title"></a>
+ </h2>
+ <h3><TMPL_VAR name="date">
+ <span>by <a href="/Tags/<TMPL_VAR name="author">"><TMPL_VAR name="author"></a></span>
+ </h3>
+ <div>
+ <TMPL_VAR name="body">
+ <TMPL_IF name="readmore">
+ <p>
+ <a href="/<TMPL_VAR name="year">/<TMPL_VAR name="month">/<TMPL_VAR name="uri">">
+ Read the rest of this story...</a>
+ </p>
+ </TMPL_IF>
+ </div>
+ <ul>
+ <TMPL_UNLESS name="comments">
+ <li class="comments_count">
+ <span>Comments <a href="/<TMPL_VAR name="year">/<TMPL_VAR name="month">/<TMPL_VAR name="uri">#comments">(<TMPL_VAR name="comments_count">)</a></span>
+ </li>
+ </TMPL_UNLESS>
+ <TMPL_IF name="tag_loop">
+ <li class="tags">
+ <span>Tags:
+ <TMPL_LOOP name="tag_loop">
+ &nbsp; <a href="/Tags/<TMPL_VAR name="tag">"> <TMPL_VAR name="tag"></a>
+ </TMPL_LOOP>
+ </span>
+ </li>
+ </TMPL_IF>
+ </ul>
+ <TMPL_IF name="comments">
+ <div class="comments">
+ <a name="comments"></a>
+ <h4>Comments</h4>
+ <TMPL_LOOP name="comments">
+ <h5>at <span><TMPL_VAR name="date"></span>,
+ <TMPL_IF name="url">
+ <a href="<TMPL_VAR name="url">">
+ </TMPL_IF>
+ <TMPL_VAR name="name">
+ <TMPL_IF name="url"></a></TMPL_IF>
+ wrote in to say...
+ </h5>
+ <p>
+ <TMPL_VAR name="comment">
+ </p>
+ </TMPL_LOOP>
+ </div>
+ </TMPL_IF>
+ </div>
+ </TMPL_LOOP>
+ <!-- end of articles -->
+ <!-- error and notification message handling -->
+ <TMPL_IF name="error">
+ <h3 class="error">
+ <img src="/themes/<TMPL_VAR name="theme">/images/asterisk-red.gif" style="height: 20px; padding-right: 5px;"> <TMPL_VAR name="error">
+ </h3>
+ </TMPL_IF>
+ <TMPL_IF name="message">
+ <h3 class="message">
+ <img src="/themes/<TMPL_VAR name="theme">/images/asterisk-green.gif" style="height: 20px; padding-right: 5px;"> <TMPL_VAR name="message">
+ </h3>
+ </TMPL_IF>
+
+ <!-- comment submission form -->
+ <TMPL_IF name="comment_form">
+ <div class="comment_form">
+ <p>Add a comment:</p>
+ <form action="/index.cgi" method="post">
+ <p><input name="name" size="40" maxlength="100" value="<TMPL_VAR name="name">"> &nbsp; name</p>
+ <p><input name="email" size="40" maxlength="100" value="<TMPL_VAR name="email">"> &nbsp; email</p>
+ <p><input name="url" size="40" maxlength="100" value="<TMPL_VAR name="url">"> &nbsp; url</p>
+ <p>max length <TMPL_VAR name="comment_max_length"> chars<br>
+ <textarea rows="3" cols="30" name="comment"><TMPL_VAR name="comment"></textarea>
+ </p>
+ <script src="<TMPL_VAR name="captcha_api_server">/challenge?k=<TMPL_VAR name="captcha_pubkey">" type="text/javascript"></script>
+ <noscript>
+ <iframe frameborder="0" height="300" src="<TMPL_VAR name="captcha_api_server">/noscript?k=<TMPL_VAR name="captcha_pubkey">" width="500"></iframe>
+ <textarea cols="40" name="recaptcha_challenge_field" rows="3"></textarea>
+ <input name="recaptcha_response_field" type="hidden" value="manual_challenge">
+ </noscript>
+ <p><input type="submit" name="submit" value="submit comment"></p>
+ <input type="hidden" name="id" value="<TMPL_VAR name="id">">
+ </form>
+ </div>
+ </TMPL_IF>
+ </div> <!-- end of #main -->
+ <div id="sidebar">
+ <div id="archive">
+ <h3>Archive</h3>
+ <TMPL_LOOP name="archives">
+ <ul>
+ <li><a href="/<TMPL_VAR name="year">/"><TMPL_VAR name="year"></a><span class="count"> (<TMPL_VAR name="count">)</span>
+ <ul>
+ <TMPL_LOOP name="month_loop">
+ <li><a href="/<TMPL_VAR name="year">/<TMPL_VAR name="month">/"><TMPL_VAR name="month_name"></a><TMPL_IF name="count"><span class="count"> (<TMPL_VAR name="count">)</span></TMPL_IF>
+ <TMPL_IF name="uri_loop">
+ <ul>
+ <TMPL_LOOP name="uri_loop">
+ <li><a title="<TMPL_VAR name="full_title">" href="/<TMPL_VAR name="year">/<TMPL_VAR name="month">/<TMPL_VAR name="uri">"><TMPL_VAR name="title"></a></li>
+ </TMPL_LOOP>
+ </ul>
+ </TMPL_IF>
+ </li>
+ </TMPL_LOOP>
+ </ul>
+ </li>
+ </ul>
+ </TMPL_LOOP>
+ </div>
+ <div id="tagcloud">
+ <h3>Tag Cloud</h3>
+ <ul>
+ <TMPL_LOOP name="tagcloud">
+ <li class="tagcloud_<TMPL_VAR name="scale">"><a href="/Tags/<TMPL_VAR name="tag">"><TMPL_VAR name="tag"></a></li>
+ </TMPL_LOOP>
+ </ul>
+ </div>
+ <div id="feeds">
+ <h3>Feeds</h3>
+ <ul>
+ <li><a href="/rss.xml"><img src="/themes/<TMPL_VAR name="theme">/images/xml.gif" alt="RSS 1.0">RSS 1.0</a></li>
+ <li><a href="/rss2.xml"><img src="/themes/<TMPL_VAR name="theme">/images/xml.gif" alt="RSS 2.0">RSS 2.0</a></li>
+ </ul>
+ </div>
+ </div> <!-- end of sidebar -->
+ <div id="footer">
+ <ul>
+ <TMPL_IF name="page_prev"><li class="lastpage"><a href="/Page/<TMPL_VAR name="page_prev">">Newer Articles</a></li></TMPL_IF>
+ <TMPL_IF name="page_next"><li class="nextpage"><a href="/Page/<TMPL_VAR name="page_next">">Older Articles</a></li></TMPL_IF>
+ <li>&copy; <TMPL_VAR name="copyright"></li>
+ </ul>
+ </div> <!-- end of footer -->
+</div> <!-- end of wrapper -->
+
+<pre id="dump"><TMPL_VAR name="dump"></pre>
+
+<TMPL_IF name="google_analytics_id"> <!-- analytics support -->
+<script type="text/javascript">
+ var gaJsHost = (("https:" == document.location.protocol) ? "https://ssl." : "http://www.");
+ document.write(unescape("%3Cscript src='" + gaJsHost + "google-analytics.com/ga.js' type='text/javascript'%3E%3C/script%3E"));
+</script>
+<script type="text/javascript">
+ try {
+ var pageTracker = _gat._getTracker("<TMPL_VAR name="google_analytics_id">");
+ pageTracker._trackPageview();
+ } catch(err) {}
+</script>
+</TMPL_IF>
+
+</body>
+</html>
diff --git a/themes/default/style.css b/themes/default/style.css
new file mode 100644
index 0000000..7e2f549
--- /dev/null
+++ b/themes/default/style.css
@@ -0,0 +1,87 @@
+/* global styles */
+body { font-family: Georgia, Palatino, Palatino Linotype, Times, Times New Roman, serif; text-align: left; }
+div, h1, h2, h3, h4, h5 { padding: 0; margin: 0; }
+h1 { font-size: 1.8em; }
+h2 { font-size: 1.3em; }
+h3 { font-size: 0.9em; }
+img { border: 0; }
+p { font-size: 0.8em; }
+pre { font-size: 0.7em; background-color: #ccc; padding: 15px 25px; }
+pre, tt { font-family: Courier; }
+
+/* page header */
+#header { padding: .5em 0 1.5em 0; }
+#header a { text-decoration: none; color: #aaa; }
+#header h1 span { font-size: 0.5em; color: #ccc; }
+#header h3 a:hover { color: #777; }
+
+/* page content wrapper */
+#wrapper { margin-left: 15%; margin-right: 10%; }
+
+/* article wrapper */
+#main { width: 65%; float: left; position: relative; }
+
+/* admin functions */
+#header #view { text-decoration: none; color: #777; }
+#mod_story, #mod_story_disabled { font-size: 1.3em; font-weight: bold; }
+#mod_user, #mod_date { font-size: 1.1em; font-weight: bold; }
+#mod_story_disabled { color: #999; }
+#preview { padding: 3% 5%; margin: 0 30% 10% 0; background-color: #fff; border: 1px dotted black; }
+#preview h2 { color: #a14732; }
+#preview div a { color: #a14732; text-decoration: none; border-bottom: 1px dashed; }
+#preview ul { padding: 0; list-style: none; }
+
+/* article display */
+.article { width: 100%; padding: 4% 0; }
+.article h2 { border-bottom: 1px solid #ccc; }
+.article h2 a { text-decoration: none; }
+.article h2 a, a#mod_story { color: #a14732; }
+.article h3 { margin: 18px 0 10px 0; }
+.article h3 span { font-size: 0.9em; color: #999; }
+.article h3 span a { text-decoration: none; font-weight: normal; color: #09c; border-bottom: 1px dashed; }
+.article div a { color: #a14732; text-decoration: none; border-bottom: 1px dashed; }
+.article ul { padding: 0; list-style: none; }
+.article ul li.comments_count { width: 20%; float: left; }
+.article ul li.tags { width: 80%; float: right; text-align: right; }
+.article ul li span, .tags span { font-size: 0.7em; }
+.article ul li a, .tags span a { color: #09c; }
+.article .comments h4 { padding-top: 30px; }
+.article .comments h5 { font-size: 0.8em; font-weight: normal; }
+.article .comments h5 span { font-size: 1.0em; font-weight: bold; }
+.article .comments p { padding: 0 0 18px 10px; }
+.article .tags span a { text-decoration: none; border-bottom: 1px dashed; }
+
+/* sidebar archive/rss styles */
+#sidebar { float: left; clear: none; position: relative; width: 30%; padding: 2% 0 0 5%; font-size: 0.8em; }
+#sidebar h3 { font-size: 1.4em; color: #777; padding: 4px 0 17px 0; }
+#sidebar h3 { padding-bottom: 5px; margin-bottom: 10px; border-bottom: 1px solid #ccc; }
+#sidebar ul { list-style: none; padding: 0; margin: 0; }
+#sidebar li span { color: #777; padding-left: 5px; }
+#sidebar a { color: #c66; text-decoration: none; font-weight: bold; }
+#sidebar ul li ul { padding-left: 20px; }
+#sidebar #tagcloud h3, #feeds h3 { padding-top: 30px; }
+#sidebar #feeds img { padding-right: 7px; }
+
+/* tag cloud */
+#tagcloud li { display: inline; }
+#tagcloud .tagcloud_0 a { font-size: 1.0em; color: #8ea0d2; }
+#tagcloud .tagcloud_1 a { font-size: 1.2em; color: #7c91cb; }
+#tagcloud .tagcloud_2 a { font-size: 1.5em; color: #6981c3; }
+#tagcloud .tagcloud_3 a { font-size: 1.8em; color: #5772bc; }
+#tagcloud .tagcloud_4 a { font-size: 2.1em; color: #4764b3; }
+#tagcloud .tagcloud_5 a { font-size: 2.4em; color: #405aa0; }
+
+/* footer styles */
+#footer { padding-top: 5%; clear: both; text-align: center; }
+#footer ul { list-style: none; padding: 0; margin: 0; }
+#footer li { padding: 15px 0; font-size: 0.9em; color: #aaa; border-top: 1px solid; }
+#footer ul li.lastpage { float: left; border: none; }
+#footer ul li.nextpage { float: right; text-align: right; border: none; }
+#footer a { font-size: 0.9em; text-decoration: none; color: #777; }
+#footer a:hover { text-decoration: underline; color: #09c; }
+
+/* messages and debugging */
+h3.error { padding-top: 2%; font-size: 1.1em; color: #c00; }
+h3.message { padding-top: 2%; font-size: 0.9em; color: #060; }
+pre#dump { background-color: #fff; }
+