├── .gitignore ├── README.md ├── editor.js ├── forum.php └── index.php /.gitignore: -------------------------------------------------------------------------------- 1 | # This is just to prevent my editor (Aptana) to sending this files 2 | .project 3 | .settings/* 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ForumFive v2 2 | 3 | A lightweight PHP forum system in less than ~~5kB~~ 10kB. Features email login system, DNSBL checking, rate-limiting, IP logging, HTML support (including youtube/vimeo movies), moderator tools, user profiles, and a nice clean interface. 4 | 5 | This system self-installs and uses an SQLite database making backups very simple. 6 | 7 | Requires the system to be in the root directory `/` of the site. If you already have a website then place this system in a subdomain such as (`forum.example.com`). 8 | 9 | Download the system, edit `forum.php`, and enjoy! 10 | 11 | ![ScreenShot](http://i.imgur.com/jMxc8.png) 12 | 13 | #### [Demo System](http://talk.davidpennington.me/) (without admin controls) 14 | 15 | [David Pennington](http://davidpennington.me) 16 | [MIT License](http://david.mit-license.org/) 17 | -------------------------------------------------------------------------------- /editor.js: -------------------------------------------------------------------------------- 1 | /* 2 | // Returns a function, that, as long as it continues to be invoked, will not 3 | // be triggered. The function will be called after it stops being called for 4 | // N milliseconds. If `immediate` is passed, trigger the function on the 5 | // leading edge, instead of the trailing. 6 | function debounce(func, wait, immediate) { 7 | var timeout; 8 | return function() { 9 | var context = this, args = arguments; 10 | var later = function() { 11 | timeout = null; 12 | if (!immediate) func.apply(context, args); 13 | }; 14 | var callNow = immediate && !timeout; 15 | clearTimeout(timeout); 16 | timeout = setTimeout(later, wait); 17 | if (callNow) func.apply(context, args); 18 | }; 19 | }; 20 | 21 | var updateTextarea = debounce(function(html) { 22 | $('textarea[name=body]').val(html); 23 | }, 250); 24 | */ 25 | 26 | // Use summernote for the HTML WYSIWYG editor 27 | $(document).ready(function() { 28 | 29 | // Progressive Enhancement 30 | $('textarea').hide(); 31 | 32 | $('form').on('submit', function() { 33 | $('textarea[name="body"]').html($('.summernote').code()); 34 | }); 35 | 36 | $('.summernote').summernote({ 37 | styleWithSpan: false, 38 | minHeight: 300, 39 | /* Not working, switching to onSubmit event 40 | onkeyup: function(e) { 41 | updateTextarea($('.summernote').code()); 42 | }, 43 | onblur: function(e) { 44 | updateTextarea($('.summernote').code()); 45 | }, 46 | onChange: function(e) { 47 | updateTextarea($('.summernote').code()); 48 | },*/ 49 | // Fix Microsoft Word Pastes 50 | onpaste: function() { 51 | setTimeout(function() { 52 | var html = $(this).code(); 53 | var newHtml = $('
').append(html) 54 | .find('*').removeAttr('style').removeAttr('lang').removeAttr('class').removeAttr('width').removeAttr('border').removeAttr('valign').end() 55 | .find('table').removeAttr('cellspacing').removeAttr('cellpadding').addClass('table').end() 56 | .html() 57 | .replace(/<\/o:p>/g, '') 58 | .replace(/ <\/o:p>/g, ''); 59 | $(this).code('').code(newHtml); 60 | }, 10); 61 | }, 62 | toolbar: [ 63 | ['style', ['style']], 64 | ['font', ['bold', 'italic', 'underline', 'strikethrough', 'clear']], 65 | ['insert', ['picture', 'link', 'video']], 66 | ['para', ['ul', 'ol', 'paragraph', 'table']], 67 | ['misc', ['undo', 'redo', 'fullscreen', 'codeview', 'help']], 68 | ] 69 | }); 70 | }); -------------------------------------------------------------------------------- /forum.php: -------------------------------------------------------------------------------- 1 | PDO::ERRMODE_EXCEPTION)) 40 | { 41 | static $db; 42 | $db = $db ?: (new PDO('sqlite:' . DB, 0, 0, $args)); 43 | return $db; 44 | } 45 | 46 | function query($sql, $params = NULL) 47 | { 48 | $s = db()->prepare($sql); 49 | $s->execute(array_values((array) $params)); 50 | return $s; 51 | } 52 | 53 | function insert($table, $data) 54 | { 55 | query("INSERT INTO $table(" . join(',', array_keys($data)) . ')VALUES(' 56 | . str_repeat('?,', count($data)-1). '?)', $data); 57 | return db()->lastInsertId(); 58 | } 59 | 60 | function update($table, $data, $value) 61 | { 62 | return query("UPDATE $table SET ". join('`=?,`', array_keys($data)) 63 | . "=?WHERE id=?", $data + array($value))->rowCount(); 64 | } 65 | 66 | function delete($table, $field, $value) 67 | { 68 | return query("DELETE FROM $table WHERE $field = ?", $value)->rowCount(); 69 | } 70 | 71 | function filter($string) 72 | { 73 | return nl2br(htmlspecialchars(trim(@iconv('UTF-8', 'UTF-8//TRANSLIT//IGNORE', $string)))); 74 | } 75 | 76 | session_start(); 77 | $_SESSION += array('email' => '', 'admin' => '', 'trusted' => 0, 'check' => '', 'posts' => 0); 78 | $ip = getenv('REMOTE_ADDR'); 79 | 80 | if(IP_CHECK AND ! $_SESSION['check']) 81 | { 82 | checkdnsrr(join('.',array_reverse(explode('.',$ip))).".opm.tornevall.org","A") && die('Bot IP'); 83 | $_SESSION['check'] = 1; 84 | } 85 | 86 | // Append to the array: Topic ID, Topic Headline, Topic/Comment Body, Comment ID, Delete request 87 | extract($_REQUEST + array( 88 | 'email' => '', 'topicID' => 0, 'commentID' => 0, 'title' => 0, 'body' => 0, 'delete' => 0 89 | )); 90 | 91 | if( ! is_file(DB)) 92 | { 93 | //unlink(DB); 94 | 95 | /* 96 | * (u)pdated timestamp and (c)reated timestamp 97 | */ 98 | query('CREATE TABLE topic ( 99 | id INTEGER PRIMARY KEY, 100 | u INTEGER, 101 | c INTEGER, 102 | ip TEXT, 103 | email TEXT, 104 | title TEXT, 105 | body TEXT 106 | )'); 107 | 108 | query('CREATE TABLE comment ( 109 | id INTEGER PRIMARY KEY, 110 | topic_id INTEGER, 111 | c INTEGER, 112 | ip TEXT, 113 | email TEXT, 114 | body TEXT 115 | )'); 116 | 117 | query('CREATE TABLE user ( 118 | id INTEGER PRIMARY KEY, 119 | email TEXT UNIQUE not null, 120 | logins INTEGER DEFAULT 0, 121 | banned INTEGER DEFAULT 0, 122 | posts INTEGER DEFAULT 0, 123 | c INTEGER 124 | )'); 125 | 126 | insert('user', array('c' => time(), 'email' => 'user@example.com')); 127 | 128 | for ($i=0; $i < 3; $i++) 129 | { 130 | $id = insert('topic', array( 131 | 'u' => time() + $i, 132 | 'c' => time() + WAIT + $i, 133 | 'ip' => $ip, 134 | 'email' => 'user@example.com', 135 | 'title' => "This is a topic about $i stuff", 136 | 'body' => "

