diff options
99 files changed, 7234 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b277f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/debug.log +test.php +/.git +/htdocs/css +/node_modules/ +/vendor/ +.DS_Store +._.DS_Store +.sass-cache/ +config-static.php +config-local.php @@ -0,0 +1,21 @@ +This is complete code of ch1p.io website. + +REQUIREMENTS + + PHP >= 8.1 with mysqli or sqlite3, gd + +CONFIGURATION + + Should be done by copying config.php to config-local.php and modifying config-local.php. + +INSTALLATION + + TODO + +LOGGING + + TODO + +LICENSE + + GPLv3
\ No newline at end of file diff --git a/cli_util.php b/cli_util.php new file mode 100755 index 0000000..97885ff --- /dev/null +++ b/cli_util.php @@ -0,0 +1,75 @@ +#!/usr/bin/env php8.1 +<?php + +namespace cli_util; + +use cli; +use posts; +use uploads; +use pages; +use config; + +require_once __DIR__.'/init.php'; + +$cli = new cli(__NAMESPACE__); +$cli->run(); + +function admin_reset(): void { + $pwd1 = cli::silentInput("New password: "); + $pwd2 = cli::silentInput("Again: "); + + if ($pwd1 != $pwd2) + cli::die("Passwords do not match"); + + if (trim($pwd1) == '') + cli::die("Password can not be empty"); + + if (!config::set('admin_pwd', salt_password($pwd1))) + cli::die("Database error"); +} + +function admin_check(): void { + $pwd = config::get('admin_pwd'); + echo is_null($pwd) ? "Not set" : $pwd; + echo "\n"; +} + +function blog_erase(): void { + $db = getDb(); + $tables = ['posts', 'posts_tags', 'tags']; + foreach ($tables as $t) { + $db->query("TRUNCATE TABLE $t"); + } +} + +function tags_recount(): void { + $tags = posts::getAllTags(true); + foreach ($tags as $tag) + posts::recountPostsWithTag($tag->id); +} + +function posts_html(): void { + $kw = ['include_hidden' => true]; + $posts = posts::getPosts(0, posts::getPostsCount(...$kw), ...$kw); + foreach ($posts as $p) { + $p->updateHtml(); + $p->updateText(); + } +} + +function pages_html(): void { + $pages = pages::getAll(); + foreach ($pages as $p) { + $p->updateHtml(); + } +} + +function add_files_to_uploads(): void { + $path = cli::input('Enter path: '); + if (!file_exists($path)) + cli::die("file $path doesn't exists"); + $name = basename($path); + $ext = extension($name); + $id = uploads::add($path, $name, ''); + echo "upload id: $id\n"; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..305e87d --- /dev/null +++ b/composer.json @@ -0,0 +1,14 @@ +{ + "require": { + "sixlive/parsedown-highlight": "^0.3.1", + "erusev/parsedown": "1.8.0-beta-5", + "ext-mbstring": "*", + "ext-gd": "*", + "ext-mysqli": "*", + "ext-json": "*", + "ext-sqlite3": "*" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "preferred-install": "dist" +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..c196297 --- /dev/null +++ b/composer.lock @@ -0,0 +1,352 @@ +{ + "_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": "cfed6325222efab0afd48dc69b6dd69b", + "packages": [ + { + "name": "bhaktaraz/php-rss-generator", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/bhaktaraz/php-rss-generator.git", + "reference": "4c90e6e2fbb74f0dcb3cf6d1fcd4fa1a77b53f0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bhaktaraz/php-rss-generator/zipball/4c90e6e2fbb74f0dcb3cf6d1fcd4fa1a77b53f0d", + "reference": "4c90e6e2fbb74f0dcb3cf6d1fcd4fa1a77b53f0d", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Bhaktaraz\\RSSGenerator\\": "Source/Bhaktaraz/RSSGenerator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bhaktaraz Bhatta", + "email": "bhattabhakta@gmail.com" + } + ], + "description": "Simple RSS generator library for PHP 5.5 or later.", + "homepage": "https://github.com/bhaktaraz/php-rss-generator", + "keywords": [ + "Facebook product feed generator", + "feed", + "generator", + "rss", + "writer" + ], + "time": "2019-01-08T12:17:22+00:00" + }, + { + "name": "erusev/parsedown", + "version": "1.8.0-beta-5", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown.git", + "reference": "c26a2ee4bf8ba0270daab7da0353f2525ca6564a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/c26a2ee4bf8ba0270daab7da0353f2525ca6564a", + "reference": "c26a2ee4bf8ba0270daab7da0353f2525ca6564a", + "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": "2018-06-11T18:15:32+00:00" + }, + { + "name": "scrivo/highlight.php", + "version": "v9.15.8.0", + "source": { + "type": "git", + "url": "https://github.com/scrivo/highlight.php.git", + "reference": "2626bf8731737b2487e54bda5a980f0e5a143320" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/2626bf8731737b2487e54bda5a980f0e5a143320", + "reference": "2626bf8731737b2487e54bda5a980f0e5a143320", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*" + }, + "require-dev": { + "phpunit/phpunit": "^4.8|^5.7", + "symfony/finder": "^2.8" + }, + "suggest": { + "ext-dom": "Needed to make use of the features in the utilities namespace" + }, + "type": "library", + "autoload": { + "psr-0": { + "Highlight\\": "", + "HighlightUtilities\\": "" + }, + "files": [ + "HighlightUtilities/functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Geert Bergman", + "homepage": "http://www.scrivo.org/", + "role": "Project Author" + }, + { + "name": "Vladimir Jimenez", + "homepage": "https://allejo.io", + "role": "Contributor" + }, + { + "name": "Martin Folkers", + "homepage": "https://twobrain.io", + "role": "Contributor" + } + ], + "description": "Server side syntax highlighter that supports 185 languages. It's a PHP port of highlight.js", + "keywords": [ + "code", + "highlight", + "highlight.js", + "highlight.php", + "syntax" + ], + "time": "2019-05-31T06:24:05+00:00" + }, + { + "name": "sixlive/parsedown-highlight", + "version": "v0.3.1", + "source": { + "type": "git", + "url": "https://github.com/sixlive/parsedown-highlight.git", + "reference": "e05632eea4cf97c865a17d2b65f31c5b477a6a7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sixlive/parsedown-highlight/zipball/e05632eea4cf97c865a17d2b65f31c5b477a6a7a", + "reference": "e05632eea4cf97c865a17d2b65f31c5b477a6a7a", + "shasum": "" + }, + "require": { + "erusev/parsedown": "1.8.0-beta-5", + "php": "^7.1|7.2", + "scrivo/highlight.php": "^9.14" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.10", + "larapack/dd": "^1.0", + "phpunit/phpunit": "^6.0|^7.0", + "sempro/phpunit-pretty-print": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "sixlive\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "TJ Miller", + "email": "oss@tjmiller.co", + "homepage": "https://tjmiller.co", + "role": "Developer" + } + ], + "description": "Server side code block rendering for Parsedown", + "homepage": "https://github.com/sixlive/parsedown-highlight", + "keywords": [ + "code", + "markdown", + "parsedown" + ], + "time": "2019-04-14T15:21:19+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "82ebae02209c21113908c229e9883c419720738a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", + "reference": "82ebae02209c21113908c229e9883c419720738a", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "twig/twig", + "version": "v1.42.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "671347603760a88b1e7288aaa9378f33687d7edf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/671347603760a88b1e7288aaa9378f33687d7edf", + "reference": "671347603760a88b1e7288aaa9378f33687d7edf", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "symfony/polyfill-ctype": "^1.8" + }, + "require-dev": { + "psr/container": "^1.0", + "symfony/debug": "^2.7", + "symfony/phpunit-bridge": "^3.4.19|^4.1.8|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.42-dev" + } + }, + "autoload": { + "psr-0": { + "Twig_": "lib/" + }, + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + }, + { + "name": "Twig Team", + "homepage": "https://twig.symfony.com/contributors", + "role": "Contributors" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "time": "2019-06-04T11:31:08+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": { + "bhaktaraz/php-rss-generator": 20 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..0e2a225 --- /dev/null +++ b/config.php @@ -0,0 +1,28 @@ +<?php + +return [ + 'domain' => 'example.com', + 'cookie_host' => '.example.com', + 'admin_email' => 'admin@example.com', + + 'db' => [ + 'type' => 'mysql', + 'host' => '127.0.0.1', + 'user' => '', + 'password' => '', + 'database' => '', + ], + + 'log_file' => '/var/log/example.com-backend.log', + + 'password_salt' => '12345', + 'csrf_token' => '12345', + 'uploads_dir' => '/home/user/files.example.com', + 'uploads_host' => 'files.example.com', + + 'sassc_bin' => '/usr/local/bin/sassc', + 'dirs_mode' => 0775, + 'files_mode' => 0664, + 'group' => 33, // id -g www-data + 'is_dev' => true, +]; diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..ddad0c6 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +DEV_DIR="${DIR}" +STAGING_DIR="$HOME/staging" +PROD_DIR="$HOME/prod" +PHP=/usr/bin/php8.1 + +git push origin master + +[ -d "$STAGING_DIR" ] || mkdir "$STAGING_DIR" +pushd "$STAGING_DIR" + +if [ ! -d .git ]; then + git init + git remote add origin git@ch1p.io:ch1p_io_web.git + git fetch + git checkout master +fi + +git reset --hard +git pull origin master + +$PHP composer.phar install --no-dev --optimize-autoloader +$PHP prepare_static.php + +cp "$DEV_DIR/config-local.php" . +cat config-local.php | grep -v is_dev | tee config-local.php +popd + +# copy staging to prod +rsync -a --delete --delete-excluded --info=progress2 "$STAGING_DIR/" "$PROD_DIR/" \ + --exclude .git \ + --exclude debug.log \ + --exclude='/composer.*' \ + --exclude='/htdocs/scss' \ + --exclude='/htdocs/sass.php' \ + --exclude='*.sh' diff --git a/engine/AjaxErrorResponse.php b/engine/AjaxErrorResponse.php new file mode 100644 index 0000000..a1fe381 --- /dev/null +++ b/engine/AjaxErrorResponse.php @@ -0,0 +1,9 @@ +<?php + +class AjaxErrorResponse extends AjaxResponse { + + public function __construct(string $error, int $code = 200) { + parent::__construct(code: $code, body: json_encode(['error' => $error], JSON_UNESCAPED_UNICODE)); + } + +}
\ No newline at end of file diff --git a/engine/AjaxOkResponse.php b/engine/AjaxOkResponse.php new file mode 100644 index 0000000..253a563 --- /dev/null +++ b/engine/AjaxOkResponse.php @@ -0,0 +1,9 @@ +<?php + +class AjaxOkResponse extends AjaxResponse { + + public function __construct($data) { + parent::__construct(code: 200, body: json_encode(['response' => $data], JSON_UNESCAPED_UNICODE)); + } + +}
\ No newline at end of file diff --git a/engine/AjaxResponse.php b/engine/AjaxResponse.php new file mode 100644 index 0000000..931e5e7 --- /dev/null +++ b/engine/AjaxResponse.php @@ -0,0 +1,13 @@ +<?php + +class AjaxResponse extends Response { + + public function __construct(...$args) { + parent::__construct(...$args); + $this->addHeader('Content-Type: application/json; charset=utf-8'); + $this->addHeader('Cache-Control: no-cache, must-revalidate'); + $this->addHeader('Pragma: no-cache'); + $this->addHeader('Content-Type: application/json; charset=utf-8'); + } + +}
\ No newline at end of file diff --git a/engine/InputType.php b/engine/InputType.php new file mode 100644 index 0000000..401f7ca --- /dev/null +++ b/engine/InputType.php @@ -0,0 +1,8 @@ +<?php + +enum InputType: string { + case INT = 'i'; + case FLOAT = 'f'; + case BOOL = 'b'; + case STRING = 's'; +}
\ No newline at end of file diff --git a/engine/LangAccess.php b/engine/LangAccess.php new file mode 100644 index 0000000..db55b3b --- /dev/null +++ b/engine/LangAccess.php @@ -0,0 +1,8 @@ +<?php + +interface LangAccess { + + public function lang(...$args): string; + public function langRaw(string $key, ...$args); + +}
\ No newline at end of file diff --git a/engine/LangData.php b/engine/LangData.php new file mode 100644 index 0000000..6f108f2 --- /dev/null +++ b/engine/LangData.php @@ -0,0 +1,108 @@ +<?php + +class LangData implements ArrayAccess { + + private static ?LangData $instance = null; + protected array $data = []; + protected array $loaded = []; + + public static function getInstance(): static { + if (is_null(self::$instance)) { + self::$instance = new self(); + self::$instance->load('en'); + } + return self::$instance; + } + + public function __invoke(string $key, ...$args) { + $val = $this[$key]; + return empty($args) ? $val : sprintf($val, ...$args); + } + + public function load(string $name) { + if (array_key_exists($name, $this->loaded)) + return; + + $data = require_once ROOT."/lang/{$name}.php"; + $this->data = array_replace($this->data, + $data); + + $this->loaded[$name] = true; + } + + public function offsetSet(mixed $offset, mixed $value): void { + logError(__METHOD__ . ': not implemented'); + } + + public function offsetExists($offset): bool { + return isset($this->data[$offset]); + } + + public function offsetUnset(mixed $offset): void { + logError(__METHOD__ . ': not implemented'); + } + + public function offsetGet(mixed $offset): mixed { + return $this->data[$offset] ?? '{' . $offset . '}'; + } + + public function search(string $regexp): array|false { + return preg_grep($regexp, array_keys($this->data)); + } + + // function plural(array $s, int $n, array $opts = []) { + // $opts = array_merge([ + // 'format' => true, + // 'format_delim' => ' ', + // 'lang' => 'en', + // ], $opts); + // + // switch ($opts['lang']) { + // case 'ru': + // $n = $n % 100; + // if ($n > 19) + // $n %= 10; + // + // if ($n == 1) { + // $word = 0; + // } else if ($n >= 2 && $n <= 4) { + // $word = 1; + // } else if ($n == 0 && count($s) == 4) { + // $word = 3; + // } else { + // $word = 2; + // } + // break; + // + // default: + // if (!$n && count($s) == 4) { + // $word = 3; + // } else { + // $word = (int)!!$n; + // } + // break; + // } + // + // // if zero + // if ($word == 3) + // return $s[3]; + // + // if (is_callable($opts['format'])) { + // $num = $opts['format']($n); + // } else if ($opts['format'] === true) { + // $num = formatNumber($n, $opts['format_delim']); + // } + // + // return sprintf($s[$word], $num); + // } + // + // function formatNumber(int $num, string $delim = ' ', bool $short = false): string { + // if ($short) { + // if ($num >= 1000000) + // return floor($num / 1000000).'m'; + // if ($num >= 1000) + // return floor($num / 1000).'k'; + // } + // return number_format($num, 0, '.', $delim); + // } +} diff --git a/engine/Model.php b/engine/Model.php new file mode 100644 index 0000000..e80b09d --- /dev/null +++ b/engine/Model.php @@ -0,0 +1,240 @@ +<?php + +enum Type { + case STRING; + case INTEGER; + case FLOAT; + case ARRAY; + case BOOLEAN; + case JSON; + case SERIALIZED; +} + +abstract class Model { + + const DB_TABLE = null; + const DB_KEY = 'id'; + + protected static array $SpecCache = []; + + public static function create_instance(...$args) { + $cl = get_called_class(); + return new $cl(...$args); + } + + public function __construct(array $raw) { + if (!isset(self::$SpecCache[static::class])) { + list($fields, $model_name_map, $db_name_map) = static::get_spec(); + self::$SpecCache[static::class] = [ + 'fields' => $fields, + 'model_name_map' => $model_name_map, + 'db_name_map' => $db_name_map + ]; + } + + foreach (self::$SpecCache[static::class]['fields'] as $field) + $this->{$field['model_name']} = self::cast_to_type($field['type'], $raw[$field['db_name']]); + + if (is_null(static::DB_TABLE)) + trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined'); + } + + public function edit(array $fields) { + $db = getDb(); + + $model_upd = []; + $db_upd = []; + + foreach ($fields as $name => $value) { + $index = self::$SpecCache[static::class]['db_name_map'][$name] ?? null; + if (is_null($index)) { + logError(__METHOD__.': field `'.$name.'` not found in '.static::class); + continue; + } + + $field = self::$SpecCache[static::class]['fields'][$index]; + switch ($field['type']) { + case Type::ARRAY: + if (is_array($value)) { + $db_upd[$name] = implode(',', $value); + $model_upd[$field['model_name']] = $value; + } else { + logError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.'); + } + break; + + case Type::INTEGER: + $value = (int)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + + case Type::FLOAT: + $value = (float)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + + case Type::BOOLEAN: + $db_upd[$name] = $value ? 1 : 0; + $model_upd[$field['model_name']] = $value; + break; + + case Type::JSON: + $db_upd[$name] = json_encode($value, JSON_UNESCAPED_UNICODE); + $model_upd[$field['model_name']] = $value; + break; + + case Type::SERIALIZED: + $db_upd[$name] = serialize($value); + $model_upd[$field['model_name']] = $value; + break; + + default: + $value = (string)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + } + } + + if (!empty($db_upd) && !$db->update(static::DB_TABLE, $db_upd, static::DB_KEY."=?", $this->get_id())) { + logError(__METHOD__.': failed to update database'); + return; + } + + if (!empty($model_upd)) { + foreach ($model_upd as $name => $value) + $this->{$name} = $value; + } + } + + public function get_id() { + return $this->{to_camel_case(static::DB_KEY)}; + } + + public function as_array(array $fields = [], array $custom_getters = []): array { + if (empty($fields)) + $fields = array_keys(static::$SpecCache[static::class]['db_name_map']); + + $array = []; + foreach ($fields as $field) { + if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) { + $array[$field] = $custom_getters[$field](); + } else { + $array[$field] = $this->{to_camel_case($field)}; + } + } + + return $array; + } + + protected static function cast_to_type(Type $type, $value) { + switch ($type) { + case Type::BOOLEAN: + return (bool)$value; + + case Type::INTEGER: + return (int)$value; + + case Type::FLOAT: + return (float)$value; + + case Type::ARRAY: + return array_filter(explode(',', $value)); + + case Type::JSON: + $val = json_decode($value, true); + if (!$val) + $val = null; + return $val; + + case Type::SERIALIZED: + $val = unserialize($value); + if ($val === false) + $val = null; + return $val; + + default: + return (string)$value; + } + } + + protected static function get_spec(): array { + $rc = new ReflectionClass(static::class); + $props = $rc->getProperties(ReflectionProperty::IS_PUBLIC); + + $list = []; + $index = 0; + + $model_name_map = []; + $db_name_map = []; + + foreach ($props as $prop) { + if ($prop->isStatic()) + continue; + + $name = $prop->getName(); + if (str_starts_with($name, '_')) + continue; + + $type = $prop->getType(); + $phpdoc = $prop->getDocComment(); + + $mytype = null; + if (!$prop->hasType() && !$phpdoc) + $mytype = Type::STRING; + else { + $typename = $type->getName(); + switch ($typename) { + case 'string': + $mytype = Type::STRING; + break; + case 'int': + $mytype = Type::INTEGER; + break; + case 'float': + $mytype = Type::FLOAT; + break; + case 'array': + $mytype = Type::ARRAY; + break; + case 'bool': + $mytype = Type::BOOLEAN; + break; + } + + if ($phpdoc != '') { + $pos = strpos($phpdoc, '@'); + if ($pos === false) + continue; + + if (substr($phpdoc, $pos+1, 4) == 'json') + $mytype = Type::JSON; + else if (substr($phpdoc, $pos+1, 5) == 'array') + $mytype = Type::ARRAY; + else if (substr($phpdoc, $pos+1, 10) == 'serialized') + $mytype = Type::SERIALIZED; + } + } + + if (is_null($mytype)) + logError(__METHOD__.": ".$name." is still null in ".static::class); + + $dbname = from_camel_case($name); + $list[] = [ + 'type' => $mytype, + 'model_name' => $name, + 'db_name' => $dbname + ]; + + $model_name_map[$name] = $index; + $db_name_map[$dbname] = $index; + + $index++; + } + + return [$list, $model_name_map, $db_name_map]; + } + +} diff --git a/engine/RedirectResponse.php b/engine/RedirectResponse.php new file mode 100644 index 0000000..7900229 --- /dev/null +++ b/engine/RedirectResponse.php @@ -0,0 +1,11 @@ +<?php + +class RedirectResponse extends Response { + + public function __construct(string $url) { + parent::__construct(301); + $this->addHeader('HTTP/1.1 301 Moved Permanently'); + $this->addHeader('Location: '.$url); + } + +}
\ No newline at end of file diff --git a/engine/RequestDispatcher.php b/engine/RequestDispatcher.php new file mode 100644 index 0000000..38b965d --- /dev/null +++ b/engine/RequestDispatcher.php @@ -0,0 +1,79 @@ +<?php + +class RequestDispatcher { + + public function __construct( + protected Router $router + ) {} + + public function dispatch(): void { + try { + if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET'])) + throw new NotImplementedException('Method '.$_SERVER['REQUEST_METHOD'].' not implemented'); + + $route = $this->router->find(self::path()); + if ($route === null) + throw new NotFoundException('Route not found'); + + $route = preg_split('/ +/', $route); + $handler_class = $route[0]; + if (($pos = strrpos($handler_class, '/')) !== false) { + $class_name = substr($handler_class, $pos+1); + $class_name = ucfirst(to_camel_case($class_name)); + $handler_class = str_replace('/', '\\', substr($handler_class, 0, $pos)).'\\'.$class_name; + } else { + $handler_class = ucfirst(to_camel_case($handler_class)); + } + $handler_class = 'handler\\'.$handler_class; + + if (!class_exists($handler_class)) + throw new NotFoundException('Handler class "'.$handler_class.'" not found'); + + $router_input = []; + if (count($route) > 1) { + for ($i = 1; $i < count($route); $i++) { + $var = $route[$i]; + list($k, $v) = explode('=', $var); + $router_input[trim($k)] = trim($v); + } + } + + $skin = new Skin(); + $skin->static[] = '/css/common-bundle.css'; + $skin->static[] = '/js/common.js'; + + /** @var RequestHandler $handler */ + $handler = new $handler_class($skin, LangData::getInstance(), $router_input); + $resp = $handler->beforeDispatch(); + if ($resp instanceof Response) { + $resp->send(); + return; + } + + $resp = call_user_func([$handler, strtolower($_SERVER['REQUEST_METHOD'])]); + } catch (NotFoundException $e) { + $resp = $this->getErrorResponse($e, 'not_found'); + } catch (ForbiddenException $e) { + $resp = $this->getErrorResponse($e, 'forbidden'); + } catch (NotImplementedException $e) { + $resp = $this->getErrorResponse($e, 'not_implemented'); + } catch (UnauthorizedException $e) { + $resp = $this->getErrorResponse($e, 'unauthorized'); + } + $resp->send(); + } + + protected function getErrorResponse(Exception $e, string $render_function): Response { + $ctx = new SkinContext('\\skin\\error'); + $html = call_user_func([$ctx, $render_function], $e->getMessage()); + return new Response($e->getCode(), $html); + } + + public static function path(): string { + $uri = $_SERVER['REQUEST_URI']; + if (($pos = strpos($uri, '?')) !== false) + $uri = substr($uri, 0, $pos); + return $uri; + } + +} diff --git a/engine/RequestHandler.php b/engine/RequestHandler.php new file mode 100644 index 0000000..a9dfccd --- /dev/null +++ b/engine/RequestHandler.php @@ -0,0 +1,56 @@ +<?php + +class RequestHandler { + + public function __construct( + protected Skin $skin, + protected LangData $lang, + protected array $routerInput + ) {} + + public function beforeDispatch(): ?Response { + return null; + } + + public function get(): Response { + throw new NotImplementedException(); + } + + public function post(): Response { + throw new NotImplementedException(); + } + + public function input(string $input): array { + $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); + $ret = []; + foreach ($input as $var) { + if (($pos = strpos($var, ':')) !== false) { + $type = InputType::from(substr($var, 0, $pos)); + $name = trim(substr($var, $pos+1)); + } else { + $type = InputType::STRING; + $name = $var; + } + + $value = $this->routerInput[$name] ?? $_REQUEST[$name] ?? ''; + switch ($type) { + case InputType::INT: + $value = (int)$value; + break; + case InputType::FLOAT: + $value = (float)$value; + break; + case InputType::BOOL: + $value = (bool)$value; + break; + } + + $ret[] = $value; + } + return $ret; + } + + protected function isRetina(): bool { + return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; + } +} diff --git a/engine/Response.php b/engine/Response.php new file mode 100644 index 0000000..6250063 --- /dev/null +++ b/engine/Response.php @@ -0,0 +1,28 @@ +<?php + +class Response { + + protected array $headers = []; + + public function __construct( + public int $code = 200, + public ?string $body = null + ) {} + + public function send(): void { + $this->setHeaders(); + if ($this->code == 200 || $this->code >= 400) + echo $this->body; + } + + public function addHeader(string $header): void { + $this->headers[] = $header; + } + + public function setHeaders(): void { + http_response_code($this->code); + foreach ($this->headers as $header) + header($header); + } + +}
\ No newline at end of file diff --git a/engine/Router.php b/engine/Router.php new file mode 100644 index 0000000..0cb761d --- /dev/null +++ b/engine/Router.php @@ -0,0 +1,165 @@ +<?php + +class Router { + + protected array $routes = [ + 'children' => [], + 're_children' => [] + ]; + + public function add($template, $value) { + if ($template == '') + return $this; + + // expand {enum,erat,ions} + $templates = [[$template, $value]]; + if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) { + foreach ($matches[1] as $match_index => $variants) { + $variants = explode(',', $variants); + $variants = array_map('trim', $variants); + $variants = array_filter($variants, function($s) { return $s != ''; }); + + for ($i = 0; $i < count($templates); ) { + list($template, $value) = $templates[$i]; + $new_templates = []; + foreach ($variants as $variant_index => $variant) { + $new_templates[] = [ + str_replace_once($matches[0][$match_index], $variant, $template), + str_replace('${'.($match_index+1).'}', $variant, $value) + ]; + } + array_splice($templates, $i, 1, $new_templates); + $i += count($new_templates); + } + } + } + + // process all generated routes + foreach ($templates as $template) { + list($template, $value) = $template; + + $start_pos = 0; + $parent = &$this->routes; + $template_len = strlen($template); + + while ($start_pos < $template_len) { + $slash_pos = strpos($template, '/', $start_pos); + if ($slash_pos !== false) { + $part = substr($template, $start_pos, $slash_pos-$start_pos+1); + $start_pos = $slash_pos+1; + } else { + $part = substr($template, $start_pos); + $start_pos = $template_len; + } + + $parent = &$this->_addRoute($parent, $part, + $start_pos < $template_len ? null : $value); + } + } + + return $this; + } + + protected function &_addRoute(&$parent, $part, $value = null) { + $par_pos = strpos($part, '('); + $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\'); + + $children_key = !$is_regex ? 'children' : 're_children'; + + if (isset($parent[$children_key][$part])) { + if (is_null($value)) { + $parent = &$parent[$children_key][$part]; + } else { + if (!isset($parent[$children_key][$part]['value'])) { + $parent[$children_key][$part]['value'] = $value; + } else { + trigger_error(__METHOD__.': route is already defined'); + } + } + return $parent; + } + + $child = [ + 'children' => [], + 're_children' => [] + ]; + if (!is_null($value)) + $child['value'] = $value; + + $parent[$children_key][$part] = $child; + return $parent[$children_key][$part]; + } + + public function find($uri) { + if ($uri != '/' && $uri[0] == '/') { + $uri = substr($uri, 1); + } + $start_pos = 0; + $parent = &$this->routes; + $uri_len = strlen($uri); + $matches = []; + + while ($start_pos < $uri_len) { + $slash_pos = strpos($uri, '/', $start_pos); + if ($slash_pos !== false) { + $part = substr($uri, $start_pos, $slash_pos-$start_pos+1); + $start_pos = $slash_pos+1; + } else { + $part = substr($uri, $start_pos); + $start_pos = $uri_len; + } + + $found = false; + if (isset($parent['children'][$part])) { + $parent = &$parent['children'][$part]; + $found = true; + } else if (!empty($parent['re_children'])) { + foreach ($parent['re_children'] as $re => &$child) { + $exp = '#^'.$re.'$#'; + $re_result = preg_match($exp, $part, $match); + if ($re_result === false) { + logError(__METHOD__.": regex $exp failed"); + continue; + } + + if ($re_result) { + if (count($match) > 1) { + $matches = array_merge($matches, array_slice($match, 1)); + } + $parent = &$child; + $found = true; + break; + } + } + } + + if (!$found) + return false; + } + + if (!isset($parent['value'])) + return false; + + $value = $parent['value']; + if (!empty($matches)) { + foreach ($matches as $i => $match) { + $needle = '$('.($i+1).')'; + $pos = strpos($value, $needle); + if ($pos !== false) { + $value = substr_replace($value, $match, $pos, strlen($needle)); + } + } + } + + return $value; + } + + public function load($routes) { + $this->routes = $routes; + } + + public function dump(): array { + return $this->routes; + } + +} diff --git a/engine/Skin.php b/engine/Skin.php new file mode 100644 index 0000000..57f8b90 --- /dev/null +++ b/engine/Skin.php @@ -0,0 +1,57 @@ +<?php + +class Skin { + + public string $title = 'title'; + public array $static = []; + public array $meta = []; + + protected array $langKeys = []; + protected array $options = [ + 'full_width' => false, + 'dynlogo_enabled' => true, + 'logo_path_map' => [], + 'logo_link_map' => [], + ]; + + public function renderPage($f, ...$vars): Response { + $f = '\\skin\\'.str_replace('/', '\\', $f); + $ctx = new SkinContext(substr($f, 0, ($pos = strrpos($f, '\\')))); + $body = call_user_func_array([$ctx, substr($f, $pos+1)], $vars); + if (is_array($body)) + list($body, $js) = $body; + else + $js = null; + + $layout_ctx = new SkinContext('\\skin\\base'); + $lang = $this->getLang(); + $lang = !empty($lang) ? json_encode($lang, JSON_UNESCAPED_UNICODE) : ''; + return new Response(200, $layout_ctx->layout( + static: $this->static, + title: $this->title, + opts: $this->options, + js: $js, + meta: $this->meta, + unsafe_lang: $lang, + unsafe_body: $body, + exec_time: exectime() + )); + } + + public function addLangKeys(array $keys): void { + $this->langKeys = array_merge($this->langKeys, $keys); + } + + protected function getLang(): array { + $lang = []; + $ld = LangData::getInstance(); + foreach ($this->langKeys as $key) + $lang[$key] = $ld[$key]; + return $lang; + } + + public function setOptions(array $options): void { + $this->options = array_merge($this->options, $options); + } + +} diff --git a/engine/SkinBase.php b/engine/SkinBase.php new file mode 100644 index 0000000..b50c172 --- /dev/null +++ b/engine/SkinBase.php @@ -0,0 +1,22 @@ +<?php + +class SkinBase implements LangAccess { + + protected static LangData $ld; + + public static function __constructStatic(): void { + self::$ld = LangData::getInstance(); + } + + public function lang(...$args): string { + return htmlescape($this->langRaw(...$args)); + } + + public function langRaw(string $key, ...$args) { + $val = self::$ld[$key]; + return empty($args) ? $val : sprintf($val, ...$args); + } + +} + +SkinBase::__constructStatic();
\ No newline at end of file diff --git a/engine/SkinContext.php b/engine/SkinContext.php new file mode 100644 index 0000000..cfb5068 --- /dev/null +++ b/engine/SkinContext.php @@ -0,0 +1,116 @@ +<?php + +class SkinContext extends SkinBase { + + protected string $ns; + protected array $data = []; + + public function __construct(string $namespace) { + $this->ns = $namespace; + require_once ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.skin.php'; + } + + public function __call($name, array $arguments) { + $plain_args = array_is_list($arguments); + + $fn = $this->ns.'\\'.$name; + $refl = new ReflectionFunction($fn); + $fparams = $refl->getParameters(); + assert(count($fparams) == count($arguments)+1, "$fn: invalid number of arguments (".count($fparams)." != ".(count($arguments)+1).")"); + + foreach ($fparams as $n => $param) { + if ($n == 0) + continue; // skip $ctx + + $key = $plain_args ? $n-1 : $param->name; + if (!$plain_args && !array_key_exists($param->name, $arguments)) { + if (!$param->isDefaultValueAvailable()) + throw new InvalidArgumentException('argument '.$param->name.' not found'); + else + continue; + } + + if (is_string($arguments[$key]) || $arguments[$key] instanceof SkinString) { + if (is_string($arguments[$key])) + $arguments[$key] = new SkinString($arguments[$key]); + + if (($pos = strpos($param->name, '_')) !== false) { + $mod_type = match(substr($param->name, 0, $pos)) { + 'unsafe' => SkinStringModificationType::RAW, + 'urlencoded' => SkinStringModificationType::URL, + 'jsonencoded' => SkinStringModificationType::JSON, + 'addslashes' => SkinStringModificationType::ADDSLASHES, + default => SkinStringModificationType::HTML + }; + } else { + $mod_type = SkinStringModificationType::HTML; + } + $arguments[$key]->setModType($mod_type); + } + } + + array_unshift($arguments, $this); + return call_user_func_array($fn, $arguments); + } + + public function __get(string $name) { + $fn = $this->ns.'\\'.$name; + if (function_exists($fn)) + return [$this, $name]; + + if (array_key_exists($name, $this->data)) + return $this->data[$name]; + } + + public function __set(string $name, $value) { + $this->data[$name] = $value; + } + + public function if_not($cond, $callback, ...$args) { + return $this->_if_condition(!$cond, $callback, ...$args); + } + + public function if_true($cond, $callback, ...$args) { + return $this->_if_condition($cond, $callback, ...$args); + } + + public function if_admin($callback, ...$args) { + return $this->_if_condition(admin::isAdmin(), $callback, ...$args); + } + + public function if_dev($callback, ...$args) { + global $config; + return $this->_if_condition($config['is_dev'], $callback, ...$args); + } + + public function if_then_else($cond, $cb1, $cb2) { + return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2); + } + + public function csrf($key): string { + return csrf::get($key); + } + + protected function _if_condition($condition, $callback, ...$args) { + if (is_string($condition) || $condition instanceof Stringable) + $condition = (string)$condition !== ''; + if ($condition) + return $this->_return_callback($callback, $args); + return ''; + } + + protected function _return_callback($callback, $args = []) { + if (is_callable($callback)) + return call_user_func_array($callback, $args); + else if (is_string($callback)) + return $callback; + } + + public function for_each(array $iterable, callable $callback) { + $html = ''; + foreach ($iterable as $k => $v) + $html .= call_user_func($callback, $v, $k); + return $html; + } + +} diff --git a/engine/SkinString.php b/engine/SkinString.php new file mode 100644 index 0000000..0f8f14d --- /dev/null +++ b/engine/SkinString.php @@ -0,0 +1,23 @@ +<?php + +class SkinString implements Stringable { + + protected SkinStringModificationType $modType; + + public function __construct(protected string $string) {} + + public function setModType(SkinStringModificationType $modType) { + $this->modType = $modType; + } + + public function __toString(): string { + return match ($this->modType) { + SkinStringModificationType::HTML => htmlescape($this->string), + SkinStringModificationType::URL => urlencode($this->string), + SkinStringModificationType::JSON => json_encode($this->string, JSON_UNESCAPED_UNICODE), + SkinStringModificationType::ADDSLASHES => addslashes($this->string), + default => $this->string, + }; + } + +}
\ No newline at end of file diff --git a/engine/SkinStringModificationType.php b/engine/SkinStringModificationType.php new file mode 100644 index 0000000..7e750f2 --- /dev/null +++ b/engine/SkinStringModificationType.php @@ -0,0 +1,9 @@ +<?php + +enum SkinStringModificationType { + case RAW; + case URL; + case HTML; + case JSON; + case ADDSLASHES; +}
\ No newline at end of file diff --git a/engine/ansi.php b/engine/ansi.php new file mode 100644 index 0000000..311c837 --- /dev/null +++ b/engine/ansi.php @@ -0,0 +1,34 @@ +<?php + +namespace ansi; + +enum Color: int { + case BLACK = 0; + case RED = 1; + case GREEN = 2; + case YELLOW = 3; + case BLUE = 4; + case MAGENTA = 5; + case CYAN = 6; + case WHITE = 7; +} + +function wrap(string $text, + ?Color $fg = null, + ?Color $bg = null, + bool $bold = false, + bool $fg_bright = false, + bool $bg_bright = false): string { + $codes = []; + if (!is_null($fg)) + $codes[] = $fg->value + ($fg_bright ? 90 : 30); + if (!is_null($bg)) + $codes[] = $bg->value + ($bg_bright ? 100 : 40); + if ($bold) + $codes[] = 1; + + if (empty($codes)) + return $text; + + return "\033[".implode(';', $codes)."m".$text."\033[0m"; +}
\ No newline at end of file diff --git a/engine/csrf.php b/engine/csrf.php new file mode 100644 index 0000000..20ea919 --- /dev/null +++ b/engine/csrf.php @@ -0,0 +1,22 @@ +<?php + +class csrf { + + public static function check(string $key): void { + $user_csrf = self::get($key); + $sent_csrf = $_REQUEST['token'] ?? ''; + + if ($sent_csrf != $user_csrf) + throw new ForbiddenException("csrf error"); + } + + public static function get(string $key): string { + return self::getToken($_SERVER['REMOTE_ADDR'], $key); + } + + protected static function getToken(string $user_token, string $key): string { + global $config; + return substr(sha1($config['csrf_token'].$user_token.$key), 0, 20); + } + +}
\ No newline at end of file diff --git a/engine/database/CommonDatabase.php b/engine/database/CommonDatabase.php new file mode 100644 index 0000000..13ea79c --- /dev/null +++ b/engine/database/CommonDatabase.php @@ -0,0 +1,107 @@ +<?php + +abstract class CommonDatabase { + + abstract public function query(string $sql, ...$args); + abstract public function escape(string $s): string; + abstract public function fetch($q): ?array; + abstract public function fetchAll($q): ?array; + abstract public function fetchRow($q): ?array; + abstract public function result($q, int $field = 0); + abstract public function insertId(): ?int; + abstract public function numRows($q): ?int; + + protected function prepareQuery(string $sql, ...$args): string { + global $config; + if (!empty($args)) { + $mark_count = substr_count($sql, '?'); + $positions = array(); + $last_pos = -1; + for ($i = 0; $i < $mark_count; $i++) { + $last_pos = strpos($sql, '?', $last_pos + 1); + $positions[] = $last_pos; + } + for ($i = $mark_count - 1; $i >= 0; $i--) { + $arg_val = $args[$i]; + if (is_null($arg_val)) { + $v = 'NULL'; + } else { + $v = '\''.$this->escape($arg_val) . '\''; + } + $sql = substr_replace($sql, $v, $positions[$i], 1); + } + } + if (!empty($config['db']['log'])) + logDebug(__METHOD__.': ', $sql); + return $sql; + } + + public function insert(string $table, array $fields) { + return $this->performInsert('INSERT', $table, $fields); + } + + public function replace(string $table, array $fields) { + return $this->performInsert('REPLACE', $table, $fields); + } + + protected function performInsert(string $command, string $table, array $fields) { + $names = []; + $values = []; + $count = 0; + foreach ($fields as $k => $v) { + $names[] = $k; + $values[] = $v; + $count++; + } + + $sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")"; + array_unshift($values, $sql); + + return $this->query(...$values); + } + + public function update(string $table, array $rows, ...$cond) { + $fields = []; + $args = []; + foreach ($rows as $row_name => $row_value) { + $fields[] = "`{$row_name}`=?"; + $args[] = $row_value; + } + $sql = "UPDATE `$table` SET ".implode(', ', $fields); + if (!empty($cond)) { + $sql .= " WHERE ".$cond[0]; + if (count($cond) > 1) + $args = array_merge($args, array_slice($cond, 1)); + } + return $this->query($sql, ...$args); + } + + public function multipleInsert(string $table, array $rows) { + list($names, $values) = $this->getMultipleInsertValues($rows); + $sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; + return $this->query($sql); + } + + public function multipleReplace(string $table, array $rows) { + list($names, $values) = $this->getMultipleInsertValues($rows); + $sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values; + return $this->query($sql); + } + + protected function getMultipleInsertValues(array $rows): array { + $names = []; + $sql_rows = []; + foreach ($rows as $i => $fields) { + $row_values = []; + foreach ($fields as $field_name => $field_val) { + if ($i == 0) { + $names[] = $field_name; + } + $row_values[] = $this->escape($field_val); + } + $sql_rows[] = "('".implode("', '", $row_values)."')"; + } + return [$names, implode(', ', $sql_rows)]; + } + +}
\ No newline at end of file diff --git a/engine/database/MySQLConnection.php b/engine/database/MySQLConnection.php new file mode 100644 index 0000000..9b473cb --- /dev/null +++ b/engine/database/MySQLConnection.php @@ -0,0 +1,79 @@ +<?php + +class MySQLConnection extends CommonDatabase { + + protected ?mysqli $link = null; + + public function __construct( + protected string $host, + protected string $user, + protected string $password, + protected string $database) {} + + public function __destruct() { + if ($this->link) + $this->link->close(); + } + + public function connect(): bool { + $this->link = new mysqli(); + return !!$this->link->real_connect($this->host, $this->user, $this->password, $this->database); + } + + public function query(string $sql, ...$args): mysqli_result|bool { + $sql = $this->prepareQuery($sql, ...$args); + $q = $this->link->query($sql); + if (!$q) + logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace(1)); + return $q; + } + + public function fetch($q): ?array { + $row = $q->fetch_assoc(); + if (!$row) { + $q->free(); + return null; + } + return $row; + } + + public function fetchAll($q): ?array { + if (!$q) + return null; + $list = []; + while ($f = $q->fetch_assoc()) { + $list[] = $f; + } + $q->free(); + return $list; + } + + public function fetchRow($q): ?array { + return $q?->fetch_row(); + } + + public function result($q, $field = 0) { + return $q?->fetch_row()[$field]; + } + + public function insertId(): int { + return $this->link->insert_id; + } + + public function numRows($q): ?int { + return $q?->num_rows; + } + + // public function affectedRows() { + // return $this->link->affected_rows; + // } + // + // public function foundRows() { + // return $this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count']; + // } + + public function escape(string $s): string { + return $this->link->real_escape_string($s); + } + +} diff --git a/engine/database/SQLiteConnection.php b/engine/database/SQLiteConnection.php new file mode 100644 index 0000000..f124ced --- /dev/null +++ b/engine/database/SQLiteConnection.php @@ -0,0 +1,90 @@ +<?php + +class SQLiteConnection extends CommonDatabase { + + const SCHEMA_VERSION = 2; + + protected SQLite3 $link; + + public function __construct(string $db_path) { + $will_create = !file_exists($db_path); + $this->link = new SQLite3($db_path); + if ($will_create) + setperm($db_path); + $this->link->enableExceptions(true); + $this->upgradeSchema(); + } + + protected function upgradeSchema() { + $cur = $this->getSchemaVersion(); + if ($cur == self::SCHEMA_VERSION) + return; + + if ($cur < 1) { + $this->link->exec("CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT, + password TEXT + )"); + } + if ($cur < 2) { + $this->link->exec("CREATE TABLE vk_processed ( + last_message_time INTEGER + )"); + $this->link->exec("INSERT INTO vk_processed (last_message_time) VALUES (0)"); + } + $this->syncSchemaVersion(); + } + + protected function getSchemaVersion() { + return $this->link->query("PRAGMA user_version")->fetchArray()[0]; + } + + protected function syncSchemaVersion() { + $this->link->exec("PRAGMA user_version=".self::SCHEMA_VERSION); + } + + public function query(string $sql, ...$params): SQLite3Result { + return $this->link->query($this->prepareQuery($sql, ...$params)); + } + + public function exec(string $sql, ...$params) { + return $this->link->exec($this->prepareQuery($sql, ...$params)); + } + + public function querySingle(string $sql, ...$params) { + return $this->link->querySingle($this->prepareQuery($sql, ...$params)); + } + + public function querySingleRow(string $sql, ...$params) { + return $this->link->querySingle($this->prepareQuery($sql, ...$params), true); + } + + public function insertId(): int { + return $this->link->lastInsertRowID(); + } + + public function escape(string $s): string { + return $this->link->escapeString($s); + } + + public function fetch($q): ?array { + // TODO: Implement fetch() method. + } + + public function fetchAll($q): ?array { + // TODO: Implement fetchAll() method. + } + + public function fetchRow($q): ?array { + // TODO: Implement fetchRow() method. + } + + public function result($q, int $field = 0) { + return $q?->fetchArray()[$field]; + } + + public function numRows($q): ?int { + // TODO: Implement numRows() method. + } +}
\ No newline at end of file diff --git a/engine/exceptions/ForbiddenException.php b/engine/exceptions/ForbiddenException.php new file mode 100644 index 0000000..4184908 --- /dev/null +++ b/engine/exceptions/ForbiddenException.php @@ -0,0 +1,9 @@ +<?php + +class ForbiddenException extends BadMethodCallException { + + public function __construct(string $message = '') { + parent::__construct($message, 403); + } + +}
\ No newline at end of file diff --git a/engine/exceptions/NotFoundException.php b/engine/exceptions/NotFoundException.php new file mode 100644 index 0000000..211106f --- /dev/null +++ b/engine/exceptions/NotFoundException.php @@ -0,0 +1,9 @@ +<?php + +class NotFoundException extends BadMethodCallException { + + public function __construct(string $message = '') { + parent::__construct($message, 404); + } + +}
\ No newline at end of file diff --git a/engine/exceptions/NotImplementedException.php b/engine/exceptions/NotImplementedException.php new file mode 100644 index 0000000..1c4562a --- /dev/null +++ b/engine/exceptions/NotImplementedException.php @@ -0,0 +1,9 @@ +<?php + +class NotImplementedException extends BadMethodCallException { + + public function __construct(string $message = '') { + parent::__construct($message, 501); + } + +}
\ No newline at end of file diff --git a/engine/exceptions/UnauthorizedException.php b/engine/exceptions/UnauthorizedException.php new file mode 100644 index 0000000..84a1251 --- /dev/null +++ b/engine/exceptions/UnauthorizedException.php @@ -0,0 +1,9 @@ +<?php + +class UnauthorizedException extends BadMethodCallException { + + public function __construct(string $message = '') { + parent::__construct($message, 401); + } + +}
\ No newline at end of file diff --git a/engine/logging.php b/engine/logging.php new file mode 100644 index 0000000..f77a5fa --- /dev/null +++ b/engine/logging.php @@ -0,0 +1,187 @@ +<?php + +require_once 'engine/ansi.php'; +use \ansi\Color; +use function \ansi\wrap; + +enum LogLevel { + case ERROR; + case WARNING; + case INFO; + case DEBUG; +} + +function logDebug(...$args): void { logging::logCustom(LogLevel::DEBUG, ...$args); } +function logInfo(...$args): void { logging::logCustom(LogLevel::INFO, ...$args); } +function logWarning(...$args): void { logging::logCustom(LogLevel::WARNING, ...$args); } +function logError(...$args): void { logging::logCustom(LogLevel::ERROR, ...$args); } + +class logging { + + // private static $instance = null; + + protected static ?string $logFile = null; + protected static bool $enabled = false; + protected static int $counter = 0; + + /** @var ?callable $filter */ + protected static $filter = null; + + public static function setLogFile(string $log_file): void { + self::$logFile = $log_file; + } + + public static function setErrorFilter(callable $filter): void { + self::$filter = $filter; + } + + public static function disable(): void { + self::$enabled = false; + + restore_error_handler(); + register_shutdown_function(function() {}); + } + + public static function enable(): void { + self::$enabled = true; + + set_error_handler(function($no, $str, $file, $line) { + if (is_callable(self::$filter) && !(self::$filter)($no, $file, $line, $str)) + return; + + self::write(LogLevel::ERROR, $str, + errno: $no, + errfile: $file, + errline: $line); + }); + + register_shutdown_function(function() { + if (!($error = error_get_last())) + return; + + if (is_callable(self::$filter) + && !(self::$filter)($error['type'], $error['file'], $error['line'], $error['message'])) { + return; + } + + self::write(LogLevel::ERROR, $error['message'], + errno: $error['type'], + errfile: $error['file'], + errline: $error['line']); + }); + } + + public static function logCustom(LogLevel $level, ...$args): void { + self::write($level, self::strVars($args)); + } + + protected static function write(LogLevel $level, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null): void { + + // TODO test + if (is_null(self::$logFile)) { + fprintf(STDERR, __METHOD__.': logfile is not set'); + return; + } + + $num = self::$counter++; + $time = time(); + + // TODO rewrite using sprintf + $exec_time = strval(exectime()); + if (strlen($exec_time) < 6) + $exec_time .= str_repeat('0', 6 - strlen($exec_time)); + + // $bt = backtrace(2); + + $title = PHP_SAPI == 'cli' ? 'cli' : $_SERVER['REQUEST_URI']; + $date = date('d/m/y H:i:s', $time); + + $buf = ''; + if ($num == 0) { + $buf .= wrap(" $title ", + fg: Color::WHITE, + bg: Color::MAGENTA, + fg_bright: true, + bold: true); + $buf .= wrap(" $date ", fg: Color::WHITE, bg: Color::BLUE, fg_bright: true); + $buf .= "\n"; + } + + $letter = strtoupper($level->name[0]); + $color = match ($level) { + LogLevel::ERROR => Color::RED, + LogLevel::INFO, LogLevel::DEBUG => Color::WHITE, + LogLevel::WARNING => Color::YELLOW + }; + + $buf .= wrap($letter.wrap('='.wrap($num, bold: true)), fg: $color).' '; + $buf .= wrap($exec_time, fg: Color::CYAN).' '; + if (!is_null($errno)) { + $buf .= wrap($errfile, fg: Color::GREEN); + $buf .= wrap(':', fg: Color::WHITE); + $buf .= wrap($errline, fg: Color::GREEN, fg_bright: true); + $buf .= ' ('.self::getPhpErrorName($errno).') '; + } + + $buf .= $message."\n"; + if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING])) + $buf .= backtrace(2)."\n"; + + // TODO test + $set_perm = !file_exists(self::$logFile); + $f = fopen(self::$logFile, 'a'); + if (!$f) { + fprintf(STDERR, __METHOD__.': failed to open file "'.self::$logFile.'" for writing'); + return; + } + + fwrite($f, $buf); + fclose($f); + + if ($set_perm) + setperm($f); + } + + protected static function getPhpErrorName(int $errno): string { + static $errors = null; + if (is_null($errors)) + $errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true)); + return $errors[$errno]; + } + + protected static function strVarDump($var, bool $print_r = false): string { + ob_start(); + $print_r ? print_r($var) : var_dump($var); + return trim(ob_get_clean()); + } + + protected static function strVars(array $args): string { + $args = array_map(fn($a) => match (gettype($a)) { + 'string' => $a, + 'array', 'object' => self::strVarDump($a, true), + default => self::strVarDump($a) + }, $args); + return implode(' ', $args); + } + +} + +function backtrace(int $shift = 0): string { + $bt = debug_backtrace(); + $lines = []; + foreach ($bt as $i => $t) { + if ($i < $shift) + continue; + + if (!isset($t['file'])) { + $lines[] = 'from ?'; + } else { + $lines[] = 'from '.$t['file'].':'.$t['line']; + } + } + return implode("\n", $lines); +} diff --git a/functions.php b/functions.php new file mode 100644 index 0000000..9f62f32 --- /dev/null +++ b/functions.php @@ -0,0 +1,298 @@ +<?php + +function htmlescape(string|array $s): string|array { + if (is_array($s)) { + foreach ($s as $k => $v) { + $s[$k] = htmlescape($v); + } + return $s; + } + return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); +} + +function strtrim(string $str, int $len, bool &$trimmed): string { + if (mb_strlen($str) > $len) { + $str = mb_substr($str, 0, $len); + $trimmed = true; + } else { + $trimmed = false; + } + return $str; +} + +function sizeString(int $size): string { + $ks = array('B', 'KiB', 'MiB', 'GiB'); + foreach ($ks as $i => $k) { + if ($size < pow(1024, $i + 1)) { + if ($i == 0) + return $size . ' ' . $k; + return round($size / pow(1024, $i), 2).' '.$k; + } + } + return $size; +} + +function extension(string $name): string { + $expl = explode('.', $name); + return end($expl); +} + +/** + * @param string $filename + * @return resource|bool + */ +function imageopen(string $filename) { + $size = getimagesize($filename); + $types = [ + 1 => 'gif', + 2 => 'jpeg', + 3 => 'png' + ]; + if (!$size || !isset($types[$size[2]])) + return null; + return call_user_func('imagecreatefrom'.$types[$size[2]], $filename); +} + +function detect_image_type(string $filename) { + $size = getimagesize($filename); + $types = [ + 1 => 'gif', + 2 => 'jpg', + 3 => 'png' + ]; + if (!$size || !isset($types[$size[2]])) + return false; + return $types[$size[2]]; +} + +function transliterate(string $string): string { + $roman = array( + 'Sch', 'sch', 'Yo', 'Zh', 'Kh', 'Ts', 'Ch', 'Sh', 'Yu', 'ya', 'yo', + 'zh', 'kh', 'ts', 'ch', 'sh', 'yu', 'ya', 'A', 'B', 'V', 'G', 'D', 'E', + 'Z', 'I', 'Y', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'S', 'T', 'U', 'F', + '', 'Y', '', 'E', 'a', 'b', 'v', 'g', 'd', 'e', 'z', 'i', 'y', 'k', + 'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'f', '', 'y', '', 'e' + ); + $cyrillic = array( + 'Щ', 'щ', 'Ё', 'Ж', 'Х', 'Ц', 'Ч', 'Ш', 'Ю', 'я', 'ё', 'ж', 'х', 'ц', + 'ч', 'ш', 'ю', 'я', 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'З', 'И', 'Й', 'К', + 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Ь', 'Ы', 'Ъ', 'Э', + 'а', 'б', 'в', 'г', 'д', 'е', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', + 'п', 'р', 'с', 'т', 'у', 'ф', 'ь', 'ы', 'ъ', 'э' + ); + return str_replace($cyrillic, $roman, $string); +} + +/** + * @param resource $img + * @param ?int $w + * @param ?int $h + * @param ?int[] $transparent_color + */ +function imageresize(&$img, ?int $w = null, ?int $h = null, ?array $transparent_color = null) { + assert(is_int($w) || is_int($h)); + + $curw = imagesx($img); + $curh = imagesy($img); + + if (!is_int($w) && is_int($h)) { + $w = round($curw / ($curw / $w)); + } else if (is_int($w) && !is_int($h)) { + $h = round($curh / ($curh / $h)); + } + + $img2 = imagecreatetruecolor($w, $h); + if (is_array($transparent_color)) { + list($r, $g, $b) = $transparent_color; + $col = imagecolorallocate($img2, $r, $g, $b); + imagefilledrectangle($img2, 0, 0, $w, $h, $col); + } else { + imagealphablending($img2, false); + imagesavealpha($img2, true); + imagefilledrectangle($img2, 0, 0, $w, $h, imagecolorallocatealpha($img2, 255, 255, 255, 127)); + } + + imagecopyresampled($img2, $img, 0, 0, 0, 0, $w, $h, $curw, $curh); + imagedestroy($img); + + $img = $img2; +} + +function rrmdir(string $dir, bool $dont_delete_dir = false): bool { + if (!is_dir($dir)) { + logError('rrmdir: '.$dir.' is not a directory'); + return false; + } + + $objects = scandir($dir); + foreach ($objects as $object) { + if ($object != '.' && $object != '..') { + if (is_dir($dir.'/'.$object)) { + rrmdir($dir.'/'.$object); + } else { + unlink($dir.'/'.$object); + } + } + } + + if (!$dont_delete_dir) + rmdir($dir); + + return true; +} + +function ip2ulong(string $ip): int { + return sprintf("%u", ip2long($ip)); +} + +function ulong2ip(int $ip): string { + $long = 4294967295 - ($ip - 1); + return long2ip(-$long); +} + +function from_camel_case(string $s): string { + $buf = ''; + $len = strlen($s); + for ($i = 0; $i < $len; $i++) { + if (!ctype_upper($s[$i])) { + $buf .= $s[$i]; + } else { + $buf .= '_'.strtolower($s[$i]); + } + } + return $buf; +} + +function to_camel_case(string $input, string $separator = '_'): string { + return lcfirst(str_replace($separator, '', ucwords($input, $separator))); +} + +function str_replace_once(string $needle, string $replace, string $haystack) { + $pos = strpos($haystack, $needle); + if ($pos !== false) + $haystack = substr_replace($haystack, $replace, $pos, strlen($needle)); + return $haystack; +} + +function strgen(int $len): string { + $buf = ''; + for ($i = 0; $i < $len; $i++) { + $j = mt_rand(0, 61); + if ($j >= 36) { + $j += 13; + } else if ($j >= 10) { + $j += 7; + } + $buf .= chr(48 + $j); + } + return $buf; +} + +function sanitize_filename(string $name): string { + $name = mb_strtolower($name); + $name = transliterate($name); + $name = preg_replace('/[^\w\d\-_\s.]/', '', $name); + $name = preg_replace('/\s+/', '_', $name); + return $name; +} + +function glob_escape(string $pattern): string { + if (strpos($pattern, '[') !== false || strpos($pattern, ']') !== false) { + $placeholder = uniqid(); + $replaces = array( $placeholder.'[', $placeholder.']', ); + $pattern = str_replace( array('[', ']', ), $replaces, $pattern); + $pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern); + } + return $pattern; +} + +/** + * Does not support flag GLOB_BRACE + * + * @param string $pattern + * @param int $flags + * @return array + */ +function glob_recursive(string $pattern, int $flags = 0): array { + $files = glob(glob_escape($pattern), $flags); + foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) { + $files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags)); + } + return $files; +} + +function setperm(string $file): void { + global $config; + + // chgrp + $gid = filegroup($file); + if ($gid != $config['group']) { + if (!chgrp($file, $config['group'])) { + logError(__FUNCTION__.": chgrp() failed on $file"); + } + } + + // chmod + $perms = fileperms($file); + $need_perms = is_dir($file) ? $config['dirs_mode'] : $config['files_mode']; + if (($perms & $need_perms) !== $need_perms) { + if (!chmod($file, $need_perms)) { + logError(__FUNCTION__.": chmod() failed on $file"); + } + } +} + +function salt_password(string $pwd): string { + global $config; + return hash('sha256', "{$pwd}|{$config['password_salt']}"); +} + +function exectime(?string $format = null) { + $time = round(microtime(true) - START_TIME, 4); + if (!is_null($format)) + $time = sprintf($format, $time); + return $time; +} + +function fullURL(string $url): string { + global $config; + return 'https://'.$config['domain'].$url; +} + +function getDb(): SQLiteConnection|MySQLConnection|null { + global $config; + static $link = null; + if (!is_null($link)) + return $link; + + switch ($config['db']['type']) { + case 'mysql': + $link = new MySQLConnection( + $config['db']['host'], + $config['db']['user'], + $config['db']['password'], + $config['db']['database']); + if (!$link->connect()) { + if (PHP_SAPI != 'cli') { + header('HTTP/1.1 503 Service Temporarily Unavailable'); + header('Status: 503 Service Temporarily Unavailable'); + header('Retry-After: 300'); + die('database connection failed'); + } else { + fwrite(STDERR, 'database connection failed'); + exit(1); + } + } + break; + + case 'sqlite': + $link = new SQLiteConnection($config['db']['path']); + break; + + default: + logError('invalid database type'); + break; + } + + return $link; +} diff --git a/gimp/contact.xcf b/gimp/contact.xcf Binary files differnew file mode 100644 index 0000000..b60e9d6 --- /dev/null +++ b/gimp/contact.xcf diff --git a/gimp/favicon.xcf b/gimp/favicon.xcf Binary files differnew file mode 100644 index 0000000..e8ae821 --- /dev/null +++ b/gimp/favicon.xcf diff --git a/handler/Auto.php b/handler/Auto.php new file mode 100644 index 0000000..c0d4c13 --- /dev/null +++ b/handler/Auto.php @@ -0,0 +1,106 @@ +<?php + +namespace handler; + +use admin; +use NotFoundException; +use pages; +use Post; +use posts; +use RedirectResponse; +use RequestHandler; +use Response; +use Tag; + +class Auto extends RequestHandler { + + public function get(): Response { + list($name) = $this->input('name'); + if ($name == 'coreboot-mba51-flashing') + return new RedirectResponse('/coreboot-mba52-flashing/'); + + if (is_numeric($name)) { + $post = posts::get((int)$name); + } else { + $post = posts::getPostByName($name); + } + if ($post) + return $this->getPost($post); + + $tag = posts::getTag($name); + if ($tag) + return $this->getTag($tag); + + $page = pages::getPageByName($name); + if ($page) + return $this->getPage($page); + + if (admin::isAdmin()) { + $this->skin->title = $name; + return $this->skin->renderPage('admin/pageNew', + short_name: $name); + } + + throw new NotFoundException(); + } + + public function getPost(Post $post): Response { + global $config; + + if (!$post->visible && !admin::isAdmin()) + throw new NotFoundException(); + + $tags = $post->getTags(); + + $s = $this->skin; + $s->meta[] = ['property' => 'og:title', 'content' => $post->title]; + $s->meta[] = ['property' => 'og:url', 'content' => fullURL($post->getUrl())]; + if (($img = $post->getFirstImage()) !== null) + $s->meta[] = ['property' => 'og:image', 'content' => $img->getDirectUrl()]; + $s->meta[] = [ + 'name' => 'description', + 'property' => 'og:description', + 'content' => $post->getDescriptionPreview(155) + ]; + + $s->title = $post->title; + + return $s->renderPage('main/post', + title: $post->title, + id: $post->id, + unsafe_html: $post->getHtml($this->isRetina()), + date: $post->getFullDate(), + tags: $tags, + visible: $post->visible, + url: $post->getUrl(), + email: $config['admin_email'], + urlencoded_reply_subject: 'Re: '.$post->title); + } + + public function getTag(Tag $tag): Response { + $tag = posts::getTag($tag); + if (!admin::isAdmin() && !$tag->visiblePostsCount) + throw new NotFoundException(); + + $count = posts::getPostsCountByTagId($tag->id, admin::isAdmin()); + $posts = $count ? posts::getPostsByTagId($tag->id, admin::isAdmin()) : []; + + $this->skin->title = '#'.$tag->tag; + return $this->skin->renderPage('main/tag', + count: $count, + posts: $posts, + tag: $tag->tag); + } + + public function getPage(\Page $page): Response { + if (!admin::isAdmin() && !$page->visible) + throw new NotFoundException(); + + $this->skin->title = $page ? $page->title : '???'; + return $this->skin->renderPage('main/page', + unsafe_html: $page->getHtml($this->isRetina()), + page_url: $page->getUrl(), + short_name: $page->shortName); + } + +}
\ No newline at end of file diff --git a/handler/Contacts.php b/handler/Contacts.php new file mode 100644 index 0000000..c60479d --- /dev/null +++ b/handler/Contacts.php @@ -0,0 +1,16 @@ +<?php + +namespace handler; + +use Response; + +class Contacts extends \RequestHandler { + + public function get(): Response { + global $config; + $this->skin->title = $this->lang['contacts']; + return $this->skin->renderPage('main/contacts', + email: $config['admin_email']); + } + +}
\ No newline at end of file diff --git a/handler/Index.php b/handler/Index.php new file mode 100644 index 0000000..c852511 --- /dev/null +++ b/handler/Index.php @@ -0,0 +1,20 @@ +<?php + +namespace handler; + +use admin; +use posts; + +class Index extends \RequestHandler { + + public function get(): \Response { + $posts = posts::getPosts(include_hidden: admin::isAdmin()); + $tags = posts::getAllTags(include_hidden: admin::isAdmin()); + + $this->skin->title = "ch1p's Blog"; + $this->skin->setOptions(['dynlogo_enabled' => false]); + return $this->skin->renderPage('main/index', + posts: $posts, + tags: $tags); + } +}
\ No newline at end of file diff --git a/handler/PostId.php b/handler/PostId.php new file mode 100644 index 0000000..ec9f750 --- /dev/null +++ b/handler/PostId.php @@ -0,0 +1,20 @@ +<?php + +namespace handler; + +class PostId extends \RequestHandler { + + public function get(): \Response { + list($post_id) = $this->input('i:id'); + + $post = posts_getPost($post_id); + if (!$post || (!$post->visible && !\admin::isAdmin())) + throw new \NotFoundException(); + + if ($post->shortName != '') + return new \RedirectResponse($post->getUrl()); + + throw new \NotFoundException(); + } + +}
\ No newline at end of file diff --git a/handler/ProjectsHtml.php b/handler/ProjectsHtml.php new file mode 100644 index 0000000..beada44 --- /dev/null +++ b/handler/ProjectsHtml.php @@ -0,0 +1,11 @@ +<?php + +namespace handler\main; + +class ProjectsHtml extends \RequestHandler { + + public function get(): \Response { + return new \RedirectResponse('/projects/'); + } + +}
\ No newline at end of file diff --git a/handler/RSS.php b/handler/RSS.php new file mode 100644 index 0000000..08a2136 --- /dev/null +++ b/handler/RSS.php @@ -0,0 +1,32 @@ +<?php + +namespace handler; +use posts; +use Response; +use SkinContext; + +class RSS extends \RequestHandler { + + public function get(): Response { + global $config; + + $items = array_map(fn(\Post $post) => [ + 'title' => $post->title, + 'link' => $post->getUrl(), + 'pub_date' => date(DATE_RSS, $post->ts), + 'description' => $post->getDescriptionPreview(500), + ], posts::getPosts(0, 20)); + + $ctx = new SkinContext('\\skin\\rss'); + $body = $ctx->atom( + title: ($this->lang)('site_title'), + link: 'https://'.$config['domain'], + rss_link: 'https://'.$config['domain'].'/feed.rss', + items: $items); + + $response = new Response(200, $body); + $response->addHeader('Content-Type: application/rss+xml; charset=utf-8'); + return $response; + } + +}
\ No newline at end of file diff --git a/handler/admin/AdminRequestHandler.php b/handler/admin/AdminRequestHandler.php new file mode 100644 index 0000000..04b7cde --- /dev/null +++ b/handler/admin/AdminRequestHandler.php @@ -0,0 +1,20 @@ +<?php + +namespace handler\admin; + +use admin; +use Response; + +class AdminRequestHandler extends \RequestHandler { + + public function beforeDispatch(): ?Response { + $this->skin->static[] = '/css/admin.css'; + $this->skin->static[] = '/js/admin.js'; + + if (!($this instanceof Login) && !admin::isAdmin()) + throw new \ForbiddenException('looks like you are not admin'); + + return null; + } + +}
\ No newline at end of file diff --git a/handler/admin/AutoAddOrEdit.php b/handler/admin/AutoAddOrEdit.php new file mode 100644 index 0000000..027c827 --- /dev/null +++ b/handler/admin/AutoAddOrEdit.php @@ -0,0 +1,97 @@ +<?php + +namespace handler\admin; + +use Page; +use Post; +use Response; + +abstract class AutoAddOrEdit extends AdminRequestHandler { + + public function beforeDispatch(): ?Response { + $this->skin->setOptions([ + 'full_width' => true, + 'no_footer' => true + ]); + return parent::beforeDispatch(); + } + + protected function _get_postAdd( + string $title = '', + string $text = '', + ?array $tags = null, + string $short_name = '', + ?string $error_code = null + ): Response { + $this->skin->addLangKeys($this->lang->search('/^(err_)?blog_/')); + $this->skin->title = $this->lang['blog_write']; + return $this->skin->renderPage('admin/postForm', + title: $title, + text: $text, + tags: $tags ? implode(', ', $tags) : '', + short_name: $short_name, + error_code: $error_code); + } + + protected function _get_postEdit( + Post $post, + string $title = '', + string $text = '', + ?array $tags = null, + bool $visible = false, + string $short_name = '', + ?string $error_code = null, + bool $saved = false, + ): Response { + $this->skin->addLangKeys($this->lang->search('/^(err_)?blog_/')); + $this->skin->title = ($this->lang)('blog_post_edit_title', $post->title); + return $this->skin->renderPage('admin/postForm', + is_edit: true, + post_id: $post->id, + post_url: $post->getUrl(), + title: $title, + text: $text, + tags: $tags ? implode(', ', $tags) : '', + visible: $visible, + saved: $saved, + short_name: $short_name, + error_code: $error_code + ); + } + + protected function _get_pageAdd( + string $name, + string $title = '', + string $text = '', + ?string $error_code = null + ): Response { + $this->skin->addLangKeys($this->lang->search('/^(err_)?pages_/')); + $this->skin->title = ($this->lang)('pages_create_title', $name); + return $this->skin->renderPage('admin/pageForm', + short_name: $name, + title: $title, + text: $text, + error_code: $error_code); + } + + protected function _get_pageEdit( + Page $page, + string $title = '', + string $text = '', + bool $saved = false, + bool $visible = false, + ?string $error_code = null + ): Response { + $this->skin->addLangKeys($this->lang->search('/^(err_)?pages_/')); + $this->skin->title = ($this->lang)('pages_page_edit_title', $page->shortName.'.html'); + return $this->skin->renderPage('admin/pageForm', + is_edit: true, + short_name: $page->shortName, + title: $title, + text: $text, + visible: $visible, + saved: $saved, + error_code: $error_code); + } + +}
\ No newline at end of file diff --git a/handler/admin/AutoDelete.php b/handler/admin/AutoDelete.php new file mode 100644 index 0000000..80c8eef --- /dev/null +++ b/handler/admin/AutoDelete.php @@ -0,0 +1,34 @@ +<?php + +namespace handler\admin; + +use csrf; +use NotFoundException; +use pages; +use posts; +use RedirectResponse; +use Response; + +class AutoDelete extends AdminRequestHandler { + + public function get(): Response { + list($name) = $this->input('short_name'); + + $post = posts::getPostByName($name); + if ($post) { + csrf::check('delpost'.$post->id); + posts::delete($post); + return new RedirectResponse('/'); + } + + $page = pages::getPageByName($name); + if ($page) { + csrf::check('delpage'.$page->shortName); + pages::delete($page); + return new RedirectResponse('/'); + } + + throw new NotFoundException(); + } + +}
\ No newline at end of file diff --git a/handler/admin/AutoEdit.php b/handler/admin/AutoEdit.php new file mode 100644 index 0000000..9d70c5b --- /dev/null +++ b/handler/admin/AutoEdit.php @@ -0,0 +1,127 @@ +<?php + +namespace handler\admin; + +use csrf; +use pages; +use posts; +use Response; + +class AutoEdit extends AutoAddOrEdit { + + public function get(): Response { + list($short_name, $saved) = $this->input('short_name, b:saved'); + + $post = posts::getPostByName($short_name); + if ($post) { + $tags = $post->getTags(); + return $this->_get_postEdit($post, + tags: $post->getTags(), + saved: $saved, + title: $post->title, + text: $post->md, + visible: $post->visible, + short_name: $post->shortName, + ); + } + + $page = pages::getPageByName($short_name); + if ($page) { + return $this->_get_pageEdit($page, + title: $page->title, + text: $page->md, + visible: $page->visible, + saved: $saved, + ); + } + + throw new \NotFoundException(); + } + + public function post(): Response { + list($short_name) = $this->input('short_name'); + + $post = posts::getPostByName($short_name); + if ($post) { + csrf::check('editpost'.$post->id); + + list($text, $title, $tags, $visible, $short_name) + = $this->input('text, title, tags, b:visible, new_short_name'); + + $tags = posts::splitStringToTags($tags); + $error_code = null; + + if (!$title) { + $error_code = 'no_title'; + } else if (!$text) { + $error_code = 'no_text'; + } else if (empty($tags)) { + $error_code = 'no_tags'; + } else if (empty($short_name)) { + $error_code = 'no_short_name'; + } + + if ($error_code) + $this->_get_postEdit($post, + text: $text, + title: $title, + tags: $tags, + visible: $visible, + short_name: $short_name, + error_code: $error_code + ); + + $post->edit([ + 'title' => $title, + 'md' => $text, + 'visible' => (int)$visible, + 'short_name' => $short_name + ]); + $tag_ids = posts::getTagIds($tags); + $post->setTagIds($tag_ids); + + return new \RedirectResponse($post->getUrl().'edit/?saved=1'); + } + + $page = pages::getPageByName($short_name); + if ($page) { + csrf::check('editpage'.$page->shortName); + + list($text, $title, $visible, $short_name) + = $this->input('text, title, b:visible, new_short_name'); + + $text = trim($text); + $title = trim($title); + $error_code = null; + + if (!$title) { + $error_code = 'no_title'; + } else if (!$text) { + $error_code = 'no_text'; + } else if (!$short_name) { + $error_code = 'no_short_name'; + } + + if ($error_code) { + return $this->_get_pageEdit($page, + title: $title, + text: $text, + visible: $visible, + error_code: $error_code + ); + } + + $page->edit([ + 'title' => $title, + 'md' => $text, + 'visible' => (int)$visible, + 'short_name' => $short_name, + ]); + + return new \RedirectResponse($page->getUrl().'edit/?saved=1'); + } + + throw new \NotFoundException(); + } + +}
\ No newline at end of file diff --git a/handler/admin/Index.php b/handler/admin/Index.php new file mode 100644 index 0000000..e829913 --- /dev/null +++ b/handler/admin/Index.php @@ -0,0 +1,13 @@ +<?php + +namespace handler\admin; + +use Response; + +class Index extends AdminRequestHandler { + + public function get(): Response { + return $this->skin->renderPage('admin/index'); + } + +}
\ No newline at end of file diff --git a/handler/admin/Login.php b/handler/admin/Login.php new file mode 100644 index 0000000..cade137 --- /dev/null +++ b/handler/admin/Login.php @@ -0,0 +1,31 @@ +<?php + +namespace handler\admin; + +use admin; +use csrf; +use RedirectResponse; +use Response; +use UnauthorizedException; + +class Login extends AdminRequestHandler { + + public function get(): Response { + if (admin::isAdmin()) + return new RedirectResponse('/admin/'); + return $this->skin->renderPage('admin/login'); + } + + public function post(): Response { + csrf::check('adminlogin'); + $password = $_POST['password'] ?? ''; + $valid = admin::checkPassword($password); + if ($valid) { + admin::logAuth(); + admin::setCookie(); + return new RedirectResponse('/admin/'); + } + throw new UnauthorizedException('nice try'); + } + +}
\ No newline at end of file diff --git a/handler/admin/Logout.php b/handler/admin/Logout.php new file mode 100644 index 0000000..bb11e43 --- /dev/null +++ b/handler/admin/Logout.php @@ -0,0 +1,17 @@ +<?php + +namespace handler\admin; + +use admin; +use csrf; +use Response; + +class Logout extends AdminRequestHandler { + + public function get(): Response { + csrf::check('logout'); + admin::unsetCookie(); + return new \RedirectResponse('/admin/login/'); + } + +}
\ No newline at end of file diff --git a/handler/admin/MarkdownPreview.php b/handler/admin/MarkdownPreview.php new file mode 100644 index 0000000..e513709 --- /dev/null +++ b/handler/admin/MarkdownPreview.php @@ -0,0 +1,22 @@ +<?php + +namespace handler\admin; + +use Response; + +class MarkdownPreview extends AdminRequestHandler { + + public function post(): Response { + list($md, $title, $use_image_previews) = $this->input('md, title, b:use_image_previews'); + + $html = \markup::markdownToHtml($md, $use_image_previews); + + $ctx = new \SkinContext('\\skin\\admin'); + $html = $ctx->markdownPreview( + unsafe_html: $html, + title: $title + ); + return new \AjaxOkResponse(['html' => $html]); + } + +}
\ No newline at end of file diff --git a/handler/admin/PageAdd.php b/handler/admin/PageAdd.php new file mode 100644 index 0000000..8754f0f --- /dev/null +++ b/handler/admin/PageAdd.php @@ -0,0 +1,66 @@ +<?php + +namespace handler\admin; + +use csrf; +use NotFoundException; +use pages; +use RedirectResponse; +use Response; + +class PageAdd extends AutoAddOrEdit { + + public function get(): Response { + list($name) = $this->input('short_name'); + $page = pages::getPageByName($name); + if ($page) + throw new NotFoundException(); + + return $this->_get_pageAdd($name); + } + + public function post(): Response { + csrf::check('addpage'); + + list($name) = $this->input('short_name'); + $page = pages::getPageByName($name); + if ($page) + throw new NotFoundException(); + + $text = trim($_POST['text'] ?? ''); + $title = trim($_POST['title'] ?? ''); + $error_code = null; + + if (!$title) { + $error_code = 'no_title'; + } else if (!$text) { + $error_code = 'no_text'; + } + + if ($error_code) { + return $this->_get_pageAdd( + name: $name, + text: $text, + title: $title, + error_code: $error_code + ); + } + + if (!pages::add([ + 'short_name' => $name, + 'title' => $title, + 'md' => $text + ])) { + return $this->_get_pageAdd( + name: $name, + text: $text, + title: $title, + error_code: 'db_err' + ); + } + + $page = pages::getPageByName($name); + return new RedirectResponse($page->getUrl()); + } + +}
\ No newline at end of file diff --git a/handler/admin/PostAdd.php b/handler/admin/PostAdd.php new file mode 100644 index 0000000..c21a239 --- /dev/null +++ b/handler/admin/PostAdd.php @@ -0,0 +1,68 @@ +<?php + +namespace handler\admin; + +use csrf; +use posts; +use RedirectResponse; +use Response; + +class PostAdd extends AutoAddOrEdit { + + public function get(): Response { + return $this->_get_postAdd(); + } + + public function post(): Response { + csrf::check('addpost'); + + list($text, $title, $tags, $visible, $short_name) + = $this->input('text, title, tags, b:visible, short_name'); + $tags = posts::splitStringToTags($tags); + + $error_code = null; + + if (!$title) { + $error_code = 'no_title'; + } else if (!$text) { + $error_code = 'no_text'; + } else if (empty($tags)) { + $error_code = 'no_tags'; + } else if (empty($short_name)) { + $error_code = 'no_short_name'; + } + + if ($error_code) + return $this->_get_postAdd( + text: $text, + title: $title, + tags: $tags, + short_name: $short_name, + error_code: $error_code + ); + + $id = posts::add([ + 'title' => $title, + 'md' => $text, + 'visible' => (int)$visible, + 'short_name' => $short_name, + ]); + + if (!$id) + $this->_get_postAdd( + text: $text, + title: $title, + tags: $tags, + short_name: $short_name, + error_code: 'db_err' + ); + + // set tags + $post = posts::get($id); + $tag_ids = posts::getTagIds($tags); + $post->setTagIds($tag_ids); + + return new RedirectResponse($post->getUrl()); + } + +}
\ No newline at end of file diff --git a/handler/admin/UploadDelete.php b/handler/admin/UploadDelete.php new file mode 100644 index 0000000..26b58b7 --- /dev/null +++ b/handler/admin/UploadDelete.php @@ -0,0 +1,25 @@ +<?php + +namespace handler\admin; + +use csrf; +use RedirectResponse; +use Response; + +class UploadDelete extends AdminRequestHandler { + + public function get(): Response { + list($id) = $this->input('i:id'); + + $upload = \uploads::get($id); + if (!$upload) + return new RedirectResponse('/uploads/?error='.urlencode('upload not found')); + + csrf::check('delupl'.$id); + + \uploads::delete($id); + + return new RedirectResponse('/uploads/'); + } + +}
\ No newline at end of file diff --git a/handler/admin/UploadEditNote.php b/handler/admin/UploadEditNote.php new file mode 100644 index 0000000..e7cdbb2 --- /dev/null +++ b/handler/admin/UploadEditNote.php @@ -0,0 +1,25 @@ +<?php + +namespace handler\admin; + +use csrf; +use Response; + +class UploadEditNote extends AdminRequestHandler { + + public function post(): Response { + list($id) = $this->input('i:id'); + + $upload = \uploads::get($id); + if (!$upload) + return new \RedirectResponse('/uploads/?error='.urlencode('upload not found')); + + csrf::check('editupl'.$id); + + $note = $_POST['note'] ?? ''; + $upload->setNote($note); + + return new \RedirectResponse('/uploads/'); + } + +}
\ No newline at end of file diff --git a/handler/admin/Uploads.php b/handler/admin/Uploads.php new file mode 100644 index 0000000..0cbb2f6 --- /dev/null +++ b/handler/admin/Uploads.php @@ -0,0 +1,73 @@ +<?php + +namespace handler\admin; + +use csrf; +use RedirectResponse; +use Response; + +// So it's 2022 outside, and it's PHP 8.1 already, which is actually so cool comparing to 5.x and even 7.4, but... +// ...class names are still case-insensitive?!! And I can't import \uploads because it's the same as Uploads?!! +// +// PHP, what the fuck is wrong with you?! + +class Uploads extends AdminRequestHandler { + + public function get(): Response { + list($error) = $this->input('error'); + $uploads = \uploads::getAll(); + + $this->skin->title = ($this->lang)('blog_upload'); + return $this->skin->renderPage('admin/uploads', + error: $error, + uploads: $uploads); + } + + public function post(): Response { + csrf::check('addupl'); + + list($custom_name, $note) = $this->input('name, note'); + + if (!isset($_FILES['files'])) + return new RedirectResponse('/uploads/?error='.urlencode('no file')); + + $files = []; + for ($i = 0; $i < count($_FILES['files']['name']); $i++) { + $files[] = [ + 'name' => $_FILES['files']['name'][$i], + 'type' => $_FILES['files']['type'][$i], + 'tmp_name' => $_FILES['files']['tmp_name'][$i], + 'error' => $_FILES['files']['error'][$i], + 'size' => $_FILES['files']['size'][$i], + ]; + } + + if (count($files) > 1) { + $note = ''; + $custom_name = ''; + } + + foreach ($files as $f) { + if ($f['error']) + return new RedirectResponse('/uploads/?error='.urlencode('error code '.$f['error'])); + + if (!$f['size']) + return new RedirectResponse('/uploads/?error='.urlencode('received empty file')); + + $ext = extension($f['name']); + if (!\uploads::isExtensionAllowed($ext)) + return new RedirectResponse('/uploads/?error='.urlencode('extension not allowed')); + + $upload_id = \uploads::add( + $f['tmp_name'], + $custom_name ?: $f['name'], + $note); + + if (!$upload_id) + return new RedirectResponse('/uploads/?error='.urlencode('failed to create upload')); + } + + return new RedirectResponse('/uploads/'); + } + +}
\ No newline at end of file diff --git a/htdocs/ahrefs_73b56e4c8d3bca4f4712e71f638a499c464e3faf55dd02ed02dbb5649850b8f3 b/htdocs/ahrefs_73b56e4c8d3bca4f4712e71f638a499c464e3faf55dd02ed02dbb5649850b8f3 new file mode 100644 index 0000000..cdf5a84 --- /dev/null +++ b/htdocs/ahrefs_73b56e4c8d3bca4f4712e71f638a499c464e3faf55dd02ed02dbb5649850b8f3 @@ -0,0 +1 @@ +ahrefs-site-verification_73b56e4c8d3bca4f4712e71f638a499c464e3faf55dd02ed02dbb5649850b8f3
\ No newline at end of file diff --git a/htdocs/favicon.ico b/htdocs/favicon.ico Binary files differnew file mode 100644 index 0000000..d5ff579 --- /dev/null +++ b/htdocs/favicon.ico diff --git a/htdocs/img/attachment.svg b/htdocs/img/attachment.svg new file mode 100644 index 0000000..9026687 --- /dev/null +++ b/htdocs/img/attachment.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="680" viewBox="-25 0 510 510.257" width="680"><path d="M32.39 314.484l37.74-37.738 169.815-169.808c31.297-31.063 81.817-30.97 112.997.21 31.18 31.18 31.273 81.7.21 112.997L183.345 390.004c-6.945 6.95-18.211 6.95-25.16 0-6.946-6.95-6.946-18.211 0-25.16l169.808-169.86c17.368-17.367 17.368-45.52 0-62.886-17.367-17.368-45.52-17.368-62.886 0L95.246 301.906l-37.738 37.735c-31.266 31.277-31.254 81.976.02 113.238 31.277 31.262 81.972 31.254 113.238-.023l31.441-31.454L378.31 245.301l12.582-12.578c43.976-45.352 43.418-117.602-1.25-162.274-44.672-44.668-116.922-45.226-162.274-1.25L38.687 257.883a17.797 17.797 0 0 1-29.765-7.977 17.796 17.796 0 0 1 4.606-17.183l188.68-188.68c59.09-58.82 154.64-58.711 213.593.246 58.957 58.953 59.066 154.504.246 213.594l-188.68 188.68-31.488 31.453c-45.41 43.617-117.375 42.89-161.89-1.641-44.52-44.527-45.227-116.492-1.598-161.89zm0 0" fill="#888"/></svg>
\ No newline at end of file diff --git a/htdocs/img/contact.gif b/htdocs/img/contact.gif Binary files differnew file mode 100644 index 0000000..f4695c1 --- /dev/null +++ b/htdocs/img/contact.gif diff --git a/htdocs/img/contact@2x.gif b/htdocs/img/contact@2x.gif Binary files differnew file mode 100644 index 0000000..ab79cd3 --- /dev/null +++ b/htdocs/img/contact@2x.gif diff --git a/htdocs/img/enter.svg b/htdocs/img/enter.svg new file mode 100644 index 0000000..6fe49ed --- /dev/null +++ b/htdocs/img/enter.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="15" height="9.474"><path d="M0 4.737l4.737 4.737 1.105-1.106L3 5.526h12V.79h-1.579v3.158H3l2.842-2.842L4.737 0z"/></svg> diff --git a/htdocs/index.php b/htdocs/index.php new file mode 100644 index 0000000..a1199da --- /dev/null +++ b/htdocs/index.php @@ -0,0 +1,28 @@ +<?php + +require_once __DIR__.'/../init.php'; + +$r = (new Router()) + // route handler input + // ----- ------- ----- + ->add('/', 'index') + ->add('contacts/', 'contacts') + ->add('projects.html', 'projects') + ->add('blog/(\d+)/', 'post_id id=$(1)') + ->add('([a-z0-9-]+)/', 'auto name=$(1)') + + ->add('feed.rss', 'RSS') + + ->add('admin/', 'admin/index') + ->add('admin/{login,logout,log}/', 'admin/${1}') + + ->add('([a-z0-9-]+)/{delete,edit}/', 'admin/auto_${1} short_name=$(1)') + ->add('([a-z0-9-]+)/create/', 'admin/page_add short_name=$(1)') + ->add('write/', 'admin/post_add') + ->add('admin/markdown-preview.ajax', 'admin/markdown_preview') + + ->add('uploads/', 'admin/uploads') + ->add('uploads/{edit_note,delete}/(\d+)/','admin/upload_${1} id=$(1)') +; + +(new RequestDispatcher($r))->dispatch();
\ No newline at end of file diff --git a/htdocs/js/admin.js b/htdocs/js/admin.js new file mode 100644 index 0000000..a717d5c --- /dev/null +++ b/htdocs/js/admin.js @@ -0,0 +1,193 @@ +var LS = window.localStorage; + +var Draft = { + get: function() { + if (!LS) return null; + + var title = LS.getItem('draft_title') || null; + var text = LS.getItem('draft_text') || null; + + return { + title: title, + text: text + }; + }, + + setTitle: function(text) { + if (!LS) return null; + LS.setItem('draft_title', text); + }, + + setText: function(text) { + if (!LS) return null; + LS.setItem('draft_text', text); + }, + + reset: function() { + if (!LS) return; + LS.removeItem('draft_title'); + LS.removeItem('draft_text'); + } +}; + +var AdminWriteForm = { + form: null, + previewTimeout: null, + previewRequest: null, + + init: function(opts) { + opts = opts || {}; + + this.opts = opts; + this.form = document.forms[opts.pages ? 'pageForm' : 'postForm']; + this.isFixed = false; + + addEvent(this.form, 'submit', this.onSubmit); + if (!opts.pages) + addEvent(this.form.title, 'input', this.onInput); + + addEvent(this.form.text, 'input', this.onInput); + addEvent(ge('toggle_wrap'), 'click', this.onToggleWrapClick); + + if (this.form.text.value !== '') + this.showPreview(); + + // TODO make it more clever and context-aware + /*var draft = Draft.get(); + if (draft.title) + this.form.title.value = draft.title; + if (draft.text) + this.form.text.value = draft.text;*/ + + addEvent(window, 'scroll', this.onScroll); + addEvent(window, 'resize', this.onResize); + }, + + showPreview: function() { + if (this.previewRequest !== null) { + this.previewRequest.abort(); + } + this.previewRequest = ajax.post('/admin/markdown-preview.ajax', { + title: this.form.elements.title.value, + md: this.form.elements.text.value, + use_image_previews: this.opts.pages ? 1 : 0 + }, function(err, response) { + if (err) + return console.error(err); + ge('preview_html').innerHTML = response.html; + }); + }, + + onSubmit: function(event) { + try { + var fields = ['title', 'text']; + if (!this.opts.pages) + fields.push('tags'); + if (this.opts.edit) { + fields.push('new_short_name'); + } else { + fields.push('short_name'); + } + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + if (event.target.elements[field].value.trim() === '') + throw 'no_'+field + } + + // Draft.reset(); + } catch (e) { + var error = typeof e == 'string' ? lang((this.opts.pages ? 'err_pages_' : 'err_blog_')+e) : e.message; + alert(error); + console.error(e); + return cancelEvent(event); + } + }, + + onToggleWrapClick: function(e) { + var textarea = this.form.elements.text; + if (!hasClass(textarea, 'nowrap')) { + addClass(textarea, 'nowrap'); + } else { + removeClass(textarea, 'nowrap'); + } + return cancelEvent(e); + }, + + onInput: function(e) { + if (this.previewTimeout !== null) { + clearTimeout(this.previewTimeout); + } + this.previewTimeout = setTimeout(function() { + this.previewTimeout = null; + this.showPreview(); + + // Draft[e.target.name === 'title' ? 'setTitle' : 'setText'](e.target.value); + }.bind(this), 300); + }, + + onScroll: function() { + var ANCHOR_TOP = 10; + + var y = window.pageYOffset; + var form = this.form; + var td = ge('form_first_cell'); + var ph = ge('form_placeholder'); + + var rect = td.getBoundingClientRect(); + + if (rect.top <= ANCHOR_TOP && !this.isFixed) { + ph.style.height = form.getBoundingClientRect().height+'px'; + + var w = (rect.width - (parseInt(getComputedStyle(td).paddingRight, 10) || 0)); + form.style.display = 'block'; + form.style.width = w+'px'; + form.style.position = 'fixed'; + form.style.top = ANCHOR_TOP+'px'; + + this.isFixed = true; + } else if (rect.top > ANCHOR_TOP && this.isFixed) { + form.style.display = ''; + form.style.width = ''; + form.style.position = ''; + form.style.position = ''; + ph.style.height = ''; + + this.isFixed = false; + } + }, + + onResize: function() { + if (this.isFixed) { + var form = this.form; + var td = ge('form_first_cell'); + var ph = ge('form_placeholder'); + + var rect = td.getBoundingClientRect(); + var pr = parseInt(getComputedStyle(td).paddingRight, 10) || 0; + + ph.style.height = form.getBoundingClientRect().height+'px'; + form.style.width = (rect.width - pr) + 'px'; + } + } +}; +bindEventHandlers(AdminWriteForm); + +var BlogUploadList = { + submitNoteEdit: function(action, note) { + if (note === null) + return; + + var form = document.createElement('form'); + form.setAttribute('method', 'post'); + form.setAttribute('action', action); + + var input = document.createElement('input'); + input.setAttribute('type', 'hidden'); + input.setAttribute('name', 'note'); + input.setAttribute('value', note); + + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + } +}; diff --git a/htdocs/js/common.js b/htdocs/js/common.js new file mode 100644 index 0000000..4e4199c --- /dev/null +++ b/htdocs/js/common.js @@ -0,0 +1,392 @@ +if (!String.prototype.startsWith) { + String.prototype.startsWith = function(search, pos) { + pos = !pos || pos < 0 ? 0 : +pos; + return this.substring(pos, pos + search.length) === search; + }; +} + +if (!String.prototype.endsWith) { + String.prototype.endsWith = function(search, this_len) { + if (this_len === undefined || this_len > this.length) { + this_len = this.length; + } + return this.substring(this_len - search.length, this_len) === search; + }; +} + +// +// AJAX +// +(function() { + + var defaultOpts = { + json: true + }; + + function createXMLHttpRequest() { + if (window.XMLHttpRequest) { + return new XMLHttpRequest(); + } + + var xhr; + try { + xhr = new ActiveXObject('Msxml2.XMLHTTP'); + } catch (e) { + try { + xhr = new ActiveXObject('Microsoft.XMLHTTP'); + } catch (e) {} + } + if (!xhr) { + console.error('Your browser doesn\'t support XMLHttpRequest.'); + } + return xhr; + } + + function request(method, url, data, optarg1, optarg2) { + data = data || null; + + var opts, callback; + if (optarg2 !== undefined) { + opts = optarg1; + callback = optarg2; + } else { + callback = optarg1; + } + + opts = opts || {}; + + if (typeof callback != 'function') { + throw new Error('callback must be a function'); + } + + if (!url) { + throw new Error('no url specified'); + } + + switch (method) { + case 'GET': + if (isObject(data)) { + for (var k in data) { + if (data.hasOwnProperty(k)) { + url += (url.indexOf('?') == -1 ? '?' : '&')+encodeURIComponent(k)+'='+encodeURIComponent(data[k]) + } + } + } + break; + + case 'POST': + if (isObject(data)) { + var sdata = []; + for (var k in data) { + if (data.hasOwnProperty(k)) { + sdata.push(encodeURIComponent(k)+'='+encodeURIComponent(data[k])); + } + } + data = sdata.join('&'); + } + break; + } + + opts = extend({}, defaultOpts, opts); + + var xhr = createXMLHttpRequest(); + xhr.open(method, url); + + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + if (method == 'POST') { + xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + } + + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + if ('status' in xhr && !/^2|1223/.test(xhr.status)) { + throw new Error('http code '+xhr.status) + } + if (opts.json) { + var resp = JSON.parse(xhr.responseText) + if (!isObject(resp)) { + throw new Error('ajax: object expected') + } + if (resp.error) { + throw new Error(resp.error) + } + callback(null, resp.response); + } else { + callback(null, xhr.responseText); + } + } + }; + + xhr.onerror = function(e) { + callback(e); + }; + + xhr.send(method == 'GET' ? null : data); + + return xhr; + } + + window.ajax = { + get: request.bind(request, 'GET'), + post: request.bind(request, 'POST') + } + +})(); + +function bindEventHandlers(obj) { + for (var k in obj) { + if (obj.hasOwnProperty(k) + && typeof obj[k] == 'function' + && k.length > 2 + && k.startsWith('on') + && k[2].charCodeAt(0) >= 65 + && k[2].charCodeAt(0) <= 90) { + obj[k] = obj[k].bind(obj) + } + } +} + +// +// DOM helpers +// +function ge(id) { + return document.getElementById(id) +} + +function hasClass(el, name) { + return el && el.nodeType === 1 && (" " + el.className + " ").replace(/[\t\r\n\f]/g, " ").indexOf(" " + name + " ") >= 0 +} + +function addClass(el, name) { + if (!el) { + return console.warn('addClass: el is', el) + } + if (!hasClass(el, name)) { + el.className = (el.className ? el.className + ' ' : '') + name + } +} + +function removeClass(el, name) { + if (!el) { + return console.warn('removeClass: el is', el) + } + if (isArray(name)) { + for (var i = 0; i < name.length; i++) { + removeClass(el, name[i]); + } + return; + } + el.className = ((el.className || '').replace((new RegExp('(\\s|^)' + name + '(\\s|$)')), ' ')).trim() +} + +function addEvent(el, type, f, useCapture) { + if (!el) { + return console.warn('addEvent: el is', el, stackTrace()) + } + + if (isArray(type)) { + for (var i = 0; i < type.length; i++) { + addEvent(el, type[i], f, useCapture); + } + return; + } + + if (el.addEventListener) { + el.addEventListener(type, f, useCapture || false); + return true; + } else if (el.attachEvent) { + return el.attachEvent('on' + type, f); + } + + return false; +} + +function removeEvent(el, type, f, useCapture) { + if (isArray(type)) { + for (var i = 0; i < type.length; i++) { + var t = type[i]; + removeEvent(el, type[i], f, useCapture); + } + return; + } + + if (el.removeEventListener) { + el.removeEventListener(type, f, useCapture || false); + } else if (el.detachEvent) { + return el.detachEvent('on' + type, f); + } + + return false; +} + +function cancelEvent(evt) { + if (!evt) { + return console.warn('cancelEvent: event is', evt) + } + + if (evt.preventDefault) evt.preventDefault(); + if (evt.stopPropagation) evt.stopPropagation(); + + evt.cancelBubble = true; + evt.returnValue = false; + + return false; +} + + +// +// Cookies +// +function setCookie(name, value, days) { + var expires = ""; + if (days) { + var date = new Date(); + date.setTime(date.getTime() + (days*24*60*60*1000)); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = name + "=" + (value || "") + expires + "; path=/"; +} + +function unsetCookie(name) { + document.cookie = name+'=; Max-Age=-99999999;'; +} + +function getCookie(name) { + var nameEQ = name + "="; + var ca = document.cookie.split(';'); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) === ' ') + c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) + return c.substring(nameEQ.length, c.length); + } + return null; +} + +// +// Misc +// +function isObject(o) { + return Object.prototype.toString.call(o) === '[object Object]'; +} + +function isArray(a) { + return Object.prototype.toString.call(a) === '[object Array]'; +} + +function extend(dst, src) { + if (!isObject(dst)) { + return console.error('extend: dst is not an object'); + } + if (!isObject(src)) { + return console.error('extend: src is not an object'); + } + for (var key in src) { + dst[key] = src[key]; + } + return dst; +} + +function stackTrace(split) { + if (split === undefined) { + split = true; + } + try { + o.lo.lo += 0; + } catch(e) { + if (e.stack) { + var stack = split ? e.stack.split('\n') : e.stack; + stack.shift(); + stack.shift(); + return stack.join('\n'); + } + } + return null; +} + +function escape(str) { + var pre = document.createElement('pre'); + var text = document.createTextNode(str); + pre.appendChild(text); + return pre.innerHTML; +} + +// +// + +function lang(key) { + return __lang[key] !== undefined ? __lang[key] : '{'+key+'}'; +} + +var DynamicLogo = { + dynLink: null, + afr: null, + afrUrl: null, + + init: function() { + this.dynLink = ge('head_dyn_link'); + this.cdText = ge('head_cd_text'); + + if (!this.dynLink) { + return console.warn('DynamicLogo.init: !this.dynLink'); + } + + var spans = this.dynLink.querySelectorAll('span.head-logo-path'); + for (var i = 0; i < spans.length; i++) { + var span = spans[i]; + addEvent(span, 'mouseover', this.onSpanOver); + addEvent(span, 'mouseout', this.onSpanOut); + } + }, + + setUrl: function(url) { + if (this.afr !== null) { + cancelAnimationFrame(this.afr); + } + this.afrUrl = url; + this.afr = requestAnimationFrame(this.onAnimationFrame); + }, + + onAnimationFrame: function() { + var url = this.afrUrl; + + // update link + this.dynLink.setAttribute('href', url); + + // update console text + if (this.afrUrl === '/') { + url = '~'; + } else { + url = '~'+url.replace(/\/$/, ''); + } + this.cdText.innerHTML = escape(url); + + this.afr = null; + }, + + onSpanOver: function() { + var span = event.target; + this.setUrl(span.getAttribute('data-url')); + cancelEvent(event); + }, + + onSpanOut: function() { + var span = event.target; + this.setUrl('/'); + cancelEvent(event); + } +}; +bindEventHandlers(DynamicLogo); + +window.__lang = {}; + +// set/remove retina cookie +(function() { + var isRetina = window.devicePixelRatio >= 1.5; + if (isRetina) { + setCookie('is_retina', 1, 365); + } else { + unsetCookie('is_retina'); + } +})(); diff --git a/htdocs/openpgp-pubkey.txt b/htdocs/openpgp-pubkey.txt new file mode 100644 index 0000000..5a96ec0 --- /dev/null +++ b/htdocs/openpgp-pubkey.txt @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFv6mhkBEADRyPzYlrvij31CVACiIk99y+i2xKl2xWNn2mGGv1PvlmC/VFxH +AzVig3woAmafA19ia69KGadfXAbI8le7BW/qj0bCbgG93S3XcIGbDamPYoQ26tE6 +DcNtwYBrIQ5I8DNSOnkhK72Rv+JsTchlzlrQ03wEcIpsIzzOmq9r9rE1bUKiaogT +UC7vhpieH3AdK8AdVnIAtxJKMnjeGC2xpd65hE4YFWyLo8CZ6yqUz8HFIFZ7QodZ +aWeEvcgxtCVtR+ZUVNsFnLsDaJdhILmJcwpezAyC63bZTCQiJEOmrcbbthR/6XaB +2QktGINceDv0cLTGfML1S0M/y5+xeO5CVRz/TGfazWFHOGtNoWq2Pslzaa+Ri7qz +KfqerGhBMr5W0t1JDZgurpmsBGPnJWbmpMZxPzK6JvxVSkEpjHquPTFHnInnVsBN +FczObCLCSnTcicC7PdfLd15yug1nj3s9ne+YDzVegKnauYsYBSvQAZoVgPOuEIeA +uVXMzIp9uuvsATUop1PyQilY0fn5TPNyDqKWCDlG7c1hcCbnxZDy2S2deAVbSq4h +i8/EZ8Uf5Ry3CXiiFoUR4o8hyT+f//MSxwXWBvxhRgBXg4DC37NXpDg4BvJ2sY62 +eRh16zGXoD3HfyvLTGaj7kMHLNKUaenqYN7VNOsFV9S9T3Q3b2NVlcfUmwARAQAB +tBxFdmdlbnkgWmlub3ZpZXYgPG1lQGNoMXAuaW8+iQJOBBMBCAA4FiEEYpXeAgGD +VL8eJyX+jUo3Qlb04m4FAlv6mhkCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AA +CgkQjUo3Qlb04m54Jw/8CEoxzvps+lvEVNELzuj/ILIKt963HCWXztWG7v6gXMrt +iEmeETGbS9Md1DdVLQei2Yofiqv47josfg9cKYjmwC4g1rJ23GroiOkciYZsdpgm +dLWEO+t7WpC72BQBfasVK8AisSfICCSq82h1N+tZqJ2X3wa81mEOClqbQAW60xN0 +dOEvkU00ZG6lrCDOBsdo7vu0sdwbXS33Q0U+bNenre3vMxrzdTajtPMiw7izXGJ3 +U4uo7FjLqsjGPWd1wY1zuBAGpGVlj62InvCvsurAKvpz4Tt7W8IevoINsKAQvcUa +fnwRVRLTi7PBUtLjVLc3vFQNKUPtUm3JWeqP/vQEz1txZbjb003iodr+Jo8oWiv0 +rn2Vmt6AesA8mDlqknZHDMzuTmRKF1pcP0U5BKntOl6N+hiKZyVka8zQpKGnoO57 +ilEj8qx39JfF8JjWJLBIQ7NBiV+OB7vNYWm+LYHrjp8BZt+ZVsgPUG9io+1vUDf2 +5GSO2wQIQ95GAEp7LzBUdJ2X1caDWgQygw+DT+HKk4oaQPXyuSS/dM9NwwO0EzVX +OX11G/eXCNwXi4dng1Zy4jRQgQhnDJKBTDRZQdHAC32PjBqE4Viut2VkLRHiKKik +Olq+cewczCoNXmvywsbz6E5vvmouiPs2mY8oBxL90/1wnTFS9m17lwucWb51WlS5 +Ag0EW/qaGQEQAKRlDK0Z9jClLSCxrR4iqP3I4u2N/bfdVrng3gwtuXCz1/kFJsHd +zmh8gDFvdaZ8tHlpK08CctodcbtFKdW8U21GYhBBZv4LAZqqYewpf0hX8nDsgtIh +qE4I8YugmGFblMLM96jbRc+WDIelk8e4+4+IJd/t3CeDnq/osCRJUGlNvj7B+jid +YGJuMOnxKFFHhjtXeRFKLnYs9ybqx8CNuNlBXvQuSgN4tcg1Uo0Up+GDQ26++1UI +qHH9oXewjus8qvCuqlaqfvE6i63gN2l9HnHVz6swkUBUuj1pjD5awy68mt4oQRvF +5c9CLp1wSuHNVjxbBiPwqZsv4h1VaWMZhbv4mK5p8MUVxhMQg5gWHqSlRHvuPLv4 +1NlMdV6uCO8aj+Y/s1LI7VyeXwt5lG5PAeX8KI8+J25hoT65Ge4sEUMqp6r72OIa +WQwKt5mt1pZzvFR8U+N6hoPMinN880nisazUaGgRLJbl9IbnTv4mj4v86KS4dcIh +zNUzdIWrdq7NI29Ckyy+WIAFFXN3jiuWp7L+AWc32aG+pQBtwmW6ImIPx6CKqI2S +O6oIEQKY78n3iDdkh0RgXoIsXPg+n9qSHqGMA+cOUtgcxTDudWcgRXsfR2uQanE5 +dO8z7yGKR3zstuGHakORgxHMPF+lmbXewhyG4TF1Ah4xoB4N5CoOmfvpABEBAAGJ +AjYEGAEIACAWIQRild4CAYNUvx4nJf6NSjdCVvTibgUCW/qaGQIbDAAKCRCNSjdC +VvTibuiOD/0btYX+ifdDu0gQzWhd+KUOjXCcsfqpiRZ1t0lHiLjaym3mfYEwP0dn +LqW6QLiovU3VvH3ffJSXCiIdLKmro4lqXnnvnN/q5YiiUXYJx1yA0SxSsAm4XS4f +vIHza6z+73odZPui/Mh3cZQ1bi6KRH8ndRnkuY/lcK6M1Ypl9azhUjaSE5nnU5mF +np/pVLdBEdb8YlO5dE2jUrr2cjjW5afkWTfl0HewAbxWSPlHSGRpizMVhjo/G2wT +4pPrWx1pIM79UbhV75U67bXj5Vh8U3bNHreJPKZsGd+3x4Il1U0sG5g6nx60BwJa +p5G60Lnqx9bxQ/+R72gk2lolR/gJ9pwJSAvYOstfgneeABTiY2qkSwzb626PxH35 +YwfnpGyTbuJzylGeiJWQaV9B1vibvbD0gcdtO0sq7dtOYUInuHfUC7ujOdkPL4yJ +JCGXlI35Mjd614Kz/xk/IL7fQE/J4u/NNsaB/aS9Aa/4h0+aCuxyncd4+o22Kw78 +MSy7dkEFEi0oqgkL0/xxxbemqOgs3oLjFP3tGQ+bYUDI+JP5UBXP7JjURwIs+ILg +UAts1DGtLggcLR4BZLp1SsU17QW9BLjrki+FswY0egQG6nOX700WI0CUWl4wbmtX +uOn1Uocmrsy+7j4DjXRwf7dY1j0AhI0ijA8+mnv7bIs+gWqEs5rnAw== +=cgca +-----END PGP PUBLIC KEY BLOCK----- diff --git a/htdocs/sass.php b/htdocs/sass.php new file mode 100644 index 0000000..eb24962 --- /dev/null +++ b/htdocs/sass.php @@ -0,0 +1,48 @@ +<?php + +require __DIR__.'/../init.php'; +global $config; + +$name = $_REQUEST['name'] ?? ''; +if (!$config['is_dev'] || !$name || !file_exists($path = ROOT.'/htdocs/scss/'.$name.'.scss')) { + // logError(__FILE__.': access denied'); + http_response_code(403); + exit; +} + +// logInfo(__FILE__.': continuing, path='.$path); + +$cmd = 'sassc -t expanded '.escapeshellarg($path); +$descriptorspec = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr +]; + +$process = proc_open($cmd, $descriptorspec, $pipes, ROOT); +if (!is_resource($process)) { + http_response_code(500); + logError('could not open sassc process'); + exit; +} + +$stdout = stream_get_contents($pipes[1]); +fclose($pipes[1]); + +$stderr = stream_get_contents($pipes[2]); +fclose($pipes[2]); + +$code = proc_close($process); +if ($code) { + http_response_code(500); + logError('sassc('.$path.') returned '.$code); + logError($stderr); + exit; +} + +header('Content-Type: text/css'); +header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); +header("Cache-Control: post-check=0, pre-check=0", false); +header("Pragma: no-cache"); + +echo $stdout; diff --git a/htdocs/scss/admin.scss b/htdocs/scss/admin.scss new file mode 100644 index 0000000..06808d0 --- /dev/null +++ b/htdocs/scss/admin.scss @@ -0,0 +1,3 @@ +.admin-page { + line-height: 155%; +} diff --git a/htdocs/scss/blog.scss b/htdocs/scss/blog.scss new file mode 100644 index 0000000..7641683 --- /dev/null +++ b/htdocs/scss/blog.scss @@ -0,0 +1,383 @@ +@import 'vars'; + +.blog-write-link-wrap { + margin-bottom: $base-padding; +} +.blog-write-table { + table-layout: fixed; + border-collapse: collapse; + border: 0; + width: 100%; + + > tbody > tr > td { + text-align: left; + vertical-align: top; + } + > tbody > tr > td:first-child { + padding-right: 8px; + width: 45%; + } + > tbody > tr > td:last-child { + padding-left: 8px; + } +} + +.blog-write-form { + .form-field-input { + width: 100%; + } + textarea.form-field-input { + height: 400px; + font-family: $ffMono; + font-size: 12px; + } + textarea.form-field-input.nowrap { + white-space: pre; + overflow-wrap: normal; + } +} +.blog-write-options-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + td { + padding-top: 12px; + } + td:nth-child(1) { + width: 70%; + } + td:nth-child(2) { + width: 30%; + padding-left: 10px; + } + tr:first-child td { + padding-top: 0px; + } + button[type="submit"] { + margin-left: 3px; + } +} + +.blog-write-form-toggle-link { + margin-top: 3px; + display: inline-block; +} + +.blog-upload-form { + padding-bottom: $base-padding; +} + +.blog-upload-list {} +.blog-upload-item { + border-top: 1px $border-color solid; + padding: 10px 0; +} +.blog-upload-item-actions { + float: right; +} +.blog-upload-item-name { + font-weight: bold; + margin-bottom: 2px; +} +.blog-upload-item-info { + color: #888; + font-size: $fs - 2px; +} +.blog-upload-item-note { + padding: 0 0 4px; +} +.blog-upload-item-md { + margin-top: 3px; +} + +.blog-post-title { + margin: 0 0 16px; +} +.blog-post-title h1 { + font-size: 22px; + font-weight: bold; + padding: 0; + margin: 0; +} + +.blog-post-date { + color: #888; + margin-top: 5px; + font-size: $fs - 1px; + > a { + margin-left: 5px; + } +} + +.blog-post-tags { + margin-top: 16px; + margin-bottom: -1px; +} +.blog-post-tags > a { + display: block; + float: left; + font-size: $fs - 1px; + margin-right: 8px; + cursor: pointer; +} +.blog-post-tags > a:last-child { + margin-right: 0; +} +.blog-post-tags > a > span { + opacity: 0.5; +} + +.blog-post-text {} +.blog-post-text { + li { + margin: 13px 0; + } + + p { + margin-top: 13px; + margin-bottom: 13px; + } + p:first-child { + margin-top: 0; + } + p:last-child { + margin-bottom: 0; + } + + pre { + background-color: $code-block-bg; + font-family: $ffMono; + //font-size: $fsMono; + overflow: auto; + @include radius(3px); + } + + code { + background-color: $inline-code-block-bg; + font-family: $ffMono; + font-size: $fsMono; + padding: 3px 5px; + @include radius(3px); + } + + pre code { + display: block; + padding: 12px; + line-height: 145%; + background-color: $code-block-bg; + + span.term-prompt { + color: #999; + @include user-select(none); + } + } + + blockquote { + border-left: 3px #e0e0e0 solid; + margin-left: 0; + padding: 5px 0 5px 12px; + color: #888; + } + + table.table-100 { + border-collapse: collapse; + border: 0; + margin: 0; + width: 100%; + table-layout: fixed; + } + table.table-100 td { + padding: 0; + border: 0; + vertical-align: top; + text-align: left; + padding: 0 4px; + } + table.table-100 td:first-child { + padding-left: 0; + } + table.table-100 td:last-child { + padding-right: 0; + } + td > pre:first-child { + margin-top: 0; + } + td > pre:last-child { + margin-bottom: 0; + } + + h1 { + margin: 40px 0 16px; + font-weight: 600; + font-size: 30px; + border-bottom: 1px $border-color solid; + padding-bottom: 8px; + } + + h2 { + margin: 35px 0 16px; + font-weight: 500; + font-size: 25px; + border-bottom: 1px $border-color solid; + padding-bottom: 6px; + } + + h3 { + margin: 27px 0 16px; + font-size: 24px; + font-weight: 500; + } + + h4 { + font-size: 18px; + margin: 24px 0 16px; + } + + h5 { + font-size: 15px; + margin: 24px 0 16px; + } + + h6 { + font-size: 13px; + margin: 24px 0 16px; + color: #666; + } + + h3:first-child, + h4:first-child, + h5:first-child, + h6:first-child { + margin-top: 0; + } + h1:first-child, + h2:first-child { + margin-top: 5px; + } + + hr { + height: 1px; + border: 0; + background: $border-color; + margin: 17px 0; + } +} +.blog-post-comments { + margin-top: $base-padding; + padding: 12px 15px; + border: 1px #e0e0e0 solid; + @include radius(3px); +} +.blog-post-comments img { + vertical-align: middle; + position: relative; + top: -1px; + margin-left: 2px; +} + +$blog-tags-width: 175px; + +.index-blog-block { + margin-top: 23px; +} + +.blog-list {} +.blog-list.withtags { + margin-right: $blog-tags-width + $base-padding*2; +} +.blog-list-title { + font-size: 22px; + margin-bottom: 15px; + > span { + margin-left: 2px; + > a { + font-size: 16px; + margin-left: 2px; + } + } +} +.blog-list-table-wrap { + padding: 5px 0; +} +.blog-list-table { + border-collapse: collapse; +} +.blog-list-table td { + vertical-align: top; + padding: 0 0 13px; +} +.blog-list-table tr:last-child td { + padding-bottom: 0; +} +td.blog-item-date-cell { + width: 1px; + white-space: nowrap; + text-align: right; + padding-right: 10px; +} +.blog-item-date { + color: #777; + //text-transform: lowercase; +} +td.blog-item-title-cell { + text-align: left; +} +.blog-item-title { + //font-weight: bold; +} +.blog-item-row { + font-size: $fs; + line-height: 140%; +} +.blog-item-row.ishidden a.blog-item-title { + color: $fg; +} +.blog-item-row-year { + td { + padding-top: 10px; + text-align: right; + font-size: 20px; + letter-spacing: -0.5px; + } + &:first-child td { + padding-top: 0; + } +} +a.blog-item-view-all-link { + display: inline-block; + padding: 4px 17px; + @include radius(5px); + background-color: #f4f4f4; + color: #555; + margin-top: 2px; +} +a.blog-item-view-all-link:hover { + text-decoration: none; + background-color: #ededed; +} + + +.blog-tags { + float: right; + width: $blog-tags-width; + padding-left: $base-padding - 10px; + border-left: 1px $border-color solid; +} +.blog-tags-title { + margin-bottom: 15px; + font-size: 22px; + padding: 0 7px; +} +.blog-tag-item { + padding: 6px 10px; + font-size: $fs - 1px; +} +.blog-tag-item > a { + color: #222; +} +.blog-tag-item-count { + color: #aaa; + margin-left: 6px; + text-decoration: none !important; +} diff --git a/htdocs/scss/common-bundle.scss b/htdocs/scss/common-bundle.scss new file mode 100644 index 0000000..397f0c3 --- /dev/null +++ b/htdocs/scss/common-bundle.scss @@ -0,0 +1,9 @@ +@import "./common.scss"; +@import "./blog.scss"; +@import "./form.scss"; +@import "./hljs/github.scss"; +@import "./pages.scss"; + +@media screen and (max-width: 600px) { + @import "./mobile.scss"; +} diff --git a/htdocs/scss/common.scss b/htdocs/scss/common.scss new file mode 100644 index 0000000..644a080 --- /dev/null +++ b/htdocs/scss/common.scss @@ -0,0 +1,415 @@ +@import "vars"; + +.clearfix:after { + content: "."; + display: block; + clear: both; + visibility: hidden; + line-height: 0; + height: 0; +} + +html, body { + padding: 0; + margin: 0; + border: 0; + //background-color: $bg; + color: $fg; + height: 100%; + min-height: 100%; +} +body { + font-family: $ff; + font-size: $fs; +} + +.base-width { + max-width: $base-width; + margin: 0 auto; + position: relative; +} + +body.full-width .base-width { + max-width: 100%; + margin-left: auto; + margin-right: auto; +} + +input[type="text"], textarea { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + box-sizing: border-box; + border: 1px $input-border solid; + border-radius: 0px; + background-color: $input-bg; + color: $fg; + font-family: $ff; + font-size: $fs; + padding: 6px; + outline: none; + @include radius(3px); +} +textarea { + resize: vertical; +} +input[type="text"]:focus, +textarea:focus { + border-color: $input-border-focused; +} +//input[type="checkbox"] { +// margin-left: 0; +//} + +//button { +// border-radius: 2px; +// background-color: $light-bg; +// color: $fg; +// padding: 7px 12px; +// border: none; +// /*box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);*/ +// font-family: $ff; +// font-size: $fs - 1px; +// outline: none; +// cursor: pointer; +// position: relative; +//} +//button:hover { +// box-shadow: 0 1px 9px rgba(0, 0, 0, 0.2); +//} +//button:active { +// top: 1px; +//} + +a { + text-decoration: none; + color: $link-color; + outline: none; +} +a:hover { + text-decoration: underline; +} + +p, p code { + line-height: 150%; +} + +.unicode { font-family: sans-serif; } + +.ff_ms { font-family: $ffMono } +.fl_r { float: right } +.fl_l { float: left } +.pos_rel { position: relative } +.pos_abs { position: absolute } +.pos_fxd { position: fixed } + +.page-content { + padding: 0 $side-padding; +} +.page-content-inner { + padding: $base-padding 0; +} + +.head { + padding: 0 $side-padding; +} +.head-inner { + //padding: 13px 0; + position: relative; + border-bottom: 2px $border-color solid; +} +.head-logo { + padding: 4px 0; + font-family: $ffMono; + font-size: 15px; + display: inline-block; + position: absolute; + left: 0; + background-color: transparent; + @include transition(background-color, 0.03s); +} +.head-logo { + padding: 16px 0; + background-color: #fff; +} +.head-logo:after { + content: ''; + display: block; + width: 40px; + position: absolute; + right: -40px; + top: 0; + bottom: 0; + border-left: 8px #fff solid; + box-sizing: border-box; + background: linear-gradient(to left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); /* W3C */ +} +.head-logo > a { + color: $fg; + font-size: 14px; +} +.head-logo > a:hover { + text-decoration: none; +} +.head-logo-enter { + display: inline; + opacity: 0; + font-size: 11px; + position: relative; + background: #eee; + padding: 2px 5px; + color: #333; + font-weight: normal; + vertical-align: middle; + top: -1px; + @include transition(opacity, 0.03s); +} +.head-logo-enter-icon { + width: 12px; + height: 7px; + display: inline-block; + background: url(/img/enter.svg) 0 0 no-repeat; + background-size: 12px 7px; + margin-right: 5px; +} +.head-logo > a:hover .head-logo-enter { + opacity: 1; +} +.head-logo-path { + color: $fg; + font-weight: bold; + -webkit-font-smoothing: antialiased; + @include transition(color, 0.03s); +} +.head-logo > a:hover .head-logo-path:not(.alwayshover) { + color: #aaa; +} +.head-logo-path:not(.neverhover):hover { + color: #000 !important; +} +.head-logo-dolsign { + color: $head-green-color; + font-weight: normal; + &.is_root { + color: $head-red-color; + } +} +.head-logo-cd { + display: none; +} +.head-logo > a:hover .head-logo-cd { + display: inline; +} +.head-logo-path-mapped { + padding: 3px 5px; + background: #f1f1f1; + pointer-events: none; + @include radius(3px); + margin: 0 2px; +} + +.head-items { + float: right; + color: #777; // color of separators + //padding: 8px 0; +} +a.head-item { + color: $fg; + font-size: $fs - 1px; + display: block; + float: left; + padding: 16px 0; +} +a.head-item > span { + padding: 0 12px; + border-right: 1px #d0d0d0 solid; +} +a.head-item > span > span { + padding: 2px 0; +} +a.head-item:last-child > span { + border-right: 0; + padding-right: 1px; +} +/*a.head-item:first-child > span { + padding-left: 2px; +}*/ +a.head-item:hover { + //color: $link-color; + text-decoration: none; +} +a.head-item:hover > span > span { + border-bottom: 1px #d0d0d0 solid; +} + +table.contacts { + border: 0; + border-collapse: collapse; + margin: 8px auto 0; + //width: 100%; + //table-layout: fixed; +} +table.contacts td { + white-space: nowrap; + padding-bottom: 15px; + vertical-align: top; +} +table.contacts td.label { + text-align: right; + width: 30%; + color: #777; +} +table.contacts td.value { + text-align: left; + padding-left: 8px; +} +table.contacts td.value span { + background: #eee; + padding: 3px 7px 4px; + border-radius: 3px; + color: #333; + font-family: $ffMono; + font-size: $fs - 1px; +} +table.contacts td b { + font-weight: 600; +} +table.contacts td pre { + padding: 0; + margin: 0; + font-size: 12px; +} + +table.contacts div.note { + font-size: $fs - 3px; + padding-top: 2px; + color: #777; + > a { + color: #777; + border-bottom: 1px #ccc solid; + &:hover { + text-decoration: none; + border-bottom-color: #999; + } + } +} + +.pt { + margin: 5px 0 20px; + color: $dark-fg; + padding-bottom: 7px; + border-bottom: 2px rgba(255, 255, 255, 0.12) solid; +} +.pt h3 { + margin: 0; + display: inline-block; + font-weight: bold; + font-size: $fs; + color: $fg; +} +.pt h3:not(:first-child) { + margin-left: 5px; +} +.pt a { + margin-right: 5px; +} +.pt a:not(:first-child) { + margin-left: 5px; +} +.pt a, .pt h3 { + position: relative; + top: 1px; +} +.pt_r { margin-top: 5px } + +.empty { + text-align: center; + padding: 40px 20px; + color: $dark-fg; + @include radius(3px); + background-color: #f7f7f7; +} + +.contact-img { + display: inline-block; + width: 77px; + height: 12px; + background: transparent url(/img/contact.gif?1) no-repeat; + background-size: 77px 12px; +} +@media (-o-min-device-pixel-ratio:3/2), (-webkit-min-device-pixel-ratio:1.5), (min--moz-device-pixel-ratio:1.5), (min-resolution:1.5dppx) { +.contact-img { + background-image: url(/img/contact@2x.gif?1); +} +} + +.md-file-attach { + padding: 3px 0; +} +.md-file-attach-icon { + width: 14px; + height: 14px; + background: transparent url(/img/attachment.svg) no-repeat center center; + background-size: 14px 14px; + display: inline-block; + margin-right: 5px; + position: relative; + top: 1px; +} +.md-file-attach > a { + //font-weight: bold; +} +.md-file-attach-size { + color: #888; + margin-left: 2px; +} +.md-file-attach-note { + color: #000; + margin-left: 2px; +} + +.md-image { + padding: 3px 0; + line-height: 0; + max-width: 100%; +} +.md-images { + margin-bottom: -8px; + padding: 3px 0; + max-width: 100%; +} +.md-images .md-image { + padding-top: 0; + padding-bottom: 0; +} +.md-images > span { + display: block; + float: left; + margin: 0 8px 8px 0; + max-width: 100%; +} +.md-image.align-center { text-align: center; } +.md-image.align-left { text-align: left; } +.md-image.align-right { text-align: right; } +.md-image-wrap { + display: inline-block; + max-width: 100%; + overflow: hidden; +} +.md-image-wrap > a { + display: block; + max-width: 100%; +} +.md-image-note { + line-height: 150%; + color: #777; + padding: 2px 0 4px; +} + +.md-video video { + max-width: 100%; +} + +.language-ascii { + line-height: 125% !important; +} diff --git a/htdocs/scss/form.scss b/htdocs/scss/form.scss new file mode 100644 index 0000000..4faa4d1 --- /dev/null +++ b/htdocs/scss/form.scss @@ -0,0 +1,59 @@ +@import 'vars'; + +$form-field-label-width: 120px; + +form { display: block; margin: 0; } + +.form-layout-h { + .form-field-wrap { + padding: 8px 0; + } + .form-field-label { + float: left; + width: $form-field-label-width; + text-align: right; + padding: 7px 0 0; + } + .form-field { + margin-left: $form-field-label-width + 10px; + } +} + +.form-layout-v { + .form-field-wrap { + padding: 6px 0; + } + .form-field-wrap:first-child { + padding-top: 0; + } + .form-field-wrap:last-child { + padding-bottom: 0; + } + .form-field-label { + padding: 0 0 4px 4px; + font-weight: bold; + font-size: 12px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #888; + } + .form-field { + //margin-left: $form-field-label-width + 10px; + } +} + +.form-error { + padding: 10px 13px; + margin-bottom: $base-padding; + background-color: $error-block-bg; + color: $error-block-fg; + @include radius(3px); +} + +.form-success { + padding: 10px 13px; + margin-bottom: $base-padding; + background-color: $success-block-bg; + color: $success-block-fg; + @include radius(3px); +} diff --git a/htdocs/scss/hljs.scss b/htdocs/scss/hljs.scss new file mode 100644 index 0000000..36f52de --- /dev/null +++ b/htdocs/scss/hljs.scss @@ -0,0 +1 @@ +@import "./hljs/github.css"; diff --git a/htdocs/scss/hljs/github.scss b/htdocs/scss/hljs/github.scss new file mode 100644 index 0000000..791932b --- /dev/null +++ b/htdocs/scss/hljs/github.scss @@ -0,0 +1,99 @@ +/* + +github.com style (c) Vasily Polovnyov <vast@whiteants.net> + +*/ + +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + color: #333; + background: #f8f8f8; +} + +.hljs-comment, +.hljs-quote { + color: #998; + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: #333; + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: #008080; +} + +.hljs-string, +.hljs-doctag { + color: #d14; +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: #900; + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: #458; + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: #000080; + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: #009926; +} + +.hljs-symbol, +.hljs-bullet { + color: #990073; +} + +.hljs-built_in, +.hljs-builtin-name { + color: #0086b3; +} + +.hljs-meta { + color: #999; + font-weight: bold; +} + +.hljs-deletion { + background: #fdd; +} + +.hljs-addition { + background: #dfd; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} diff --git a/htdocs/scss/mobile.scss b/htdocs/scss/mobile.scss new file mode 100644 index 0000000..d4d0d25 --- /dev/null +++ b/htdocs/scss/mobile.scss @@ -0,0 +1,41 @@ +@import 'vars'; + +textarea { + -webkit-overflow-scrolling: touch; +} + +// header +.head-logo { + position: static; + display: block; + //padding-bottom: 6px; + // not very good fix: + overflow: hidden; + white-space: nowrap; + padding-bottom: 0; +} +.head-logo::after { + display: none; +} +.head-items { + float: none; +} +a.head-item:hover, +a.head-item:active { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important; +} +a.head-item:last-child > span { + border-right: 0; + padding-right: 12px; +} +a.head-item:first-child > span { + padding-left: 1px; +} + +// blog +.blog-tags { + display: none; +} +.blog-list.withtags { + margin-right: 0; +} diff --git a/htdocs/scss/pages.scss b/htdocs/scss/pages.scss new file mode 100644 index 0000000..873a6ae --- /dev/null +++ b/htdocs/scss/pages.scss @@ -0,0 +1,14 @@ +.page { + +} +.page-edit-links { + display: none; + float: right; + font-size: 15px; + > a { + margin-left: 5px; + } +} +.page-content-inner:hover .page-edit-links { + display: block; +} diff --git a/htdocs/scss/vars.scss b/htdocs/scss/vars.scss new file mode 100644 index 0000000..e056740 --- /dev/null +++ b/htdocs/scss/vars.scss @@ -0,0 +1,71 @@ +$fs: 16px; +$fsMono: 85%; +$ff: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; +$ffMono: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, monospace; + +$base-width: 900px; +//$sb-width: 120px; +$side-padding: 25px; +$base-padding: 18px; + +$footer-height: 64px; +$head-green-color: #0bad19; +$head-red-color: #ce1a1a; +$link-color: #116fd4; + +$bg: #f7f7f7; +$content-bg: #fff; +$code-block-bg: #f3f3f3; +$inline-code-block-bg: #f1f1f1; + +$fg: #222; +$blue1: #729fcf; +$blue2: #3465a4; +$blue3: #204a87; +$orange1: #fcaf3e; +$orange2: #f57900; +$orange3: #ce5c00; + +$light-bg: #464c4e; +$dark-bg: #272C2D; +$dark-fg: #999; + +$input-border: #e0e0e0; +$input-border-focused: #e0e0e0; +$input-bg: #f7f7f7; +$border-color: #e0e0e0; + +$error-block-bg: #f9eeee; +$error-block-fg: #d13d3d; + +$success-block-bg: #eff5f0; +$success-block-fg: #2a6f34; + +@mixin radius($radius) { + -o-border-radius: $radius; + -ms-border-radius: $radius; + -moz-border-radius: $radius; + -webkit-border-radius: $radius; + border-radius: $radius; +} + +@mixin transition($property, $duration, $easing: linear) { + transition: $property $duration $easing; + -webkit-transition: $property $duration $easing; + -moz-transition: $property $duration $easing; +} + +@mixin linearGradient($top, $bottom){ + background: -moz-linear-gradient(top, $top 0%, $bottom 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,$top), color-stop(100%,$bottom)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, $top 0%,$bottom 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, $top 0%,$bottom 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, $top 0%,$bottom 100%); /* IE10+ */ + background: linear-gradient(to bottom, $top 0%,$bottom 100%); /* W3C */ +} + +@mixin user-select($value) { + -moz-user-select: $value; + -webkit-user-select: $value; + user-select: $value; +} diff --git a/htdocs/yandex_3512181a57932602.html b/htdocs/yandex_3512181a57932602.html new file mode 100644 index 0000000..07384d2 --- /dev/null +++ b/htdocs/yandex_3512181a57932602.html @@ -0,0 +1,6 @@ +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + </head> + <body>Verification: 3512181a57932602</body> +</html> diff --git a/init.php b/init.php new file mode 100644 index 0000000..6d9f5f0 --- /dev/null +++ b/init.php @@ -0,0 +1,69 @@ +<?php + +error_reporting(E_ALL); +ini_set('display_errors', 1); +date_default_timezone_set('Europe/Moscow'); + +mb_internal_encoding('UTF-8'); +mb_regex_encoding('UTF-8'); + +define('ROOT', __DIR__); +define('START_TIME', microtime(true)); + +set_include_path(get_include_path().PATH_SEPARATOR.ROOT); + +spl_autoload_register(function($class) { + if (str_ends_with($class, 'Exception')) { + $path = ROOT.'/engine/exceptions/'.$class.'.php'; + } else if (in_array($class, ['MySQLConnection', 'SQLiteConnection', 'CommonDatabase'])) { + $path = ROOT.'/engine/database/'.$class.'.php'; + } else if (str_starts_with($class, 'handler\\')) { + $path = ROOT.'/'.str_replace('\\', '/', $class).'.php'; + } + + if (isset($path)) { + if (!is_file($path)) + return; + } else { + foreach (['engine', 'lib', 'model'] as $dir) { + if (is_file($path = ROOT.'/'.$dir.'/'.$class.'.php')) + break; + } + } + + require_once $path; +}); + +$config = require_once 'config.php'; +if (file_exists(ROOT.'/config-local.php')) { + $config = array_replace($config, require 'config-local.php'); +} + +// turn off errors output on production domains + +require_once 'functions.php'; + +if (PHP_SAPI == 'cli') { + $_SERVER['HTTP_HOST'] = $config['domain']; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; +} else { + if (array_key_exists('HTTP_X_REAL_IP', $_SERVER)) + $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP']; +} + +if (!$config['is_dev']) { + if (file_exists(ROOT.'/config-static.php')) + $config['static'] = require_once 'config-static.php'; + else + die('confic-static.php not found'); +} + +if (!$config['is_dev']) { + error_reporting(0); + ini_set('display_errors', 0); +} + +logging::setLogFile($config['log_file']); +logging::enable(); + +require 'vendor/autoload.php'; diff --git a/lang/en.php b/lang/en.php new file mode 100644 index 0000000..cf2af6c --- /dev/null +++ b/lang/en.php @@ -0,0 +1,99 @@ +<?php + +return [ + // common + 'ch1p' => 'ch1p', + 'site_title' => 'ch1p', + 'index_title' => 'Home | ch1p', + + 'posts' => 'posts', + 'all_posts' => 'all posts', + 'blog' => 'blog', + 'contacts' => 'contacts', + 'email' => 'email', + 'projects' => 'projects', + 'unknown_error' => 'Unknown error', + 'error' => 'Error', + 'write' => 'Write', + 'submit' => 'submit', + 'edit' => 'edit', + 'delete' => 'delete', + 'info_saved' => 'Information saved.', + + // contacts + 'contacts_email' => 'email', + 'contacts_pgp' => 'OpenPGP public key', + 'contacts_tg' => 'telegram', + 'contacts_freenode' => 'freenode', + + // blog + 'blog_tags' => 'tags', + 'blog_latest' => 'Latest posts', + 'blog_no' => 'No posts yet.', + 'blog_view_all' => 'View all', + 'blog_write' => 'Write a post', + 'blog_post_delete_confirmation' => 'Are you sure you want to delete this post?', + 'blog_post_edit_title' => 'Edit post "%s"', + 'blog_post_hidden' => 'Hidden', + 'blog_tag_title' => 'Posts tagged with "%s"', + 'blog_tag_not_found' => 'No posts found.', + 'blog_comments_text' => 'If you have any comments, <a href="mailto:%s?subject=%s">contact me by email</a>.', + + 'blog_write_form_preview_btn' => 'Preview', + 'blog_write_form_submit_btn' => 'Submit', + 'blog_write_form_title' => 'Title', + 'blog_write_form_text' => 'Text', + 'blog_write_form_preview' => 'Preview', + 'blog_write_form_enter_text' => 'Enter text..', + 'blog_write_form_enter_title' => 'Enter title..', + 'blog_write_form_tags' => 'Tags', + 'blog_write_form_visible' => 'Visible', + 'blog_write_form_short_name' => 'Short name', + 'blog_write_form_toggle_wrap' => 'Toggle wrap', + 'blog_write_form_options' => 'Options', + + 'blog_uploads' => 'Uploads', + 'blog_upload' => 'Upload files', + 'blog_upload_delete' => 'Delete', + 'blog_upload_delete_confirmation' => 'Are you sure you want to delete this upload?', + 'blog_upload_show_md' => 'Show md', + 'blog_upload_form_file' => 'File', + 'blog_upload_form_custom_name' => 'Custom name', + 'blog_upload_form_note' => 'Note', + + // blog (errors) + 'err_blog_no_title' => 'Title not specified', + 'err_blog_no_text' => 'Text not specified', + 'err_blog_no_tags' => 'Tags not specified', + 'err_blog_db_err' => 'Database error', + 'err_blog_no_short_name' => 'Short name not specified', + 'err_blog_short_name_exists' => 'This short name already exists', + + // pages + 'pages_create' => 'create new page', + 'pages_edit' => 'edit', + 'pages_delete' => 'delete', + 'pages_create_title' => 'create new page "%s"', + 'pages_page_delete_confirmation' => 'Are you sure you want to delete this page?', + 'pages_page_edit_title' => 'Edit %s', + + 'pages_write_form_submit_btn' => 'Submit', + 'pages_write_form_title' => 'Title', + 'pages_write_form_text' => 'Text', + 'pages_write_form_enter_text' => 'Enter text..', + 'pages_write_form_enter_title' => 'Enter title..', + 'pages_write_form_visible' => 'Visible', + 'pages_write_form_short_name' => 'Short name', + 'pages_write_form_toggle_wrap' => 'Toggle wrap', + 'pages_write_form_options' => 'Options', + + // pages (errors) + 'err_pages_no_title' => 'Title not specified', + 'err_pages_no_text' => 'Text not specified', + 'err_pages_no_id' => 'ID not specified', + 'err_pages_no_short_name' => 'Short name not specified', + 'err_pages_db_err' => 'Database error', + + // admin-switch + 'as_form_password' => 'Password', +]; diff --git a/lib/MyParsedown.php b/lib/MyParsedown.php new file mode 100644 index 0000000..c2c0112 --- /dev/null +++ b/lib/MyParsedown.php @@ -0,0 +1,209 @@ +<?php + +use sixlive\ParsedownHighlight; + +class MyParsedown extends ParsedownHighlight { + + public function __construct( + protected bool $useImagePreviews = false + ) { + parent::__construct(); + $this->InlineTypes['{'][] = 'FileAttach'; + $this->InlineTypes['{'][] = 'Image'; + $this->InlineTypes['{'][] = 'Video'; + $this->inlineMarkerList .= '{'; + } + + protected function inlineFileAttach($excerpt) { + if (preg_match('/^{fileAttach:([\w]{8})}{\/fileAttach}/', $excerpt['text'], $matches)) { + $random_id = $matches[1]; + $upload = uploads::getByRandomId($random_id); + $result = [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'name' => 'span', + 'text' => '', + ], + 'type' => '' + ]; + + if (!$upload) { + return $result; + } + + unset($result['element']['text']); + + $ctx = self::getSkinContext(); + $result['element']['rawHtml'] = $ctx->fileupload($upload->name, $upload->getDirectUrl(), $upload->note, $upload->getSize()); + + return $result; + } + } + + protected function inlineImage($excerpt) { + global $config; + + if (preg_match('/^{image:([\w]{8}),(.*?)}{\/image}/', $excerpt['text'], $matches)) { + $random_id = $matches[1]; + + $opts = [ + 'w' => 'auto', + 'h' => 'auto', + 'align' => 'left', + 'nolabel' => false, + ]; + $inputopts = explode(',', $matches[2]); + + foreach ($inputopts as $opt) { + if ($opt == 'nolabel') + $opts[$opt] = true; + else { + list($k, $v) = explode('=', $opt); + if (!isset($opts[$k])) + continue; + $opts[$k] = $v; + } + } + + $image = uploads::getByRandomId($random_id); + $result = [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'name' => 'span', + 'text' => '', + ], + 'type' => '' + ]; + + if (!$image) { + return $result; + } + + list($w, $h) = $image->getImagePreviewSize( + $opts['w'] == 'auto' ? null : $opts['w'], + $opts['h'] == 'auto' ? null : $opts['h'] + ); + $opts['w'] = $w; + // $opts['h'] = $h; + + if (!$this->useImagePreviews) + $image_url = $image->getDirectUrl(); + else + $image_url = $image->getDirectPreviewUrl($w, $h); + + unset($result['element']['text']); + + $ctx = self::getSkinContext(); + $result['element']['rawHtml'] = $ctx->image( + w: $opts['w'], + nolabel: $opts['nolabel'], + align: $opts['align'], + padding_top: round($h / $w * 100, 4), + + url: $image_url, + direct_url: $image->getDirectUrl(), + note: $image->note + ); + + return $result; + } + } + + protected function inlineVideo($excerpt) { + if (preg_match('/^{video:([\w]{8})(?:,(.*?))?}{\/video}/', $excerpt['text'], $matches)) { + $random_id = $matches[1]; + + $opts = [ + 'w' => 'auto', + 'h' => 'auto', + 'align' => 'left', + 'nolabel' => false, + ]; + $inputopts = !empty($matches[2]) ? explode(',', $matches[2]) : []; + + foreach ($inputopts as $opt) { + if ($opt == 'nolabel') + $opts[$opt] = true; + else { + list($k, $v) = explode('=', $opt); + if (!isset($opts[$k])) + continue; + $opts[$k] = $v; + } + } + + $video = uploads::getByRandomId($random_id); + $result = [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'name' => 'span', + 'text' => '', + ], + 'type' => '' + ]; + + if (!$video) { + return $result; + } + + $video_url = $video->getDirectUrl(); + + unset($result['element']['text']); + + $ctx = self::getSkinContext(); + $result['element']['rawHtml'] = $ctx->video( + url: $video_url, + w: $opts['w'], + h: $opts['h'] + ); + + return $result; + } + } + + protected function paragraph($line) { + if (preg_match('/^{fileAttach:([\w]{8})}{\/fileAttach}$/', $line['text'])) { + return $this->inlineFileAttach($line); + } + if (preg_match('/^{image:([\w]{8}),(?:.*?)}{\/image}/', $line['text'])) { + return $this->inlineImage($line); + } + if (preg_match('/^{video:([\w]{8})(?:,(?:.*?))?}{\/video}/', $line['text'])) { + return $this->inlineVideo($line); + } + return parent::paragraph($line); + } + + protected function blockFencedCodeComplete($block) { + if (!isset($block['element']['element']['attributes'])) { + return $block; + } + + $code = $block['element']['element']['text']; + $languageClass = $block['element']['element']['attributes']['class']; + $language = explode('-', $languageClass); + + if ($language[1] == 'term') { + $lines = explode("\n", $code); + for ($i = 0; $i < count($lines); $i++) { + $line = $lines[$i]; + if (str_starts_with($line, '$ ') || str_starts_with($line, '# ')) { + $lines[$i] = '<span class="term-prompt">'.substr($line, 0, 2).'</span>'.htmlspecialchars(substr($line, 2), ENT_NOQUOTES, 'UTF-8'); + } else { + $lines[$i] = htmlspecialchars($line, ENT_NOQUOTES, 'UTF-8'); + } + } + $block['element']['element']['rawHtml'] = implode("\n", $lines); + unset($block['element']['element']['text']); + + return $block; + } + + return parent::blockFencedCodeComplete($block); + } + + protected static function getSkinContext(): SkinContext { + return new SkinContext('\\skin\\markdown'); + } + +} diff --git a/lib/admin.php b/lib/admin.php new file mode 100644 index 0000000..8b36b36 --- /dev/null +++ b/lib/admin.php @@ -0,0 +1,57 @@ +<?php + +class admin { + + const SESSION_TIMEOUT = 86400 * 14; + const COOKIE_NAME = 'admin_key'; + + protected static ?bool $isAdmin = null; + + public static function isAdmin(): bool { + if (is_null(self::$isAdmin)) + self::$isAdmin = self::_verifyKey(); + return self::$isAdmin; + } + + protected static function _verifyKey(): bool { + if (isset($_COOKIE[self::COOKIE_NAME])) { + $cookie = (string)$_COOKIE[self::COOKIE_NAME]; + if ($cookie !== self::getKey()) + self::unsetCookie(); + return true; + } + return false; + } + + public static function checkPassword(string $pwd): bool { + return salt_password($pwd) === config::get('admin_pwd'); + } + + protected static function getKey(): string { + global $config; + $admin_pwd_hash = config::get('admin_pwd'); + return salt_password("$admin_pwd_hash|{$_SERVER['REMOTE_ADDR']}"); + } + + public static function setCookie(): void { + global $config; + $key = self::getKey(); + setcookie(self::COOKIE_NAME, $key, time() + self::SESSION_TIMEOUT, '/', $config['cookie_host']); + } + + public static function unsetCookie(): void { + global $config; + setcookie(self::COOKIE_NAME, null, -1, '/', $config['cookie_host']); + } + + public static function logAuth(): void { + getDb()->insert('admin_log', [ + 'ts' => time(), + 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + ]); + } + + +} + diff --git a/lib/cli.php b/lib/cli.php new file mode 100644 index 0000000..3bda20f --- /dev/null +++ b/lib/cli.php @@ -0,0 +1,70 @@ +<?php + +class cli { + + protected ?array $commandsCache = null; + + public function __construct( + protected string $ns + ) {} + + protected function usage($error = null): void { + global $argv; + + if (!is_null($error)) + echo "error: {$error}\n\n"; + + echo "Usage: $argv[0] COMMAND\n\nCommands:\n"; + foreach ($this->getCommands() as $c) + echo " $c\n"; + + exit(is_null($error) ? 0 : 1); + } + + public function getCommands(): array { + if (is_null($this->commandsCache)) { + $funcs = array_filter(get_defined_functions()['user'], fn(string $f) => str_starts_with($f, $this->ns)); + $funcs = array_map(fn(string $f) => str_replace('_', '-', substr($f, strlen($this->ns.'\\'))), $funcs); + $this->commandsCache = array_values($funcs); + } + return $this->commandsCache; + } + + public function run(): void { + global $argv, $argc; + + if (PHP_SAPI != 'cli') + cli::die('SAPI != cli'); + + if ($argc < 2) + $this->usage(); + + $func = $argv[1]; + if (!in_array($func, $this->getCommands())) + self::usage('unknown command "'.$func.'"'); + + $func = str_replace('-', '_', $func); + call_user_func($this->ns.'\\'.$func); + } + + public static function input(string $prompt): string { + echo $prompt; + $input = substr(fgets(STDIN), 0, -1); + return $input; + } + + public static function silentInput(string $prompt = ''): string { + echo $prompt; + system('stty -echo'); + $input = substr(fgets(STDIN), 0, -1); + system('stty echo'); + echo "\n"; + return $input; + } + + public static function die($error): void { + fwrite(STDERR, "error: {$error}\n"); + exit(1); + } + +}
\ No newline at end of file diff --git a/lib/config.php b/lib/config.php new file mode 100644 index 0000000..bb7e5ca --- /dev/null +++ b/lib/config.php @@ -0,0 +1,46 @@ +<?php + +class config { + + public static function get(string $key) { + $db = getDb(); + $q = $db->query("SELECT value FROM config WHERE name=?", $key); + if (!$db->numRows($q)) + return null; + return $db->result($q); + } + + public static function mget($keys) { + $map = []; + foreach ($keys as $key) { + $map[$key] = null; + } + + $db = getDb(); + $keys = array_map(fn($s) => $db->escape($s), $keys); + + $q = $db->query("SELECT * FROM config WHERE name IN('".implode("','", $keys)."')"); + while ($row = $db->fetch($q)) + $map[$row['name']] = $row['value']; + + return $map; + } + + public static function set($key, $value) { + $db = getDb(); + return $db->query("REPLACE INTO config (name, value) VALUES (?, ?)", $key, $value); + } + + public static function mset($map) { + $rows = []; + foreach ($map as $name => $value) { + $rows[] = [ + 'name' => $name, + 'value' => $value + ]; + } + $db = getDb(); + return $db->multipleReplace('config', $rows); + } + +} diff --git a/lib/markup.php b/lib/markup.php new file mode 100644 index 0000000..52ccf24 --- /dev/null +++ b/lib/markup.php @@ -0,0 +1,30 @@ +<?php + +class markup { + + public static function markdownToHtml(string $md, bool $use_image_previews = true): string { + $pd = new MyParsedown(useImagePreviews: $use_image_previews); + return $pd->text($md); + } + + public static function htmlToText(string $html): string { + $text = html_entity_decode(strip_tags($html)); + $lines = explode("\n", $text); + $lines = array_map('trim', $lines); + $text = implode("\n", $lines); + $text = preg_replace("/(\r?\n){2,}/", "\n\n", $text); + return $text; + } + + public static function htmlRetinaFix(string $html): string { + global $config; + return preg_replace_callback( + '/('.preg_quote($config['uploads_host'], '/').'\/\w{8}\/p)(\d+)x(\d+)(\.jpg)/', + function($match) { + return $match[1].(intval($match[2])*2).'x'.(intval($match[3])*2).$match[4]; + }, + $html + ); + } + +}
\ No newline at end of file diff --git a/lib/pages.php b/lib/pages.php new file mode 100644 index 0000000..281ee52 --- /dev/null +++ b/lib/pages.php @@ -0,0 +1,32 @@ +<?php + +class pages { + + public static function add(array $data): ?int { + $db = getDb(); + $data['ts'] = time(); + $data['html'] = markup::markdownToHtml($data['md']); + if (!$db->insert('pages', $data)) + return null; + return $db->insertId(); + } + + public static function delete(Page $page): void { + getDb()->query("DELETE FROM pages WHERE short_name=?", $page->shortName); + } + + public static function getPageByName(string $short_name): ?Page { + $db = getDb(); + $q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name); + return $db->numRows($q) ? new Page($db->fetch($q)) : null; + } + + /** + * @return Page[] + */ + public static function getAll(): array { + $db = getDb(); + return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages"))); + } + +}
\ No newline at end of file diff --git a/lib/posts.php b/lib/posts.php new file mode 100644 index 0000000..bf8d149 --- /dev/null +++ b/lib/posts.php @@ -0,0 +1,188 @@ +<?php + +class posts { + + public static function getPostsCount(bool $include_hidden = false): int { + $db = getDb(); + $sql = "SELECT COUNT(*) FROM posts"; + if (!$include_hidden) { + $sql .= " WHERE visible=1"; + } + return (int)$db->result($db->query($sql)); + } + + public static function getPostsCountByTagId(int $tag_id, bool $include_hidden = false): int { + $db = getDb(); + if ($include_hidden) { + $sql = "SELECT COUNT(*) FROM posts_tags WHERE tag_id=?"; + } else { + $sql = "SELECT COUNT(*) FROM posts_tags + LEFT JOIN posts ON posts.id=posts_tags.post_id + WHERE posts_tags.tag_id=? AND posts.visible=1"; + } + return (int)$db->result($db->query($sql, $tag_id)); + } + + public static function getPosts(int $offset = 0, int $count = -1, bool $include_hidden = false): array { + $db = getDb(); + $sql = "SELECT * FROM posts"; + if (!$include_hidden) + $sql .= " WHERE visible=1"; + $sql .= " ORDER BY ts DESC"; + if ($offset != 0 && $count != -1) + $sql .= "LIMIT $offset, $count"; + $q = $db->query($sql); + return array_map('Post::create_instance', $db->fetchAll($q)); + } + + /** + * @return Post[] + */ + public static function getPostsByTagId(int $tag_id, bool $include_hidden = false): array { + $db = getDb(); + $sql = "SELECT posts.* FROM posts_tags + LEFT JOIN posts ON posts.id=posts_tags.post_id + WHERE posts_tags.tag_id=?"; + if (!$include_hidden) + $sql .= " AND posts.visible=1"; + $sql .= " ORDER BY posts.ts DESC"; + $q = $db->query($sql, $tag_id); + return array_map('Post::create_instance', $db->fetchAll($q)); + } + + public static function add(array $data = []): int|bool { + $db = getDb(); + + $html = \markup::markdownToHtml($data['md']); + $text = \markup::htmlToText($html); + + $data += [ + 'ts' => time(), + 'html' => $html, + 'text' => $text, + ]; + + if (!$db->insert('posts', $data)) + return false; + + $id = $db->insertId(); + + $post = posts::get($id); + $post->updateImagePreviews(); + + return $id; + } + + public static function delete(Post $post): void { + $tags = $post->getTags(); + + $db = getDb(); + $db->query("DELETE FROM posts WHERE id=?", $post->id); + $db->query("DELETE FROM posts_tags WHERE post_id=?", $post->id); + + foreach ($tags as $tag) + self::recountPostsWithTag($tag->id); + } + + public static function getTagIds(array $tags): array { + $found_tags = []; + $map = []; + + $db = getDb(); + $q = $db->query("SELECT id, tag FROM tags + WHERE tag IN ('".implode("','", array_map(function($tag) use ($db) { return $db->escape($tag); }, $tags))."')"); + while ($row = $db->fetch($q)) { + $found_tags[] = $row['tag']; + $map[$row['tag']] = (int)$row['id']; + } + + $notfound_tags = array_diff($tags, $found_tags); + if (!empty($notfound_tags)) { + foreach ($notfound_tags as $tag) { + $db->insert('tags', ['tag' => $tag]); + $map[$tag] = $db->insertId(); + } + } + + return $map; + } + + public static function get(int $id): ?Post { + $db = getDb(); + $q = $db->query("SELECT * FROM posts WHERE id=?", $id); + return $db->numRows($q) ? new Post($db->fetch($q)) : null; + } + + public static function getPostByName(string $short_name): ?Post { + $db = getDb(); + $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); + return $db->numRows($q) ? new Post($db->fetch($q)) : null; + } + + public static function getPostsById(array $ids, bool $flat = false): array { + if (empty($ids)) { + return []; + } + + $db = getDb(); + $posts = array_fill_keys($ids, null); + + $q = $db->query("SELECT * FROM posts WHERE id IN(".implode(',', $ids).")"); + + while ($row = $db->fetch($q)) { + $posts[(int)$row['id']] = new Post($row); + } + + if ($flat) { + $list = []; + foreach ($ids as $id) { + $list[] = $posts[$id]; + } + unset($posts); + return $list; + } + + return $posts; + } + + public static function getAllTags(bool $include_hidden = false): array { + $db = getDb(); + $field = $include_hidden ? 'posts_count' : 'visible_posts_count'; + $q = $db->query("SELECT * FROM tags WHERE $field > 0 ORDER BY $field DESC, tag"); + return array_map('Tag::create_instance', $db->fetchAll($q)); + } + + public static function getTag(string $tag): ?Tag { + $db = getDb(); + $q = $db->query("SELECT * FROM tags WHERE tag=?", $tag); + return $db->numRows($q) ? new Tag($db->fetch($q)) : null; + } + + /** + * @param int $tag_id + */ + public static function recountPostsWithTag($tag_id) { + $db = getDb(); + $count = $db->result($db->query("SELECT COUNT(*) FROM posts_tags WHERE tag_id=?", $tag_id)); + $vis_count = $db->result($db->query("SELECT COUNT(*) FROM posts_tags + LEFT JOIN posts ON posts.id=posts_tags.post_id + WHERE posts_tags.tag_id=? AND posts.visible=1", $tag_id)); + $db->query("UPDATE tags SET posts_count=?, visible_posts_count=? WHERE id=?", + $count, $vis_count, $tag_id); + } + + public static function splitStringToTags(string $tags): array { + $tags = trim($tags); + if ($tags == '') { + return []; + } + + $tags = preg_split('/,\s+/', $tags); + $tags = array_filter($tags, function($tag) { return trim($tag) != ''; }); + $tags = array_map('trim', $tags); + $tags = array_map('mb_strtolower', $tags); + + return $tags; + } + +} diff --git a/lib/uploads.php b/lib/uploads.php new file mode 100644 index 0000000..62be276 --- /dev/null +++ b/lib/uploads.php @@ -0,0 +1,145 @@ +<?php + +class uploads { + + protected static $allowedExtensions = [ + 'jpg', 'png', 'git', 'mp4', 'mp3', 'ogg', 'diff', 'txt', 'gz', 'tar', + 'icc', 'icm', 'patch', 'zip', 'brd', 'pdf', 'lua', 'xpi', 'rar', '7z', + 'tgz', 'bin', 'py', 'pac', + ]; + + public static function getCount(): int { + $db = getDb(); + return (int)$db->result($db->query("SELECT COUNT(*) FROM uploads")); + } + + public static function isExtensionAllowed(string $ext): bool { + return in_array($ext, self::$allowedExtensions); + } + + public static function add(string $tmp_name, string $name, string $note): ?int { + global $config; + + $name = sanitize_filename($name); + if (!$name) + $name = 'file'; + + $random_id = self::getNewRandomId(); + $size = filesize($tmp_name); + $is_image = detect_image_type($tmp_name) !== false; + $image_w = 0; + $image_h = 0; + if ($is_image) { + list($image_w, $image_h) = getimagesize($tmp_name); + } + + $db = getDb(); + if (!$db->insert('uploads', [ + 'random_id' => $random_id, + 'ts' => time(), + 'name' => $name, + 'size' => $size, + 'image' => (int)$is_image, + 'image_w' => $image_w, + 'image_h' => $image_h, + 'note' => $note, + 'downloads' => 0, + ])) { + return null; + } + + $id = $db->insertId(); + + $dir = $config['uploads_dir'].'/'.$random_id; + $path = $dir.'/'.$name; + + mkdir($dir); + chmod($dir, 0775); // g+w + + rename($tmp_name, $path); + chmod($path, 0664); // g+w + + return $id; + } + + public static function delete(int $id): bool { + $upload = self::get($id); + if (!$upload) + return false; + + $db = getDb(); + $db->query("DELETE FROM uploads WHERE id=?", $id); + + rrmdir($upload->getDirectory()); + return true; + } + + /** + * @return Upload[] + */ + public static function getAll(): array { + $db = getDb(); + $q = $db->query("SELECT * FROM uploads ORDER BY id DESC"); + return array_map('Upload::create_instance', $db->fetchAll($q)); + } + + public static function get(int $id): ?Upload { + $db = getDb(); + $q = $db->query("SELECT * FROM uploads WHERE id=?", $id); + if ($db->numRows($q)) { + return new Upload($db->fetch($q)); + } else { + return null; + } + } + + /** + * @param string[] $ids + * @param bool $flat + * @return Upload[] + */ + public static function getUploadsByRandomId(array $ids, bool $flat = false): array { + if (empty($ids)) { + return []; + } + + $db = getDb(); + $uploads = array_fill_keys($ids, null); + + $q = $db->query("SELECT * FROM uploads WHERE random_id IN('".implode('\',\'', array_map([$db, 'escape'], $ids))."')"); + + while ($row = $db->fetch($q)) { + $uploads[$row['random_id']] = new Upload($row); + } + + if ($flat) { + $list = []; + foreach ($ids as $id) { + $list[] = $uploads[$id]; + } + unset($uploads); + return $list; + } + + return $uploads; + } + + public static function getByRandomId(string $random_id): ?Upload { + $db = getDb(); + $q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id); + if ($db->numRows($q)) { + return new Upload($db->fetch($q)); + } else { + return null; + } + } + + protected static function getNewRandomId(): string { + $db = getDb(); + do { + $random_id = strgen(8); + } while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0); + return $random_id; + } + +} diff --git a/model/Page.php b/model/Page.php new file mode 100644 index 0000000..6711a2c --- /dev/null +++ b/model/Page.php @@ -0,0 +1,44 @@ +<?php + +class Page extends Model { + + const DB_TABLE = 'pages'; + const DB_KEY = 'short_name'; + + public string $title; + public string $md; + public string $html; + public int $ts; + public int $updateTs; + public bool $visible; + public string $shortName; + + public function edit(array $data) { + $data['update_ts'] = time(); + if ($data['md'] != $this->md) + $data['html'] = markup::markdownToHtml($data['md']); + parent::edit($data); + } + + public function isUpdated(): bool { + return $this->updateTs && $this->updateTs != $this->ts; + } + + public function getHtml(bool $retina): string { + $html = $this->html; + if ($retina) + $html = markup::htmlRetinaFix($html); + return $html; + } + + public function getUrl(): string { + return "/{$this->shortName}/"; + } + + public function updateHtml() { + $html = markup::markdownToHtml($this->md); + $this->html = $html; + getDb()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName); + } + +} diff --git a/model/Post.php b/model/Post.php new file mode 100644 index 0000000..10a396b --- /dev/null +++ b/model/Post.php @@ -0,0 +1,185 @@ +<?php + +class Post extends Model { + + const DB_TABLE = 'posts'; + + public int $id; + public string $title; + public string $md; + public string $html; + public string $text; + public int $ts; + public int $updateTs; + public bool $visible; + public string $shortName; + + public function edit(array $data) { + $cur_ts = time(); + if (!$this->visible && $data['visible']) + $data['ts'] = $cur_ts; + + $data['update_ts'] = $cur_ts; + + if ($data['md'] != $this->md) { + $data['html'] = \markup::markdownToHtml($data['md']); + $data['text'] = \markup::htmlToText($data['html']); + } + + parent::edit($data); + $this->updateImagePreviews(); + } + + public function updateHtml() { + $html = \markup::markdownToHtml($this->md); + $this->html = $html; + + getDb()->query("UPDATE posts SET html=? WHERE id=?", $html, $this->id); + } + + public function updateText() { + $html = \markup::markdownToHtml($this->md); + $text = \markup::htmlToText($html); + $this->text = $text; + + getDb()->query("UPDATE posts SET text=? WHERE id=?", $text, $this->id); + } + + public function getDescriptionPreview(int $len): string { + if (mb_strlen($this->text) >= $len) + return mb_substr($this->text, 0, $len-3).'...'; + return $this->text; + } + + public function getFirstImage(): ?Upload { + if (!preg_match('/\{image:([\w]{8})/', $this->md, $match)) + return null; + return uploads::getByRandomId($match[1]); + } + + public function getUrl(): string { + return $this->shortName != '' ? "/{$this->shortName}/" : "/{$this->id}/"; + } + + public function getDate(): string { + return date('j M', $this->ts); + } + + public function getYear(): int { + return (int)date('Y', $this->ts); + } + + public function getFullDate(): string { + return date('j F Y', $this->ts); + } + + public function getUpdateDate(): string { + return date('j M', $this->updateTs); + } + + public function getFullUpdateDate(): string { + return date('j F Y', $this->updateTs); + } + + public function getHtml(bool $retina): string { + $html = $this->html; + if ($retina) + $html = markup::htmlRetinaFix($html); + return $html; + } + + public function isUpdated(): bool { + return $this->updateTs && $this->updateTs != $this->ts; + } + + /** + * @return Tag[] + */ + public function getTags(): array { + $db = getDb(); + $q = $db->query("SELECT tags.* FROM posts_tags + LEFT JOIN tags ON tags.id=posts_tags.tag_id + WHERE posts_tags.post_id=? + ORDER BY posts_tags.tag_id", $this->id); + return array_map('Tag::create_instance', $db->fetchAll($q)); + } + + /** + * @return int[] + */ + public function getTagIds(): array { + $ids = []; + $db = getDb(); + $q = $db->query("SELECT tag_id FROM posts_tags WHERE post_id=? ORDER BY tag_id", $this->id); + while ($row = $db->fetch($q)) { + $ids[] = (int)$row['tag_id']; + } + return $ids; + } + + public function setTagIds(array $new_tag_ids) { + $cur_tag_ids = $this->getTagIds(); + $add_tag_ids = array_diff($new_tag_ids, $cur_tag_ids); + $rm_tag_ids = array_diff($cur_tag_ids, $new_tag_ids); + + $db = getDb(); + if (!empty($add_tag_ids)) { + $rows = []; + foreach ($add_tag_ids as $id) + $rows[] = ['post_id' => $this->id, 'tag_id' => $id]; + $db->multipleInsert('posts_tags', $rows); + } + + if (!empty($rm_tag_ids)) + $db->query("DELETE FROM posts_tags WHERE post_id=? AND tag_id IN(".implode(',', $rm_tag_ids).")", $this->id); + + $upd_tag_ids = array_merge($new_tag_ids, $rm_tag_ids); + $upd_tag_ids = array_unique($upd_tag_ids); + foreach ($upd_tag_ids as $id) + posts::recountPostsWithTag($id); + } + + /** + * @param bool $update Whether to overwrite preview if already exists + * @return int + */ + public function updateImagePreviews(bool $update = false): int { + $images = []; + if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches)) + return 0; + + for ($i = 0; $i < count($matches[0]); $i++) { + $id = $matches[1][$i]; + $w = $h = null; + $opts = explode(',', $matches[2][$i]); + foreach ($opts as $opt) { + if (strpos($opt, '=') !== false) { + list($k, $v) = explode('=', $opt); + if ($k == 'w') + $w = (int)$v; + else if ($k == 'h') + $h = (int)$v; + } + } + $images[$id][] = [$w, $h]; + } + + if (empty($images)) + return 0; + + $images_affected = 0; + $uploads = uploads::getUploadsByRandomId(array_keys($images), true); + foreach ($uploads as $u) { + foreach ($images[$u->randomId] as $s) { + list($w, $h) = $s; + list($w, $h) = $u->getImagePreviewSize($w, $h); + if ($u->createImagePreview($w, $h, $update)) { + $images_affected++; + } + } + } + + return $images_affected; + } + +} diff --git a/model/Tag.php b/model/Tag.php new file mode 100644 index 0000000..a8324f7 --- /dev/null +++ b/model/Tag.php @@ -0,0 +1,24 @@ +<?php + +class Tag extends Model implements Stringable { + + const DB_TABLE = 'tags'; + + public int $id; + public string $tag; + public int $postsCount; + public int $visiblePostsCount; + + public function getUrl(): string { + return '/'.$this->tag.'/'; + } + + public function getPostsCount(bool $is_admin): int { + return $is_admin ? $this->postsCount : $this->visiblePostsCount; + } + + public function __toString(): string { + return $this->tag; + } + +} diff --git a/model/Upload.php b/model/Upload.php new file mode 100644 index 0000000..586be24 --- /dev/null +++ b/model/Upload.php @@ -0,0 +1,152 @@ +<?php + +class Upload extends Model { + + const DB_TABLE = 'uploads'; + + public static array $ImageExtensions = ['jpg', 'jpeg', 'png', 'gif']; + public static array $VideoExtensions = ['mp4', 'ogg']; + + public int $id; + public string $randomId; + public int $ts; + public string $name; + public int $size; + public int $downloads; + public int $image; // TODO: remove + public int $imageW; + public int $imageH; + public string $note; + + public function getDirectory(): string { + global $config; + return $config['uploads_dir'].'/'.$this->randomId; + } + + public function getDirectUrl(): string { + global $config; + return 'https://'.$config['uploads_host'].'/'.$this->randomId.'/'.$this->name; + } + + public function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string { + global $config; + if ($w == $this->imageW && $this->imageH == $h) + return $this->getDirectUrl(); + + if ($retina) { + $w *= 2; + $h *= 2; + } + + return 'https://'.$config['uploads_host'].'/'.$this->randomId.'/p'.$w.'x'.$h.'.jpg'; + } + + // TODO remove? + public function incrementDownloads() { + $db = getDb(); + $db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id); + $this->downloads++; + } + + public function getSize(): string { + return sizeString($this->size); + } + + public function getMarkdown(): string { + if ($this->isImage()) { + $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.'}{/image}'; + } else if ($this->isVideo()) { + $md = '{video:'.$this->randomId.'}{/video}'; + } else { + $md = '{fileAttach:'.$this->randomId.'}{/fileAttach}'; + } + $md .= ' <!-- '.$this->name.' -->'; + return $md; + } + + public function setNote(string $note) { + $db = getDb(); + $db->query("UPDATE uploads SET note=? WHERE id=?", $note, $this->id); + } + + public function isImage(): bool { + return in_array(extension($this->name), self::$ImageExtensions); + } + + public function isVideo(): bool { + return in_array(extension($this->name), self::$VideoExtensions); + } + + public function getImageRatio(): float { + return $this->imageW / $this->imageH; + } + + public function getImagePreviewSize(?int $w = null, ?int $h = null): array { + if (is_null($w) && is_null($h)) + throw new Exception(__METHOD__.': both width and height can\'t be null'); + + if (is_null($h)) + $h = round($w / $this->getImageRatio()); + + if (is_null($w)) + $w = round($h * $this->getImageRatio()); + + return [$w, $h]; + } + + /** + * @param ?int $w + * @param ?int $h + * @param bool $update Whether to proceed if preview already exists + * @return bool + */ + public function createImagePreview(?int $w = null, ?int $h = null, bool $update = false): bool { + global $config; + + $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; + $updated = false; + + for ($mult = 1; $mult <= 2; $mult++) { + $dw = $w * $mult; + $dh = $h * $mult; + $dst = $config['uploads_dir'].'/'.$this->randomId.'/p'.$dw.'x'.$dh.'.jpg'; + + if (file_exists($dst)) { + if (!$update) + continue; + unlink($dst); + } + + $img = imageopen($orig); + imageresize($img, $dw, $dh, [255, 255, 255]); + imagejpeg($img, $dst, $mult == 1 ? 93 : 67); + imagedestroy($img); + + setperm($dst); + $updated = true; + } + + return $updated; + } + + /** + * @return int Number of deleted files + */ + public function deleteAllImagePreviews(): int { + global $config; + $dir = $config['uploads_dir'].'/'.$this->randomId; + $files = scandir($dir); + $deleted = 0; + foreach ($files as $f) { + if (preg_match('/^p(\d+)x(\d+)\.jpg$/', $f)) { + if (is_file($dir.'/'.$f)) + unlink($dir.'/'.$f); + else + logError('deleteAllImagePreviews: '.$dir.'/'.$f.' is not a file!'); + $deleted++; + } + } + return $deleted; + } + +} diff --git a/prepare_static.php b/prepare_static.php new file mode 100755 index 0000000..e3663f8 --- /dev/null +++ b/prepare_static.php @@ -0,0 +1,48 @@ +#!/usr/bin/env php8.1 +<?php + +function gethash(string $path): string { + return substr(sha1(file_get_contents($path)), 0, 8); +} + +function sassc(string $src_dir, string $dst_dir, string $file): int { + $cmd = 'sassc -t expanded '.escapeshellarg($src_dir.'/'.$file).' '.escapeshellarg($dst_dir.'/'.preg_replace('/\.scss$/', '.css', $file)); + exec($cmd, $output, $code); + return $code; +} + +require __DIR__.'/init.php'; +global $config; + +function build_static(): void { + $css_dir = ROOT.'/htdocs/css'; + $hashes = []; + + if (!file_exists($css_dir)) + mkdir($css_dir); + + $files = ['common-bundle.scss', 'admin.scss']; + foreach ($files as $file) { + if (sassc(ROOT.'/htdocs/scss', $css_dir, $file) != 0) + fwrite(STDERR, "error: could not compile $file\n"); + } + + foreach (['css', 'js'] as $type) { + $reldir = ROOT.'/htdocs/'; + $files = glob_recursive($reldir.$type.'/*.'.$type); + if (empty($files)) { + continue; + } + foreach ($files as $file) { + $name = preg_replace('/^'.preg_quote($reldir, '/').'/', '', $file); + $hashes[$name] = gethash($file); + } + } + + $scfg = "<?php\n\n"; + $scfg .= "return ".var_export($hashes, true).";\n"; + + file_put_contents(ROOT.'/config-static.php', $scfg); +} + +build_static();
\ No newline at end of file diff --git a/skin/admin.skin.php b/skin/admin.skin.php new file mode 100644 index 0000000..f03d7ce --- /dev/null +++ b/skin/admin.skin.php @@ -0,0 +1,344 @@ +<?php + +namespace skin\admin; + +use Stringable; + +// login page +// ---------- + +function login($ctx) { +$html = <<<HTML +<form action="/admin/login/" method="post" class="form-layout-h"> + <input type="hidden" name="token" value="{$ctx->csrf('adminlogin')}" /> + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('as_form_password')}:</div> + <div class="form-field"> + <input id="as_password" class="form-field-input" type="password" name="password" size="50" /> + </div> + </div> + <div class="form-field-wrap clearfix"> + <div class="form-field-label"></div> + <div class="form-field"> + <button type="submit">{$ctx->lang('submit')}</button> + </div> + </div> +</form> +HTML; + +$js = <<<JAVASCRIPT +ge('as_password').focus(); +JAVASCRIPT; + +return [$html, $js]; +} + + +// index page +// ---------- + +function index($ctx) { + return <<<HTML +<div class="admin-page"> +<!-- <a href="/admin/log/">Log</a><br/>--> + <a href="/admin/logout/?token={$ctx->csrf('logout')}">Sign out</a> +</div> +HTML; +} + + +// uploads page +// ------------ + +function uploads($ctx, $uploads, $error) { +return <<<HTML +{$ctx->if_true($error, $ctx->formError, $error)} + +<div class="blog-upload-form"> + <form action="/uploads/" method="post" enctype="multipart/form-data" class="form-layout-h"> + <input type="hidden" name="token" value="{$ctx->csrf('addupl')}" /> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('blog_upload_form_file')}:</div> + <div class="form-field"> + <input type="file" name="files[]" multiple> + </div> + </div> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('blog_upload_form_custom_name')}:</div> + <div class="form-field"> + <input type="text" name="name"> + </div> + </div> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('blog_upload_form_note')}:</div> + <div class="form-field"> + <input type="text" name="note" size="55"> + </div> + </div> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label"></div> + <div class="form-field"> + <input type="submit" value="Upload"> + </div> + </div> + </form> +</div> + +<div class="blog-upload-list"> + {$ctx->for_each($uploads, fn($u) => $ctx->uploadsItem( + id: $u->id, + name: $u->name, + direct_url: $u->getDirectUrl(), + note: $u->note, + addslashes_note: $u->note, + markdown: $u->getMarkdown(), + size: $u->getSize(), + ))} +</div> +HTML; +} + +function uploadsItem($ctx, $id, $direct_url, $note, $addslashes_note, $markdown, $name, $size) { +return <<<HTML +<div class="blog-upload-item"> + <div class="blog-upload-item-actions"> + <a href="javascript:void(0)" onclick="var mdel = ge('upload{$id}_md'); mdel.style.display = (mdel.style.display === 'none' ? 'block' : 'none')">{$ctx->lang('blog_upload_show_md')}</a> + | <a href="javascript:void(0)" onclick="BlogUploadList.submitNoteEdit('/uploads/edit_note/{$id}/?token={$ctx->csrf('editupl'.$id)}', prompt('Note:', '{$addslashes_note}'))">Edit note</a> + | <a href="/uploads/delete/{$id}/?token={$ctx->csrf('delupl'.$id)}" onclick="return confirm('{$ctx->lang('blog_upload_delete_confirmation')}')">{$ctx->lang('blog_upload_delete')}</a> + </div> + <div class="blog-upload-item-name"><a href="{$direct_url}">{$name}</a></div> + {$ctx->if_true($note, '<div class="blog-upload-item-note">'.$note.'</div>')} + <div class="blog-upload-item-info">{$size}</div> + <div class="blog-upload-item-md" id="upload{$id}_md" style="display: none"> + <input type="text" value="{$markdown}" onclick="this.select()" readonly size="30"> + </div> +</div> +HTML; +} + +function postForm($ctx, + string|Stringable $title, + string|Stringable $text, + string|Stringable $short_name, + string|Stringable $tags = '', + bool $is_edit = false, + $error_code = null, + ?bool $saved = null, + ?bool $visible = null, + string|Stringable|null $post_url = null, + ?int $post_id = null): array { +$form_url = !$is_edit ? '/write/' : $post_url.'edit/'; + +$html = <<<HTML +{$ctx->if_true($error_code, '<div class="form-error">'.$ctx->lang('err_blog_'.$error_code).'</div>')} +{$ctx->if_true($saved, '<div class="form-success">'.$ctx->lang('info_saved').'</div>')} +<table cellpadding="0" cellspacing="0" class="blog-write-table"> +<tr> + <td id="form_first_cell"> + <form class="blog-write-form form-layout-v" name="postForm" action="{$form_url}" method="post" enctype="multipart/form-data"> + <input type="hidden" name="token" value="{$ctx->if_then_else($is_edit, $ctx->csrf('editpost'.$post_id), $ctx->csrf('addpost'))}" /> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('blog_write_form_title')}</div> + <div class="form-field"> + <input class="form-field-input" type="text" name="title" value="{$title}" /> + </div> + </div> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('blog_write_form_text')}</div> + <div class="form-field"> + <textarea class="form-field-input" name="text" wrap="soft">{$text}</textarea><br/> + <a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{$ctx->lang('blog_write_form_toggle_wrap')}</a> + </div> + </div> + + <div class="form-field-wrap clearfix"> + <table class="blog-write-options-table"> + <tr> + <td> + <div class="clearfix"> + <div class="form-field-label">{$ctx->lang('blog_write_form_tags')}</div> + <div class="form-field"> + <input class="form-field-input" type="text" name="tags" value="{$tags}" /> + </div> + </div> + </td> + <td> + <div class="clearfix"> + <div class="form-field-label">{$ctx->lang('blog_write_form_options')}</div> + <div class="form-field"> + <label for="visible_cb"><input type="checkbox" id="visible_cb" name="visible"{$ctx->if_true($visible, ' checked="checked"')}> {$ctx->lang('blog_write_form_visible')}</label> + </div> + </div> + </td> + </tr> + <tr> + <td> + <div class="clearfix"> + <div class="form-field-label">{$ctx->lang('blog_write_form_short_name')}</div> + <div class="form-field"> + <input class="form-field-input" type="text" name="{$ctx->if_then_else($is_edit, 'new_short_name', 'short_name')}" value="{$short_name}" /> + </div> + </div> + </td> + <td> + <div class="clearfix"> + <div class="form-field-label"> </div> + <div class="form-field"> + <button type="submit" name="submit_btn"><b>{$ctx->lang('blog_write_form_submit_btn')}</b></button> + </div> + </div> + </td> + </tr> + </table> + </div> + </form> + <div id="form_placeholder"></div> + </td> + <td> + <div class="blog-write-form-preview post_text" id="preview_html"></div> + </td> +</tr> +</table> +HTML; + +$js_params = json_encode($is_edit + ? ['edit' => true, 'id' => $post_id] + : (object)[]); +$js = "AdminWriteForm.init({$js_params});"; + +return [$html, $js]; +} + + +function pageForm($ctx, + string|Stringable $title, + string|Stringable $text, + string|Stringable $short_name, + bool $is_edit = false, + $error_code = null, + ?bool $saved = null, + bool $visible = false): array { +$form_url = '/'.$short_name.'/'.($is_edit ? 'edit' : 'create').'/'; +$html = <<<HTML +{$ctx->if_true($error_code, '<div class="form-error">'.$ctx->lang('err_pages_'.$error_code).'</div>')} +{$ctx->if_true($saved, '<div class="form-success">'.$ctx->lang('info_saved').'</div>')} +<table cellpadding="0" cellspacing="0" class="blog-write-table"> +<tr> + <td id="form_first_cell"> + <form class="blog-write-form form-layout-v" name="pageForm" action="{$form_url}" method="post"> + <input type="hidden" name="token" value="{$ctx->if_then_else($is_edit, $ctx->csrf('editpage'.$short_name), $ctx->csrf('addpage'))}" /> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('pages_write_form_title')}</div> + <div class="form-field"> + <input class="form-field-input" type="text" name="title" value="{$title}" /> + </div> + </div> + + <div class="form-field-wrap clearfix"> + <div class="form-field-label">{$ctx->lang('pages_write_form_text')}</div> + <div class="form-field"> + <textarea class="form-field-input" name="text" wrap="soft">{$text}</textarea><br/> + <a class="blog-write-form-toggle-link" id="toggle_wrap" href="">{$ctx->lang('pages_write_form_toggle_wrap')}</a> + </div> + </div> + + {$ctx->if_then_else($is_edit, + fn() => $ctx->pageFormEditOptions($short_name, $visible), + fn() => $ctx->pageFormAddOptions($short_name))} + + </form> + <div id="form_placeholder"></div> + </td> + <td> + <div class="blog-write-form-preview post_text" id="preview_html"></div> + </td> +</tr> +</table> +HTML; + +$js_params = json_encode(['pages' => true, 'edit' => $is_edit]); +$js = <<<JAVASCRIPT +AdminWriteForm.init({$js_params}); +JAVASCRIPT; + +return [$html, $js]; +} + +function pageFormEditOptions($ctx, $short_name, $visible) { +return <<<HTML +<div class="form-field-wrap clearfix"> + <table class="blog-write-options-table"> + <tr> + <td> + <div class="clearfix"> + <div class="form-field-label">{$ctx->lang('pages_write_form_short_name')}</div> + <div class="form-field"> + <input class="form-field-input" type="text" name="new_short_name" value="{$short_name}" /> + </div> + </div> + </td> + <td> + <div class="clearfix"> + <div class="form-field-label">{$ctx->lang('pages_write_form_options')}</div> + <div class="form-field"> + <label for="visible_cb"><input type="checkbox" id="visible_cb" name="visible"{$ctx->if_true($visible, ' checked="checked"')}> {$ctx->lang('pages_write_form_visible')}</label> + </div> + </div> + </td> + </tr> + <tr> + <td rowspan="2"> + <button type="submit" name="submit_btn"><b>{$ctx->lang('pages_write_form_submit_btn')}</b></button> + </td> + </tr> + </table> +</div> +HTML; +} + +function pageFormAddOptions($ctx, $short_name) { +return <<<HTML +<div class="form-field-wrap clearfix"> + <div class="form-field-label"></div> + <div class="form-field"> + <button type="submit" name="submit_btn"><b>{$ctx->lang('pages_write_form_submit_btn')}</b></button> + </div> +</div> +<input name="short_name" value="{$short_name}" type="hidden" /> +HTML; +} + +function pageNew($ctx, $short_name) { +return <<<HTML +<div class="page"> + <div class="empty"> + <a href="/{$short_name}/create/">{$ctx->lang('pages_create')}</a> + </div> +</div> +HTML; + +} + +// misc +function formError($ctx, $error) { +return <<<HTML +<div class="form-error">{$ctx->lang('error')}: {$error}</div> +HTML; +} + +function markdownPreview($ctx, $unsafe_html, $title) { +return <<<HTML +<div class="blog-post"> + {$ctx->if_true($title, '<div class="blog-post-title"><h1>'.$title.'</h1></div>')} + <div class="blog-post-text">{$unsafe_html}</div> +</div> +HTML; + +}
\ No newline at end of file diff --git a/skin/base.skin.php b/skin/base.skin.php new file mode 100644 index 0000000..b0ebac3 --- /dev/null +++ b/skin/base.skin.php @@ -0,0 +1,190 @@ +<?php + +namespace skin\base; + +function layout($ctx, $title, $unsafe_body, $static, $meta, $js, $opts, $exec_time, $unsafe_lang) { +return <<<HTML +<!doctype html> +<html lang="en"> + <head> + <meta http-equiv="content-type" content="text/html; charset=utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <link rel="shortcut icon" href="/favicon.ico?4" type="image/x-icon"> + <link rel="alternate" type="application/rss+xml" href="/feed.rss"> + <title>{$title}</title> + {$ctx->renderMeta($meta)} + {$ctx->renderStatic($static)} + </head> + <body{$ctx->if_true($opts['full_width'], ' class="full-width"')}> + {$ctx->renderHeader(renderLogo($ctx, $opts['logo_path_map'], $opts['logo_link_map']))} + <div class="page-content base-width"> + <div class="page-content-inner">{$unsafe_body}</div> + </div> + {$ctx->if_true($js != '' || !empty($lang) || $opts['dynlogo_enabled'], + $ctx->renderScript, $js, $unsafe_lang, $opts['dynlogo_enabled'])} + </body> +</html> +<!-- exec time: {$exec_time}s --> +HTML; +} + +function renderScript($ctx, $unsafe_js, $unsafe_lang, $enable_dynlogo) { +return <<<HTML +<script type="text/javascript"> +{$ctx->if_true($unsafe_js, '(function(){'.$unsafe_js.'})();')} +{$ctx->if_true($unsafe_lang, 'extend(__lang, '.$unsafe_lang.');')} +{$ctx->if_true($enable_dynlogo, 'DynamicLogo.init();')} +</script> +HTML; +} + +function renderMeta($ctx, $meta) { + if (empty($meta)) + return ''; + return implode('', array_map(function(array $item): string { + $s = '<meta'; + foreach ($item as $k => $v) + $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"'; + $s .= '>'; + return $s; + }, $meta)); +} + +function renderStatic($ctx, $static) { + global $config; + $html = []; + foreach ($static as $name) { + // list($name, $options) = $item; + $version = $config['is_dev'] ? time() : $config['static'][substr($name, 1)] ?? 'notfound'; + if (str_ends_with($name, '.js')) + $html[] = jsLink($name, $version); + else if (str_ends_with($name, '.css')) + $html[] = cssLink($name, $version/*, $options*/); + } + return implode("\n", $html); +} + +function renderHeader($ctx, $unsafe_logo_html) { + return <<<HTML +<div class="head base-width"> + <div class="head-inner clearfix"> + <div class="head-logo">{$unsafe_logo_html}</div> + <div class="head-items clearfix"> + <a class="head-item" href="/"><span><span>blog</span></span></a> + <a class="head-item" href="/projects/"><span><span>projects</span></span></a> + <a class="head-item" href="https://git.ch1p.io/?s=idle"><span><span>git</span></span></a> + <a class="head-item" href="/misc/"><span><span>misc</span></span></a> + <a class="head-item" href="/contacts/"><span><span>contacts</span></span></a> + {$ctx->if_admin('<a class="head-item" href="/admin/"><span><span>admin</span></span></a>')} + </div> + </div> +</div> +HTML; +} + +// TODO rewrite this fcking crap +function renderLogo($ctx, array $path_map = [], array $link_map = []): string { + $uri = \RequestDispatcher::path(); + + if (!\admin::isAdmin()) { + $prompt_sign = '<span class="head-logo-dolsign">$</span>'; + } else { + $prompt_sign = '<span class="head-logo-dolsign is_root">#</span>'; + } + + if ($uri == '/') { + $html = '<span class="head-logo-path">/home/'.$ctx->lang('ch1p').'</span> '.$prompt_sign; + } else { + $uri_len = strlen($uri); + + $html = '<a href="/" id="head_dyn_link">'; + $close_tags = 0; + + $path_parts = []; + $path_links = []; + + $last_pos = 0; + $cur_path = ''; + while ($last_pos < $uri_len) { + $first = $last_pos === 0; + $end = false; + + $pos = strpos($uri, '/', $last_pos); + if ($pos === false || $pos == $uri_len-1) { + $pos = $uri_len-1; + $end = true; + } + + $part = substr($uri, $last_pos, $pos - $last_pos + 1); + $cur_path .= $part; + + if ($end) { + if (substr($part, -1) == '/') + $part = substr($part, 0, strlen($part)-1); + $cur_path = '/'; + $html .= str_repeat('</span>', $close_tags-1); + $close_tags = 1; + } + + $span_class = 'head-logo-path'; + if ($first) { + $span_class .= ' alwayshover'; + } else if ($end) { + $span_class .= ' neverhover'; + } + + $html .= '<span class="'.$span_class.'" data-url="$[['.count($path_links).']]">${{'.count($path_parts).'}}'; + $path_parts[] = ($first ? '~' : '').$part; + $path_links[] = $cur_path; + + $last_pos = $pos + 1; + $close_tags++; + } + $html .= str_repeat('</span>', $close_tags).' '.$prompt_sign.' <span class="head-logo-cd">cd <span id="head_cd_text">~</span> <span class="head-logo-enter"><span class="head-logo-enter-icon"></span>Enter</span></span></a>'; + + for ($i = count($path_parts)-1, $j = 0; $i >= 0; $i--, $j++) { + if (isset($path_map[$j])) { + $tmp = htmlescape(strtrim($path_map[$j], 40, $trimmed)); + if ($trimmed) + $tmp .= '…'; + $tmp_html = '<span class="head-logo-path-mapped">'.$tmp.'</span>'; + if ($j > 0) + $tmp_html .= '/'; + $html = str_replace_once('${{'.$i.'}}', $tmp_html, $html); + } else { + $html = str_replace_once('${{'.$i.'}}', $path_parts[$i], $html); + } + + if (isset($link_map[$j])) { + $html = str_replace_once('$[['.$i.']]', $link_map[$j], $html); + } else { + $html = str_replace_once('$[['.$i.']]', $path_links[$i], $html); + } + } + } + + return $html; +} + +function jsLink(string $name, $version = null): string { + if ($version !== null) + $name .= '?'.$version; + return '<script src="'.$name.'" type="text/javascript"></script>'; +} + +function cssLink(string $name, $version = null/*, $options = null*/): string { + global $config; + if ($config['is_dev']) { + $bname = basename($name); + if (($pos = strrpos($bname, '.'))) + $bname = substr($bname, 0, $pos); + $href = '/sass.php?name='.urlencode($bname); + } else { + $href = $name.($version !== null ? '?'.$version : ''); + } + $s = '<link rel="stylesheet" type="text/css" href="'.$href.'"'; + // if (!is_null($options)) + // $s .= ' media="'.$options.'"'; + $s .= '>'; + return $s; +}
\ No newline at end of file diff --git a/skin/error.skin.php b/skin/error.skin.php new file mode 100644 index 0000000..b0925d3 --- /dev/null +++ b/skin/error.skin.php @@ -0,0 +1,40 @@ +<?php + +namespace skin\error; + +use Stringable; + +function forbidden($ctx, $message) { + return $ctx->common(403, 'Forbidden', $message); +} + +function not_found($ctx, $message) { + return $ctx->common(404, 'Not Found', $message); +} + +function unauthorized($ctx, $message) { + return $ctx->common(401, 'Unauthorized', $message); +} + +function not_implemented($ctx, $message) { + return $ctx->common(501, 'Not Implemented', $message); +} + +function common($ctx, + int $code, + string|Stringable $title, + string|Stringable|null $message = null) { +return <<<HTML +<html> + <head><title>$code $title</title></head> + <body> + <center><h1>$code $title</h1></center> + {$ctx->if_true($message, + '<hr><p align="center">'.$message.'</p>' + )} + + </body> +</html> +HTML; + +}
\ No newline at end of file diff --git a/skin/main.skin.php b/skin/main.skin.php new file mode 100644 index 0000000..40813b9 --- /dev/null +++ b/skin/main.skin.php @@ -0,0 +1,195 @@ +<?php + +namespace skin\main; + +// index page +// ---------- + +function index($ctx, array $posts, array $tags) { + return empty($posts) ? $ctx->indexEmtpy() : $ctx->indexBlog($posts); +} + +function indexEmtpy($ctx): string { +return <<<HTML +<div class="empty"> + {$ctx->lang('blog_no')} + {$ctx->if_admin('<a href="/blog/write/">'.$ctx->lang('write').'</a>')} +</div> +HTML; +} + +function indexBlog($ctx, array $posts): string { +return <<<HTML +<div class="blog-list"> + <div class="blog-list-title"> + all posts + {$ctx->if_admin( + '<span> + <a href="/write/">new</a> + <a href="/uploads/">uploads</a> + </span>' + )} + </div> + {$ctx->indexPostsTable($posts)} +</div> +HTML; +} + +function indexPostsTable($ctx, array $posts): string { +$ctx->year = 3000; +return <<<HTML +<div class="blog-list-table-wrap"> + <table class="blog-list-table" width="100%" cellspacing="0" cellpadding="0"> + {$ctx->for_each($posts, fn($post) => $ctx->indexPostRow( + $post->getYear(), + $post->visible, + $post->getDate(), + $post->getUrl(), + $post->title + ))} + </table> +</div> +HTML; +} + +function indexPostRow($ctx, $year, $is_visible, $date, $url, $title): string { +return <<<HTML +{$ctx->if_true($ctx->year > $year, $ctx->indexYearLine, $year)} +<tr class="blog-item-row{$ctx->if_not($is_visible, ' ishidden')}"> + <td class="blog-item-date-cell"> + <span class="blog-item-date">{$date}</span> + </td> + <td class="blog-item-title-cell"> + <a class="blog-item-title" href="{$url}">{$title}</a> + </td> +</tr> +HTML; +} + +function indexYearLine($ctx, $year): string { +$ctx->year = $year; +return <<<HTML +<tr class="blog-item-row-year"> + <td class="blog-item-date-cell"><span>{$year}</span></td> + <td></td> +</tr> +HTML; +} + + +// contacts page +// ------------- + +function contacts($ctx, $email) { +return <<<HTML +<table class="contacts" cellpadding="0" cellspacing="0"> + <tr> + <td class="wide" colspan="2" style="line-height: 170%; padding-bottom: 18px;"> + Feel free to contact me by any of the following means: + </td> + </tr> + <tr> + <td class="label">Email:</td> + <td class="value"> + <a href="mailto:{$email}">{$email}</a> + <div class="note">Please use <a href="/openpgp-pubkey.txt?1">PGP</a>.</div> + </td> + </tr> + <tr> + <td class="label">Telegram:</td> + <td class="value"> + <a href="https://t.me/eacces">@eacces</a> + <div class="note">Please use Secret Chats.</div> + </td> + </tr> + <tr> + <td class="label">Libera.Chat:</td> + <td class="value"><span>ch1p</span></td> + </tr> +</table> +HTML; + +} + + +// any page +// -------- + +function page($ctx, $page_url, $short_name, $unsafe_html) { +return <<<HTML +<div class="page"> + {$ctx->if_admin($ctx->pageAdminLinks, $page_url, $short_name)} + <div class="blog-post-text">{$unsafe_html}</div> +</div> +HTML; +} + +function pageAdminLinks($ctx, $url, $short_name) { +return <<<HTML +<div class="page-edit-links"> + <a href="{$url}edit/">{$ctx->lang('edit')}</a> + <a href="{$url}delete/?token={$ctx->csrf('delpage'.$short_name)}" onclick="return confirm('{$ctx->lang('pages_page_delete_confirmation')}')">{$ctx->lang('delete')}</a> +</div> +HTML; + +} + + +// post page +// --------- + +function post($ctx, $id, $title, $unsafe_html, $date, $visible, $url, $tags, $email, $urlencoded_reply_subject) { +return <<<HTML +<div class="blog-post"> + <div class="blog-post-title"> + <h1>{$title}</h1> + <div class="blog-post-date"> + {$ctx->if_not($visible, '<b>'.$ctx->lang('blog_post_hidden').'</b> |')} + {$date} + {$ctx->if_admin($ctx->postAdminLinks, $url, $id)} + </div> + <div class="blog-post-tags clearfix"> + {$ctx->for_each($tags, fn($tag) => $ctx->postTag($tag->getUrl(), $tag->tag))} + </div> + </div> + <div class="blog-post-text">{$unsafe_html}</div> +</div> +<div class="blog-post-comments"> + {$ctx->langRaw('blog_comments_text', $email, $urlencoded_reply_subject)} +</div> +HTML; +} + +function postAdminLinks($ctx, $url, $id) { +return <<<HTML +<a href="{$url}edit/">{$ctx->lang('edit')}</a> +<a href="{$url}delete/?token={$ctx->csrf('delpost'.$id)}" onclick="return confirm('{$ctx->lang('blog_post_delete_confirmation')}')">{$ctx->lang('delete')}</a> +HTML; +} + +function postTag($ctx, $url, $name) { +return <<<HTML +<a href="{$url}"><span>#</span>{$name}</a> +HTML; + +} + + +// tag page +// -------- + +function tag($ctx, $count, $posts, $tag) { +if (!$count) + return <<<HTML + <div class="empty"> + {$ctx->lang('blog_tag_not_found')} + </div> +HTML; + +return <<<HTML +<div class="blog-list"> + <div class="blog-list-title">#{$tag}</div> + {$ctx->indexPostsTable($posts)} +</div> +HTML; +}
\ No newline at end of file diff --git a/skin/markdown.skin.php b/skin/markdown.skin.php new file mode 100644 index 0000000..02d3a0f --- /dev/null +++ b/skin/markdown.skin.php @@ -0,0 +1,43 @@ +<?php + +namespace skin\markdown; + +function fileupload($ctx, $name, $direct_url, $note, $size) { +return <<<HTML +<div class="md-file-attach"> + <span class="md-file-attach-icon"></span><a href="{$direct_url}">{$name}</a> + {$ctx->if_true($note, '<span class="md-file-attach-note">'.$note.'</span>')} + <span class="md-file-attach-size">{$size}</span> +</div> +HTML; +} + +function image($ctx, + // options + $align, $nolabel, $w, $padding_top, + // image data + $direct_url, $url, $note) { +return <<<HTML +<div class="md-image align-{$align}"> + <div class="md-image-wrap"> + <a href="{$direct_url}"> + <div style="background: #f2f2f2 url('{$url}') no-repeat; background-size: contain; width: {$w}px; padding-top: {$padding_top}%;"></div> + </a> + {$ctx->if_true( + $note != '' && !$nolabel, + '<div class="md-image-note">'.$note.'</div>' + )} + </div> +</div> +HTML; +} + +function video($ctx, $url, $w, $h) { +return <<<HTML +<div class="md-video"> + <div class="md-video-wrap"> + <video src="{$url}" controls{$ctx->if_true($w, ' width="'.$w.'"')}{$ctx->if_true($h, ' height="'.$h.'"')}></video> + </div> +</div> +HTML; +}
\ No newline at end of file diff --git a/skin/rss.skin.php b/skin/rss.skin.php new file mode 100644 index 0000000..0806182 --- /dev/null +++ b/skin/rss.skin.php @@ -0,0 +1,29 @@ +<?php + +namespace skin\rss; + +function atom($ctx, $title, $link, $rss_link, $items) { +return <<<HTML +<?xml version="1.0" encoding="UTF-8"?> +<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"> + <channel> + <title>{$title}</title> + <link>{$link}</link> + <description/> + <atom:link href="{$rss_link}" rel="self" type="application/rss+xml"/> + {$ctx->for_each($items, fn($item) => $ctx->item(...$item))} + </channel> +</rss> +HTML; +} + +function item($ctx, $title, $link, $pub_date, $description) { +return <<<HTML +<item> + <title>{$title}</title> + <link>{$link}</link> + <pubDate>{$pub_date}</pubDate> + <description>{$description}</description> +</item> +HTML; +}
\ No newline at end of file |