commit 968305d049d02c6dff97723ba3944824046b2db3 Author: flash Date: Tue Dec 24 10:37:16 2024 +0100 Import diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a33d77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/config.php +/.debug +/vendor diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..257479b --- /dev/null +++ b/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "swiftmailer/swiftmailer": "^6.0", + "erusev/parsedown": "^1.7" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..df4c595 --- /dev/null +++ b/composer.lock @@ -0,0 +1,481 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "49f50504731fd9419fd191d68c171bd1", + "packages": [ + { + "name": "doctrine/lexer", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6", + "reference": "5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6", + "shasum": "" + }, + "require": { + "php": "^7.2" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpstan/phpstan": "^0.11.8", + "phpunit/phpunit": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "time": "2019-10-30T14:39:59+00:00" + }, + { + "name": "egulias/email-validator", + "version": "2.1.11", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "92dd169c32f6f55ba570c309d83f5209cefb5e23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/92dd169c32f6f55ba570c309d83f5209cefb5e23", + "reference": "92dd169c32f6f55ba570c309d83f5209cefb5e23", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^1.0.1", + "php": ">= 5.5" + }, + "require-dev": { + "dominicsayers/isemail": "dev-master", + "phpunit/phpunit": "^4.8.35||^5.7||^6.0", + "satooshi/php-coveralls": "^1.0.1", + "symfony/phpunit-bridge": "^4.4@dev" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "EmailValidator" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "time": "2019-08-13T17:33:27+00:00" + }, + { + "name": "erusev/parsedown", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown.git", + "reference": "6d893938171a817f4e9bc9e86f2da1e370b7bcd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/6d893938171a817f4e9bc9e86f2da1e370b7bcd7", + "reference": "6d893938171a817f4e9bc9e86f2da1e370b7bcd7", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "Parsedown": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "Parser for Markdown.", + "homepage": "http://parsedown.org", + "keywords": [ + "markdown", + "parser" + ], + "time": "2019-03-17T18:48:37+00:00" + }, + { + "name": "swiftmailer/swiftmailer", + "version": "v6.2.1", + "source": { + "type": "git", + "url": "https://github.com/swiftmailer/swiftmailer.git", + "reference": "5397cd05b0a0f7937c47b0adcb4c60e5ab936b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/5397cd05b0a0f7937c47b0adcb4c60e5ab936b6a", + "reference": "5397cd05b0a0f7937c47b0adcb4c60e5ab936b6a", + "shasum": "" + }, + "require": { + "egulias/email-validator": "~2.0", + "php": ">=7.0.0", + "symfony/polyfill-iconv": "^1.0", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "mockery/mockery": "~0.9.1", + "symfony/phpunit-bridge": "^3.4.19|^4.1.8" + }, + "suggest": { + "ext-intl": "Needed to support internationalized email addresses", + "true/punycode": "Needed to support internationalized email addresses, if ext-intl is not installed" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.2-dev" + } + }, + "autoload": { + "files": [ + "lib/swift_required.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Corbyn" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Swiftmailer, free feature-rich PHP mailer", + "homepage": "https://swiftmailer.symfony.com", + "keywords": [ + "email", + "mail", + "mailer" + ], + "time": "2019-04-21T09:21:45+00:00" + }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.12.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "685968b11e61a347c18bf25db32effa478be610f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/685968b11e61a347c18bf25db32effa478be610f", + "reference": "685968b11e61a347c18bf25db32effa478be610f", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.12-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "time": "2019-08-06T08:03:45+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.12.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/6af626ae6fa37d396dc90a399c0ff08e5cfc45b2", + "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php72": "^1.9" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.12-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "time": "2019-08-06T08:03:45+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.12.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/b42a2f66e8f1b15ccf25652c3424265923eb4f17", + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.12-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2019-08-06T08:03:45+00:00" + }, + { + "name": "symfony/polyfill-php72", + "version": "v1.12.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php72.git", + "reference": "04ce3335667451138df4307d6a9b61565560199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/04ce3335667451138df4307d6a9b61565560199e", + "reference": "04ce3335667451138df4307d6a9b61565560199e", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.12-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php72\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-08-06T08:03:45+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/config.example.php b/config.example.php new file mode 100644 index 0000000..3d33195 --- /dev/null +++ b/config.example.php @@ -0,0 +1,21 @@ +prepare('SELECT * FROM `fmf_categories` WHERE `cat_id` = :id'); + $getCat->bindValue('id', $category); + $categoryInfo = $getCat->execute() ? $getCat->fetch(PDO::FETCH_ASSOC) : false; + + return $cats[$category] = ($categoryInfo ? $categoryInfo : []); +} + +function category_children(int $parent = 0, int $recurse = 3): array { + global $pdo; + + if($parent < 0) + return []; + + $getCats = $pdo->prepare('SELECT * FROM `fmf_categories` WHERE `cat_parent` = :parent ORDER BY `cat_order`'); + $getCats->bindValue('parent', $parent); + $categories = $getCats->execute() ? $getCats->fetchAll(PDO::FETCH_ASSOC) : false; + $categories = $categories ? $categories : []; + + if($recurse > 0) { + $recurse--; + + for($i = 0; $i < count($categories); $i++) + $categories[$i]['children'] = category_children($categories[$i]['cat_id'], $recurse); + } + + return $categories; +} + +function root_category(): array { + $categories = category_children(); + $forums = []; + + for($i = 0; $i < count($categories); $i++) { + if($categories[$i]['cat_type'] != 1) { + $forums[] = $categories[$i]; + unset($categories[$i]); + } + } + + if(count($forums) > 0) + $categories[] = [ + 'cat_id' => 0, + 'cat_type' => 1, + 'cat_name' => 'Categories', + 'children' => $forums, + ]; + + return $categories; +} + +function category_bump(int $category, ?int $post = null, bool $topics = true, bool $posts = true): void { + global $pdo; + + if($category < 1) + return; + + $getParentId = $pdo->prepare('SELECT `cat_parent` FROM `fmf_categories` WHERE `cat_id` = :id'); + $getParentId->bindValue('id', $category); + $parentId = $getParentId->execute() ? (int)$getParentId->fetchColumn() : 0; + + if($parentId > 0) + category_bump($parentId, $post, $topics, $posts); + + $bump = $pdo->prepare('UPDATE `fmf_categories` SET `cat_count_topics` = IF(:topics, `cat_count_topics` + 1, `cat_count_topics`), `cat_count_posts` = IF(:posts, `cat_count_posts` + 1, `cat_count_posts`), `cat_last_post_id` = COALESCE(:post, `cat_last_post_id`) WHERE `cat_id` = :id'); + $bump->bindValue('topics', $topics ? 1 : 0); + $bump->bindValue('posts', $posts ? 1 : 0); + $bump->bindValue('post', $post); + $bump->bindValue('id', $category); + $bump->execute(); +} + +function category_breadcrumbs(int $category, bool $excludeSelf): array { + global $pdo; + + $breadcrumbs = []; + $getBreadcrumb = $pdo->prepare(' + SELECT `cat_id`, `cat_name`, `cat_parent` + FROM `fmf_categories` + WHERE `cat_id` = :category + '); + + while($category > 0) { + $getBreadcrumb->bindValue('category', $category); + $breadcrumb = $getBreadcrumb->execute() ? $getBreadcrumb->fetch(PDO::FETCH_ASSOC) : []; + + if(empty($breadcrumb)) { + break; + } + + $breadcrumbs[] = $breadcrumb; + $category = $breadcrumb['cat_parent']; + } + + if($excludeSelf) + $breadcrumbs = array_slice($breadcrumbs, 1); + + return array_reverse($breadcrumbs); +} + +function category_child_ids(int $category): array { + global $pdo; + + if($category < 1) + return []; + + static $cached = []; + + if(isset($cached[$category])) + return $cached[$category]; + + $getChildren = $pdo->prepare(' + SELECT `cat_id` + FROM `fmf_categories` + WHERE `cat_parent` = :category + '); + $getChildren->bindValue('category', $category); + $children = $getChildren->fetchAll(PDO::FETCH_ASSOC); + + return $cached[$category] = array_column($children, 'cat_id'); +} diff --git a/include/_csrf.php b/include/_csrf.php new file mode 100644 index 0000000..42d5217 --- /dev/null +++ b/include/_csrf.php @@ -0,0 +1,96 @@ +setTolerance($tolerance); + $this->setTimestamp($timestamp ?? self::timestamp()); + } + + public static function timestamp(): int { + return time() - self::EPOCH; + } + + public static function setGlobalIdentity(string $identity): void { + self::$globalIdentity = $identity; + } + public static function setGlobalSecretKey(string $secretKey): void { + self::$globalSecretKey = $secretKey; + } + public static function validate(string $token): bool { + try { + return self::decode($token, self::$globalIdentity, self::$globalSecretKey)->isValid(); + } catch(Exception $ex) { + return false; + } + } + public static function token(): string { + return (new static)->encode(self::$globalIdentity, self::$globalSecretKey); + } + public static function html(): string { + return sprintf('', self::token()); + } + public static function verify(): bool { + return self::validate(!empty($_REQUEST['_csrf']) && is_string($_REQUEST['_csrf']) ? $_REQUEST['_csrf'] : ''); + } + + public static function decode(string $token, string $identity, string $secretKey): CSRF { + $hash = substr($token, 12); + $unpacked = unpack('Vtimestamp/vtolerance', hex2bin(substr($token, 0, 12))); + + if(empty($hash) || empty($unpacked['timestamp']) || empty($unpacked['tolerance'])) + throw new InvalidArgumentException('Invalid token provided.'); + + $csrf = new static($unpacked['tolerance'], $unpacked['timestamp']); + + if(!hash_equals($csrf->getHash($identity, $secretKey), $hash)) + throw new InvalidArgumentException('Modified token.'); + + return $csrf; + } + + public function encode(string $identity, string $secretKey): string { + $token = bin2hex(pack('Vv', $this->getTimestamp(), $this->getTolerance())); + $token .= $this->getHash($identity, $secretKey); + return $token; + } + + public function getHash(string $identity, string $secretKey): string { + return hash_hmac(self::HASH_ALGO, "{$identity}|{$this->getTimestamp()}|{$this->getTolerance()}", $secretKey); + } + + public function getTimestamp(): int { + return $this->timestamp; + } + public function setTimestamp(int $timestamp): self { + if($timestamp < 0 || $timestamp > 0xFFFFFFFF) + throw new InvalidArgumentException('Timestamp must be within the constaints of an unsigned 32-bit integer.'); + $this->timestamp = $timestamp; + return $this; + } + + public function getTolerance(): int { + return $this->tolerance; + } + public function setTolerance(int $tolerance): self { + if($tolerance < 0 || $tolerance > 0xFFFF) + throw new InvalidArgumentException('Tolerance must be within the constaints of an unsigned 16-bit integer.'); + $this->tolerance = $tolerance; + return $this; + } + + public function isValid(): bool { + $currentTime = self::timestamp(); + return $currentTime >= $this->getTimestamp() && $currentTime <= $this->getTimestamp() + $this->getTolerance(); + } +} diff --git a/include/_posts.php b/include/_posts.php new file mode 100644 index 0000000..6a1e64e --- /dev/null +++ b/include/_posts.php @@ -0,0 +1,100 @@ +prepare('INSERT INTO `fmf_posts` (`cat_id`, `topic_id`, `user_id`, `post_type`, `post_text`) VALUES (:category, :topic, :user, :type, :text)'); + $createPost->bindValue('category', $category); + $createPost->bindValue('topic', $topic); + $createPost->bindValue('user', $user); + $createPost->bindValue('type', $type); + $createPost->bindValue('text', $data); + $createPost->execute(); + + return (int)$pdo->lastInsertId(); +} + +function posts_in_topic(int $topic): array { + global $pdo; + + if($topic < 1) + return []; + + $getTopics = $pdo->prepare('SELECT *, UNIX_TIMESTAMP(`post_created`) AS `post_created`, UNIX_TIMESTAMP(`post_edited`) AS `post_edited`, UNIX_TIMESTAMP(`post_deleted`) AS `post_deleted` FROM `fmf_posts` WHERE `topic_id` = :topic ORDER BY `post_created`'); + $getTopics->bindValue('topic', $topic); + $topics = $getTopics->execute() ? $getTopics->fetchAll(PDO::FETCH_ASSOC) : false; + + return $topics ? $topics : []; +} + +function post_info(?int $post): array { + global $pdo; + static $posts = []; + + if($post < 1) + return []; + if(!empty($posts[$post])) + return $posts[$post]; + + $getPost = $pdo->prepare('SELECT *, UNIX_TIMESTAMP(`post_created`) AS `post_created`, UNIX_TIMESTAMP(`post_edited`) AS `post_edited`, UNIX_TIMESTAMP(`post_deleted`) AS `post_deleted` FROM `fmf_posts` WHERE `post_id` = :post'); + $getPost->bindValue('post', $post); + $postInfo = $getPost->execute() ? $getPost->fetch(PDO::FETCH_ASSOC) : false; + + return $posts[$post] = ($postInfo ? $postInfo : []); +} + +function post_delete(int $post, bool $hard = false): void { + global $pdo; + + if($post < 1) + return; + + $delete = $pdo->prepare($hard ? 'DELETE FROM `fmf_posts` WHERE `post_id` = :post' : 'UPDATE `fmf_posts` SET `post_deleted` = NOW() WHERE `post_id` = :post'); + $delete->bindValue('post', $post); + $delete->execute(); +} + +function post_restore(int $post): void { + global $pdo; + + if($post < 1) + return; + + $restore = $pdo->prepare('UPDATE `fmf_posts` SET `post_deleted` = NULL WHERE `post_id` = :post'); + $restore->bindValue('post', $post); + $restore->execute(); +} + +function post_anonymize(int $post): void { + global $pdo; + + if($post < 1) + return; + + $restore = $pdo->prepare('UPDATE `fmf_posts` SET `user_id` = NULL WHERE `post_id` = :post'); + $restore->bindValue('post', $post); + $restore->execute(); +} + +function post_update(int $post, string $text): void { + global $pdo; + + if($post < 1) + return; + + $restore = $pdo->prepare('UPDATE `fmf_posts` SET `post_text` = :text, `post_edited` = NOW() WHERE `post_id` = :post'); + $restore->bindValue('text', $text); + $restore->bindValue('post', $post); + $restore->execute(); +} diff --git a/include/_topics.php b/include/_topics.php new file mode 100644 index 0000000..a6bdccd --- /dev/null +++ b/include/_topics.php @@ -0,0 +1,78 @@ +prepare('SELECT *, UNIX_TIMESTAMP(`topic_created`) AS `topic_created`, UNIX_TIMESTAMP(`topic_bumped`) AS `topic_bumped`, UNIX_TIMESTAMP(`topic_locked`) AS `topic_locked`, UNIX_TIMESTAMP(`topic_resolved`) AS `topic_resolved`, UNIX_TIMESTAMP(`topic_confirmed`) AS `topic_confirmed` FROM `fmf_topics` WHERE `cat_id` = :category AND `topic_bumped` IS NOT NULL ORDER BY `topic_bumped` DESC'); + $getTopics->bindValue('category', $category); + $topics = $getTopics->execute() ? $getTopics->fetchAll(PDO::FETCH_ASSOC) : false; + + return $topics ? $topics : []; +} + +function create_topic(int $category, int $user, string $title): int { + global $pdo; + + $createTopic = $pdo->prepare('INSERT INTO `fmf_topics` (`cat_id`, `user_id`, `topic_title`) VALUES (:cat, :user, :title)'); + $createTopic->bindValue('cat', $category); + $createTopic->bindValue('user', $user); + $createTopic->bindValue('title', $title); + $createTopic->execute(); + + return (int)$pdo->lastInsertId(); +} + +function topic_info(int $topic): array { + global $pdo; + + if($topic < 1) + return []; + + $getTopic = $pdo->prepare('SELECT *, UNIX_TIMESTAMP(`topic_created`) AS `topic_created`, UNIX_TIMESTAMP(`topic_locked`) AS `topic_locked`, UNIX_TIMESTAMP(`topic_resolved`) AS `topic_resolved`, UNIX_TIMESTAMP(`topic_confirmed`) AS `topic_confirmed` FROM `fmf_topics` WHERE `topic_id` = :id'); + $getTopic->bindValue('id', $topic); + $topic = $getTopic->execute() ? $getTopic->fetch(PDO::FETCH_ASSOC) : false; + + return $topic ? $topic : []; +} + +function topic_bump(int $topic, ?int $post = null, bool $onlyPostId = false): void { + global $pdo; + + if($topic < 1) + return; + + $bump = $pdo->prepare('UPDATE `fmf_topics` SET `topic_bumped` = IF(:no_bump, `topic_bumped`, NOW()), `topic_count_replies` = IF(`topic_bumped` IS NULL, `topic_count_replies`, `topic_count_replies` + 1), `topic_last_post_id` = COALESCE(:post, `topic_last_post_id`) WHERE `topic_id` = :topic'); + $bump->bindValue('topic', $topic); + $bump->bindValue('post', $post); + $bump->bindValue('no_bump', $onlyPostId ? 1 : 0); + $bump->execute(); +} + +function lock_topic(int $topic, bool $state): void { + global $pdo; + + $lock = $pdo->prepare('UPDATE `fmf_topics` SET `topic_locked` = IF(:state, NOW(), NULL) WHERE `topic_id` = :topic'); + $lock->bindValue('state', $state ? 1 : 0); + $lock->bindValue('topic', $topic); + $lock->execute(); +} + +function mark_topic_resolved(int $topic, bool $state): void { + global $pdo; + + $resolve = $pdo->prepare('UPDATE `fmf_topics` SET `topic_resolved` = IF(:state, NOW(), NULL) WHERE `topic_id` = :topic'); + $resolve->bindValue('state', $state ? 1 : 0); + $resolve->bindValue('topic', $topic); + $resolve->execute(); +} + +function mark_topic_confirmed(int $topic, bool $state): void { + global $pdo; + + $confirm = $pdo->prepare('UPDATE `fmf_topics` SET `topic_confirmed` = IF(:state, NOW(), NULL) WHERE `topic_id` = :topic'); + $confirm->bindValue('state', $state ? 1 : 0); + $confirm->bindValue('topic', $topic); + $confirm->execute(); +} diff --git a/include/_track.php b/include/_track.php new file mode 100644 index 0000000..54150b6 --- /dev/null +++ b/include/_track.php @@ -0,0 +1,94 @@ +prepare(' + SELECT COUNT(ti.`topic_id`) + FROM `fmf_topics` AS ti + LEFT JOIN `fmf_track` AS tt + ON tt.`topic_id` = ti.`topic_id` AND tt.`user_id` = :user + WHERE ti.`cat_id` = :category + AND ti.`topic_bumped` >= NOW() - INTERVAL 1 MONTH + AND ( + tt.`track_timestamp` IS NULL + OR tt.`track_timestamp` < ti.`topic_bumped` + ) + '); + $countUnread->bindValue('category', $category); + $countUnread->bindValue('user', $user); + $cache[$trackId] += $countUnread->execute() ? (int)$countUnread->fetchColumn() : 0; + + return $cache[$trackId]; +} + +function track_check_topic(int $user, int $topic): bool { + global $pdo; + static $cache = []; + + if($user < 1 || $topic < 1) + return false; + + $trackId = "{$user}-{$topic}"; + if(isset($cache[$trackId])) + return $cache[$trackId]; + + $getUnread = $pdo->prepare(' + SELECT + :user AS `target_user_id`, + ( + SELECT + `target_user_id` > 0 + AND + t.`topic_bumped` > NOW() - INTERVAL 1 MONTH + AND ( + SELECT COUNT(ti.`topic_id`) < 1 + FROM `fmf_track` AS tt + RIGHT JOIN `fmf_topics` AS ti + ON ti.`topic_id` = tt.`topic_id` + WHERE ti.`topic_id` = t.`topic_id` + AND tt.`user_id` = `target_user_id` + AND `track_timestamp` >= `topic_bumped` + ) + ) + FROM `fmf_topics` AS t + WHERE t.`topic_id` = :topic + '); + $getUnread->bindValue('user', $user); + $getUnread->bindValue('topic', $topic); + + return $cache[$trackId] = ($getUnread->execute() ? $getUnread->fetchColumn(1) : false); +} + +function update_track(int $user, int $topic, int $category): void { + global $pdo; + + if($user < 1 || $topic < 1 || $category < 1) + return; + + $updateTrack = $pdo->prepare(' + REPLACE INTO `fmf_track` + (`cat_id`, `topic_id`, `user_id`) + VALUES + (:category, :topic, :user) + '); + $updateTrack->bindValue('category', $category); + $updateTrack->bindValue('topic', $topic); + $updateTrack->bindValue('user', $user); + $updateTrack->execute(); +} diff --git a/include/_user.php b/include/_user.php new file mode 100644 index 0000000..a77868d --- /dev/null +++ b/include/_user.php @@ -0,0 +1,239 @@ +prepare('SELECT `user_id` FROM `fmf_users` WHERE LOWER(`user_login`) = LOWER(:login) OR LOWER(`user_email`) = LOWER(:email)'); + $checkUser->bindValue('login', $username); + $checkUser->bindValue('email', $email); + $checkUser->execute(); + + return (int)$checkUser->fetchColumn(); +} + +function get_user_for_login(string $nameOrMail): array { + global $pdo; + + $getUser = $pdo->prepare('SELECT `user_id`, `user_login`, `user_password`, `user_email_verification` FROM `fmf_users` WHERE LOWER(`user_login`) = LOWER(:login) OR LOWER(`user_email`) = LOWER(:email)'); + $getUser->bindValue('login', $nameOrMail); + $getUser->bindValue('email', $nameOrMail); + $user = $getUser->execute() ? $getUser->fetch(PDO::FETCH_ASSOC) : false; + + return $user ? $user : []; +} + +function user_info(?int $user, bool $fresh = false): array { + global $pdo; + static $cache = []; + + if($user < 1) + return []; + if(!$fresh && !empty($cache[$user])) + return $cache[$user]; + + $getUserInfo = $pdo->prepare('SELECT *, UNIX_TIMESTAMP(`user_created`) AS `user_created`, UNIX_TIMESTAMP(`user_banned`) AS `user_banned`, MD5(LOWER(TRIM(`user_email`))) AS `gravatar_hash` FROM `fmf_users` WHERE `user_id` = :user'); + $getUserInfo->bindValue('user', $user); + $userInfo = $getUserInfo->execute() ? $getUserInfo->fetch(PDO::FETCH_ASSOC) : false; + + return $cache[$user] = ($userInfo ? $userInfo : []); +} + +function user_has_flag(int $user, int $flag, bool $strict = false): bool { + $userInfo = user_info($user); + + if(empty($userInfo)) + return false; + + $flags = ($userInfo['user_flags'] & $flag); + + return $strict ? ($flags === $flag) : ($flags > 0); +} + +function create_user(string $username, string $email, string $password, string $ipAddr, bool $verified = false): array { + global $pdo; + + $verification = $verified ? null : bin2hex(random_bytes(16)); + + $createUser = $pdo->prepare('INSERT INTO `fmf_users` (`user_login`, `user_password`, `user_email`, `user_email_verification`, `user_ip_created`) VALUES (:login, :password, :email, :verification, INET6_ATON(:ip))'); + $createUser->bindValue('login', $username); + $createUser->bindValue('password', password_hash($password, PASSWORD_DEFAULT)); + $createUser->bindValue('email', $email); + $createUser->bindValue('verification', $verification); + $createUser->bindValue('ip', $ipAddr); + $createUser->execute(); + + return [ + 'user_id' => (int)$pdo->lastInsertId(), + 'verification' => $verification, + ]; +} + +function validate_username(string $username): ?string { + if($username !== trim($username)) + return 'Your username may not start or end with spaces.'; + + $usernameLength = strlen($username); + + if($usernameLength < 3) + return 'Your username must be longer than 3 characters.'; + + if($usernameLength > 16) + return 'Your username may not be longer than 16 characters.'; + + if(!preg_match('#^[A-Za-z0-9-_]+$#u', $username)) + return 'Your username may only contains alphanumeric characters, dashes and underscores (A-Z, a-z, 0-9, -, _).'; + + return null; +} + +function validate_email(string $email): ?string { + if(filter_var($email, FILTER_VALIDATE_EMAIL) === false) + return 'Your e-mail address is not correctly formatted.'; + + $domain = mb_substr(mb_strstr($email, '@'), 1); + + if(!checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) + return 'Your e-mail address domain does not have valid DNS records.'; + + return null; +} + +function validate_password(string $password): ?string { + if(unique_chars($password) < 10) + return 'Your password is too weak.'; + + return null; +} + +function activate_user(string $code): void { + global $pdo; + + if(strlen($code) !== 32) + return; + + $verify = $pdo->prepare('UPDATE `fmf_users` SET `user_email_verification` = NULL WHERE `user_email_verification` = :code'); + $verify->bindValue('code', $code); + $verify->execute(); +} + +function create_session(int $userId): string { + global $pdo; + + if($userId < 1) + return ''; + + $sessionKey = bin2hex(random_bytes(32)); + + $createSession = $pdo->prepare('INSERT INTO `fmf_sessions` (`user_id`, `sess_key`) VALUES (:user, :key)'); + $createSession->bindValue('user', $userId); + $createSession->bindValue('key', $sessionKey); + $createSession->execute(); + + return $sessionKey; +} + +function purge_old_sessions(): void { + global $pdo; + $pdo->exec('DELETE FROM `fmf_sessions` WHERE `sess_created` + INTERVAL 1 MONTH <= NOW()'); +} + +function session_activate(?string $key): void { + global $pdo; + + if(empty($key) || strlen($key) !== 64) + return; + + $verify = $pdo->prepare('SELECT `user_id` FROM `fmf_sessions` WHERE `sess_key` = :key AND `sess_created` + INTERVAL 1 MONTH > NOW()'); + $verify->bindValue('key', $key); + $userId = $verify->execute() ? $verify->fetchColumn() : 0; + + if($userId < 1) + return; + + $GLOBALS['fmf_user_id'] = (int)$userId; +} + +function session_active(): bool { + return !empty($GLOBALS['fmf_user_id']) && is_int($GLOBALS['fmf_user_id']) && $GLOBALS['fmf_user_id'] > 0; +} + +function logout_token(): string { + $sessionKey = $_COOKIE['fmfauth'] ?? ''; + + if(strlen($sessionKey) !== 64 || !ctype_xdigit($sessionKey)) + return bin2hex(random_bytes(4)); + + $offset = hexdec($sessionKey[0]) * 2; + $offset = hexdec($sessionKey[$offset]) * 2; + $offset = hexdec($sessionKey[$offset]) * 2; + + return substr($sessionKey, $offset, 8); +} + +function destroy_session(string $token): void { + global $pdo; + + $delete = $pdo->prepare('DELETE FROM `fmf_sessions` WHERE `sess_key` = :key'); + $delete->bindValue('key', $token); + $delete->execute(); +} + +function current_user_id(): int { + return session_active() ? $GLOBALS['fmf_user_id'] : 0; +} + +function verify_password(string $pass, ?int $user = null): bool { + global $pdo; + $user = $user ?? current_user_id(); + + if($user < 1) + return false; + + $getHash = $pdo->prepare('SELECT `user_password` FROM `fmf_users` WHERE `user_id` = :user'); + $getHash->bindValue('user', $user); + $hash = $getHash->execute() ? $getHash->fetchColumn() : ''; + + if(empty($hash)) + return false; + + return password_verify($pass, $hash); +} + +function user_set_password(int $user, string $password): void { + global $pdo; + + if($user < 1) + return; + + $password = password_hash($password, PASSWORD_DEFAULT); + + $update = $pdo->prepare('UPDATE `fmf_users` SET `user_password` = :pass WHERE `user_id` = :user'); + $update->bindValue('pass', $password); + $update->bindValue('user', $user); + $update->execute(); +} + +function user_set_email(int $user, string $email, bool $verified = false): ?string { + global $pdo; + + if($user < 1) + return null; + + $verification = $verified ? null : bin2hex(random_bytes(16)); + + $update = $pdo->prepare('UPDATE `fmf_users` SET `user_email` = LOWER(:mail), `user_email_verification` = :verf WHERE `user_id` = :user'); + $update->bindValue('mail', $email); + $update->bindValue('verf', $verification); + $update->bindValue('user', $user); + $update->execute(); + + return $verification; +} + +function user_gravatar(?int $user, int $res = 80): string { + $authorInfo = user_info($user); + return '//www.gravatar.com/avatar/'. ($authorInfo['gravatar_hash'] ?? str_repeat('0', 32)) .'?s='. $res .'&r=g&d=identicon'; +} diff --git a/include/_utils.php b/include/_utils.php new file mode 100644 index 0000000..bf030e6 --- /dev/null +++ b/include/_utils.php @@ -0,0 +1,17 @@ +'; + +if(empty($banReason)) { + $message .= 'No reason was provided.'; +} else { + $message .= $banReason; +} + +include_once __DIR__ . '/notice.php'; \ No newline at end of file diff --git a/layout/footer.php b/layout/footer.php new file mode 100644 index 0000000..66a54c5 --- /dev/null +++ b/layout/footer.php @@ -0,0 +1,15 @@ + + + + diff --git a/layout/header.php b/layout/header.php new file mode 100644 index 0000000..3df2323 --- /dev/null +++ b/layout/header.php @@ -0,0 +1,48 @@ + + + + + + + <?=$title ?? 'flash.moe message board';?> + + + + +
+
+

flash.moe message board

+
+
+ Home + + Settings + Log out + + Log in + Register + +
+ + + +
+
diff --git a/layout/notice.php b/layout/notice.php new file mode 100644 index 0000000..b353045 --- /dev/null +++ b/layout/notice.php @@ -0,0 +1,6 @@ +forum.flash.moe » '; +foreach($breadcrumbs_arr as $breadcrumb) { + $breadcrumbs .= sprintf('%s » ', $breadcrumb['cat_id'], $breadcrumb['cat_name']); +} + +echo $breadcrumbs; +?> +

+ 0) { +?> +
+
+
Categories
+
Topics
+
Posts
+
Latest post
+
+
+ +
+
+
+ +
+
+ +
+
+
+ + No posts + + #
+ + +
+ +
+ +
+
+ +Create Topic +
+
+
Topics
+
Author
+
Created
+
Posts
+
Latest reply
+
+
+ +
+
+
+ + <?=($categoryInfo['cat_variation'] === 1 ? 'Implemented' : 'Resolved');?> + + <?=($categoryInfo['cat_variation'] === 1 ? 'Accepted' : 'Confirmed');?> + + + Locked + +
+ +
+
+
+ +
+
+
+ + No replies + + #
+ + +
+
+ +
+
+Create Topic + +
+
+
+ +
+
Topics
+
Posts
+
Latest post
+
+
+ +
+
+
+ +
+
+ +
+
+
+ + No posts + + #
+ + +
+ +
+ +
+
+ +
+ +
+

Log in

+
+ +
+ + + +
+ +
+
+ 0) { + $postInfo = post_info($postId); + + if(empty($postInfo)) + die_ex('Post not found.', 404); + if($postInfo['post_type'] != FMF_POST_TYPE_MESSAGE) + die_ex('This is not a message.', 400); + if(!$userInfo['user_moderator'] && $userInfo['user_id'] != $postInfo['user_id']) + die_ex('You aren\'t allowed to edit this post.', 403); + + $categoryId = $postInfo['cat_id'] ?? 0; + $topicId = $postInfo['topic_id'] ?? 0; + $postId = $postInfo['post_id'] ?? 0; + $postText = $postInfo['post_text'] ?? ''; +} else { + $postId = 0; +} + +if($topicId > 0) { + $topicInfo = topic_info($topicId); + + if(empty($topicInfo)) + die_ex('Topic not found.', 404); + + $categoryId = $topicInfo['cat_id'] ?? 0; + $topicId = $topicInfo['topic_id'] ?? 0; +} else { + $topicId = 0; +} + +$categoryInfo = category_info($categoryId); + +if(empty($categoryInfo)) { + die_ex('Category does not exist.', 404); +} + +if($categoryInfo['cat_type'] != 0) { + die_ex('This category cannot hold topics.'); +} + +if(isset($topicInfo)) { + if(!empty($topicInfo['topic_locked']) && !$userInfo['user_moderator']) { + die_ex('You may not respond to locked topics.', 403); + } +} + +$title = isset($topicInfo) ? ((isset($postInfo) ? 'Editing reply to ' : 'Replying to ') . $topicInfo['topic_title']) : ('Creating a topic in ' . $categoryInfo['cat_name']); + +if(isset($_POST['text']) && CSRF::verify()) { + $postTitle = isset($_POST['title']) && is_string($_POST['title']) ? $_POST['title'] : ''; + $postText = trim(is_string($_POST['text']) ? $_POST['text'] : ''); + + $postLen = mb_strlen($postText); + + if($postLen < 10) { + $error = 'Post content must be longer than 10 characters.'; + } elseif($postLen > 50000) { + $error = 'Post content may not be longer than 50000 characters.'; + } else { + if(!isset($topicInfo)) { + $titleLen = mb_strlen($postTitle); + + if($titleLen < 5) { + $error = 'Topic titles must be longer than 5 characters.'; + } elseif($titleLen > 100) { + $error = 'Topic titles may not be longer than 100 characters.'; + } else { + $topicId = create_topic($categoryInfo['cat_id'], current_user_id(), $postTitle); + + if($topicId < 1) { + $error = 'Failed to create topic.'; + } else { + $topicInfo = topic_info($topicId); + $satoriMsg = "[b]forum.flash.moe[/b]: [url=https://forum.flash.moe/user/{$userInfo['user_id']}][b]{$userInfo['user_login']}[/b][/url] created topic [url=https://forum.flash.moe/topic/{$topicId}][b]{$topicInfo['topic_title']}[/b][/url]"; + } + } + } + + if(!isset($error) && !isset($message)) { + if(isset($postInfo)) { + post_update($postInfo['post_id'], $postText); + } else { + $postId = create_post($categoryInfo['cat_id'], $topicInfo['topic_id'], current_user_id(), $postText); + topic_bump($topicInfo['topic_id'], $postId, !empty($topicInfo['topic_resolved'])); + category_bump($categoryInfo['cat_id'], $postId, isset($titleLen)); + + if(!isset($satoriMsg)) + $satoriMsg = "[b]forum.flash.moe[/b]: [url=https://forum.flash.moe/user/{$userInfo['user_id']}][b]{$userInfo['user_login']}[/b][/url] replied to [url=https://forum.flash.moe/post/{$postId}][b]{$topicInfo['topic_title']}[/b][/url]"; + } + + if(defined('SATORI_SECRET') && !empty($satoriMsg)) { + $sock = @fsockopen(SATORI_HOST, SATORI_PORT, $errno, $errstr, 2); + + if($sock) { + fwrite($sock, chr(0xF) . hash_hmac('sha256', $satoriMsg, SATORI_SECRET) . $satoriMsg . chr(0xF)); + fflush($sock); + fclose($sock); + } + } + + $postUrl = isset($titleLen) ? "/topic/{$topicInfo['topic_id']}" : "/post/{$postId}"; + header("Location: {$postUrl}"); + return; + } + } +} + +include FMF_LAYOUT . '/header.php'; + +$breadcrumbs = category_breadcrumbs($categoryInfo['cat_id'], empty($topicInfo)); +echo 'forum.flash.moe » '; +foreach($breadcrumbs as $breadcrumb) + printf('%s » ', $breadcrumb['cat_id'], $breadcrumb['cat_name']); +echo '

' . ($topicInfo['topic_title'] ?? $categoryInfo['cat_name']) . '

'; +?> +
+ + + +
+ + +
+ value="" name="title" class="posting-title" tabindex="1"value="Re: " class="posting-title posting-title-disabled" disabled readonly/> + +
+ + + Markdown supported +
+ 0) { + $error = 'This username or e-mail address has already been used.'; + } else { + $registerInfo = create_user($username, $email, $password, $_SERVER['REMOTE_ADDR']); + + if($registerInfo['user_id'] < 1) { + $error = 'Failed to create user.'; + } else { + if(!empty($registerInfo['verification'])) { + setcookie('fmfas', '', 0, '/'); + $mailer->send( + (new Swift_Message('flash.moe message board activation')) + ->setFrom(['system@flash.moe' => 'flash.moe']) + ->setTo([$email => $username]) + ->setBody( + "Hey {$username},\r\n\r\n". + "Click the following link to activate your account:\r\n\r\n". + "\r\n" + ) + ); + + $message = 'Your account has been created! A verification link has been sent to your e-mail address.'; + } else { + header('Location: /login?m=welcome'); + return; + } + } + } + } + } +} + +include FMF_LAYOUT . '/header.php'; +?> +
+ +
+