This is topic $i

")); 137 | 138 | for ($x=0; $x < 5; $x++) 139 | { 140 | insert('comment', array( 141 | 'topic_id' => $id, 142 | 'c' => time() + WAIT + $x, 143 | 'ip' => $ip, 144 | 'email' => 'user@example.com', 145 | 'body' => "

This is comment $x

")); 146 | } 147 | 148 | unset($id); 149 | } 150 | } 151 | 152 | // Login with BrowserID 153 | if(isset($_POST['a'])) 154 | { 155 | sleep(1); // rate-limit 156 | 157 | curl_setopt_array($h = curl_init('https://verifier.login.persona.org/verify'),array( 158 | CURLOPT_RETURNTRANSFER=>1, 159 | CURLOPT_POST=>1, 160 | CURLOPT_POSTFIELDS=>"assertion=" .$_POST['a'] . "&audience=http://".getenv('HTTP_HOST') 161 | )); 162 | 163 | if(($d = json_decode(curl_exec($h))) && $d->status == 'okay') 164 | { 165 | $emails = file(EMAIL_BLACKLIST, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 166 | 167 | if(in_array($d->email, $emails)) { 168 | return new Exception("EMAIL"); 169 | } 170 | 171 | // Changing emails by logging in while logged in 172 | if($_SESSION['email'] AND $_SESSION['email'] !== $d->email) { 173 | query('UPDATE user SET email = ? WHERE email = ?', array($d->email, $_SESSION['email'])); 174 | } 175 | 176 | $user = query('SELECT * FROM user WHERE email = ?', array($d->email))->fetch(); 177 | 178 | if($user) { 179 | 180 | if($user->banned) { 181 | return new Exception("BANNED"); 182 | } 183 | 184 | // We stop doing flood-limiting as much for trusted members 185 | $_SESSION['posts'] = $user->posts; 186 | 187 | // We show emails for trusted members 188 | if($_SESSION['posts'] >= TRUST_COUNT) { 189 | $_SESSION['trusted'] = 1; 190 | } 191 | 192 | query('UPDATE "user" SET logins = (logins + 1) WHERE email = ?', array($d->email)); 193 | 194 | } else { 195 | 196 | if( ! ALLOW_REGISTER) { 197 | return new Exception("REGISTER"); 198 | } 199 | 200 | insert('user', array( 201 | 'email' => $d->email, 202 | 'c' => time() 203 | )); 204 | 205 | } 206 | 207 | if(strpos(ADMIN, ($_SESSION['email'] = $d->email))) 208 | { 209 | $_SESSION['admin'] = true; 210 | } 211 | } 212 | 213 | ob_end_clean(); 214 | die('{status:true}'); 215 | } 216 | 217 | 218 | // We don't want to waste resources on every page re-loading the user record 219 | // Only check for banning for the account when they try to modify the site 220 | if($_SESSION['email'] AND ($body OR $delete)) { 221 | 222 | $banned = query('SELECT banned FROM user WHERE email = ?', array($_SESSION['email']))->fetchColumn(); 223 | 224 | if($banned) { 225 | $_SESSION['email'] = $_SESSION['admin'] = null; 226 | return new Exception("BANNED"); 227 | } 228 | } 229 | 230 | // Trying to delete a topic/comment? 231 | if($delete && $_SESSION['admin']) 232 | { 233 | // Also delete the comments that belong to this topic 234 | if($delete == 'topic') { 235 | 236 | delete('comment', 'topic_id', $topicID); 237 | delete('topic', 'id', $topicID); 238 | 239 | return new Exception("REMOVED"); 240 | 241 | } elseif($delete == 'user') { // We don't actually delete users... 242 | 243 | query('UPDATE user SET banned = 1 WHERE email = ?', array($email)); 244 | 245 | } elseif($delete == 'unban') { // We don't actually delete users... 246 | 247 | query('UPDATE user SET banned = 0 WHERE email = ?', array($email)); 248 | 249 | } else if($commentID) { 250 | 251 | delete('comment', 'id', $commentID); 252 | } 253 | } 254 | 255 | // Fetch the topic if we are loading it 256 | if($topicID && !($topic = query('SELECT * FROM topic WHERE id = ?', $topicID)->fetch())) 257 | { 258 | return new Exception("MISSING"); 259 | } 260 | 261 | // Fetch the user if we are loading them 262 | if($email) { 263 | 264 | // Only admin's and the user themselves can see this page 265 | if( ! $_SESSION['admin'] AND $email !== $_SESSION['email']) { 266 | return new Exception('MISSING'); 267 | } 268 | 269 | if(!($user = query('SELECT * FROM user WHERE email = ?', $email)->fetch())) { 270 | return new Exception("MISSING"); 271 | } 272 | } 273 | 274 | // We are inserting a new topic or comment 275 | if($body && $_SESSION['email']) 276 | { 277 | if(mb_strlen($body) > ($topicID ? 2000 : 7000)) { 278 | return new Exception('LENGTH'); 279 | } 280 | 281 | $wait = WAIT; 282 | // Admin's and trusted users can post more often 283 | if($_SESSION['admin'] OR $_SESSION['posts'] >= TRUST_COUNT) { 284 | $_SESSION['trusted'] = true; 285 | $wait = TRUSTED_WAIT; 286 | } 287 | 288 | // Make sure they haven't posted more than twice every 3 minutes 289 | $sql = 'SELECT COUNT(*) FROM '. ($topicID ? 'comment' : 'topic').' WHERE ip = ? AND c > ?'; 290 | if(query($sql, array($ip, time()-$wait))->fetchColumn() > 2) { 291 | sleep(1); // Flood control 292 | return new Exception("OFTEN"); 293 | } 294 | 295 | $body = DOMCleaner::purify($body); 296 | 297 | // Assume we are inserting a topic 298 | $data = array( 299 | 'c' => time(), 300 | 'ip' => $ip, 301 | 'email' => $_SESSION['email'], 302 | 'body' => $body 303 | ); 304 | 305 | // If this is a comment, add a reference to the topic, then update the topic modified time 306 | if($topicID) { 307 | 308 | $data['topic_id'] = $topicID; 309 | update('topic', array('u' => time()), $topicID); 310 | 311 | } else { 312 | 313 | $data['title'] = filter($title); 314 | $data['u'] = time(); 315 | if( ! $data['title'] OR mb_strlen($data['title']) > 80) { 316 | return new Exception('HEADER'); 317 | } 318 | 319 | } 320 | 321 | insert($topicID ? 'comment' : 'topic', $data); 322 | query('UPDATE user SET posts = (posts + 1) WHERE email = ?', $_SESSION['email']); 323 | $_SESSION['posts']++; 324 | } 325 | 326 | // Close the file now so AJAX an use it 327 | session_write_close(); 328 | 329 | // We are showing a topic 330 | if($email) { 331 | $rows = query('SELECT * FROM comment WHERE email = ? ORDER BY id DESC LIMIT 30', array($email)); 332 | } elseif($topicID) { 333 | $rows = query('SELECT * FROM comment WHERE topic_id = ? ORDER BY id ASC LIMIT 100', array($topicID)); 334 | } else { 335 | $rows = query('SELECT * FROM topic ORDER BY id DESC LIMIT 100'); 336 | } 337 | 338 | 339 | /***************************END****************************/ 340 | 341 | 342 | /** 343 | * DOMCleaner 344 | * 345 | * Requires LIBXML / PHP DOM which is now standard in PHP 346 | * @author David Pennington 347 | * @url http://github.com/xeoncross 348 | */ 349 | class DOMCleaner { 350 | 351 | public static $whitelist = array( 352 | 'a' => array('href'), 353 | 'b', 'em', 'i', 'u', 'strike', 'sup', 'sub', 354 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 355 | 'p', 'blockquote','pre', 'code','ul','ol','li', 356 | 'img' => array('src', 'alt', 'title'), 357 | 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 358 | 'br', '#text', 'html', 'body', 359 | 360 | // Youtube, vimeo, etc... 361 | 'iframe' => array('src', 'class', 'allowtransparency', 'allowfullscreen', 'width', 'height', 'frameborder') 362 | ); 363 | 364 | 365 | public static $video_sites = array( 366 | //'www.dailymotion.com', 367 | 'www.youtube.com', 368 | 'player.vimeo.com' 369 | ); 370 | 371 | public static function decode($string) 372 | { 373 | while (strcmp($string, ($temp = html_entity_decode($string, ENT_QUOTES, 'UTF-8'))) !== 0) { 374 | $string = $temp; 375 | } 376 | 377 | return $string; 378 | } 379 | 380 | public static function purify($html, array $whitelist = null, $protocols = 'http|https|ftp') 381 | { 382 | libxml_use_internal_errors(true) AND libxml_clear_errors(); 383 | 384 | if (is_object($html)) { 385 | 386 | if ( ! in_array($html->nodeName, array_keys($whitelist))) { 387 | $html->parentNode->removeChild($html); 388 | return; 389 | } 390 | 391 | if ($html->hasChildNodes() === true) { 392 | 393 | // Purify/Delete child elements in reverse order so we don't messup DOM tree 394 | foreach (range($html->childNodes->length - 1, 0) as $i) { 395 | static::purify($html->childNodes->item($i), $whitelist, $protocols); 396 | } 397 | } 398 | 399 | if ($html->hasAttributes() === true) { 400 | 401 | foreach (range($html->attributes->length - 1, 0) as $i) { 402 | $attribute = $html->attributes->item($i); 403 | 404 | if( ! $attribute->value OR ! in_array($attribute->name, $whitelist[$html->nodeName])) { 405 | $html->removeAttributeNode($attribute); 406 | continue; 407 | } 408 | 409 | $value = static::decode($attribute->value); 410 | 411 | if($attribute->name == 'src') { 412 | $domain = parse_url($value, PHP_URL_HOST); 413 | 414 | // Only EVER allow embeds from Youtube / Vimeo 415 | if( ! $domain OR ! in_array($domain, static::$video_sites)) { 416 | $html->removeAttributeNode($attribute); 417 | } 418 | 419 | continue; 420 | } 421 | 422 | if(strpos($value, ':') !== false) { 423 | if(preg_match('~([^:]{0,10}):~', $value, $match)) { 424 | if ( ! in_array(strtolower(trim($match[1])), $protocols)) { 425 | $html->removeAttributeNode($attribute); 426 | } 427 | } 428 | } 429 | } 430 | } 431 | 432 | return; 433 | } 434 | 435 | if( ! trim($html)) { 436 | return; 437 | } 438 | 439 | $dom = new DomDocument(); 440 | if(! $dom->loadHTML($html)) { 441 | return; 442 | } 443 | 444 | if( ! $whitelist) { 445 | $whitelist = static::$whitelist; 446 | } 447 | 448 | // Allow tags to be given without the "tag => array()" syntax 449 | foreach ($whitelist as $tag => $attributes) { 450 | if (is_int($tag)) { 451 | unset($whitelist[$tag]); 452 | $whitelist[$attributes] = array(); 453 | } 454 | } 455 | 456 | $protocols = explode('|', strtolower($protocols)); 457 | static::purify($dom->documentElement, $whitelist, $protocols); 458 | 459 | return preg_replace('~<(?:!DOCTYPE|/?(?:html|body))[^>]*>\s*~i', '', $dom->saveHTML()); 460 | } 461 | 462 | } 463 | 464 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <?php if(!empty($topic)) print $topic['title'] . ' - '; ?>Forum Five 14 | 15 | 16 | 17 | 18 | 19 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 112 | 113 | 114 | 115 | 116 |
117 |
118 | 133 |

