commit 39cf0cadb82a70445faee443d666031cbc1fdb18 Author: E. S. Date: Sat Dec 30 23:29:31 2023 +0000 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ae0b83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/debug.log +test.php +/.git +/node_modules/ +/vendor/ +.DS_Store +._.DS_Store +.sass-cache/ +config-static.php +config-local.php +/.idea +/htdocs/dist-css +/htdocs/dist-js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..124162c --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +all: + @echo "Supported commands:" + @echo + @echo " \033[1mmake deploy\033[0m - deploy to production" + @echo " \033[1mmake static\033[0m - build static locally" + @echo + +deploy: + ./deploy/deploy.sh + +static: build-js build-css static-config + +build-js: + ./deploy/build_js.sh -i ./htdocs/js -o ./htdocs/dist-js + +build-css: + ./deploy/build_css.sh -i ./htdocs/scss -o ./htdocs/dist-css + +static-config: + ./deploy/gen_static_config.php -i ./htdocs > ./config-static.php + +.PHONY: all deploy static \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..86efee0 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# ch1p_io_web + +This is a source code of 4in1.ws web site. + +## Features +- it's not just blog, you can create any page with any address +- 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: + - written from scratch + - no heavy frameworks + - no "classic" template engine + - vanilla php templates designed from scratch (because why not) + - thus, no overhead from templates "compilation" + - all strings are transparently escaped unless explicitly specified not to + - ultra fast on frontend: + - written from scratch + - simple readable ECMAScript 5.1 scripts + - no modern web bullshit like webpack or babel + - simple build system that just works + - secure: + - CSRF protection + - automatic XSS protection in templates + - see [this section](#bug-bounty) below + +## Requirements + +- PHP >= 8.1, with following extensions: + - mysqli + - gd +- MariaDB server +- Composer +- Node.JS +- SCSS compiler, e.g. sassc + +## Configuration + +Should be done by copying config.php to config-local.php and modifying config-local.php. + +## Installation + +It uses https://github.com/sixlive/parsedown-highlight which you'll need to install using Composer, but since that +package's manifest is a bit outdated you have to pass `--ignore-platform-reqs` to composer. + +TODO + +## Logging + +TODO + +## Deploying + +``` +make deploy +``` + +## Bug bounty + +I take security very seriously. If you found an exploitable vulnerability in _my_ code, please contact me by email. + +I'm willing to pay $50 to $500 in crypto (depending on severity) for every discovered vulnerability. + +## License + +GPLv3 \ No newline at end of file diff --git a/classes/AjaxErrorResponse.php b/classes/AjaxErrorResponse.php new file mode 100644 index 0000000..a1fe381 --- /dev/null +++ b/classes/AjaxErrorResponse.php @@ -0,0 +1,9 @@ + $error], JSON_UNESCAPED_UNICODE)); + } + +} \ No newline at end of file diff --git a/classes/AjaxOkResponse.php b/classes/AjaxOkResponse.php new file mode 100644 index 0000000..253a563 --- /dev/null +++ b/classes/AjaxOkResponse.php @@ -0,0 +1,9 @@ + $data], JSON_UNESCAPED_UNICODE)); + } + +} \ No newline at end of file diff --git a/classes/AjaxResponse.php b/classes/AjaxResponse.php new file mode 100644 index 0000000..931e5e7 --- /dev/null +++ b/classes/AjaxResponse.php @@ -0,0 +1,13 @@ +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/classes/InputType.php b/classes/InputType.php new file mode 100644 index 0000000..401f7ca --- /dev/null +++ b/classes/InputType.php @@ -0,0 +1,8 @@ +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/classes/LogLevel.php b/classes/LogLevel.php new file mode 100644 index 0000000..46320d3 --- /dev/null +++ b/classes/LogLevel.php @@ -0,0 +1,8 @@ + [ + 'tablespan' => true + ] + ]; + if (!is_null($opts)) { + $parsedown_opts = array_merge($parsedown_opts, $opts); + } + parent::__construct($parsedown_opts); + + $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), + may_have_alpha: $image->imageMayHaveAlphaChannel(), + + 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] = ''.substr($line, 0, 2).''.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/classes/RedirectResponse.php b/classes/RedirectResponse.php new file mode 100644 index 0000000..4526c6c --- /dev/null +++ b/classes/RedirectResponse.php @@ -0,0 +1,10 @@ +addHeader('Location: '.$url); + } + +} \ No newline at end of file diff --git a/classes/RequestDispatcher.php b/classes/RequestDispatcher.php new file mode 100644 index 0000000..5306ddc --- /dev/null +++ b/classes/RequestDispatcher.php @@ -0,0 +1,89 @@ +router->find(self::path()); + if (!$route) + throw new NotFoundException('4in1'); + + $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.'Handler'; + + if (!class_exists($handler_class)) + throw new NotFoundException($config['is_dev'] ? '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.css'; + $skin->static[] = 'js/common.js'; + + $lang = LangData::getInstance(); + $skin->addLangKeys($lang->search('/^theme_/')); + + /** @var RequestHandler $handler */ + $handler = new $handler_class($skin, $lang, $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/classes/RequestHandler.php b/classes/RequestHandler.php new file mode 100644 index 0000000..d1fc160 --- /dev/null +++ b/classes/RequestHandler.php @@ -0,0 +1,58 @@ +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/classes/Response.php b/classes/Response.php new file mode 100644 index 0000000..6250063 --- /dev/null +++ b/classes/Response.php @@ -0,0 +1,28 @@ +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/classes/Router.php b/classes/Router.php new file mode 100644 index 0000000..0cb761d --- /dev/null +++ b/classes/Router.php @@ -0,0 +1,165 @@ + [], + '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/classes/Skin.php b/classes/Skin.php new file mode 100644 index 0000000..4a49ad2 --- /dev/null +++ b/classes/Skin.php @@ -0,0 +1,60 @@ + false, + 'wide' => false, + ]; + + 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; + + $theme = themes::getUserTheme(); + if ($theme != 'auto' && !themes::themeExists($theme)) + $theme = 'auto'; + + $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, + theme: $theme, + 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/classes/SkinBase.php b/classes/SkinBase.php new file mode 100644 index 0000000..b50c172 --- /dev/null +++ b/classes/SkinBase.php @@ -0,0 +1,22 @@ +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/classes/SkinContext.php b/classes/SkinContext.php new file mode 100644 index 0000000..f446c31 --- /dev/null +++ b/classes/SkinContext.php @@ -0,0 +1,118 @@ +ns = $namespace; + require_once ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.phps'; + } + + 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)) { + $f = [$this, $name]; + return $f; + } + + 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/classes/SkinString.php b/classes/SkinString.php new file mode 100644 index 0000000..a385133 --- /dev/null +++ b/classes/SkinString.php @@ -0,0 +1,23 @@ +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/classes/SkinStringModificationType.php b/classes/SkinStringModificationType.php new file mode 100644 index 0000000..7e750f2 --- /dev/null +++ b/classes/SkinStringModificationType.php @@ -0,0 +1,9 @@ +insert('admin_log', [ + 'ts' => time(), + 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + ]); + } + +} diff --git a/classes/cli.php b/classes/cli.php new file mode 100644 index 0000000..a860871 --- /dev/null +++ b/classes/cli.php @@ -0,0 +1,74 @@ +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 { + self::error($error); + exit(1); + } + + public static function error($error): void { + fwrite(STDERR, "error: {$error}\n"); + } + +} \ No newline at end of file diff --git a/classes/config.php b/classes/config.php new file mode 100644 index 0000000..bb7e5ca --- /dev/null +++ b/classes/config.php @@ -0,0 +1,46 @@ +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/classes/csrf.php b/classes/csrf.php new file mode 100644 index 0000000..20ea919 --- /dev/null +++ b/classes/csrf.php @@ -0,0 +1,22 @@ += 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/classes/database/MySQLConnection.php b/classes/database/MySQLConnection.php new file mode 100644 index 0000000..fe6a5c3 --- /dev/null +++ b/classes/database/MySQLConnection.php @@ -0,0 +1,87 @@ +link) + $this->link->close(); + } + + public function connect(): bool { + $this->link = new mysqli(); + $result = $this->link->real_connect($this->host, $this->user, $this->password, $this->database); + if ($result) + $this->link->set_charset('utf8mb4'); + return !!$result; + } + + 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/classes/database/SQLiteConnection.php b/classes/database/SQLiteConnection.php new file mode 100644 index 0000000..047c7c6 --- /dev/null +++ b/classes/database/SQLiteConnection.php @@ -0,0 +1,83 @@ +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) { + // TODO + } + + $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/classes/exceptions/ForbiddenException.php b/classes/exceptions/ForbiddenException.php new file mode 100644 index 0000000..855abe2 --- /dev/null +++ b/classes/exceptions/ForbiddenException.php @@ -0,0 +1,11 @@ +skin->title = $this->lang['contacts']; + return $this->skin->renderPage('main/contacts', + email: $config['admin_email']); + } + +} \ No newline at end of file diff --git a/classes/handler/AutoHandler.php b/classes/handler/AutoHandler.php new file mode 100644 index 0000000..2c3ba29 --- /dev/null +++ b/classes/handler/AutoHandler.php @@ -0,0 +1,110 @@ +input('name'); + if ($name == 'coreboot-mba51-flashing') + return new RedirectResponse('/coreboot-mba52-flashing/', 301); + + 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; + + if ($post->toc) + $s->setOptions(['wide' => true]); + + return $s->renderPage('main/post', + title: $post->title, + id: $post->id, + unsafe_html: $post->getHtml($this->isRetina(), \themes::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); + } + + 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(\model\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(), \themes::getUserTheme()), + page_url: $page->getUrl(), + short_name: $page->shortName); + } + +} \ No newline at end of file diff --git a/classes/handler/IndexHandler.php b/classes/handler/IndexHandler.php new file mode 100644 index 0000000..79695a2 --- /dev/null +++ b/classes/handler/IndexHandler.php @@ -0,0 +1,21 @@ +skin->title = $page->title; + + //$this->skin->title = $this->skin->lang('site_title'); + return $this->skin->renderPage('main/index', + unsafe_content: $page->getHtml($this->isRetina(), \themes::getUserTheme())); + } +} \ No newline at end of file diff --git a/classes/handler/RSSHandler.php b/classes/handler/RSSHandler.php new file mode 100644 index 0000000..89843fa --- /dev/null +++ b/classes/handler/RSSHandler.php @@ -0,0 +1,32 @@ + [ + '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/classes/handler/admin/AdminRequestHandler.php b/classes/handler/admin/AdminRequestHandler.php new file mode 100644 index 0000000..4002ba3 --- /dev/null +++ b/classes/handler/admin/AdminRequestHandler.php @@ -0,0 +1,21 @@ +skin->static[] = 'css/admin.css'; + $this->skin->static[] = 'js/admin.js'; + + if (!($this instanceof LoginHandler) && !admin::isAdmin()) + throw new ForbiddenException('These aren\'t the pages you\'re looking for...'); + + return null; + } + +} \ No newline at end of file diff --git a/classes/handler/admin/AutoAddOrEditHandler.php b/classes/handler/admin/AutoAddOrEditHandler.php new file mode 100644 index 0000000..62d3b33 --- /dev/null +++ b/classes/handler/admin/AutoAddOrEditHandler.php @@ -0,0 +1,99 @@ +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, + bool $toc = 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, + toc: $toc, + 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/classes/handler/admin/AutoDelete.php b/classes/handler/admin/AutoDelete.php new file mode 100644 index 0000000..80c8eef --- /dev/null +++ b/classes/handler/admin/AutoDelete.php @@ -0,0 +1,34 @@ +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/classes/handler/admin/AutoEditHandler.php b/classes/handler/admin/AutoEditHandler.php new file mode 100644 index 0000000..3d81773 --- /dev/null +++ b/classes/handler/admin/AutoEditHandler.php @@ -0,0 +1,130 @@ +input('short_name, b:saved'); + + $post = posts::getPostByName($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::getPageByName($short_name); + if ($page) { + return $this->_get_pageEdit($page, + title: $page->title, + text: $page->md, + saved: $saved, + visible: $page->visible, + ); + } + + throw new \exceptions\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, $toc, $short_name) + = $this->input('text, title, tags, b:visible, b:toc, 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, + 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 = 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 \exceptions\NotFoundException(); + } + +} \ No newline at end of file diff --git a/classes/handler/admin/IndexIndex.php b/classes/handler/admin/IndexIndex.php new file mode 100644 index 0000000..60eab07 --- /dev/null +++ b/classes/handler/admin/IndexIndex.php @@ -0,0 +1,13 @@ +skin->renderPage('admin/index'); + } + +} \ No newline at end of file diff --git a/classes/handler/admin/LoginHandler.php b/classes/handler/admin/LoginHandler.php new file mode 100644 index 0000000..80da2e9 --- /dev/null +++ b/classes/handler/admin/LoginHandler.php @@ -0,0 +1,31 @@ +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/classes/handler/admin/LogoutHandler.php b/classes/handler/admin/LogoutHandler.php new file mode 100644 index 0000000..943316d --- /dev/null +++ b/classes/handler/admin/LogoutHandler.php @@ -0,0 +1,17 @@ +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/classes/handler/admin/PageAddHandler.php b/classes/handler/admin/PageAddHandler.php new file mode 100644 index 0000000..902fec7 --- /dev/null +++ b/classes/handler/admin/PageAddHandler.php @@ -0,0 +1,66 @@ +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, + title: $title, + text: $text, + 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' + ); + } + + $page = pages::getPageByName($name); + return new RedirectResponse($page->getUrl()); + } + +} \ No newline at end of file diff --git a/classes/handler/admin/PostAddHandler.php b/classes/handler/admin/PostAddHandler.php new file mode 100644 index 0000000..3d0a177 --- /dev/null +++ b/classes/handler/admin/PostAddHandler.php @@ -0,0 +1,68 @@ +_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( + title: $title, + text: $text, + 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( + title: $title, + text: $text, + 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/classes/handler/admin/UploadDeleteHandler.php b/classes/handler/admin/UploadDeleteHandler.php new file mode 100644 index 0000000..63cb7e2 --- /dev/null +++ b/classes/handler/admin/UploadDeleteHandler.php @@ -0,0 +1,25 @@ +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/classes/handler/admin/UploadEditNoteHandler.php b/classes/handler/admin/UploadEditNoteHandler.php new file mode 100644 index 0000000..020aca3 --- /dev/null +++ b/classes/handler/admin/UploadEditNoteHandler.php @@ -0,0 +1,25 @@ +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/classes/handler/admin/UploadsHandler.php b/classes/handler/admin/UploadsHandler.php new file mode 100644 index 0000000..da6b015 --- /dev/null +++ b/classes/handler/admin/UploadsHandler.php @@ -0,0 +1,73 @@ +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/classes/logging.php b/classes/logging.php new file mode 100644 index 0000000..45c3fbd --- /dev/null +++ b/classes/logging.php @@ -0,0 +1,177 @@ +name[0]); + $color = match ($level) { + LogLevel::ERROR => Color::RED, + LogLevel::INFO, LogLevel::DEBUG => Color::WHITE, + LogLevel::WARNING => Color::YELLOW + }; + + $buf .= AnsiUtil::wrap($letter.AnsiUtil::wrap('='.AnsiUtil::wrap($num, bold: true)), fg: $color).' '; + $buf .= AnsiUtil::wrap($exec_time, fg: Color::CYAN).' '; + if (!is_null($errno)) { + $buf .= AnsiUtil::wrap($errfile, fg: Color::GREEN); + $buf .= AnsiUtil::wrap(':', fg: Color::WHITE); + $buf .= AnsiUtil::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(self::$logFile); + } + + 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/classes/markup.php b/classes/markup.php new file mode 100644 index 0000000..f6ddd0f --- /dev/null +++ b/classes/markup.php @@ -0,0 +1,46 @@ +text($md); + } + + public static function toc(string $md): string { + $pd = new MyParsedown([ + 'toc' => [ + 'lowercase' => true, + 'transliterate' => true, + 'urlencode' => false, + 'headings' => ['h1', 'h2', 'h3'] + ] + ]); + $pd->text($md); + return $pd->contentsList(); + } + + 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 htmlImagesFix(string $html, bool $is_retina, string $user_theme): string { + global $config; + $is_dark_theme = $user_theme === 'dark'; + return preg_replace_callback( + '/('.preg_quote($config['uploads_host'], '/').'\/\w{8}\/)([ap])(\d+)x(\d+)(\.jpg)/', + function($match) use ($is_retina, $is_dark_theme) { + $mult = $is_retina ? 2 : 1; + $is_alpha = $match[2] == 'a'; + return $match[1].$match[2].(intval($match[3])*$mult).'x'.(intval($match[4])*$mult).($is_alpha && $is_dark_theme ? '_dark' : '').$match[5]; + }, + $html + ); + } + +} \ No newline at end of file diff --git a/classes/model/Model.php b/classes/model/Model.php new file mode 100644 index 0000000..c287c7f --- /dev/null +++ b/classes/model/Model.php @@ -0,0 +1,232 @@ + $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 ModelType::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 ModelType::INTEGER: + $value = (int)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + + case ModelType::FLOAT: + $value = (float)$value; + $db_upd[$name] = $value; + $model_upd[$field['model_name']] = $value; + break; + + case ModelType::BOOLEAN: + $db_upd[$name] = $value ? 1 : 0; + $model_upd[$field['model_name']] = $value; + break; + + case ModelType::JSON: + $db_upd[$name] = json_encode($value, JSON_UNESCAPED_UNICODE); + $model_upd[$field['model_name']] = $value; + break; + + case ModelType::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(ModelType $type, $value) { + switch ($type) { + case ModelType::BOOLEAN: + return (bool)$value; + + case ModelType::INTEGER: + return (int)$value; + + case ModelType::FLOAT: + return (float)$value; + + case ModelType::ARRAY: + return array_filter(explode(',', $value)); + + case ModelType::JSON: + $val = json_decode($value, true); + if (!$val) + $val = null; + return $val; + + case ModelType::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 = ModelType::STRING; + else { + $typename = $type->getName(); + switch ($typename) { + case 'string': + $mytype = ModelType::STRING; + break; + case 'int': + $mytype = ModelType::INTEGER; + break; + case 'float': + $mytype = ModelType::FLOAT; + break; + case 'array': + $mytype = ModelType::ARRAY; + break; + case 'bool': + $mytype = ModelType::BOOLEAN; + break; + } + + if ($phpdoc != '') { + $pos = strpos($phpdoc, '@'); + if ($pos === false) + continue; + + if (substr($phpdoc, $pos+1, 4) == 'json') + $mytype = ModelType::JSON; + else if (substr($phpdoc, $pos+1, 5) == 'array') + $mytype = ModelType::ARRAY; + else if (substr($phpdoc, $pos+1, 10) == 'serialized') + $mytype = ModelType::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/classes/model/ModelType.php b/classes/model/ModelType.php new file mode 100644 index 0000000..1b15254 --- /dev/null +++ b/classes/model/ModelType.php @@ -0,0 +1,13 @@ +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 $is_retina, string $user_theme): string { + $html = $this->html; + $html = markup::htmlImagesFix($html, $is_retina, $user_theme); + 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/classes/model/Post.php b/classes/model/Post.php new file mode 100644 index 0000000..f913abe --- /dev/null +++ b/classes/model/Post.php @@ -0,0 +1,199 @@ +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']); + } + + if ((isset($data['toc']) && $data['toc']) || $this->toc) { + $data['toc_html'] = markup::toc($data['md']); + } + + 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 $is_retina, string $theme): string { + $html = $this->html; + $html = markup::htmlImagesFix($html, $is_retina, $theme); + return $html; + } + + public function getToc(): ?string { + return $this->toc ? $this->tocHtml : null; + } + + 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('model\Tag', $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, $u->imageMayHaveAlphaChannel())) + $images_affected++; + } + } + + return $images_affected; + } + +} diff --git a/classes/model/Tag.php b/classes/model/Tag.php new file mode 100644 index 0000000..0c2f51c --- /dev/null +++ b/classes/model/Tag.php @@ -0,0 +1,27 @@ +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/classes/model/Upload.php b/classes/model/Upload.php new file mode 100644 index 0000000..782159e --- /dev/null +++ b/classes/model/Upload.php @@ -0,0 +1,168 @@ +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; + } + + $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p'; + return 'https://'.$config['uploads_host'].'/'.$this->randomId.'/'.$prefix.$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 .= ' '; + 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); + } + + // assume all png images have alpha channel + // i know this is wrong, but anyway + public function imageMayHaveAlphaChannel(): bool { + return strtolower(extension($this->name)) == 'png'; + } + + 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]; + } + + public function createImagePreview(?int $w = null, + ?int $h = null, + bool $force_update = false, + bool $may_have_alpha = false): bool { + global $config; + + $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; + $updated = false; + + foreach (themes::getThemes() as $theme) { + if (!$may_have_alpha && $theme == 'dark') + continue; + + for ($mult = 1; $mult <= 2; $mult++) { + $dw = $w * $mult; + $dh = $h * $mult; + + $prefix = $may_have_alpha ? 'a' : 'p'; + $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg'; + + if (file_exists($dst)) { + if (!$force_update) + continue; + unlink($dst); + } + + $img = imageopen($orig); + imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme)); + 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('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) { + if (is_file($dir.'/'.$f)) + unlink($dir.'/'.$f); + else + logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!'); + $deleted++; + } + } + return $deleted; + } + +} diff --git a/classes/pages.php b/classes/pages.php new file mode 100644 index 0000000..37fc5e6 --- /dev/null +++ b/classes/pages.php @@ -0,0 +1,32 @@ +insert('pages', $data); + } + + 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('model\Page', $db->fetchAll($db->query("SELECT * FROM pages"))); + } + +} \ No newline at end of file diff --git a/classes/posts.php b/classes/posts.php new file mode 100644 index 0000000..e371369 --- /dev/null +++ b/classes/posts.php @@ -0,0 +1,194 @@ +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)); + } + + /** + * @return Post[] + */ + 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('\model\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('model\Post', $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('\model\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/classes/themes.php b/classes/themes.php new file mode 100644 index 0000000..839377f --- /dev/null +++ b/classes/themes.php @@ -0,0 +1,38 @@ + [ + 'bg' => 0x222222, + // 'alpha' => 0x303132, + 'alpha' => 0x222222, + ], + 'light' => [ + 'bg' => 0xffffff, + // 'alpha' => 0xf2f2f2, + 'alpha' => 0xffffff, + ] + ]; + + public static function getThemes(): array { + return array_keys(self::$Themes); + } + + public static function themeExists(string $name): bool { + return array_key_exists($name, self::$Themes); + } + + public static function getThemeAlphaColorAsRGB(string $name): array { + $color = self::$Themes[$name]['alpha']; + $r = ($color >> 16) & 0xff; + $g = ($color >> 8) & 0xff; + $b = $color & 0xff; + return [$r, $g, $b]; + } + + public static function getUserTheme(): string { + return ($_COOKIE['theme'] ?? 'auto'); + } + +} \ No newline at end of file diff --git a/classes/uploads.php b/classes/uploads.php new file mode 100644 index 0000000..81a16f3 --- /dev/null +++ b/classes/uploads.php @@ -0,0 +1,147 @@ +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', $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/classes/util/AnsiColor.php b/classes/util/AnsiColor.php new file mode 100644 index 0000000..f1b9f95 --- /dev/null +++ b/classes/util/AnsiColor.php @@ -0,0 +1,14 @@ +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/cli_util.php b/cli_util.php new file mode 100755 index 0000000..be335f8 --- /dev/null +++ b/cli_util.php @@ -0,0 +1,83 @@ +#!/usr/bin/env php +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 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"; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0b01e6d --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "require": { + "gch1p/parsedown-highlight": "master", + "gch1p/parsedown-highlight-extended": "dev-main", + "erusev/parsedown": "1.8.0-beta-7", + "ext-mbstring": "*", + "ext-gd": "*", + "ext-mysqli": "*", + "ext-json": "*" + }, + "repositories": [ + { + "url": "https://github.com/gch1p/parsedown-highlight", + "type": "git" + }, + { + "url": "https://github.com/gch1p/ParsedownHighlightExtended", + "type": "git" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "preferred-install": "dist" +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..9454cb5 --- /dev/null +++ b/composer.lock @@ -0,0 +1,308 @@ +{ + "_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": "ca8ca355a9f6ce85170f473238a57d6b", + "packages": [ + { + "name": "erusev/parsedown", + "version": "1.8.0-beta-7", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown.git", + "reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955", + "reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955", + "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" + ], + "support": { + "issues": "https://github.com/erusev/parsedown/issues", + "source": "https://github.com/erusev/parsedown/tree/1.8.0-beta-7" + }, + "time": "2019-03-17T18:47:21+00:00" + }, + { + "name": "erusev/parsedown-extra", + "version": "0.8.1", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown-extra.git", + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef", + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef", + "shasum": "" + }, + "require": { + "erusev/parsedown": "^1.7.4" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "ParsedownExtra": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "An extension of Parsedown that adds support for Markdown Extra.", + "homepage": "https://github.com/erusev/parsedown-extra", + "keywords": [ + "markdown", + "markdown extra", + "parsedown", + "parser" + ], + "support": { + "issues": "https://github.com/erusev/parsedown-extra/issues", + "source": "https://github.com/erusev/parsedown-extra/tree/0.8.x" + }, + "time": "2019-12-30T23:20:37+00:00" + }, + { + "name": "gch1p/parsedown-highlight", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/gch1p/parsedown-highlight", + "reference": "d017545cc221f4becac0f7fc0570304ceabba846" + }, + "require": { + "erusev/parsedown": "1.8.0-beta-7", + "erusev/parsedown-extra": "0.8.1", + "php": ">=7.1", + "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" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "gch1p\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests" + } + }, + "scripts": { + "test": [ + "phpunit" + ], + "styles:lint": [ + "php-cs-fixer fix --dry-run --diff" + ], + "styles:fix": [ + "php-cs-fixer fix" + ] + }, + "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/gch1p/parsedown-highlight", + "keywords": [ + "code", + "markdown", + "parsedown" + ], + "time": "2023-03-01T22:25:56+00:00" + }, + { + "name": "gch1p/parsedown-highlight-extended", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/gch1p/ParsedownHighlightExtended", + "reference": "e2d9d7eae203680690d61877fbef2dc2df61dd0b" + }, + "require": { + "erusev/parsedown": "^1.8-beta-6", + "erusev/parsedown-extra": "^0.8.0", + "gch1p/parsedown-highlight": "master", + "php": ">=7.2" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-0": { + "ParsedownExtended": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Hoegh", + "homepage": "https://github.com/benjaminhoegh" + } + ], + "description": "An extension for ParsedownHighlight.", + "homepage": "https://github.com/gch1p/ParsedownHighlightExtended", + "keywords": [ + "markdown", + "markdown extended", + "parsedown", + "parsedown extended" + ], + "time": "2023-03-01T22:30:01+00:00" + }, + { + "name": "scrivo/highlight.php", + "version": "v9.18.1.10", + "source": { + "type": "git", + "url": "https://github.com/scrivo/highlight.php.git", + "reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/850f4b44697a2552e892ffe71490ba2733c2fc6e", + "reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.4" + }, + "require-dev": { + "phpunit/phpunit": "^4.8|^5.7", + "sabberworm/php-css-parser": "^8.3", + "symfony/finder": "^2.8|^3.4|^5.4", + "symfony/var-dumper": "^2.8|^3.4|^5.4" + }, + "suggest": { + "ext-mbstring": "Allows highlighting code with unicode characters and supports language with unicode keywords" + }, + "type": "library", + "autoload": { + "files": [ + "HighlightUtilities/functions.php" + ], + "psr-0": { + "Highlight\\": "", + "HighlightUtilities\\": "" + } + }, + "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": "Maintainer" + }, + { + "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" + ], + "support": { + "issues": "https://github.com/scrivo/highlight.php/issues", + "source": "https://github.com/scrivo/highlight.php" + }, + "funding": [ + { + "url": "https://github.com/allejo", + "type": "github" + } + ], + "time": "2022-12-17T21:53:22+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": { + "gch1p/parsedown-highlight-extended": 20 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "ext-mbstring": "*", + "ext-gd": "*", + "ext-mysqli": "*", + "ext-json": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..b1f1244 --- /dev/null +++ b/config.php @@ -0,0 +1,27 @@ + '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', + + 'dirs_mode' => 0775, + 'files_mode' => 0664, + 'group' => 33, // id -g www-data + 'is_dev' => false, +]; diff --git a/deploy/build_common.sh b/deploy/build_common.sh new file mode 100644 index 0000000..97d0965 --- /dev/null +++ b/deploy/build_common.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +set -e + +INDIR= +OUTDIR= + +error() { + >&2 echo "error: $@" +} + +warning() { + >&2 echo "warning: $@" +} + +die() { + error "$@" + exit 1 +} + +usage() { + local code="$1" + cat < "$file.tmp" + rm "$file" + mv "$file.tmp" "$file" +} + +create_dark_patch() { + local entry_name="$1" + local light_file="$OUTDIR/$entry_name.css" + local dark_file="$OUTDIR/${entry_name}_dark.css" + + "$DIR"/gen_css_diff.js "$light_file" "$dark_file" > "$dark_file.diff" + rm "$dark_file" + mv "$dark_file.diff" "$dark_file" +} + +THEMES="light dark" +TARGETS="common admin" + +input_args "$@" +check_args + +[ -x "$CLEANCSS" ] || die "cleancss is not found" + +for theme in $THEMES; do + for target in $TARGETS; do + build_scss "$target" "$theme" + done +done + +for target in $TARGETS; do + create_dark_patch "$target" + for theme in $THEMES; do cleancss "$target" "$theme"; done +done \ No newline at end of file diff --git a/deploy/build_js.sh b/deploy/build_js.sh new file mode 100644 index 0000000..b1019f7 --- /dev/null +++ b/deploy/build_js.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +PROGNAME="$0" +DIR="$( cd "$( dirname "$(readlink -f "${BASH_SOURCE[0]}")" )" && pwd )" + +. $DIR/build_common.sh + +# suckless version of webpack +# watch and learn, bitches! +build_chunk() { + local name="$1" + local output="$OUTDIR/$name.js" + local not_first=0 + for file in "$INDIR/$name"/*.js; do + # insert newline before out comment + [ "$not_first" = "1" ] && echo "" >> "$output" + echo "/* $(basename "$file") */" >> "$output" + + cat "$file" >> "$output" + not_first=1 + done +} + +TARGETS="common admin" + +input_args "$@" +check_args + +for f in $TARGETS; do + build_chunk "$f" +done \ No newline at end of file diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..d981fa4 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +die() { + >&2 echo "error: $@" + exit 1 +} + +set -e + +DIR="$( cd "$( dirname "$(readlink -f "${BASH_SOURCE[0]}")" )" && pwd )" + +DEV_DIR="$(realpath "$DIR/../")" +STAGING_DIR="$HOME/staging" +PROD_DIR="$HOME/www" +PHP=/usr/bin/php + +git push origin master + +[ -d "$STAGING_DIR" ] || mkdir "$STAGING_DIR" +pushd "$STAGING_DIR" + +if [ ! -d .git ]; then + git init + git remote add origin git-hidden@ch1p.io:4in1_ws_web.git + git fetch + git checkout master +fi + +git reset --hard +git pull origin master + +composer install --no-dev --optimize-autoloader --ignore-platform-reqs + +if [ ! -d node_modules ]; then + npm i +fi + +cp "$DEV_DIR/config-local.php" . +sed -i '/is_dev/d' ./config-local.php + +"$DIR"/build_js.sh -i "$DEV_DIR/htdocs/js" -o "$STAGING_DIR/htdocs/dist-js" || die "build_js failed" +"$DIR"/build_css.sh -i "$DEV_DIR/htdocs/scss" -o "$STAGING_DIR/htdocs/dist-css" || die "build_css failed" +$PHP "$DIR"/gen_static_config.php -i "$STAGING_DIR/htdocs" > "$STAGING_DIR/config-static.php" || die "gen_static_config failed" + +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/js' \ + --exclude='/htdocs/sass.php' \ + --exclude='/htdocs/js.php' \ + --exclude='*.sh' \ + --exclude='*.sql' diff --git a/deploy/gen_css_diff.js b/deploy/gen_css_diff.js new file mode 100644 index 0000000..5ca1945 --- /dev/null +++ b/deploy/gen_css_diff.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +const {generateCSSPatch} = require('css-patch') +const fs = require('fs') + +const files = process.argv.slice(2) +if (files.length !== 2) { + console.log(`usage: ${process.argv[0]} file1 file2`) + process.exit() +} + +const css1 = fs.readFileSync(files[0], 'utf-8') +const css2 = fs.readFileSync(files[1], 'utf-8') + +console.log(generateCSSPatch(css1, css2)) \ No newline at end of file diff --git a/deploy/gen_static_config.php b/deploy/gen_static_config.php new file mode 100644 index 0000000..de56e40 --- /dev/null +++ b/deploy/gen_static_config.php @@ -0,0 +1,57 @@ +#!/usr/bin/env php + 0) { + switch ($argv[0]) { + case '-i': + array_shift($argv); + $input_dir = array_shift($argv); + break; + + default: + cli::die('unsupported argument: '.$argv[0]); + } +} + +if (is_null($input_dir)) + cli::die("input directory has not been specified"); + +$hashes = []; +foreach (['css', 'js'] as $type) { + $entries = glob_recursive($input_dir.'/dist-'.$type.'/*.'.$type); + if (empty($entries)) { + cli::error("warning: no files found in $input_dir/dist-$type"); + continue; + } + + foreach ($entries as $file) + $hashes[$type.'/'.basename($file)] = get_hash($file); +} + +echo " $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(): \database\SQLiteConnection|\database\MySQLConnection|null { + global $config; + static $link = null; + + if (!is_null($link)) + return $link; + + switch ($config['db']['type']) { + case 'mysql': + $link = new \database\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 \database\SQLiteConnection($config['db']['path']); + break; + + default: + logError('invalid database type'); + break; + } + + return $link; +} + + +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); } \ No newline at end of file diff --git a/htdocs/favicon.ico b/htdocs/favicon.ico new file mode 100644 index 0000000..54442e2 Binary files /dev/null and b/htdocs/favicon.ico differ diff --git a/htdocs/favicon.png b/htdocs/favicon.png new file mode 100644 index 0000000..8e633f8 Binary files /dev/null and b/htdocs/favicon.png 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 @@ + \ 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 Binary files /dev/null and b/htdocs/img/contact.gif differ diff --git a/htdocs/img/contact@2x.gif b/htdocs/img/contact@2x.gif new file mode 100644 index 0000000..ab79cd3 Binary files /dev/null and b/htdocs/img/contact@2x.gif differ diff --git a/htdocs/index.php b/htdocs/index.php new file mode 100644 index 0000000..8e02c8e --- /dev/null +++ b/htdocs/index.php @@ -0,0 +1,26 @@ +add('/', 'index') + //->add('about/', 'about') + ->add('([a-zA-Z0-9\-]+)/', 'auto name=$(1)') + + ->add('feed.rss', 'RSS') + + ->add('admin/', 'admin/index') + ->add('admin/{login,logout,log}/', 'admin/${1}') + + ->add('([a-zA-Z0-9\-]+)/{delete,edit}/', 'admin/auto_${1} short_name=$(1)') + ->add('([a-zA-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.php b/htdocs/js.php new file mode 100644 index 0000000..c9939fe --- /dev/null +++ b/htdocs/js.php @@ -0,0 +1,31 @@ + 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); diff --git a/htdocs/js/admin/12-upload-list.js b/htdocs/js/admin/12-upload-list.js new file mode 100644 index 0000000..5b496f6 --- /dev/null +++ b/htdocs/js/admin/12-upload-list.js @@ -0,0 +1,19 @@ +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/00-polyfills.js b/htdocs/js/common/00-polyfills.js new file mode 100644 index 0000000..74ec195 --- /dev/null +++ b/htdocs/js/common/00-polyfills.js @@ -0,0 +1,47 @@ +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; + }; +} + +if (!Object.assign) { + Object.defineProperty(Object, 'assign', { + enumerable: false, + configurable: true, + writable: true, + value: function(target, firstSource) { + 'use strict'; + if (target === undefined || target === null) { + throw new TypeError('Cannot convert first argument to object'); + } + + var to = Object(target); + for (var i = 1; i < arguments.length; i++) { + var nextSource = arguments[i]; + if (nextSource === undefined || nextSource === null) { + continue; + } + + var keysArray = Object.keys(Object(nextSource)); + for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) { + var nextKey = keysArray[nextIndex]; + var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey); + if (desc !== undefined && desc.enumerable) { + to[nextKey] = nextSource[nextKey]; + } + } + } + return to; + } + }); +} \ No newline at end of file diff --git a/htdocs/js/common/02-ajax.js b/htdocs/js/common/02-ajax.js new file mode 100644 index 0000000..c432a51 --- /dev/null +++ b/htdocs/js/common/02-ajax.js @@ -0,0 +1,118 @@ +// +// 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 = Object.assign({}, 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') + } + +})(); diff --git a/htdocs/js/common/03-dom.js b/htdocs/js/common/03-dom.js new file mode 100644 index 0000000..d05bcd0 --- /dev/null +++ b/htdocs/js/common/03-dom.js @@ -0,0 +1,117 @@ +// +// 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 + "; domain=" + window.appConfig.cookieHost + "; path=/"; +} + +function unsetCookie(name) { + document.cookie = name + '=; Max-Age=-99999999; domain=' + window.appConfig.cookieHost + "; path=/"; +} + +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; +} diff --git a/htdocs/js/common/04-util.js b/htdocs/js/common/04-util.js new file mode 100644 index 0000000..da95e39 --- /dev/null +++ b/htdocs/js/common/04-util.js @@ -0,0 +1,89 @@ +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) + } + } +} + +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 timestamp() { + return Math.floor(Date.now() / 1000) +} + +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 parseUrl(uri) { + var parser = document.createElement('a'); + parser.href = uri; + + return { + protocol: parser.protocol, // => "http:" + host: parser.host, // => "example.com:3000" + hostname: parser.hostname, // => "example.com" + port: parser.port, // => "3000" + pathname: parser.pathname, // => "/pathname/" + hash: parser.hash, // => "#hash" + search: parser.search, // => "?search=test" + origin: parser.origin, // => "http://example.com:3000" + path: (parser.pathname || '') + (parser.search || '') + } +} + +function once(fn, context) { + var result; + return function() { + if (fn) { + result = fn.apply(context || this, arguments); + fn = null; + } + return result; + }; +} \ No newline at end of file diff --git a/htdocs/js/common/10-lang.js b/htdocs/js/common/10-lang.js new file mode 100644 index 0000000..bd0d8e7 --- /dev/null +++ b/htdocs/js/common/10-lang.js @@ -0,0 +1,5 @@ +function lang(key) { + return __lang[key] !== undefined ? __lang[key] : '{'+key+'}'; +} + +window.__lang = {}; \ No newline at end of file diff --git a/htdocs/js/common/20-dynlogo.js b/htdocs/js/common/20-dynlogo.js new file mode 100644 index 0000000..2d62a27 --- /dev/null +++ b/htdocs/js/common/20-dynlogo.js @@ -0,0 +1,60 @@ +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); \ No newline at end of file diff --git a/htdocs/js/common/30-static-manager.js b/htdocs/js/common/30-static-manager.js new file mode 100644 index 0000000..af2d91f --- /dev/null +++ b/htdocs/js/common/30-static-manager.js @@ -0,0 +1,48 @@ +var StaticManager = { + /** + * @type {string[]} + */ + loadedStyles: [], + + /** + * @type {object} + */ + versions: {}, + + /** + * @param {string[]} loadedStyles + * @param {object} versions + */ + init: function(loadedStyles, versions) { + this.loadedStyles = loadedStyles; + this.versions = versions; + }, + + /** + * @param {string} name + * @param {string} theme + * @param {function} callback + */ + loadStyle: function(name, theme, callback) { + var url, id; + if (!window.appConfig.devMode) { + if (theme === 'dark') + name += '_dark'; + url = '/dist-css/'+name+'.css?'+this.versions.css[name]; + id = 'style_'+name; + } else { + url = '/sass.php?name='+name+'&theme='+theme+'&v='+timestamp(); + id = 'style_'+name+(theme === 'dark' ? '_dark' : ''); + } + + var el = document.createElement('link'); + el.onerror = callback; + el.onload = callback; + el.setAttribute('rel', 'stylesheet'); + el.setAttribute('type', 'text/css'); + el.setAttribute('id', id); + el.setAttribute('href', url); + + document.getElementsByTagName('head')[0].appendChild(el); + } +}; diff --git a/htdocs/js/common/35-theme-switcher.js b/htdocs/js/common/35-theme-switcher.js new file mode 100644 index 0000000..c612152 --- /dev/null +++ b/htdocs/js/common/35-theme-switcher.js @@ -0,0 +1,214 @@ +var ThemeSwitcher = (function() { + /** + * @type {string[]} + */ + var modes = ['auto', 'dark', 'light']; + + /** + * @type {number} + */ + var currentModeIndex = -1; + + /** + * @type {boolean|null} + */ + var systemState = null; + + /** + * @type {function[]} + */ + var changeListeners = []; + + /** + * @returns {boolean} + */ + function isSystemModeSupported() { + try { + // crashes on: + // Mozilla/5.0 (Windows NT 6.2; ARM; Trident/7.0; Touch; rv:11.0; WPDesktop; Lumia 630 Dual SIM) like Gecko + // Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1 + // Mozilla/5.0 (iPad; CPU OS 12_5_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1 + // + // error examples: + // - window.matchMedia("(prefers-color-scheme: dark)").addEventListener is not a function. (In 'window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.onSystemSettingChange.bind(this))', 'window.matchMedia("(prefers-color-scheme: dark)").addEventListener' is undefined) + // - Object [object MediaQueryList] has no method 'addEventListener' + return !!window['matchMedia'] + && typeof window.matchMedia("(prefers-color-scheme: dark)").addEventListener === 'function'; + } catch (e) { + return false + } + } + + /** + * @returns {boolean} + */ + function isDarkModeApplied() { + var st = StaticManager.loadedStyles; + for (var i = 0; i < st.length; i++) { + var name = st[i]; + if (ge('style_'+name+'_dark')) + return true; + } + return false; + } + + /** + * @returns {string} + */ + function getSavedMode() { + var val = getCookie('theme'); + if (!val) + return modes[0]; + if (modes.indexOf(val) === -1) { + console.error('[ThemeSwitcher getSavedMode] invalid cookie value') + unsetCookie('theme') + return modes[0] + } + return val + } + + /** + * @param {boolean} dark + */ + function changeTheme(dark) { + addClass(document.body, 'theme-changing'); + + var onDone = function() { + window.requestAnimationFrame(function() { + removeClass(document.body, 'theme-changing'); + changeListeners.forEach(function(f) { + try { + f(dark) + } catch (e) { + console.error('[ThemeSwitcher->changeTheme->onDone] error while calling user callback:', e) + } + }) + }) + }; + + window.requestAnimationFrame(function() { + if (dark) + enableDark(onDone); + else + disableDark(onDone); + }) + } + + /** + * @param {function} callback + */ + function enableDark(callback) { + var names = []; + StaticManager.loadedStyles.forEach(function(name) { + var el = ge('style_'+name+'_dark'); + if (el) + return; + names.push(name); + }); + + var left = names.length; + names.forEach(function(name) { + StaticManager.loadStyle(name, 'dark', once(function(e) { + left--; + if (left === 0) + callback(); + })); + }) + } + + /** + * @param {function} callback + */ + function disableDark(callback) { + StaticManager.loadedStyles.forEach(function(name) { + var el = ge('style_'+name+'_dark'); + if (el) + el.remove(); + }) + callback(); + } + + /** + * @param {string} mode + */ + function setLabel(mode) { + var labelEl = ge('theme-switcher-label'); + labelEl.innerHTML = escape(lang('theme_'+mode)); + } + + return { + init: function() { + var cur = getSavedMode(); + currentModeIndex = modes.indexOf(cur); + + var systemSupported = isSystemModeSupported(); + if (!systemSupported) { + if (currentModeIndex === 0) { + modes.shift(); // remove 'auto' from the list + currentModeIndex = 1; // set to 'light' + if (isDarkModeApplied()) + disableDark(); + } + } else { + /** + * @param {boolean} dark + */ + var onSystemChange = function(dark) { + var prevSystemState = systemState; + systemState = dark; + + if (modes[currentModeIndex] !== 'auto') + return; + + if (systemState !== prevSystemState) + changeTheme(systemState); + }; + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) { + onSystemChange(e.matches === true) + }); + + onSystemChange(window.matchMedia('(prefers-color-scheme: dark)').matches === true); + } + + setLabel(modes[currentModeIndex]); + }, + + next: function(e) { + if (hasClass(document.body, 'theme-changing')) { + console.log('next: theme changing is in progress, ignoring...') + return; + } + + currentModeIndex = (currentModeIndex + 1) % modes.length; + switch (modes[currentModeIndex]) { + case 'auto': + if (systemState !== null && systemState !== isDarkModeApplied()) + changeTheme(systemState); + break; + + case 'light': + if (isDarkModeApplied()) + changeTheme(false); + break; + + case 'dark': + if (!isDarkModeApplied()) + changeTheme(true); + break; + } + + setLabel(modes[currentModeIndex]); + setCookie('theme', modes[currentModeIndex]); + + return cancelEvent(e); + }, + + /** + * @param {function} f + */ + addOnChangeListener: function(f) { + changeListeners.push(f); + } + }; +})(); \ No newline at end of file diff --git a/htdocs/js/common/90-retina.js b/htdocs/js/common/90-retina.js new file mode 100644 index 0000000..46b9f17 --- /dev/null +++ b/htdocs/js/common/90-retina.js @@ -0,0 +1,9 @@ +// set/remove retina cookie +(function() { + var isRetina = window.devicePixelRatio >= 1.5; + if (isRetina) { + setCookie('is_retina', 1, 365); + } else { + unsetCookie('is_retina'); + } +})(); \ No newline at end of file diff --git a/htdocs/sass.php b/htdocs/sass.php new file mode 100644 index 0000000..186b706 --- /dev/null +++ b/htdocs/sass.php @@ -0,0 +1,55 @@ + ['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/app/blog.scss b/htdocs/scss/app/blog.scss new file mode 100644 index 0000000..c0e3ca8 --- /dev/null +++ b/htdocs/scss/app/blog.scss @@ -0,0 +1,476 @@ +@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: $grey; + font-size: $fs - 2px; +} +.blog-upload-item-note { + padding: 0 0 4px; +} +.blog-upload-item-md { + margin-top: 3px; +} + +.blog-post-wrap2 { + display: table; + table-layout: fixed; + border: none; + border-collapse: collapse; + width: 100%; +} +.blog-post-wrap1 { + display: table-row; +} +.blog-post { + display: table-cell; + vertical-align: top; +} +.blog-post-toc { + display: table-cell; + vertical-align: top; + font-size: $fs - 2px; + + &-wrap { + position: sticky; + top: 0; + padding: 10px 0 0 20px; + overflow-y: auto; + max-height: 100vh; + box-sizing: border-box; + } + + &-inner-wrap { + border-left: 1px $border-color solid; + padding-left: 20px; + margin-bottom: 10px; + + ul { + list-style-type: none; + margin: 5px 0; + padding-left: 18px; + } + > ul { + padding-left: 0 !important; + } + + li { + margin: 2px 0; + line-height: 150%; + > a { + display: inline-block; + } + } + } + + &-title { + font-weight: bold; + padding: 6px 0; + } +} +body.wide .blog-post { + width: $base_width; +} + +@media screen and (max-width: 1150px) { + .blog-post-toc { + display: none; + } + .blog-post { + width: auto !important; + } + .blog-post-wrap2 { + width: 100%; + } +} + +.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: $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; +} + +.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: $light-grey; + @include user-select(none); + } + } + + blockquote { + border-left: 3px $border-color solid; + margin-left: 0; + padding: 5px 0 5px 12px; + color: $grey; + &:first-child { + padding-top: 0; + margin-top: 0; + } + } + + table { + border-collapse: collapse; + border: 1px $border-color solid; + } + table thead td { + font-weight: bold; + } + table td, table th { + padding: 7px; + border: 1px $border-color solid; + } + table th { + padding-right: 12px; + } + + 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 $border-color 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: $grey; + //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: $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 new file mode 100644 index 0000000..c734e8f --- /dev/null +++ b/htdocs/scss/app/common.scss @@ -0,0 +1,281 @@ +@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; +} + +@media screen and (min-width: $wide_width) { + body.wide .base-width { + max-width: $wide_width; + margin-left: auto; + margin-right: auto; + } +} + +input[type="text"], +input[type="password"], +textarea { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + box-sizing: border-box; + border: 1px $input-border solid; + border-radius: 0; + background-color: $input-bg; + color: $fg; + font-family: $ff; + font-size: $fs; + padding: 6px; + outline: none; + @include radius(3px); + + &:focus { + border-color: $input-border-focused; + } +} + +textarea { + resize: vertical; +} + +//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; +} + + +table.contacts { + border: 0; + border-collapse: collapse; + margin: 8px auto 0; +} +table.contacts td { + padding-bottom: 15px; + vertical-align: top; +} +table.contacts td.label { + text-align: right; + width: 30%; + color: $dark-grey; +} +table.contacts td.value { + text-align: left; + padding-left: 8px; +} +table.contacts td.value span { + background: $inline-code-block-bg; + padding: 3px 7px 4px; + border-radius: 3px; + color: $fg; + 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: $dark-grey; + > a { + color: $dark-grey; + border-bottom: 1px $border-color solid; + &:hover { + text-decoration: none; + color: $link-color; + border-bottom-color: $link-color; + } + } +} + +.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: $grey; + @include radius(3px); + background-color: $dark-bg; +} + +.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: $grey; + margin-left: 2px; +} +.md-file-attach-note { + color: $fg; + 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: $dark-grey; + padding: 2px 0 4px; +} + +.md-video video { + max-width: 100%; +} + +.language-ascii { + line-height: 125% !important; +} diff --git a/htdocs/scss/app/form.scss b/htdocs/scss/app/form.scss new file mode 100644 index 0000000..197732c --- /dev/null +++ b/htdocs/scss/app/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: $grey; + } + .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/app/head.scss b/htdocs/scss/app/head.scss new file mode 100644 index 0000000..d00e472 --- /dev/null +++ b/htdocs/scss/app/head.scss @@ -0,0 +1,97 @@ +.head { + display: table; + width: 100%; + border-collapse: collapse; + border-bottom: 2px $border-color solid; +} +.head-inner { + display: table-row; +} + +.head-logo-wrap { + display: table-cell; + padding: 8px 0; +} +.head-logo { + padding: 10px 14px; + margin-left: -14px; + z-index: 5; + font-weight: bold; + font-size: 16px; + left: 0; + background-color: transparent; + + &:hover { + border-radius: 4px; + background-color: $hover-hl; + } + + > a:hover { + text-decoration: none; + } + + &-title { + padding-bottom: 3px; + &-author { + font-weight: normal; + color: $grey; + } + } + + &-subtitle { + font-size: 13px; + color: $fg; + font-weight: normal; + } +} +//body:not(.theme-changing) .head-logo { +// @include transition(background-color, 0.03s); +//} + +.head-items { + text-align: right; + display: table-cell; + vertical-align: middle; + color: $dark-grey; // color of separators +} +a.head-item { + color: $fg; + font-size: $fs - 1px; + display: inline-block; + padding: 8px 12px; + //margin-right: -16px; + + &:hover { + border-radius: 4px; + background-color: $hover-hl; + text-decoration: none; + } + + > span { + position: relative; + + > span { + padding: 2px 0; + + &.moon-icon { + padding: 0; + position: absolute; + top: 0; + left: 0; + + > svg path { + fill: $fg; + } + } + } + } + + &.is-theme-switcher > span { + padding-left: 20px; + } + + //&:last-child > span { + // border-right: 0; + // padding-right: 1px; + //} +} \ No newline at end of file diff --git a/htdocs/scss/app/hljs.scss b/htdocs/scss/app/hljs.scss new file mode 100644 index 0000000..913c45e --- /dev/null +++ b/htdocs/scss/app/hljs.scss @@ -0,0 +1 @@ +@import "../hljs/github.css"; diff --git a/htdocs/scss/app/mobile.scss b/htdocs/scss/app/mobile.scss new file mode 100644 index 0000000..bf5d9d5 --- /dev/null +++ b/htdocs/scss/app/mobile.scss @@ -0,0 +1,51 @@ +@import '../vars'; + +textarea { + -webkit-overflow-scrolling: touch; +} + +.head { + padding: 0; +} + +// header +.head-logo { + position: static; + display: block; + overflow: hidden; + white-space: nowrap; + padding: 18px $side-padding 7px; +} +.head-logo::after { + display: none; +} +.head-items { + float: none; + padding: 0 $side-padding 0 $side-padding - 2px; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; +} +a.head-item { + float: none; + display: inline-block; +} +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; +} + +// blog +.blog-tags { + display: none; +} +.blog-list.withtags { + margin-right: 0; +} +.blog-post-text code { + word-wrap: break-word; +} \ No newline at end of file diff --git a/htdocs/scss/app/pages.scss b/htdocs/scss/app/pages.scss new file mode 100644 index 0000000..873a6ae --- /dev/null +++ b/htdocs/scss/app/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/bundle_admin.scss b/htdocs/scss/bundle_admin.scss new file mode 100644 index 0000000..06808d0 --- /dev/null +++ b/htdocs/scss/bundle_admin.scss @@ -0,0 +1,3 @@ +.admin-page { + line-height: 155%; +} diff --git a/htdocs/scss/bundle_common.scss b/htdocs/scss/bundle_common.scss new file mode 100644 index 0000000..56d9ead --- /dev/null +++ b/htdocs/scss/bundle_common.scss @@ -0,0 +1,10 @@ +@import "./app/common"; +@import "./app/head"; +@import "./app/blog"; +@import "./app/form"; +@import "./app/pages"; +@import "./hljs/github.scss"; + +@media screen and (max-width: 880px) { + @import "./app/mobile"; +} diff --git a/htdocs/scss/colors/dark.scss b/htdocs/scss/colors/dark.scss new file mode 100644 index 0000000..48dfe36 --- /dev/null +++ b/htdocs/scss/colors/dark.scss @@ -0,0 +1,47 @@ +$head_green_color: #0bad19; +$head_red_color: #e23636; +$link-color: #71abe5; + +$hover-hl: rgba(255, 255, 255, 0.09); +$grey: #798086; +$dark-grey: $grey; +$light-grey: $grey; +$fg: #eee; +$bg: #333; + +$code-block-bg: #394146; +$inline-code-block-bg: #394146; + +$light-bg: #464c4e; +$dark-bg: #444; +$dark-fg: #999; + +$input-border: #48535a; +$input-border-focused: #48535a; +$input-bg: #30373b; +$border-color: #48535a; + +$error-block-bg: #882b2b; +$error-block-fg: $fg; + +$success-block-bg: #2a4b2d; +$success-block-fg: $fg; + +$head-items-separator: #5e6264; + +// colors from https://github.com/Kelbster/highlightjs-material-dark-theme/blob/master/css/materialdark.css +$hljs_fg: #CDD3D8; +$hljs_bg: #2B2B2D; +$hljs_quote: #6272a4; +$hljs_string: #f1fa8c; +$hljs_literal: #bd93f9; +$hljs_title: #75A5FF; +$hljs_keyword: #C792EA; +$hljs_type: #da4939; +$hljs_tag: #abb2bf; +$hljs_regexp: #F77669; +$hljs_symbol: #C792EA; +$hljs_builtin: #C792EA; +$hljs_meta: #75A5FF; +$hljs_deletion: #e6e1dc; +$hljs_addition: #144212; \ No newline at end of file diff --git a/htdocs/scss/colors/light.scss b/htdocs/scss/colors/light.scss new file mode 100644 index 0000000..5c3c746 --- /dev/null +++ b/htdocs/scss/colors/light.scss @@ -0,0 +1,47 @@ +$head_green_color: #0bad19; +$head_red_color: #ce1a1a; +$link-color: #116fd4; + +$hover-hl: #f0f0f0; +$grey: #888; +$dark-grey: #777; +$light-grey: #999; +$fg: #222; +$bg: #fff; + +$code-block-bg: #f3f3f3; +$inline-code-block-bg: #f1f1f1; + +$light-bg: #efefef; +$dark-bg: #dfdfdf; +$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; + +$head-items-separator: #d0d0d0; + +// github.com style (c) Vasily Polovnyov +$hljs_fg: #333; +$hljs_bg: #f8f8f8; +$hljs_quote: #998; +$hljs_string: #d14; +$hljs_literal: #008080; +$hljs_title: #900; +$hljs_keyword: $hljs_fg; +$hljs_type: #458; +$hljs_tag: #000080; +$hljs_regexp: #009926; +$hljs_symbol: #990073; +$hljs_builtin: #0086b3; +$hljs_meta: #999; +$hljs_deletion: #fdd; +$hljs_addition: #dfd; \ No newline at end of file diff --git a/htdocs/scss/entries/admin/dark.scss b/htdocs/scss/entries/admin/dark.scss new file mode 100644 index 0000000..d704436 --- /dev/null +++ b/htdocs/scss/entries/admin/dark.scss @@ -0,0 +1,2 @@ +@import '../../colors/dark'; +@import '../../bundle_admin'; \ No newline at end of file diff --git a/htdocs/scss/entries/admin/light.scss b/htdocs/scss/entries/admin/light.scss new file mode 100644 index 0000000..4ac8bf0 --- /dev/null +++ b/htdocs/scss/entries/admin/light.scss @@ -0,0 +1,2 @@ +@import '../../colors/light'; +@import '../../bundle_admin'; \ No newline at end of file diff --git a/htdocs/scss/entries/common/dark.scss b/htdocs/scss/entries/common/dark.scss new file mode 100644 index 0000000..f983214 --- /dev/null +++ b/htdocs/scss/entries/common/dark.scss @@ -0,0 +1,2 @@ +@import '../../colors/dark'; +@import '../../bundle_common'; \ No newline at end of file diff --git a/htdocs/scss/entries/common/light.scss b/htdocs/scss/entries/common/light.scss new file mode 100644 index 0000000..a3044bd --- /dev/null +++ b/htdocs/scss/entries/common/light.scss @@ -0,0 +1,2 @@ +@import '../../colors/light'; +@import '../../bundle_common'; \ No newline at end of file diff --git a/htdocs/scss/hljs/github.scss b/htdocs/scss/hljs/github.scss new file mode 100644 index 0000000..ddbfc0a --- /dev/null +++ b/htdocs/scss/hljs/github.scss @@ -0,0 +1,93 @@ +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + color: $hljs_fg; + background: $hljs_bg; +} + +.hljs-comment, +.hljs-quote { + color: $hljs_quote; + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: $hljs_fg; + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: $hljs_literal; +} + +.hljs-string, +.hljs-doctag { + color: $hljs_string; +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: $hljs_title; + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: $hljs_type; + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: $hljs_tag; + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: $hljs_regexp; +} + +.hljs-symbol, +.hljs-bullet { + color: $hljs_symbol; +} + +.hljs-built_in, +.hljs-builtin-name { + color: $hljs_builtin; +} + +.hljs-meta { + color: $hljs_meta; + font-weight: bold; +} + +.hljs-deletion { + background: $hljs_deletion; +} + +.hljs-addition { + background: $hljs_addition; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} diff --git a/htdocs/scss/vars.scss b/htdocs/scss/vars.scss new file mode 100644 index 0000000..71a5f3f --- /dev/null +++ b/htdocs/scss/vars.scss @@ -0,0 +1,39 @@ +$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; +$wide_width: 1240px; +$side-padding: 25px; +$base-padding: 18px; +$footer-height: 64px; + +@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/init.php b/init.php new file mode 100644 index 0000000..688a883 --- /dev/null +++ b/init.php @@ -0,0 +1,54 @@ + '4in1', + 'site_title' => '4in1. Mask of Shakespeare, mysteries of Bacon, book by Cartier, secrets of the NSA', + 'index_title' => '4in1 | Index', + + '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.', + 'toc' => 'Table of Contents', + + // theme switcher + 'theme_auto' => 'auto', + 'theme_dark' => 'dark', + 'theme_light' => 'light', + + // contacts + 'contacts_email' => 'email', + + // 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, contact me by email.', + + '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_toc' => 'ToC', + '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/make_favicon.php b/make_favicon.php new file mode 100644 index 0000000..8f59ac9 --- /dev/null +++ b/make_favicon.php @@ -0,0 +1,60 @@ += W-1) { + $x = 0; + $y++; + } else { + $x++; + } +} + +function encode(string $letter): array { + global $alphabet; + $letter = strtoupper($letter); + $n = strpos($alphabet, $letter); + if ($n === false) + throw new Exception("letter $letter not found in the alphabet"); + $ab = []; + for ($i = 0; $i < 5; $i++) + $ab[] = ($n >> $i) & 0x01; + return array_reverse($ab); +} + +$a = imagecolorallocate($img, 0xcc, 0xcc, 0xcc); +$b = imagecolorallocate($img, 0x99, 0x99, 0x99); + +for ($i = 0; $i < strlen($plaintext); $i++) { + $c = $plaintext[$i]; + if ($c == ' ') { + for ($j = 0; $j < 5; $j++) + move_cursor(); + continue; + } + + foreach (encode($c) as $bit) { + imagesetpixel($img, $x, $y, $bit ? $b : $a); + move_cursor(); + } +} + +imagepng($img, '/tmp/4in1_fav.png'); \ No newline at end of file diff --git a/model/Page.php b/model/Page.php new file mode 100644 index 0000000..f516836 --- /dev/null +++ b/model/Page.php @@ -0,0 +1,43 @@ +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 $is_retina, string $user_theme): string { + $html = $this->html; + $html = markup::htmlImagesFix($html, $is_retina, $user_theme); + 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..6f3f1ab --- /dev/null +++ b/model/Post.php @@ -0,0 +1,193 @@ +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']); + } + + if ((isset($data['toc']) && $data['toc']) || $this->toc) { + $data['toc_html'] = markup::toc($data['md']); + } + + 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 $is_retina, string $theme): string { + $html = $this->html; + $html = markup::htmlImagesFix($html, $is_retina, $theme); + return $html; + } + + public function getToc(): ?string { + return $this->toc ? $this->tocHtml : null; + } + + 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, $u->imageMayHaveAlphaChannel())) + $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 @@ +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..06b348b --- /dev/null +++ b/model/Upload.php @@ -0,0 +1,163 @@ +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; + } + + $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p'; + return 'https://'.$config['uploads_host'].'/'.$this->randomId.'/'.$prefix.$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 .= ' '; + 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); + } + + // assume all png images have alpha channel + // i know this is wrong, but anyway + public function imageMayHaveAlphaChannel(): bool { + return strtolower(extension($this->name)) == 'png'; + } + + 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]; + } + + public function createImagePreview(?int $w = null, + ?int $h = null, + bool $force_update = false, + bool $may_have_alpha = false): bool { + global $config; + + $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; + $updated = false; + + foreach (themes::getThemes() as $theme) { + if (!$may_have_alpha && $theme == 'dark') + continue; + + for ($mult = 1; $mult <= 2; $mult++) { + $dw = $w * $mult; + $dh = $h * $mult; + + $prefix = $may_have_alpha ? 'a' : 'p'; + $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg'; + + if (file_exists($dst)) { + if (!$force_update) + continue; + unlink($dst); + } + + $img = imageopen($orig); + imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme)); + 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('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) { + if (is_file($dir.'/'.$f)) + unlink($dir.'/'.$f); + else + logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!'); + $deleted++; + } + } + return $deleted; + } + +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5819851 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": "www-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "clean-css": "^5.3.0", + "clean-css-cli": "^5.6.0", + "css-patch": "^1.2.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css-cli": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/clean-css-cli/-/clean-css-cli-5.6.3.tgz", + "integrity": "sha512-MUAta8pEqA/d2DKQwtZU5nm0Og8TCyAglOx3GlWwjhGdKBwY4kVF6E5M6LU/jmmuswv+HbYqG/dKKkq5p1dD0A==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "clean-css": "^5.3.3", + "commander": "7.x", + "glob": "^7.1.6" + }, + "bin": { + "cleancss": "bin/cleancss" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/css-patch": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-patch/-/css-patch-1.2.0.tgz", + "integrity": "sha512-wCIyPGugTmf10KO39QLD3N+qIum0ljrj/8pJdULjjuXQ6oEeYd5+quMF7jIdnEL5Ftp0wmbvO8qPvAmzrw0EaA==", + "dev": true, + "dependencies": { + "diff": "^5.0.0", + "stylis": "^4.0.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", + "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3abc792 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "devDependencies": { + "clean-css": "^5.3.0", + "clean-css-cli": "^5.6.0", + "css-patch": "^1.2.0" + } +} diff --git a/skin/admin.phps b/skin/admin.phps new file mode 100644 index 0000000..b2d9bb4 --- /dev/null +++ b/skin/admin.phps @@ -0,0 +1,346 @@ + + +
+
{$ctx->lang('as_form_password')}:
+
+ +
+
+
+
+
+ +
+
+ +HTML; + +$js = << + + Sign out + +HTML; +} + + +// uploads page +// ------------ + +function uploads($ctx, $uploads, $error) { +return <<if_true($error, $ctx->formError, $error)} + +
+
+ + +
+
{$ctx->lang('blog_upload_form_file')}:
+
+ +
+
+ +
+
{$ctx->lang('blog_upload_form_custom_name')}:
+
+ +
+
+ +
+
{$ctx->lang('blog_upload_form_note')}:
+
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+ {$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(), + ))} +
+HTML; +} + +function uploadsItem($ctx, $id, $direct_url, $note, $addslashes_note, $markdown, $name, $size) { +return << + + + {$ctx->if_true($note, '
'.$note.'
')} +
{$size}
+ + +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, + ?bool $toc = null, + string|Stringable|null $post_url = null, + ?int $post_id = null): array { +$form_url = !$is_edit ? '/write/' : $post_url.'edit/'; + +$html = <<if_true($error_code, '
'.$ctx->lang('err_blog_'.$error_code).'
')} +{$ctx->if_true($saved, '
'.$ctx->lang('info_saved').'
')} + + + + + +
+
+ + +
+
{$ctx->lang('blog_write_form_title')}
+
+ +
+
+ +
+
{$ctx->lang('blog_write_form_text')}
+ +
+ +
+ + + + + + + + + +
+
+
{$ctx->lang('blog_write_form_tags')}
+
+ +
+
+
+
+
{$ctx->lang('blog_write_form_options')}
+
+ + +
+
+
+
+
{$ctx->lang('blog_write_form_short_name')}
+
+ +
+
+
+
+
 
+
+ +
+
+
+
+
+
+
+
+
+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 = <<if_true($error_code, '
'.$ctx->lang('err_pages_'.$error_code).'
')} +{$ctx->if_true($saved, '
'.$ctx->lang('info_saved').'
')} + + + + + +
+
+ + +
+
{$ctx->lang('pages_write_form_title')}
+
+ +
+
+ +
+
{$ctx->lang('pages_write_form_text')}
+ +
+ + {$ctx->if_then_else($is_edit, + fn() => $ctx->pageFormEditOptions($short_name, $visible), + fn() => $ctx->pageFormAddOptions($short_name))} + +
+
+
+
+
+HTML; + +$js_params = json_encode(['pages' => true, 'edit' => $is_edit]); +$js = << + + + + + + + + +
+
+
{$ctx->lang('pages_write_form_short_name')}
+
+ +
+
+
+
+
{$ctx->lang('pages_write_form_options')}
+
+ +
+
+
+ +
+ +HTML; +} + +function pageFormAddOptions($ctx, $short_name) { +return << +
+
+ +
+ + +HTML; +} + +function pageNew($ctx, $short_name) { +return << + + +HTML; + +} + +// misc +function formError($ctx, $error) { +return <<{$ctx->lang('error')}: {$error} +HTML; +} + +function markdownPreview($ctx, $unsafe_html, $title) { +return << + {$ctx->if_true($title, '

'.$title.'

')} +
{$unsafe_html}
+ +HTML; + +} \ No newline at end of file diff --git a/skin/base.phps b/skin/base.phps new file mode 100644 index 0000000..4f75c03 --- /dev/null +++ b/skin/base.phps @@ -0,0 +1,224 @@ + $config['domain'], + 'devMode' => $config['is_dev'], + 'cookieHost' => $config['cookie_host'], +]); + +$body_class = []; +if ($opts['full_width']) + $body_class[] = 'full-width'; +else if ($opts['wide']) + $body_class[] = 'wide'; + +return << + + + + + + + {$title} + + {$ctx->renderMeta($meta)} + {$ctx->renderStatic($static, $theme)} + + if_true($body_class, ' class="'.implode(' ', $body_class).'"')}> + {$ctx->renderHeader($theme)} +
+
{$unsafe_body}
+
+ {$ctx->renderScript($js, $unsafe_lang)} + + +HTML; +} + +function renderScript($ctx, $unsafe_js, $unsafe_lang) { +global $config; + +$styles = json_encode($ctx->styleNames); +if ($config['is_dev']) + $versions = '{}'; +else { + $versions = []; + foreach ($config['static'] as $name => $v) { + list($type, $bname) = getStaticNameParts($name); + $versions[$type][$bname] = $v; + } + $versions = json_encode($versions); +} + +return << +StaticManager.init({$styles}, {$versions}); +{$ctx->if_true($unsafe_js, '(function(){'.$unsafe_js.'})();')} +{$ctx->if_true($unsafe_lang, 'extend(__lang, '.$unsafe_lang.');')} +ThemeSwitcher.init(); + +HTML; +} + +function renderMeta($ctx, $meta) { + if (empty($meta)) + return ''; + return implode('', array_map(function(array $item): string { + $s = ' $v) + $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"'; + $s .= '>'; + return $s; + }, $meta)); +} + +function renderStatic($ctx, $static, $theme) { + global $config; + $html = []; + $dark = $theme == 'dark'; + $ctx->styleNames = []; + foreach ($static as $name) { + // javascript + if (str_starts_with($name, 'js/')) + $html[] = jsLink($name); + + // css + else if (str_starts_with($name, 'css/')) { + $html[] = cssLink($name, 'light', $style_name); + $ctx->styleNames[] = $style_name; + + if ($dark) + $html[] = cssLink($name, 'dark', $style_name); + else if (!$config['is_dev']) + $html[] = cssPrefetchLink($style_name.'_dark'); + } + else + logError(__FUNCTION__.': unexpected static entry: '.$name); + } + return implode("\n", $html); +} + +function jsLink(string $name): string { + global $config; + list (, $bname) = getStaticNameParts($name); + if ($config['is_dev']) { + $href = '/js.php?name='.urlencode($bname).'&v='.time(); + } else { + $href = '/dist-js/'.$bname.'.js?'.getStaticVersion($name); + } + return ''; +} + +function cssLink(string $name, string $theme, &$bname = null): string { + global $config; + + list(, $bname) = getStaticNameParts($name); + + if ($config['is_dev']) { + $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time(); + } else { + $version = getStaticVersion('css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css'); + $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?'.$version; + } + + $id = 'style_'.$bname; + if ($theme == 'dark') + $id .= '_dark'; + + return ''; +} + +function cssPrefetchLink(string $name): string { +$url = '/dist-css/'.$name.'.css?'.getStaticVersion('css/'.$name.'.css'); +return << +HTML; +} + +function getStaticNameParts(string $name): array { + $dname = dirname($name); + $bname = basename($name); + if (($pos = strrpos($bname, '.'))) { + $ext = substr($bname, $pos+1); + $bname = substr($bname, 0, $pos); + } else { + $ext = ''; + } + return [$dname, $bname, $ext]; +} + +function getStaticVersion(string $name): string { + global $config; + if ($config['is_dev']) + return time(); + if (str_starts_with($name, '/')) { + logWarning(__FUNCTION__.': '.$name.' starts with /'); + $name = substr($name, 1); + } + return $config['static'][$name] ?? 'notfound'; +} + + +function renderHeader(SkinContext $ctx, string $theme): string { +$items = [ + ['url' => 'javascript:void(0)', 'label' => $theme, 'label_id' => 'theme-switcher-label', 'theme_switcher' => true], + //['url' => '/articles/', 'label' => 'articles'], + ['url' => 'https://files.4in1.ws', 'label' => 'materials'], + ['url' => '/about/', 'label' => 'about'], +]; +if (\admin::isAdmin()) + $items[] = ['url' => '/admin/', 'label' => 'admin']; + +// here, items are rendered using for_each, so that there are no gaps (whitespaces) between tags + +return << +
+ +
+ {$ctx->for_each($items, fn($item) => $ctx->renderHeaderItem($item['url'], $item['label'], $item['label_id'] ?? null, $item['theme_switcher'] ?? false))} +
+
+ +HTML; +} + + +function renderHeaderItem(SkinContext $ctx, + string $url, + string $label, + ?Stringable $label_id, + bool $is_theme_switcher): string { +return <<if_true($is_theme_switcher, ' onclick="return ThemeSwitcher.next(event)"')}> + + {$ctx->if_true($is_theme_switcher, ''.$ctx->renderMoonIcon().'')} + if_true($label_id, ' id="'.$label_id.'"')}>{$label} + + +HTML; +} + + +function renderMoonIcon(SkinContext $ctx): string { +return << +SVG; +} diff --git a/skin/error.phps b/skin/error.phps new file mode 100644 index 0000000..5276bfa --- /dev/null +++ b/skin/error.phps @@ -0,0 +1,40 @@ +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 << + $code $title + +

