<?php
/**
* api: php
* type: handler
* title: Follow The Thread
* description: Straightforward threaded discussion forum
* version: 0.3
* category: discussion
* depends: HTMLPurifier, input
* config:
* <var name="forum_cfg[categories]" type="list" default="discussion,documentation" help="Comma-separated list of thread classifiers"/>
*
*
* Implements a minimalistic web forum.
* Primary goals are:
* β Threaded discussions in place of bulletin board blabber.
* β Contemporary security over restriction gimmicks.
* β Usability instead of UI featuritis.
* β Open access in lieu of accounteritis.
*
* Single table database:
* [id] INT PRIMARY KEY NOT NULL UNIQUE,
* [pid] INT NOT NULL DEFAULT(0) REFERENCES [forum] ([id]),
* [gid] INT NOT NULL REFERENCES [forum] ([id]),
* [tag] VARCHAR (0, 32) NOT NULL DEFAULT('discussion'),
* [summary] VARCHAR (0, 200) NOT NULL,
* [source] TEXT,
* [html] TEXT,
* [excerpt] TEXT,
* [author] VARCHAR (0, 80),
* [miniature] TEXT,
* [t_published] INT NOT NULL,
* [edit_token] VARCHAR (16, 64)
*
* Templating
* β Requires a template wrapper with <ul class=forum> prepared.
* β Index page must provide a quirky switch block, to dispatch depending
* on $_GET[name] function (one of post, edit, submit).
* β But just $forum->index() for output.
* β forum_entry.php for rendering a table entry.
* β forum_submit_form.php for post editing.
*
* Behaviour
* β Forum index is grouped by threads. Threads with new additions go on top now.
* But within threads the oldest entries come first.
* β Editing token is valid for 48 hours.
* β Admin settings must be inported manually from main application (->$is_admin).
*
* Javascript
* β AJAX is mainly used for submission to divert spambots.
* β Posts are folded to an excerpt, until clicked.
*
* Configuration
* β Basically just the categories are configurable.
*
*
*
*/
/**
* Decorative classification/grouping of threads.
*
*/
global $forum_cfg;
$forum_cfg["categories"] = "discussion,projects,announcement,code,documentation,autoupdate";
/**
* Callbacks, dispatcher and handler.
*
*/
class forum {
/**
* Can be set externally, depending on application logic.
*
*/
var $is_admin = 0;
var $can_edit = 1;
/**
* NOP
*
*/
function __construct() {
}
/**
* Show forum listing
*
*/
function index($page=0) {
// Fetch thread groups (attached to root gid=0)
$entries = db("
WITH grouped
AS (SELECT gid, (MAX(t_published) + gid/1000) AS gid_group
FROM forum
GROUP BY gid
LIMIT 50
OFFSET ?*50
)
SELECT *
FROM forum
LEFT JOIN grouped
ON forum.gid=grouped.gid
ORDER BY gid_group DESC, forum.gid DESC, t_published ASC;
", $page);
// Iterate over groups
$last = 0;
$group = array();
foreach ($entries as $e) {
if ($e["gid"] != $last) {
$this->show_thread($group);
$group = array();
}
$group[] = $e;
$last = $e["gid"];
}
$this->show_thread($group);
}
/**
* Iterate over grouped entry list
* and recursively output posts.
*
*/
function show_thread($group, $pid=0) {
#-- find available parent ids
$parents = array_column($group, "pid");
#-- step throuh
foreach ($group as $entry) {
#-- show if associated
if ($entry["pid"] == $pid) {
$entry["miniature"] or $entry["miniature"] = "/img/user.png";
include("template/forum_entry.php");
#-- Nest its children
if (in_array($entry["id"], $parents)) {
print " <ul>\n";
$this->show_thread($group, $entry["id"]);
print " </ul>\n";
}
print " </li>\n";
}
}
}
/**
* Load a single entry.
*
*/
function entry($id) {
}
/**
* Accept POST input and populate new forum post.
* Adds an reply if ?pid= is not zero.
* Doubles as edit function if ?id= is present.
*
*/
function submit($INSERT="INSERT") {
#-- Prepare some fields
$data = array(
"id" => NULL,
"pid" => $_POST->int["pid"],
"gid" => NULL,
"author" => $_POST->text->lengthβ¦30->html["author"],
"miniature" => $_POST->text->lengthβ¦200->html["image"],
"tag" => $_POST->text->lengthβ¦20->html["tag"],
"summary" => $_POST->text->lengthβ¦120->html["summary"],
"source" => $_POST->nocontrol->lengthβ¦12000["source"],
"html" => "",
"excerpt" => "",
"t_published" => time(),
"edit_token" => $this->edit_token(),
);
#-- Source to HTML
$data = $this->prepare_output($data);
#-- Reject too minor submisions
if (strlen("$data[source]$data[summary]") < 100) {
exit("<p class=warning>Your post was a little too coarse. Please elaborate to keep discussions going.</p>");
}
#-- Edit
if ($id = $_POST->int["id"]) {
$prev = $this->edit_keep($this->edit_entry($id));
$data = array_merge($data, $prev);
$INSERT = "REPLACE";
# var_dump($INSERT, $data, $prev);
}
#-- Reply
elseif ($data["pid"]) {
$data["gid"] = $this->group_id($data["pid"]);
}
#print_r($data);
/**
* Store entry
* β Find maximum ID
* β Use as new id, and group id if new
* β Else keep previous pid and gid/id
*
* $data and $ids are split up before, so the :? and ::
* placeholders don't consume all fields.
*
*/
# $GLOBALS["db"]->setAttribute(PDO::SQLITE_ATTR_EXTENDED_RESULT_CODES, true);
$ids = array_splice($data, 0, 3); // extract id,pid,gid
$ok = db("
$INSERT INTO forum (id, pid, gid, :?)
VALUES (
IFNULL(:id, (SELECT IFNULL(MAX(id), 0) + 1 AS id FROM forum)),
IFNULL(:pid, 0),
COALESCE(:gid, :id, (SELECT IFNULL(MAX(id), 0) + 1 AS id FROM forum)),
::
);
", $data, $data, $ids);
#-- return rendered
if ($ok) {
$data["id"] = db()->lastInsertId();
$data["pid"] = 0;
if ($data["id"]) {
$this->show_thread([$data], 0);
}
else {
header("Status: 500 Error");
print_r($ok->errorInfo());
}
}
}
#-- Editing timeout / permission
function edit_permission($prev) {
return
$this->is_admin
or
$_COOKIE->name["edit_token"] == $prev["edit_token"]
and
$prev["t_published"] + 48*3600 > time();
}
#-- Retrieves or sets edit_token
function edit_token() {
if (empty($token = $_COOKIE->name["edit_token"])) {
setcookie("edit_token", $token = sha1(serialize($_SERVER)), time()+7*24*3600);
}
return $token;
}
/**
* Retrieve post entry, check edit permission, and/or set defaults
*
* β For forum/edit requests fetches the existing post content.
* β Also checks editing permissions (token),
* or e.g. existing OpenID logon ($this->can_edit is set from main site).
*
*/
function edit_entry($id) {
if ($prev = db("SELECT * FROM forum WHERE id=?", $id)->fetch()) {
if (!$this->can_edit) {
exit("<p class=warning>You aren't logged in on the main site. Please associate an OpenID account.</p>");
}
if (!$this->edit_permission($prev)) {
exit("<p class=warning>Entry not editable. (The edit token does not match, or the article is too old for editing.)</p>");
}
return $prev;
}
exit("<p class=error>Post #$id does not exist.</p>");
}
#-- Unsets a few fields for replying.
function edit_keep($prev) {
$copy = array_flip(str_getcsv("id,pid,gid,miniature,t_published,edit_token"));
return array_intersect_key($prev, $copy);
}
#-- Copy thread/group id from parent post etc.
function group_id($parent_id) {
if ($prev = db("SELECT gid FROM forum WHERE id = ?", $parent_id)) {
return $prev->gid;
}
return 0;
}
#-- Convert Markdown/BB source into HTML, create excerpt, prepare user avatar
function prepare_output($data) {
#-- Content
define("HTMLPURIFIER_PREFIX", "phar://lib/htmlpurifier.phar/standalone/");
$md = new Parsedown();
$data["html"] = input::purify($md->parse($data["source"]));
$data["excerpt"] = input::html(substr(strip_tags($data["html"]), 0, 320));
#-- Author
if (strlen($data["miniature"]) and $type = $_POST->in_array("img_type", "gravatar,identicon,monsterid,wavatar,retro"))
{
$data["miniature"] = "http://www.gravatar.com/avatar/"
. md5($data["miniature"])
. ".jpeg?s=16" . ($type == "gravatar" ? "" : "&d=$type");
}
return $data;
}
/**
* Output submit <form>
*
* β Provides a few blank fields.
* β Injects cookie defaults if fresh submission / not an edit.
* β Escapes previous content for edit_form() calls.
*
*/
function submit_form($pid=0, $id=0, $data=array()) {
global $forum_cfg;
extract(array_merge(
array_fill_keys(str_getcsv("author,image,tag,summary,source"), ""),
$data ? array() : $_COOKIE->list->text->html["author,image"],
$data // all fields excerpt `$source` are already HTML-escaped
));
$source = input::html($source);
include("template/forum_submit_form.php");
}
/**
* Show editing <form> instead with prefilled previous data.
*
* β Retrieves previous post content, checks permissions at that.
* β Outputs edit form (replied for AJAX $.load() request)
*
*/
function edit_form($pid=0, $id=0) {
$data = $this->edit_entry($id);
$this->submit_form(0, 0, $data);
}
}