Forum Five

134 |
135 | 136 |
137 | 138 | 139 | 140 | 141 | 142 |
143 | 144 | 145 | 146 | 147 | 148 |

149 |

Joined

150 |

User has posts and has logged in times

151 | 152 | 153 | 154 | Un-Ban User 155 | 156 | Ban User 157 | 158 | 159 | 160 |
161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | fetchAll() as $row) { ?> 174 | 175 | 178 | 181 | 185 | 188 | 189 | 190 | 191 | 192 |
IPCommentDate
176 | 177 | 179 | view topic 180 | 182 |
183 | delete comment 184 |
186 | 187 |
193 | 194 | 195 | 196 | 197 | 198 |
199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 |
210 | 211 |

212 | 213 | 214 |
- 215 | delete
216 | 217 | 218 | 219 | 220 |
221 |
222 | 223 |
224 | 225 |
226 | fetchAll() as $row) { ?> 227 | 228 |
229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 |
241 | 242 | 243 |

- delete

244 | 245 | 246 | 247 | 248 |
249 |
250 | 251 |
252 | 253 | 254 |
255 | 256 | 257 | 258 |
259 | Leave a Reply 260 |
261 |
262 | 263 |
264 | 265 |
266 | 267 | 268 | 269 | 270 | 271 |
272 |

It's all just talk

273 |

This is a demonstration install of the ForumFive PHP forum system. Please be respectful.

274 |
275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | fetchAll() as $row) { ?> 290 | 291 | 300 | 308 | 311 | 312 | 313 | 314 | 315 |
UserTopicLast Activity
292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 301 |
302 | 303 | 304 | 305 | - delete 306 | 307 |
309 | 310 |
316 | 317 | 318 | 319 |
320 | Create new Topic 321 |
322 | 323 | 324 |
325 |
326 | 327 |
328 | 329 |
330 | 331 |
332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 |
340 | getMessage() == 'MISSING') { ?> 341 | Sorry, we could not find the topic 342 | getMessage() == 'OFTEN') { ?> 343 | Sorry, you can only post twice every minutes. Please wait a few moments and then refresh the page to re-send your post. 344 | getMessage() == 'REMOVED') { ?> 345 | The topic/comment has been removed. 346 | getMessage() == 'EMAIL') { ?> 347 | Sorry, your email provider is banned because of spam accounts. 348 | getMessage() == 'BANNED') { ?> 349 | Sorry, your account is banned. Remember the golden rule, "So whatever you wish that others would do to you, do also to them" - Matthew 7:12 350 | getMessage() == 'LENGTH') { ?> 351 | Sorry, your topic or comment is to long. 352 | getMessage() == 'REGISTER') { ?> 353 | Sorry, registration is currently disabled. 354 | getMessage() == 'HEADER') { ?> 355 | You must have a topic header. 356 | 357 |
358 | 359 | 360 | 361 | ' . print_r($_SESSION, TRUE) . ''; ?> 362 | 363 |
364 | 365 |
366 | 367 | 368 | 369 |

Welcome , you have posted messages. 370 | $_SESSION['posts']) { 371 | print 'You will be a moderator after ' . (TRUST_COUNT - $_SESSION['posts']); ?> more posts.

372 | 373 | 374 | 375 |

Registration is currently disabled. Existing users can still login.

376 | 377 | 378 |

© - Powered by forumfive

379 | 380 | 381 | 382 |
383 | 384 |
385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 452 | 453 | 454 | 455 | --------------------------------------------------------------------------------