diff --git a/README.md b/README.md index 75964fd..bd1dbd3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ This is a source code of 4in1.ws web site. - posts and pages are written in Markdown: - supports syntax highlighting in code blocks - supports embedding of uploaded files and image resizing - - tags - rss feed - dark theme - ultra fast on backend: diff --git a/engine/mysql.php b/engine/mysql.php index bab8048..9d3a54c 100644 --- a/engine/mysql.php +++ b/engine/mysql.php @@ -49,6 +49,8 @@ class mysql { $count = 0; foreach ($fields as $k => $v) { $names[] = $k; + if (is_bool($v)) + $v = (int)$v; $values[] = $v; $count++; } @@ -118,9 +120,14 @@ class mysql { 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_as_string(1)); + $q = false; + try { + $q = $this->link->query($sql); + if (!$q) + logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1)); + } catch (mysqli_sql_exception $e) { + logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtrace_as_string(1)); + } return $q; } diff --git a/engine/request.php b/engine/request.php index e67d4fb..2992fbf 100644 --- a/engine/request.php +++ b/engine/request.php @@ -42,6 +42,7 @@ enum HTTPCode: int { case MovedPermanently = 301; case Found = 302; + case InvalidRequest = 400; case Unauthorized = 401; case NotFound = 404; case Forbidden = 403; @@ -51,22 +52,33 @@ enum HTTPCode: int { } function http_error(HTTPCode $http_code, string $message = ''): void { - $ctx = new SkinContext('\\skin\\error'); - $http_message = preg_replace('/(?name); - $html = $ctx->http_error($http_code->value, $http_message, $message); - http_response_code($http_code->value); - echo $html; - exit; + if (is_xhr_request()) { + $data = []; + if ($message != '') + $data['message'] = $message; + ajax_error((object)$data, $http_code->value); + } else { + $ctx = new SkinContext('\\skin\\error'); + $http_message = preg_replace('/(?name); + $html = $ctx->http_error($http_code->value, $http_message, $message); + http_response_code($http_code->value); + echo $html; + exit; + } } function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): void { if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found])) internal_server_error('invalid http code'); + if (is_xhr_request()) { + ajax_ok(['redirect' => $url]); + } http_response_code($code->value); header('Location: '.$url); exit; } +function invalid_request(string $message = '') { http_error(HTTPCode::InvalidRequest, $message); } function internal_server_error(string $message = '') { http_error(HTTPCode::InternalServerError, $message); } function not_found(string $message = '') { http_error(HTTPCode::NotFound, $message); } function forbidden(string $message = '') { http_error(HTTPCode::Forbidden, $message); } @@ -83,6 +95,10 @@ function ajax_response(mixed $data, int $code = 200): void { exit; } +function ensure_admin() { + if (!is_admin()) + forbidden(); +} abstract class request_handler { function __construct() { @@ -90,7 +106,6 @@ abstract class request_handler { 'css/common.css', 'js/common.js' ); - add_skin_strings_re('/^theme_/'); } function before_dispatch(string $http_method, string $action) {} @@ -124,8 +139,12 @@ enum InputVarType: string { case ENUM = 'e'; } -function input(string $input): array { +function input(string $input, array $options = []): array { global $RouterInput; + + $options = array_merge(['trim' => false], $options); + $strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val; + $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); $ret = []; foreach ($input as $var) { @@ -133,7 +152,7 @@ function input(string $input): array { $enum_default = null; $pos = strpos($var, ':'); - if ($pos !== false) { + if ($pos === 1) { // only one-character type specifiers are supported $type = substr($var, 0, $pos); $rest = substr($var, $pos + 1); @@ -177,14 +196,14 @@ function input(string $input): array { $val = $_GET[$name]; } if (is_array($val)) - $val = implode($val); + $val = $strval(implode($val)); $ret[] = match($vartype) { InputVarType::INTEGER => (int)$val, InputVarType::FLOAT => (float)$val, InputVarType::BOOLEAN => (bool)$val, - InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : (string)$val, - default => (string)$val + InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val), + default => $strval($val) }; } return $ret; diff --git a/engine/skin.php b/engine/skin.php index f11eada..45f0bd0 100644 --- a/engine/skin.php +++ b/engine/skin.php @@ -12,6 +12,9 @@ $SkinState = new class { 'dynlogo_enabled' => true, 'logo_path_map' => [], 'logo_link_map' => [], + 'is_index' => false, + 'head_section' => null, + 'articles_lang' => null, ]; public array $static = []; }; @@ -38,10 +41,14 @@ function render($f, ...$vars): void { $lang[$key] = lang($key); $lang = !empty($lang) ? json_encode($lang, JSON_UNESCAPED_UNICODE) : ''; + $title = $SkinState->title; + if (!$SkinState->options['is_index']) + $title = lang('4in1').' - '.$title; + $html = $layout_ctx->layout( static: $SkinState->static, theme: $theme, - title: $SkinState->title, + title: $title, opts: $SkinState->options, js: $js, meta: $SkinState->meta, @@ -182,6 +189,27 @@ class SkinContext { return csrf_get($key); } + function bc(array $items, ?string $style = null): string { + $buf = implode(array_map(function(array $i): string { + $buf = ''; + $has_url = array_key_exists('url', $i); + + if ($has_url) + $buf .= ''; + else + $buf .= ''; + $buf .= htmlescape($i['text']); + + if ($has_url) + $buf .= ' '; + else + $buf .= ''; + + return $buf; + }, $items)); + return '
'.$buf.'
'; + } + protected function _if_condition($condition, $callback, ...$args) { if (is_string($condition) || $condition instanceof Stringable) $condition = (string)$condition !== ''; @@ -234,7 +262,7 @@ class SkinString implements Stringable { return match ($this->modType) { SkinStringModificationType::HTML => htmlescape($this->string), SkinStringModificationType::URL => urlencode($this->string), - SkinStringModificationType::JSON => json_encode($this->string, JSON_UNESCAPED_UNICODE), + SkinStringModificationType::JSON => jsonEncode($this->string), SkinStringModificationType::ADDSLASHES => addslashes($this->string), default => $this->string, }; diff --git a/functions.php b/functions.php index 84d3e4b..c139110 100644 --- a/functions.php +++ b/functions.php @@ -23,7 +23,7 @@ function verify_hostname(?string $host = null): void { } } - if (is_cli() && str_ends_with(dirname(__DIR__), 'www-dev')) + if (is_cli() && str_ends_with(__DIR__, 'www-dev')) $config['is_dev'] = true; } diff --git a/handler/AdminHandler.php b/handler/AdminHandler.php index 3e2e080..227631b 100644 --- a/handler/AdminHandler.php +++ b/handler/AdminHandler.php @@ -5,6 +5,7 @@ class AdminHandler extends request_handler { function __construct() { parent::__construct(); add_static('css/admin.css', 'js/admin.js'); + add_skin_strings(['error']); } function before_dispatch(string $http_method, string $action) { @@ -13,8 +14,10 @@ class AdminHandler extends request_handler { } function GET_index() { + $admin_info = admin_current_info(); set_title('$admin_title'); - render('admin/index'); + render('admin/index', + admin_login: $admin_info['login']); } function GET_login() { @@ -26,19 +29,15 @@ class AdminHandler extends request_handler { function POST_login() { csrf_check('adminlogin'); - $password = $_POST['password'] ?? ''; - $valid = admin_check_password($password); - if ($valid) { - admin_log_auth(); - admin_set_cookie(); - redirect('/admin/'); - } - forbidden(); + list($login, $password) = input('login, password'); + admin_auth($login, $password) + ? redirect('/admin/') + : forbidden(); } function GET_logout() { csrf_check('logout'); - admin_unset_cookie(); + admin_logout(); redirect('/admin/login/', HTTPCode::Found); } @@ -157,26 +156,15 @@ class AdminHandler extends request_handler { $error_code = 'no_text'; } - if ($error_code) { - return $this->_get_pageAdd( - name: $name, - title: $title, - text: $text, - error_code: $error_code - ); - } + if ($error_code) + ajax_error(['code' => $error_code]); if (!pages::add([ 'short_name' => $name, 'title' => $title, 'md' => $text ])) { - return $this->_get_pageAdd( - name: $name, - title: $title, - text: $text, - error_code: 'db_err' - ); + ajax_error(['code' => 'db_err']); } $page = pages::getByName($name); @@ -184,97 +172,230 @@ class AdminHandler extends request_handler { } function GET_post_add() { - return $this->_get_postAdd(); + add_skin_strings_re('/^(err_)?blog_/'); + set_title('$blog_write'); + static::make_wide(); + + $js_texts = []; + foreach (PostLanguage::cases() as $pl) { + $js_texts[$pl->value] = [ + 'title' => '', + 'md' => '', + 'toc' => false, + ]; + } + + render('admin/postForm', + title: '', + text: '', + langs: PostLanguage::cases(), + short_name: '', + js_texts: $js_texts, + lang: PostLanguage::getDefault()->value); } function POST_post_add() { - csrf_check('addpost'); + if (!is_xhr_request()) + invalid_request(); - list($text, $title, $tags, $visible, $short_name) - = input('text, title, tags, b:visible, short_name'); - $tags = tags::splitString($tags); + csrf_check('post_add'); + + list($visibility_enabled, $short_name, $langs, $date) + = input('b:visible, short_name, langs, date'); + + self::_postEditValidateCommonData($date); + + $lang_data = []; + $at_least_one_lang_is_written = false; + foreach (PostLanguage::cases() as $lang) { + list($title, $text, $toc_enabled) = input("title:{$lang->value}, text:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]); + if ($title !== '' && $text !== '') { + $lang_data[$lang->value] = [$title, $text, $toc_enabled]; + $at_least_one_lang_is_written = true; + } + } $error_code = null; - if (!$title) { - $error_code = 'no_title'; - } else if (!$text) { + if (!$at_least_one_lang_is_written) { $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( - title: $title, - text: $text, - tags: $tags, - short_name: $short_name, - error_code: $error_code - ); + ajax_error(['code' => $error_code]); - $id = posts::add([ - 'title' => $title, - 'md' => $text, - 'visible' => (int)$visible, + $post = posts::add([ + 'visible' => $visibility_enabled, 'short_name' => $short_name, + 'date' => $date ]); - if (!$id) - $this->_get_postAdd( - title: $title, - text: $text, - tags: $tags, - short_name: $short_name, - error_code: 'db_err' - ); + if (!$post) + ajax_error(['code' => 'db_err', 'message' => 'failed to add post']); - // set tags - $post = posts::get($id); - $tag_ids = array_values(tags::getTags($tags)); - $post->setTagIds($tag_ids); + // add texts + foreach ($lang_data as $lang => $data) { + list($title, $text, $toc_enabled) = $data; + if (!$post->addText( + lang: PostLanguage::from($lang), + title: $title, + md: $text, + toc: $toc_enabled) + ) { + posts::delete($post); + ajax_error(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]); + } + } - redirect($post->getUrl()); + // done + ajax_ok(['url' => $post->getUrl()]); } - function GET_auto_delete() { + protected static function _postEditValidateCommonData($date) { + $dt = DateTime::createFromFormat("Y-m-d", $date); + $date_is_valid = $dt && $dt->format("Y-m-d") === $date; + if (!$date_is_valid) + ajax_error(['code' => 'no_date']); + } + + function GET_page_delete() { + list($name) = input('short_name'); + + $page = pages::getByName($name); + if (!$page) + not_found(); + + csrf_check('delpage'.$page->shortName); + pages::delete($page); + redirect('/'); + } + + function GET_post_delete() { list($name) = input('short_name'); $post = posts::getByName($name); - if ($post) { - csrf_check('delpost'.$post->id); - posts::delete($post); - redirect('/'); - } + if (!$post) + not_found(); - $page = pages::getByName($name); - if ($page) { - csrf_check('delpage'.$page->shortName); - pages::delete($page); - redirect('/'); + csrf_check('delpost'.$post->id); + posts::delete($post); + redirect('/articles/'); + } + + function GET_post_edit() { + list($short_name, $saved, $lang) = input('short_name, b:saved, lang'); + $lang = PostLanguage::from($lang); + + $post = posts::getByName($short_name); + if ($post) { + $texts = $post->getTexts(); + if (!isset($texts[$lang->value])) + not_found(); + + $js_texts = []; + foreach (PostLanguage::cases() as $pl) { + if (isset($texts[$pl->value])) { + $text = $texts[$pl->value]; + $js_texts[$pl->value] = [ + 'title' => $text->title, + 'md' => $text->md, + 'toc' => $text->toc, + ]; + } else { + $js_texts[$pl->value] = [ + 'title' => '', + 'md' => '', + 'toc' => false, + ]; + } + } + + $text = $texts[$lang->value]; + + add_skin_strings_re('/^(err_)?blog_/'); + add_skin_strings(['blog_post_edit_title']); + set_title(lang('blog_post_edit_title', $text->title)); + static::make_wide(); + render('admin/postForm', + is_edit: true, + post_id: $post->id, + post_url: $post->getUrl(), + title: $text->title, + text: $text->md, + date: $post->getDateForInputField(), + visible: $post->visible, + toc: $text->toc, + saved: $saved, + short_name: $short_name, + langs: PostLanguage::cases(), + lang: $text->lang->value, + js_texts: $js_texts + ); } not_found(); } + function POST_post_edit() { + if (!is_xhr_request()) + invalid_request(); + + list($old_short_name, $short_name, $langs, $date) = input('short_name, new_short_name, langs, date'); + + $post = posts::getByName($old_short_name); + if (!$post) + not_found(); + + csrf_check('editpost'.$post->id); + + self::_postEditValidateCommonData($date); + + if (empty($short_name)) + ajax_error(['code' => 'no_short_name']); + + foreach (explode(',', $langs) as $lang) { + $lang = PostLanguage::from($lang); + list($text, $title, $visible, $toc) = input("text:{$lang->value}, title:{$lang->value}, b:visible, b:toc:{$lang->value}"); + + $error_code = null; + if (!$title) + $error_code = 'no_title'; + else if (!$text) + $error_code = 'no_text'; + if ($error_code) + ajax_error(['code' => $error_code]); + + $pt = $post->getText($lang); + if (!$pt) { + $pt = $post->addText( + lang: $lang, + title: $title, + md: $text, + toc: $toc + ); + if (!$pt) + ajax_error(['code' => 'db_err']); + } else { + $pt->edit([ + 'title' => $title, + 'md' => $text, + 'toc' => (int)$toc + ]); + } + } + + $post_data = ['date' => $date, 'visible' => $visible]; + if ($post->shortName != $short_name) + $post_data['short_name'] = $short_name; + $post->edit($post_data); + + ajax_ok(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]); + + } + function GET_auto_edit() { list($short_name, $saved) = input('short_name, b:saved'); - $post = posts::getByName($short_name); - if ($post) { - $tags = $post->getTags(); - return $this->_get_postEdit($post, - title: $post->title, - text: $post->md, - tags: $post->getTags(), - visible: $post->visible, - toc: $post->toc, - short_name: $post->shortName, - saved: $saved, - ); - } - $page = pages::getByName($short_name); if ($page) { return $this->_get_pageEdit($page, @@ -291,50 +412,6 @@ class AdminHandler extends request_handler { function POST_auto_edit() { list($short_name) = input('short_name'); - $post = posts::getByName($short_name); - if ($post) { - csrf_check('editpost'.$post->id); - - list($text, $title, $tags, $visible, $toc, $short_name) - = input('text, title, tags, b:visible, b:toc, new_short_name'); - - $tags = tags::splitString($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, - title: $title, - text: $text, - tags: $tags, - visible: $visible, - toc: $toc, - short_name: $short_name, - error_code: $error_code - ); - - $post->edit([ - 'title' => $title, - 'md' => $text, - 'visible' => (int)$visible, - 'toc' => (int)$toc, - 'short_name' => $short_name - ]); - $tag_ids = array_values(tags::getTags($tags)); - $post->setTagIds($tag_ids); - - redirect($post->getUrl().'edit/?saved=1'); - } - $page = pages::getByName($short_name); if ($page) { csrf_check('editpage'.$page->shortName); @@ -359,7 +436,6 @@ class AdminHandler extends request_handler { title: $title, text: $text, visible: $visible, - error_code: $error_code ); } @@ -376,7 +452,7 @@ class AdminHandler extends request_handler { not_found(); } - protected static function setWidePage() { + protected static function make_wide() { set_skin_opts([ 'full_width' => true, 'no_footer' => true @@ -386,17 +462,15 @@ class AdminHandler extends request_handler { protected function _get_pageAdd( string $name, string $title = '', - string $text = '', - ?string $error_code = null + string $text = '' ) { add_skin_strings_re('/^(err_)?pages_/'); set_title(lang('pages_create_title', $name)); - static::setWidePage(); + static::make_wide(); render('admin/pageForm', short_name: $name, title: $title, - text: $text, - error_code: $error_code); + text: $text); } protected function _get_pageEdit( @@ -409,62 +483,14 @@ class AdminHandler extends request_handler { ) { add_skin_strings_re('/^(err_)?pages_/'); set_title(lang('pages_page_edit_title', $page->shortName.'.html')); - static::setWidePage(); + static::make_wide(); render('admin/pageForm', is_edit: true, short_name: $page->shortName, title: $title, text: $text, visible: $visible, - saved: $saved, - error_code: $error_code); - } - - protected function _get_postEdit( - Post $post, - string $title = '', - string $text = '', - ?array $tags = null, - bool $visible = false, - bool $toc = false, - string $short_name = '', - ?string $error_code = null, - bool $saved = false, - ) { - add_skin_strings_re('/^(err_)?blog_/'); - set_title(lang('blog_post_edit_title', $post->title)); - static::setWidePage(); - render('admin/postForm', - is_edit: true, - post_id: $post->id, - post_url: $post->getUrl(), - title: $title, - text: $text, - tags: $tags ? implode(', ', $tags) : '', - visible: $visible, - toc: $toc, - saved: $saved, - short_name: $short_name, - error_code: $error_code - ); - } - - protected function _get_postAdd( - string $title = '', - string $text = '', - ?array $tags = null, - string $short_name = '', - ?string $error_code = null - ) { - add_skin_strings_re('/^(err_)?blog_/'); - set_title('$blog_write'); - static::setWidePage(); - render('admin/postForm', - title: $title, - text: $text, - tags: $tags ? implode(', ', $tags) : '', - short_name: $short_name, - error_code: $error_code); + saved: $saved); } } \ No newline at end of file diff --git a/handler/MainHandler.php b/handler/MainHandler.php index 7994ec5..14199bc 100644 --- a/handler/MainHandler.php +++ b/handler/MainHandler.php @@ -4,29 +4,16 @@ class MainHandler extends request_handler { function GET_index() { set_title('$site_title'); + set_skin_opts(['is_index' => true]); render('main/index'); } function GET_about() { redirect('/info/'); } function GET_contacts() { redirect('/info/'); } - function GET_auto() { + function GET_page() { list($name) = input('name'); - if (is_admin()) { - if (is_numeric($name)) { - $post = posts::get((int)$name); - } else { - $post = posts::getByName($name); - } - if ($post) - return $this->renderPost($post); - - $tag = tags::get($name); - if ($tag) - return $this->renderTag($tag); - } - $page = pages::getByName($name); if ($page) return $this->renderPage($page); @@ -40,58 +27,77 @@ class MainHandler extends request_handler { not_found(); } - protected function renderPost(Post $post) { + function GET_post() { global $config; - if (!$post->visible && !is_admin()) - not_found(); + ensure_admin(); - $tags = $post->getTags(); + list($name, $input_lang) = input('name, lang'); - add_meta( - ['property' => 'og:title', 'content' => $post->title], - ['property' => 'og:url', 'content' => $config['domain'].$post->getUrl()] - ); - if (($img = $post->getFirstImage()) !== null) - add_meta(['property' => 'og:image', 'content' => $img->getDirectUrl()]); + $lang = null; + try { + if ($input_lang) + $lang = PostLanguage::from($input_lang); + } catch (ValueError $e) { + not_found($e->getMessage()); + } - add_meta([ - 'name' => 'description', - 'property' => 'og:description', - 'content' => $post->getDescriptionPreview(155) - ]); + if (!$lang) + $lang = PostLanguage::getDefault(); - set_title($post->title); + $post = posts::getByName($name); - if ($post->toc) - set_skin_opts(['wide' => true]); + if ($post) { + if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value) + redirect($post->getUrl()); + if (!$post->hasLang($lang)) + not_found('no text for language '.$lang->name); + if (!$post->visible && !is_admin()) + not_found(); - render('main/post', - title: $post->title, - id: $post->id, - unsafe_html: $post->getHtml(is_retina(), getUserTheme()), - unsafe_toc_html: $post->getToc(), - date: $post->getFullDate(), - tags: $tags, - visible: $post->visible, - url: $post->getUrl(), - email: $config['admin_email'], - urlencoded_reply_subject: 'Re: '.$post->title); - } + $pt = $post->getText($lang); - protected function renderTag(Tag $tag) { - $tag = tags::get($tag); - if (!is_admin() && !$tag->visiblePostsCount) - not_found(); + $other_langs = []; + foreach (PostLanguage::cases() as $pl) { + if ($pl == $lang) + continue; + if ($post->hasLang($pl)) + $other_langs[] = $pl->value; + } - $count = posts::getCountByTagId($tag->id, is_admin()); - $posts = $count ? posts::getPostsByTagId($tag->id, is_admin()) : []; + add_meta( + ['property' => 'og:title', 'content' => $pt->title], + ['property' => 'og:url', 'content' => $config['domain'].$post->getUrl()] + ); + if (($img = $pt->getFirstImage()) !== null) + add_meta(['property' => 'og:image', 'content' => $img->getDirectUrl()]); - set_title('#'.$tag->tag); - render('main/tag', - count: $count, - posts: $posts, - tag: $tag->tag); + add_meta([ + 'name' => 'description', + 'property' => 'og:description', + 'content' => $pt->getDescriptionPreview(155) + ]); + + set_skin_opts(['articles_lang' => $lang->value]); + + set_title($pt->title); + + if ($pt->hasTableOfContents()) + set_skin_opts(['wide' => true]); + + render('main/post', + title: $pt->title, + id: $post->id, + unsafe_html: $pt->getHtml(is_retina(), getUserTheme()), + unsafe_toc_html: $pt->getTableOfContentsHtml(), + date: $post->getFullDate(), + visible: $post->visible, + url: $post->getUrl(), + lang: $lang->value, + other_langs: $other_langs); + } + + not_found(); } protected function renderPage(Page $page) { @@ -100,6 +106,9 @@ class MainHandler extends request_handler { if (!is_admin() && !$page->visible && $page->get_id() != $config['index_page_id']) not_found(); + if ($page->shortName == 'info') + set_skin_opts(['head_section' => 'about']); + set_title($page ? $page->title : '???'); render('main/page', unsafe_html: $page->getHtml(is_retina(), getUserTheme()), @@ -110,12 +119,16 @@ class MainHandler extends request_handler { function GET_rss() { 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::getList(0, 20)); + $lang = PostLanguage::getDefault(); + $items = array_map(function(Post $post) use ($lang) { + $pt = $post->getText($lang); + return [ + 'title' => $pt->title, + 'link' => $post->getUrl(), + 'pub_date' => date(DATE_RSS, $post->ts), + 'description' => $pt->getDescriptionPreview(500) + ]; + }, posts::getList(0, 20, filter_by_lang: $lagn)); $ctx = new SkinContext('\\skin\\rss'); $body = $ctx->atom( @@ -130,9 +143,27 @@ class MainHandler extends request_handler { } function GET_articles() { - $posts = posts::getList(0, 1000); + ensure_admin(); + + list($lang) = input('lang'); + if ($lang) { + $lang = PostLanguage::tryFrom($lang); + if (!$lang || $lang == PostLanguage::getDefault()) + redirect('/articles/'); + } else { + $lang = PostLanguage::getDefault(); + } + + $posts = posts::getList( + include_hidden: is_admin(), + filter_by_lang: $lang); + set_title('$articles'); - render('main/articles', posts: $posts); + set_skin_opts(['head_section' => 'articles']); + + render('main/articles', + posts: $posts, + selected_lang: $lang); } } \ No newline at end of file diff --git a/helper/ArticlesHelper.php b/helper/ArticlesHelper.php new file mode 100644 index 0000000..bfe8f69 --- /dev/null +++ b/helper/ArticlesHelper.php @@ -0,0 +1,19 @@ + { + this.tocByLang[this.getCurrentLang()] = e.target.checked + }) + } + + let lang = 'en' + if (this.isPost()) { + lang = this.getCurrentLang() + this.form.lang.addEventListener('change', (e) => { + let newLang = e.target.options[e.target.selectedIndex].value + this.draft.setLang(newLang) + this.fillFromDraft({applyEvenEmpty: true}) + ge('toc_cb').checked = this.tocByLang[newLang] + }) + } + + let draftId, draftType = !this.isPost() ? 'page' : 'post' + if (this.isEditing()) { + draftId = `edit_${draftType}${this.opts.id}` + } else { + draftId = `new_${draftType}` + } + + this.draft = new Draft(draftId, lang); + if (this.isEditing()) { + for (let l in opts.texts) { + this.draft.setLang(l) + this.draft.title = opts.texts[l].title + this.draft.text = opts.texts[l].md + this.tocByLang[l] = opts.texts[l].toc + } + this.draft.setLang(lang) + this.showPreview() + } else { + this.fillFromDraft() + } + + // window.addEventListener('scroll', this.onScroll) + // window.addEventListener('resize', this.onResize) + } + + getCurrentLang() { return this.form.lang.options[this.form.lang.selectedIndex].value } + isPost() { return !this.opts.pages } + isPage() { return !!this.opts.pages } + isEditing() { return !!this.opts.edit } + + fillFromDraft({applyEvenEmpty} = {applyEvenEmpty: false}) { + for (const what of ['title', 'text']) { + if (this.draft[what] !== '' || applyEvenEmpty) + this.form[what].value = this.draft[what] + } + + if (this.form.text.value !== '' || applyEvenEmpty) + this.showPreview() + } + + showPreview() { + if (this.previewRequest !== null) + this.previewRequest.abort() + const params = { + md: this.form.elements.text.value, + use_image_previews: this.isPage() ? 1 : 0 + } + if (this.isPost()) + params.title = this.form.elements.title.value + this.previewRequest = ajax.post('/admin/markdown-preview.ajax', params, (err, response) => { + if (err) + return console.error(err) + ge('preview_html').innerHTML = response.html + }) + } + + showError(code, message) { + if (code) { + let el = ge('form-error') + let label = escape(lang(`err_blog_${code}`)) + if (message) + label += ' (' + message + ')' + el.innerHTML = label + el.style.display = 'block' + } else if (message) { + alert(lang('error')+': '+message) + } + } + + hideError() { + let el = ge('form-error') + el.style.display = 'none' + } + + onSubmit = (evt) => { + const fields = [] try { - var fields = ['title', 'text']; - if (!this.opts.pages) - fields.push('tags'); - if (this.opts.edit) { + if (this.isEditing()) { 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() === '') + for (const field of fields) { + if (evt.target.elements[field].value.trim() === '') throw 'no_'+field } - // Draft.reset(); + const fd = new FormData() + for (const f of fields) { + console.log(`field: ${f}`) + fd.append(f, evt.target[f].value.trim()) + } + + // fd.append('lang', this.getCurrentLang()) + fd.append('visible', ge('visible_cb').checked ? 1 : 0) + + // language-specific fields + let atLeastOneLangIsWritten = false + const writtenLangs = [] + for (const l of this.opts.langs) { + let title = this.draft.getForLang(l, 'title') + let text = this.draft.getForLang(l, 'text') + console.log(`lang: ${l}`, title, text) + if (title !== '' && text !== '') + atLeastOneLangIsWritten = true + fd.append(`title:${l}`, title) + fd.append(`text:${l}`, text) + fd.append(`toc:${l}`, this.tocByLang[l] ? 1 : 0) + writtenLangs.push(l) + } + if (!atLeastOneLangIsWritten) + throw 'no_text' + + fd.append('langs', writtenLangs.join(',')) + + // date field + const dateInput = evt.target.elements.date; + if (!dateInput.value) + throw 'no_date' + fd.append('date', dateInput.value) + + fd.append('token', this.opts.token) + cancelEvent(evt) + + this.hideError(); + + ajax.post(evt.target.action, fd, (error, response) => { + if (error) { + this.showError(error.code, error.message) + return; + } + + if (response.url) { + this.draft.reset(this.opts.langs); + window.location = response.url + } + }) } catch (e) { - var error = typeof e == 'string' ? lang((this.opts.pages ? 'err_pages_' : 'err_blog_')+e) : e.message; - alert(error); + const errorText = typeof e == 'string' ? lang('error')+': '+lang((this.isPage() ? 'err_pages_' : 'err_blog_')+e) : e.message; + alert(errorText); console.error(e); - return cancelEvent(event); + return cancelEvent(evt); } - }, + } - onToggleWrapClick: function(e) { - var textarea = this.form.elements.text; + onToggleWrapClick = (e) => { + const textarea = this.form.elements.text if (!hasClass(textarea, 'nowrap')) { - addClass(textarea, 'nowrap'); + addClass(textarea, 'nowrap') } else { - removeClass(textarea, 'nowrap'); + removeClass(textarea, 'nowrap') } - return cancelEvent(e); - }, + return cancelEvent(e) + } - onInput: function(e) { - if (this.previewTimeout !== null) { + onInput = (e) => { + if (this.previewTimeout !== null) clearTimeout(this.previewTimeout); - } - this.previewTimeout = setTimeout(function() { + + this.previewTimeout = setTimeout(() => { this.previewTimeout = null; this.showPreview(); - // Draft[e.target.name === 'title' ? 'setTitle' : 'setText'](e.target.value); - }.bind(this), 300); - }, + const what = e.target.name === 'title' ? 'title' : 'text' + this.draft[what] = e.target.value + }, 300) + } - onScroll: function() { + onScroll = () => { var ANCHOR_TOP = 10; var y = window.pageYOffset; @@ -124,21 +234,22 @@ var AdminWriteForm = { 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); + onResize = () => { + if (!this.isFixed) + return + + const form = this.form + const td = ge('form_first_cell') + const ph = ge('form_placeholder') + + const rect = td.getBoundingClientRect() + const pr = parseInt(getComputedStyle(td).paddingRight, 10) || 0 + + ph.style.height = form.getBoundingClientRect().height+'px' + form.style.width = (rect.width - pr) + 'px' + } +} + +// bindEventHandlers(AdminWriteForm); diff --git a/htdocs/js/common/02-ajax.js b/htdocs/js/common/02-ajax.js index 59e867b..0e5d356 100644 --- a/htdocs/js/common/02-ajax.js +++ b/htdocs/js/common/02-ajax.js @@ -8,9 +8,8 @@ }; function createXMLHttpRequest() { - if (window.XMLHttpRequest) { + if (window.XMLHttpRequest) return new XMLHttpRequest(); - } var xhr; try { @@ -59,7 +58,7 @@ break; case 'POST': - if (isObject(data)) { + if (isObject(data) && !(data instanceof FormData)) { var sdata = []; for (var k in data) { if (data.hasOwnProperty(k)) { @@ -77,32 +76,33 @@ xhr.open(method, url); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); - if (method === 'POST') { - xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - } + if (method === 'POST' && !(data instanceof FormData)) + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + var callbackFired = false; xhr.onreadystatechange = function() { + if (callbackFired) + return + 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)) { + if (!isObject(resp)) throw new Error('ajax: object expected') - } - if (resp.error) { - throw new Error(resp.error) - } - callback(null, resp.response); + + callbackFired = true; + if (resp.error) + callback(resp.error, null, xhr.status); + else + callback(null, resp.response, xhr.status); } else { - callback(null, xhr.responseText); + callback(null, xhr.responseText, xhr.status); } } }; xhr.onerror = function(e) { - callback(e); + callback(e, null, 0); }; xhr.send(method === 'GET' ? null : data); diff --git a/htdocs/scss/app/blog.scss b/htdocs/scss/app/blog.scss index c0e3ca8..23874d9 100644 --- a/htdocs/scss/app/blog.scss +++ b/htdocs/scss/app/blog.scss @@ -45,14 +45,15 @@ padding-top: 12px; } td:nth-child(1) { - width: 70%; + width: 40%; } - td:nth-child(2) { + td:nth-child(2), + td:nth-child(3) { width: 30%; padding-left: 10px; } tr:first-child td { - padding-top: 0px; + padding-top: 0; } button[type="submit"] { margin-left: 3px; @@ -177,27 +178,9 @@ body.wide .blog-post { color: $grey; 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; + //> a { + // margin-left: 5px; + //} } .blog-post-text {} @@ -206,14 +189,14 @@ body.wide .blog-post { margin: 13px 0; } - p { - margin-top: 13px; - margin-bottom: 13px; + p, center { + margin-top: 20px; + margin-bottom: 20px; } - p:first-child { + p:first-child, center:first-child { margin-top: 0; } - p:last-child { + p:last-child, center:last-child { margin-bottom: 0; } @@ -246,10 +229,11 @@ body.wide .blog-post { } blockquote { - border-left: 3px $border-color solid; + border-left: 2px $quote_line solid; margin-left: 0; padding: 5px 0 5px 12px; - color: $grey; + color: $quote_color; + font-style: italic; &:first-child { padding-top: 0; margin-top: 0; @@ -367,27 +351,22 @@ body.wide .blog-post { 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 { +.blog-item-right-links { + font-size: 16px; + float: right; + > a { margin-left: 2px; - > a { - font-size: 16px; - margin-left: 2px; - } } } +.blog-links-separator { + color: $grey; +} + .blog-list-table-wrap { padding: 5px 0; } @@ -436,41 +415,3 @@ td.blog-item-title-cell { } } -/* -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: $fg; -} -.blog-tag-item-count { - color: #aaa; - margin-left: 6px; - text-decoration: none !important; -} diff --git a/htdocs/scss/app/common.scss b/htdocs/scss/app/common.scss index 4243ad9..3754c36 100644 --- a/htdocs/scss/app/common.scss +++ b/htdocs/scss/app/common.scss @@ -51,7 +51,6 @@ textarea { appearance: none; box-sizing: border-box; border: 1px $input-border solid; - border-radius: 0; background-color: $input-bg; color: $fg; font-family: $ff; @@ -64,34 +63,30 @@ textarea { border-color: $input-border-focused; } } - textarea { resize: vertical; } -//input[type="checkbox"] { -// margin-left: 0; -//} +button { + @include radius(3px); + background-color: $light-bg; + color: $fg; + padding: 6px 12px; + border: 1px $input-border solid; + font-family: $ff; + font-size: $fs; + outline: none; + cursor: pointer; + position: relative; +} +button:hover { + background-color: $input-border; +} +button:active { + border-color: $input-border-focused; + background-color: $input-border-focused; +} -//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; @@ -360,4 +355,38 @@ a.index-dl-line { background-color: $link-color-underline; } } +} + +.bc { + padding-bottom: 15px; + + > a.bc-item, > span.bc-item { + font-size: $fs + 2px; + display: inline-block; + padding: 5px 10px; + + &:not(:first-child) { + margin-left: 10px; + } + } + + > a.bc-item { + background-color: $hover-hl; + border-radius: 4px; + } + > span.bc-item { + padding: 5px; + font-weight: 600; + } + + > a.bc-item:hover { + text-decoration: none; + border-bottom-color: $link-color-underline; + background-color: $hover-hl-darker; + } + + // arrow + span.bc-arrow { + color: $grey !important; + } } \ No newline at end of file diff --git a/htdocs/scss/app/head.scss b/htdocs/scss/app/head.scss index 7ee8b56..15e8e6f 100644 --- a/htdocs/scss/app/head.scss +++ b/htdocs/scss/app/head.scss @@ -2,7 +2,6 @@ display: table; width: 100%; border-collapse: collapse; - //border-bottom: 1px $border-color solid; } .head-inner { display: table-row; @@ -22,23 +21,16 @@ background-color: transparent; display: inline-block; - &:hover { - border-radius: 4px; - background-color: $hover-hl; - } - > a:hover { text-decoration: none; } &-title { + color: $fg; padding-bottom: 3px; &-author { font-weight: normal; color: $grey; - //font-size: $fs; - //position: relative; - //top: -5px; } } @@ -49,7 +41,36 @@ padding-top: 2px; line-height: 18px; } + } +.head.no-subtitle .head-logo { + padding-bottom: 0; + + > a { + border-bottom: 1px transparent solid; + display: inline-block; + + &:hover { + border-bottom: 1px $border-color solid; + } + + .head-logo-subtitle, + .head-logo-title { + display: block; + float: left; + } + + .head-logo-subtitle { + margin-left: 10px; + font-size: $fs; + } + + .head-logo-subtitle > br { + display: none; + } + } +} + //body:not(.theme-changing) .head-logo { // @include transition(background-color, 0.03s); //} @@ -57,8 +78,9 @@ .head-items { text-align: right; display: table-cell; - vertical-align: middle; + vertical-align: top; color: $dark-grey; // color of separators + padding-top: 15px; } a.head-item { color: $fg; @@ -79,7 +101,7 @@ a.head-item { } } - &:hover { + &:hover, &.is-selected { border-radius: 4px; background-color: $hover-hl; text-decoration: none; diff --git a/htdocs/scss/app/mobile.scss b/htdocs/scss/app/mobile.scss index 883c857..a9cfb4e 100644 --- a/htdocs/scss/app/mobile.scss +++ b/htdocs/scss/app/mobile.scss @@ -4,18 +4,26 @@ textarea { -webkit-overflow-scrolling: touch; } -.head, .head-inner, .head-logo-wrap, .head-items, head-logo { +.page-content { + padding: 0 15px; +} + +.head, .head-inner, .head-logo-wrap, .head-items, .head-logo { display: block; } +.head { + overflow: hidden; +} +.head-logo-wrap { + margin-left: -20px; + margin-right: -20px; + border-bottom: 1px $border-color solid; + + padding-top: 5px; + padding-bottom: 5px; +} .head-logo { margin: 0; -} -.head-logo-wrap { - padding-bottom: 4px; -} -.head-logo { - border: 1px $border-color solid; - border-radius: 6px; display: block; text-align: center; font-size: $fs; @@ -23,22 +31,19 @@ textarea { padding-top: 14px; padding-bottom: 14px; - &:hover { - border-color: $hover-hl; + > a:hover { + border-bottom-color: transparent !important; } - //&-subtitle { - // font-size: $fs - 2px; - //} } .head-items { text-align: center; - padding: 8px 0 16px; + padding: 15px 0; white-space: nowrap; overflow-x: auto; overflow-y: hidden; - margin-left: -10px; - margin-right: -10px; + //margin-left: -10px; + //margin-right: -10px; max-width: 100%; box-sizing: border-box; } @@ -50,13 +55,6 @@ a.head-item:last-child > span { border-right: 0; } -// blog -.blog-tags { - display: none; -} -.blog-list.withtags { - margin-right: 0; -} .blog-post-text code { word-wrap: break-word; } diff --git a/htdocs/scss/colors/dark.scss b/htdocs/scss/colors/dark.scss index 726210e..9c416dc 100644 --- a/htdocs/scss/colors/dark.scss +++ b/htdocs/scss/colors/dark.scss @@ -6,6 +6,8 @@ $link-color-underline: #69849d; $hover-hl: rgba(255, 255, 255, 0.09); $hover-hl-darker: rgba(255, 255, 255, 0.12); $grey: #798086; +$quote_color: #3c9577; +$quote_line: #45544d; $dark-grey: $grey; $light-grey: $grey; $fg: #eee; @@ -15,11 +17,13 @@ $code-block-bg: #394146; $inline-code-block-bg: #394146; $light-bg: #464c4e; +$light-bg-hover: #dce3e8; $dark-bg: #444; $dark-fg: #999; $input-border: #48535a; -$input-border-focused: #48535a; +$input-border-focused: lighten($input-border, 7%); + $input-bg: #30373b; $border-color: #48535a; diff --git a/htdocs/scss/colors/light.scss b/htdocs/scss/colors/light.scss index e670640..33cf07d 100644 --- a/htdocs/scss/colors/light.scss +++ b/htdocs/scss/colors/light.scss @@ -6,6 +6,8 @@ $link-color-underline: #95b5da; $hover-hl: #f0f0f0; $hover-hl-darker: #ebebeb; $grey: #888; +$quote_color: #1f9329; +$quote_line: #d1e0d2; $dark-grey: #777; $light-grey: #999; $fg: #222; @@ -15,11 +17,13 @@ $code-block-bg: #f3f3f3; $inline-code-block-bg: #f1f1f1; $light-bg: #efefef; +$light-bg-hover: #dce3e8; $dark-bg: #dfdfdf; $dark-fg: #999; $input-border: #e0e0e0; -$input-border-focused: #e0e0e0; +$input-border-focused: darken($input-border, 7%); + $input-bg: #f7f7f7; $border-color: #e0e0e0; diff --git a/init.php b/init.php index b139624..a3b58cd 100644 --- a/init.php +++ b/init.php @@ -15,17 +15,22 @@ set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT); spl_autoload_register(function($class) { static $libs = [ - 'lib/tags' => ['Tag', 'tags'], 'lib/pages' => ['Page', 'pages'], - 'lib/posts' => ['Post', 'posts'], + 'lib/posts' => ['Post', 'PostText', 'PostLanguage', 'posts'], 'lib/uploads' => ['Upload', 'uploads'], 'engine/model' => ['model'], 'engine/skin' => ['SkinContext'], ]; - if (str_ends_with($class, 'Handler')) { - $path = APP_ROOT.'/handler/'.str_replace('\\', '/', $class).'.php'; - } else { + $path = null; + foreach (['Handler', 'Helper'] as $sfx) { + if (str_ends_with($class, $sfx)) { + $path = APP_ROOT.'/'.strtolower($sfx).'/'.str_replace('\\', '/', $class).'.php'; + break; + } + } + + if (is_null($path)) { foreach ($libs as $lib_file => $class_names) { if (in_array($class, $class_names)) { $path = APP_ROOT.'/'.$lib_file.'.php'; @@ -34,7 +39,7 @@ spl_autoload_register(function($class) { } } - if (!isset($path)) + if (is_null($path)) $path = APP_ROOT.'/lib/'.$class.'.php'; if (!is_file($path)) diff --git a/lib/admin.php b/lib/admin.php index 116ee3c..a929156 100644 --- a/lib/admin.php +++ b/lib/admin.php @@ -4,48 +4,148 @@ require_once 'lib/stored_config.php'; const ADMIN_SESSION_TIMEOUT = 86400 * 14; const ADMIN_COOKIE_NAME = 'admin_key'; +const ADMIN_LOGIN_MAX_LENGTH = 32; +$AdminSession = [ + 'id' => null, + 'auth_id' => 0, + 'login' => null, +]; function is_admin(): bool { - static $is_admin = null; - if (is_null($is_admin)) - $is_admin = _admin_verify_key(); - return $is_admin; + global $AdminSession; + if ($AdminSession['id'] === null) + _admin_check(); + return $AdminSession['id'] != 0; } -function _admin_verify_key(): bool { - if (isset($_COOKIE[ADMIN_COOKIE_NAME])) { - $cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME]; - if ($cookie !== _admin_get_key()) - admin_unset_cookie(); - return true; - } - return false; +function admin_current_info(): array { + global $AdminSession; + return [ + 'id' => $AdminSession['id'], + 'login' => $AdminSession['login'] + ]; } -function admin_check_password(string $pwd): bool { - return salt_password($pwd) === scGet('admin_pwd'); +function _admin_check(): void { + if (!isset($_COOKIE[ADMIN_COOKIE_NAME])) + return; + + $cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME]; + $db = DB(); + $q = $db->query("SELECT + admin_auth.id AS auth_id, + admin_auth.admin_id AS id, + admins.login AS login + FROM admin_auth + LEFT JOIN admins ON admin_auth.admin_id=admins.id + WHERE admin_auth.token=? + LIMIT 1", $cookie); + + if (!$db->numRows($q)) + return; + + $info = $db->fetch($q); + + global $AdminSession; + $AdminSession['id'] = (int)$info['id']; + $AdminSession['login'] = $info['login']; + $AdminSession['auth_id'] = (int)$info['auth_id']; } -function _admin_get_key(): string { - $admin_pwd_hash = scGet('admin_pwd'); - return salt_password("$admin_pwd_hash|{$_SERVER['REMOTE_ADDR']}"); +function admin_exists(string $login): bool { + $db = DB(); + return (int)$db->result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0; } -function admin_set_cookie(): void { +function admin_add(string $login, string $password): int { + $db = DB(); + $db->insert('admins', [ + 'login' => $login, + 'password' => salt_password($password) + ]); + return $db->insertId(); +} + +function admin_delete(string $login): bool { + $db = DB(); + $id = admin_get_id_by_login($login); + if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false; + if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false; + return true; +} + +function admin_get_id_by_login(string $login): ?int { + $db = DB(); + $q = $db->query("SELECT id FROM admins WHERE login=?", $login); + return $db->numRows($q) > 0 ? (int)$db->result($q) : null; +} + + +function admin_set_password(string $login, string $password): bool { + $db = DB(); + $db->query("UPDATE admins SET password=? WHERE login=?", salt_password($password), $login); + return $db->affectedRows() > 0; +} + +function admin_auth(string $login, string $password): bool { + global $AdminSession; + + $db = DB(); + $q = $db->query("SELECT id FROM admins WHERE login=? AND password=?", $login, salt_password($password)); + if (!$db->numRows($q)) + return false; + + $id = (int)$db->result($q); + $time = time(); + + do { + $token = strgen(32); + } while ($db->numRows($db->query("SELECT id FROM admin_auth WHERE token=? LIMIT 1", $token)) > 0); + + $db->insert('admin_auth', [ + 'admin_id' => $id, + 'token' => $token, + 'ts' => $time + ]); + + $db->insert('admin_log', [ + 'admin_id' => $id, + 'ts' => $time, + 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + ]); + + $AdminSession = [ + 'id' => $id, + 'login' => $login, + ]; + + admin_set_cookie($token); + return true; +} + +function admin_logout() { + if (!is_admin()) + return; + + global $AdminSession; + $db = DB(); + + $db->query("DELETE FROM admin_auth WHERE id=?", $AdminSession['auth_id']); + + $AdminSession['id'] = null; + $AdminSession['login'] = null; + $AdminSession['auth_id'] = 0; + + admin_unset_cookie(); +} + +function admin_set_cookie(string $token): void { global $config; - $key = _admin_get_key(); - setcookie(ADMIN_COOKIE_NAME, $key, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']); + setcookie(ADMIN_COOKIE_NAME, $token, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']); } function admin_unset_cookie(): void { global $config; setcookie(ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']); } - -function admin_log_auth(): void { - DB()->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 index 910f4c1..624a719 100644 --- a/lib/cli.php +++ b/lib/cli.php @@ -2,11 +2,7 @@ class cli { - protected ?array $commandsCache = null; - - function __construct( - protected string $ns - ) {} + protected array $commands = []; protected function usage($error = null): void { global $argv; @@ -15,19 +11,15 @@ class cli { echo "error: {$error}\n\n"; echo "Usage: $argv[0] COMMAND\n\nCommands:\n"; - foreach ($this->getCommands() as $c) + foreach ($this->commands as $c => $tmp) echo " $c\n"; exit(is_null($error) ? 0 : 1); } - 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; + function on(string $command, callable $f) { + $this->commands[$command] = $f; + return $this; } function run(): void { @@ -39,12 +31,14 @@ class cli { if ($argc < 2) $this->usage(); + if (empty($this->commands)) + cli::die("no commands added"); + $func = $argv[1]; - if (!in_array($func, $this->getCommands())) + if (!isset($this->commands[$func])) self::usage('unknown command "'.$func.'"'); - $func = str_replace('-', '_', $func); - call_user_func($this->ns.'\\'.$func); + $this->commands[$func](); } public static function input(string $prompt): string { diff --git a/lib/posts.php b/lib/posts.php index 8ffb92b..3149b8e 100644 --- a/lib/posts.php +++ b/lib/posts.php @@ -1,28 +1,132 @@ $title, + 'lang' => $lang->value, + 'post_id' => $this->id, + 'html' => $html, + 'text' => $text, + 'md' => $md, + 'toc' => $toc, + ]; + + $db = DB(); + if (!$db->insert('posts_texts', $data)) + return null; + + $id = $db->insertId(); + + $post_text = posts::getText($id); + $post_text->updateImagePreviews(); + + return $post_text; + } + + public function registerText(PostText $postText): void { + if (array_key_exists($postText->lang->value, $this->texts)) + throw new Exception("text for language {$postText->lang->value} has already been registered"); + $this->texts[$postText->lang->value] = $postText; + } + + public function loadTexts() { + if (!empty($this->texts)) + return; + $db = DB(); + $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id); + while ($row = $db->fetch($q)) { + $text = new PostText($row); + $this->registerText($text); + } + } + + /** + * @return PostText[] + */ + public function getTexts(): array { + $this->loadTexts(); + return $this->texts; + } + + public function getText(PostLanguage $lang): ?PostText { + $this->loadTexts(); + return $this->texts[$lang->value] ?? null; + } + + public function hasLang(PostLanguage $lang) { + $this->loadTexts(); + foreach ($this->texts as $text) { + if ($text->lang == $lang) + return true; + } + return false; + } + + public function getUrl(?PostLanguage $lang = null): string { + $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/"; + if ($lang && $lang != PostLanguage::English) + $buf .= '?lang='.$lang->value; + return $buf; + } + + protected function getTimestamp(): int { + return (new DateTime($this->date))->getTimestamp(); + } + + public function getDate(): string { + return date('j M', $this->getTimestamp()); + } + + public function getYear(): int { + return (int)date('Y', $this->getTimestamp()); + } + + public function getFullDate(): string { + return date('j F Y', $this->getTimestamp()); + } + + public function getDateForInputField(): string { + return date('Y-m-d', $this->getTimestamp()); + } +} + +class PostText extends model { + const DB_TABLE = 'posts_texts'; + + public int $id; + public int $postId; + public PostLanguage $lang; public string $title; public string $md; public string $html; - public string $tocHtml; public string $text; - public int $ts; - public int $updateTs; - public bool $visible; public bool $toc; - public string $shortName; - - function edit(array $fields) { - $cur_ts = time(); - if (!$this->visible && $fields['visible']) - $fields['ts'] = $cur_ts; - - $fields['update_ts'] = $cur_ts; + public string $tocHtml; + public function edit(array $fields) { if ($fields['md'] != $this->md) { $fields['html'] = markup::markdownToHtml($fields['md']); $fields['text'] = markup::htmlToText($fields['html']); @@ -36,116 +140,42 @@ class Post extends model { $this->updateImagePreviews(); } - function updateHtml() { + public function updateHtml(): void { $html = markup::markdownToHtml($this->md); $this->html = $html; - - DB()->query("UPDATE posts SET html=? WHERE id=?", $html, $this->id); + DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id); } - function updateText() { + public function updateText(): void { $html = markup::markdownToHtml($this->md); $text = markup::htmlToText($html); $this->text = $text; - - DB()->query("UPDATE posts SET text=? WHERE id=?", $text, $this->id); + DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id); } - function getDescriptionPreview(int $len): string { + public function getDescriptionPreview(int $len): string { if (mb_strlen($this->text) >= $len) return mb_substr($this->text, 0, $len-3).'...'; return $this->text; } - function getFirstImage(): ?Upload { + public function getFirstImage(): ?Upload { if (!preg_match('/\{image:([\w]{8})/', $this->md, $match)) return null; return uploads::getUploadByRandomId($match[1]); } - function getUrl(): string { - return $this->shortName != '' ? "/{$this->shortName}/" : "/{$this->id}/"; - } - - function getDate(): string { - return date('j M', $this->ts); - } - - function getYear(): int { - return (int)date('Y', $this->ts); - } - - function getFullDate(): string { - return date('j F Y', $this->ts); - } - - function getUpdateDate(): string { - return date('j M', $this->updateTs); - } - - function getFullUpdateDate(): string { - return date('j F Y', $this->updateTs); - } - - function getHtml(bool $is_retina, string $theme): string { + public function getHtml(bool $is_retina, string $theme): string { $html = $this->html; - $html = markup::htmlImagesFix($html, $is_retina, $theme); - return $html; + return markup::htmlImagesFix($html, $is_retina, $theme); } - function getToc(): ?string { + public function getTableOfContentsHtml(): ?string { return $this->toc ? $this->tocHtml : null; } - function isUpdated(): bool { - return $this->updateTs && $this->updateTs != $this->ts; - } - - /** - * @return Tag[] - */ - function getTags(): array { - $db = DB(); - $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[] - */ - function getTagIds(): array { - $ids = []; - $db = DB(); - $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; - } - - 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 = DB(); - 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) - tags::recountTagPosts($id); + public function hasTableOfContents(): bool { + return $this->toc; } /** @@ -153,7 +183,7 @@ class Post extends model { * @return int * @throws Exception */ - function updateImagePreviews(bool $update = false): int { + public function updateImagePreviews(bool $update = false): int { $images = []; if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches)) return 0; @@ -204,80 +234,53 @@ class posts { return (int)$db->result($db->query($sql)); } - static function getCountByTagId(int $tag_id, bool $include_hidden = false): int { - $db = DB(); - 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)); - } - /** * @return Post[] */ - static function getList(int $offset = 0, int $count = -1, bool $include_hidden = false): array { + static function getList(int $offset = 0, + int $count = -1, + bool $include_hidden = false, + ?PostLanguage $filter_by_lang = null + ): array { $db = DB(); $sql = "SELECT * FROM posts"; if (!$include_hidden) $sql .= " WHERE visible=1"; - $sql .= " ORDER BY ts DESC"; + $sql .= " ORDER BY `date` DESC"; if ($offset != 0 && $count != -1) $sql .= "LIMIT $offset, $count"; $q = $db->query($sql); - return array_map('Post::create_instance', $db->fetchAll($q)); + $posts = []; + while ($row = $db->fetch($q)) { + $posts[$row['id']] = $row; + } + + if (!empty($posts)) { + foreach ($posts as &$post) + $post = new Post($post); + $q = $db->query("SELECT * FROM posts_texts WHERE post_id IN (".implode(',', array_keys($posts)).")"); + while ($row = $db->fetch($q)) { + $posts[$row['post_id']]->registerText(new PostText($row)); + } + } + + if ($filter_by_lang !== null) + $posts = array_filter($posts, fn(Post $post) => $post->hasLang($filter_by_lang)); + + return array_values($posts); } - /** - * @return Post[] - */ - static function getPostsByTagId(int $tag_id, bool $include_hidden = false): array { + static function add(array $data = []): ?Post { $db = DB(); - $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)); - } - - static function add(array $data = []): int|bool { - $db = DB(); - - $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 = self::get($id); - $post->updateImagePreviews(); - - return $id; + return null; + return self::get($db->insertId()); } static function delete(Post $post): void { - $tags = $post->getTags(); - $db = DB(); $db->query("DELETE FROM posts WHERE id=?", $post->id); - $db->query("DELETE FROM posts_tags WHERE post_id=?", $post->id); - - foreach ($tags as $tag) - tags::recountTagPosts($tag->id); + $db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id); } static function get(int $id): ?Post { @@ -286,6 +289,12 @@ class posts { return $db->numRows($q) ? new Post($db->fetch($q)) : null; } + static function getText(int $text_id): ?PostText { + $db = DB(); + $q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id); + return $db->numRows($q) ? new PostText($db->fetch($q)) : null; + } + static function getByName(string $short_name): ?Post { $db = DB(); $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); diff --git a/lib/tags.php b/lib/tags.php deleted file mode 100644 index ecc9e5a..0000000 --- a/lib/tags.php +++ /dev/null @@ -1,87 +0,0 @@ -tag.'/'; - } - - function getPostsCount(bool $is_admin): int { - return $is_admin ? $this->postsCount : $this->visiblePostsCount; - } - - function __toString(): string { - return $this->tag; - } - -} - -class tags { - - static function getAll(bool $include_hidden = false): array { - $db = DB(); - $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)); - } - - static function get(string $tag): ?Tag { - $db = DB(); - $q = $db->query("SELECT * FROM tags WHERE tag=?", $tag); - return $db->numRows($q) ? new Tag($db->fetch($q)) : null; - } - - static function recountTagPosts(int $tag_id): void { - $db = DB(); - $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); - } - - static function splitString(string $tags): array { - $tags = trim($tags); - if ($tags == '') - return []; - $tags = preg_split('/,\s+/', $tags); - $tags = array_filter($tags, static function($tag) { return trim($tag) != ''; }); - $tags = array_map('trim', $tags); - $tags = array_map('mb_strtolower', $tags); - - return $tags; - } - - static function getTags(array $tags): array { - $found_tags = []; - $map = []; - - $db = DB(); - $q = $db->query("SELECT id, tag FROM tags - WHERE tag IN ('".implode("','", array_map(fn($tag) => $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; - } - -} \ No newline at end of file diff --git a/lib/uploads.php b/lib/uploads.php index 3690086..70830db 100644 --- a/lib/uploads.php +++ b/lib/uploads.php @@ -1,5 +1,7 @@ randomId.'/'.$this->name; $updated = false; - foreach (themes::getThemes() as $theme) { + foreach (getThemes() as $theme) { if (!$may_have_alpha && $theme == 'dark') continue; @@ -273,7 +275,7 @@ class Upload extends model { } $img = imageopen($orig); - imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme)); + imageresize($img, $dw, $dh, getThemeAlphaColorAsRGB($theme)); imagejpeg($img, $dst, $mult == 1 ? 93 : 67); imagedestroy($img); diff --git a/routes.php b/routes.php index 55de520..24019e6 100644 --- a/routes.php +++ b/routes.php @@ -3,28 +3,25 @@ return (function() { $routes = [ 'Main' => [ - '/' => 'index', - '{about,contacts}/' => 'about', - 'feed.rss' => 'rss', - '([a-z0-9-]+)/' => 'auto name=$(1)', + '/' => 'index', + '{about,contacts}/' => 'about', + 'feed.rss' => 'rss', + '([a-z0-9-]+)/' => 'page name=$(1)', + 'articles/' => 'articles', + 'articles/([a-z0-9-]+)/' => 'post name=$(1)', ], 'Admin' => [ 'admin/' => 'index', 'admin/{login,logout,log}/' => '${1}', - '([a-z0-9-]+)/{delete,edit}/' => 'auto_${1} short_name=$(1)', + '([a-z0-9-]+)/{delete,edit}/' => 'page_${1} short_name=$(1)', '([a-z0-9-]+)/create/' => 'page_add short_name=$(1)', + 'articles/write/' => 'post_add', + 'articles/([a-z0-9-]+)/{delete,edit}/' => 'post_${1} short_name=$(1)', 'admin/markdown-preview.ajax' => 'ajax_md_preview', 'admin/uploads/' => 'uploads', 'admin/uploads/{edit_note,delete}/(\d+)/' => 'upload_${1} id=$(1)' ] ]; - if (is_dev()) { - $routes['Main'] += [ - 'articles/' => 'articles' - ]; - $routes['Admin'] += [ - 'articles/write/' => 'post_add', - ]; - } + return $routes; })(); diff --git a/skin/admin.phps b/skin/admin.phps index 811ca31..ada8617 100644 --- a/skin/admin.phps +++ b/skin/admin.phps @@ -11,16 +11,25 @@ function login($ctx) { $html = << +
-
{$ctx->lang('as_form_password')}:
+
{$ctx->lang('admin_login')}:
- +
+ +
+
{$ctx->lang('admin_password')}:
+
+ +
+
+
- +
@@ -37,9 +46,10 @@ return [$html, $js]; // index page // ---------- -function index($ctx) { +function index($ctx, $admin_login) { return << + Authorized as {$admin_login}
Uploads
Sign out @@ -55,6 +65,11 @@ function uploads($ctx, $uploads, $error) { return <<if_true($error, $ctx->formError, $error)} +{$ctx->bc([ + ['text' => $ctx->lang('admin_title'), 'url' => '/admin/'], + ['text' => $ctx->lang('blog_uploads')], +])} +
@@ -121,29 +136,41 @@ return << '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')] +]; +if ($is_edit) { + $bc_tree[] = ['url' => $post_url.'?lang='.$lang, 'text' => $ctx->lang('blog_view_post')]; +} else { + $bc_tree[] = ['text' => $ctx->lang('blog_new_post')]; +} + $html = <<if_true($error_code, fn() => '
'.$ctx->lang('err_blog_'.$error_code).'
')} + {$ctx->if_true($saved, fn() => '
'.$ctx->lang('info_saved').'
')} +{$ctx->bc($bc_tree, 'padding-bottom: 20px')} + - @@ -210,10 +245,21 @@ $html = << HTML; -$js_params = json_encode($is_edit - ? ['edit' => true, 'id' => $post_id] - : (object)[]); -$js = "AdminWriteForm.init({$js_params});"; +$js_params = [ + 'langs' => array_map(fn($lang) => $lang->value, $langs), + 'token' => $is_edit ? csrf_get('editpost'.$post_id) : csrf_get('post_add') +]; +if ($is_edit) + $js_params += [ + 'edit' => true, + 'id' => $post_id, + 'texts' => $js_texts + ]; +$js_params = jsonEncode($js_params); + +$js = << if_true($body_class, ' class="'.implode(' ', $body_class).'"')}>
- {$ctx->renderHeader($theme)} + {$ctx->renderHeader($theme, $opts['head_section'], $opts['articles_lang'], $opts['is_index'])}
{$unsafe_body}
- + {$ctx->if_not($opts['full_width'], fn() => $ctx->renderFooter($admin_email))}
{$ctx->renderScript($js, $unsafe_lang)} @@ -187,28 +185,36 @@ function getStaticVersion(string $name): string { } -function renderHeader(SkinContext $ctx, string $theme): string { +function renderHeader(SkinContext $ctx, + string $theme, + ?string $section, + ?string $articles_lang, + bool $show_subtitle): string { $items = []; if (is_admin()) - $items[] = ['url' => '/articles/', 'label' => 'articles']; + $items[] = ['url' => '/articles/'.($articles_lang ? '?lang='.$articles_lang : ''), 'label' => 'articles', 'selected' => $section === 'articles']; array_push($items, - ['url' => 'https://files.4in1.ws', 'label' => 'materials'], - ['url' => '/info/', 'label' => 'about'] + ['url' => 'https://files.4in1.ws', 'label' => 'files', 'selected' => $section === 'files'], + ['url' => '/info/', 'label' => 'about', 'selected' => $section === 'about'] ); if (is_admin()) - $items[] = ['url' => '/admin/', 'label' => $ctx->renderSettingsIcon(), 'type' => 'settings']; + $items[] = ['url' => '/admin/', 'label' => $ctx->renderSettingsIcon(), 'type' => 'settings', 'selected' => $section === 'admin']; $items[] = ['url' => 'javascript:void(0)', 'label' => $ctx->renderMoonIcons(), 'type' => 'theme-switcher', 'type_opts' => $theme]; // here, items are rendered using for_each, so that there are no gaps (whitespaces) between tags +$class = 'head'; +if (!$show_subtitle) + $class .= ' no-subtitle'; + return << +
@@ -225,12 +232,12 @@ return <<{$unsafe_label} HTML; @@ -271,4 +281,13 @@ return << SVG; -} \ No newline at end of file +} + +function renderFooter($ctx, $admin_email): string { +return << + Email: {$admin_email} +
+HTML; + +} diff --git a/skin/main.phps b/skin/main.phps index c4e6cc8..192bb63 100644 --- a/skin/main.phps +++ b/skin/main.phps @@ -2,8 +2,12 @@ namespace skin\main; -// index page -// ---------- +// articles page +// ------------- + +use Post; +use PostLanguage; +use function is_admin; function index($ctx) { return << -// {$ctx->lang('blog_no')} -// {$ctx->if_admin(''.$ctx->lang('write').'')} -// -//HTML; -//} +function articles($ctx, array $posts, PostLanguage $selected_lang): string { +if (empty($posts)) + return $ctx->articlesEmpty($selected_lang); -function articles($ctx, array $posts): string { return << -
- - {$ctx->if_admin( - ' - new - ' - )} -
- {$ctx->indexPostsTable($posts)} + {$ctx->articlesPostsTable($posts, $selected_lang)} HTML; } -function indexPostsTable($ctx, array $posts): string { +function articlesEmpty($ctx, PostLanguage $selected_lang) { +return << + {$ctx->lang('blog_no')} + {$ctx->articlesRightLinks($selected_lang->value)} + +HTML; +} + +function articlesPostsTable($ctx, array $posts, PostLanguage $selected_lang): string { $ctx->year = 3000; return <<
- - - +
{$ctx->lang('blog_write_form_title')}
@@ -164,24 +191,34 @@ $html = <<
-
{$ctx->lang('blog_write_form_tags')}
+
{$ctx->lang('blog_post_options')}
- +
-
{$ctx->lang('blog_write_form_options')}
+
{$ctx->lang('blog_text_options')}
- +   +
+
+
+
+
{$ctx->lang('blog_write_form_date')}
+
+ if_true($date, ' value="'.$date.'"')}>
+
{$ctx->lang('blog_write_form_short_name')}
@@ -190,11 +227,9 @@ $html = <<
-
-
 
-
- -
+
 
+
+
- {$ctx->for_each($posts, fn($post) => $ctx->indexPostRow( - $post->getYear(), - $post->visible, - $post->getDate(), - $post->getUrl(), - $post->title - ))} + {$ctx->for_each($posts, fn($post, $i) => $ctx->articlesPostRow($i, $post, $selected_lang))}
HTML; } -function indexPostRow($ctx, $year, $is_visible, $date, $url, $title): string { +function articlesPostRow($ctx, int $index, Post $post, PostLanguage $selected_lang): string { +$year = $post->getYear(); +$date = $post->getDate(); +$url = $post->getUrl($selected_lang); + +$pt = $post->getText($selected_lang); +$title = $pt->title; + return <<if_true($ctx->year > $year, $ctx->indexYearLine, $year)} - +{$ctx->if_true($ctx->year > $year, $ctx->articlesIndexYearLine, $year, $index === 0, $selected_lang->value)} + {$date} @@ -95,17 +95,43 @@ return <<year = $year; return << {$year} - + + {$ctx->if_true($show_right_links, $ctx->articlesRightLinks($selected_lang))} + HTML; } +function articlesRightLinks($ctx, string $selected_lang) { +$links = [ + ['url' => $selected_lang != 'en' ? '/articles/' : null, 'label' => 'en'], + ['url' => $selected_lang != 'ru' ? '/articles/?lang=ru' : null, 'label' => 'ru'], +]; +if (is_admin()) { + $links[] = ['url' => '/articles/write/?lang='.$selected_lang, 'label' => 'write']; +} + +return << + {$ctx->for_each($links, fn($link, $index) => $ctx->articlesRightLink($link['url'], $link['label'], $index))} + +HTML; +} + +function articlesRightLink($ctx, $url, string $label, int $index) { + $buf = ''; + if ($index > 0) + $buf .= ' | '; + $buf .= !$url ? $label : ''.$label.''; + return $buf; +} + // any page // -------- @@ -113,6 +139,9 @@ HTML; function page($ctx, $page_url, $short_name, $unsafe_html) { $html = << + {$ctx->if_admin($ctx->pageAdminLinks, $page_url, $short_name)}
{$unsafe_html}
@@ -135,20 +164,21 @@ HTML; // post page // --------- -function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, $tags, $email, $urlencoded_reply_subject) { +function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, string $lang, $other_langs) { $html = <<
+ {$ctx->bc([ + ['url' => '/articles/?lang='.$lang, 'text' => $ctx->lang('articles')] + ])}

{$title}

-
{$unsafe_html}
@@ -161,6 +191,14 @@ HTML; return [$html, markdownThemeChangeListener()]; } +function postOtherLangs($ctx, $url, $other_langs) { + $buf = ''; + foreach ($other_langs as $lang) { + $buf .= ' | '.$ctx->lang('blog_read_in_'.$lang).''; + } + return $buf; +} + function postToc($ctx, $unsafe_toc_html) { return << @@ -175,16 +213,10 @@ HTML; } -function postAdminLinks($ctx, $url, $id) { +function postAdminLinks($ctx, $url, $id, string $lang) { return <<{$ctx->lang('edit')} -{$ctx->lang('delete')} -HTML; -} - -function postTag($ctx, $url, $name) { -return <<#{$name} +| {$ctx->lang('edit')} +| {$ctx->lang('delete')} HTML; } @@ -213,23 +245,3 @@ ThemeSwitcher.addOnChangeListener(function(isDark) { }); JS; } - - -// tag page -// -------- - -function tag($ctx, $count, $posts, $tag) { -if (!$count) - return << - {$ctx->lang('blog_tag_not_found')} -
-HTML; - -return << -
#{$tag}
- {$ctx->indexPostsTable($posts)} -
-HTML; -} diff --git a/strings/main.yaml b/strings/main.yaml index d6ab211..3c8000a 100644 --- a/strings/main.yaml +++ b/strings/main.yaml @@ -1,36 +1,26 @@ # common 4in1: '4in1' site_title: '4in1. Mask of Shakespeare, mysteries of Bacon, book by Cartier, secrets of the NSA' -index_title: '4in1 | Index' +index: 'Index' -posts: 'posts' -all_posts: 'all posts' -blog: 'blog' -contacts: 'contacts' -email: 'email' -projects: 'projects' +posts: 'Posts' +all_posts: 'All posts' +articles: 'Articles' unknown_error: 'Unknown error' error: 'Error' write: 'Write' submit: 'submit' +sign_in: "Sign in" edit: 'edit' delete: 'delete' +save: "Save" info_saved: 'Information saved.' toc: 'Table of Contents' -# theme switcher -theme_auto: 'auto' -theme_dark: 'dark' -theme_light: 'light' - -# contacts -contacts_email: 'email' -contacts_pgp: 'OpenPGP public key' -contacts_tg: 'telegram' -contacts_freenode: 'freenode' - # blog -blog_tags: 'tags' +blog_new_post: "New post" +blog_view_post: "View post" +#blog_editing: "Editing..." blog_latest: 'Latest posts' blog_no: 'No posts yet.' blog_view_all: 'View all' @@ -38,23 +28,24 @@ 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, contact me by email.' +blog_read_in_ru: "Читать на русском" +blog_read_in_en: "Read in English" 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_date: 'Date' 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_toc: 'ToC' blog_write_form_short_name: 'Short name' blog_write_form_toggle_wrap: 'Toggle wrap' -blog_write_form_options: 'Options' +blog_post_options: "Post options" +blog_text_options: "Text options" blog_uploads: 'Uploads' blog_upload: 'Upload files' @@ -66,9 +57,8 @@ 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_no_text: 'Text or title is not specified' +err_blog_no_date: 'Date is 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' @@ -92,12 +82,12 @@ 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_text: 'Text or title is 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 admin_title: "Admin" -as_form_password: 'Password' +admin_password: 'Password' +admin_login: "Login" diff --git a/tools/cli_util.php b/tools/cli_util.php index cd730e2..39eceb2 100755 --- a/tools/cli_util.php +++ b/tools/cli_util.php @@ -1,21 +1,89 @@ #!/usr/bin/env php on('admin-add', function() { + list($login, $password) = _get_admin_login_password_input(); -$cli = new cli(__NAMESPACE__); -$cli->run(); + if (admin_exists($login)) + cli::die("Admin ".$login." already exists"); -function admin_reset(): void { - $pwd1 = cli::silentInput("New password: "); + $id = admin_add($login, $password); + echo "ok: id = $id\n"; +}) + +->on('admin-delete', function() { + $login = cli::input('Login: '); + if (!admin_exists($login)) + cli::die("No such admin"); + if (!admin_delete($login)) + cli::die("Database error"); + echo "ok\n"; +}) + +->on('admin-set-password', function() { + list($login, $password) = _get_admin_login_password_input(); + echo admin_set_password($login, $password) ? 'ok' : 'fail'; + echo "\n"; +}) + +->on('blog-erase', function() { + $db = DB(); + $tables = ['posts', 'posts_texts']; + foreach ($tables as $t) { + $db->query("TRUNCATE TABLE $t"); + } +}) + +->on('posts-html', function() { + $kw = ['include_hidden' => true]; + $posts = posts::getList(0, posts::getCount(...$kw), ...$kw); + foreach ($posts as $p) { + $texts = $p->getTexts(); + foreach ($texts as $t) { + $t->updateHtml(); + $t->updateText(); + } + } +}) + +->on('posts-images', function() { + $kw = ['include_hidden' => true]; + $posts = posts::getList(0, posts::getCount(...$kw), ...$kw); + foreach ($posts as $p) { + $texts = $p->getTexts(); + foreach ($texts as $t) { + $t->updateImagePreviews(true); + } + } +}) + +->on('pages-html', function() { + $pages = Pages::getAll(); + foreach ($pages as $p) { + $p->updateHtml(); + } +}) + +->on('add-files-to-uploads', function() { + $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"; +}) + +->run(); + +function _get_admin_login_password_input(): array { + $login = cli::input('Login: '); + $pwd1 = cli::silentInput("Password: "); $pwd2 = cli::silentInput("Again: "); if ($pwd1 != $pwd2) @@ -24,60 +92,8 @@ function admin_reset(): void { if (trim($pwd1) == '') cli::die("Password can not be empty"); - if (!Config::set('admin_pwd', salt_password($pwd1))) - cli::die("Database error"); -} + if (strlen($login) > ADMIN_LOGIN_MAX_LENGTH) + cli::die("Login is longer than max length (".ADMIN_LOGIN_MAX_LENGTH.")"); -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 posts_images(): void { - $kw = ['include_hidden' => true]; - $posts = Posts::getPosts(0, Posts::getPostsCount(...$kw), ...$kw); - foreach ($posts as $p) { - $p->updateImagePreviews(true); - } -} - -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"; + return [$login, $pwd1]; }