initial
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/debug.log
|
||||||
|
test.php
|
||||||
|
/.git
|
||||||
|
/node_modules/
|
||||||
|
/vendor/
|
||||||
|
.DS_Store
|
||||||
|
._.DS_Store
|
||||||
|
.sass-cache/
|
||||||
|
config-static.php
|
||||||
|
config-local.php
|
||||||
|
/.idea
|
||||||
|
/htdocs/dist-css
|
||||||
|
/htdocs/dist-js
|
22
Makefile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
all:
|
||||||
|
@echo "Supported commands:"
|
||||||
|
@echo
|
||||||
|
@echo " \033[1mmake deploy\033[0m - deploy to production"
|
||||||
|
@echo " \033[1mmake static\033[0m - build static locally"
|
||||||
|
@echo
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
./deploy/deploy.sh
|
||||||
|
|
||||||
|
static: build-js build-css static-config
|
||||||
|
|
||||||
|
build-js:
|
||||||
|
./deploy/build_js.sh -i ./htdocs/js -o ./htdocs/dist-js
|
||||||
|
|
||||||
|
build-css:
|
||||||
|
./deploy/build_css.sh -i ./htdocs/scss -o ./htdocs/dist-css
|
||||||
|
|
||||||
|
static-config:
|
||||||
|
./deploy/gen_static_config.php -i ./htdocs > ./config-static.php
|
||||||
|
|
||||||
|
.PHONY: all deploy static
|
69
README.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# ch1p_io_web
|
||||||
|
|
||||||
|
This is a source code of 4in1.ws web site.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- it's not just blog, you can create any page with any address
|
||||||
|
- posts and pages are written in Markdown:
|
||||||
|
- supports syntax highlighting in code blocks
|
||||||
|
- supports embedding of uploaded files and image resizing
|
||||||
|
- tags
|
||||||
|
- rss feed
|
||||||
|
- dark theme
|
||||||
|
- ultra fast on backend:
|
||||||
|
- written from scratch
|
||||||
|
- no heavy frameworks
|
||||||
|
- no "classic" template engine
|
||||||
|
- vanilla php templates designed from scratch (because why not)
|
||||||
|
- thus, no overhead from templates "compilation"
|
||||||
|
- all strings are transparently escaped unless explicitly specified not to
|
||||||
|
- ultra fast on frontend:
|
||||||
|
- written from scratch
|
||||||
|
- simple readable ECMAScript 5.1 scripts
|
||||||
|
- no modern web bullshit like webpack or babel
|
||||||
|
- simple build system that just works
|
||||||
|
- secure:
|
||||||
|
- CSRF protection
|
||||||
|
- automatic XSS protection in templates
|
||||||
|
- see [this section](#bug-bounty) below
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP >= 8.1, with following extensions:
|
||||||
|
- mysqli
|
||||||
|
- gd
|
||||||
|
- MariaDB server
|
||||||
|
- Composer
|
||||||
|
- Node.JS
|
||||||
|
- SCSS compiler, e.g. sassc
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Should be done by copying config.php to config-local.php and modifying config-local.php.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
It uses https://github.com/sixlive/parsedown-highlight which you'll need to install using Composer, but since that
|
||||||
|
package's manifest is a bit outdated you have to pass `--ignore-platform-reqs` to composer.
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
```
|
||||||
|
make deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bug bounty
|
||||||
|
|
||||||
|
I take security very seriously. If you found an exploitable vulnerability in _my_ code, please contact me by email.
|
||||||
|
|
||||||
|
I'm willing to pay $50 to $500 in crypto (depending on severity) for every discovered vulnerability.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPLv3
|
9
classes/AjaxErrorResponse.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class AjaxErrorResponse extends AjaxResponse {
|
||||||
|
|
||||||
|
public function __construct(string $error, int $code = 200) {
|
||||||
|
parent::__construct(code: $code, body: json_encode(['error' => $error], JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
9
classes/AjaxOkResponse.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class AjaxOkResponse extends AjaxResponse {
|
||||||
|
|
||||||
|
public function __construct($data) {
|
||||||
|
parent::__construct(code: 200, body: json_encode(['response' => $data], JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
13
classes/AjaxResponse.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class AjaxResponse extends Response {
|
||||||
|
|
||||||
|
public function __construct(...$args) {
|
||||||
|
parent::__construct(...$args);
|
||||||
|
$this->addHeader('Content-Type: application/json; charset=utf-8');
|
||||||
|
$this->addHeader('Cache-Control: no-cache, must-revalidate');
|
||||||
|
$this->addHeader('Pragma: no-cache');
|
||||||
|
$this->addHeader('Content-Type: application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
8
classes/InputType.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
enum InputType: string {
|
||||||
|
case INT = 'i';
|
||||||
|
case FLOAT = 'f';
|
||||||
|
case BOOL = 'b';
|
||||||
|
case STRING = 's';
|
||||||
|
}
|
8
classes/LangAccess.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
interface LangAccess {
|
||||||
|
|
||||||
|
public function lang(...$args): string;
|
||||||
|
public function langRaw(string $key, ...$args);
|
||||||
|
|
||||||
|
}
|
108
classes/LangData.php
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class LangData implements ArrayAccess {
|
||||||
|
|
||||||
|
private static ?LangData $instance = null;
|
||||||
|
protected array $data = [];
|
||||||
|
protected array $loaded = [];
|
||||||
|
|
||||||
|
public static function getInstance(): static {
|
||||||
|
if (is_null(self::$instance)) {
|
||||||
|
self::$instance = new self();
|
||||||
|
self::$instance->load('en');
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(string $key, ...$args) {
|
||||||
|
$val = $this[$key];
|
||||||
|
return empty($args) ? $val : sprintf($val, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function load(string $name) {
|
||||||
|
if (array_key_exists($name, $this->loaded))
|
||||||
|
return;
|
||||||
|
|
||||||
|
$data = require_once ROOT."/lang/{$name}.php";
|
||||||
|
$this->data = array_replace($this->data,
|
||||||
|
$data);
|
||||||
|
|
||||||
|
$this->loaded[$name] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetSet(mixed $offset, mixed $value): void {
|
||||||
|
logError(__METHOD__ . ': not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetExists($offset): bool {
|
||||||
|
return isset($this->data[$offset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetUnset(mixed $offset): void {
|
||||||
|
logError(__METHOD__ . ': not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetGet(mixed $offset): mixed {
|
||||||
|
return $this->data[$offset] ?? '{' . $offset . '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(string $regexp): array|false {
|
||||||
|
return preg_grep($regexp, array_keys($this->data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// function plural(array $s, int $n, array $opts = []) {
|
||||||
|
// $opts = array_merge([
|
||||||
|
// 'format' => true,
|
||||||
|
// 'format_delim' => ' ',
|
||||||
|
// 'lang' => 'en',
|
||||||
|
// ], $opts);
|
||||||
|
//
|
||||||
|
// switch ($opts['lang']) {
|
||||||
|
// case 'ru':
|
||||||
|
// $n = $n % 100;
|
||||||
|
// if ($n > 19)
|
||||||
|
// $n %= 10;
|
||||||
|
//
|
||||||
|
// if ($n == 1) {
|
||||||
|
// $word = 0;
|
||||||
|
// } else if ($n >= 2 && $n <= 4) {
|
||||||
|
// $word = 1;
|
||||||
|
// } else if ($n == 0 && count($s) == 4) {
|
||||||
|
// $word = 3;
|
||||||
|
// } else {
|
||||||
|
// $word = 2;
|
||||||
|
// }
|
||||||
|
// break;
|
||||||
|
//
|
||||||
|
// default:
|
||||||
|
// if (!$n && count($s) == 4) {
|
||||||
|
// $word = 3;
|
||||||
|
// } else {
|
||||||
|
// $word = (int)!!$n;
|
||||||
|
// }
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // if zero
|
||||||
|
// if ($word == 3)
|
||||||
|
// return $s[3];
|
||||||
|
//
|
||||||
|
// if (is_callable($opts['format'])) {
|
||||||
|
// $num = $opts['format']($n);
|
||||||
|
// } else if ($opts['format'] === true) {
|
||||||
|
// $num = formatNumber($n, $opts['format_delim']);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return sprintf($s[$word], $num);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// function formatNumber(int $num, string $delim = ' ', bool $short = false): string {
|
||||||
|
// if ($short) {
|
||||||
|
// if ($num >= 1000000)
|
||||||
|
// return floor($num / 1000000).'m';
|
||||||
|
// if ($num >= 1000)
|
||||||
|
// return floor($num / 1000).'k';
|
||||||
|
// }
|
||||||
|
// return number_format($num, 0, '.', $delim);
|
||||||
|
// }
|
||||||
|
}
|
8
classes/LogLevel.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
enum LogLevel {
|
||||||
|
case ERROR;
|
||||||
|
case WARNING;
|
||||||
|
case INFO;
|
||||||
|
case DEBUG;
|
||||||
|
}
|
218
classes/MyParsedown.php
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class MyParsedown extends ParsedownExtended {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
?array $opts = null,
|
||||||
|
protected bool $useImagePreviews = false
|
||||||
|
) {
|
||||||
|
$parsedown_opts = [
|
||||||
|
'tables' => [
|
||||||
|
'tablespan' => true
|
||||||
|
]
|
||||||
|
];
|
||||||
|
if (!is_null($opts)) {
|
||||||
|
$parsedown_opts = array_merge($parsedown_opts, $opts);
|
||||||
|
}
|
||||||
|
parent::__construct($parsedown_opts);
|
||||||
|
|
||||||
|
$this->InlineTypes['{'][] = 'FileAttach';
|
||||||
|
$this->InlineTypes['{'][] = 'Image';
|
||||||
|
$this->InlineTypes['{'][] = 'Video';
|
||||||
|
$this->inlineMarkerList .= '{';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function inlineFileAttach($excerpt) {
|
||||||
|
if (preg_match('/^{fileAttach:([\w]{8})}{\/fileAttach}/', $excerpt['text'], $matches)) {
|
||||||
|
$random_id = $matches[1];
|
||||||
|
$upload = uploads::getByRandomId($random_id);
|
||||||
|
$result = [
|
||||||
|
'extent' => strlen($matches[0]),
|
||||||
|
'element' => [
|
||||||
|
'name' => 'span',
|
||||||
|
'text' => '',
|
||||||
|
],
|
||||||
|
'type' => ''
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$upload) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($result['element']['text']);
|
||||||
|
|
||||||
|
$ctx = self::getSkinContext();
|
||||||
|
$result['element']['rawHtml'] = $ctx->fileupload($upload->name, $upload->getDirectUrl(), $upload->note, $upload->getSize());
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function inlineImage($excerpt) {
|
||||||
|
global $config;
|
||||||
|
|
||||||
|
if (preg_match('/^{image:([\w]{8}),(.*?)}{\/image}/', $excerpt['text'], $matches)) {
|
||||||
|
$random_id = $matches[1];
|
||||||
|
|
||||||
|
$opts = [
|
||||||
|
'w' => 'auto',
|
||||||
|
'h' => 'auto',
|
||||||
|
'align' => 'left',
|
||||||
|
'nolabel' => false,
|
||||||
|
];
|
||||||
|
$inputopts = explode(',', $matches[2]);
|
||||||
|
|
||||||
|
foreach ($inputopts as $opt) {
|
||||||
|
if ($opt == 'nolabel')
|
||||||
|
$opts[$opt] = true;
|
||||||
|
else {
|
||||||
|
list($k, $v) = explode('=', $opt);
|
||||||
|
if (!isset($opts[$k]))
|
||||||
|
continue;
|
||||||
|
$opts[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = uploads::getByRandomId($random_id);
|
||||||
|
$result = [
|
||||||
|
'extent' => strlen($matches[0]),
|
||||||
|
'element' => [
|
||||||
|
'name' => 'span',
|
||||||
|
'text' => '',
|
||||||
|
],
|
||||||
|
'type' => ''
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$image) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
list($w, $h) = $image->getImagePreviewSize(
|
||||||
|
$opts['w'] == 'auto' ? null : $opts['w'],
|
||||||
|
$opts['h'] == 'auto' ? null : $opts['h']
|
||||||
|
);
|
||||||
|
$opts['w'] = $w;
|
||||||
|
// $opts['h'] = $h;
|
||||||
|
|
||||||
|
if (!$this->useImagePreviews)
|
||||||
|
$image_url = $image->getDirectUrl();
|
||||||
|
else
|
||||||
|
$image_url = $image->getDirectPreviewUrl($w, $h);
|
||||||
|
|
||||||
|
unset($result['element']['text']);
|
||||||
|
|
||||||
|
$ctx = self::getSkinContext();
|
||||||
|
$result['element']['rawHtml'] = $ctx->image(
|
||||||
|
w: $opts['w'],
|
||||||
|
nolabel: $opts['nolabel'],
|
||||||
|
align: $opts['align'],
|
||||||
|
padding_top: round($h / $w * 100, 4),
|
||||||
|
may_have_alpha: $image->imageMayHaveAlphaChannel(),
|
||||||
|
|
||||||
|
url: $image_url,
|
||||||
|
direct_url: $image->getDirectUrl(),
|
||||||
|
note: $image->note
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function inlineVideo($excerpt) {
|
||||||
|
if (preg_match('/^{video:([\w]{8})(?:,(.*?))?}{\/video}/', $excerpt['text'], $matches)) {
|
||||||
|
$random_id = $matches[1];
|
||||||
|
|
||||||
|
$opts = [
|
||||||
|
'w' => 'auto',
|
||||||
|
'h' => 'auto',
|
||||||
|
'align' => 'left',
|
||||||
|
'nolabel' => false,
|
||||||
|
];
|
||||||
|
$inputopts = !empty($matches[2]) ? explode(',', $matches[2]) : [];
|
||||||
|
|
||||||
|
foreach ($inputopts as $opt) {
|
||||||
|
if ($opt == 'nolabel')
|
||||||
|
$opts[$opt] = true;
|
||||||
|
else {
|
||||||
|
list($k, $v) = explode('=', $opt);
|
||||||
|
if (!isset($opts[$k]))
|
||||||
|
continue;
|
||||||
|
$opts[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$video = uploads::getByRandomId($random_id);
|
||||||
|
$result = [
|
||||||
|
'extent' => strlen($matches[0]),
|
||||||
|
'element' => [
|
||||||
|
'name' => 'span',
|
||||||
|
'text' => '',
|
||||||
|
],
|
||||||
|
'type' => ''
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$video) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$video_url = $video->getDirectUrl();
|
||||||
|
|
||||||
|
unset($result['element']['text']);
|
||||||
|
|
||||||
|
$ctx = self::getSkinContext();
|
||||||
|
$result['element']['rawHtml'] = $ctx->video(
|
||||||
|
url: $video_url,
|
||||||
|
w: $opts['w'],
|
||||||
|
h: $opts['h']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function paragraph($line) {
|
||||||
|
if (preg_match('/^{fileAttach:([\w]{8})}{\/fileAttach}$/', $line['text'])) {
|
||||||
|
return $this->inlineFileAttach($line);
|
||||||
|
}
|
||||||
|
if (preg_match('/^{image:([\w]{8}),(?:.*?)}{\/image}/', $line['text'])) {
|
||||||
|
return $this->inlineImage($line);
|
||||||
|
}
|
||||||
|
if (preg_match('/^{video:([\w]{8})(?:,(?:.*?))?}{\/video}/', $line['text'])) {
|
||||||
|
return $this->inlineVideo($line);
|
||||||
|
}
|
||||||
|
return parent::paragraph($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function blockFencedCodeComplete($block) {
|
||||||
|
if (!isset($block['element']['element']['attributes'])) {
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $block['element']['element']['text'];
|
||||||
|
$languageClass = $block['element']['element']['attributes']['class'];
|
||||||
|
$language = explode('-', $languageClass);
|
||||||
|
|
||||||
|
if ($language[1] == 'term') {
|
||||||
|
$lines = explode("\n", $code);
|
||||||
|
for ($i = 0; $i < count($lines); $i++) {
|
||||||
|
$line = $lines[$i];
|
||||||
|
if (str_starts_with($line, '$ ') || str_starts_with($line, '# ')) {
|
||||||
|
$lines[$i] = '<span class="term-prompt">'.substr($line, 0, 2).'</span>'.htmlspecialchars(substr($line, 2), ENT_NOQUOTES, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
$lines[$i] = htmlspecialchars($line, ENT_NOQUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$block['element']['element']['rawHtml'] = implode("\n", $lines);
|
||||||
|
unset($block['element']['element']['text']);
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::blockFencedCodeComplete($block);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getSkinContext(): SkinContext {
|
||||||
|
return new SkinContext('\\skin\\markdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
10
classes/RedirectResponse.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class RedirectResponse extends Response {
|
||||||
|
|
||||||
|
public function __construct(string $url, int $code = 302) {
|
||||||
|
parent::__construct($code);
|
||||||
|
$this->addHeader('Location: '.$url);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
89
classes/RequestDispatcher.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
58
classes/RequestHandler.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?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'];
|
||||||
|
}
|
||||||
|
}
|
28
classes/Response.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Response {
|
||||||
|
|
||||||
|
protected array $headers = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $code = 200,
|
||||||
|
public ?string $body = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function send(): void {
|
||||||
|
$this->setHeaders();
|
||||||
|
if ($this->code == 200 || $this->code >= 400)
|
||||||
|
echo $this->body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addHeader(string $header): void {
|
||||||
|
$this->headers[] = $header;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHeaders(): void {
|
||||||
|
http_response_code($this->code);
|
||||||
|
foreach ($this->headers as $header)
|
||||||
|
header($header);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
165
classes/Router.php
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Router {
|
||||||
|
|
||||||
|
protected array $routes = [
|
||||||
|
'children' => [],
|
||||||
|
're_children' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
public function add($template, $value) {
|
||||||
|
if ($template == '')
|
||||||
|
return $this;
|
||||||
|
|
||||||
|
// expand {enum,erat,ions}
|
||||||
|
$templates = [[$template, $value]];
|
||||||
|
if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) {
|
||||||
|
foreach ($matches[1] as $match_index => $variants) {
|
||||||
|
$variants = explode(',', $variants);
|
||||||
|
$variants = array_map('trim', $variants);
|
||||||
|
$variants = array_filter($variants, function($s) { return $s != ''; });
|
||||||
|
|
||||||
|
for ($i = 0; $i < count($templates); ) {
|
||||||
|
list($template, $value) = $templates[$i];
|
||||||
|
$new_templates = [];
|
||||||
|
foreach ($variants as $variant_index => $variant) {
|
||||||
|
$new_templates[] = [
|
||||||
|
str_replace_once($matches[0][$match_index], $variant, $template),
|
||||||
|
str_replace('${'.($match_index+1).'}', $variant, $value)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
array_splice($templates, $i, 1, $new_templates);
|
||||||
|
$i += count($new_templates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// process all generated routes
|
||||||
|
foreach ($templates as $template) {
|
||||||
|
list($template, $value) = $template;
|
||||||
|
|
||||||
|
$start_pos = 0;
|
||||||
|
$parent = &$this->routes;
|
||||||
|
$template_len = strlen($template);
|
||||||
|
|
||||||
|
while ($start_pos < $template_len) {
|
||||||
|
$slash_pos = strpos($template, '/', $start_pos);
|
||||||
|
if ($slash_pos !== false) {
|
||||||
|
$part = substr($template, $start_pos, $slash_pos-$start_pos+1);
|
||||||
|
$start_pos = $slash_pos+1;
|
||||||
|
} else {
|
||||||
|
$part = substr($template, $start_pos);
|
||||||
|
$start_pos = $template_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = &$this->_addRoute($parent, $part,
|
||||||
|
$start_pos < $template_len ? null : $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function &_addRoute(&$parent, $part, $value = null) {
|
||||||
|
$par_pos = strpos($part, '(');
|
||||||
|
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
|
||||||
|
|
||||||
|
$children_key = !$is_regex ? 'children' : 're_children';
|
||||||
|
|
||||||
|
if (isset($parent[$children_key][$part])) {
|
||||||
|
if (is_null($value)) {
|
||||||
|
$parent = &$parent[$children_key][$part];
|
||||||
|
} else {
|
||||||
|
if (!isset($parent[$children_key][$part]['value'])) {
|
||||||
|
$parent[$children_key][$part]['value'] = $value;
|
||||||
|
} else {
|
||||||
|
trigger_error(__METHOD__.': route is already defined');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
$child = [
|
||||||
|
'children' => [],
|
||||||
|
're_children' => []
|
||||||
|
];
|
||||||
|
if (!is_null($value))
|
||||||
|
$child['value'] = $value;
|
||||||
|
|
||||||
|
$parent[$children_key][$part] = $child;
|
||||||
|
return $parent[$children_key][$part];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find($uri) {
|
||||||
|
if ($uri != '/' && $uri[0] == '/') {
|
||||||
|
$uri = substr($uri, 1);
|
||||||
|
}
|
||||||
|
$start_pos = 0;
|
||||||
|
$parent = &$this->routes;
|
||||||
|
$uri_len = strlen($uri);
|
||||||
|
$matches = [];
|
||||||
|
|
||||||
|
while ($start_pos < $uri_len) {
|
||||||
|
$slash_pos = strpos($uri, '/', $start_pos);
|
||||||
|
if ($slash_pos !== false) {
|
||||||
|
$part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
|
||||||
|
$start_pos = $slash_pos+1;
|
||||||
|
} else {
|
||||||
|
$part = substr($uri, $start_pos);
|
||||||
|
$start_pos = $uri_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
$found = false;
|
||||||
|
if (isset($parent['children'][$part])) {
|
||||||
|
$parent = &$parent['children'][$part];
|
||||||
|
$found = true;
|
||||||
|
} else if (!empty($parent['re_children'])) {
|
||||||
|
foreach ($parent['re_children'] as $re => &$child) {
|
||||||
|
$exp = '#^'.$re.'$#';
|
||||||
|
$re_result = preg_match($exp, $part, $match);
|
||||||
|
if ($re_result === false) {
|
||||||
|
logError(__METHOD__.": regex $exp failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($re_result) {
|
||||||
|
if (count($match) > 1) {
|
||||||
|
$matches = array_merge($matches, array_slice($match, 1));
|
||||||
|
}
|
||||||
|
$parent = &$child;
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($parent['value']))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
$value = $parent['value'];
|
||||||
|
if (!empty($matches)) {
|
||||||
|
foreach ($matches as $i => $match) {
|
||||||
|
$needle = '$('.($i+1).')';
|
||||||
|
$pos = strpos($value, $needle);
|
||||||
|
if ($pos !== false) {
|
||||||
|
$value = substr_replace($value, $match, $pos, strlen($needle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function load($routes) {
|
||||||
|
$this->routes = $routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dump(): array {
|
||||||
|
return $this->routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
60
classes/Skin.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Skin {
|
||||||
|
|
||||||
|
public string $title = 'title';
|
||||||
|
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) : '';
|
||||||
|
return new Response(200, $layout_ctx->layout(
|
||||||
|
static: $this->static,
|
||||||
|
theme: $theme,
|
||||||
|
title: $this->title,
|
||||||
|
opts: $this->options,
|
||||||
|
js: $js,
|
||||||
|
meta: $this->meta,
|
||||||
|
unsafe_lang: $lang,
|
||||||
|
unsafe_body: $body,
|
||||||
|
//exec_time: exectime()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addLangKeys(array $keys): void {
|
||||||
|
$this->langKeys = array_merge($this->langKeys, $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getLang(): array {
|
||||||
|
$lang = [];
|
||||||
|
$ld = LangData::getInstance();
|
||||||
|
foreach ($this->langKeys as $key)
|
||||||
|
$lang[$key] = $ld[$key];
|
||||||
|
return $lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOptions(array $options): void {
|
||||||
|
$this->options = array_merge($this->options, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
22
classes/SkinBase.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class SkinBase implements LangAccess {
|
||||||
|
|
||||||
|
protected static LangData $ld;
|
||||||
|
|
||||||
|
public static function __constructStatic(): void {
|
||||||
|
self::$ld = LangData::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lang(...$args): string {
|
||||||
|
return htmlescape($this->langRaw(...$args));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function langRaw(string $key, ...$args) {
|
||||||
|
$val = self::$ld[$key];
|
||||||
|
return empty($args) ? $val : sprintf($val, ...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
SkinBase::__constructStatic();
|
118
classes/SkinContext.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
23
classes/SkinString.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
9
classes/SkinStringModificationType.php
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
enum SkinStringModificationType {
|
||||||
|
case RAW;
|
||||||
|
case URL;
|
||||||
|
case HTML;
|
||||||
|
case JSON;
|
||||||
|
case ADDSLASHES;
|
||||||
|
}
|
55
classes/admin.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?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'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
74
classes/cli.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class cli {
|
||||||
|
|
||||||
|
protected ?array $commandsCache = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected string $ns
|
||||||
|
) {}
|
||||||
|
|
||||||
|
protected function usage($error = null): void {
|
||||||
|
global $argv;
|
||||||
|
|
||||||
|
if (!is_null($error))
|
||||||
|
echo "error: {$error}\n\n";
|
||||||
|
|
||||||
|
echo "Usage: $argv[0] COMMAND\n\nCommands:\n";
|
||||||
|
foreach ($this->getCommands() as $c)
|
||||||
|
echo " $c\n";
|
||||||
|
|
||||||
|
exit(is_null($error) ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCommands(): array {
|
||||||
|
if (is_null($this->commandsCache)) {
|
||||||
|
$funcs = array_filter(get_defined_functions()['user'], fn(string $f) => str_starts_with($f, $this->ns));
|
||||||
|
$funcs = array_map(fn(string $f) => str_replace('_', '-', substr($f, strlen($this->ns.'\\'))), $funcs);
|
||||||
|
$this->commandsCache = array_values($funcs);
|
||||||
|
}
|
||||||
|
return $this->commandsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): void {
|
||||||
|
global $argv, $argc;
|
||||||
|
|
||||||
|
if (PHP_SAPI != 'cli')
|
||||||
|
cli::die('SAPI != cli');
|
||||||
|
|
||||||
|
if ($argc < 2)
|
||||||
|
$this->usage();
|
||||||
|
|
||||||
|
$func = $argv[1];
|
||||||
|
if (!in_array($func, $this->getCommands()))
|
||||||
|
self::usage('unknown command "'.$func.'"');
|
||||||
|
|
||||||
|
$func = str_replace('-', '_', $func);
|
||||||
|
call_user_func($this->ns.'\\'.$func);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function input(string $prompt): string {
|
||||||
|
echo $prompt;
|
||||||
|
$input = substr(fgets(STDIN), 0, -1);
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function silentInput(string $prompt = ''): string {
|
||||||
|
echo $prompt;
|
||||||
|
system('stty -echo');
|
||||||
|
$input = substr(fgets(STDIN), 0, -1);
|
||||||
|
system('stty echo');
|
||||||
|
echo "\n";
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function die($error): void {
|
||||||
|
self::error($error);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error($error): void {
|
||||||
|
fwrite(STDERR, "error: {$error}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
46
classes/config.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class config {
|
||||||
|
|
||||||
|
public static function get(string $key) {
|
||||||
|
$db = getDb();
|
||||||
|
$q = $db->query("SELECT value FROM config WHERE name=?", $key);
|
||||||
|
if (!$db->numRows($q))
|
||||||
|
return null;
|
||||||
|
return $db->result($q);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mget($keys) {
|
||||||
|
$map = [];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$map[$key] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = getDb();
|
||||||
|
$keys = array_map(fn($s) => $db->escape($s), $keys);
|
||||||
|
|
||||||
|
$q = $db->query("SELECT * FROM config WHERE name IN('".implode("','", $keys)."')");
|
||||||
|
while ($row = $db->fetch($q))
|
||||||
|
$map[$row['name']] = $row['value'];
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set($key, $value) {
|
||||||
|
$db = getDb();
|
||||||
|
return $db->query("REPLACE INTO config (name, value) VALUES (?, ?)", $key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mset($map) {
|
||||||
|
$rows = [];
|
||||||
|
foreach ($map as $name => $value) {
|
||||||
|
$rows[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'value' => $value
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$db = getDb();
|
||||||
|
return $db->multipleReplace('config', $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
22
classes/csrf.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class csrf {
|
||||||
|
|
||||||
|
public static function check(string $key): void {
|
||||||
|
$user_csrf = self::get($key);
|
||||||
|
$sent_csrf = $_REQUEST['token'] ?? '';
|
||||||
|
|
||||||
|
if ($sent_csrf != $user_csrf)
|
||||||
|
throw new ForbiddenException("csrf error");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(string $key): string {
|
||||||
|
return self::getToken($_SERVER['REMOTE_ADDR'], $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getToken(string $user_token, string $key): string {
|
||||||
|
global $config;
|
||||||
|
return substr(sha1($config['csrf_token'].$user_token.$key), 0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
109
classes/database/CommonDatabase.php
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<?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)];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
87
classes/database/MySQLConnection.php
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
83
classes/database/SQLiteConnection.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?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.
|
||||||
|
}
|
||||||
|
}
|
11
classes/exceptions/ForbiddenException.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace exceptions;
|
||||||
|
|
||||||
|
class ForbiddenException extends \BadMethodCallException {
|
||||||
|
|
||||||
|
public function __construct(string $message = '') {
|
||||||
|
parent::__construct($message, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
11
classes/exceptions/NotFoundException.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace exceptions;
|
||||||
|
|
||||||
|
class NotFoundException extends \BadMethodCallException {
|
||||||
|
|
||||||
|
public function __construct(string $message = '') {
|
||||||
|
parent::__construct($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
11
classes/exceptions/NotImplementedException.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace exceptions;
|
||||||
|
|
||||||
|
class NotImplementedException extends \BadMethodCallException {
|
||||||
|
|
||||||
|
public function __construct(string $message = '') {
|
||||||
|
parent::__construct($message, 501);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
11
classes/exceptions/UnauthorizedException.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace exceptions;
|
||||||
|
|
||||||
|
class UnauthorizedException extends \BadMethodCallException {
|
||||||
|
|
||||||
|
public function __construct(string $message = '') {
|
||||||
|
parent::__construct($message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
16
classes/handler/AboutHandler.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace handler;
|
||||||
|
|
||||||
|
use Response;
|
||||||
|
|
||||||
|
class AboutHandler extends \RequestHandler {
|
||||||
|
|
||||||
|
public function get(): Response {
|
||||||
|
global $config;
|
||||||
|
$this->skin->title = $this->lang['contacts'];
|
||||||
|
return $this->skin->renderPage('main/contacts',
|
||||||
|
email: $config['admin_email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
110
classes/handler/AutoHandler.php
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace handler;
|
||||||
|
|
||||||
|
use admin;
|
||||||
|
use model\Post;
|
||||||
|
use model\Tag;
|
||||||
|
use 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 ($name == 'coreboot-mba51-flashing')
|
||||||
|
return new RedirectResponse('/coreboot-mba52-flashing/', 301);
|
||||||
|
|
||||||
|
if (is_numeric($name)) {
|
||||||
|
$post = posts::get((int)$name);
|
||||||
|
} else {
|
||||||
|
$post = posts::getPostByName($name);
|
||||||
|
}
|
||||||
|
if ($post)
|
||||||
|
return $this->getPost($post);
|
||||||
|
|
||||||
|
$tag = posts::getTag($name);
|
||||||
|
if ($tag)
|
||||||
|
return $this->getTag($tag);
|
||||||
|
|
||||||
|
$page = pages::getPageByName($name);
|
||||||
|
if ($page)
|
||||||
|
return $this->getPage($page);
|
||||||
|
|
||||||
|
if (admin::isAdmin()) {
|
||||||
|
$this->skin->title = $name;
|
||||||
|
return $this->skin->renderPage('admin/pageNew',
|
||||||
|
short_name: $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPost(Post $post): Response {
|
||||||
|
global $config;
|
||||||
|
|
||||||
|
if (!$post->visible && !admin::isAdmin())
|
||||||
|
throw new NotFoundException();
|
||||||
|
|
||||||
|
$tags = $post->getTags();
|
||||||
|
|
||||||
|
$s = $this->skin;
|
||||||
|
$s->meta[] = ['property' => 'og:title', 'content' => $post->title];
|
||||||
|
$s->meta[] = ['property' => 'og:url', 'content' => fullURL($post->getUrl())];
|
||||||
|
if (($img = $post->getFirstImage()) !== null)
|
||||||
|
$s->meta[] = ['property' => 'og:image', 'content' => $img->getDirectUrl()];
|
||||||
|
$s->meta[] = [
|
||||||
|
'name' => 'description',
|
||||||
|
'property' => 'og:description',
|
||||||
|
'content' => $post->getDescriptionPreview(155)
|
||||||
|
];
|
||||||
|
|
||||||
|
$s->title = $post->title;
|
||||||
|
|
||||||
|
if ($post->toc)
|
||||||
|
$s->setOptions(['wide' => true]);
|
||||||
|
|
||||||
|
return $s->renderPage('main/post',
|
||||||
|
title: $post->title,
|
||||||
|
id: $post->id,
|
||||||
|
unsafe_html: $post->getHtml($this->isRetina(), \themes::getUserTheme()),
|
||||||
|
unsafe_toc_html: $post->getToc(),
|
||||||
|
date: $post->getFullDate(),
|
||||||
|
tags: $tags,
|
||||||
|
visible: $post->visible,
|
||||||
|
url: $post->getUrl(),
|
||||||
|
email: $config['admin_email'],
|
||||||
|
urlencoded_reply_subject: 'Re: '.$post->title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTag(Tag $tag): Response {
|
||||||
|
$tag = posts::getTag($tag);
|
||||||
|
if (!admin::isAdmin() && !$tag->visiblePostsCount)
|
||||||
|
throw new NotFoundException();
|
||||||
|
|
||||||
|
$count = posts::getPostsCountByTagId($tag->id, admin::isAdmin());
|
||||||
|
$posts = $count ? posts::getPostsByTagId($tag->id, admin::isAdmin()) : [];
|
||||||
|
|
||||||
|
$this->skin->title = '#'.$tag->tag;
|
||||||
|
return $this->skin->renderPage('main/tag',
|
||||||
|
count: $count,
|
||||||
|
posts: $posts,
|
||||||
|
tag: $tag->tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPage(\model\Page $page): Response {
|
||||||
|
if (!admin::isAdmin() && !$page->visible)
|
||||||
|
throw new NotFoundException();
|
||||||
|
|
||||||
|
$this->skin->title = $page ? $page->title : '???';
|
||||||
|
return $this->skin->renderPage('main/page',
|
||||||
|
unsafe_html: $page->getHtml($this->isRetina(), \themes::getUserTheme()),
|
||||||
|
page_url: $page->getUrl(),
|
||||||
|
short_name: $page->shortName);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
21
classes/handler/IndexHandler.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace handler;
|
||||||
|
|
||||||
|
use admin;
|
||||||
|
use posts;
|
||||||
|
|
||||||
|
class IndexHandler extends \RequestHandler {
|
||||||
|
|
||||||
|
public function get(): \Response {
|
||||||
|
//$posts = posts::getPosts(include_hidden: admin::isAdmin());
|
||||||
|
//$tags = posts::getAllTags(include_hidden: admin::isAdmin());
|
||||||
|
|
||||||
|
$page = \pages::getPageByName('index-wgm9Fkl');
|
||||||
|
$this->skin->title = $page->title;
|
||||||
|
|
||||||
|
//$this->skin->title = $this->skin->lang('site_title');
|
||||||
|
return $this->skin->renderPage('main/index',
|
||||||
|
unsafe_content: $page->getHtml($this->isRetina(), \themes::getUserTheme()));
|
||||||
|
}
|
||||||
|
}
|
32
classes/handler/RSSHandler.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
21
classes/handler/admin/AdminRequestHandler.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
99
classes/handler/admin/AutoAddOrEditHandler.php
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
34
classes/handler/admin/AutoDelete.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace handler\admin;
|
||||||
|
|
||||||
|
use csrf;
|
||||||
|
use NotFoundException;
|
||||||
|
use pages;
|
||||||
|
use posts;
|
||||||
|
use RedirectResponse;
|
||||||
|
use Response;
|
||||||
|
|
||||||
|
class AutoDelete extends AdminRequestHandler {
|
||||||
|
|
||||||
|
public function get(): Response {
|
||||||
|
list($name) = $this->input('short_name');
|
||||||
|
|
||||||
|
$post = posts::getPostByName($name);
|
||||||
|
if ($post) {
|
||||||
|
csrf::check('delpost'.$post->id);
|
||||||
|
posts::delete($post);
|
||||||
|
return new RedirectResponse('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = pages::getPageByName($name);
|
||||||
|
if ($page) {
|
||||||
|
csrf::check('delpage'.$page->shortName);
|
||||||
|
pages::delete($page);
|
||||||
|
return new RedirectResponse('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
130
classes/handler/admin/AutoEditHandler.php
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
13
classes/handler/admin/IndexIndex.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace handler\admin;
|
||||||
|
|
||||||
|
use Response;
|
||||||
|
|
||||||
|
class IndexIndex extends AdminRequestHandler {
|
||||||
|
|
||||||
|
public function get(): Response {
|
||||||
|
return $this->skin->renderPage('admin/index');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
31
classes/handler/admin/LoginHandler.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
17
classes/handler/admin/LogoutHandler.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?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/');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
22
classes/handler/admin/MarkdownPreviewHandler.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
66
classes/handler/admin/PageAddHandler.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
68
classes/handler/admin/PostAddHandler.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
25
classes/handler/admin/UploadDeleteHandler.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?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('/uploads/?error='.urlencode('upload not found'));
|
||||||
|
|
||||||
|
csrf::check('delupl'.$id);
|
||||||
|
|
||||||
|
\uploads::delete($id);
|
||||||
|
|
||||||
|
return new RedirectResponse('/uploads/');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
25
classes/handler/admin/UploadEditNoteHandler.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?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('/uploads/?error='.urlencode('upload not found'));
|
||||||
|
|
||||||
|
csrf::check('editupl'.$id);
|
||||||
|
|
||||||
|
$note = $_POST['note'] ?? '';
|
||||||
|
$upload->setNote($note);
|
||||||
|
|
||||||
|
return new \RedirectResponse('/uploads/');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
73
classes/handler/admin/UploadsHandler.php
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?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('/uploads/?error='.urlencode('no file'));
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
for ($i = 0; $i < count($_FILES['files']['name']); $i++) {
|
||||||
|
$files[] = [
|
||||||
|
'name' => $_FILES['files']['name'][$i],
|
||||||
|
'type' => $_FILES['files']['type'][$i],
|
||||||
|
'tmp_name' => $_FILES['files']['tmp_name'][$i],
|
||||||
|
'error' => $_FILES['files']['error'][$i],
|
||||||
|
'size' => $_FILES['files']['size'][$i],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($files) > 1) {
|
||||||
|
$note = '';
|
||||||
|
$custom_name = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($files as $f) {
|
||||||
|
if ($f['error'])
|
||||||
|
return new RedirectResponse('/uploads/?error='.urlencode('error code '.$f['error']));
|
||||||
|
|
||||||
|
if (!$f['size'])
|
||||||
|
return new RedirectResponse('/uploads/?error='.urlencode('received empty file'));
|
||||||
|
|
||||||
|
$ext = extension($f['name']);
|
||||||
|
if (!\uploads::isExtensionAllowed($ext))
|
||||||
|
return new RedirectResponse('/uploads/?error='.urlencode('extension not allowed'));
|
||||||
|
|
||||||
|
$upload_id = \uploads::add(
|
||||||
|
$f['tmp_name'],
|
||||||
|
$custom_name ?: $f['name'],
|
||||||
|
$note);
|
||||||
|
|
||||||
|
if (!$upload_id)
|
||||||
|
return new RedirectResponse('/uploads/?error='.urlencode('failed to create upload'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RedirectResponse('/uploads/');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
177
classes/logging.php
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
46
classes/markup.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class markup {
|
||||||
|
|
||||||
|
public static function markdownToHtml(string $md, bool $use_image_previews = true): string {
|
||||||
|
$pd = new MyParsedown(useImagePreviews: $use_image_previews);
|
||||||
|
return $pd->text($md);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function toc(string $md): string {
|
||||||
|
$pd = new MyParsedown([
|
||||||
|
'toc' => [
|
||||||
|
'lowercase' => true,
|
||||||
|
'transliterate' => true,
|
||||||
|
'urlencode' => false,
|
||||||
|
'headings' => ['h1', 'h2', 'h3']
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
$pd->text($md);
|
||||||
|
return $pd->contentsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function htmlToText(string $html): string {
|
||||||
|
$text = html_entity_decode(strip_tags($html));
|
||||||
|
$lines = explode("\n", $text);
|
||||||
|
$lines = array_map('trim', $lines);
|
||||||
|
$text = implode("\n", $lines);
|
||||||
|
$text = preg_replace("/(\r?\n){2,}/", "\n\n", $text);
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function htmlImagesFix(string $html, bool $is_retina, string $user_theme): string {
|
||||||
|
global $config;
|
||||||
|
$is_dark_theme = $user_theme === 'dark';
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/('.preg_quote($config['uploads_host'], '/').'\/\w{8}\/)([ap])(\d+)x(\d+)(\.jpg)/',
|
||||||
|
function($match) use ($is_retina, $is_dark_theme) {
|
||||||
|
$mult = $is_retina ? 2 : 1;
|
||||||
|
$is_alpha = $match[2] == 'a';
|
||||||
|
return $match[1].$match[2].(intval($match[3])*$mult).'x'.(intval($match[4])*$mult).($is_alpha && $is_dark_theme ? '_dark' : '').$match[5];
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
232
classes/model/Model.php
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
13
classes/model/ModelType.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace model;
|
||||||
|
|
||||||
|
enum ModelType {
|
||||||
|
case STRING;
|
||||||
|
case INTEGER;
|
||||||
|
case FLOAT;
|
||||||
|
case ARRAY;
|
||||||
|
case BOOLEAN;
|
||||||
|
case JSON;
|
||||||
|
case SERIALIZED;
|
||||||
|
}
|
48
classes/model/Page.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
199
classes/model/Post.php
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
27
classes/model/Tag.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
168
classes/model/Upload.php
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace model;
|
||||||
|
use 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 'https://'.$config['uploads_host'].'/'.$this->randomId.'/'.$this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string {
|
||||||
|
global $config;
|
||||||
|
if ($w == $this->imageW && $this->imageH == $h)
|
||||||
|
return $this->getDirectUrl();
|
||||||
|
|
||||||
|
if ($retina) {
|
||||||
|
$w *= 2;
|
||||||
|
$h *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p';
|
||||||
|
return 'https://'.$config['uploads_host'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO remove?
|
||||||
|
public function incrementDownloads() {
|
||||||
|
$db = getDb();
|
||||||
|
$db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id);
|
||||||
|
$this->downloads++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSize(): string {
|
||||||
|
return sizeString($this->size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMarkdown(): string {
|
||||||
|
if ($this->isImage()) {
|
||||||
|
$md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.'}{/image}';
|
||||||
|
} else if ($this->isVideo()) {
|
||||||
|
$md = '{video:'.$this->randomId.'}{/video}';
|
||||||
|
} else {
|
||||||
|
$md = '{fileAttach:'.$this->randomId.'}{/fileAttach}';
|
||||||
|
}
|
||||||
|
$md .= ' <!-- '.$this->name.' -->';
|
||||||
|
return $md;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNote(string $note) {
|
||||||
|
$db = getDb();
|
||||||
|
$db->query("UPDATE uploads SET note=? WHERE id=?", $note, $this->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isImage(): bool {
|
||||||
|
return in_array(extension($this->name), self::$ImageExtensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
32
classes/pages.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?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")));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
194
classes/posts.php
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
38
classes/themes.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
147
classes/uploads.php
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<?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('\Upload', $db->fetchAll($q));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(int $id): ?Upload {
|
||||||
|
$db = getDb();
|
||||||
|
$q = $db->query("SELECT * FROM uploads WHERE id=?", $id);
|
||||||
|
if ($db->numRows($q)) {
|
||||||
|
return new Upload($db->fetch($q));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $ids
|
||||||
|
* @param bool $flat
|
||||||
|
* @return Upload[]
|
||||||
|
*/
|
||||||
|
public static function getUploadsByRandomId(array $ids, bool $flat = false): array {
|
||||||
|
if (empty($ids)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = getDb();
|
||||||
|
$uploads = array_fill_keys($ids, null);
|
||||||
|
|
||||||
|
$q = $db->query("SELECT * FROM uploads WHERE random_id IN('".implode('\',\'', array_map([$db, 'escape'], $ids))."')");
|
||||||
|
|
||||||
|
while ($row = $db->fetch($q)) {
|
||||||
|
$uploads[$row['random_id']] = new Upload($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($flat) {
|
||||||
|
$list = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$list[] = $uploads[$id];
|
||||||
|
}
|
||||||
|
unset($uploads);
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uploads;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getByRandomId(string $random_id): ?Upload {
|
||||||
|
$db = getDb();
|
||||||
|
$q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id);
|
||||||
|
if ($db->numRows($q)) {
|
||||||
|
return new Upload($db->fetch($q));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getNewRandomId(): string {
|
||||||
|
$db = getDb();
|
||||||
|
do {
|
||||||
|
$random_id = strgen(8);
|
||||||
|
} while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0);
|
||||||
|
return $random_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
14
classes/util/AnsiColor.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
27
classes/util/AnsiUtil.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?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";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
83
cli_util.php
Executable file
@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace cli_util;
|
||||||
|
|
||||||
|
use cli;
|
||||||
|
use config;
|
||||||
|
use pages;
|
||||||
|
use posts;
|
||||||
|
use uploads;
|
||||||
|
|
||||||
|
require_once __DIR__.'/init.php';
|
||||||
|
|
||||||
|
$cli = new cli(__NAMESPACE__);
|
||||||
|
$cli->run();
|
||||||
|
|
||||||
|
function admin_reset(): void {
|
||||||
|
$pwd1 = cli::silentInput("New password: ");
|
||||||
|
$pwd2 = cli::silentInput("Again: ");
|
||||||
|
|
||||||
|
if ($pwd1 != $pwd2)
|
||||||
|
cli::die("Passwords do not match");
|
||||||
|
|
||||||
|
if (trim($pwd1) == '')
|
||||||
|
cli::die("Password can not be empty");
|
||||||
|
|
||||||
|
if (!config::set('admin_pwd', salt_password($pwd1)))
|
||||||
|
cli::die("Database error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function admin_check(): void {
|
||||||
|
$pwd = config::get('admin_pwd');
|
||||||
|
echo is_null($pwd) ? "Not set" : $pwd;
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function blog_erase(): void {
|
||||||
|
$db = getDb();
|
||||||
|
$tables = ['posts', 'posts_tags', 'tags'];
|
||||||
|
foreach ($tables as $t) {
|
||||||
|
$db->query("TRUNCATE TABLE $t");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tags_recount(): void {
|
||||||
|
$tags = posts::getAllTags(true);
|
||||||
|
foreach ($tags as $tag)
|
||||||
|
posts::recountPostsWithTag($tag->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function posts_html(): void {
|
||||||
|
$kw = ['include_hidden' => true];
|
||||||
|
$posts = posts::getPosts(0, posts::getPostsCount(...$kw), ...$kw);
|
||||||
|
foreach ($posts as $p) {
|
||||||
|
$p->updateHtml();
|
||||||
|
$p->updateText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function posts_images(): void {
|
||||||
|
$kw = ['include_hidden' => true];
|
||||||
|
$posts = posts::getPosts(0, posts::getPostsCount(...$kw), ...$kw);
|
||||||
|
foreach ($posts as $p) {
|
||||||
|
$p->updateImagePreviews(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pages_html(): void {
|
||||||
|
$pages = pages::getAll();
|
||||||
|
foreach ($pages as $p) {
|
||||||
|
$p->updateHtml();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function add_files_to_uploads(): void {
|
||||||
|
$path = cli::input('Enter path: ');
|
||||||
|
if (!file_exists($path))
|
||||||
|
cli::die("file $path doesn't exists");
|
||||||
|
$name = basename($path);
|
||||||
|
$ext = extension($name);
|
||||||
|
$id = uploads::add($path, $name, '');
|
||||||
|
echo "upload id: $id\n";
|
||||||
|
}
|
24
composer.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"gch1p/parsedown-highlight": "master",
|
||||||
|
"gch1p/parsedown-highlight-extended": "dev-main",
|
||||||
|
"erusev/parsedown": "1.8.0-beta-7",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-mysqli": "*",
|
||||||
|
"ext-json": "*"
|
||||||
|
},
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/gch1p/parsedown-highlight",
|
||||||
|
"type": "git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/gch1p/ParsedownHighlightExtended",
|
||||||
|
"type": "git"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minimum-stability": "dev",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"preferred-install": "dist"
|
||||||
|
}
|
308
composer.lock
generated
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "ca8ca355a9f6ce85170f473238a57d6b",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "erusev/parsedown",
|
||||||
|
"version": "1.8.0-beta-7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/erusev/parsedown.git",
|
||||||
|
"reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/erusev/parsedown/zipball/fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955",
|
||||||
|
"reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"php": ">=5.3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8.35"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-0": {
|
||||||
|
"Parsedown": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Emanuil Rusev",
|
||||||
|
"email": "hello@erusev.com",
|
||||||
|
"homepage": "http://erusev.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Parser for Markdown.",
|
||||||
|
"homepage": "http://parsedown.org",
|
||||||
|
"keywords": [
|
||||||
|
"markdown",
|
||||||
|
"parser"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/erusev/parsedown/issues",
|
||||||
|
"source": "https://github.com/erusev/parsedown/tree/1.8.0-beta-7"
|
||||||
|
},
|
||||||
|
"time": "2019-03-17T18:47:21+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "erusev/parsedown-extra",
|
||||||
|
"version": "0.8.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/erusev/parsedown-extra.git",
|
||||||
|
"reference": "91ac3ff98f0cea243bdccc688df43810f044dcef"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef",
|
||||||
|
"reference": "91ac3ff98f0cea243bdccc688df43810f044dcef",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"erusev/parsedown": "^1.7.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8.35"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-0": {
|
||||||
|
"ParsedownExtra": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Emanuil Rusev",
|
||||||
|
"email": "hello@erusev.com",
|
||||||
|
"homepage": "http://erusev.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An extension of Parsedown that adds support for Markdown Extra.",
|
||||||
|
"homepage": "https://github.com/erusev/parsedown-extra",
|
||||||
|
"keywords": [
|
||||||
|
"markdown",
|
||||||
|
"markdown extra",
|
||||||
|
"parsedown",
|
||||||
|
"parser"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/erusev/parsedown-extra/issues",
|
||||||
|
"source": "https://github.com/erusev/parsedown-extra/tree/0.8.x"
|
||||||
|
},
|
||||||
|
"time": "2019-12-30T23:20:37+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gch1p/parsedown-highlight",
|
||||||
|
"version": "dev-master",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/gch1p/parsedown-highlight",
|
||||||
|
"reference": "d017545cc221f4becac0f7fc0570304ceabba846"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"erusev/parsedown": "1.8.0-beta-7",
|
||||||
|
"erusev/parsedown-extra": "0.8.1",
|
||||||
|
"php": ">=7.1",
|
||||||
|
"scrivo/highlight.php": "^9.14"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^2.10",
|
||||||
|
"larapack/dd": "^1.0",
|
||||||
|
"phpunit/phpunit": "^6.0|^7.0",
|
||||||
|
"sempro/phpunit-pretty-print": "^1.0"
|
||||||
|
},
|
||||||
|
"default-branch": true,
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"gch1p\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": [
|
||||||
|
"phpunit"
|
||||||
|
],
|
||||||
|
"styles:lint": [
|
||||||
|
"php-cs-fixer fix --dry-run --diff"
|
||||||
|
],
|
||||||
|
"styles:fix": [
|
||||||
|
"php-cs-fixer fix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "TJ Miller",
|
||||||
|
"email": "oss@tjmiller.co",
|
||||||
|
"homepage": "https://tjmiller.co",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Server side code block rendering for Parsedown",
|
||||||
|
"homepage": "https://github.com/gch1p/parsedown-highlight",
|
||||||
|
"keywords": [
|
||||||
|
"code",
|
||||||
|
"markdown",
|
||||||
|
"parsedown"
|
||||||
|
],
|
||||||
|
"time": "2023-03-01T22:25:56+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gch1p/parsedown-highlight-extended",
|
||||||
|
"version": "dev-main",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/gch1p/ParsedownHighlightExtended",
|
||||||
|
"reference": "e2d9d7eae203680690d61877fbef2dc2df61dd0b"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"erusev/parsedown": "^1.8-beta-6",
|
||||||
|
"erusev/parsedown-extra": "^0.8.0",
|
||||||
|
"gch1p/parsedown-highlight": "master",
|
||||||
|
"php": ">=7.2"
|
||||||
|
},
|
||||||
|
"default-branch": true,
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-0": {
|
||||||
|
"ParsedownExtended": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Benjamin Hoegh",
|
||||||
|
"homepage": "https://github.com/benjaminhoegh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An extension for ParsedownHighlight.",
|
||||||
|
"homepage": "https://github.com/gch1p/ParsedownHighlightExtended",
|
||||||
|
"keywords": [
|
||||||
|
"markdown",
|
||||||
|
"markdown extended",
|
||||||
|
"parsedown",
|
||||||
|
"parsedown extended"
|
||||||
|
],
|
||||||
|
"time": "2023-03-01T22:30:01+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scrivo/highlight.php",
|
||||||
|
"version": "v9.18.1.10",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/scrivo/highlight.php.git",
|
||||||
|
"reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/scrivo/highlight.php/zipball/850f4b44697a2552e892ffe71490ba2733c2fc6e",
|
||||||
|
"reference": "850f4b44697a2552e892ffe71490ba2733c2fc6e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": ">=5.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^4.8|^5.7",
|
||||||
|
"sabberworm/php-css-parser": "^8.3",
|
||||||
|
"symfony/finder": "^2.8|^3.4|^5.4",
|
||||||
|
"symfony/var-dumper": "^2.8|^3.4|^5.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-mbstring": "Allows highlighting code with unicode characters and supports language with unicode keywords"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"HighlightUtilities/functions.php"
|
||||||
|
],
|
||||||
|
"psr-0": {
|
||||||
|
"Highlight\\": "",
|
||||||
|
"HighlightUtilities\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Geert Bergman",
|
||||||
|
"homepage": "http://www.scrivo.org/",
|
||||||
|
"role": "Project Author"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vladimir Jimenez",
|
||||||
|
"homepage": "https://allejo.io",
|
||||||
|
"role": "Maintainer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Martin Folkers",
|
||||||
|
"homepage": "https://twobrain.io",
|
||||||
|
"role": "Contributor"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Server side syntax highlighter that supports 185 languages. It's a PHP port of highlight.js",
|
||||||
|
"keywords": [
|
||||||
|
"code",
|
||||||
|
"highlight",
|
||||||
|
"highlight.js",
|
||||||
|
"highlight.php",
|
||||||
|
"syntax"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/scrivo/highlight.php/issues",
|
||||||
|
"source": "https://github.com/scrivo/highlight.php"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/allejo",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2022-12-17T21:53:22+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "dev",
|
||||||
|
"stability-flags": {
|
||||||
|
"gch1p/parsedown-highlight-extended": 20
|
||||||
|
},
|
||||||
|
"prefer-stable": true,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-mysqli": "*",
|
||||||
|
"ext-json": "*"
|
||||||
|
},
|
||||||
|
"platform-dev": [],
|
||||||
|
"plugin-api-version": "2.3.0"
|
||||||
|
}
|
27
config.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'domain' => 'example.com',
|
||||||
|
'cookie_host' => '.example.com',
|
||||||
|
'admin_email' => 'admin@example.com',
|
||||||
|
|
||||||
|
'db' => [
|
||||||
|
'type' => 'mysql',
|
||||||
|
'host' => '127.0.0.1',
|
||||||
|
'user' => '',
|
||||||
|
'password' => '',
|
||||||
|
'database' => '',
|
||||||
|
],
|
||||||
|
|
||||||
|
'log_file' => '/var/log/example.com-backend.log',
|
||||||
|
|
||||||
|
'password_salt' => '12345',
|
||||||
|
'csrf_token' => '12345',
|
||||||
|
'uploads_dir' => '/home/user/files.example.com',
|
||||||
|
'uploads_host' => 'files.example.com',
|
||||||
|
|
||||||
|
'dirs_mode' => 0775,
|
||||||
|
'files_mode' => 0664,
|
||||||
|
'group' => 33, // id -g www-data
|
||||||
|
'is_dev' => false,
|
||||||
|
];
|
74
deploy/build_common.sh
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
INDIR=
|
||||||
|
OUTDIR=
|
||||||
|
|
||||||
|
error() {
|
||||||
|
>&2 echo "error: $@"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
>&2 echo "warning: $@"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
error "$@"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
local code="$1"
|
||||||
|
cat <<EOF
|
||||||
|
usage: $PROGNAME [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o output directory
|
||||||
|
-i input directory
|
||||||
|
-h show this help
|
||||||
|
EOF
|
||||||
|
exit $code
|
||||||
|
}
|
||||||
|
|
||||||
|
input_args() {
|
||||||
|
[ -z "$1" ] && usage
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-o)
|
||||||
|
OUTDIR="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-i)
|
||||||
|
INDIR="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "unexpected argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_args() {
|
||||||
|
[ -z "$OUTDIR" ] && {
|
||||||
|
error "output directory not specified"
|
||||||
|
usage 1
|
||||||
|
}
|
||||||
|
[ -z "$INDIR" ] && {
|
||||||
|
error "input directory not specified"
|
||||||
|
usage 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -d "$OUTDIR" ]; then
|
||||||
|
mkdir "$OUTDIR"
|
||||||
|
else
|
||||||
|
# warning "$OUTDIR already exists, erasing it"
|
||||||
|
rm "$OUTDIR"/*
|
||||||
|
fi
|
||||||
|
}
|
62
deploy/build_css.sh
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
PROGNAME="$0"
|
||||||
|
DIR="$( cd "$( dirname "$(readlink -f "${BASH_SOURCE[0]}")" )" && pwd )"
|
||||||
|
ROOT="$(realpath "$DIR/../")"
|
||||||
|
CLEANCSS="$ROOT"/node_modules/clean-css-cli/bin/cleancss
|
||||||
|
|
||||||
|
. $DIR/build_common.sh
|
||||||
|
|
||||||
|
build_scss() {
|
||||||
|
local entry_name="$1"
|
||||||
|
local theme="$2"
|
||||||
|
|
||||||
|
local input="$INDIR/entries/$entry_name/$theme.scss"
|
||||||
|
local output="$OUTDIR/$entry_name"
|
||||||
|
[ "$theme" = "dark" ] && output="${output}_dark"
|
||||||
|
output="${output}.css"
|
||||||
|
|
||||||
|
sassc -t compressed "$input" "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleancss() {
|
||||||
|
local entry_name="$1"
|
||||||
|
local theme="$2"
|
||||||
|
|
||||||
|
local file="$OUTDIR/$entry_name"
|
||||||
|
[ "$theme" = "dark" ] && file="${file}_dark"
|
||||||
|
file="${file}.css"
|
||||||
|
|
||||||
|
$CLEANCSS -O2 "all:on;mergeSemantically:on;restructureRules:on" "$file" > "$file.tmp"
|
||||||
|
rm "$file"
|
||||||
|
mv "$file.tmp" "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_dark_patch() {
|
||||||
|
local entry_name="$1"
|
||||||
|
local light_file="$OUTDIR/$entry_name.css"
|
||||||
|
local dark_file="$OUTDIR/${entry_name}_dark.css"
|
||||||
|
|
||||||
|
"$DIR"/gen_css_diff.js "$light_file" "$dark_file" > "$dark_file.diff"
|
||||||
|
rm "$dark_file"
|
||||||
|
mv "$dark_file.diff" "$dark_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
THEMES="light dark"
|
||||||
|
TARGETS="common admin"
|
||||||
|
|
||||||
|
input_args "$@"
|
||||||
|
check_args
|
||||||
|
|
||||||
|
[ -x "$CLEANCSS" ] || die "cleancss is not found"
|
||||||
|
|
||||||
|
for theme in $THEMES; do
|
||||||
|
for target in $TARGETS; do
|
||||||
|
build_scss "$target" "$theme"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
for target in $TARGETS; do
|
||||||
|
create_dark_patch "$target"
|
||||||
|
for theme in $THEMES; do cleancss "$target" "$theme"; done
|
||||||
|
done
|
31
deploy/build_js.sh
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
PROGNAME="$0"
|
||||||
|
DIR="$( cd "$( dirname "$(readlink -f "${BASH_SOURCE[0]}")" )" && pwd )"
|
||||||
|
|
||||||
|
. $DIR/build_common.sh
|
||||||
|
|
||||||
|
# suckless version of webpack
|
||||||
|
# watch and learn, bitches!
|
||||||
|
build_chunk() {
|
||||||
|
local name="$1"
|
||||||
|
local output="$OUTDIR/$name.js"
|
||||||
|
local not_first=0
|
||||||
|
for file in "$INDIR/$name"/*.js; do
|
||||||
|
# insert newline before out comment
|
||||||
|
[ "$not_first" = "1" ] && echo "" >> "$output"
|
||||||
|
echo "/* $(basename "$file") */" >> "$output"
|
||||||
|
|
||||||
|
cat "$file" >> "$output"
|
||||||
|
not_first=1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
TARGETS="common admin"
|
||||||
|
|
||||||
|
input_args "$@"
|
||||||
|
check_args
|
||||||
|
|
||||||
|
for f in $TARGETS; do
|
||||||
|
build_chunk "$f"
|
||||||
|
done
|
57
deploy/deploy.sh
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
die() {
|
||||||
|
>&2 echo "error: $@"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIR="$( cd "$( dirname "$(readlink -f "${BASH_SOURCE[0]}")" )" && pwd )"
|
||||||
|
|
||||||
|
DEV_DIR="$(realpath "$DIR/../")"
|
||||||
|
STAGING_DIR="$HOME/staging"
|
||||||
|
PROD_DIR="$HOME/www"
|
||||||
|
PHP=/usr/bin/php
|
||||||
|
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
[ -d "$STAGING_DIR" ] || mkdir "$STAGING_DIR"
|
||||||
|
pushd "$STAGING_DIR"
|
||||||
|
|
||||||
|
if [ ! -d .git ]; then
|
||||||
|
git init
|
||||||
|
git remote add origin git-hidden@ch1p.io:4in1_ws_web.git
|
||||||
|
git fetch
|
||||||
|
git checkout master
|
||||||
|
fi
|
||||||
|
|
||||||
|
git reset --hard
|
||||||
|
git pull origin master
|
||||||
|
|
||||||
|
composer install --no-dev --optimize-autoloader --ignore-platform-reqs
|
||||||
|
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
npm i
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$DEV_DIR/config-local.php" .
|
||||||
|
sed -i '/is_dev/d' ./config-local.php
|
||||||
|
|
||||||
|
"$DIR"/build_js.sh -i "$DEV_DIR/htdocs/js" -o "$STAGING_DIR/htdocs/dist-js" || die "build_js failed"
|
||||||
|
"$DIR"/build_css.sh -i "$DEV_DIR/htdocs/scss" -o "$STAGING_DIR/htdocs/dist-css" || die "build_css failed"
|
||||||
|
$PHP "$DIR"/gen_static_config.php -i "$STAGING_DIR/htdocs" > "$STAGING_DIR/config-static.php" || die "gen_static_config failed"
|
||||||
|
|
||||||
|
popd
|
||||||
|
|
||||||
|
# copy staging to prod
|
||||||
|
rsync -a --delete --delete-excluded --info=progress2 "$STAGING_DIR/" "$PROD_DIR/" \
|
||||||
|
--exclude .git \
|
||||||
|
--exclude debug.log \
|
||||||
|
--exclude='/composer.*' \
|
||||||
|
--exclude='/htdocs/scss' \
|
||||||
|
--exclude='/htdocs/js' \
|
||||||
|
--exclude='/htdocs/sass.php' \
|
||||||
|
--exclude='/htdocs/js.php' \
|
||||||
|
--exclude='*.sh' \
|
||||||
|
--exclude='*.sql'
|
14
deploy/gen_css_diff.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const {generateCSSPatch} = require('css-patch')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
const files = process.argv.slice(2)
|
||||||
|
if (files.length !== 2) {
|
||||||
|
console.log(`usage: ${process.argv[0]} file1 file2`)
|
||||||
|
process.exit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const css1 = fs.readFileSync(files[0], 'utf-8')
|
||||||
|
const css2 = fs.readFileSync(files[1], 'utf-8')
|
||||||
|
|
||||||
|
console.log(generateCSSPatch(css1, css2))
|
57
deploy/gen_static_config.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/../init.php';
|
||||||
|
|
||||||
|
if ($argc <= 1) {
|
||||||
|
usage();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input_dir = null;
|
||||||
|
|
||||||
|
array_shift($argv);
|
||||||
|
while (count($argv) > 0) {
|
||||||
|
switch ($argv[0]) {
|
||||||
|
case '-i':
|
||||||
|
array_shift($argv);
|
||||||
|
$input_dir = array_shift($argv);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
cli::die('unsupported argument: '.$argv[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_null($input_dir))
|
||||||
|
cli::die("input directory has not been specified");
|
||||||
|
|
||||||
|
$hashes = [];
|
||||||
|
foreach (['css', 'js'] as $type) {
|
||||||
|
$entries = glob_recursive($input_dir.'/dist-'.$type.'/*.'.$type);
|
||||||
|
if (empty($entries)) {
|
||||||
|
cli::error("warning: no files found in $input_dir/dist-$type");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entries as $file)
|
||||||
|
$hashes[$type.'/'.basename($file)] = get_hash($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "<?php\n\n";
|
||||||
|
echo "return ".var_export($hashes, true).";\n";
|
||||||
|
|
||||||
|
function usage(): void {
|
||||||
|
global $argv;
|
||||||
|
echo <<<EOF
|
||||||
|
usage: {$argv[0]} [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-i input htdocs directory
|
||||||
|
|
||||||
|
EOF;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_hash(string $path): string {
|
||||||
|
return substr(sha1(file_get_contents($path)), 0, 8);
|
||||||
|
}
|
BIN
favicon/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
favicon/favicon.png
Normal file
After Width: | Height: | Size: 664 B |
305
functions.php
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function htmlescape(string|array $s): string|array {
|
||||||
|
if (is_array($s)) {
|
||||||
|
foreach ($s as $k => $v) {
|
||||||
|
$s[$k] = htmlescape($v);
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function strtrim(string $str, int $len, bool &$trimmed): string {
|
||||||
|
if (mb_strlen($str) > $len) {
|
||||||
|
$str = mb_substr($str, 0, $len);
|
||||||
|
$trimmed = true;
|
||||||
|
} else {
|
||||||
|
$trimmed = false;
|
||||||
|
}
|
||||||
|
return $str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeString(int $size): string {
|
||||||
|
$ks = array('B', 'KiB', 'MiB', 'GiB');
|
||||||
|
foreach ($ks as $i => $k) {
|
||||||
|
if ($size < pow(1024, $i + 1)) {
|
||||||
|
if ($i == 0)
|
||||||
|
return $size . ' ' . $k;
|
||||||
|
return round($size / pow(1024, $i), 2).' '.$k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extension(string $name): string {
|
||||||
|
$expl = explode('.', $name);
|
||||||
|
return end($expl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $filename
|
||||||
|
* @return resource|bool
|
||||||
|
*/
|
||||||
|
function imageopen(string $filename) {
|
||||||
|
$size = getimagesize($filename);
|
||||||
|
$types = [
|
||||||
|
1 => 'gif',
|
||||||
|
2 => 'jpeg',
|
||||||
|
3 => 'png'
|
||||||
|
];
|
||||||
|
if (!$size || !isset($types[$size[2]]))
|
||||||
|
return null;
|
||||||
|
return call_user_func('imagecreatefrom'.$types[$size[2]], $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detect_image_type(string $filename) {
|
||||||
|
$size = getimagesize($filename);
|
||||||
|
$types = [
|
||||||
|
1 => 'gif',
|
||||||
|
2 => 'jpg',
|
||||||
|
3 => 'png'
|
||||||
|
];
|
||||||
|
if (!$size || !isset($types[$size[2]]))
|
||||||
|
return false;
|
||||||
|
return $types[$size[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transliterate(string $string): string {
|
||||||
|
$roman = array(
|
||||||
|
'Sch', 'sch', 'Yo', 'Zh', 'Kh', 'Ts', 'Ch', 'Sh', 'Yu', 'ya', 'yo',
|
||||||
|
'zh', 'kh', 'ts', 'ch', 'sh', 'yu', 'ya', 'A', 'B', 'V', 'G', 'D', 'E',
|
||||||
|
'Z', 'I', 'Y', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'S', 'T', 'U', 'F',
|
||||||
|
'', 'Y', '', 'E', 'a', 'b', 'v', 'g', 'd', 'e', 'z', 'i', 'y', 'k',
|
||||||
|
'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'f', '', 'y', '', 'e'
|
||||||
|
);
|
||||||
|
$cyrillic = array(
|
||||||
|
'Щ', 'щ', 'Ё', 'Ж', 'Х', 'Ц', 'Ч', 'Ш', 'Ю', 'я', 'ё', 'ж', 'х', 'ц',
|
||||||
|
'ч', 'ш', 'ю', 'я', 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'З', 'И', 'Й', 'К',
|
||||||
|
'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Ь', 'Ы', 'Ъ', 'Э',
|
||||||
|
'а', 'б', 'в', 'г', 'д', 'е', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о',
|
||||||
|
'п', 'р', 'с', 'т', 'у', 'ф', 'ь', 'ы', 'ъ', 'э'
|
||||||
|
);
|
||||||
|
return str_replace($cyrillic, $roman, $string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $img
|
||||||
|
* @param ?int $w
|
||||||
|
* @param ?int $h
|
||||||
|
* @param ?int[] $transparent_color
|
||||||
|
*/
|
||||||
|
function imageresize(&$img, ?int $w = null, ?int $h = null, ?array $transparent_color = null) {
|
||||||
|
assert(is_int($w) || is_int($h));
|
||||||
|
|
||||||
|
$curw = imagesx($img);
|
||||||
|
$curh = imagesy($img);
|
||||||
|
|
||||||
|
if (!is_int($w) && is_int($h)) {
|
||||||
|
$w = round($curw / ($curw / $w));
|
||||||
|
} else if (is_int($w) && !is_int($h)) {
|
||||||
|
$h = round($curh / ($curh / $h));
|
||||||
|
}
|
||||||
|
|
||||||
|
$img2 = imagecreatetruecolor($w, $h);
|
||||||
|
if (is_array($transparent_color)) {
|
||||||
|
list($r, $g, $b) = $transparent_color;
|
||||||
|
$col = imagecolorallocate($img2, $r, $g, $b);
|
||||||
|
imagefilledrectangle($img2, 0, 0, $w, $h, $col);
|
||||||
|
} else {
|
||||||
|
imagealphablending($img2, false);
|
||||||
|
imagesavealpha($img2, true);
|
||||||
|
imagefilledrectangle($img2, 0, 0, $w, $h, imagecolorallocatealpha($img2, 255, 255, 255, 127));
|
||||||
|
}
|
||||||
|
|
||||||
|
imagecopyresampled($img2, $img, 0, 0, 0, 0, $w, $h, $curw, $curh);
|
||||||
|
imagedestroy($img);
|
||||||
|
|
||||||
|
$img = $img2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rrmdir(string $dir, bool $dont_delete_dir = false): bool {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
logError('rrmdir: '.$dir.' is not a directory');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$objects = scandir($dir);
|
||||||
|
foreach ($objects as $object) {
|
||||||
|
if ($object != '.' && $object != '..') {
|
||||||
|
if (is_dir($dir.'/'.$object)) {
|
||||||
|
rrmdir($dir.'/'.$object);
|
||||||
|
} else {
|
||||||
|
unlink($dir.'/'.$object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dont_delete_dir)
|
||||||
|
rmdir($dir);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ip2ulong(string $ip): int {
|
||||||
|
return sprintf("%u", ip2long($ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ulong2ip(int $ip): string {
|
||||||
|
$long = 4294967295 - ($ip - 1);
|
||||||
|
return long2ip(-$long);
|
||||||
|
}
|
||||||
|
|
||||||
|
function from_camel_case(string $s): string {
|
||||||
|
$buf = '';
|
||||||
|
$len = strlen($s);
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
if (!ctype_upper($s[$i])) {
|
||||||
|
$buf .= $s[$i];
|
||||||
|
} else {
|
||||||
|
$buf .= '_'.strtolower($s[$i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function to_camel_case(string $input, string $separator = '_'): string {
|
||||||
|
return lcfirst(str_replace($separator, '', ucwords($input, $separator)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function str_replace_once(string $needle, string $replace, string $haystack) {
|
||||||
|
$pos = strpos($haystack, $needle);
|
||||||
|
if ($pos !== false)
|
||||||
|
$haystack = substr_replace($haystack, $replace, $pos, strlen($needle));
|
||||||
|
return $haystack;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strgen(int $len): string {
|
||||||
|
$buf = '';
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$j = mt_rand(0, 61);
|
||||||
|
if ($j >= 36) {
|
||||||
|
$j += 13;
|
||||||
|
} else if ($j >= 10) {
|
||||||
|
$j += 7;
|
||||||
|
}
|
||||||
|
$buf .= chr(48 + $j);
|
||||||
|
}
|
||||||
|
return $buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize_filename(string $name): string {
|
||||||
|
$name = mb_strtolower($name);
|
||||||
|
$name = transliterate($name);
|
||||||
|
$name = preg_replace('/[^\w\d\-_\s.]/', '', $name);
|
||||||
|
$name = preg_replace('/\s+/', '_', $name);
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glob_escape(string $pattern): string {
|
||||||
|
if (strpos($pattern, '[') !== false || strpos($pattern, ']') !== false) {
|
||||||
|
$placeholder = uniqid();
|
||||||
|
$replaces = array( $placeholder.'[', $placeholder.']', );
|
||||||
|
$pattern = str_replace( array('[', ']', ), $replaces, $pattern);
|
||||||
|
$pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern);
|
||||||
|
}
|
||||||
|
return $pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does not support flag GLOB_BRACE
|
||||||
|
*
|
||||||
|
* @param string $pattern
|
||||||
|
* @param int $flags
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
function glob_recursive(string $pattern, int $flags = 0): array {
|
||||||
|
$files = glob(glob_escape($pattern), $flags);
|
||||||
|
foreach (glob(glob_escape(dirname($pattern)).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
|
||||||
|
$files = array_merge($files, glob_recursive($dir.'/'.basename($pattern), $flags));
|
||||||
|
}
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setperm(string $file): void {
|
||||||
|
global $config;
|
||||||
|
|
||||||
|
// chgrp
|
||||||
|
$gid = filegroup($file);
|
||||||
|
if ($gid != $config['group']) {
|
||||||
|
if (!chgrp($file, $config['group'])) {
|
||||||
|
logError(__FUNCTION__.": chgrp() failed on $file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chmod
|
||||||
|
$perms = fileperms($file);
|
||||||
|
$need_perms = is_dir($file) ? $config['dirs_mode'] : $config['files_mode'];
|
||||||
|
if (($perms & $need_perms) !== $need_perms) {
|
||||||
|
if (!chmod($file, $need_perms)) {
|
||||||
|
logError(__FUNCTION__.": chmod() failed on $file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function salt_password(string $pwd): string {
|
||||||
|
global $config;
|
||||||
|
return hash('sha256', "{$pwd}|{$config['password_salt']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
function exectime(?string $format = null) {
|
||||||
|
$time = round(microtime(true) - START_TIME, 4);
|
||||||
|
if (!is_null($format))
|
||||||
|
$time = sprintf($format, $time);
|
||||||
|
return $time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullURL(string $url): string {
|
||||||
|
global $config;
|
||||||
|
return 'https://'.$config['domain'].$url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDb(): \database\SQLiteConnection|\database\MySQLConnection|null {
|
||||||
|
global $config;
|
||||||
|
static $link = null;
|
||||||
|
|
||||||
|
if (!is_null($link))
|
||||||
|
return $link;
|
||||||
|
|
||||||
|
switch ($config['db']['type']) {
|
||||||
|
case 'mysql':
|
||||||
|
$link = new \database\MySQLConnection(
|
||||||
|
$config['db']['host'],
|
||||||
|
$config['db']['user'],
|
||||||
|
$config['db']['password'],
|
||||||
|
$config['db']['database']);
|
||||||
|
if (!$link->connect()) {
|
||||||
|
if (PHP_SAPI != 'cli') {
|
||||||
|
header('HTTP/1.1 503 Service Temporarily Unavailable');
|
||||||
|
header('Status: 503 Service Temporarily Unavailable');
|
||||||
|
header('Retry-After: 300');
|
||||||
|
die('database connection failed');
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, 'database connection failed');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'sqlite':
|
||||||
|
$link = new \database\SQLiteConnection($config['db']['path']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logError('invalid database type');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $link;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function logDebug(...$args): void { logging::logCustom(LogLevel::DEBUG, ...$args); }
|
||||||
|
function logInfo(...$args): void { logging::logCustom(LogLevel::INFO, ...$args); }
|
||||||
|
function logWarning(...$args): void { logging::logCustom(LogLevel::WARNING, ...$args); }
|
||||||
|
function logError(...$args): void { logging::logCustom(LogLevel::ERROR, ...$args); }
|
BIN
htdocs/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
htdocs/favicon.png
Normal file
After Width: | Height: | Size: 664 B |
1
htdocs/img/attachment.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="680" viewBox="-25 0 510 510.257" width="680"><path d="M32.39 314.484l37.74-37.738 169.815-169.808c31.297-31.063 81.817-30.97 112.997.21 31.18 31.18 31.273 81.7.21 112.997L183.345 390.004c-6.945 6.95-18.211 6.95-25.16 0-6.946-6.95-6.946-18.211 0-25.16l169.808-169.86c17.368-17.367 17.368-45.52 0-62.886-17.367-17.368-45.52-17.368-62.886 0L95.246 301.906l-37.738 37.735c-31.266 31.277-31.254 81.976.02 113.238 31.277 31.262 81.972 31.254 113.238-.023l31.441-31.454L378.31 245.301l12.582-12.578c43.976-45.352 43.418-117.602-1.25-162.274-44.672-44.668-116.922-45.226-162.274-1.25L38.687 257.883a17.797 17.797 0 0 1-29.765-7.977 17.796 17.796 0 0 1 4.606-17.183l188.68-188.68c59.09-58.82 154.64-58.711 213.593.246 58.957 58.953 59.066 154.504.246 213.594l-188.68 188.68-31.488 31.453c-45.41 43.617-117.375 42.89-161.89-1.641-44.52-44.527-45.227-116.492-1.598-161.89zm0 0" fill="#888"/></svg>
|
After Width: | Height: | Size: 934 B |
BIN
htdocs/img/contact.gif
Normal file
After Width: | Height: | Size: 164 B |
BIN
htdocs/img/contact@2x.gif
Normal file
After Width: | Height: | Size: 386 B |
26
htdocs/index.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__.'/../init.php';
|
||||||
|
|
||||||
|
$r = (new Router())
|
||||||
|
// route handler input
|
||||||
|
// ----- ------- -----
|
||||||
|
->add('/', 'index')
|
||||||
|
//->add('about/', 'about')
|
||||||
|
->add('([a-zA-Z0-9\-]+)/', 'auto name=$(1)')
|
||||||
|
|
||||||
|
->add('feed.rss', 'RSS')
|
||||||
|
|
||||||
|
->add('admin/', 'admin/index')
|
||||||
|
->add('admin/{login,logout,log}/', 'admin/${1}')
|
||||||
|
|
||||||
|
->add('([a-zA-Z0-9\-]+)/{delete,edit}/', 'admin/auto_${1} short_name=$(1)')
|
||||||
|
->add('([a-zA-Z0-9\-]+)/create/', 'admin/page_add short_name=$(1)')
|
||||||
|
->add('write/', 'admin/post_add')
|
||||||
|
->add('admin/markdown-preview.ajax', 'admin/markdown_preview')
|
||||||
|
|
||||||
|
->add('uploads/', 'admin/uploads')
|
||||||
|
->add('uploads/{edit_note,delete}/(\d+)/','admin/upload_${1} id=$(1)')
|
||||||
|
;
|
||||||
|
|
||||||
|
(new RequestDispatcher($r))->dispatch();
|
31
htdocs/js.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/../init.php';
|
||||||
|
global $config;
|
||||||
|
|
||||||
|
$name = $_REQUEST['name'] ?? '';
|
||||||
|
|
||||||
|
if (!$config['is_dev'] || !$name || !is_dir($path = ROOT.'/htdocs/js/'.$name)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/javascript');
|
||||||
|
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
header("Cache-Control: post-check=0, pre-check=0", false);
|
||||||
|
header("Pragma: no-cache");
|
||||||
|
|
||||||
|
$files = scandir($path, SCANDIR_SORT_ASCENDING);
|
||||||
|
$first = true;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($file == '.' || $file == '..')
|
||||||
|
continue;
|
||||||
|
// logDebug(__FILE__.': reading '.$path.'/'.$file);
|
||||||
|
if (!$first)
|
||||||
|
echo "\n";
|
||||||
|
else
|
||||||
|
$first = false;
|
||||||
|
echo "/* $file */\n";
|
||||||
|
if (readfile($path.'/'.$file) === false)
|
||||||
|
logError(__FILE__.': failed to readfile('.$path.'/'.$file.')');
|
||||||
|
}
|
1
htdocs/js/admin/00-common.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
var LS = window.localStorage;
|
29
htdocs/js/admin/10-draft.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
var Draft = {
|
||||||
|
get: function() {
|
||||||
|
if (!LS) return null;
|
||||||
|
|
||||||
|
var title = LS.getItem('draft_title') || null;
|
||||||
|
var text = LS.getItem('draft_text') || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
text: text
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setTitle: function(text) {
|
||||||
|
if (!LS) return null;
|
||||||
|
LS.setItem('draft_title', text);
|
||||||
|
},
|
||||||
|
|
||||||
|
setText: function(text) {
|
||||||
|
if (!LS) return null;
|
||||||
|
LS.setItem('draft_text', text);
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: function() {
|
||||||
|
if (!LS) return;
|
||||||
|
LS.removeItem('draft_title');
|
||||||
|
LS.removeItem('draft_text');
|
||||||
|
}
|
||||||
|
};
|
142
htdocs/js/admin/11-write-form.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
var AdminWriteForm = {
|
||||||
|
form: null,
|
||||||
|
previewTimeout: null,
|
||||||
|
previewRequest: null,
|
||||||
|
|
||||||
|
init: function(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
this.opts = opts;
|
||||||
|
this.form = document.forms[opts.pages ? 'pageForm' : 'postForm'];
|
||||||
|
this.isFixed = false;
|
||||||
|
|
||||||
|
addEvent(this.form, 'submit', this.onSubmit);
|
||||||
|
if (!opts.pages)
|
||||||
|
addEvent(this.form.title, 'input', this.onInput);
|
||||||
|
|
||||||
|
addEvent(this.form.text, 'input', this.onInput);
|
||||||
|
addEvent(ge('toggle_wrap'), 'click', this.onToggleWrapClick);
|
||||||
|
|
||||||
|
if (this.form.text.value !== '')
|
||||||
|
this.showPreview();
|
||||||
|
|
||||||
|
// TODO make it more clever and context-aware
|
||||||
|
/*var draft = Draft.get();
|
||||||
|
if (draft.title)
|
||||||
|
this.form.title.value = draft.title;
|
||||||
|
if (draft.text)
|
||||||
|
this.form.text.value = draft.text;*/
|
||||||
|
|
||||||
|
addEvent(window, 'scroll', this.onScroll);
|
||||||
|
addEvent(window, 'resize', this.onResize);
|
||||||
|
},
|
||||||
|
|
||||||
|
showPreview: function() {
|
||||||
|
if (this.previewRequest !== null) {
|
||||||
|
this.previewRequest.abort();
|
||||||
|
}
|
||||||
|
this.previewRequest = ajax.post('/admin/markdown-preview.ajax', {
|
||||||
|
title: this.form.elements.title.value,
|
||||||
|
md: this.form.elements.text.value,
|
||||||
|
use_image_previews: this.opts.pages ? 1 : 0
|
||||||
|
}, function(err, response) {
|
||||||
|
if (err)
|
||||||
|
return console.error(err);
|
||||||
|
ge('preview_html').innerHTML = response.html;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onSubmit: function(event) {
|
||||||
|
try {
|
||||||
|
var fields = ['title', 'text'];
|
||||||
|
if (!this.opts.pages)
|
||||||
|
fields.push('tags');
|
||||||
|
if (this.opts.edit) {
|
||||||
|
fields.push('new_short_name');
|
||||||
|
} else {
|
||||||
|
fields.push('short_name');
|
||||||
|
}
|
||||||
|
for (var i = 0; i < fields.length; i++) {
|
||||||
|
var field = fields[i];
|
||||||
|
if (event.target.elements[field].value.trim() === '')
|
||||||
|
throw 'no_'+field
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft.reset();
|
||||||
|
} catch (e) {
|
||||||
|
var error = typeof e == 'string' ? lang((this.opts.pages ? 'err_pages_' : 'err_blog_')+e) : e.message;
|
||||||
|
alert(error);
|
||||||
|
console.error(e);
|
||||||
|
return cancelEvent(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onToggleWrapClick: function(e) {
|
||||||
|
var textarea = this.form.elements.text;
|
||||||
|
if (!hasClass(textarea, 'nowrap')) {
|
||||||
|
addClass(textarea, 'nowrap');
|
||||||
|
} else {
|
||||||
|
removeClass(textarea, 'nowrap');
|
||||||
|
}
|
||||||
|
return cancelEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
onInput: function(e) {
|
||||||
|
if (this.previewTimeout !== null) {
|
||||||
|
clearTimeout(this.previewTimeout);
|
||||||
|
}
|
||||||
|
this.previewTimeout = setTimeout(function() {
|
||||||
|
this.previewTimeout = null;
|
||||||
|
this.showPreview();
|
||||||
|
|
||||||
|
// Draft[e.target.name === 'title' ? 'setTitle' : 'setText'](e.target.value);
|
||||||
|
}.bind(this), 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
onScroll: function() {
|
||||||
|
var ANCHOR_TOP = 10;
|
||||||
|
|
||||||
|
var y = window.pageYOffset;
|
||||||
|
var form = this.form;
|
||||||
|
var td = ge('form_first_cell');
|
||||||
|
var ph = ge('form_placeholder');
|
||||||
|
|
||||||
|
var rect = td.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (rect.top <= ANCHOR_TOP && !this.isFixed) {
|
||||||
|
ph.style.height = form.getBoundingClientRect().height+'px';
|
||||||
|
|
||||||
|
var w = (rect.width - (parseInt(getComputedStyle(td).paddingRight, 10) || 0));
|
||||||
|
form.style.display = 'block';
|
||||||
|
form.style.width = w+'px';
|
||||||
|
form.style.position = 'fixed';
|
||||||
|
form.style.top = ANCHOR_TOP+'px';
|
||||||
|
|
||||||
|
this.isFixed = true;
|
||||||
|
} else if (rect.top > ANCHOR_TOP && this.isFixed) {
|
||||||
|
form.style.display = '';
|
||||||
|
form.style.width = '';
|
||||||
|
form.style.position = '';
|
||||||
|
form.style.position = '';
|
||||||
|
ph.style.height = '';
|
||||||
|
|
||||||
|
this.isFixed = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onResize: function() {
|
||||||
|
if (this.isFixed) {
|
||||||
|
var form = this.form;
|
||||||
|
var td = ge('form_first_cell');
|
||||||
|
var ph = ge('form_placeholder');
|
||||||
|
|
||||||
|
var rect = td.getBoundingClientRect();
|
||||||
|
var pr = parseInt(getComputedStyle(td).paddingRight, 10) || 0;
|
||||||
|
|
||||||
|
ph.style.height = form.getBoundingClientRect().height+'px';
|
||||||
|
form.style.width = (rect.width - pr) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bindEventHandlers(AdminWriteForm);
|
19
htdocs/js/admin/12-upload-list.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
var BlogUploadList = {
|
||||||
|
submitNoteEdit: function(action, note) {
|
||||||
|
if (note === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.setAttribute('method', 'post');
|
||||||
|
form.setAttribute('action', action);
|
||||||
|
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.setAttribute('type', 'hidden');
|
||||||
|
input.setAttribute('name', 'note');
|
||||||
|
input.setAttribute('value', note);
|
||||||
|
|
||||||
|
form.appendChild(input);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
};
|
47
htdocs/js/common/00-polyfills.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
if (!String.prototype.startsWith) {
|
||||||
|
String.prototype.startsWith = function(search, pos) {
|
||||||
|
pos = !pos || pos < 0 ? 0 : +pos;
|
||||||
|
return this.substring(pos, pos + search.length) === search;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String.prototype.endsWith) {
|
||||||
|
String.prototype.endsWith = function(search, this_len) {
|
||||||
|
if (this_len === undefined || this_len > this.length) {
|
||||||
|
this_len = this.length;
|
||||||
|
}
|
||||||
|
return this.substring(this_len - search.length, this_len) === search;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.assign) {
|
||||||
|
Object.defineProperty(Object, 'assign', {
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: function(target, firstSource) {
|
||||||
|
'use strict';
|
||||||
|
if (target === undefined || target === null) {
|
||||||
|
throw new TypeError('Cannot convert first argument to object');
|
||||||
|
}
|
||||||
|
|
||||||
|
var to = Object(target);
|
||||||
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
|
var nextSource = arguments[i];
|
||||||
|
if (nextSource === undefined || nextSource === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keysArray = Object.keys(Object(nextSource));
|
||||||
|
for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) {
|
||||||
|
var nextKey = keysArray[nextIndex];
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey);
|
||||||
|
if (desc !== undefined && desc.enumerable) {
|
||||||
|
to[nextKey] = nextSource[nextKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
118
htdocs/js/common/02-ajax.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// AJAX
|
||||||
|
//
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
var defaultOpts = {
|
||||||
|
json: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function createXMLHttpRequest() {
|
||||||
|
if (window.XMLHttpRequest) {
|
||||||
|
return new XMLHttpRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var xhr;
|
||||||
|
try {
|
||||||
|
xhr = new ActiveXObject('Msxml2.XMLHTTP');
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
xhr = new ActiveXObject('Microsoft.XMLHTTP');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (!xhr) {
|
||||||
|
console.error('Your browser doesn\'t support XMLHttpRequest.');
|
||||||
|
}
|
||||||
|
return xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(method, url, data, optarg1, optarg2) {
|
||||||
|
data = data || null;
|
||||||
|
|
||||||
|
var opts, callback;
|
||||||
|
if (optarg2 !== undefined) {
|
||||||
|
opts = optarg1;
|
||||||
|
callback = optarg2;
|
||||||
|
} else {
|
||||||
|
callback = optarg1;
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = opts || {};
|
||||||
|
|
||||||
|
if (typeof callback != 'function') {
|
||||||
|
throw new Error('callback must be a function');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('no url specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'GET':
|
||||||
|
if (isObject(data)) {
|
||||||
|
for (var k in data) {
|
||||||
|
if (data.hasOwnProperty(k)) {
|
||||||
|
url += (url.indexOf('?') == -1 ? '?' : '&')+encodeURIComponent(k)+'='+encodeURIComponent(data[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if (isObject(data)) {
|
||||||
|
var sdata = [];
|
||||||
|
for (var k in data) {
|
||||||
|
if (data.hasOwnProperty(k)) {
|
||||||
|
sdata.push(encodeURIComponent(k)+'='+encodeURIComponent(data[k]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = sdata.join('&');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = Object.assign({}, defaultOpts, opts);
|
||||||
|
|
||||||
|
var xhr = createXMLHttpRequest();
|
||||||
|
xhr.open(method, url);
|
||||||
|
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
if (method == 'POST') {
|
||||||
|
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState == 4) {
|
||||||
|
if ('status' in xhr && !/^2|1223/.test(xhr.status)) {
|
||||||
|
throw new Error('http code '+xhr.status)
|
||||||
|
}
|
||||||
|
if (opts.json) {
|
||||||
|
var resp = JSON.parse(xhr.responseText)
|
||||||
|
if (!isObject(resp)) {
|
||||||
|
throw new Error('ajax: object expected')
|
||||||
|
}
|
||||||
|
if (resp.error) {
|
||||||
|
throw new Error(resp.error)
|
||||||
|
}
|
||||||
|
callback(null, resp.response);
|
||||||
|
} else {
|
||||||
|
callback(null, xhr.responseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function(e) {
|
||||||
|
callback(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(method == 'GET' ? null : data);
|
||||||
|
|
||||||
|
return xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ajax = {
|
||||||
|
get: request.bind(request, 'GET'),
|
||||||
|
post: request.bind(request, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
117
htdocs/js/common/03-dom.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
//
|
||||||
|
// DOM helpers
|
||||||
|
//
|
||||||
|
function ge(id) {
|
||||||
|
return document.getElementById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClass(el, name) {
|
||||||
|
return el && el.nodeType === 1 && (" " + el.className + " ").replace(/[\t\r\n\f]/g, " ").indexOf(" " + name + " ") >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function addClass(el, name) {
|
||||||
|
if (!el) {
|
||||||
|
return console.warn('addClass: el is', el)
|
||||||
|
}
|
||||||
|
if (!hasClass(el, name)) {
|
||||||
|
el.className = (el.className ? el.className + ' ' : '') + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClass(el, name) {
|
||||||
|
if (!el) {
|
||||||
|
return console.warn('removeClass: el is', el)
|
||||||
|
}
|
||||||
|
if (isArray(name)) {
|
||||||
|
for (var i = 0; i < name.length; i++) {
|
||||||
|
removeClass(el, name[i]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.className = ((el.className || '').replace((new RegExp('(\\s|^)' + name + '(\\s|$)')), ' ')).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent(el, type, f, useCapture) {
|
||||||
|
if (!el) {
|
||||||
|
return console.warn('addEvent: el is', el, stackTrace())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArray(type)) {
|
||||||
|
for (var i = 0; i < type.length; i++) {
|
||||||
|
addEvent(el, type[i], f, useCapture);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.addEventListener) {
|
||||||
|
el.addEventListener(type, f, useCapture || false);
|
||||||
|
return true;
|
||||||
|
} else if (el.attachEvent) {
|
||||||
|
return el.attachEvent('on' + type, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEvent(el, type, f, useCapture) {
|
||||||
|
if (isArray(type)) {
|
||||||
|
for (var i = 0; i < type.length; i++) {
|
||||||
|
var t = type[i];
|
||||||
|
removeEvent(el, type[i], f, useCapture);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.removeEventListener) {
|
||||||
|
el.removeEventListener(type, f, useCapture || false);
|
||||||
|
} else if (el.detachEvent) {
|
||||||
|
return el.detachEvent('on' + type, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEvent(evt) {
|
||||||
|
if (!evt) {
|
||||||
|
return console.warn('cancelEvent: event is', evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.preventDefault) evt.preventDefault();
|
||||||
|
if (evt.stopPropagation) evt.stopPropagation();
|
||||||
|
|
||||||
|
evt.cancelBubble = true;
|
||||||
|
evt.returnValue = false;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
// Cookies
|
||||||
|
//
|
||||||
|
function setCookie(name, value, days) {
|
||||||
|
var expires = "";
|
||||||
|
if (days) {
|
||||||
|
var date = new Date();
|
||||||
|
date.setTime(date.getTime() + (days*24*60*60*1000));
|
||||||
|
expires = "; expires=" + date.toUTCString();
|
||||||
|
}
|
||||||
|
document.cookie = name + "=" + (value || "") + expires + "; domain=" + window.appConfig.cookieHost + "; path=/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsetCookie(name) {
|
||||||
|
document.cookie = name + '=; Max-Age=-99999999; domain=' + window.appConfig.cookieHost + "; path=/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
var nameEQ = name + "=";
|
||||||
|
var ca = document.cookie.split(';');
|
||||||
|
for (var i = 0; i < ca.length; i++) {
|
||||||
|
var c = ca[i];
|
||||||
|
while (c.charAt(0) === ' ')
|
||||||
|
c = c.substring(1, c.length);
|
||||||
|
if (c.indexOf(nameEQ) === 0)
|
||||||
|
return c.substring(nameEQ.length, c.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
89
htdocs/js/common/04-util.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
function bindEventHandlers(obj) {
|
||||||
|
for (var k in obj) {
|
||||||
|
if (obj.hasOwnProperty(k)
|
||||||
|
&& typeof obj[k] == 'function'
|
||||||
|
&& k.length > 2
|
||||||
|
&& k.startsWith('on')
|
||||||
|
&& k[2].charCodeAt(0) >= 65
|
||||||
|
&& k[2].charCodeAt(0) <= 90) {
|
||||||
|
obj[k] = obj[k].bind(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(o) {
|
||||||
|
return Object.prototype.toString.call(o) === '[object Object]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArray(a) {
|
||||||
|
return Object.prototype.toString.call(a) === '[object Array]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extend(dst, src) {
|
||||||
|
if (!isObject(dst)) {
|
||||||
|
return console.error('extend: dst is not an object');
|
||||||
|
}
|
||||||
|
if (!isObject(src)) {
|
||||||
|
return console.error('extend: src is not an object');
|
||||||
|
}
|
||||||
|
for (var key in src) {
|
||||||
|
dst[key] = src[key];
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp() {
|
||||||
|
return Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackTrace(split) {
|
||||||
|
if (split === undefined) {
|
||||||
|
split = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
o.lo.lo += 0;
|
||||||
|
} catch(e) {
|
||||||
|
if (e.stack) {
|
||||||
|
var stack = split ? e.stack.split('\n') : e.stack;
|
||||||
|
stack.shift();
|
||||||
|
stack.shift();
|
||||||
|
return stack.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(str) {
|
||||||
|
var pre = document.createElement('pre');
|
||||||
|
var text = document.createTextNode(str);
|
||||||
|
pre.appendChild(text);
|
||||||
|
return pre.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUrl(uri) {
|
||||||
|
var parser = document.createElement('a');
|
||||||
|
parser.href = uri;
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: parser.protocol, // => "http:"
|
||||||
|
host: parser.host, // => "example.com:3000"
|
||||||
|
hostname: parser.hostname, // => "example.com"
|
||||||
|
port: parser.port, // => "3000"
|
||||||
|
pathname: parser.pathname, // => "/pathname/"
|
||||||
|
hash: parser.hash, // => "#hash"
|
||||||
|
search: parser.search, // => "?search=test"
|
||||||
|
origin: parser.origin, // => "http://example.com:3000"
|
||||||
|
path: (parser.pathname || '') + (parser.search || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function once(fn, context) {
|
||||||
|
var result;
|
||||||
|
return function() {
|
||||||
|
if (fn) {
|
||||||
|
result = fn.apply(context || this, arguments);
|
||||||
|
fn = null;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
5
htdocs/js/common/10-lang.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
function lang(key) {
|
||||||
|
return __lang[key] !== undefined ? __lang[key] : '{'+key+'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__lang = {};
|
60
htdocs/js/common/20-dynlogo.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
var DynamicLogo = {
|
||||||
|
dynLink: null,
|
||||||
|
afr: null,
|
||||||
|
afrUrl: null,
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this.dynLink = ge('head_dyn_link');
|
||||||
|
this.cdText = ge('head_cd_text');
|
||||||
|
|
||||||
|
if (!this.dynLink) {
|
||||||
|
return console.warn('DynamicLogo.init: !this.dynLink');
|
||||||
|
}
|
||||||
|
|
||||||
|
var spans = this.dynLink.querySelectorAll('span.head-logo-path');
|
||||||
|
for (var i = 0; i < spans.length; i++) {
|
||||||
|
var span = spans[i];
|
||||||
|
addEvent(span, 'mouseover', this.onSpanOver);
|
||||||
|
addEvent(span, 'mouseout', this.onSpanOut);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setUrl: function(url) {
|
||||||
|
if (this.afr !== null) {
|
||||||
|
cancelAnimationFrame(this.afr);
|
||||||
|
}
|
||||||
|
this.afrUrl = url;
|
||||||
|
this.afr = requestAnimationFrame(this.onAnimationFrame);
|
||||||
|
},
|
||||||
|
|
||||||
|
onAnimationFrame: function() {
|
||||||
|
var url = this.afrUrl;
|
||||||
|
|
||||||
|
// update link
|
||||||
|
this.dynLink.setAttribute('href', url);
|
||||||
|
|
||||||
|
// update console text
|
||||||
|
if (this.afrUrl === '/') {
|
||||||
|
url = '~';
|
||||||
|
} else {
|
||||||
|
url = '~'+url.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
this.cdText.innerHTML = escape(url);
|
||||||
|
|
||||||
|
this.afr = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
onSpanOver: function() {
|
||||||
|
var span = event.target;
|
||||||
|
this.setUrl(span.getAttribute('data-url'));
|
||||||
|
cancelEvent(event);
|
||||||
|
},
|
||||||
|
|
||||||
|
onSpanOut: function() {
|
||||||
|
var span = event.target;
|
||||||
|
this.setUrl('/');
|
||||||
|
cancelEvent(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bindEventHandlers(DynamicLogo);
|
48
htdocs/js/common/30-static-manager.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
var StaticManager = {
|
||||||
|
/**
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
loadedStyles: [],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {object}
|
||||||
|
*/
|
||||||
|
versions: {},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} loadedStyles
|
||||||
|
* @param {object} versions
|
||||||
|
*/
|
||||||
|
init: function(loadedStyles, versions) {
|
||||||
|
this.loadedStyles = loadedStyles;
|
||||||
|
this.versions = versions;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} theme
|
||||||
|
* @param {function} callback
|
||||||
|
*/
|
||||||
|
loadStyle: function(name, theme, callback) {
|
||||||
|
var url, id;
|
||||||
|
if (!window.appConfig.devMode) {
|
||||||
|
if (theme === 'dark')
|
||||||
|
name += '_dark';
|
||||||
|
url = '/dist-css/'+name+'.css?'+this.versions.css[name];
|
||||||
|
id = 'style_'+name;
|
||||||
|
} else {
|
||||||
|
url = '/sass.php?name='+name+'&theme='+theme+'&v='+timestamp();
|
||||||
|
id = 'style_'+name+(theme === 'dark' ? '_dark' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
var el = document.createElement('link');
|
||||||
|
el.onerror = callback;
|
||||||
|
el.onload = callback;
|
||||||
|
el.setAttribute('rel', 'stylesheet');
|
||||||
|
el.setAttribute('type', 'text/css');
|
||||||
|
el.setAttribute('id', id);
|
||||||
|
el.setAttribute('href', url);
|
||||||
|
|
||||||
|
document.getElementsByTagName('head')[0].appendChild(el);
|
||||||
|
}
|
||||||
|
};
|
214
htdocs/js/common/35-theme-switcher.js
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
var ThemeSwitcher = (function() {
|
||||||
|
/**
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
var modes = ['auto', 'dark', 'light'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
var currentModeIndex = -1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {boolean|null}
|
||||||
|
*/
|
||||||
|
var systemState = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {function[]}
|
||||||
|
*/
|
||||||
|
var changeListeners = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isSystemModeSupported() {
|
||||||
|
try {
|
||||||
|
// crashes on:
|
||||||
|
// Mozilla/5.0 (Windows NT 6.2; ARM; Trident/7.0; Touch; rv:11.0; WPDesktop; Lumia 630 Dual SIM) like Gecko
|
||||||
|
// Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1
|
||||||
|
// Mozilla/5.0 (iPad; CPU OS 12_5_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1
|
||||||
|
//
|
||||||
|
// error examples:
|
||||||
|
// - window.matchMedia("(prefers-color-scheme: dark)").addEventListener is not a function. (In 'window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",this.onSystemSettingChange.bind(this))', 'window.matchMedia("(prefers-color-scheme: dark)").addEventListener' is undefined)
|
||||||
|
// - Object [object MediaQueryList] has no method 'addEventListener'
|
||||||
|
return !!window['matchMedia']
|
||||||
|
&& typeof window.matchMedia("(prefers-color-scheme: dark)").addEventListener === 'function';
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isDarkModeApplied() {
|
||||||
|
var st = StaticManager.loadedStyles;
|
||||||
|
for (var i = 0; i < st.length; i++) {
|
||||||
|
var name = st[i];
|
||||||
|
if (ge('style_'+name+'_dark'))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getSavedMode() {
|
||||||
|
var val = getCookie('theme');
|
||||||
|
if (!val)
|
||||||
|
return modes[0];
|
||||||
|
if (modes.indexOf(val) === -1) {
|
||||||
|
console.error('[ThemeSwitcher getSavedMode] invalid cookie value')
|
||||||
|
unsetCookie('theme')
|
||||||
|
return modes[0]
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} dark
|
||||||
|
*/
|
||||||
|
function changeTheme(dark) {
|
||||||
|
addClass(document.body, 'theme-changing');
|
||||||
|
|
||||||
|
var onDone = function() {
|
||||||
|
window.requestAnimationFrame(function() {
|
||||||
|
removeClass(document.body, 'theme-changing');
|
||||||
|
changeListeners.forEach(function(f) {
|
||||||
|
try {
|
||||||
|
f(dark)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ThemeSwitcher->changeTheme->onDone] error while calling user callback:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
window.requestAnimationFrame(function() {
|
||||||
|
if (dark)
|
||||||
|
enableDark(onDone);
|
||||||
|
else
|
||||||
|
disableDark(onDone);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {function} callback
|
||||||
|
*/
|
||||||
|
function enableDark(callback) {
|
||||||
|
var names = [];
|
||||||
|
StaticManager.loadedStyles.forEach(function(name) {
|
||||||
|
var el = ge('style_'+name+'_dark');
|
||||||
|
if (el)
|
||||||
|
return;
|
||||||
|
names.push(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
var left = names.length;
|
||||||
|
names.forEach(function(name) {
|
||||||
|
StaticManager.loadStyle(name, 'dark', once(function(e) {
|
||||||
|
left--;
|
||||||
|
if (left === 0)
|
||||||
|
callback();
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {function} callback
|
||||||
|
*/
|
||||||
|
function disableDark(callback) {
|
||||||
|
StaticManager.loadedStyles.forEach(function(name) {
|
||||||
|
var el = ge('style_'+name+'_dark');
|
||||||
|
if (el)
|
||||||
|
el.remove();
|
||||||
|
})
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} mode
|
||||||
|
*/
|
||||||
|
function setLabel(mode) {
|
||||||
|
var labelEl = ge('theme-switcher-label');
|
||||||
|
labelEl.innerHTML = escape(lang('theme_'+mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: function() {
|
||||||
|
var cur = getSavedMode();
|
||||||
|
currentModeIndex = modes.indexOf(cur);
|
||||||
|
|
||||||
|
var systemSupported = isSystemModeSupported();
|
||||||
|
if (!systemSupported) {
|
||||||
|
if (currentModeIndex === 0) {
|
||||||
|
modes.shift(); // remove 'auto' from the list
|
||||||
|
currentModeIndex = 1; // set to 'light'
|
||||||
|
if (isDarkModeApplied())
|
||||||
|
disableDark();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* @param {boolean} dark
|
||||||
|
*/
|
||||||
|
var onSystemChange = function(dark) {
|
||||||
|
var prevSystemState = systemState;
|
||||||
|
systemState = dark;
|
||||||
|
|
||||||
|
if (modes[currentModeIndex] !== 'auto')
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (systemState !== prevSystemState)
|
||||||
|
changeTheme(systemState);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
|
onSystemChange(e.matches === true)
|
||||||
|
});
|
||||||
|
|
||||||
|
onSystemChange(window.matchMedia('(prefers-color-scheme: dark)').matches === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLabel(modes[currentModeIndex]);
|
||||||
|
},
|
||||||
|
|
||||||
|
next: function(e) {
|
||||||
|
if (hasClass(document.body, 'theme-changing')) {
|
||||||
|
console.log('next: theme changing is in progress, ignoring...')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentModeIndex = (currentModeIndex + 1) % modes.length;
|
||||||
|
switch (modes[currentModeIndex]) {
|
||||||
|
case 'auto':
|
||||||
|
if (systemState !== null && systemState !== isDarkModeApplied())
|
||||||
|
changeTheme(systemState);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'light':
|
||||||
|
if (isDarkModeApplied())
|
||||||
|
changeTheme(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'dark':
|
||||||
|
if (!isDarkModeApplied())
|
||||||
|
changeTheme(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLabel(modes[currentModeIndex]);
|
||||||
|
setCookie('theme', modes[currentModeIndex]);
|
||||||
|
|
||||||
|
return cancelEvent(e);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {function} f
|
||||||
|
*/
|
||||||
|
addOnChangeListener: function(f) {
|
||||||
|
changeListeners.push(f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
9
htdocs/js/common/90-retina.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// set/remove retina cookie
|
||||||
|
(function() {
|
||||||
|
var isRetina = window.devicePixelRatio >= 1.5;
|
||||||
|
if (isRetina) {
|
||||||
|
setCookie('is_retina', 1, 365);
|
||||||
|
} else {
|
||||||
|
unsetCookie('is_retina');
|
||||||
|
}
|
||||||
|
})();
|
55
htdocs/sass.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/../init.php';
|
||||||
|
global $config;
|
||||||
|
|
||||||
|
$name = $_REQUEST['name'] ?? '';
|
||||||
|
$theme = $_REQUEST['theme'] ?? '';
|
||||||
|
|
||||||
|
if ($theme != 'light' && $theme != 'dark') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$config['is_dev'] || !$name || !file_exists($path = ROOT.'/htdocs/scss/entries/'.$name.'/'.$theme.'.scss')) {
|
||||||
|
// logError(__FILE__.': access denied');
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// logInfo(__FILE__.': continuing, path='.$path);
|
||||||
|
|
||||||
|
$cmd = 'sassc -t expanded '.escapeshellarg($path);
|
||||||
|
$descriptorspec = [
|
||||||
|
0 => ['pipe', 'r'], // stdin
|
||||||
|
1 => ['pipe', 'w'], // stdout
|
||||||
|
2 => ['pipe', 'w'], // stderr
|
||||||
|
];
|
||||||
|
|
||||||
|
$process = proc_open($cmd, $descriptorspec, $pipes, ROOT);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
http_response_code(500);
|
||||||
|
logError('could not open sassc process');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stdout = stream_get_contents($pipes[1]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
|
||||||
|
$stderr = stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
|
||||||
|
$code = proc_close($process);
|
||||||
|
if ($code) {
|
||||||
|
http_response_code(500);
|
||||||
|
logError('sassc('.$path.') returned '.$code);
|
||||||
|
logError($stderr);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: text/css');
|
||||||
|
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
header("Cache-Control: post-check=0, pre-check=0", false);
|
||||||
|
header("Pragma: no-cache");
|
||||||
|
|
||||||
|
echo $stdout;
|
476
htdocs/scss/app/blog.scss
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
@import '../vars';
|
||||||
|
|
||||||
|
.blog-write-link-wrap {
|
||||||
|
margin-bottom: $base-padding;
|
||||||
|
}
|
||||||
|
.blog-write-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> tbody > tr > td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
> tbody > tr > td:first-child {
|
||||||
|
padding-right: 8px;
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
> tbody > tr > td:last-child {
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-write-form {
|
||||||
|
.form-field-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
textarea.form-field-input {
|
||||||
|
height: 400px;
|
||||||
|
font-family: $ffMono;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
textarea.form-field-input.nowrap {
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.blog-write-options-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
td:nth-child(1) {
|
||||||
|
width: 70%;
|
||||||
|
}
|
||||||
|
td:nth-child(2) {
|
||||||
|
width: 30%;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
tr:first-child td {
|
||||||
|
padding-top: 0px;
|
||||||
|
}
|
||||||
|
button[type="submit"] {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-write-form-toggle-link {
|
||||||
|
margin-top: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-upload-form {
|
||||||
|
padding-bottom: $base-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-upload-list {}
|
||||||
|
.blog-upload-item {
|
||||||
|
border-top: 1px $border-color solid;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.blog-upload-item-actions {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.blog-upload-item-name {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.blog-upload-item-info {
|
||||||
|
color: $grey;
|
||||||
|
font-size: $fs - 2px;
|
||||||
|
}
|
||||||
|
.blog-upload-item-note {
|
||||||
|
padding: 0 0 4px;
|
||||||
|
}
|
||||||
|
.blog-upload-item-md {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-wrap2 {
|
||||||
|
display: table;
|
||||||
|
table-layout: fixed;
|
||||||
|
border: none;
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.blog-post-wrap1 {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
.blog-post {
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.blog-post-toc {
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: $fs - 2px;
|
||||||
|
|
||||||
|
&-wrap {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding: 10px 0 0 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-inner-wrap {
|
||||||
|
border-left: 1px $border-color solid;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 5px 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
> ul {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 2px 0;
|
||||||
|
line-height: 150%;
|
||||||
|
> a {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.wide .blog-post {
|
||||||
|
width: $base_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1150px) {
|
||||||
|
.blog-post-toc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.blog-post {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
.blog-post-wrap2 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.blog-post-title h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-date {
|
||||||
|
color: $grey;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: $fs - 1px;
|
||||||
|
> a {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-tags {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.blog-post-tags > a {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
font-size: $fs - 1px;
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.blog-post-tags > a:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.blog-post-tags > a > span {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-text {}
|
||||||
|
.blog-post-text {
|
||||||
|
li {
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 13px;
|
||||||
|
margin-bottom: 13px;
|
||||||
|
}
|
||||||
|
p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: $code-block-bg;
|
||||||
|
font-family: $ffMono;
|
||||||
|
//font-size: $fsMono;
|
||||||
|
overflow: auto;
|
||||||
|
@include radius(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: $inline-code-block-bg;
|
||||||
|
font-family: $ffMono;
|
||||||
|
font-size: $fsMono;
|
||||||
|
padding: 3px 5px;
|
||||||
|
@include radius(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
padding: 12px;
|
||||||
|
line-height: 145%;
|
||||||
|
background-color: $code-block-bg;
|
||||||
|
|
||||||
|
span.term-prompt {
|
||||||
|
color: $light-grey;
|
||||||
|
@include user-select(none);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px $border-color solid;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 5px 0 5px 12px;
|
||||||
|
color: $grey;
|
||||||
|
&:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px $border-color solid;
|
||||||
|
}
|
||||||
|
table thead td {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
table td, table th {
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px $border-color solid;
|
||||||
|
}
|
||||||
|
table th {
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table-100 {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
table.table-100 td {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
table.table-100 td:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
table.table-100 td:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
td > pre:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
td > pre:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 40px 0 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 30px;
|
||||||
|
border-bottom: 1px $border-color solid;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 35px 0 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 25px;
|
||||||
|
border-bottom: 1px $border-color solid;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 27px 0 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 24px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 24px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 24px 0 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3:first-child,
|
||||||
|
h4:first-child,
|
||||||
|
h5:first-child,
|
||||||
|
h6:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
h1:first-child,
|
||||||
|
h2:first-child {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: $border-color;
|
||||||
|
margin: 17px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.blog-post-comments {
|
||||||
|
margin-top: $base-padding;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 1px $border-color solid;
|
||||||
|
@include radius(3px);
|
||||||
|
}
|
||||||
|
.blog-post-comments img {
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
$blog-tags-width: 175px;
|
||||||
|
|
||||||
|
.index-blog-block {
|
||||||
|
margin-top: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-list {}
|
||||||
|
.blog-list.withtags {
|
||||||
|
margin-right: $blog-tags-width + $base-padding*2;
|
||||||
|
}
|
||||||
|
.blog-list-title {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
> span {
|
||||||
|
margin-left: 2px;
|
||||||
|
> a {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.blog-list-table-wrap {
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
.blog-list-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.blog-list-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0 0 13px;
|
||||||
|
}
|
||||||
|
.blog-list-table tr:last-child td {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
td.blog-item-date-cell {
|
||||||
|
width: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
.blog-item-date {
|
||||||
|
color: $grey;
|
||||||
|
//text-transform: lowercase;
|
||||||
|
}
|
||||||
|
td.blog-item-title-cell {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.blog-item-title {
|
||||||
|
//font-weight: bold;
|
||||||
|
}
|
||||||
|
.blog-item-row {
|
||||||
|
font-size: $fs;
|
||||||
|
line-height: 140%;
|
||||||
|
}
|
||||||
|
.blog-item-row.ishidden a.blog-item-title {
|
||||||
|
color: $fg;
|
||||||
|
}
|
||||||
|
.blog-item-row-year {
|
||||||
|
td {
|
||||||
|
padding-top: 10px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
&:first-child td {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
a.blog-item-view-all-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 17px;
|
||||||
|
@include radius(5px);
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
a.blog-item-view-all-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: #ededed;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
.blog-tags {
|
||||||
|
float: right;
|
||||||
|
width: $blog-tags-width;
|
||||||
|
padding-left: $base-padding - 10px;
|
||||||
|
border-left: 1px $border-color solid;
|
||||||
|
}
|
||||||
|
.blog-tags-title {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
.blog-tag-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: $fs - 1px;
|
||||||
|
}
|
||||||
|
.blog-tag-item > a {
|
||||||
|
color: $fg;
|
||||||
|
}
|
||||||
|
.blog-tag-item-count {
|
||||||
|
color: #aaa;
|
||||||
|
margin-left: 6px;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
281
htdocs/scss/app/common.scss
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
@import "../vars";
|
||||||
|
|
||||||
|
.clearfix:after {
|
||||||
|
content: ".";
|
||||||
|
display: block;
|
||||||
|
clear: both;
|
||||||
|
visibility: hidden;
|
||||||
|
line-height: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
background-color: $bg;
|
||||||
|
color: $fg;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: $ff;
|
||||||
|
font-size: $fs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-width {
|
||||||
|
max-width: $base-width;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.full-width .base-width {
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: $wide_width) {
|
||||||
|
body.wide .base-width {
|
||||||
|
max-width: $wide_width;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px $input-border solid;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: $input-bg;
|
||||||
|
color: $fg;
|
||||||
|
font-family: $ff;
|
||||||
|
font-size: $fs;
|
||||||
|
padding: 6px;
|
||||||
|
outline: none;
|
||||||
|
@include radius(3px);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $input-border-focused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
//input[type="checkbox"] {
|
||||||
|
// margin-left: 0;
|
||||||
|
//}
|
||||||
|
|
||||||
|
//button {
|
||||||
|
// border-radius: 2px;
|
||||||
|
// background-color: $light-bg;
|
||||||
|
// color: $fg;
|
||||||
|
// padding: 7px 12px;
|
||||||
|
// border: none;
|
||||||
|
// /*box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);*/
|
||||||
|
// font-family: $ff;
|
||||||
|
// font-size: $fs - 1px;
|
||||||
|
// outline: none;
|
||||||
|
// cursor: pointer;
|
||||||
|
// position: relative;
|
||||||
|
//}
|
||||||
|
//button:hover {
|
||||||
|
// box-shadow: 0 1px 9px rgba(0, 0, 0, 0.2);
|
||||||
|
//}
|
||||||
|
//button:active {
|
||||||
|
// top: 1px;
|
||||||
|
//}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $link-color;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, p code {
|
||||||
|
line-height: 150%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unicode { font-family: sans-serif; }
|
||||||
|
|
||||||
|
.ff_ms { font-family: $ffMono }
|
||||||
|
.fl_r { float: right }
|
||||||
|
.fl_l { float: left }
|
||||||
|
.pos_rel { position: relative }
|
||||||
|
.pos_abs { position: absolute }
|
||||||
|
.pos_fxd { position: fixed }
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
padding: 0 $side-padding;
|
||||||
|
}
|
||||||
|
.page-content-inner {
|
||||||
|
padding: $base-padding 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
table.contacts {
|
||||||
|
border: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 8px auto 0;
|
||||||
|
}
|
||||||
|
table.contacts td {
|
||||||
|
padding-bottom: 15px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
table.contacts td.label {
|
||||||
|
text-align: right;
|
||||||
|
width: 30%;
|
||||||
|
color: $dark-grey;
|
||||||
|
}
|
||||||
|
table.contacts td.value {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
table.contacts td.value span {
|
||||||
|
background: $inline-code-block-bg;
|
||||||
|
padding: 3px 7px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: $fg;
|
||||||
|
font-family: $ffMono;
|
||||||
|
font-size: $fs - 1px;
|
||||||
|
}
|
||||||
|
table.contacts td b {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
table.contacts td pre {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.contacts div.note {
|
||||||
|
font-size: $fs - 3px;
|
||||||
|
padding-top: 2px;
|
||||||
|
color: $dark-grey;
|
||||||
|
> a {
|
||||||
|
color: $dark-grey;
|
||||||
|
border-bottom: 1px $border-color solid;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $link-color;
|
||||||
|
border-bottom-color: $link-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt {
|
||||||
|
margin: 5px 0 20px;
|
||||||
|
color: $dark-fg;
|
||||||
|
padding-bottom: 7px;
|
||||||
|
border-bottom: 2px rgba(255, 255, 255, 0.12) solid;
|
||||||
|
}
|
||||||
|
.pt h3 {
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: $fs;
|
||||||
|
color: $fg;
|
||||||
|
}
|
||||||
|
.pt h3:not(:first-child) {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.pt a {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
.pt a:not(:first-child) {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.pt a, .pt h3 {
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.pt_r { margin-top: 5px }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: $grey;
|
||||||
|
@include radius(3px);
|
||||||
|
background-color: $dark-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-file-attach {
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.md-file-attach-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: transparent url(/img/attachment.svg) no-repeat center center;
|
||||||
|
background-size: 14px 14px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.md-file-attach > a {
|
||||||
|
//font-weight: bold;
|
||||||
|
}
|
||||||
|
.md-file-attach-size {
|
||||||
|
color: $grey;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.md-file-attach-note {
|
||||||
|
color: $fg;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-image {
|
||||||
|
padding: 3px 0;
|
||||||
|
line-height: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.md-images {
|
||||||
|
margin-bottom: -8px;
|
||||||
|
padding: 3px 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.md-images .md-image {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.md-images > span {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
margin: 0 8px 8px 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.md-image.align-center { text-align: center; }
|
||||||
|
.md-image.align-left { text-align: left; }
|
||||||
|
.md-image.align-right { text-align: right; }
|
||||||
|
.md-image-wrap {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.md-image-wrap > a {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.md-image-note {
|
||||||
|
line-height: 150%;
|
||||||
|
color: $dark-grey;
|
||||||
|
padding: 2px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-video video {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-ascii {
|
||||||
|
line-height: 125% !important;
|
||||||
|
}
|
59
htdocs/scss/app/form.scss
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
@import '../vars';
|
||||||
|
|
||||||
|
$form-field-label-width: 120px;
|
||||||
|
|
||||||
|
form { display: block; margin: 0; }
|
||||||
|
|
||||||
|
.form-layout-h {
|
||||||
|
.form-field-wrap {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.form-field-label {
|
||||||
|
float: left;
|
||||||
|
width: $form-field-label-width;
|
||||||
|
text-align: right;
|
||||||
|
padding: 7px 0 0;
|
||||||
|
}
|
||||||
|
.form-field {
|
||||||
|
margin-left: $form-field-label-width + 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-layout-v {
|
||||||
|
.form-field-wrap {
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.form-field-wrap:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.form-field-wrap:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-field-label {
|
||||||
|
padding: 0 0 4px 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $grey;
|
||||||
|
}
|
||||||
|
.form-field {
|
||||||
|
//margin-left: $form-field-label-width + 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
padding: 10px 13px;
|
||||||
|
margin-bottom: $base-padding;
|
||||||
|
background-color: $error-block-bg;
|
||||||
|
color: $error-block-fg;
|
||||||
|
@include radius(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-success {
|
||||||
|
padding: 10px 13px;
|
||||||
|
margin-bottom: $base-padding;
|
||||||
|
background-color: $success-block-bg;
|
||||||
|
color: $success-block-fg;
|
||||||
|
@include radius(3px);
|
||||||
|
}
|