refactoring
This commit is contained in:
parent
2e4728b61e
commit
8f644da144
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
/debug.log
|
||||
/log
|
||||
test.php
|
||||
/.git
|
||||
/node_modules/
|
||||
@ -7,7 +8,8 @@ test.php
|
||||
._.DS_Store
|
||||
.sass-cache/
|
||||
config-static.php
|
||||
config-local.php
|
||||
/config-local.php
|
||||
/config.yaml
|
||||
/.idea
|
||||
/htdocs/dist-css
|
||||
/htdocs/dist-js
|
||||
|
@ -1,4 +1,4 @@
|
||||
# ch1p_io_web
|
||||
# 4in1_io_web
|
||||
|
||||
This is a source code of 4in1.ws web site.
|
||||
|
||||
|
@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
class AjaxErrorResponse extends AjaxResponse {
|
||||
|
||||
public function __construct(string $error, int $code = 200) {
|
||||
parent::__construct(code: $code, body: json_encode(['error' => $error], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
class AjaxOkResponse extends AjaxResponse {
|
||||
|
||||
public function __construct($data) {
|
||||
parent::__construct(code: 200, body: json_encode(['response' => $data], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
class AjaxResponse extends Response {
|
||||
|
||||
public function __construct(...$args) {
|
||||
parent::__construct(...$args);
|
||||
$this->addHeader('Content-Type: application/json; charset=utf-8');
|
||||
$this->addHeader('Cache-Control: no-cache, must-revalidate');
|
||||
$this->addHeader('Pragma: no-cache');
|
||||
$this->addHeader('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
enum InputType: string {
|
||||
case INT = 'i';
|
||||
case FLOAT = 'f';
|
||||
case BOOL = 'b';
|
||||
case STRING = 's';
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
interface LangAccess {
|
||||
|
||||
public function lang(...$args): string;
|
||||
public function langRaw(string $key, ...$args);
|
||||
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
class LangData implements ArrayAccess {
|
||||
|
||||
private static ?LangData $instance = null;
|
||||
protected array $data = [];
|
||||
protected array $loaded = [];
|
||||
|
||||
public static function getInstance(): static {
|
||||
if (is_null(self::$instance)) {
|
||||
self::$instance = new self();
|
||||
self::$instance->load('en');
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function __invoke(string $key, ...$args) {
|
||||
$val = $this[$key];
|
||||
return empty($args) ? $val : sprintf($val, ...$args);
|
||||
}
|
||||
|
||||
public function load(string $name) {
|
||||
if (array_key_exists($name, $this->loaded))
|
||||
return;
|
||||
|
||||
$data = require_once ROOT."/lang/{$name}.php";
|
||||
$this->data = array_replace($this->data,
|
||||
$data);
|
||||
|
||||
$this->loaded[$name] = true;
|
||||
}
|
||||
|
||||
public function offsetSet(mixed $offset, mixed $value): void {
|
||||
logError(__METHOD__ . ': not implemented');
|
||||
}
|
||||
|
||||
public function offsetExists($offset): bool {
|
||||
return isset($this->data[$offset]);
|
||||
}
|
||||
|
||||
public function offsetUnset(mixed $offset): void {
|
||||
logError(__METHOD__ . ': not implemented');
|
||||
}
|
||||
|
||||
public function offsetGet(mixed $offset): mixed {
|
||||
return $this->data[$offset] ?? '{' . $offset . '}';
|
||||
}
|
||||
|
||||
public function search(string $regexp): array|false {
|
||||
return preg_grep($regexp, array_keys($this->data));
|
||||
}
|
||||
|
||||
// function plural(array $s, int $n, array $opts = []) {
|
||||
// $opts = array_merge([
|
||||
// 'format' => true,
|
||||
// 'format_delim' => ' ',
|
||||
// 'lang' => 'en',
|
||||
// ], $opts);
|
||||
//
|
||||
// switch ($opts['lang']) {
|
||||
// case 'ru':
|
||||
// $n = $n % 100;
|
||||
// if ($n > 19)
|
||||
// $n %= 10;
|
||||
//
|
||||
// if ($n == 1) {
|
||||
// $word = 0;
|
||||
// } else if ($n >= 2 && $n <= 4) {
|
||||
// $word = 1;
|
||||
// } else if ($n == 0 && count($s) == 4) {
|
||||
// $word = 3;
|
||||
// } else {
|
||||
// $word = 2;
|
||||
// }
|
||||
// break;
|
||||
//
|
||||
// default:
|
||||
// if (!$n && count($s) == 4) {
|
||||
// $word = 3;
|
||||
// } else {
|
||||
// $word = (int)!!$n;
|
||||
// }
|
||||
// break;
|
||||
// }
|
||||
//
|
||||
// // if zero
|
||||
// if ($word == 3)
|
||||
// return $s[3];
|
||||
//
|
||||
// if (is_callable($opts['format'])) {
|
||||
// $num = $opts['format']($n);
|
||||
// } else if ($opts['format'] === true) {
|
||||
// $num = formatNumber($n, $opts['format_delim']);
|
||||
// }
|
||||
//
|
||||
// return sprintf($s[$word], $num);
|
||||
// }
|
||||
//
|
||||
// function formatNumber(int $num, string $delim = ' ', bool $short = false): string {
|
||||
// if ($short) {
|
||||
// if ($num >= 1000000)
|
||||
// return floor($num / 1000000).'m';
|
||||
// if ($num >= 1000)
|
||||
// return floor($num / 1000).'k';
|
||||
// }
|
||||
// return number_format($num, 0, '.', $delim);
|
||||
// }
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
enum LogLevel {
|
||||
case ERROR;
|
||||
case WARNING;
|
||||
case INFO;
|
||||
case DEBUG;
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
class RedirectResponse extends Response {
|
||||
|
||||
public function __construct(string $url, int $code = 302) {
|
||||
parent::__construct($code);
|
||||
$this->addHeader('Location: '.$url);
|
||||
}
|
||||
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
use exceptions\ForbiddenException;
|
||||
use exceptions\NotFoundException;
|
||||
use exceptions\NotImplementedException;
|
||||
use exceptions\UnauthorizedException;
|
||||
|
||||
class RequestDispatcher {
|
||||
|
||||
public function __construct(
|
||||
protected Router $router
|
||||
) {}
|
||||
|
||||
public function dispatch(): void {
|
||||
global $config;
|
||||
|
||||
try {
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
|
||||
throw new NotImplementedException('Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
|
||||
|
||||
$route = $this->router->find(self::path());
|
||||
if (!$route)
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
use exceptions\NotImplementedException;
|
||||
|
||||
class RequestHandler {
|
||||
|
||||
public function __construct(
|
||||
protected Skin $skin,
|
||||
protected LangData $lang,
|
||||
protected array $routerInput
|
||||
) {}
|
||||
|
||||
public function beforeDispatch(): ?Response {
|
||||
return null;
|
||||
}
|
||||
|
||||
public function get(): Response {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public function post(): Response {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public function input(string $input): array {
|
||||
$input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$ret = [];
|
||||
foreach ($input as $var) {
|
||||
if (($pos = strpos($var, ':')) !== false) {
|
||||
$type = InputType::from(substr($var, 0, $pos));
|
||||
$name = trim(substr($var, $pos+1));
|
||||
} else {
|
||||
$type = InputType::STRING;
|
||||
$name = $var;
|
||||
}
|
||||
|
||||
$value = $this->routerInput[$name] ?? $_REQUEST[$name] ?? '';
|
||||
switch ($type) {
|
||||
case InputType::INT:
|
||||
$value = (int)$value;
|
||||
break;
|
||||
case InputType::FLOAT:
|
||||
$value = (float)$value;
|
||||
break;
|
||||
case InputType::BOOL:
|
||||
$value = (bool)$value;
|
||||
break;
|
||||
}
|
||||
|
||||
$ret[] = $value;
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
protected function isRetina(): bool {
|
||||
return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina'];
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
class Response {
|
||||
|
||||
protected array $headers = [];
|
||||
|
||||
public function __construct(
|
||||
public int $code = 200,
|
||||
public ?string $body = null
|
||||
) {}
|
||||
|
||||
public function send(): void {
|
||||
$this->setHeaders();
|
||||
if ($this->code == 200 || $this->code >= 400)
|
||||
echo $this->body;
|
||||
}
|
||||
|
||||
public function addHeader(string $header): void {
|
||||
$this->headers[] = $header;
|
||||
}
|
||||
|
||||
public function setHeaders(): void {
|
||||
http_response_code($this->code);
|
||||
foreach ($this->headers as $header)
|
||||
header($header);
|
||||
}
|
||||
|
||||
}
|
@ -1,165 +0,0 @@
|
||||
<?php
|
||||
|
||||
class Router {
|
||||
|
||||
protected array $routes = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
|
||||
public function add($template, $value) {
|
||||
if ($template == '')
|
||||
return $this;
|
||||
|
||||
// expand {enum,erat,ions}
|
||||
$templates = [[$template, $value]];
|
||||
if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) {
|
||||
foreach ($matches[1] as $match_index => $variants) {
|
||||
$variants = explode(',', $variants);
|
||||
$variants = array_map('trim', $variants);
|
||||
$variants = array_filter($variants, function($s) { return $s != ''; });
|
||||
|
||||
for ($i = 0; $i < count($templates); ) {
|
||||
list($template, $value) = $templates[$i];
|
||||
$new_templates = [];
|
||||
foreach ($variants as $variant_index => $variant) {
|
||||
$new_templates[] = [
|
||||
str_replace_once($matches[0][$match_index], $variant, $template),
|
||||
str_replace('${'.($match_index+1).'}', $variant, $value)
|
||||
];
|
||||
}
|
||||
array_splice($templates, $i, 1, $new_templates);
|
||||
$i += count($new_templates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process all generated routes
|
||||
foreach ($templates as $template) {
|
||||
list($template, $value) = $template;
|
||||
|
||||
$start_pos = 0;
|
||||
$parent = &$this->routes;
|
||||
$template_len = strlen($template);
|
||||
|
||||
while ($start_pos < $template_len) {
|
||||
$slash_pos = strpos($template, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($template, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($template, $start_pos);
|
||||
$start_pos = $template_len;
|
||||
}
|
||||
|
||||
$parent = &$this->_addRoute($parent, $part,
|
||||
$start_pos < $template_len ? null : $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function &_addRoute(&$parent, $part, $value = null) {
|
||||
$par_pos = strpos($part, '(');
|
||||
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
|
||||
|
||||
$children_key = !$is_regex ? 'children' : 're_children';
|
||||
|
||||
if (isset($parent[$children_key][$part])) {
|
||||
if (is_null($value)) {
|
||||
$parent = &$parent[$children_key][$part];
|
||||
} else {
|
||||
if (!isset($parent[$children_key][$part]['value'])) {
|
||||
$parent[$children_key][$part]['value'] = $value;
|
||||
} else {
|
||||
trigger_error(__METHOD__.': route is already defined');
|
||||
}
|
||||
}
|
||||
return $parent;
|
||||
}
|
||||
|
||||
$child = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
if (!is_null($value))
|
||||
$child['value'] = $value;
|
||||
|
||||
$parent[$children_key][$part] = $child;
|
||||
return $parent[$children_key][$part];
|
||||
}
|
||||
|
||||
public function find($uri) {
|
||||
if ($uri != '/' && $uri[0] == '/') {
|
||||
$uri = substr($uri, 1);
|
||||
}
|
||||
$start_pos = 0;
|
||||
$parent = &$this->routes;
|
||||
$uri_len = strlen($uri);
|
||||
$matches = [];
|
||||
|
||||
while ($start_pos < $uri_len) {
|
||||
$slash_pos = strpos($uri, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($uri, $start_pos);
|
||||
$start_pos = $uri_len;
|
||||
}
|
||||
|
||||
$found = false;
|
||||
if (isset($parent['children'][$part])) {
|
||||
$parent = &$parent['children'][$part];
|
||||
$found = true;
|
||||
} else if (!empty($parent['re_children'])) {
|
||||
foreach ($parent['re_children'] as $re => &$child) {
|
||||
$exp = '#^'.$re.'$#';
|
||||
$re_result = preg_match($exp, $part, $match);
|
||||
if ($re_result === false) {
|
||||
logError(__METHOD__.": regex $exp failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($re_result) {
|
||||
if (count($match) > 1) {
|
||||
$matches = array_merge($matches, array_slice($match, 1));
|
||||
}
|
||||
$parent = &$child;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isset($parent['value']))
|
||||
return false;
|
||||
|
||||
$value = $parent['value'];
|
||||
if (!empty($matches)) {
|
||||
foreach ($matches as $i => $match) {
|
||||
$needle = '$('.($i+1).')';
|
||||
$pos = strpos($value, $needle);
|
||||
if ($pos !== false) {
|
||||
$value = substr_replace($value, $match, $pos, strlen($needle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function load($routes) {
|
||||
$this->routes = $routes;
|
||||
}
|
||||
|
||||
public function dump(): array {
|
||||
return $this->routes;
|
||||
}
|
||||
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
class Skin {
|
||||
|
||||
public string $title = '4in1';
|
||||
public bool $fixedTitle = false;
|
||||
public array $static = [];
|
||||
public array $meta = [];
|
||||
|
||||
protected array $langKeys = [];
|
||||
protected array $options = [
|
||||
'full_width' => 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) : '';
|
||||
|
||||
$title = $this->title;
|
||||
if (!$this->fixedTitle)
|
||||
$title .= ' :: 4in1';
|
||||
return new Response(200, $layout_ctx->layout(
|
||||
static: $this->static,
|
||||
theme: $theme,
|
||||
title: $title,
|
||||
opts: $this->options,
|
||||
js: $js,
|
||||
meta: $this->meta,
|
||||
unsafe_lang: $lang,
|
||||
unsafe_body: $body,
|
||||
));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
class SkinBase implements LangAccess {
|
||||
|
||||
protected static LangData $ld;
|
||||
|
||||
public static function __constructStatic(): void {
|
||||
self::$ld = LangData::getInstance();
|
||||
}
|
||||
|
||||
public function lang(...$args): string {
|
||||
return htmlescape($this->langRaw(...$args));
|
||||
}
|
||||
|
||||
public function langRaw(string $key, ...$args) {
|
||||
$val = self::$ld[$key];
|
||||
return empty($args) ? $val : sprintf($val, ...$args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
SkinBase::__constructStatic();
|
@ -1,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
class SkinContext extends SkinBase {
|
||||
|
||||
protected string $ns;
|
||||
protected array $data = [];
|
||||
|
||||
public function __construct(string $namespace) {
|
||||
$this->ns = $namespace;
|
||||
require_once ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.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;
|
||||
}
|
||||
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
class SkinString implements Stringable {
|
||||
|
||||
protected SkinStringModificationType $modType;
|
||||
|
||||
public function __construct(protected string $string) {}
|
||||
|
||||
public function setModType(SkinStringModificationType $modType): void {
|
||||
$this->modType = $modType;
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
return match ($this->modType) {
|
||||
SkinStringModificationType::HTML => htmlescape($this->string),
|
||||
SkinStringModificationType::URL => urlencode($this->string),
|
||||
SkinStringModificationType::JSON => json_encode($this->string, JSON_UNESCAPED_UNICODE),
|
||||
SkinStringModificationType::ADDSLASHES => addslashes($this->string),
|
||||
default => $this->string,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
enum SkinStringModificationType {
|
||||
case RAW;
|
||||
case URL;
|
||||
case HTML;
|
||||
case JSON;
|
||||
case ADDSLASHES;
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
class admin {
|
||||
|
||||
const SESSION_TIMEOUT = 86400 * 14;
|
||||
const COOKIE_NAME = 'admin_key';
|
||||
|
||||
protected static ?bool $isAdmin = null;
|
||||
|
||||
public static function isAdmin(): bool {
|
||||
if (is_null(self::$isAdmin))
|
||||
self::$isAdmin = self::_verifyKey();
|
||||
return self::$isAdmin;
|
||||
}
|
||||
|
||||
protected static function _verifyKey(): bool {
|
||||
if (isset($_COOKIE[self::COOKIE_NAME])) {
|
||||
$cookie = (string)$_COOKIE[self::COOKIE_NAME];
|
||||
if ($cookie !== self::getKey())
|
||||
self::unsetCookie();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function checkPassword(string $pwd): bool {
|
||||
return salt_password($pwd) === config::get('admin_pwd');
|
||||
}
|
||||
|
||||
protected static function getKey(): string {
|
||||
global $config;
|
||||
$admin_pwd_hash = config::get('admin_pwd');
|
||||
return salt_password("$admin_pwd_hash|{$_SERVER['REMOTE_ADDR']}");
|
||||
}
|
||||
|
||||
public static function setCookie(): void {
|
||||
global $config;
|
||||
$key = self::getKey();
|
||||
setcookie(self::COOKIE_NAME, $key, time() + self::SESSION_TIMEOUT, '/', $config['cookie_host']);
|
||||
}
|
||||
|
||||
public static function unsetCookie(): void {
|
||||
global $config;
|
||||
setcookie(self::COOKIE_NAME, '', 1, '/', $config['cookie_host']);
|
||||
}
|
||||
|
||||
public static function logAuth(): void {
|
||||
getDb()->insert('admin_log', [
|
||||
'ts' => time(),
|
||||
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
||||
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
class config {
|
||||
|
||||
public static function get(string $key) {
|
||||
$db = getDb();
|
||||
$q = $db->query("SELECT value FROM config WHERE name=?", $key);
|
||||
if (!$db->numRows($q))
|
||||
return null;
|
||||
return $db->result($q);
|
||||
}
|
||||
|
||||
public static function mget($keys) {
|
||||
$map = [];
|
||||
foreach ($keys as $key) {
|
||||
$map[$key] = null;
|
||||
}
|
||||
|
||||
$db = getDb();
|
||||
$keys = array_map(fn($s) => $db->escape($s), $keys);
|
||||
|
||||
$q = $db->query("SELECT * FROM config WHERE name IN('".implode("','", $keys)."')");
|
||||
while ($row = $db->fetch($q))
|
||||
$map[$row['name']] = $row['value'];
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public static function set($key, $value) {
|
||||
$db = getDb();
|
||||
return $db->query("REPLACE INTO config (name, value) VALUES (?, ?)", $key, $value);
|
||||
}
|
||||
|
||||
public static function mset($map) {
|
||||
$rows = [];
|
||||
foreach ($map as $name => $value) {
|
||||
$rows[] = [
|
||||
'name' => $name,
|
||||
'value' => $value
|
||||
];
|
||||
}
|
||||
$db = getDb();
|
||||
return $db->multipleReplace('config', $rows);
|
||||
}
|
||||
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
class csrf {
|
||||
|
||||
public static function check(string $key): void {
|
||||
$user_csrf = self::get($key);
|
||||
$sent_csrf = $_REQUEST['token'] ?? '';
|
||||
|
||||
if ($sent_csrf != $user_csrf)
|
||||
throw new ForbiddenException("csrf error");
|
||||
}
|
||||
|
||||
public static function get(string $key): string {
|
||||
return self::getToken($_SERVER['REMOTE_ADDR'], $key);
|
||||
}
|
||||
|
||||
protected static function getToken(string $user_token, string $key): string {
|
||||
global $config;
|
||||
return substr(sha1($config['csrf_token'].$user_token.$key), 0, 20);
|
||||
}
|
||||
|
||||
}
|
@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace database;
|
||||
|
||||
abstract class CommonDatabase {
|
||||
|
||||
abstract public function query(string $sql, ...$args);
|
||||
abstract public function escape(string $s): string;
|
||||
abstract public function fetch($q): ?array;
|
||||
abstract public function fetchAll($q): ?array;
|
||||
abstract public function fetchRow($q): ?array;
|
||||
abstract public function result($q, int $field = 0);
|
||||
abstract public function insertId(): ?int;
|
||||
abstract public function numRows($q): ?int;
|
||||
|
||||
protected function prepareQuery(string $sql, ...$args): string {
|
||||
global $config;
|
||||
if (!empty($args)) {
|
||||
$mark_count = substr_count($sql, '?');
|
||||
$positions = array();
|
||||
$last_pos = -1;
|
||||
for ($i = 0; $i < $mark_count; $i++) {
|
||||
$last_pos = strpos($sql, '?', $last_pos + 1);
|
||||
$positions[] = $last_pos;
|
||||
}
|
||||
for ($i = $mark_count - 1; $i >= 0; $i--) {
|
||||
$arg_val = $args[$i];
|
||||
if (is_null($arg_val)) {
|
||||
$v = 'NULL';
|
||||
} else {
|
||||
$v = '\''.$this->escape($arg_val) . '\'';
|
||||
}
|
||||
$sql = substr_replace($sql, $v, $positions[$i], 1);
|
||||
}
|
||||
}
|
||||
if (!empty($config['db']['log']))
|
||||
logDebug(__METHOD__.': ', $sql);
|
||||
return $sql;
|
||||
}
|
||||
|
||||
public function insert(string $table, array $fields) {
|
||||
return $this->performInsert('INSERT', $table, $fields);
|
||||
}
|
||||
|
||||
public function replace(string $table, array $fields) {
|
||||
return $this->performInsert('REPLACE', $table, $fields);
|
||||
}
|
||||
|
||||
protected function performInsert(string $command, string $table, array $fields) {
|
||||
$names = [];
|
||||
$values = [];
|
||||
$count = 0;
|
||||
foreach ($fields as $k => $v) {
|
||||
$names[] = $k;
|
||||
$values[] = $v;
|
||||
$count++;
|
||||
}
|
||||
|
||||
$sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")";
|
||||
array_unshift($values, $sql);
|
||||
|
||||
return $this->query(...$values);
|
||||
}
|
||||
|
||||
public function update(string $table, array $rows, ...$cond) {
|
||||
$fields = [];
|
||||
$args = [];
|
||||
foreach ($rows as $row_name => $row_value) {
|
||||
$fields[] = "`{$row_name}`=?";
|
||||
$args[] = $row_value;
|
||||
}
|
||||
$sql = "UPDATE `$table` SET ".implode(', ', $fields);
|
||||
if (!empty($cond)) {
|
||||
$sql .= " WHERE ".$cond[0];
|
||||
if (count($cond) > 1)
|
||||
$args = array_merge($args, array_slice($cond, 1));
|
||||
}
|
||||
return $this->query($sql, ...$args);
|
||||
}
|
||||
|
||||
public function multipleInsert(string $table, array $rows) {
|
||||
list($names, $values) = $this->getMultipleInsertValues($rows);
|
||||
$sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
|
||||
return $this->query($sql);
|
||||
}
|
||||
|
||||
public function multipleReplace(string $table, array $rows) {
|
||||
list($names, $values) = $this->getMultipleInsertValues($rows);
|
||||
$sql = "REPLACE INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
|
||||
return $this->query($sql);
|
||||
}
|
||||
|
||||
protected function getMultipleInsertValues(array $rows): array {
|
||||
$names = [];
|
||||
$sql_rows = [];
|
||||
foreach ($rows as $i => $fields) {
|
||||
$row_values = [];
|
||||
foreach ($fields as $field_name => $field_val) {
|
||||
if ($i == 0) {
|
||||
$names[] = $field_name;
|
||||
}
|
||||
$row_values[] = $this->escape($field_val);
|
||||
}
|
||||
$sql_rows[] = "('".implode("', '", $row_values)."')";
|
||||
}
|
||||
return [$names, implode(', ', $sql_rows)];
|
||||
}
|
||||
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace database;
|
||||
|
||||
use mysqli;
|
||||
use mysqli_result;
|
||||
|
||||
class MySQLConnection extends CommonDatabase {
|
||||
|
||||
protected ?mysqli $link = null;
|
||||
|
||||
public function __construct(
|
||||
protected string $host,
|
||||
protected string $user,
|
||||
protected string $password,
|
||||
protected string $database) {}
|
||||
|
||||
public function __destruct() {
|
||||
if ($this->link)
|
||||
$this->link->close();
|
||||
}
|
||||
|
||||
public function connect(): bool {
|
||||
$this->link = new mysqli();
|
||||
$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);
|
||||
}
|
||||
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace database;
|
||||
|
||||
class SQLiteConnection extends CommonDatabase {
|
||||
|
||||
const SCHEMA_VERSION = 0;
|
||||
|
||||
protected SQLite3 $link;
|
||||
|
||||
public function __construct(string $db_path) {
|
||||
$will_create = !file_exists($db_path);
|
||||
$this->link = new SQLite3($db_path);
|
||||
if ($will_create)
|
||||
setperm($db_path);
|
||||
$this->link->enableExceptions(true);
|
||||
$this->upgradeSchema();
|
||||
}
|
||||
|
||||
protected function upgradeSchema() {
|
||||
$cur = $this->getSchemaVersion();
|
||||
if ($cur == self::SCHEMA_VERSION)
|
||||
return;
|
||||
|
||||
if ($cur < 1) {
|
||||
// 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.
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace exceptions;
|
||||
|
||||
class ForbiddenException extends \BadMethodCallException {
|
||||
|
||||
public function __construct(string $message = '') {
|
||||
parent::__construct($message, 403);
|
||||
}
|
||||
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace exceptions;
|
||||
|
||||
class NotFoundException extends \BadMethodCallException {
|
||||
|
||||
public function __construct(string $message = '4in1') {
|
||||
parent::__construct($message, 404);
|
||||
}
|
||||
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace exceptions;
|
||||
|
||||
class NotImplementedException extends \BadMethodCallException {
|
||||
|
||||
public function __construct(string $message = '') {
|
||||
parent::__construct($message, 501);
|
||||
}
|
||||
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace exceptions;
|
||||
|
||||
class UnauthorizedException extends \BadMethodCallException {
|
||||
|
||||
public function __construct(string $message = '') {
|
||||
parent::__construct($message, 401);
|
||||
}
|
||||
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler;
|
||||
|
||||
use RedirectResponse;
|
||||
use Response;
|
||||
|
||||
class AboutHandler extends \RequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
return new RedirectResponse('/contacts/');
|
||||
}
|
||||
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler;
|
||||
|
||||
use admin;
|
||||
use posts;
|
||||
|
||||
class ArticlesHandler extends \RequestHandler
|
||||
{
|
||||
|
||||
public function get(): \Response {
|
||||
$posts = posts::getPosts(include_hidden: admin::isAdmin());
|
||||
//$tags = posts::getAllTags(include_hidden: admin::isAdmin());
|
||||
|
||||
$this->skin->title = \LangData::getInstance()['articles'];
|
||||
return $this->skin->renderPage('main/articles',
|
||||
posts: $posts);
|
||||
}
|
||||
}
|
@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler;
|
||||
|
||||
use admin;
|
||||
use model\Page;
|
||||
use model\Post;
|
||||
use model\Tag;
|
||||
use exceptions\NotFoundException;
|
||||
use pages;
|
||||
use posts;
|
||||
use RedirectResponse;
|
||||
use RequestHandler;
|
||||
use Response;
|
||||
|
||||
class AutoHandler extends RequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
list($name) = $this->input('name');
|
||||
|
||||
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(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);
|
||||
}
|
||||
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler;
|
||||
|
||||
use admin;
|
||||
use posts;
|
||||
|
||||
class IndexHandler extends \RequestHandler {
|
||||
|
||||
public function get(): \Response {
|
||||
global $config;
|
||||
|
||||
$page = \pages::getPageByName($config['index_page_id']);
|
||||
$this->skin->title = $page->title;
|
||||
$this->skin->fixedTitle = true;
|
||||
|
||||
return $this->skin->renderPage('main/page',
|
||||
unsafe_html: $page->getHtml($this->isRetina(), \themes::getUserTheme()),
|
||||
page_url: $page->getUrl(),
|
||||
short_name: $page->shortName);
|
||||
}
|
||||
}
|
||||
|
@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler;
|
||||
use posts;
|
||||
use Response;
|
||||
use SkinContext;
|
||||
|
||||
class RSSHandler extends \RequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
global $config;
|
||||
|
||||
$items = array_map(fn(\model\Post $post) => [
|
||||
'title' => $post->title,
|
||||
'link' => $post->getUrl(),
|
||||
'pub_date' => date(DATE_RSS, $post->ts),
|
||||
'description' => $post->getDescriptionPreview(500),
|
||||
], posts::getPosts(0, 20));
|
||||
|
||||
$ctx = new SkinContext('\\skin\\rss');
|
||||
$body = $ctx->atom(
|
||||
title: ($this->lang)('site_title'),
|
||||
link: 'https://'.$config['domain'],
|
||||
rss_link: 'https://'.$config['domain'].'/feed.rss',
|
||||
items: $items);
|
||||
|
||||
$response = new Response(200, $body);
|
||||
$response->addHeader('Content-Type: application/rss+xml; charset=utf-8');
|
||||
return $response;
|
||||
}
|
||||
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use admin;
|
||||
use exceptions\ForbiddenException;
|
||||
use Response;
|
||||
|
||||
class AdminRequestHandler extends \RequestHandler {
|
||||
|
||||
public function beforeDispatch(): ?Response {
|
||||
$this->skin->static[] = 'css/admin.css';
|
||||
$this->skin->static[] = 'js/admin.js';
|
||||
|
||||
if (!($this instanceof LoginHandler) && !admin::isAdmin())
|
||||
throw new ForbiddenException('These aren\'t the pages you\'re looking for...');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use model\Page;
|
||||
use model\Post;
|
||||
use Response;
|
||||
|
||||
abstract class AutoAddOrEditHandler extends AdminRequestHandler {
|
||||
|
||||
public function beforeDispatch(): ?Response {
|
||||
$this->skin->setOptions([
|
||||
'full_width' => true,
|
||||
'no_footer' => true
|
||||
]);
|
||||
return parent::beforeDispatch();
|
||||
}
|
||||
|
||||
protected function _get_postAdd(
|
||||
string $title = '',
|
||||
string $text = '',
|
||||
?array $tags = null,
|
||||
string $short_name = '',
|
||||
?string $error_code = null
|
||||
): Response {
|
||||
$this->skin->addLangKeys($this->lang->search('/^(err_)?blog_/'));
|
||||
$this->skin->title = $this->lang['blog_write'];
|
||||
return $this->skin->renderPage('admin/postForm',
|
||||
title: $title,
|
||||
text: $text,
|
||||
tags: $tags ? implode(', ', $tags) : '',
|
||||
short_name: $short_name,
|
||||
error_code: $error_code);
|
||||
}
|
||||
|
||||
protected function _get_postEdit(
|
||||
Post $post,
|
||||
string $title = '',
|
||||
string $text = '',
|
||||
?array $tags = null,
|
||||
bool $visible = false,
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use csrf;
|
||||
use NotFoundException;
|
||||
use pages;
|
||||
use posts;
|
||||
use RedirectResponse;
|
||||
use Response;
|
||||
|
||||
class AutoDelete extends AdminRequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
list($name) = $this->input('short_name');
|
||||
|
||||
$post = posts::getPostByName($name);
|
||||
if ($post) {
|
||||
csrf::check('delpost'.$post->id);
|
||||
posts::delete($post);
|
||||
return new RedirectResponse('/');
|
||||
}
|
||||
|
||||
$page = pages::getPageByName($name);
|
||||
if ($page) {
|
||||
csrf::check('delpage'.$page->shortName);
|
||||
pages::delete($page);
|
||||
return new RedirectResponse('/');
|
||||
}
|
||||
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
}
|
@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use csrf;
|
||||
use pages;
|
||||
use posts;
|
||||
use Response;
|
||||
|
||||
class AutoEditHandler extends AutoAddOrEditHandler {
|
||||
|
||||
public function get(): Response {
|
||||
list($short_name, $saved) = $this->input('short_name, b:saved');
|
||||
|
||||
$post = posts::getPostByName($short_name);
|
||||
if ($post) {
|
||||
$tags = $post->getTags();
|
||||
return $this->_get_postEdit($post,
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use Response;
|
||||
|
||||
class IndexHandler extends AdminRequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
$this->skin->title = 'Admin';
|
||||
return $this->skin->renderPage('admin/index');
|
||||
}
|
||||
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use admin;
|
||||
use csrf;
|
||||
use RedirectResponse;
|
||||
use Response;
|
||||
use UnauthorizedException;
|
||||
|
||||
class LoginHandler extends AdminRequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
if (admin::isAdmin())
|
||||
return new RedirectResponse('/admin/');
|
||||
return $this->skin->renderPage('admin/login');
|
||||
}
|
||||
|
||||
public function post(): Response {
|
||||
csrf::check('adminlogin');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$valid = admin::checkPassword($password);
|
||||
if ($valid) {
|
||||
admin::logAuth();
|
||||
admin::setCookie();
|
||||
return new RedirectResponse('/admin/');
|
||||
}
|
||||
throw new UnauthorizedException('nice try');
|
||||
}
|
||||
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use admin;
|
||||
use csrf;
|
||||
use Response;
|
||||
|
||||
class LogoutHandler extends AdminRequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
csrf::check('logout');
|
||||
admin::unsetCookie();
|
||||
return new \RedirectResponse('/admin/login/');
|
||||
}
|
||||
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use Response;
|
||||
|
||||
class MarkdownPreviewHandler extends AdminRequestHandler {
|
||||
|
||||
public function post(): Response {
|
||||
list($md, $title, $use_image_previews) = $this->input('md, title, b:use_image_previews');
|
||||
|
||||
$html = \markup::markdownToHtml($md, $use_image_previews);
|
||||
|
||||
$ctx = new \SkinContext('\\skin\\admin');
|
||||
$html = $ctx->markdownPreview(
|
||||
unsafe_html: $html,
|
||||
title: $title
|
||||
);
|
||||
return new \AjaxOkResponse(['html' => $html]);
|
||||
}
|
||||
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use csrf;
|
||||
use NotFoundException;
|
||||
use pages;
|
||||
use RedirectResponse;
|
||||
use Response;
|
||||
|
||||
class PageAddHandler extends AutoAddOrEditHandler {
|
||||
|
||||
public function get(): Response {
|
||||
list($name) = $this->input('short_name');
|
||||
$page = pages::getPageByName($name);
|
||||
if ($page)
|
||||
throw new NotFoundException();
|
||||
|
||||
return $this->_get_pageAdd($name);
|
||||
}
|
||||
|
||||
public function post(): Response {
|
||||
csrf::check('addpage');
|
||||
|
||||
list($name) = $this->input('short_name');
|
||||
$page = pages::getPageByName($name);
|
||||
if ($page)
|
||||
throw new NotFoundException();
|
||||
|
||||
$text = trim($_POST['text'] ?? '');
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$error_code = null;
|
||||
|
||||
if (!$title) {
|
||||
$error_code = 'no_title';
|
||||
} else if (!$text) {
|
||||
$error_code = 'no_text';
|
||||
}
|
||||
|
||||
if ($error_code) {
|
||||
return $this->_get_pageAdd(
|
||||
name: $name,
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use csrf;
|
||||
use posts;
|
||||
use RedirectResponse;
|
||||
use Response;
|
||||
|
||||
class PostAddHandler extends AutoAddOrEditHandler {
|
||||
|
||||
public function get(): Response {
|
||||
return $this->_get_postAdd();
|
||||
}
|
||||
|
||||
public function post(): Response {
|
||||
csrf::check('addpost');
|
||||
|
||||
list($text, $title, $tags, $visible, $short_name)
|
||||
= $this->input('text, title, tags, b:visible, short_name');
|
||||
$tags = posts::splitStringToTags($tags);
|
||||
|
||||
$error_code = null;
|
||||
|
||||
if (!$title) {
|
||||
$error_code = 'no_title';
|
||||
} else if (!$text) {
|
||||
$error_code = 'no_text';
|
||||
} else if (empty($tags)) {
|
||||
$error_code = 'no_tags';
|
||||
} else if (empty($short_name)) {
|
||||
$error_code = 'no_short_name';
|
||||
}
|
||||
|
||||
if ($error_code)
|
||||
return $this->_get_postAdd(
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use csrf;
|
||||
use RedirectResponse;
|
||||
use Response;
|
||||
|
||||
class UploadDeleteHandler extends AdminRequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
list($id) = $this->input('i:id');
|
||||
|
||||
$upload = \uploads::get($id);
|
||||
if (!$upload)
|
||||
return new RedirectResponse('/admin/uploads/?error='.urlencode('upload not found'));
|
||||
|
||||
csrf::check('delupl'.$id);
|
||||
|
||||
\uploads::delete($id);
|
||||
|
||||
return new RedirectResponse('/admin/uploads/');
|
||||
}
|
||||
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use csrf;
|
||||
use Response;
|
||||
|
||||
class UploadEditNoteHandler extends AdminRequestHandler {
|
||||
|
||||
public function post(): Response {
|
||||
list($id) = $this->input('i:id');
|
||||
|
||||
$upload = \uploads::get($id);
|
||||
if (!$upload)
|
||||
return new \RedirectResponse('/admin/uploads/?error='.urlencode('upload not found'));
|
||||
|
||||
csrf::check('editupl'.$id);
|
||||
|
||||
$note = $_POST['note'] ?? '';
|
||||
$upload->setNote($note);
|
||||
|
||||
return new \RedirectResponse('/admin/uploads/');
|
||||
}
|
||||
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace handler\admin;
|
||||
|
||||
use csrf;
|
||||
use RedirectResponse;
|
||||
use Response;
|
||||
|
||||
// So it's 2022 outside, and it's PHP 8.2 already, which is actually so cool comparing to 5.x and even 7.4, but...
|
||||
// ...class names are still case-insensitive?!! And I can't import \uploads because it's the same as Uploads?!!
|
||||
//
|
||||
// PHP, what the fuck is wrong with you?!
|
||||
|
||||
class UploadsHandler extends AdminRequestHandler {
|
||||
|
||||
public function get(): Response {
|
||||
list($error) = $this->input('error');
|
||||
$uploads = \uploads::getAll();
|
||||
|
||||
$this->skin->title = ($this->lang)('blog_upload');
|
||||
return $this->skin->renderPage('admin/uploads',
|
||||
error: $error,
|
||||
uploads: $uploads);
|
||||
}
|
||||
|
||||
public function post(): Response {
|
||||
csrf::check('addupl');
|
||||
|
||||
list($custom_name, $note) = $this->input('name, note');
|
||||
|
||||
if (!isset($_FILES['files']))
|
||||
return new RedirectResponse('/admin/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('/admin/uploads/?error='.urlencode('error code '.$f['error']));
|
||||
|
||||
if (!$f['size'])
|
||||
return new RedirectResponse('/admin/uploads/?error='.urlencode('received empty file'));
|
||||
|
||||
$ext = extension($f['name']);
|
||||
if (!\uploads::isExtensionAllowed($ext))
|
||||
return new RedirectResponse('/admin/uploads/?error='.urlencode('extension not allowed'));
|
||||
|
||||
$upload_id = \uploads::add(
|
||||
$f['tmp_name'],
|
||||
$custom_name ?: $f['name'],
|
||||
$note);
|
||||
|
||||
if (!$upload_id)
|
||||
return new RedirectResponse('/admin/uploads/?error='.urlencode('failed to create upload'));
|
||||
}
|
||||
|
||||
return new RedirectResponse('/admin/uploads/');
|
||||
}
|
||||
|
||||
}
|
@ -1,177 +0,0 @@
|
||||
<?php
|
||||
|
||||
use util\AnsiColor as Color;
|
||||
use util\AnsiUtil;
|
||||
|
||||
class logging {
|
||||
|
||||
// private static $instance = null;
|
||||
|
||||
protected static ?string $logFile = null;
|
||||
protected static bool $enabled = false;
|
||||
protected static int $counter = 0;
|
||||
|
||||
/** @var ?callable $filter */
|
||||
protected static $filter = null;
|
||||
|
||||
public static function setLogFile(string $log_file): void {
|
||||
self::$logFile = $log_file;
|
||||
}
|
||||
|
||||
public static function setErrorFilter(callable $filter): void {
|
||||
self::$filter = $filter;
|
||||
}
|
||||
|
||||
public static function disable(): void {
|
||||
self::$enabled = false;
|
||||
|
||||
restore_error_handler();
|
||||
register_shutdown_function(function() {});
|
||||
}
|
||||
|
||||
public static function enable(): void {
|
||||
self::$enabled = true;
|
||||
|
||||
set_error_handler(function($no, $str, $file, $line) {
|
||||
if (is_callable(self::$filter) && !(self::$filter)($no, $file, $line, $str))
|
||||
return;
|
||||
|
||||
self::write(LogLevel::ERROR, $str,
|
||||
errno: $no,
|
||||
errfile: $file,
|
||||
errline: $line);
|
||||
});
|
||||
|
||||
register_shutdown_function(function() {
|
||||
if (!($error = error_get_last()))
|
||||
return;
|
||||
|
||||
if (is_callable(self::$filter)
|
||||
&& !(self::$filter)($error['type'], $error['file'], $error['line'], $error['message'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::write(LogLevel::ERROR, $error['message'],
|
||||
errno: $error['type'],
|
||||
errfile: $error['file'],
|
||||
errline: $error['line']);
|
||||
});
|
||||
}
|
||||
|
||||
public static function logCustom(LogLevel $level, ...$args): void {
|
||||
global $config;
|
||||
if (!$config['is_dev'] && $level == LogLevel::DEBUG)
|
||||
return;
|
||||
self::write($level, self::strVars($args));
|
||||
}
|
||||
|
||||
protected static function write(LogLevel $level,
|
||||
string $message,
|
||||
?int $errno = null,
|
||||
?string $errfile = null,
|
||||
?string $errline = null): void {
|
||||
|
||||
// TODO test
|
||||
if (is_null(self::$logFile)) {
|
||||
fprintf(STDERR, __METHOD__.': logfile is not set');
|
||||
return;
|
||||
}
|
||||
|
||||
$num = self::$counter++;
|
||||
$time = time();
|
||||
|
||||
// TODO rewrite using sprintf
|
||||
$exec_time = strval(exectime());
|
||||
if (strlen($exec_time) < 6)
|
||||
$exec_time .= str_repeat('0', 6 - strlen($exec_time));
|
||||
|
||||
// $bt = backtrace(2);
|
||||
|
||||
$title = PHP_SAPI == 'cli' ? 'cli' : $_SERVER['REQUEST_URI'];
|
||||
$date = date('d/m/y H:i:s', $time);
|
||||
|
||||
$buf = '';
|
||||
if ($num == 0) {
|
||||
$buf .= AnsiUtil::wrap(" $title ",
|
||||
fg: Color::WHITE,
|
||||
bg: Color::MAGENTA,
|
||||
bold: true,
|
||||
fg_bright: true);
|
||||
$buf .= AnsiUtil::wrap(" $date ", fg: Color::WHITE, bg: Color::BLUE, fg_bright: true);
|
||||
$buf .= "\n";
|
||||
}
|
||||
|
||||
$letter = strtoupper($level->name[0]);
|
||||
$color = match ($level) {
|
||||
LogLevel::ERROR => Color::RED,
|
||||
LogLevel::INFO, LogLevel::DEBUG => Color::WHITE,
|
||||
LogLevel::WARNING => Color::YELLOW
|
||||
};
|
||||
|
||||
$buf .= 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);
|
||||
}
|
@ -1,232 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace model;
|
||||
|
||||
abstract class Model {
|
||||
|
||||
const DB_TABLE = null;
|
||||
const DB_KEY = 'id';
|
||||
|
||||
protected static array $SpecCache = [];
|
||||
|
||||
public static function create_instance(...$args) {
|
||||
$cl = get_called_class();
|
||||
return new $cl(...$args);
|
||||
}
|
||||
|
||||
public function __construct(array $raw) {
|
||||
if (!isset(self::$SpecCache[static::class])) {
|
||||
list($fields, $model_name_map, $db_name_map) = static::get_spec();
|
||||
self::$SpecCache[static::class] = [
|
||||
'fields' => $fields,
|
||||
'model_name_map' => $model_name_map,
|
||||
'db_name_map' => $db_name_map
|
||||
];
|
||||
}
|
||||
|
||||
foreach (self::$SpecCache[static::class]['fields'] as $field)
|
||||
$this->{$field['model_name']} = self::cast_to_type($field['type'], $raw[$field['db_name']]);
|
||||
|
||||
if (is_null(static::DB_TABLE))
|
||||
trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined');
|
||||
}
|
||||
|
||||
public function edit(array $fields) {
|
||||
$db = getDb();
|
||||
|
||||
$model_upd = [];
|
||||
$db_upd = [];
|
||||
|
||||
foreach ($fields as $name => $value) {
|
||||
$index = self::$SpecCache[static::class]['db_name_map'][$name] ?? null;
|
||||
if (is_null($index)) {
|
||||
logError(__METHOD__.': field `'.$name.'` not found in '.static::class);
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = self::$SpecCache[static::class]['fields'][$index];
|
||||
switch ($field['type']) {
|
||||
case 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];
|
||||
}
|
||||
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace model;
|
||||
|
||||
enum ModelType {
|
||||
case STRING;
|
||||
case INTEGER;
|
||||
case FLOAT;
|
||||
case ARRAY;
|
||||
case BOOLEAN;
|
||||
case JSON;
|
||||
case SERIALIZED;
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace model;
|
||||
|
||||
use markup;
|
||||
|
||||
class Page extends Model
|
||||
{
|
||||
|
||||
const DB_TABLE = 'pages';
|
||||
const DB_KEY = 'short_name';
|
||||
|
||||
public string $title;
|
||||
public string $md;
|
||||
public string $html;
|
||||
public int $ts;
|
||||
public int $updateTs;
|
||||
public bool $visible;
|
||||
public string $shortName;
|
||||
|
||||
public function edit(array $data) {
|
||||
$data['update_ts'] = time();
|
||||
if ($data['md'] != $this->md)
|
||||
$data['html'] = markup::markdownToHtml($data['md']);
|
||||
parent::edit($data);
|
||||
}
|
||||
|
||||
public function isUpdated(): bool {
|
||||
return $this->updateTs && $this->updateTs != $this->ts;
|
||||
}
|
||||
|
||||
public function getHtml(bool $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);
|
||||
}
|
||||
|
||||
}
|
@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace model;
|
||||
|
||||
use markup;
|
||||
use posts;
|
||||
use uploads;
|
||||
|
||||
class Post extends Model {
|
||||
|
||||
const DB_TABLE = 'posts';
|
||||
|
||||
public int $id;
|
||||
public string $title;
|
||||
public string $md;
|
||||
public string $html;
|
||||
public string $tocHtml;
|
||||
public string $text;
|
||||
public int $ts;
|
||||
public int $updateTs;
|
||||
public bool $visible;
|
||||
public bool $toc;
|
||||
public string $shortName;
|
||||
|
||||
public function edit(array $data) {
|
||||
$cur_ts = time();
|
||||
if (!$this->visible && $data['visible'])
|
||||
$data['ts'] = $cur_ts;
|
||||
|
||||
$data['update_ts'] = $cur_ts;
|
||||
|
||||
if ($data['md'] != $this->md) {
|
||||
$data['html'] = markup::markdownToHtml($data['md']);
|
||||
$data['text'] = markup::htmlToText($data['html']);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace model;
|
||||
|
||||
class Tag extends Model implements \Stringable
|
||||
{
|
||||
|
||||
const DB_TABLE = 'tags';
|
||||
|
||||
public int $id;
|
||||
public string $tag;
|
||||
public int $postsCount;
|
||||
public int $visiblePostsCount;
|
||||
|
||||
public function getUrl(): string {
|
||||
return '/'.$this->tag.'/';
|
||||
}
|
||||
|
||||
public function getPostsCount(bool $is_admin): int {
|
||||
return $is_admin ? $this->postsCount : $this->visiblePostsCount;
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
}
|
@ -1,167 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace model;
|
||||
use themes;
|
||||
|
||||
class Upload extends Model
|
||||
{
|
||||
|
||||
const DB_TABLE = 'uploads';
|
||||
|
||||
public static array $ImageExtensions = ['jpg', 'jpeg', 'png', 'gif'];
|
||||
public static array $VideoExtensions = ['mp4', 'ogg'];
|
||||
|
||||
public int $id;
|
||||
public string $randomId;
|
||||
public int $ts;
|
||||
public string $name;
|
||||
public int $size;
|
||||
public int $downloads;
|
||||
public int $image; // TODO: remove
|
||||
public int $imageW;
|
||||
public int $imageH;
|
||||
public string $note;
|
||||
|
||||
public function getDirectory(): string {
|
||||
global $config;
|
||||
return $config['uploads_dir'].'/'.$this->randomId;
|
||||
}
|
||||
|
||||
public function getDirectUrl(): string {
|
||||
global $config;
|
||||
return $config['uploads_path'].'/'.$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 $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg';
|
||||
}
|
||||
|
||||
// TODO remove?
|
||||
public function incrementDownloads(): void {
|
||||
$db = getDb();
|
||||
$db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id);
|
||||
$this->downloads++;
|
||||
}
|
||||
|
||||
public function getSize(): string {
|
||||
return sizeString($this->size);
|
||||
}
|
||||
|
||||
public function getMarkdown(): string {
|
||||
if ($this->isImage()) {
|
||||
$md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.'}{/image}';
|
||||
} else if ($this->isVideo()) {
|
||||
$md = '{video:'.$this->randomId.'}{/video}';
|
||||
} else {
|
||||
$md = '{fileAttach:'.$this->randomId.'}{/fileAttach}';
|
||||
}
|
||||
$md .= ' <!-- '.$this->name.' -->';
|
||||
return $md;
|
||||
}
|
||||
|
||||
public function setNote(string $note): void {
|
||||
$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;
|
||||
}
|
||||
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use model\Page;
|
||||
|
||||
class pages {
|
||||
|
||||
public static function add(array $data): bool {
|
||||
$db = getDb();
|
||||
$data['ts'] = time();
|
||||
$data['html'] = markup::markdownToHtml($data['md']);
|
||||
return !!$db->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")));
|
||||
}
|
||||
|
||||
}
|
@ -1,194 +0,0 @@
|
||||
<?php
|
||||
|
||||
use model\Post;
|
||||
use model\Tag;
|
||||
|
||||
class posts {
|
||||
|
||||
public static function getPostsCount(bool $include_hidden = false): int {
|
||||
$db = getDb();
|
||||
$sql = "SELECT COUNT(*) FROM posts";
|
||||
if (!$include_hidden) {
|
||||
$sql .= " WHERE visible=1";
|
||||
}
|
||||
return (int)$db->result($db->query($sql));
|
||||
}
|
||||
|
||||
public static function getPostsCountByTagId(int $tag_id, bool $include_hidden = false): int {
|
||||
$db = getDb();
|
||||
if ($include_hidden) {
|
||||
$sql = "SELECT COUNT(*) FROM posts_tags WHERE tag_id=?";
|
||||
} else {
|
||||
$sql = "SELECT COUNT(*) FROM posts_tags
|
||||
LEFT JOIN posts ON posts.id=posts_tags.post_id
|
||||
WHERE posts_tags.tag_id=? AND posts.visible=1";
|
||||
}
|
||||
return (int)$db->result($db->query($sql, $tag_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
class themes {
|
||||
|
||||
public static array $Themes = [
|
||||
'dark' => [
|
||||
'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');
|
||||
}
|
||||
|
||||
}
|
@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
use model\Upload;
|
||||
|
||||
class uploads {
|
||||
|
||||
protected static $allowedExtensions = [
|
||||
'jpg', 'png', 'git', 'mp4', 'mp3', 'ogg', 'diff', 'txt', 'gz', 'tar',
|
||||
'icc', 'icm', 'patch', 'zip', 'brd', 'pdf', 'lua', 'xpi', 'rar', '7z',
|
||||
'tgz', 'bin', 'py', 'pac', 'yaml', 'toml', 'xml', 'json', 'yml',
|
||||
];
|
||||
|
||||
public static function getCount(): int {
|
||||
$db = getDb();
|
||||
return (int)$db->result($db->query("SELECT COUNT(*) FROM uploads"));
|
||||
}
|
||||
|
||||
public static function isExtensionAllowed(string $ext): bool {
|
||||
return in_array($ext, self::$allowedExtensions);
|
||||
}
|
||||
|
||||
public static function add(string $tmp_name, string $name, string $note): ?int {
|
||||
global $config;
|
||||
|
||||
$name = sanitize_filename($name);
|
||||
if (!$name)
|
||||
$name = 'file';
|
||||
|
||||
$random_id = self::getNewRandomId();
|
||||
$size = filesize($tmp_name);
|
||||
$is_image = detect_image_type($tmp_name) !== false;
|
||||
$image_w = 0;
|
||||
$image_h = 0;
|
||||
if ($is_image) {
|
||||
list($image_w, $image_h) = getimagesize($tmp_name);
|
||||
}
|
||||
|
||||
$db = getDb();
|
||||
if (!$db->insert('uploads', [
|
||||
'random_id' => $random_id,
|
||||
'ts' => time(),
|
||||
'name' => $name,
|
||||
'size' => $size,
|
||||
'image' => (int)$is_image,
|
||||
'image_w' => $image_w,
|
||||
'image_h' => $image_h,
|
||||
'note' => $note,
|
||||
'downloads' => 0,
|
||||
])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = $db->insertId();
|
||||
|
||||
$dir = $config['uploads_dir'].'/'.$random_id;
|
||||
$path = $dir.'/'.$name;
|
||||
|
||||
mkdir($dir);
|
||||
chmod($dir, 0775); // g+w
|
||||
|
||||
rename($tmp_name, $path);
|
||||
chmod($path, 0664); // g+w
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool {
|
||||
$upload = self::get($id);
|
||||
if (!$upload)
|
||||
return false;
|
||||
|
||||
$db = getDb();
|
||||
$db->query("DELETE FROM uploads WHERE id=?", $id);
|
||||
|
||||
rrmdir($upload->getDirectory());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Upload[]
|
||||
*/
|
||||
public static function getAll(): array {
|
||||
$db = getDb();
|
||||
$q = $db->query("SELECT * FROM uploads ORDER BY id DESC");
|
||||
return array_map('model\Upload::create_instance', $db->fetchAll($q));
|
||||
}
|
||||
|
||||
public static function get(int $id): ?Upload {
|
||||
$db = getDb();
|
||||
$q = $db->query("SELECT * FROM uploads WHERE id=?", $id);
|
||||
if ($db->numRows($q)) {
|
||||
return new Upload($db->fetch($q));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @param bool $flat
|
||||
* @return Upload[]
|
||||
*/
|
||||
public static function getUploadsByRandomId(array $ids, bool $flat = false): array {
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = getDb();
|
||||
$uploads = array_fill_keys($ids, null);
|
||||
|
||||
$q = $db->query("SELECT * FROM uploads WHERE random_id IN('".implode('\',\'', array_map([$db, 'escape'], $ids))."')");
|
||||
|
||||
while ($row = $db->fetch($q)) {
|
||||
$uploads[$row['random_id']] = new Upload($row);
|
||||
}
|
||||
|
||||
if ($flat) {
|
||||
$list = [];
|
||||
foreach ($ids as $id) {
|
||||
$list[] = $uploads[$id];
|
||||
}
|
||||
unset($uploads);
|
||||
return $list;
|
||||
}
|
||||
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
public static function getByRandomId(string $random_id): ?Upload {
|
||||
$db = getDb();
|
||||
$q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id);
|
||||
if ($db->numRows($q)) {
|
||||
return new Upload($db->fetch($q));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected static function getNewRandomId(): string {
|
||||
$db = getDb();
|
||||
do {
|
||||
$random_id = strgen(8);
|
||||
} while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0);
|
||||
return $random_id;
|
||||
}
|
||||
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace util;
|
||||
|
||||
enum AnsiColor: int {
|
||||
case BLACK = 0;
|
||||
case RED = 1;
|
||||
case GREEN = 2;
|
||||
case YELLOW = 3;
|
||||
case BLUE = 4;
|
||||
case MAGENTA = 5;
|
||||
case CYAN = 6;
|
||||
case WHITE = 7;
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace util;
|
||||
|
||||
class AnsiUtil {
|
||||
|
||||
public static function wrap(string $text,
|
||||
?AnsiColor $fg = null,
|
||||
?AnsiColor $bg = null,
|
||||
bool $bold = false,
|
||||
bool $fg_bright = false,
|
||||
bool $bg_bright = false): string {
|
||||
$codes = [];
|
||||
if (!is_null($fg))
|
||||
$codes[] = $fg->value + ($fg_bright ? 90 : 30);
|
||||
if (!is_null($bg))
|
||||
$codes[] = $bg->value + ($bg_bright ? 100 : 40);
|
||||
if ($bold)
|
||||
$codes[] = 1;
|
||||
|
||||
if (empty($codes))
|
||||
return $text;
|
||||
|
||||
return "\033[".implode(';', $codes)."m".$text."\033[0m";
|
||||
}
|
||||
|
||||
}
|
@ -6,7 +6,8 @@
|
||||
"ext-mbstring": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-mysqli": "*",
|
||||
"ext-json": "*"
|
||||
"ext-json": "*",
|
||||
"ext-yaml": "*"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
|
27
config.php
27
config.php
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'domain' => 'example.com',
|
||||
'cookie_host' => '.example.com',
|
||||
'admin_email' => 'admin@example.com',
|
||||
|
||||
'db' => [
|
||||
'type' => 'mysql',
|
||||
'host' => '127.0.0.1',
|
||||
'user' => '',
|
||||
'password' => '',
|
||||
'database' => '',
|
||||
],
|
||||
|
||||
'log_file' => '/var/log/example.com-backend.log',
|
||||
|
||||
'password_salt' => '12345',
|
||||
'csrf_token' => '12345',
|
||||
'uploads_dir' => '/home/user/files.example.com',
|
||||
|
||||
'dirs_mode' => 0775,
|
||||
'files_mode' => 0664,
|
||||
'group' => 33, // id -g www-data
|
||||
'is_dev' => false,
|
||||
'index_page_id' => 'index-wgm9Fkl'
|
||||
];
|
33
config.yaml.example
Normal file
33
config.yaml.example
Normal file
@ -0,0 +1,33 @@
|
||||
domain: example.org
|
||||
cookie_host: .example.org
|
||||
admin_email: admin@example.org
|
||||
|
||||
dev_domains:
|
||||
- dev
|
||||
|
||||
mysql:
|
||||
host: "127.0.0.1"
|
||||
user: "user"
|
||||
password: "password"
|
||||
database: "dbname"
|
||||
# misc
|
||||
log: false
|
||||
log_stat: false
|
||||
|
||||
|
||||
umask: 0002
|
||||
group: www-data
|
||||
files_mode: 0664
|
||||
dirs_mode: 0775
|
||||
|
||||
csrf_token: "123"
|
||||
password_salt: "456"
|
||||
|
||||
uploads_dir: /home/user/files.example.org
|
||||
uploads_path: /uploads
|
||||
|
||||
# deploy config
|
||||
git_repo: git@github.com:example/example_org.git
|
||||
|
||||
# runtime variables
|
||||
is_dev: false
|
@ -13,6 +13,7 @@ DEV_DIR="$(realpath "$DIR/../")"
|
||||
STAGING_DIR="$HOME/staging"
|
||||
PROD_DIR="$HOME/www"
|
||||
PHP=/usr/bin/php
|
||||
REPO_URI=$(cat "$DEV_DIR/config.yaml" | grep ^git_repo | head -1 | awk '{print $2}')
|
||||
|
||||
git push origin master
|
||||
|
||||
@ -21,7 +22,7 @@ pushd "$STAGING_DIR"
|
||||
|
||||
if [ ! -d .git ]; then
|
||||
git init
|
||||
git remote add origin git-hidden@ch1p.io:4in1_ws_web.git
|
||||
git remote add origin "$REPO_URI"
|
||||
git fetch
|
||||
git checkout master
|
||||
fi
|
||||
|
@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use util\cli;
|
||||
|
||||
require __DIR__.'/../init.php';
|
||||
|
||||
if ($argc <= 1) {
|
||||
|
267
engine/logging.php
Normal file
267
engine/logging.php
Normal file
@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
require_once 'lib/ansi.php';
|
||||
|
||||
enum LogLevel: int {
|
||||
case ERROR = 10;
|
||||
case WARNING = 5;
|
||||
case INFO = 3;
|
||||
case DEBUG = 2;
|
||||
}
|
||||
|
||||
function logDebug(...$args): void { global $__logger; $__logger->log(LogLevel::DEBUG, ...$args); }
|
||||
function logInfo(...$args): void { global $__logger; $__logger->log(LogLevel::INFO, ...$args); }
|
||||
function logWarning(...$args): void { global $__logger; $__logger->log(LogLevel::WARNING, ...$args); }
|
||||
function logError(...$args): void { global $__logger; $__logger->log(LogLevel::ERROR, ...$args); }
|
||||
|
||||
abstract class Logger {
|
||||
protected bool $enabled = false;
|
||||
protected int $counter = 0;
|
||||
protected int $recursionLevel = 0;
|
||||
|
||||
/** @var ?callable $filter */
|
||||
protected $filter = null;
|
||||
|
||||
function setErrorFilter(callable $filter): void {
|
||||
$this->filter = $filter;
|
||||
}
|
||||
|
||||
function disable(): void {
|
||||
$this->enabled = false;
|
||||
}
|
||||
|
||||
function enable(): void {
|
||||
static $error_handler_set = false;
|
||||
$this->enabled = true;
|
||||
|
||||
if ($error_handler_set)
|
||||
return;
|
||||
|
||||
$self = $this;
|
||||
|
||||
set_error_handler(function($no, $str, $file, $line) use ($self) {
|
||||
if (!$self->enabled)
|
||||
return;
|
||||
|
||||
if (is_callable($self->filter) && !($self->filter)($no, $file, $line, $str))
|
||||
return;
|
||||
|
||||
static::write(LogLevel::ERROR, $str,
|
||||
errno: $no,
|
||||
errfile: $file,
|
||||
errline: $line);
|
||||
});
|
||||
|
||||
register_shutdown_function(function () use ($self) {
|
||||
if (!$self->enabled || !($error = error_get_last()))
|
||||
return;
|
||||
|
||||
if (is_callable($self->filter)
|
||||
&& !($self->filter)($error['type'], $error['file'], $error['line'], $error['message'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::write(LogLevel::ERROR, $error['message'],
|
||||
errno: $error['type'],
|
||||
errfile: $error['file'],
|
||||
errline: $error['line']);
|
||||
});
|
||||
|
||||
$error_handler_set = true;
|
||||
}
|
||||
|
||||
function log(LogLevel $level, ...$args): void {
|
||||
if (!is_dev() && $level == LogLevel::DEBUG)
|
||||
return;
|
||||
$this->write($level, strVars($args));
|
||||
}
|
||||
|
||||
protected function canReport(): bool {
|
||||
return $this->recursionLevel < 3;
|
||||
}
|
||||
|
||||
protected function write(LogLevel $level,
|
||||
string $message,
|
||||
?int $errno = null,
|
||||
?string $errfile = null,
|
||||
?string $errline = null): void {
|
||||
$this->recursionLevel++;
|
||||
|
||||
if ($this->canReport())
|
||||
$this->writer($level, $this->counter++, $message, $errno, $errfile, $errline);
|
||||
|
||||
$this->recursionLevel--;
|
||||
}
|
||||
|
||||
abstract protected function writer(LogLevel $level,
|
||||
int $num,
|
||||
string $message,
|
||||
?int $errno = null,
|
||||
?string $errfile = null,
|
||||
?string $errline = null): void;
|
||||
}
|
||||
|
||||
class FileLogger extends Logger {
|
||||
|
||||
function __construct(protected string $logFile) {}
|
||||
|
||||
protected function writer(LogLevel $level,
|
||||
int $num,
|
||||
string $message,
|
||||
?int $errno = null,
|
||||
?string $errfile = null,
|
||||
?string $errline = null): void
|
||||
{
|
||||
if (is_null($this->logFile)) {
|
||||
fprintf(STDERR, __METHOD__.': logfile is not set');
|
||||
return;
|
||||
}
|
||||
|
||||
$time = time();
|
||||
|
||||
// TODO rewrite using sprintf
|
||||
$exec_time = strval(exectime());
|
||||
if (strlen($exec_time) < 6)
|
||||
$exec_time .= str_repeat('0', 6 - strlen($exec_time));
|
||||
|
||||
$title = is_cli() ? 'cli' : $_SERVER['REQUEST_URI'];
|
||||
$date = date('d/m/y H:i:s', $time);
|
||||
|
||||
$buf = '';
|
||||
if ($num == 0) {
|
||||
$buf .= ansi(" $title ",
|
||||
fg: AnsiColor::WHITE,
|
||||
bg: AnsiColor::MAGENTA,
|
||||
bold: true,
|
||||
fg_bright: true);
|
||||
$buf .= ansi(" $date ", fg: AnsiColor::WHITE, bg: AnsiColor::BLUE, fg_bright: true);
|
||||
$buf .= "\n";
|
||||
}
|
||||
|
||||
$letter = strtoupper($level->name[0]);
|
||||
$color = match ($level) {
|
||||
LogLevel::ERROR => AnsiColor::RED,
|
||||
LogLevel::INFO => AnsiColor::GREEN,
|
||||
LogLevel::DEBUG => AnsiColor::WHITE,
|
||||
LogLevel::WARNING => AnsiColor::YELLOW
|
||||
};
|
||||
|
||||
$buf .= ansi($letter.ansi('='.ansi($num, bold: true)), fg: $color).' ';
|
||||
$buf .= ansi($exec_time, fg: AnsiColor::CYAN).' ';
|
||||
if (!is_null($errno)) {
|
||||
$buf .= ansi($errfile, fg: AnsiColor::GREEN);
|
||||
$buf .= ansi(':', fg: AnsiColor::WHITE);
|
||||
$buf .= ansi($errline, fg: AnsiColor::GREEN, fg_bright: true);
|
||||
$buf .= ' ('.getPHPErrorName($errno).') ';
|
||||
}
|
||||
|
||||
$buf .= $message."\n";
|
||||
if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING]))
|
||||
$buf .= backtrace_as_string(2)."\n";
|
||||
|
||||
$set_perm = false;
|
||||
if (!file_exists($this->logFile)) {
|
||||
$set_perm = true;
|
||||
$dir = dirname($this->logFile);
|
||||
echo "dir: $dir\n";
|
||||
|
||||
if (!file_exists($dir)) {
|
||||
mkdir($dir);
|
||||
setperm($dir);
|
||||
}
|
||||
}
|
||||
|
||||
$f = fopen($this->logFile, 'a');
|
||||
if (!$f) {
|
||||
fprintf(STDERR, __METHOD__.': failed to open file \''.$this->logFile.'\' for writing');
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite($f, $buf);
|
||||
fclose($f);
|
||||
|
||||
if ($set_perm)
|
||||
setperm($this->logFile);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DatabaseLogger extends Logger {
|
||||
protected function writer(LogLevel $level,
|
||||
int $num,
|
||||
string $message,
|
||||
?int $errno = null,
|
||||
?string $errfile = null,
|
||||
?string $errline = null): void
|
||||
{
|
||||
$db = DB();
|
||||
|
||||
$data = [
|
||||
'ts' => time(),
|
||||
'num' => $num,
|
||||
'time' => exectime(),
|
||||
'errno' => $errno,
|
||||
'file' => $errfile,
|
||||
'line' => $errline,
|
||||
'text' => $message,
|
||||
'level' => $level->value,
|
||||
'stacktrace' => backtrace_as_string(2),
|
||||
'is_cli' => intval(is_cli()),
|
||||
'user_id' => 0,
|
||||
];
|
||||
|
||||
if (is_cli()) {
|
||||
$data += [
|
||||
'ip' => '',
|
||||
'ua' => '',
|
||||
'url' => '',
|
||||
];
|
||||
} else {
|
||||
$data += [
|
||||
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
||||
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'url' => $_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']
|
||||
];
|
||||
}
|
||||
|
||||
$db->insert('backend_errors', $data);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function strVarDump($var, bool $print_r = false): string {
|
||||
ob_start();
|
||||
$print_r ? print_r($var) : var_dump($var);
|
||||
return trim(ob_get_clean());
|
||||
}
|
||||
|
||||
function strVars(array $args): string {
|
||||
$args = array_map(fn($a) => match (gettype($a)) {
|
||||
'string' => $a,
|
||||
'array', 'object' => strVarDump($a, true),
|
||||
default => strVarDump($a)
|
||||
}, $args);
|
||||
return implode(' ', $args);
|
||||
}
|
||||
|
||||
function backtrace_as_string(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);
|
||||
}
|
331
engine/model.php
Normal file
331
engine/model.php
Normal file
@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
enum ModelFieldType {
|
||||
case STRING;
|
||||
case INTEGER;
|
||||
case FLOAT;
|
||||
case ARRAY;
|
||||
case BOOLEAN;
|
||||
case JSON;
|
||||
case SERIALIZED;
|
||||
case BITFIELD;
|
||||
case BACKED_ENUM;
|
||||
}
|
||||
|
||||
abstract class model {
|
||||
|
||||
const DB_TABLE = null;
|
||||
const DB_KEY = 'id';
|
||||
|
||||
/** @var $SpecCache ModelSpec[] */
|
||||
protected static array $SpecCache = [];
|
||||
|
||||
public static function create_instance(...$args) {
|
||||
$cl = get_called_class();
|
||||
return new $cl(...$args);
|
||||
}
|
||||
|
||||
public function __construct(array $raw) {
|
||||
if (!isset(self::$SpecCache[static::class]))
|
||||
self::$SpecCache[static::class] = static::get_spec();
|
||||
|
||||
foreach (self::$SpecCache[static::class]->getProperties() as $prop)
|
||||
$this->{$prop->getModelName()} = $prop->fromRawValue($raw[$prop->getDbName()]);
|
||||
|
||||
if (is_null(static::DB_TABLE))
|
||||
trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined');
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: support adding or subtracting (SET value=value+1)
|
||||
*/
|
||||
public function edit(array $fields) {
|
||||
$db = DB();
|
||||
|
||||
$model_upd = [];
|
||||
$db_upd = [];
|
||||
|
||||
$spec_db_name_map = self::$SpecCache[static::class]->getDbNameMap();
|
||||
$spec_props = self::$SpecCache[static::class]->getProperties();
|
||||
|
||||
foreach ($fields as $name => $value) {
|
||||
$index = $spec_db_name_map[$name] ?? null;
|
||||
if (is_null($index)) {
|
||||
logError(__METHOD__.': field `'.$name.'` not found in '.static::class);
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = $spec_props[$index];
|
||||
if ($field->isNullable() && is_null($value)) {
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
$db_upd[$name] = $value;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch ($field->getType()) {
|
||||
case ModelFieldType::ARRAY:
|
||||
if (is_array($value)) {
|
||||
$db_upd[$name] = implode(',', $value);
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
} else {
|
||||
logError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.');
|
||||
}
|
||||
break;
|
||||
|
||||
case ModelFieldType::INTEGER:
|
||||
$value = (int)$value;
|
||||
$db_upd[$name] = $value;
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
break;
|
||||
|
||||
case ModelFieldType::FLOAT:
|
||||
$value = (float)$value;
|
||||
$db_upd[$name] = $value;
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
break;
|
||||
|
||||
case ModelFieldType::BOOLEAN:
|
||||
$db_upd[$name] = $value ? 1 : 0;
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
break;
|
||||
|
||||
case ModelFieldType::JSON:
|
||||
$db_upd[$name] = jsonEncode($value);
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
break;
|
||||
|
||||
case ModelFieldType::SERIALIZED:
|
||||
$db_upd[$name] = serialize($value);
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
break;
|
||||
|
||||
case ModelFieldType::BITFIELD:
|
||||
$db_upd[$name] = $value;
|
||||
$model_upd[$field->getModelName()] = $value;
|
||||
break;
|
||||
|
||||
default:
|
||||
$value = (string)$value;
|
||||
$db_upd[$name] = $value;
|
||||
$model_upd[$field->getModelName()] = $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 $properties = [], array $custom_getters = []): array {
|
||||
if (empty($properties))
|
||||
$properties = static::$SpecCache[static::class]->getPropNames();
|
||||
|
||||
$array = [];
|
||||
foreach ($properties 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 get_spec(): ModelSpec {
|
||||
$rc = new ReflectionClass(static::class);
|
||||
$props = $rc->getProperties(ReflectionProperty::IS_PUBLIC);
|
||||
|
||||
$list = [];
|
||||
$index = 0;
|
||||
|
||||
$db_name_map = [];
|
||||
|
||||
foreach ($props as $prop) {
|
||||
if ($prop->isStatic())
|
||||
continue;
|
||||
|
||||
$name = $prop->getName();
|
||||
if (str_starts_with($name, '_'))
|
||||
continue;
|
||||
|
||||
$real_type = null;
|
||||
$type = $prop->getType();
|
||||
$phpdoc = $prop->getDocComment();
|
||||
|
||||
/** @var ?ModelFieldType $mytype */
|
||||
$mytype = null;
|
||||
if (!$prop->hasType() && !$phpdoc)
|
||||
$mytype = ModelFieldType::STRING;
|
||||
else {
|
||||
$typename = $type->getName();
|
||||
switch ($typename) {
|
||||
case 'string':
|
||||
$mytype = ModelFieldType::STRING;
|
||||
break;
|
||||
case 'int':
|
||||
$mytype = ModelFieldType::INTEGER;
|
||||
break;
|
||||
case 'float':
|
||||
$mytype = ModelFieldType::FLOAT;
|
||||
break;
|
||||
case 'array':
|
||||
$mytype = ModelFieldType::ARRAY;
|
||||
break;
|
||||
case 'bool':
|
||||
$mytype = ModelFieldType::BOOLEAN;
|
||||
break;
|
||||
case 'mysql_bitfield':
|
||||
$mytype = ModelFieldType::BITFIELD;
|
||||
break;
|
||||
default:
|
||||
if (enum_exists($typename)) {
|
||||
$mytype = ModelFieldType::BACKED_ENUM;
|
||||
$real_type = $typename;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($phpdoc != '') {
|
||||
$pos = strpos($phpdoc, '@');
|
||||
if ($pos === false)
|
||||
continue;
|
||||
|
||||
if (substr($phpdoc, $pos+1, 4) == 'json')
|
||||
$mytype = ModelFieldType::JSON;
|
||||
else if (substr($phpdoc, $pos+1, 5) == 'array')
|
||||
$mytype = ModelFieldType::ARRAY;
|
||||
else if (substr($phpdoc, $pos+1, 10) == 'serialized')
|
||||
$mytype = ModelFieldType::SERIALIZED;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_null($mytype))
|
||||
logError(__METHOD__.": ".$name." is still null in ".static::class);
|
||||
|
||||
// $dbname = from_camel_case($name);
|
||||
$model_descr = new ModelProperty(
|
||||
type: $mytype,
|
||||
realType: $real_type,
|
||||
nullable: $type->allowsNull(),
|
||||
modelName: $name,
|
||||
dbName: from_camel_case($name)
|
||||
);
|
||||
$list[] = $model_descr;
|
||||
$db_name_map[$model_descr->getDbName()] = $index++;
|
||||
}
|
||||
|
||||
return new ModelSpec($list, $db_name_map);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ModelSpec {
|
||||
|
||||
public function __construct(
|
||||
/** @var ModelProperty[] */
|
||||
protected array $properties,
|
||||
protected array $dbNameMap
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return ModelProperty[]
|
||||
*/
|
||||
public function getProperties(): array {
|
||||
return $this->properties;
|
||||
}
|
||||
|
||||
public function getDbNameMap(): array {
|
||||
return $this->dbNameMap;
|
||||
}
|
||||
|
||||
public function getPropNames(): array {
|
||||
return array_keys($this->dbNameMap);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ModelProperty {
|
||||
|
||||
public function __construct(
|
||||
protected ?ModelFieldType $type,
|
||||
protected mixed $realType,
|
||||
protected bool $nullable,
|
||||
protected string $modelName,
|
||||
protected string $dbName
|
||||
) {}
|
||||
|
||||
public function getDbName(): string {
|
||||
return $this->dbName;
|
||||
}
|
||||
|
||||
public function getModelName(): string {
|
||||
return $this->modelName;
|
||||
}
|
||||
|
||||
public function isNullable(): bool {
|
||||
return $this->nullable;
|
||||
}
|
||||
|
||||
public function getType(): ?ModelFieldType {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function fromRawValue(mixed $value): mixed {
|
||||
if ($this->nullable && is_null($value))
|
||||
return null;
|
||||
|
||||
switch ($this->type) {
|
||||
case ModelFieldType::BOOLEAN:
|
||||
return (bool)$value;
|
||||
|
||||
case ModelFieldType::INTEGER:
|
||||
return (int)$value;
|
||||
|
||||
case ModelFieldType::FLOAT:
|
||||
return (float)$value;
|
||||
|
||||
case ModelFieldType::ARRAY:
|
||||
return array_filter(explode(',', $value));
|
||||
|
||||
case ModelFieldType::JSON:
|
||||
$val = jsonDecode($value);
|
||||
if (!$val)
|
||||
$val = null;
|
||||
return $val;
|
||||
|
||||
case ModelFieldType::SERIALIZED:
|
||||
$val = unserialize($value);
|
||||
if ($val === false)
|
||||
$val = null;
|
||||
return $val;
|
||||
|
||||
case ModelFieldType::BITFIELD:
|
||||
return new mysql_bitfield($value);
|
||||
|
||||
case ModelFieldType::BACKED_ENUM:
|
||||
try {
|
||||
return $this->realType::from($value);
|
||||
} catch (ValueError $e) {
|
||||
if ($this->nullable)
|
||||
return null;
|
||||
throw $e;
|
||||
}
|
||||
|
||||
default:
|
||||
return (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
261
engine/mysql.php
Normal file
261
engine/mysql.php
Normal file
@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
class mysql {
|
||||
|
||||
protected ?mysqli $link = null;
|
||||
|
||||
function __construct(
|
||||
protected string $host,
|
||||
protected string $user,
|
||||
protected string $password,
|
||||
protected string $database) {}
|
||||
|
||||
protected function prepareQuery(string $sql, ...$args): string {
|
||||
global $config;
|
||||
if (!empty($args)) {
|
||||
$mark_count = substr_count($sql, '?');
|
||||
$positions = array();
|
||||
$last_pos = -1;
|
||||
for ($i = 0; $i < $mark_count; $i++) {
|
||||
$last_pos = strpos($sql, '?', $last_pos + 1);
|
||||
$positions[] = $last_pos;
|
||||
}
|
||||
for ($i = $mark_count - 1; $i >= 0; $i--) {
|
||||
$arg_val = $args[$i];
|
||||
if (is_null($arg_val)) {
|
||||
$v = 'NULL';
|
||||
} else {
|
||||
$v = '\''.$this->escape($arg_val) . '\'';
|
||||
}
|
||||
$sql = substr_replace($sql, $v, $positions[$i], 1);
|
||||
}
|
||||
}
|
||||
if (!empty($config['mysql']['log']))
|
||||
logDebug(__METHOD__.': ', $sql);
|
||||
return $sql;
|
||||
}
|
||||
|
||||
function insert(string $table, array $fields) {
|
||||
return $this->performInsert('INSERT', $table, $fields);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function multipleInsert(string $table, array $rows) {
|
||||
list($names, $values) = $this->getMultipleInsertValues($rows);
|
||||
$sql = "INSERT INTO `{$table}` (`".implode('`, `', $names)."`) VALUES ".$values;
|
||||
return $this->query($sql);
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
function __destruct() {
|
||||
if ($this->link)
|
||||
$this->link->close();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function query(string $sql, ...$args): mysqli_result|bool {
|
||||
$sql = $this->prepareQuery($sql, ...$args);
|
||||
$q = $this->link->query($sql);
|
||||
if (!$q)
|
||||
logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtrace_as_string(1));
|
||||
return $q;
|
||||
}
|
||||
|
||||
function fetch($q): ?array {
|
||||
$row = $q->fetch_assoc();
|
||||
if (!$row) {
|
||||
$q->free();
|
||||
return null;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
function fetchAll($q): ?array {
|
||||
if (!$q)
|
||||
return null;
|
||||
$list = [];
|
||||
while ($f = $q->fetch_assoc()) {
|
||||
$list[] = $f;
|
||||
}
|
||||
$q->free();
|
||||
return $list;
|
||||
}
|
||||
|
||||
function fetchRow($q): ?array {
|
||||
return $q?->fetch_row();
|
||||
}
|
||||
|
||||
function result($q, $field = 0) {
|
||||
return $q?->fetch_row()[$field];
|
||||
}
|
||||
|
||||
function insertId(): int {
|
||||
return $this->link->insert_id;
|
||||
}
|
||||
|
||||
function numRows($q): ?int {
|
||||
return $q?->num_rows;
|
||||
}
|
||||
|
||||
function affectedRows(): ?int {
|
||||
return $this->link?->affected_rows;
|
||||
}
|
||||
|
||||
function foundRows(): int {
|
||||
return (int)$this->fetch($this->query("SELECT FOUND_ROWS() AS `count`"))['count'];
|
||||
}
|
||||
|
||||
function escape(string $s): string {
|
||||
return $this->link->real_escape_string($s);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class mysql_bitfield {
|
||||
|
||||
private GMP $value;
|
||||
private int $size;
|
||||
|
||||
public function __construct($value, int $size = 64) {
|
||||
$this->value = gmp_init($value);
|
||||
$this->size = $size;
|
||||
}
|
||||
|
||||
public function has(int $bit): bool {
|
||||
$this->validateBit($bit);
|
||||
return gmp_testbit($this->value, $bit);
|
||||
}
|
||||
|
||||
public function set(int $bit): void {
|
||||
$this->validateBit($bit);
|
||||
gmp_setbit($this->value, $bit);
|
||||
}
|
||||
|
||||
public function clear(int $bit): void {
|
||||
$this->validateBit($bit);
|
||||
gmp_clrbit($this->value, $bit);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool {
|
||||
return !gmp_cmp($this->value, 0);
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
$buf = '';
|
||||
for ($bit = $this->size-1; $bit >= 0; --$bit)
|
||||
$buf .= gmp_testbit($this->value, $bit) ? '1' : '0';
|
||||
if (($pos = strpos($buf, '1')) !== false) {
|
||||
$buf = substr($buf, $pos);
|
||||
} else {
|
||||
$buf = '0';
|
||||
}
|
||||
return $buf;
|
||||
}
|
||||
|
||||
private function validateBit(int $bit): void {
|
||||
if ($bit < 0 || $bit >= $this->size)
|
||||
throw new Exception('invalid bit '.$bit.', allowed range: [0..'.$this->size.')');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
function DB(): mysql|null {
|
||||
global $config;
|
||||
|
||||
/** @var ?mysql $link */
|
||||
static $link = null;
|
||||
if (!is_null($link))
|
||||
return $link;
|
||||
|
||||
$link = new mysql(
|
||||
$config['mysql']['host'],
|
||||
$config['mysql']['user'],
|
||||
$config['mysql']['password'],
|
||||
$config['mysql']['database']);
|
||||
if (!$link->connect()) {
|
||||
if (!is_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);
|
||||
}
|
||||
}
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
function MC(): Memcached {
|
||||
static $mc = null;
|
||||
if ($mc === null) {
|
||||
$mc = new Memcached();
|
||||
$mc->addServer("127.0.0.1", 11211);
|
||||
}
|
||||
return $mc;
|
||||
}
|
203
engine/request.php
Normal file
203
engine/request.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
function dispatch_request(): void {
|
||||
global $RouterInput;
|
||||
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET']))
|
||||
http_error(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented');
|
||||
|
||||
$route = router_find(request_path());
|
||||
if ($route === null)
|
||||
http_error(HTTPCode::NotFound, 'Route not found');
|
||||
|
||||
$route = preg_split('/ +/', $route);
|
||||
$handler_class = $route[0].'Handler';
|
||||
if (!class_exists($handler_class))
|
||||
http_error(HTTPCode::NotFound, is_dev() ? 'Handler class "'.$handler_class.'" not found' : '');
|
||||
|
||||
$action = $route[1];
|
||||
|
||||
if (count($route) > 2) {
|
||||
for ($i = 2; $i < count($route); $i++) {
|
||||
$var = $route[$i];
|
||||
list($k, $v) = explode('=', $var);
|
||||
$RouterInput[trim($k)] = trim($v);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var request_handler $handler */
|
||||
$handler = new $handler_class();
|
||||
$handler->call_act($_SERVER['REQUEST_METHOD'], $action);
|
||||
}
|
||||
|
||||
function request_path(): string {
|
||||
$uri = $_SERVER['REQUEST_URI'];
|
||||
if (($pos = strpos($uri, '?')) !== false)
|
||||
$uri = substr($uri, 0, $pos);
|
||||
return $uri;
|
||||
}
|
||||
|
||||
|
||||
enum HTTPCode: int {
|
||||
case MovedPermanently = 301;
|
||||
case Found = 302;
|
||||
|
||||
case Unauthorized = 401;
|
||||
case NotFound = 404;
|
||||
case Forbidden = 403;
|
||||
|
||||
case InternalServerError = 500;
|
||||
case NotImplemented = 501;
|
||||
}
|
||||
|
||||
function http_error(HTTPCode $http_code, string $message = ''): void {
|
||||
$ctx = new SkinContext('\\skin\\error');
|
||||
$http_message = preg_replace('/(?<!^)([A-Z])/', ' $1', $http_code->name);
|
||||
$html = $ctx->http_error($http_code->value, $http_message, $message);
|
||||
http_response_code($http_code->value);
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): void {
|
||||
if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found]))
|
||||
internal_server_error('invalid http code');
|
||||
http_response_code($code->value);
|
||||
header('Location: '.$url);
|
||||
exit;
|
||||
}
|
||||
|
||||
function internal_server_error(string $message = '') { http_error(HTTPCode::InternalServerError, $message); }
|
||||
function not_found(string $message = '') { http_error(HTTPCode::NotFound, $message); }
|
||||
function forbidden(string $message = '') { http_error(HTTPCode::Forbidden, $message); }
|
||||
function is_xhr_request(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; }
|
||||
function ajax_ok(mixed $data): void { ajax_response(['response' => $data]); }
|
||||
function ajax_error(mixed $error, int $code = 200): void { ajax_response(['error' => $error], $code); }
|
||||
|
||||
function ajax_response(mixed $data, int $code = 200): void {
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code($code);
|
||||
echo jsonEncode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
abstract class request_handler {
|
||||
function __construct() {
|
||||
add_static(
|
||||
'css/common.css',
|
||||
'js/common.js'
|
||||
);
|
||||
add_skin_strings_re('/^theme_/');
|
||||
}
|
||||
|
||||
function before_dispatch(string $http_method, string $action) {}
|
||||
|
||||
function call_act(string $http_method, string $action, array $input = []) {
|
||||
global $RouterInput;
|
||||
|
||||
$handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action;
|
||||
if (!method_exists($this, $handler_method))
|
||||
not_found(static::class.'::'.$handler_method.' is not defined');
|
||||
|
||||
if (!((new ReflectionMethod($this, $handler_method))->isPublic()))
|
||||
not_found(static::class.'::'.$handler_method.' is not public');
|
||||
|
||||
if (!empty($input)) {
|
||||
foreach ($input as $k => $v)
|
||||
$RouterInput[$k] = $v;
|
||||
}
|
||||
|
||||
$args = $this->before_dispatch($http_method, $action);
|
||||
return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum InputVarType: string {
|
||||
case INTEGER = 'i';
|
||||
case FLOAT = 'f';
|
||||
case BOOLEAN = 'b';
|
||||
case STRING = 's';
|
||||
case ENUM = 'e';
|
||||
}
|
||||
|
||||
function input(string $input): array {
|
||||
global $RouterInput;
|
||||
$input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$ret = [];
|
||||
foreach ($input as $var) {
|
||||
$enum_values = null;
|
||||
$enum_default = null;
|
||||
|
||||
$pos = strpos($var, ':');
|
||||
if ($pos !== false) {
|
||||
$type = substr($var, 0, $pos);
|
||||
$rest = substr($var, $pos + 1);
|
||||
|
||||
$vartype = InputVarType::tryFrom($type);
|
||||
if (is_null($vartype))
|
||||
internal_server_error('invalid input type '.$type);
|
||||
|
||||
if ($vartype == InputVarType::ENUM) {
|
||||
$br_from = strpos($rest, '(');
|
||||
$br_to = strpos($rest, ')');
|
||||
|
||||
if ($br_from === false || $br_to === false)
|
||||
internal_server_error('failed to parse enum values: '.$rest);
|
||||
|
||||
$enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1))));
|
||||
$name = trim(substr($rest, 0, $br_from));
|
||||
|
||||
if (!empty($enum_values)) {
|
||||
foreach ($enum_values as $key => $val) {
|
||||
if (str_starts_with($val, '=')) {
|
||||
$enum_values[$key] = substr($val, 1);
|
||||
$enum_default = $enum_values[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$name = trim($rest);
|
||||
}
|
||||
|
||||
} else {
|
||||
$vartype = InputVarType::STRING;
|
||||
$name = trim($var);
|
||||
}
|
||||
|
||||
$val = null;
|
||||
if (isset($RouterInput[$name])) {
|
||||
$val = $RouterInput[$name];
|
||||
} else if (isset($_POST[$name])) {
|
||||
$val = $_POST[$name];
|
||||
} else if (isset($_GET[$name])) {
|
||||
$val = $_GET[$name];
|
||||
}
|
||||
if (is_array($val))
|
||||
$val = implode($val);
|
||||
|
||||
$ret[] = match($vartype) {
|
||||
InputVarType::INTEGER => (int)$val,
|
||||
InputVarType::FLOAT => (float)$val,
|
||||
InputVarType::BOOLEAN => (bool)$val,
|
||||
InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : (string)$val,
|
||||
default => (string)$val
|
||||
};
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
function csrf_get(string $key): string { return _csrf_get_token($_SERVER['REMOTE_ADDR'], $key); }
|
||||
function csrf_check(string $key) {
|
||||
if (csrf_get($key) != ($_REQUEST['token'] ?? '')) {
|
||||
forbidden('invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
function _csrf_get_token(string $user_token, string $key): string {
|
||||
global $config;
|
||||
return substr(sha1($config['csrf_token'].$user_token.$key), 0, 20);
|
||||
}
|
185
engine/router.php
Normal file
185
engine/router.php
Normal file
@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
const ROUTER_VERSION = 1;
|
||||
const ROUTER_MC_KEY = '4in1/routes';
|
||||
|
||||
$RouterInput = [];
|
||||
$Routes = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
|
||||
function router_init(): void {
|
||||
global $Routes;
|
||||
$mc = MC();
|
||||
|
||||
$from_cache = !is_dev();
|
||||
$write_cache = !is_dev();
|
||||
|
||||
if ($from_cache) {
|
||||
$cache = $mc->get(ROUTER_MC_KEY);
|
||||
|
||||
if ($cache === false || !isset($cache['version']) || $cache['version'] < ROUTER_VERSION) {
|
||||
$from_cache = false;
|
||||
} else {
|
||||
$Routes = $cache['routes'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$from_cache) {
|
||||
$routes_table = require_once APP_ROOT.'/routes.php';
|
||||
|
||||
foreach ($routes_table as $controller => $routes) {
|
||||
foreach ($routes as $route => $resolve)
|
||||
router_add($route, $controller.' '.$resolve);
|
||||
}
|
||||
|
||||
if ($write_cache)
|
||||
$mc->set(ROUTER_MC_KEY, ['version' => ROUTER_VERSION, 'routes' => $Routes]);
|
||||
}
|
||||
}
|
||||
|
||||
function router_add(string $template, string $value): void {
|
||||
global $Routes;
|
||||
if ($template == '')
|
||||
return;
|
||||
|
||||
// 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 = &$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 = &_router_add($parent, $part,
|
||||
$start_pos < $template_len ? null : $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function &_router_add(&$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];
|
||||
}
|
||||
|
||||
function router_find($uri) {
|
||||
global $Routes;
|
||||
if ($uri != '/' && $uri[0] == '/')
|
||||
$uri = substr($uri, 1);
|
||||
|
||||
$start_pos = 0;
|
||||
$parent = &$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 null;
|
||||
}
|
||||
|
||||
if (!isset($parent['value']))
|
||||
return null;
|
||||
|
||||
$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;
|
||||
}
|
242
engine/skin.php
Normal file
242
engine/skin.php
Normal file
@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
require_once 'lib/themes.php';
|
||||
|
||||
$SkinState = new class {
|
||||
public array $lang = [];
|
||||
public string $title = 'title';
|
||||
public array $meta = [];
|
||||
public array $options = [
|
||||
'full_width' => false,
|
||||
'wide' => false,
|
||||
'dynlogo_enabled' => true,
|
||||
'logo_path_map' => [],
|
||||
'logo_link_map' => [],
|
||||
];
|
||||
public array $static = [];
|
||||
};
|
||||
|
||||
function render($f, ...$vars): void {
|
||||
global $SkinState, $config;
|
||||
|
||||
$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 = getUserTheme();
|
||||
if ($theme != 'auto' && !themeExists($theme))
|
||||
$theme = 'auto';
|
||||
|
||||
$layout_ctx = new SkinContext('\\skin\\base');
|
||||
|
||||
$lang = [];
|
||||
foreach ($SkinState->lang as $key)
|
||||
$lang[$key] = lang($key);
|
||||
$lang = !empty($lang) ? json_encode($lang, JSON_UNESCAPED_UNICODE) : '';
|
||||
|
||||
$html = $layout_ctx->layout(
|
||||
static: $SkinState->static,
|
||||
theme: $theme,
|
||||
title: $SkinState->title,
|
||||
opts: $SkinState->options,
|
||||
js: $js,
|
||||
meta: $SkinState->meta,
|
||||
unsafe_lang: $lang,
|
||||
unsafe_body: $body,
|
||||
exec_time: exectime(),
|
||||
admin_email: $config['admin_email'],
|
||||
);
|
||||
echo $html;
|
||||
exit;
|
||||
}
|
||||
|
||||
function set_title(string $title): void {
|
||||
global $SkinState;
|
||||
if (str_starts_with($title, '$'))
|
||||
$title = lang(substr($title, 1));
|
||||
else if (str_starts_with($title, '\\$'))
|
||||
$title = substr($title, 1);
|
||||
$SkinState->title = $title;
|
||||
}
|
||||
|
||||
function set_skin_opts(array $options) {
|
||||
global $SkinState;
|
||||
$SkinState->options = array_merge($SkinState->options, $options);
|
||||
}
|
||||
|
||||
function add_skin_strings(array $keys): void {
|
||||
global $SkinState;
|
||||
$SkinState->lang = array_merge($SkinState->lang, $keys);
|
||||
}
|
||||
|
||||
function add_skin_strings_re(string $re): void {
|
||||
global $__lang;
|
||||
add_skin_strings($__lang->search($re));
|
||||
}
|
||||
|
||||
function add_static(string ...$files): void {
|
||||
global $SkinState;
|
||||
foreach ($files as $file)
|
||||
$SkinState->static[] = $file;
|
||||
}
|
||||
|
||||
function add_meta(array ...$data) {
|
||||
global $SkinState;
|
||||
$SkinState->meta = array_merge($SkinState->meta, $data);
|
||||
}
|
||||
|
||||
|
||||
class SkinContext {
|
||||
|
||||
protected string $ns;
|
||||
protected array $data = [];
|
||||
|
||||
function __construct(string $namespace) {
|
||||
$this->ns = $namespace;
|
||||
require_once APP_ROOT.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).'.phps';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function __set(string $name, $value) {
|
||||
$this->data[$name] = $value;
|
||||
}
|
||||
|
||||
function if_not($cond, $callback, ...$args) {
|
||||
return $this->_if_condition(!$cond, $callback, ...$args);
|
||||
}
|
||||
|
||||
function if_true($cond, $callback, ...$args) {
|
||||
return $this->_if_condition($cond, $callback, ...$args);
|
||||
}
|
||||
|
||||
function if_admin($callback, ...$args) {
|
||||
return $this->_if_condition(is_admin(), $callback, ...$args);
|
||||
}
|
||||
|
||||
function if_dev($callback, ...$args) {
|
||||
return $this->_if_condition(is_dev(), $callback, ...$args);
|
||||
}
|
||||
|
||||
function if_then_else($cond, $cb1, $cb2) {
|
||||
return $cond ? $this->_return_callback($cb1) : $this->_return_callback($cb2);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function for_each(array $iterable, callable $callback) {
|
||||
$html = '';
|
||||
foreach ($iterable as $k => $v)
|
||||
$html .= call_user_func($callback, $v, $k);
|
||||
return $html;
|
||||
}
|
||||
|
||||
function lang(...$args): string {
|
||||
return htmlescape($this->langRaw(...$args));
|
||||
}
|
||||
|
||||
function langRaw(string $key, ...$args) {
|
||||
$val = lang($key);
|
||||
return empty($args) ? $val : sprintf($val, ...$args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
enum SkinStringModificationType {
|
||||
case RAW;
|
||||
case URL;
|
||||
case HTML;
|
||||
case JSON;
|
||||
case ADDSLASHES;
|
||||
}
|
||||
|
||||
class SkinString implements Stringable {
|
||||
protected SkinStringModificationType $modType;
|
||||
|
||||
function __construct(protected string $string) {}
|
||||
function setModType(SkinStringModificationType $modType) { $this->modType = $modType; }
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
138
engine/strings.php
Normal file
138
engine/strings.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
enum DeclensionCase: string {
|
||||
case GEN = 'Gen';
|
||||
case DAT = 'Dat';
|
||||
case ACC = 'Acc';
|
||||
case INS = 'Ins';
|
||||
case ABL = 'Abl';
|
||||
}
|
||||
|
||||
enum NameSex: int {
|
||||
case MALE = 0;
|
||||
case FEMALE = 1;
|
||||
}
|
||||
|
||||
enum NameType: int {
|
||||
case FIRST_NAME = 0;
|
||||
case LAST_NAME = 1;
|
||||
}
|
||||
|
||||
class StringsBase implements ArrayAccess {
|
||||
protected array $data = [];
|
||||
|
||||
function offsetSet(mixed $offset, mixed $value): void {
|
||||
throw new RuntimeException('Not implemented');
|
||||
}
|
||||
|
||||
function offsetExists(mixed $offset): bool {
|
||||
return isset($this->data[$offset]);
|
||||
}
|
||||
|
||||
function offsetUnset(mixed $offset): void {
|
||||
throw new RuntimeException('Not implemented');
|
||||
}
|
||||
|
||||
function offsetGet(mixed $offset): mixed {
|
||||
if (!isset($this->data[$offset])) {
|
||||
logError(__METHOD__.': '.$offset.' not found');
|
||||
return '{'.$offset.'}';
|
||||
}
|
||||
return $this->data[$offset];
|
||||
}
|
||||
|
||||
function get(string $key, mixed ...$sprintf_args): string|array {
|
||||
$val = $this[$key];
|
||||
if (!empty($sprintf_args)) {
|
||||
array_unshift($sprintf_args, $val);
|
||||
return call_user_func_array('sprintf', $sprintf_args);
|
||||
} else {
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
|
||||
function num(string $key, int $num, array$opts = []) {
|
||||
$s = $this[$key];
|
||||
|
||||
$default_opts = [
|
||||
'format' => true,
|
||||
'format_delim' => ' ',
|
||||
'lang' => 'ru',
|
||||
];
|
||||
$opts = array_merge($default_opts, $opts);
|
||||
|
||||
switch ($opts['lang']) {
|
||||
case 'ru':
|
||||
$n = $num % 100;
|
||||
if ($n > 19)
|
||||
$n %= 10;
|
||||
|
||||
if ($n == 1) {
|
||||
$word = 0;
|
||||
} elseif ($n >= 2 && $n <= 4) {
|
||||
$word = 1;
|
||||
} elseif ($num == 0 && count($s) == 4) {
|
||||
$word = 3;
|
||||
} else {
|
||||
$word = 2;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if ($num == 0 && count($s) == 4) {
|
||||
$word = 3;
|
||||
} else {
|
||||
$word = $num == 1 ? 0 : 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// if zero
|
||||
if ($word == 3) {
|
||||
return $s[3];
|
||||
}
|
||||
|
||||
if (is_callable($opts['format'])) {
|
||||
$num = $opts['format']($num);
|
||||
} else if ($opts['format'] === true) {
|
||||
$num = formatNumber($num, $opts['format_delim']);
|
||||
}
|
||||
|
||||
return sprintf($s[$word], $num);
|
||||
}
|
||||
}
|
||||
|
||||
class Strings extends StringsBase {
|
||||
private static ?Strings $instance = null;
|
||||
protected array $loadedPackages = [];
|
||||
|
||||
private function __construct() {}
|
||||
protected function __clone() {}
|
||||
|
||||
public static function getInstance(): self {
|
||||
if (is_null(self::$instance))
|
||||
self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
function load(string ...$pkgs): array {
|
||||
$keys = [];
|
||||
foreach ($pkgs as $name) {
|
||||
$raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml');
|
||||
$this->data = array_merge($this->data, $raw);
|
||||
$keys = array_merge($keys, array_keys($raw));
|
||||
$this->loadedPackages[$name] = true;
|
||||
}
|
||||
return $keys;
|
||||
}
|
||||
|
||||
function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string {
|
||||
$s = iconv('utf-8', 'cp1251', $s);
|
||||
$s = vkflex($s, $case->value, $sex->value, 0, $type->value);
|
||||
return iconv('cp1251', 'utf-8', $s);
|
||||
}
|
||||
|
||||
function search(string $regexp): array {
|
||||
return preg_grep($regexp, array_keys($this->data));
|
||||
}
|
||||
}
|
@ -1,5 +1,32 @@
|
||||
<?php
|
||||
|
||||
function verify_hostname(?string $host = null): void {
|
||||
global $config;
|
||||
|
||||
if ($host === null) {
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
|
||||
// IE moment
|
||||
if (($pos = strpos($host, ':')) !== false)
|
||||
$host = substr($host, 0, $pos);
|
||||
}
|
||||
|
||||
if (!str_ends_with($host, $config['domain']))
|
||||
throw new RuntimeException('invalid http_host '.$host);
|
||||
|
||||
if (strlen($host) > ($orig_domain_len = strlen($config['domain']))) {
|
||||
$sub = substr($host, 0, -$orig_domain_len-1);
|
||||
if (in_array($sub, $config['dev_domains'])) {
|
||||
$config['is_dev'] = true;
|
||||
} else if (!in_array($sub, $config['subdomains'])) {
|
||||
throw new RuntimeException('invalid subdomain '.$sub);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_cli() && str_ends_with(dirname(__DIR__), 'www-dev'))
|
||||
$config['is_dev'] = true;
|
||||
}
|
||||
|
||||
function htmlescape(string|array $s): string|array {
|
||||
if (is_array($s)) {
|
||||
foreach ($s as $k => $v) {
|
||||
@ -247,59 +274,31 @@ function salt_password(string $pwd): string {
|
||||
return hash('sha256', "{$pwd}|{$config['password_salt']}");
|
||||
}
|
||||
|
||||
function exectime(?string $format = null) {
|
||||
function exectime(?string $format = null): string|float {
|
||||
$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;
|
||||
function formatNumber(int|float $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 $link;
|
||||
return number_format($num, 0, '.', $delim);
|
||||
}
|
||||
|
||||
function lang() {
|
||||
global $__lang;
|
||||
return call_user_func_array([$__lang, 'get'], func_get_args());
|
||||
}
|
||||
|
||||
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); }
|
||||
function is_dev(): bool { global $config; return $config['is_dev']; }
|
||||
function is_cli(): bool { return PHP_SAPI == 'cli'; };
|
||||
function is_retina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; }
|
||||
|
||||
function jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; }
|
||||
function jsonDecode($json) { return json_decode($json, true); }
|
||||
|
470
handler/AdminHandler.php
Normal file
470
handler/AdminHandler.php
Normal file
@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
class AdminHandler extends request_handler {
|
||||
|
||||
function __construct() {
|
||||
parent::__construct();
|
||||
add_static('css/admin.css', 'js/admin.js');
|
||||
}
|
||||
|
||||
function before_dispatch(string $http_method, string $action) {
|
||||
if ($action != 'login' && !is_admin())
|
||||
forbidden();
|
||||
}
|
||||
|
||||
function GET_index() {
|
||||
set_title('$admin_title');
|
||||
render('admin/index');
|
||||
}
|
||||
|
||||
function GET_login() {
|
||||
if (is_admin())
|
||||
redirect('/admin/');
|
||||
set_title('$admin_title');
|
||||
render('admin/login');
|
||||
}
|
||||
|
||||
function POST_login() {
|
||||
csrf_check('adminlogin');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$valid = admin_check_password($password);
|
||||
if ($valid) {
|
||||
admin_log_auth();
|
||||
admin_set_cookie();
|
||||
redirect('/admin/');
|
||||
}
|
||||
forbidden();
|
||||
}
|
||||
|
||||
function GET_logout() {
|
||||
csrf_check('logout');
|
||||
admin_unset_cookie();
|
||||
redirect('/admin/login/', HTTPCode::Found);
|
||||
}
|
||||
|
||||
function GET_uploads() {
|
||||
list($error) = input('error');
|
||||
$uploads = uploads::getAllUploads();
|
||||
|
||||
set_title('$blog_upload');
|
||||
render('admin/uploads',
|
||||
error: $error,
|
||||
uploads: $uploads);
|
||||
}
|
||||
|
||||
function POST_uploads() {
|
||||
csrf_check('addupl');
|
||||
|
||||
list($custom_name, $note) = input('name, note');
|
||||
|
||||
if (!isset($_FILES['files']))
|
||||
redirect('/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'])
|
||||
redirect('/uploads/?error='.urlencode('error code '.$f['error']));
|
||||
|
||||
if (!$f['size'])
|
||||
redirect('/uploads/?error='.urlencode('received empty file'));
|
||||
|
||||
$ext = extension($f['name']);
|
||||
if (!uploads::isExtensionAllowed($ext))
|
||||
redirect('/uploads/?error='.urlencode('extension not allowed'));
|
||||
|
||||
$upload_id = uploads::add(
|
||||
$f['tmp_name'],
|
||||
$custom_name ?: $f['name'],
|
||||
$note);
|
||||
|
||||
if (!$upload_id)
|
||||
redirect('/uploads/?error='.urlencode('failed to create upload'));
|
||||
}
|
||||
|
||||
redirect('/uploads/');
|
||||
}
|
||||
|
||||
function GET_upload_delete() {
|
||||
list($id) = input('i:id');
|
||||
$upload = uploads::get($id);
|
||||
if (!$upload)
|
||||
redirect('/uploads/?error='.urlencode('upload not found'));
|
||||
csrf_check('delupl'.$id);
|
||||
uploads::delete($id);
|
||||
redirect('/uploads/');
|
||||
}
|
||||
|
||||
function POST_upload_edit_note() {
|
||||
list($id, $note) = input('i:id, note');
|
||||
|
||||
$upload = uploads::get($id);
|
||||
if (!$upload)
|
||||
redirect('/uploads/?error='.urlencode('upload not found'));
|
||||
|
||||
csrf_check('editupl'.$id);
|
||||
|
||||
$upload->setNote($note);
|
||||
redirect('/uploads/');
|
||||
}
|
||||
|
||||
function POST_ajax_md_preview() {
|
||||
list($md, $title, $use_image_previews) = 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
|
||||
);
|
||||
ajax_ok(['html' => $html]);
|
||||
}
|
||||
|
||||
function GET_page_add() {
|
||||
list($name) = input('short_name');
|
||||
$page = pages::getByName($name);
|
||||
if ($page)
|
||||
not_found();
|
||||
return $this->_get_pageAdd($name);
|
||||
}
|
||||
|
||||
function POST_page_add() {
|
||||
csrf_check('addpage');
|
||||
|
||||
list($name, $text, $title) = input('short_name, text, title');
|
||||
$page = pages::getByName($name);
|
||||
if ($page)
|
||||
not_found();
|
||||
|
||||
$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::getByName($name);
|
||||
redirect($page->getUrl());
|
||||
}
|
||||
|
||||
function GET_post_add() {
|
||||
return $this->_get_postAdd();
|
||||
}
|
||||
|
||||
function POST_post_add() {
|
||||
csrf_check('addpost');
|
||||
|
||||
list($text, $title, $tags, $visible, $short_name)
|
||||
= input('text, title, tags, b:visible, short_name');
|
||||
$tags = tags::splitString($tags);
|
||||
|
||||
$error_code = null;
|
||||
if (!$title) {
|
||||
$error_code = 'no_title';
|
||||
} else if (!$text) {
|
||||
$error_code = 'no_text';
|
||||
} else if (empty($tags)) {
|
||||
$error_code = 'no_tags';
|
||||
} else if (empty($short_name)) {
|
||||
$error_code = 'no_short_name';
|
||||
}
|
||||
|
||||
if ($error_code)
|
||||
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 = array_values(tags::getTags($tags));
|
||||
$post->setTagIds($tag_ids);
|
||||
|
||||
redirect($post->getUrl());
|
||||
}
|
||||
|
||||
function GET_auto_delete() {
|
||||
list($name) = input('short_name');
|
||||
|
||||
$post = posts::getByName($name);
|
||||
if ($post) {
|
||||
csrf_check('delpost'.$post->id);
|
||||
posts::delete($post);
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
$page = pages::getByName($name);
|
||||
if ($page) {
|
||||
csrf_check('delpage'.$page->shortName);
|
||||
pages::delete($page);
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
not_found();
|
||||
}
|
||||
|
||||
function GET_auto_edit() {
|
||||
list($short_name, $saved) = input('short_name, b:saved');
|
||||
|
||||
$post = posts::getByName($short_name);
|
||||
if ($post) {
|
||||
$tags = $post->getTags();
|
||||
return $this->_get_postEdit($post,
|
||||
title: $post->title,
|
||||
text: $post->md,
|
||||
tags: $post->getTags(),
|
||||
visible: $post->visible,
|
||||
toc: $post->toc,
|
||||
short_name: $post->shortName,
|
||||
saved: $saved,
|
||||
);
|
||||
}
|
||||
|
||||
$page = pages::getByName($short_name);
|
||||
if ($page) {
|
||||
return $this->_get_pageEdit($page,
|
||||
title: $page->title,
|
||||
text: $page->md,
|
||||
saved: $saved,
|
||||
visible: $page->visible,
|
||||
);
|
||||
}
|
||||
|
||||
not_found();
|
||||
}
|
||||
|
||||
function POST_auto_edit() {
|
||||
list($short_name) = input('short_name');
|
||||
|
||||
$post = posts::getByName($short_name);
|
||||
if ($post) {
|
||||
csrf_check('editpost'.$post->id);
|
||||
|
||||
list($text, $title, $tags, $visible, $toc, $short_name)
|
||||
= input('text, title, tags, b:visible, b:toc, new_short_name');
|
||||
|
||||
$tags = tags::splitString($tags);
|
||||
$error_code = null;
|
||||
|
||||
if (!$title) {
|
||||
$error_code = 'no_title';
|
||||
} else if (!$text) {
|
||||
$error_code = 'no_text';
|
||||
} else if (empty($tags)) {
|
||||
$error_code = 'no_tags';
|
||||
} else if (empty($short_name)) {
|
||||
$error_code = 'no_short_name';
|
||||
}
|
||||
|
||||
if ($error_code)
|
||||
$this->_get_postEdit($post,
|
||||
title: $title,
|
||||
text: $text,
|
||||
tags: $tags,
|
||||
visible: $visible,
|
||||
toc: $toc,
|
||||
short_name: $short_name,
|
||||
error_code: $error_code
|
||||
);
|
||||
|
||||
$post->edit([
|
||||
'title' => $title,
|
||||
'md' => $text,
|
||||
'visible' => (int)$visible,
|
||||
'toc' => (int)$toc,
|
||||
'short_name' => $short_name
|
||||
]);
|
||||
$tag_ids = array_values(tags::getTags($tags));
|
||||
$post->setTagIds($tag_ids);
|
||||
|
||||
redirect($post->getUrl().'edit/?saved=1');
|
||||
}
|
||||
|
||||
$page = pages::getByName($short_name);
|
||||
if ($page) {
|
||||
csrf_check('editpage'.$page->shortName);
|
||||
|
||||
list($text, $title, $visible, $short_name)
|
||||
= 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,
|
||||
]);
|
||||
|
||||
redirect($page->getUrl().'edit/?saved=1');
|
||||
}
|
||||
|
||||
not_found();
|
||||
}
|
||||
|
||||
protected static function setWidePage() {
|
||||
set_skin_opts([
|
||||
'full_width' => true,
|
||||
'no_footer' => true
|
||||
]);
|
||||
}
|
||||
|
||||
protected function _get_pageAdd(
|
||||
string $name,
|
||||
string $title = '',
|
||||
string $text = '',
|
||||
?string $error_code = null
|
||||
) {
|
||||
add_skin_strings_re('/^(err_)?pages_/');
|
||||
set_title(lang('pages_create_title', $name));
|
||||
static::setWidePage();
|
||||
render('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
|
||||
) {
|
||||
add_skin_strings_re('/^(err_)?pages_/');
|
||||
set_title(lang('pages_page_edit_title', $page->shortName.'.html'));
|
||||
static::setWidePage();
|
||||
render('admin/pageForm',
|
||||
is_edit: true,
|
||||
short_name: $page->shortName,
|
||||
title: $title,
|
||||
text: $text,
|
||||
visible: $visible,
|
||||
saved: $saved,
|
||||
error_code: $error_code);
|
||||
}
|
||||
|
||||
protected function _get_postEdit(
|
||||
Post $post,
|
||||
string $title = '',
|
||||
string $text = '',
|
||||
?array $tags = null,
|
||||
bool $visible = false,
|
||||
bool $toc = false,
|
||||
string $short_name = '',
|
||||
?string $error_code = null,
|
||||
bool $saved = false,
|
||||
) {
|
||||
add_skin_strings_re('/^(err_)?blog_/');
|
||||
set_title(lang('blog_post_edit_title', $post->title));
|
||||
static::setWidePage();
|
||||
render('admin/postForm',
|
||||
is_edit: true,
|
||||
post_id: $post->id,
|
||||
post_url: $post->getUrl(),
|
||||
title: $title,
|
||||
text: $text,
|
||||
tags: $tags ? implode(', ', $tags) : '',
|
||||
visible: $visible,
|
||||
toc: $toc,
|
||||
saved: $saved,
|
||||
short_name: $short_name,
|
||||
error_code: $error_code
|
||||
);
|
||||
}
|
||||
|
||||
protected function _get_postAdd(
|
||||
string $title = '',
|
||||
string $text = '',
|
||||
?array $tags = null,
|
||||
string $short_name = '',
|
||||
?string $error_code = null
|
||||
) {
|
||||
add_skin_strings_re('/^(err_)?blog_/');
|
||||
set_title('$blog_write');
|
||||
static::setWidePage();
|
||||
render('admin/postForm',
|
||||
title: $title,
|
||||
text: $text,
|
||||
tags: $tags ? implode(', ', $tags) : '',
|
||||
short_name: $short_name,
|
||||
error_code: $error_code);
|
||||
}
|
||||
|
||||
}
|
138
handler/MainHandler.php
Normal file
138
handler/MainHandler.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
class MainHandler extends request_handler {
|
||||
|
||||
function GET_index() {
|
||||
set_title('$site_title');
|
||||
render('main/index');
|
||||
}
|
||||
|
||||
function GET_about() { redirect('/info/'); }
|
||||
function GET_contacts() { redirect('/info/'); }
|
||||
|
||||
function GET_auto() {
|
||||
list($name) = input('name');
|
||||
|
||||
if (is_admin()) {
|
||||
if (is_numeric($name)) {
|
||||
$post = posts::get((int)$name);
|
||||
} else {
|
||||
$post = posts::getByName($name);
|
||||
}
|
||||
if ($post)
|
||||
return $this->renderPost($post);
|
||||
|
||||
$tag = tags::get($name);
|
||||
if ($tag)
|
||||
return $this->renderTag($tag);
|
||||
}
|
||||
|
||||
$page = pages::getByName($name);
|
||||
if ($page)
|
||||
return $this->renderPage($page);
|
||||
|
||||
if (is_admin()) {
|
||||
set_title($name);
|
||||
render('admin/pageNew',
|
||||
short_name: $name);
|
||||
}
|
||||
|
||||
not_found();
|
||||
}
|
||||
|
||||
protected function renderPost(Post $post) {
|
||||
global $config;
|
||||
|
||||
if (!$post->visible && !is_admin())
|
||||
not_found();
|
||||
|
||||
$tags = $post->getTags();
|
||||
|
||||
add_meta(
|
||||
['property' => 'og:title', 'content' => $post->title],
|
||||
['property' => 'og:url', 'content' => $config['domain'].$post->getUrl()]
|
||||
);
|
||||
if (($img = $post->getFirstImage()) !== null)
|
||||
add_meta(['property' => 'og:image', 'content' => $img->getDirectUrl()]);
|
||||
|
||||
add_meta([
|
||||
'name' => 'description',
|
||||
'property' => 'og:description',
|
||||
'content' => $post->getDescriptionPreview(155)
|
||||
]);
|
||||
|
||||
set_title($post->title);
|
||||
|
||||
if ($post->toc)
|
||||
set_skin_opts(['wide' => true]);
|
||||
|
||||
render('main/post',
|
||||
title: $post->title,
|
||||
id: $post->id,
|
||||
unsafe_html: $post->getHtml(is_retina(), getUserTheme()),
|
||||
unsafe_toc_html: $post->getToc(),
|
||||
date: $post->getFullDate(),
|
||||
tags: $tags,
|
||||
visible: $post->visible,
|
||||
url: $post->getUrl(),
|
||||
email: $config['admin_email'],
|
||||
urlencoded_reply_subject: 'Re: '.$post->title);
|
||||
}
|
||||
|
||||
protected function renderTag(Tag $tag) {
|
||||
$tag = tags::get($tag);
|
||||
if (!is_admin() && !$tag->visiblePostsCount)
|
||||
not_found();
|
||||
|
||||
$count = posts::getCountByTagId($tag->id, is_admin());
|
||||
$posts = $count ? posts::getPostsByTagId($tag->id, is_admin()) : [];
|
||||
|
||||
set_title('#'.$tag->tag);
|
||||
render('main/tag',
|
||||
count: $count,
|
||||
posts: $posts,
|
||||
tag: $tag->tag);
|
||||
}
|
||||
|
||||
protected function renderPage(Page $page) {
|
||||
global $config;
|
||||
|
||||
if (!is_admin() && !$page->visible && $page->get_id() != $config['index_page_id'])
|
||||
not_found();
|
||||
|
||||
set_title($page ? $page->title : '???');
|
||||
render('main/page',
|
||||
unsafe_html: $page->getHtml(is_retina(), getUserTheme()),
|
||||
page_url: $page->getUrl(),
|
||||
short_name: $page->shortName);
|
||||
}
|
||||
|
||||
function GET_rss() {
|
||||
global $config;
|
||||
|
||||
$items = array_map(fn(Post $post) => [
|
||||
'title' => $post->title,
|
||||
'link' => $post->getUrl(),
|
||||
'pub_date' => date(DATE_RSS, $post->ts),
|
||||
'description' => $post->getDescriptionPreview(500),
|
||||
], posts::getList(0, 20));
|
||||
|
||||
$ctx = new SkinContext('\\skin\\rss');
|
||||
$body = $ctx->atom(
|
||||
title: lang('site_title'),
|
||||
link: 'https://'.$config['domain'],
|
||||
rss_link: 'https://'.$config['domain'].'/feed.rss',
|
||||
items: $items);
|
||||
|
||||
header('Content-Type: application/rss+xml; charset=utf-8');
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
function GET_articles() {
|
||||
$posts = posts::getList(0, 1000);
|
||||
set_title('$articles');
|
||||
render('main/articles', posts: $posts);
|
||||
}
|
||||
|
||||
}
|
BIN
htdocs/img/cover.jpg
Normal file
BIN
htdocs/img/cover.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 146 KiB |
@ -2,25 +2,5 @@
|
||||
|
||||
require_once __DIR__.'/../init.php';
|
||||
|
||||
$r = (new Router())
|
||||
// route handler input
|
||||
// ----- ------- -----
|
||||
->add('/', 'index')
|
||||
->add('([a-zA-Z0-9\-]+)/', 'auto name=$(1)')
|
||||
->add('feed.rss', 'RSS')
|
||||
->add('about/', 'about')
|
||||
//->add('articles/', 'articles')
|
||||
->add('articles/write/', 'admin/post_add')
|
||||
|
||||
// admin
|
||||
->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('admin/markdown-preview.ajax', 'admin/markdown_preview')
|
||||
|
||||
->add('admin/uploads/', 'admin/uploads')
|
||||
->add('admin/uploads/{edit_note,delete}/(\d+)/', 'admin/upload_${1} id=$(1)')
|
||||
;
|
||||
|
||||
(new RequestDispatcher($r))->dispatch();
|
||||
router_init();
|
||||
dispatch_request();
|
@ -5,7 +5,7 @@ global $config;
|
||||
|
||||
$name = $_REQUEST['name'] ?? '';
|
||||
|
||||
if (!$config['is_dev'] || !$name || !is_dir($path = ROOT.'/htdocs/js/'.$name)) {
|
||||
if (!is_dev() || !$name || !is_dir($path = APP_ROOT.'/htdocs/js/'.$name)) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
@ -35,11 +35,13 @@ var AdminWriteForm = {
|
||||
if (this.previewRequest !== null) {
|
||||
this.previewRequest.abort();
|
||||
}
|
||||
this.previewRequest = ajax.post('/admin/markdown-preview.ajax', {
|
||||
title: this.form.elements.title.value,
|
||||
var params = {
|
||||
md: this.form.elements.text.value,
|
||||
use_image_previews: this.opts.pages ? 1 : 0
|
||||
}, function(err, response) {
|
||||
};
|
||||
if (!this.opts.pages)
|
||||
params.title = this.form.elements.title.value
|
||||
this.previewRequest = ajax.post('/admin/markdown-preview.ajax', params, function(err, response) {
|
||||
if (err)
|
||||
return console.error(err);
|
||||
ge('preview_html').innerHTML = response.html;
|
||||
|
@ -52,7 +52,7 @@
|
||||
if (isObject(data)) {
|
||||
for (var k in data) {
|
||||
if (data.hasOwnProperty(k)) {
|
||||
url += (url.indexOf('?') == -1 ? '?' : '&')+encodeURIComponent(k)+'='+encodeURIComponent(data[k])
|
||||
url += (url.indexOf('?') === -1 ? '?' : '&')+encodeURIComponent(k)+'='+encodeURIComponent(data[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,12 +77,12 @@
|
||||
xhr.open(method, url);
|
||||
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
if (method == 'POST') {
|
||||
if (method === 'POST') {
|
||||
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
|
||||
}
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState == 4) {
|
||||
if (xhr.readyState === 4) {
|
||||
if ('status' in xhr && !/^2|1223/.test(xhr.status)) {
|
||||
throw new Error('http code '+xhr.status)
|
||||
}
|
||||
@ -105,7 +105,7 @@
|
||||
callback(e);
|
||||
};
|
||||
|
||||
xhr.send(method == 'GET' ? null : data);
|
||||
xhr.send(method === 'GET' ? null : data);
|
||||
|
||||
return xhr;
|
||||
}
|
||||
|
14
htdocs/js/common/40-index-page.js
Normal file
14
htdocs/js/common/40-index-page.js
Normal file
@ -0,0 +1,14 @@
|
||||
var IndexPage = {
|
||||
offsets: {
|
||||
en: 0,
|
||||
ru: 300
|
||||
},
|
||||
setCoverLang: function(lang) {
|
||||
var offset = this.offsets[lang];
|
||||
var el = ge('index-book-image');
|
||||
el.style.backgroundPosition = '-' + offset + 'px 0';
|
||||
|
||||
var url = el.getAttribute('data-link-template').replace('{lang}', lang);
|
||||
el.setAttribute('href', url)
|
||||
}
|
||||
};
|
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../init.php';
|
||||
global $config;
|
||||
|
||||
$name = $_REQUEST['name'] ?? '';
|
||||
$theme = $_REQUEST['theme'] ?? '';
|
||||
@ -11,7 +10,7 @@ if ($theme != 'light' && $theme != 'dark') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$config['is_dev'] || !$name || !file_exists($path = ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) {
|
||||
if (!is_dev() || !$name || !file_exists($path = APP_ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) {
|
||||
// logError(__FILE__.': access denied');
|
||||
http_response_code(403);
|
||||
exit;
|
||||
@ -26,7 +25,7 @@ $descriptorspec = [
|
||||
2 => ['pipe', 'w'], // stderr
|
||||
];
|
||||
|
||||
$process = proc_open($cmd, $descriptorspec, $pipes, ROOT);
|
||||
$process = proc_open($cmd, $descriptorspec, $pipes, APP_ROOT);
|
||||
if (!is_resource($process)) {
|
||||
http_response_code(500);
|
||||
logError('could not open sassc process');
|
||||
|
@ -279,3 +279,75 @@ table.contacts div.note {
|
||||
.language-ascii {
|
||||
line-height: 125% !important;
|
||||
}
|
||||
|
||||
|
||||
.index-book {
|
||||
//margin-top: -10px;
|
||||
//padding-top: 5px;
|
||||
&-image {
|
||||
float: left;
|
||||
width: 300px;
|
||||
height: 463px;
|
||||
background: url(/img/cover.jpg?1) no-repeat 0 0;
|
||||
background-size: 600px 463px;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
&-updates {
|
||||
float: right;
|
||||
text-align: right;
|
||||
line-height: 150%;
|
||||
&-link-wrap {
|
||||
border-top: 1px $border-color solid;
|
||||
margin-top: 13px;
|
||||
padding-top: 10px;
|
||||
display: inline-block;
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.index-dl-line {
|
||||
margin-left: 315px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
background: $hover-hl;
|
||||
padding: 30px 20px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
|
||||
&:last-child { margin-bottom: 0 }
|
||||
|
||||
&:hover {
|
||||
background-color: $hover-hl-darker;
|
||||
}
|
||||
|
||||
> b {
|
||||
color: $link-color;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&-info {
|
||||
color: $grey;
|
||||
padding-top: 3px;
|
||||
> span.bullet {
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
a.index-dl-line {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
> b:after {
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: $link-color-underline;
|
||||
}
|
||||
}
|
||||
}
|
9
htdocs/scss/app/foot.scss
Normal file
9
htdocs/scss/app/foot.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.footer {
|
||||
border-top: 1px $border-color solid;
|
||||
//border-radius: 5px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
color: $dark_grey;
|
||||
> span { color: $fg; }
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-bottom: 2px $border-color solid;
|
||||
//border-bottom: 1px $border-color solid;
|
||||
}
|
||||
.head-inner {
|
||||
display: table-row;
|
||||
@ -73,7 +73,6 @@ a.head-item {
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
&.is-download {display:none;}
|
||||
|
||||
&:hover {
|
||||
border-radius: 4px;
|
||||
|
@ -36,9 +36,6 @@ a.head-item:active {
|
||||
a.head-item:last-child > span {
|
||||
border-right: 0;
|
||||
}
|
||||
a.head-item.is-download {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// blog
|
||||
.blog-tags {
|
||||
@ -49,4 +46,27 @@ a.head-item.is-download {
|
||||
}
|
||||
.blog-post-text code {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.index-book-image {
|
||||
float: none;
|
||||
width: 100%;
|
||||
padding-bottom: 129.58%;
|
||||
//max-width: 300px;
|
||||
margin: 0 auto 15px;
|
||||
background-size: 200%;
|
||||
height: auto;
|
||||
background-position: 0 0 !important;
|
||||
}
|
||||
.index-book-updates {
|
||||
float: none;
|
||||
text-align: center;
|
||||
}
|
||||
.index-dl-line {
|
||||
margin-left: 0;
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
@import "./app/common";
|
||||
@import "./app/head";
|
||||
@import "./app/foot";
|
||||
@import "./app/blog";
|
||||
@import "./app/form";
|
||||
@import "./app/pages";
|
||||
|
@ -1,8 +1,10 @@
|
||||
$head_green_color: #0bad19;
|
||||
$head_red_color: #e23636;
|
||||
$link-color: #71abe5;
|
||||
$link-color-underline: #69849d;
|
||||
|
||||
$hover-hl: rgba(255, 255, 255, 0.09);
|
||||
$hover-hl-darker: rgba(255, 255, 255, 0.12);
|
||||
$grey: #798086;
|
||||
$dark-grey: $grey;
|
||||
$light-grey: $grey;
|
||||
|
@ -1,8 +1,10 @@
|
||||
$head_green_color: #0bad19;
|
||||
$head_red_color: #ce1a1a;
|
||||
$link-color: #116fd4;
|
||||
$link-color-underline: #95b5da;
|
||||
|
||||
$hover-hl: #f0f0f0;
|
||||
$hover-hl-darker: #ebebeb;
|
||||
$grey: #888;
|
||||
$dark-grey: #777;
|
||||
$light-grey: #999;
|
||||
|
@ -6,7 +6,7 @@ $ffMono: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, monospace;
|
||||
$base-width: 800px;
|
||||
$wide_width: 1240px;
|
||||
$side-padding: 20px;
|
||||
$base-padding: 18px;
|
||||
$base-padding: 14px;
|
||||
$footer-height: 64px;
|
||||
|
||||
@mixin radius($radius) {
|
||||
|
87
init.php
87
init.php
@ -2,53 +2,100 @@
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('assert.exception', 1);
|
||||
date_default_timezone_set('Europe/Moscow');
|
||||
|
||||
mb_internal_encoding('UTF-8');
|
||||
mb_regex_encoding('UTF-8');
|
||||
|
||||
define('ROOT', __DIR__);
|
||||
const APP_ROOT = __DIR__;
|
||||
define('START_TIME', microtime(true));
|
||||
|
||||
set_include_path(get_include_path().PATH_SEPARATOR.ROOT);
|
||||
set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT);
|
||||
|
||||
spl_autoload_register(function($class) {
|
||||
$path = ROOT.'/classes/'.str_replace('\\', '/', $class).'.php';
|
||||
static $libs = [
|
||||
'lib/tags' => ['Tag', 'tags'],
|
||||
'lib/pages' => ['Page', 'pages'],
|
||||
'lib/posts' => ['Post', 'posts'],
|
||||
'lib/uploads' => ['Upload', 'uploads'],
|
||||
'engine/model' => ['model'],
|
||||
'engine/skin' => ['SkinContext'],
|
||||
];
|
||||
|
||||
if (str_ends_with($class, 'Handler')) {
|
||||
$path = APP_ROOT.'/handler/'.str_replace('\\', '/', $class).'.php';
|
||||
} else {
|
||||
foreach ($libs as $lib_file => $class_names) {
|
||||
if (in_array($class, $class_names)) {
|
||||
$path = APP_ROOT.'/'.$lib_file.'.php';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($path))
|
||||
$path = APP_ROOT.'/lib/'.$class.'.php';
|
||||
|
||||
if (!is_file($path))
|
||||
return;
|
||||
|
||||
require_once $path;
|
||||
});
|
||||
|
||||
$config = require_once 'config.php';
|
||||
if (file_exists(ROOT.'/config-local.php')) {
|
||||
$config = array_replace($config, require 'config-local.php');
|
||||
}
|
||||
if (!file_exists(APP_ROOT.'/config.yaml'))
|
||||
die('Fatal: config.yaml not found');
|
||||
|
||||
// turn off errors output on production domains
|
||||
$config = yaml_parse_file(APP_ROOT.'/config.yaml');
|
||||
if ($config === false)
|
||||
die('Fatal: failed to parse config.yaml');
|
||||
|
||||
// i know what i'm doing. do you?
|
||||
umask($config['umask']);
|
||||
|
||||
require_once 'functions.php';
|
||||
require_once 'engine/mysql.php';
|
||||
require_once 'engine/router.php';
|
||||
require_once 'engine/request.php';
|
||||
require_once 'engine/logging.php';
|
||||
|
||||
if (PHP_SAPI == 'cli') {
|
||||
$_SERVER['HTTP_HOST'] = $config['domain'];
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
} else {
|
||||
if (array_key_exists('HTTP_X_REAL_IP', $_SERVER))
|
||||
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];
|
||||
try {
|
||||
if (is_cli()) {
|
||||
verify_hostname($config['domain']);
|
||||
$_SERVER['HTTP_HOST'] = $config['domain'];
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
} else {
|
||||
verify_hostname();
|
||||
if (array_key_exists('HTTP_X_REAL_IP', $_SERVER))
|
||||
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];
|
||||
|
||||
require_once 'engine/strings.php';
|
||||
require_once 'engine/skin.php';
|
||||
require_once 'lib/admin.php';
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
die('Fatal error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
if (!$config['is_dev']) {
|
||||
if (file_exists(ROOT.'/config-static.php'))
|
||||
$__logger = is_dev()
|
||||
? new FileLogger(APP_ROOT.'/log/debug.log')
|
||||
: new DatabaseLogger();
|
||||
$__logger->enable();
|
||||
|
||||
if (!is_dev()) {
|
||||
if (file_exists(APP_ROOT.'/config-static.php'))
|
||||
$config['static'] = require_once 'config-static.php';
|
||||
else
|
||||
die('confic-static.php not found');
|
||||
}
|
||||
|
||||
if (!$config['is_dev']) {
|
||||
// turn off errors output on production domains
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', 0);
|
||||
}
|
||||
|
||||
logging::setLogFile($config['log_file']);
|
||||
logging::enable();
|
||||
if (!is_cli()) {
|
||||
$__lang = Strings::getInstance();
|
||||
$__lang->load('main');
|
||||
}
|
||||
|
||||
require 'vendor/autoload.php';
|
||||
|
101
lang/en.php
101
lang/en.php
@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// common
|
||||
'4in1' => '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',
|
||||
'articles' => 'Articles',
|
||||
'contacts' => 'contacts',
|
||||
'email' => 'email',
|
||||
'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_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',
|
||||
];
|
51
lib/admin.php
Normal file
51
lib/admin.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
require_once 'lib/stored_config.php';
|
||||
|
||||
const ADMIN_SESSION_TIMEOUT = 86400 * 14;
|
||||
const ADMIN_COOKIE_NAME = 'admin_key';
|
||||
|
||||
function is_admin(): bool {
|
||||
static $is_admin = null;
|
||||
if (is_null($is_admin))
|
||||
$is_admin = _admin_verify_key();
|
||||
return $is_admin;
|
||||
}
|
||||
|
||||
function _admin_verify_key(): bool {
|
||||
if (isset($_COOKIE[ADMIN_COOKIE_NAME])) {
|
||||
$cookie = (string)$_COOKIE[ADMIN_COOKIE_NAME];
|
||||
if ($cookie !== _admin_get_key())
|
||||
admin_unset_cookie();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function admin_check_password(string $pwd): bool {
|
||||
return salt_password($pwd) === scGet('admin_pwd');
|
||||
}
|
||||
|
||||
function _admin_get_key(): string {
|
||||
$admin_pwd_hash = scGet('admin_pwd');
|
||||
return salt_password("$admin_pwd_hash|{$_SERVER['REMOTE_ADDR']}");
|
||||
}
|
||||
|
||||
function admin_set_cookie(): void {
|
||||
global $config;
|
||||
$key = _admin_get_key();
|
||||
setcookie(ADMIN_COOKIE_NAME, $key, time() + ADMIN_SESSION_TIMEOUT, '/', $config['cookie_host']);
|
||||
}
|
||||
|
||||
function admin_unset_cookie(): void {
|
||||
global $config;
|
||||
setcookie(ADMIN_COOKIE_NAME, '', 1, '/', $config['cookie_host']);
|
||||
}
|
||||
|
||||
function admin_log_auth(): void {
|
||||
DB()->insert('admin_log', [
|
||||
'ts' => time(),
|
||||
'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
||||
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
]);
|
||||
}
|
32
lib/ansi.php
Normal file
32
lib/ansi.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
enum AnsiColor: int {
|
||||
case BLACK = 0;
|
||||
case RED = 1;
|
||||
case GREEN = 2;
|
||||
case YELLOW = 3;
|
||||
case BLUE = 4;
|
||||
case MAGENTA = 5;
|
||||
case CYAN = 6;
|
||||
case WHITE = 7;
|
||||
}
|
||||
|
||||
function ansi(string $text,
|
||||
?AnsiColor $fg = null,
|
||||
?AnsiColor $bg = null,
|
||||
bool $bold = false,
|
||||
bool $fg_bright = false,
|
||||
bool $bg_bright = false): string {
|
||||
$codes = [];
|
||||
if (!is_null($fg))
|
||||
$codes[] = $fg->value + ($fg_bright ? 90 : 30);
|
||||
if (!is_null($bg))
|
||||
$codes[] = $bg->value + ($bg_bright ? 100 : 40);
|
||||
if ($bold)
|
||||
$codes[] = 1;
|
||||
|
||||
if (empty($codes))
|
||||
return $text;
|
||||
|
||||
return "\033[".implode(';', $codes)."m".$text."\033[0m";
|
||||
}
|
@ -4,7 +4,7 @@ class cli {
|
||||
|
||||
protected ?array $commandsCache = null;
|
||||
|
||||
public function __construct(
|
||||
function __construct(
|
||||
protected string $ns
|
||||
) {}
|
||||
|
||||
@ -21,7 +21,7 @@ class cli {
|
||||
exit(is_null($error) ? 0 : 1);
|
||||
}
|
||||
|
||||
public function getCommands(): array {
|
||||
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);
|
||||
@ -30,10 +30,10 @@ class cli {
|
||||
return $this->commandsCache;
|
||||
}
|
||||
|
||||
public function run(): void {
|
||||
function run(): void {
|
||||
global $argv, $argc;
|
||||
|
||||
if (PHP_SAPI != 'cli')
|
||||
if (!is_cli())
|
||||
cli::die('SAPI != cli');
|
||||
|
||||
if ($argc < 2)
|
@ -2,8 +2,10 @@
|
||||
|
||||
class MyParsedown extends ParsedownExtended {
|
||||
|
||||
public function __construct(
|
||||
?array $opts = null,
|
||||
protected array $options;
|
||||
|
||||
function __construct(
|
||||
?array $opts = null,
|
||||
protected bool $useImagePreviews = false
|
||||
) {
|
||||
$parsedown_opts = [
|
||||
@ -25,7 +27,7 @@ class MyParsedown extends ParsedownExtended {
|
||||
protected function inlineFileAttach($excerpt) {
|
||||
if (preg_match('/^{fileAttach:([\w]{8})}{\/fileAttach}/', $excerpt['text'], $matches)) {
|
||||
$random_id = $matches[1];
|
||||
$upload = uploads::getByRandomId($random_id);
|
||||
$upload = uploads::getUploadByRandomId($random_id);
|
||||
$result = [
|
||||
'extent' => strlen($matches[0]),
|
||||
'element' => [
|
||||
@ -73,7 +75,7 @@ class MyParsedown extends ParsedownExtended {
|
||||
}
|
||||
}
|
||||
|
||||
$image = uploads::getByRandomId($random_id);
|
||||
$image = uploads::getUploadByRandomId($random_id);
|
||||
$result = [
|
||||
'extent' => strlen($matches[0]),
|
||||
'element' => [
|
||||
@ -141,7 +143,7 @@ class MyParsedown extends ParsedownExtended {
|
||||
}
|
||||
}
|
||||
|
||||
$video = uploads::getByRandomId($random_id);
|
||||
$video = uploads::getUploadByRandomId($random_id);
|
||||
$result = [
|
||||
'extent' => strlen($matches[0]),
|
||||
'element' => [
|
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once 'lib/ext/MyParsedown.php';
|
||||
|
||||
class markup {
|
||||
|
||||
public static function markdownToHtml(string $md, bool $use_image_previews = true): string {
|
||||
@ -33,7 +35,7 @@ class markup {
|
||||
global $config;
|
||||
$is_dark_theme = $user_theme === 'dark';
|
||||
return preg_replace_callback(
|
||||
'/(uploads\/\w{8}\/)([ap])(\d+)x(\d+)(\.jpg)/',
|
||||
'/('.preg_quote($config['uploads_path'], '/').'\/\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';
|
74
lib/pages.php
Normal file
74
lib/pages.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
class Page extends model {
|
||||
|
||||
const DB_TABLE = 'pages';
|
||||
const DB_KEY = 'short_name';
|
||||
|
||||
public string $title;
|
||||
public string $md;
|
||||
public string $html;
|
||||
public int $ts;
|
||||
public int $updateTs;
|
||||
public bool $visible;
|
||||
public string $shortName;
|
||||
|
||||
function edit(array $fields) {
|
||||
$fields['update_ts'] = time();
|
||||
if ($fields['md'] != $this->md)
|
||||
$fields['html'] = markup::markdownToHtml($fields['md']);
|
||||
parent::edit($fields);
|
||||
}
|
||||
|
||||
function isUpdated(): bool {
|
||||
return $this->updateTs && $this->updateTs != $this->ts;
|
||||
}
|
||||
|
||||
function getHtml(bool $is_retina, string $user_theme): string {
|
||||
$html = $this->html;
|
||||
$html = markup::htmlImagesFix($html, $is_retina, $user_theme);
|
||||
return $html;
|
||||
}
|
||||
|
||||
function getUrl(): string {
|
||||
return "/{$this->shortName}/";
|
||||
}
|
||||
|
||||
function updateHtml(): void {
|
||||
$html = markup::markdownToHtml($this->md);
|
||||
$this->html = $html;
|
||||
DB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class pages {
|
||||
|
||||
static function add(array $data): ?int {
|
||||
$db = DB();
|
||||
$data['ts'] = time();
|
||||
$data['html'] = markup::markdownToHtml($data['md']);
|
||||
if (!$db->insert('pages', $data))
|
||||
return null;
|
||||
return $db->insertId();
|
||||
}
|
||||
|
||||
static function delete(Page $page): void {
|
||||
DB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName);
|
||||
}
|
||||
|
||||
static function getByName(string $short_name): ?Page {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name);
|
||||
return $db->numRows($q) ? new Page($db->fetch($q)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Page[]
|
||||
*/
|
||||
static function getAll(): array {
|
||||
$db = DB();
|
||||
return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages")));
|
||||
}
|
||||
|
||||
}
|
321
lib/posts.php
Normal file
321
lib/posts.php
Normal file
@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
class Post extends model {
|
||||
|
||||
const DB_TABLE = 'posts';
|
||||
|
||||
public int $id;
|
||||
public string $title;
|
||||
public string $md;
|
||||
public string $html;
|
||||
public string $tocHtml;
|
||||
public string $text;
|
||||
public int $ts;
|
||||
public int $updateTs;
|
||||
public bool $visible;
|
||||
public bool $toc;
|
||||
public string $shortName;
|
||||
|
||||
function edit(array $fields) {
|
||||
$cur_ts = time();
|
||||
if (!$this->visible && $fields['visible'])
|
||||
$fields['ts'] = $cur_ts;
|
||||
|
||||
$fields['update_ts'] = $cur_ts;
|
||||
|
||||
if ($fields['md'] != $this->md) {
|
||||
$fields['html'] = markup::markdownToHtml($fields['md']);
|
||||
$fields['text'] = markup::htmlToText($fields['html']);
|
||||
}
|
||||
|
||||
if ((isset($fields['toc']) && $fields['toc']) || $this->toc) {
|
||||
$fields['toc_html'] = markup::toc($fields['md']);
|
||||
}
|
||||
|
||||
parent::edit($fields);
|
||||
$this->updateImagePreviews();
|
||||
}
|
||||
|
||||
function updateHtml() {
|
||||
$html = markup::markdownToHtml($this->md);
|
||||
$this->html = $html;
|
||||
|
||||
DB()->query("UPDATE posts SET html=? WHERE id=?", $html, $this->id);
|
||||
}
|
||||
|
||||
function updateText() {
|
||||
$html = markup::markdownToHtml($this->md);
|
||||
$text = markup::htmlToText($html);
|
||||
$this->text = $text;
|
||||
|
||||
DB()->query("UPDATE posts SET text=? WHERE id=?", $text, $this->id);
|
||||
}
|
||||
|
||||
function getDescriptionPreview(int $len): string {
|
||||
if (mb_strlen($this->text) >= $len)
|
||||
return mb_substr($this->text, 0, $len-3).'...';
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
function getFirstImage(): ?Upload {
|
||||
if (!preg_match('/\{image:([\w]{8})/', $this->md, $match))
|
||||
return null;
|
||||
return uploads::getUploadByRandomId($match[1]);
|
||||
}
|
||||
|
||||
function getUrl(): string {
|
||||
return $this->shortName != '' ? "/{$this->shortName}/" : "/{$this->id}/";
|
||||
}
|
||||
|
||||
function getDate(): string {
|
||||
return date('j M', $this->ts);
|
||||
}
|
||||
|
||||
function getYear(): int {
|
||||
return (int)date('Y', $this->ts);
|
||||
}
|
||||
|
||||
function getFullDate(): string {
|
||||
return date('j F Y', $this->ts);
|
||||
}
|
||||
|
||||
function getUpdateDate(): string {
|
||||
return date('j M', $this->updateTs);
|
||||
}
|
||||
|
||||
function getFullUpdateDate(): string {
|
||||
return date('j F Y', $this->updateTs);
|
||||
}
|
||||
|
||||
function getHtml(bool $is_retina, string $theme): string {
|
||||
$html = $this->html;
|
||||
$html = markup::htmlImagesFix($html, $is_retina, $theme);
|
||||
return $html;
|
||||
}
|
||||
|
||||
function getToc(): ?string {
|
||||
return $this->toc ? $this->tocHtml : null;
|
||||
}
|
||||
|
||||
function isUpdated(): bool {
|
||||
return $this->updateTs && $this->updateTs != $this->ts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Tag[]
|
||||
*/
|
||||
function getTags(): array {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT tags.* FROM posts_tags
|
||||
LEFT JOIN tags ON tags.id=posts_tags.tag_id
|
||||
WHERE posts_tags.post_id=?
|
||||
ORDER BY posts_tags.tag_id", $this->id);
|
||||
return array_map('Tag::create_instance', $db->fetchAll($q));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
function getTagIds(): array {
|
||||
$ids = [];
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT tag_id FROM posts_tags WHERE post_id=? ORDER BY tag_id", $this->id);
|
||||
while ($row = $db->fetch($q)) {
|
||||
$ids[] = (int)$row['tag_id'];
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
function setTagIds(array $new_tag_ids) {
|
||||
$cur_tag_ids = $this->getTagIds();
|
||||
$add_tag_ids = array_diff($new_tag_ids, $cur_tag_ids);
|
||||
$rm_tag_ids = array_diff($cur_tag_ids, $new_tag_ids);
|
||||
|
||||
$db = DB();
|
||||
if (!empty($add_tag_ids)) {
|
||||
$rows = [];
|
||||
foreach ($add_tag_ids as $id)
|
||||
$rows[] = ['post_id' => $this->id, 'tag_id' => $id];
|
||||
$db->multipleInsert('posts_tags', $rows);
|
||||
}
|
||||
|
||||
if (!empty($rm_tag_ids))
|
||||
$db->query("DELETE FROM posts_tags WHERE post_id=? AND tag_id IN(".implode(',', $rm_tag_ids).")", $this->id);
|
||||
|
||||
$upd_tag_ids = array_merge($new_tag_ids, $rm_tag_ids);
|
||||
$upd_tag_ids = array_unique($upd_tag_ids);
|
||||
foreach ($upd_tag_ids as $id)
|
||||
tags::recountTagPosts($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $update Whether to overwrite preview if already exists
|
||||
* @return int
|
||||
* @throws Exception
|
||||
*/
|
||||
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 (str_contains($opt, '=')) {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class posts {
|
||||
|
||||
static function getCount(bool $include_hidden = false): int {
|
||||
$db = DB();
|
||||
$sql = "SELECT COUNT(*) FROM posts";
|
||||
if (!$include_hidden) {
|
||||
$sql .= " WHERE visible=1";
|
||||
}
|
||||
return (int)$db->result($db->query($sql));
|
||||
}
|
||||
|
||||
static function getCountByTagId(int $tag_id, bool $include_hidden = false): int {
|
||||
$db = DB();
|
||||
if ($include_hidden) {
|
||||
$sql = "SELECT COUNT(*) FROM posts_tags WHERE tag_id=?";
|
||||
} else {
|
||||
$sql = "SELECT COUNT(*) FROM posts_tags
|
||||
LEFT JOIN posts ON posts.id=posts_tags.post_id
|
||||
WHERE posts_tags.tag_id=? AND posts.visible=1";
|
||||
}
|
||||
return (int)$db->result($db->query($sql, $tag_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Post[]
|
||||
*/
|
||||
static function getList(int $offset = 0, int $count = -1, bool $include_hidden = false): array {
|
||||
$db = DB();
|
||||
$sql = "SELECT * FROM posts";
|
||||
if (!$include_hidden)
|
||||
$sql .= " WHERE visible=1";
|
||||
$sql .= " ORDER BY ts DESC";
|
||||
if ($offset != 0 && $count != -1)
|
||||
$sql .= "LIMIT $offset, $count";
|
||||
$q = $db->query($sql);
|
||||
return array_map('Post::create_instance', $db->fetchAll($q));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Post[]
|
||||
*/
|
||||
static function getPostsByTagId(int $tag_id, bool $include_hidden = false): array {
|
||||
$db = DB();
|
||||
$sql = "SELECT posts.* FROM posts_tags
|
||||
LEFT JOIN posts ON posts.id=posts_tags.post_id
|
||||
WHERE posts_tags.tag_id=?";
|
||||
if (!$include_hidden)
|
||||
$sql .= " AND posts.visible=1";
|
||||
$sql .= " ORDER BY posts.ts DESC";
|
||||
$q = $db->query($sql, $tag_id);
|
||||
return array_map('Post::create_instance', $db->fetchAll($q));
|
||||
}
|
||||
|
||||
static function add(array $data = []): int|bool {
|
||||
$db = DB();
|
||||
|
||||
$html = \markup::markdownToHtml($data['md']);
|
||||
$text = \markup::htmlToText($html);
|
||||
|
||||
$data += [
|
||||
'ts' => time(),
|
||||
'html' => $html,
|
||||
'text' => $text,
|
||||
];
|
||||
|
||||
if (!$db->insert('posts', $data))
|
||||
return false;
|
||||
|
||||
$id = $db->insertId();
|
||||
|
||||
$post = self::get($id);
|
||||
$post->updateImagePreviews();
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
static function delete(Post $post): void {
|
||||
$tags = $post->getTags();
|
||||
|
||||
$db = DB();
|
||||
$db->query("DELETE FROM posts WHERE id=?", $post->id);
|
||||
$db->query("DELETE FROM posts_tags WHERE post_id=?", $post->id);
|
||||
|
||||
foreach ($tags as $tag)
|
||||
tags::recountTagPosts($tag->id);
|
||||
}
|
||||
|
||||
static function get(int $id): ?Post {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM posts WHERE id=?", $id);
|
||||
return $db->numRows($q) ? new Post($db->fetch($q)) : null;
|
||||
}
|
||||
|
||||
static function getByName(string $short_name): ?Post {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name);
|
||||
return $db->numRows($q) ? new Post($db->fetch($q)) : null;
|
||||
}
|
||||
|
||||
static function getPostsById(array $ids, bool $flat = false): array {
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = DB();
|
||||
$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;
|
||||
}
|
||||
|
||||
}
|
14
lib/stored_config.php
Normal file
14
lib/stored_config.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
function scGet(string $key) {
|
||||
$db = DB();
|
||||
$q = $db->query("SELECT value FROM config WHERE name=?", $key);
|
||||
if (!$db->numRows($q))
|
||||
return null;
|
||||
return $db->result($q);
|
||||
}
|
||||
|
||||
function scSet($key, $value) {
|
||||
$db = DB();
|
||||
return $db->query("REPLACE INTO config (name, value) VALUES (?, ?)", $key, $value);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user