$code $title

+
+ {$ctx->if_true($message, + '

'.$message.'

' + )} + + +HTML; + +} \ No newline at end of file diff --git a/skin/main.phps b/skin/main.phps new file mode 100644 index 0000000..08783a9 --- /dev/null +++ b/skin/main.phps @@ -0,0 +1,235 @@ + +
{$unsafe_content}
+ +HTML; +} + + +function indexEmtpy($ctx): string { +return << + {$ctx->lang('blog_no')} + {$ctx->if_admin(''.$ctx->lang('write').'')} + +HTML; +} + +function indexBlog($ctx, array $posts): string { +return << +
+ all posts + {$ctx->if_admin( + ' + new + uploads + ' + )} +
+ {$ctx->indexPostsTable($posts)} + +HTML; +} + +function indexPostsTable($ctx, array $posts): string { +$ctx->year = 3000; +return << + + {$ctx->for_each($posts, fn($post) => $ctx->indexPostRow( + $post->getYear(), + $post->visible, + $post->getDate(), + $post->getUrl(), + $post->title + ))} +
+ +HTML; +} + +function indexPostRow($ctx, $year, $is_visible, $date, $url, $title): string { +return <<if_true($ctx->year > $year, $ctx->indexYearLine, $year)} + + + {$date} + + + {$title} + + +HTML; +} + +function indexYearLine($ctx, $year): string { +$ctx->year = $year; +return << + {$year} + + +HTML; +} + + +// contacts page +// ------------- + +function contacts($ctx, $email) { +return << + + +
Feel free to contact me by any of the following means:
+ + + + Tox ID: + 4F6E82D285B342CAE83687FA63F1B083A9FD053C8BE08C709E25491C74E6016CB52C987692C3 + + +HTML; + +} + + +// any page +// -------- + +function page($ctx, $page_url, $short_name, $unsafe_html) { +$html = << + {$ctx->if_admin($ctx->pageAdminLinks, $page_url, $short_name)} +
{$unsafe_html}
+ +HTML; + +return [$html, markdownThemeChangeListener()]; +} + +function pageAdminLinks($ctx, $url, $short_name) { +return << + {$ctx->lang('edit')} + {$ctx->lang('delete')} + +HTML; + +} + + +// post page +// --------- + +function post($ctx, $id, $title, $unsafe_html, $unsafe_toc_html, $date, $visible, $url, $tags, $email, $urlencoded_reply_subject) { +$html = << +
+
+
+

