├── .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 |  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(/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 |Joined = date('D, M jS Y H:i:s', $user['c']); ?>
150 |User has = $user['posts']; ?> posts and has logged in = $user['logins']; ?> times
151 | 152 | 153 | 154 | Un-Ban User 155 | 156 | Ban User 157 | 158 | 159 | 160 |IP | 167 |Comment | 168 |Date | 169 ||
---|---|---|---|
176 | = $row['ip']; ?> 177 | | 178 |179 | view topic 180 | | 181 |
182 | = date('D, M jS Y ga', $row['c']); ?> 183 | delete comment 184 | |
185 | 186 | = substr(strip_tags($row['body']), 0, 100); ?> 187 | | 188 |
This is a demonstration install of the ForumFive PHP forum system. Please be respectful.
274 |User | 283 |Topic | 284 |Last Activity | 285 |
---|---|---|
292 |
293 |
294 | |
300 |
301 | = $row['title']; ?> 302 | 303 | 304 | 305 | = $row['email']; ?> - delete 306 | 307 | |
308 | 309 | = date('D, M jS Y ga', $row['u']); ?> 310 | | 311 |
= $row['email']; ?> - delete
244 | 245 | 246 | = $row['body']; ?> 247 | 248 |252 | 253 | 254 |