Register

+
+ +
+ + + + + + +
+ +
+
+prepare(' + SELECT t.* + FROM `fmf_topics` AS t + WHERE t.`topic_title` LIKE CONCAT("%", LOWER(:title), "%") + '); + $findTopics->bindValue('title', $query); + $topics = $findTopics->execute() ? $findTopics->fetchAll(PDO::FETCH_ASSOC) : []; + + $findPosts = $pdo->prepare(' + SELECT p.*, t.`topic_title`, u.`user_login` + FROM `fmf_posts` AS p + LEFT JOIN `fmf_topics` AS t + ON t.`topic_id` = p.`topic_id` + LEFT JOIN `fmf_users` AS u + ON u.`user_id` = p.`user_id` + WHERE p.`post_text` LIKE CONCAT("%", LOWER(:text), "%") + AND p.`post_type` = 0 + AND p.`post_deleted` IS NULL + '); + $findPosts->bindValue('text', $query); + $posts = $findPosts->execute() ? $findPosts->fetchAll(PDO::FETCH_ASSOC) : []; +} + +include FMF_LAYOUT . '/header.php'; +?> +
+ + +
+Found %d topics and %d posts.', count($topics), count($posts)); + + echo '

Topics

'; + foreach($topics as $topic) { + ?> +
+ Posts'; + foreach($posts as $post) { + ?> + Re: by #
+ 'Scroll beyond end of the page.', +]; + +$timeZones = DateTimeZone::listIdentifiers(); + +if(isset($_POST['date_format_custom'], $_POST['timezone']) && CSRF::verify()) { + $timeZone = is_string($_POST['timezone']) ? $_POST['timezone'] : ''; + $dateFormatCustom = is_string($_POST['date_format_custom']) ? $_POST['date_format_custom'] : ''; + $currentPass = isset($_POST['currpass']) && is_string($_POST['currpass']) ? $_POST['currpass'] : ''; + $newPass = isset($_POST['newpwd']) && is_string($_POST['newpwd']) ? $_POST['newpwd'] : ''; + $confPass = isset($_POST['conpwd']) && is_string($_POST['conpwd']) ? $_POST['conpwd'] : ''; + $newMail = isset($_POST['newmail']) && is_string($_POST['newmail']) ? $_POST['newmail'] : ''; + $confMail = isset($_POST['conmail']) && is_string($_POST['conmail']) ? $_POST['conmail'] : ''; + $setMail = !empty($newMail) && !empty($confMail); + $setPass = !empty($newPass) && !empty($confPass); + + if($setMail || $setPass) { + if(!verify_password($currentPass)) { + $error = 'Current password was invalid.'; + } else { + if(!isset($error) && $setPass) { + $error = validate_password($newPass); + + if(!isset($error)) { + if($newPass !== $confPass) { + $email = 'Passwords don\'t match.'; + } else { + user_set_password(current_user_id(), $newPass); + } + } + } + + if(!isset($error) && $setMail) { + $error = validate_email($newMail); + + if(!isset($error)) { + if($newMail !== $confMail) { + $error = 'E-mail addresses don\'t match.'; + } else { + $emailVerification = user_set_email(current_user_id(), $newMail); + } + } + } + } + } + + if(!isset($error)) { + if(!in_array($timeZone, $timeZones)) { + $error = 'Invalid time zone specified.'; + } elseif(strlen($dateFormatCustom) > 50) { + $error = 'Invalid date/time format string.'; + } else { + $userFlags = 0; + + foreach(array_keys($options) as $flag) + if(!empty($_POST['flag_' . $flag])) + $userFlags |= $flag; + + $updateUser = $pdo->prepare(' + UPDATE `fmf_users` + SET `user_date_format` = :dtf, + `user_time_zone` = :tz, + `user_flags` = :flags + WHERE `user_id` = :user + '); + $updateUser->bindValue('dtf', htmlentities($dateFormatCustom)); + $updateUser->bindValue('tz', $timeZone); + $updateUser->bindValue('flags', $userFlags); + $updateUser->bindValue('user', current_user_id()); + $updateUser->execute(); + } + } + + if(!empty($emailVerification)) { + $userInfo = user_info(current_user_id(), true); + $mailer->send( + (new Swift_Message('flash.moe message board activation')) + ->setFrom(['system@flash.moe' => 'flash.moe']) + ->setTo([$userInfo['user_email'] => $userInfo['user_login']]) + ->setBody( + "Hey {$userInfo['user_login']},\r\n\r\n". + "You are required to reactivate your account after e-mail changes.\r\n\r\n". + "Click the following link to activate your account:\r\n\r\n". + "\r\n" + ) + ); + destroy_session($_COOKIE['fmfauth'] ?? ''); + header('Location: /login?m=reactivate'); + return; + } +} + +$userInfo = user_info(current_user_id(), true); +$title = 'Settings'; + +foreach($timeZones as $key => $timeZone) { + $timeZones[$key] = new DateTimeZone($timeZone); + $timeZones[$key]->offset = $timeZones[$key]->getOffset(new DateTime('now', new DateTimeZone('UTC'))); +} + +uasort($timeZones, function($a, $b) { + $diff = $a->offset <=> $b->offset; + + if($diff === 0) + return strcmp($a->getName(), $b->getName()); + + return $diff; +}); + +include FMF_LAYOUT . '/header.php'; +?> +
+ + + +
+ + +
+

