summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore11
-rw-r--r--README21
-rwxr-xr-xcli_util.php75
-rw-r--r--composer.json14
-rw-r--r--composer.lock352
-rw-r--r--config.php28
-rwxr-xr-xdeploy.sh41
-rw-r--r--engine/AjaxErrorResponse.php9
-rw-r--r--engine/AjaxOkResponse.php9
-rw-r--r--engine/AjaxResponse.php13
-rw-r--r--engine/InputType.php8
-rw-r--r--engine/LangAccess.php8
-rw-r--r--engine/LangData.php108
-rw-r--r--engine/Model.php240
-rw-r--r--engine/RedirectResponse.php11
-rw-r--r--engine/RequestDispatcher.php79
-rw-r--r--engine/RequestHandler.php56
-rw-r--r--engine/Response.php28
-rw-r--r--engine/Router.php165
-rw-r--r--engine/Skin.php57
-rw-r--r--engine/SkinBase.php22
-rw-r--r--engine/SkinContext.php116
-rw-r--r--engine/SkinString.php23
-rw-r--r--engine/SkinStringModificationType.php9
-rw-r--r--engine/ansi.php34
-rw-r--r--engine/csrf.php22
-rw-r--r--engine/database/CommonDatabase.php107
-rw-r--r--engine/database/MySQLConnection.php79
-rw-r--r--engine/database/SQLiteConnection.php90
-rw-r--r--engine/exceptions/ForbiddenException.php9
-rw-r--r--engine/exceptions/NotFoundException.php9
-rw-r--r--engine/exceptions/NotImplementedException.php9
-rw-r--r--engine/exceptions/UnauthorizedException.php9
-rw-r--r--engine/logging.php187
-rw-r--r--functions.php298
-rw-r--r--gimp/contact.xcfbin0 -> 4354 bytes
-rw-r--r--gimp/favicon.xcfbin0 -> 1881 bytes
-rw-r--r--handler/Auto.php106
-rw-r--r--handler/Contacts.php16
-rw-r--r--handler/Index.php20
-rw-r--r--handler/PostId.php20
-rw-r--r--handler/ProjectsHtml.php11
-rw-r--r--handler/RSS.php32
-rw-r--r--handler/admin/AdminRequestHandler.php20
-rw-r--r--handler/admin/AutoAddOrEdit.php97
-rw-r--r--handler/admin/AutoDelete.php34
-rw-r--r--handler/admin/AutoEdit.php127
-rw-r--r--handler/admin/Index.php13
-rw-r--r--handler/admin/Login.php31
-rw-r--r--handler/admin/Logout.php17
-rw-r--r--handler/admin/MarkdownPreview.php22
-rw-r--r--handler/admin/PageAdd.php66
-rw-r--r--handler/admin/PostAdd.php68
-rw-r--r--handler/admin/UploadDelete.php25
-rw-r--r--handler/admin/UploadEditNote.php25
-rw-r--r--handler/admin/Uploads.php73
-rw-r--r--htdocs/ahrefs_73b56e4c8d3bca4f4712e71f638a499c464e3faf55dd02ed02dbb5649850b8f31
-rw-r--r--htdocs/favicon.icobin0 -> 1150 bytes
-rw-r--r--htdocs/img/attachment.svg1
-rw-r--r--htdocs/img/contact.gifbin0 -> 164 bytes
-rw-r--r--htdocs/img/contact@2x.gifbin0 -> 386 bytes
-rw-r--r--htdocs/img/enter.svg1
-rw-r--r--htdocs/index.php28
-rw-r--r--htdocs/js/admin.js193
-rw-r--r--htdocs/js/common.js392
-rw-r--r--htdocs/openpgp-pubkey.txt51
-rw-r--r--htdocs/sass.php48
-rw-r--r--htdocs/scss/admin.scss3
-rw-r--r--htdocs/scss/blog.scss383
-rw-r--r--htdocs/scss/common-bundle.scss9
-rw-r--r--htdocs/scss/common.scss415
-rw-r--r--htdocs/scss/form.scss59
-rw-r--r--htdocs/scss/hljs.scss1
-rw-r--r--htdocs/scss/hljs/github.scss99
-rw-r--r--htdocs/scss/mobile.scss41
-rw-r--r--htdocs/scss/pages.scss14
-rw-r--r--htdocs/scss/vars.scss71
-rw-r--r--htdocs/yandex_3512181a57932602.html6
-rw-r--r--init.php69
-rw-r--r--lang/en.php99
-rw-r--r--lib/MyParsedown.php209
-rw-r--r--lib/admin.php57
-rw-r--r--lib/cli.php70
-rw-r--r--lib/config.php46
-rw-r--r--lib/markup.php30
-rw-r--r--lib/pages.php32
-rw-r--r--lib/posts.php188
-rw-r--r--lib/uploads.php145
-rw-r--r--model/Page.php44
-rw-r--r--model/Post.php185
-rw-r--r--model/Tag.php24
-rw-r--r--model/Upload.php152
-rwxr-xr-xprepare_static.php48
-rw-r--r--skin/admin.skin.php344
-rw-r--r--skin/base.skin.php190
-rw-r--r--skin/error.skin.php40
-rw-r--r--skin/main.skin.php195
-rw-r--r--skin/markdown.skin.php43
-rw-r--r--skin/rss.skin.php29
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
diff --git a/README b/README
new file mode 100644
index 0000000..7c866f5
--- /dev/null
+++ b/README
@@ -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
new file mode 100644
index 0000000..b60e9d6
--- /dev/null
+++ b/gimp/contact.xcf
Binary files differ
diff --git a/gimp/favicon.xcf b/gimp/favicon.xcf
new file mode 100644
index 0000000..e8ae821
--- /dev/null
+++ b/gimp/favicon.xcf
Binary files differ
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
new file mode 100644
index 0000000..d5ff579
--- /dev/null
+++ b/htdocs/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000..f4695c1
--- /dev/null
+++ b/htdocs/img/contact.gif
Binary files differ
diff --git a/htdocs/img/contact@2x.gif b/htdocs/img/contact@2x.gif
new file mode 100644
index 0000000..ab79cd3
--- /dev/null
+++ b/htdocs/img/contact@2x.gif
Binary files differ
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">&nbsp;</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 .= '&#8230;';
+ $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