{$title}

+ + +
+
{$unsafe_html}
+
+ {$ctx->if_true($unsafe_toc_html, $ctx->postToc, $unsafe_toc_html)} +
+ + +
+ {$ctx->langRaw('blog_comments_text', $email, $urlencoded_reply_subject)} +
+HTML; + +return [$html, markdownThemeChangeListener()]; +} + +function postToc($ctx, $unsafe_toc_html) { +return << +
+
+
{$ctx->lang('toc')}
+ {$unsafe_toc_html} +
+
+ +HTML; + +} + +function postAdminLinks($ctx, $url, $id) { +return <<{$ctx->lang('edit')} +{$ctx->lang('delete')} +HTML; +} + +function postTag($ctx, $url, $name) { +return <<#{$name} +HTML; +} + +function markdownThemeChangeListener() { +return << div'); + if (!div) { + console.warn('could not found a>div on this node:', node); + continue; + } + var style = div.getAttribute('style'); + if (isDark) { + style = style.replace(/(a[\d]+x[\d]+)\.jpg/, '$1_dark.jpg'); + } else { + style = style.replace(/(a[\d]+x[\d]+)_dark\.jpg/, '$1.jpg'); + } + div.setAttribute('style', style); + } +}); +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/skin/markdown.phps b/skin/markdown.phps new file mode 100644 index 0000000..58801f5 --- /dev/null +++ b/skin/markdown.phps @@ -0,0 +1,43 @@ + + {$name} + {$ctx->if_true($note, ''.$note.'')} + {$size} + +HTML; +} + +function image($ctx, + // options + $align, $nolabel, $w, $padding_top, $may_have_alpha, + // image data + $direct_url, $url, $note) { +return << +
+ +
+
+ {$ctx->if_true( + $note != '' && !$nolabel, + '
'.$note.'
' + )} +
+ +HTML; +} + +function video($ctx, $url, $w, $h) { +return << +
+ +
+ +HTML; +} \ No newline at end of file diff --git a/skin/rss.phps b/skin/rss.phps new file mode 100644 index 0000000..8bc19e9 --- /dev/null +++ b/skin/rss.phps @@ -0,0 +1,39 @@ + + + + {$title} + {$link} + + + {$ctx->for_each($items, fn($item) => $ctx->item(...$item))} + + +HTML; +} + + +function item(\SkinContext $ctx, + string $title, + string $link, + string $pub_date, + string $description): string { +return << + {$title} + {$link} + {$pub_date} + {$description} + +HTML; +} \ No newline at end of file