Avatar

+
+ Gravatar is used for user profile images, go here to change it. Only images with G rating will be used. +
+
+ +
+

Options

+
+ $oText) { + ?> +
+ +
+
+ +
+

Date/time format

+ +
+ +
+

Time zone

+
+ +
+
+ +
+

Password

+
+
+ +
+
+ +
+

E-mail

+
+ You will be forced to reactivate your account after changing your e-mail address, make sure to get it right!
+
+ +
+
+ +
+

Current Password

+
+ Only required for changing e-mail or password.
+ +
+
+ +
+ + +
+
+ a { + display: inline-block; + padding: 2px 5px; +} + +input[type="text"], +input[type="password"], +input[type="email"], +input[type="search"], +input[type="submit"], +input[type="reset"], +input[type="button"] { + color: #9daad9; + font-family: Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif; + font-size: 12px; + font-weight: 400; + padding: 2px; + border: 1px solid #9daad9; + background-color: #191E33; +} + +input[type="button"], +input[type="reset"], +input[type="submit"] { + cursor: pointer; + padding: 2px 4px; +} + +input[type="submit"] { + font-weight: 700; +} + +textarea { + background-color: #191E33; + color: #9DAAD9; + font-family: Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif; + font-weight: normal; + border: 1px solid #A9B8C2; + padding: 2px; + font-size: 12px; +} + +select { + color: #9DAAD9; + background-color: #191E33; + font-family: Tahoma, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif; + font-size: 11px; + font-weight: normal; + border: 1px solid #A9B8C2; + padding: 1px; +} + +option { + padding: 0 1em 0 0; +} + +.wrapper { + margin: 0 auto; + max-width: 1000px; + width: 100%; +} +.wrapper.scrollbeyond { + padding-bottom: 100vh; +} + +.footer { + color: #567194; + font-weight: normal; + font-size: 11px; + line-height: 14px; + text-align: center; + padding: 2px 5px; +} + +.header h1 { + padding: 10px; + padding-bottom: 5px; +} +.header a { + color: #567194; + text-decoration: none; + padding: 5px 10px; + display: inline-block; +} +.header a:hover { + color: #9DAAD9; + text-decoration: underline; +} +.header-wrap { + display: flex; + justify-content: space-between; +} + +.forum-category, +.topics { + border: 1px solid #000; + background-color: #191e33; + color: #cecece; + margin: 4px 0; +} +.forum-category-title, +.topics-header { + font-weight: 700; + display: flex; + align-items: center; +} +.forum-category-title-info, +.topics-header-info { + flex: 1 1 auto; + padding: 4px 5px; +} +.forum-category-board, +.topics-item { + border-top: 1px solid #000; + background-color: #1a2237; + display: flex; + align-items: center; + min-height: 40px; +} +.forum-category-board-indicator, +.topics-item-indicator, +.topics-item-indicator-closed, +.topics-item-indicator-locked { + width: 20px; + height: 20px; + line-height: 20px; + border: 1px solid #000; + flex: 0 0 auto; + margin: 5px; + background-color: #1a2237; +} +.topics-item-indicator-closed { + background-color: #0c0; + margin-left: 0; +} +.topics-item-indicator-locked { + background-color: #c00; + margin-left: 0; +} +.forum-category-board-indicator.unread, +.topics-item-indicator.unread { + background-color: #4D556A; +} +.topics-item-indicator, +.topics-item-indicator-closed, +.topics-item-indicator-locked { + width: 16px; + height: 16px; + line-height: 16px; +} +.forum-category-board-info, +.topics-item-info { + flex: 1 1 auto; +} +.forum-category-count, +.topics-item-count { + width: 60px; + text-align: center; + padding: 4px 5px; +} +.forum-category-latest, +.topics-item-author, +.topics-item-created, +.topics-item-latest { + width: 120px; + text-align: center; +} +.topics-item-created time, +.forum-category-latest time, +.topics-item-latest time { + font-size: .9em; + line-height: 1.2em; + display: inline-block; +} +.forum-category-latest-header, +.topics-item-latest-header { + padding: 4px 5px; +} +.forum-category-board-desc { + font-size: .9em; + line-height: 1.2em; +} +.topics-item-status { + display: inline-block; + margin-right: 4px; +} + +.auth-form { + max-width: 300px; + margin: 5px auto; +} +.auth-header { + padding: 5px 0; +} +.auth-field { + margin: 5px 0; + display: block; +} +.auth-field-value input { + width: 100%; +} +.auth-buttons { + text-align: center; + padding: 5px; +} +.auth-message { + text-align: center; +} +.auth-message-error { + color: #f00; +} +.forum-title { + padding: 2px 5px; +} + +.posting-header { + display: flex; + padding: 4px 0; +} +.posting-title { + flex: 1 1 auto; +} +.posting-submit { + margin-left: 4px; +} +.posting-text { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 500px; +} +.posting-message { + padding: 0 3px; +} +.posting-message-error { + color: #f00; +} + +.createtopicbtn, +.topic-btns a { + display: inline-block; + color: #9daad9; + font-size: 12px; + font-weight: 400; + margin: 4px 6px 2px 0; + padding: 1px 8px; + border: 1px solid #9daad9; + background-color: #191E33; + cursor: pointer; +} + +.search-form { + display: flex; +} +.search-input { + flex: 1 1 auto; +} +.search-submit { + margin-left: 4px; +} + +.post { + display: flex; + border: 1px solid #000; + background-color: #191e33; + color: #cecece; + margin: 2px 0; +} +.post-details { + flex: 0 0 auto; + width: 120px; + display: flex; + flex-direction: column; + align-items: center; + border: 0 solid #000; + border-right-width: 1px; +} +.post-permalink-wrap { + text-align: center; + font-size: .9em; + line-height: 1.4em; +} +.post-username { + font-size: 1.2em; + line-height: 1.5em; +} +.post-details * { + margin: 1px 0; +} +/*@media(max-width: 600px) {*/ + .post { + flex-direction: column; + } + .post-details { + border-right-width: 0; + border-bottom-width: 1px; + width: 100%; + flex-direction: row; + } + .post-avatar { + width: 40px; + height: 40px; + order: 1; + margin: 5px; + } + .post-username { + order: 2; + padding: 10px; + } + .post-permalink-wrap { + order: 3; + flex: 1 1 auto; + text-align: right !important; + } + .post-permalink { + padding: 10px; + } +/*}*/ +.post-text { + flex: 1 1 auto; + background-color: #1a2237; + min-height: 160px; + padding: 2px 5px; + display: flex; + flex-direction: column; +} +.post-text-inner { + flex: 1 1 auto; + padding: 3px 0; + word-wrap: normal; + word-break: break-word; +} +.post-footer { + display: flex; + flex: 0 0 auto; + font-size: .9em; +} +.post-edited { + flex: 1 1 auto; + font-style: italic; +} +.post-options { + flex: 0 0 auto; +} +.post-options a { + margin: 0 5px; +} +.post-deleted .post-text { + min-height: 0; +} + +.setting { + margin: 10px 0; +} +.setting-head h3 { + line-height: 1.5em; +} +.setting-value input[type="text"], +.setting-value input[type="password"], +.setting-value input[type="email"], +.setting-value select { + min-width: 400px; + margin: 1px 0; +} +.settings-message { + padding: 0 3px; +} +.settings-message-error { + color: #f00; +} +.settings-option { + margin: 2px 0; + padding: 0 4px; + display: block; +} + +.event { + display: block; + border: 1px solid #000; + background-color: #191e33; + color: #cecece; + margin: 2px 0; +} +.event-msg { + margin: 4px; + display: flex; +} +.event-msg img { + vertical-align: bottom; +} +.event-msg time { + font-size: .9em; + margin: 0 4px; +} +.event-msg-text { + flex: 1 1 auto; + margin: 0 4px; +} + +.notice { + display: block; + border: 1px solid #000; + background-color: #1a2237; + color: #cecece; + margin: 2px 0; + padding: 4px; +} diff --git a/public/topic.php b/public/topic.php new file mode 100644 index 0000000..1c77188 --- /dev/null +++ b/public/topic.php @@ -0,0 +1,277 @@ +forum.flash.moe » '; +$breadcrumbs_arr = category_breadcrumbs($topicInfo['cat_id'], false); +foreach($breadcrumbs_arr as $breadcrumb) { + $breadcrumbs .= sprintf('%s » ', $breadcrumb['cat_id'], $breadcrumb['cat_name']); +} + +echo $breadcrumbs; + +$topicButtons = '
'; + +if(!$isLocked || $canLock) { + $topicButtons .= 'Reply'; +} + +if($canResolve) { + $topicButtons .= ''. ($isResolved ? 'Reopen' : ($isFeatureReqs ? 'Mark Implemented' : 'Mark Resolved')) .''; +} + +if($canConfirm) { + $topicButtons .= ''. ($isConfirmed ? ($isFeatureReqs ? 'Revoke Accept' : 'Revoke Confirmation') : ($isFeatureReqs ? 'Accept for implementation' : 'Confirm')) .''; +} + +if($canLock) { + $topicButtons .= ''. ($isLocked ? 'Unlock' : 'Lock') .''; +} + +$topicButtons .= '
'; +?> +

+ +
+ This topic was locked on . +
+ +
+ This topic was marked resolved on . +
+ +
+ +
+ + <?=($authorInfo['user_login'] ?? 'Deleted User');?> +
+ +
+
+ +
+ +
+ setSafeMode(true)->text($post['post_text']);?> +
+ + +
+ #: deleted + + (Restore) + +
+ +
+
+ ':username has marked this ' . ($isFeatureReqs ? 'request as implemented.' : 'topic as resolved.'), + FMF_POST_TYPE_LOCKED => ':username has locked this conversation.', + FMF_POST_TYPE_UNLOCKED => ':username has unlocked this conversation.', + FMF_POST_TYPE_UNRESOLVED => ':username has marked this ' . ($isFeatureReqs ? 'request as unimplemented.' : 'marked this topic as unresolved.'), + FMF_POST_TYPE_CONFIRMED => ':username has ' . ($isFeatureReqs ? 'marked this request for implementation.' : 'confirmed this topic.'), + FMF_POST_TYPE_UNCONFIRMED => ':username has ' . ($isFeatureReqs ? 'revoked implementation confirmation for this request.' : 'revoked the confirmation from this topic.'), + ]; + $eventMessage = $eventMessages[$post['post_type']] ?? ':username did something.'; + break; + } + + if(isset($eventMessage)) { + $eventMessage = strtr($eventMessage, [ + ':user_id' => $authorInfo['user_id'] ?? 0, + ':username' => $authorInfo['user_login'] ?? 'Deleted User', + ]); + } + + if(!isset($eventHtml)) { + $eventHtml = sprintf( + '
%s
%s
', + user_gravatar($authorInfo['user_id'] ?? null, 20), + ($authorInfo['user_login'] ?? 'Deleted User'), + $eventMessage ?? 'Missing event description.', + $post['post_id'], + date('c', $post['post_created']), + date(FMF_DATE_FORMAT, $post['post_created']) + ); + } + ?> +
+ +
+ {$userInfo['user_login']}"; +echo '' . $userInfo['user_login'] . ''; + +include FMF_LAYOUT . '/footer.php'; diff --git a/startup.php b/startup.php new file mode 100644 index 0000000..c473ac5 --- /dev/null +++ b/startup.php @@ -0,0 +1,77 @@ +' . FMF_PHP_MIN_VER . ' is required.'); +} + +error_reporting(FMF_DEBUG ? -1 : 0); +ini_set('display_errors', FMF_DEBUG ? 'On' : 'Off'); + +date_default_timezone_set('UTC'); +mb_internal_encoding('UTF-8'); +set_include_path(get_include_path() . PATH_SEPARATOR . FMF_INCLUDE); + +if(!is_file(FMF_ROOT . '/config.php')) + die('Configuration is missing.'); + +require_once FMF_ROOT . '/vendor/autoload.php'; +require_once FMF_ROOT . '/config.php'; + +try { + $pdo = new PDO(CHIE_DB_DSN, CHIE_DB_USER, CHIE_DB_PASS, [ + PDO::ATTR_CASE => PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET SESSION time_zone = '+00:00'" + . ", sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';", + ]); + + $mailer = new Swift_Mailer( + (new Swift_SmtpTransport(CHIE_SMTP_HOST, CHIE_SMTP_PORT)) + ->setEncryption(CHIE_SMTP_ENC) + ->setUsername(CHIE_SMTP_USER) + ->setPassword(CHIE_SMTP_PASS) + ); +} catch(Exception $ex) { + die($ex->getMessage()); +} + +include_once '_csrf.php'; +include_once '_user.php'; + +function die_ex(string $message, int $status = 400): void { + http_response_code($status); + include FMF_LAYOUT . '/notice.php'; + exit; +} + +CSRF::setGlobalSecretKey(CHIE_CSRF_SECRET); +purge_old_sessions(); +session_activate($_COOKIE['fmfauth'] ?? ''); + +$userInfo = user_info(current_user_id()); +if(!empty($userInfo)) { + define('FMF_DATE_FORMAT', $userInfo['user_date_format']); + date_default_timezone_set($userInfo['user_time_zone']); + CSRF::setGlobalIdentity($_COOKIE['fmfauth'] ?? ''); + + if(!empty($userInfo['user_banned'])) { + $banTimestamp = $userInfo['user_banned']; + $banReason = $userInfo['user_banned_reason']; + include FMF_LAYOUT . '/banned.php'; + destroy_session($_COOKIE['fmfauth'] ?? ''); + exit; + } +} else { + define('FMF_DATE_FORMAT', 'D Y-m-d H:i:s T'); + CSRF::setGlobalIdentity($_SERVER['REMOTE_ADDR']); +} +unset($userInfo);