diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/MyParsedown.php | 209 | ||||
-rw-r--r-- | lib/admin.php | 57 | ||||
-rw-r--r-- | lib/cli.php | 70 | ||||
-rw-r--r-- | lib/config.php | 46 | ||||
-rw-r--r-- | lib/markup.php | 30 | ||||
-rw-r--r-- | lib/pages.php | 32 | ||||
-rw-r--r-- | lib/posts.php | 188 | ||||
-rw-r--r-- | lib/uploads.php | 145 |
8 files changed, 777 insertions, 0 deletions
diff --git a/lib/MyParsedown.php b/lib/MyParsedown.php new file mode 100644 index 0000000..c2c0112 --- /dev/null +++ b/lib/MyParsedown.php @@ -0,0 +1,209 @@ +<?php + +use sixlive\ParsedownHighlight; + +class MyParsedown extends ParsedownHighlight { + + public function __construct( + protected bool $useImagePreviews = false + ) { + parent::__construct(); + $this->InlineTypes['{'][] = 'FileAttach'; + $this->InlineTypes['{'][] = 'Image'; + $this->InlineTypes['{'][] = 'Video'; + $this->inlineMarkerList .= '{'; + } + + protected function inlineFileAttach($excerpt) { + if (preg_match('/^{fileAttach:([\w]{8})}{\/fileAttach}/', $excerpt['text'], $matches)) { + $random_id = $matches[1]; + $upload = uploads::getByRandomId($random_id); + $result = [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'name' => 'span', + 'text' => '', + ], + 'type' => '' + ]; + + if (!$upload) { + return $result; + } + + unset($result['element']['text']); + + $ctx = self::getSkinContext(); + $result['element']['rawHtml'] = $ctx->fileupload($upload->name, $upload->getDirectUrl(), $upload->note, $upload->getSize()); + + return $result; + } + } + + protected function inlineImage($excerpt) { + global $config; + + if (preg_match('/^{image:([\w]{8}),(.*?)}{\/image}/', $excerpt['text'], $matches)) { + $random_id = $matches[1]; + + $opts = [ + 'w' => 'auto', + 'h' => 'auto', + 'align' => 'left', + 'nolabel' => false, + ]; + $inputopts = explode(',', $matches[2]); + + foreach ($inputopts as $opt) { + if ($opt == 'nolabel') + $opts[$opt] = true; + else { + list($k, $v) = explode('=', $opt); + if (!isset($opts[$k])) + continue; + $opts[$k] = $v; + } + } + + $image = uploads::getByRandomId($random_id); + $result = [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'name' => 'span', + 'text' => '', + ], + 'type' => '' + ]; + + if (!$image) { + return $result; + } + + list($w, $h) = $image->getImagePreviewSize( + $opts['w'] == 'auto' ? null : $opts['w'], + $opts['h'] == 'auto' ? null : $opts['h'] + ); + $opts['w'] = $w; + // $opts['h'] = $h; + + if (!$this->useImagePreviews) + $image_url = $image->getDirectUrl(); + else + $image_url = $image->getDirectPreviewUrl($w, $h); + + unset($result['element']['text']); + + $ctx = self::getSkinContext(); + $result['element']['rawHtml'] = $ctx->image( + w: $opts['w'], + nolabel: $opts['nolabel'], + align: $opts['align'], + padding_top: round($h / $w * 100, 4), + + url: $image_url, + direct_url: $image->getDirectUrl(), + note: $image->note + ); + + return $result; + } + } + + protected function inlineVideo($excerpt) { + if (preg_match('/^{video:([\w]{8})(?:,(.*?))?}{\/video}/', $excerpt['text'], $matches)) { + $random_id = $matches[1]; + + $opts = [ + 'w' => 'auto', + 'h' => 'auto', + 'align' => 'left', + 'nolabel' => false, + ]; + $inputopts = !empty($matches[2]) ? explode(',', $matches[2]) : []; + + foreach ($inputopts as $opt) { + if ($opt == 'nolabel') + $opts[$opt] = true; + else { + list($k, $v) = explode('=', $opt); + if (!isset($opts[$k])) + continue; + $opts[$k] = $v; + } + } + + $video = uploads::getByRandomId($random_id); + $result = [ + 'extent' => strlen($matches[0]), + 'element' => [ + 'name' => 'span', + 'text' => '', + ], + 'type' => '' + ]; + + if (!$video) { + return $result; + } + + $video_url = $video->getDirectUrl(); + + unset($result['element']['text']); + + $ctx = self::getSkinContext(); + $result['element']['rawHtml'] = $ctx->video( + url: $video_url, + w: $opts['w'], + h: $opts['h'] + ); + + return $result; + } + } + + protected function paragraph($line) { + if (preg_match('/^{fileAttach:([\w]{8})}{\/fileAttach}$/', $line['text'])) { + return $this->inlineFileAttach($line); + } + if (preg_match('/^{image:([\w]{8}),(?:.*?)}{\/image}/', $line['text'])) { + return $this->inlineImage($line); + } + if (preg_match('/^{video:([\w]{8})(?:,(?:.*?))?}{\/video}/', $line['text'])) { + return $this->inlineVideo($line); + } + return parent::paragraph($line); + } + + protected function blockFencedCodeComplete($block) { + if (!isset($block['element']['element']['attributes'])) { + return $block; + } + + $code = $block['element']['element']['text']; + $languageClass = $block['element']['element']['attributes']['class']; + $language = explode('-', $languageClass); + + if ($language[1] == 'term') { + $lines = explode("\n", $code); + for ($i = 0; $i < count($lines); $i++) { + $line = $lines[$i]; + if (str_starts_with($line, '$ ') || str_starts_with($line, '# ')) { + $lines[$i] = '<span class="term-prompt">'.substr($line, 0, 2).'</span>'.htmlspecialchars(substr($line, 2), ENT_NOQUOTES, 'UTF-8'); + } else { + $lines[$i] = htmlspecialchars($line, ENT_NOQUOTES, 'UTF-8'); + } + } + $block['element']['element']['rawHtml'] = implode("\n", $lines); + unset($block['element']['element']['text']); + + return $block; + } + + return parent::blockFencedCodeComplete($block); + } + + protected static function getSkinContext(): SkinContext { + return new SkinContext('\\skin\\markdown'); + } + +} diff --git a/lib/admin.php b/lib/admin.php new file mode 100644 index 0000000..8b36b36 --- /dev/null +++ b/lib/admin.php @@ -0,0 +1,57 @@ +<?php + +class admin { + + const SESSION_TIMEOUT = 86400 * 14; + const COOKIE_NAME = 'admin_key'; + + protected static ?bool $isAdmin = null; + + public static function isAdmin(): bool { + if (is_null(self::$isAdmin)) + self::$isAdmin = self::_verifyKey(); + return self::$isAdmin; + } + + protected static function _verifyKey(): bool { + if (isset($_COOKIE[self::COOKIE_NAME])) { + $cookie = (string)$_COOKIE[self::COOKIE_NAME]; + if ($cookie !== self::getKey()) + self::unsetCookie(); + return true; + } + return false; + } + + public static function checkPassword(string $pwd): bool { + return salt_password($pwd) === config::get('admin_pwd'); + } + + protected static function getKey(): string { + global $config; + $admin_pwd_hash = config::get('admin_pwd'); + return salt_password("$admin_pwd_hash|{$_SERVER['REMOTE_ADDR']}"); + } + + public static function setCookie(): void { + global $config; + $key = self::getKey(); + setcookie(self::COOKIE_NAME, $key, time() + self::SESSION_TIMEOUT, '/', $config['cookie_host']); + } + + public static function unsetCookie(): void { + global $config; + setcookie(self::COOKIE_NAME, null, -1, '/', $config['cookie_host']); + } + + public static function logAuth(): void { + getDb()->insert('admin_log', [ + 'ts' => time(), + 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + ]); + } + + +} + diff --git a/lib/cli.php b/lib/cli.php new file mode 100644 index 0000000..3bda20f --- /dev/null +++ b/lib/cli.php @@ -0,0 +1,70 @@ +<?php + +class cli { + + protected ?array $commandsCache = null; + + public function __construct( + protected string $ns + ) {} + + protected function usage($error = null): void { + global $argv; + + if (!is_null($error)) + echo "error: {$error}\n\n"; + + echo "Usage: $argv[0] COMMAND\n\nCommands:\n"; + foreach ($this->getCommands() as $c) + echo " $c\n"; + + exit(is_null($error) ? 0 : 1); + } + + public function getCommands(): array { + if (is_null($this->commandsCache)) { + $funcs = array_filter(get_defined_functions()['user'], fn(string $f) => str_starts_with($f, $this->ns)); + $funcs = array_map(fn(string $f) => str_replace('_', '-', substr($f, strlen($this->ns.'\\'))), $funcs); + $this->commandsCache = array_values($funcs); + } + return $this->commandsCache; + } + + public function run(): void { + global $argv, $argc; + + if (PHP_SAPI != 'cli') + cli::die('SAPI != cli'); + + if ($argc < 2) + $this->usage(); + + $func = $argv[1]; + if (!in_array($func, $this->getCommands())) + self::usage('unknown command "'.$func.'"'); + + $func = str_replace('-', '_', $func); + call_user_func($this->ns.'\\'.$func); + } + + public static function input(string $prompt): string { + echo $prompt; + $input = substr(fgets(STDIN), 0, -1); + return $input; + } + + public static function silentInput(string $prompt = ''): string { + echo $prompt; + system('stty -echo'); + $input = substr(fgets(STDIN), 0, -1); + system('stty echo'); + echo "\n"; + return $input; + } + + public static function die($error): void { + fwrite(STDERR, "error: {$error}\n"); + exit(1); + } + +}
\ No newline at end of file diff --git a/lib/config.php b/lib/config.php new file mode 100644 index 0000000..bb7e5ca --- /dev/null +++ b/lib/config.php @@ -0,0 +1,46 @@ +<?php + +class config { + + public static function get(string $key) { + $db = getDb(); + $q = $db->query("SELECT value FROM config WHERE name=?", $key); + if (!$db->numRows($q)) + return null; + return $db->result($q); + } + + public static function mget($keys) { + $map = []; + foreach ($keys as $key) { + $map[$key] = null; + } + + $db = getDb(); + $keys = array_map(fn($s) => $db->escape($s), $keys); + + $q = $db->query("SELECT * FROM config WHERE name IN('".implode("','", $keys)."')"); + while ($row = $db->fetch($q)) + $map[$row['name']] = $row['value']; + + return $map; + } + + public static function set($key, $value) { + $db = getDb(); + return $db->query("REPLACE INTO config (name, value) VALUES (?, ?)", $key, $value); + } + + public static function mset($map) { + $rows = []; + foreach ($map as $name => $value) { + $rows[] = [ + 'name' => $name, + 'value' => $value + ]; + } + $db = getDb(); + return $db->multipleReplace('config', $rows); + } + +} diff --git a/lib/markup.php b/lib/markup.php new file mode 100644 index 0000000..52ccf24 --- /dev/null +++ b/lib/markup.php @@ -0,0 +1,30 @@ +<?php + +class markup { + + public static function markdownToHtml(string $md, bool $use_image_previews = true): string { + $pd = new MyParsedown(useImagePreviews: $use_image_previews); + return $pd->text($md); + } + + public static function htmlToText(string $html): string { + $text = html_entity_decode(strip_tags($html)); + $lines = explode("\n", $text); + $lines = array_map('trim', $lines); + $text = implode("\n", $lines); + $text = preg_replace("/(\r?\n){2,}/", "\n\n", $text); + return $text; + } + + public static function htmlRetinaFix(string $html): string { + global $config; + return preg_replace_callback( + '/('.preg_quote($config['uploads_host'], '/').'\/\w{8}\/p)(\d+)x(\d+)(\.jpg)/', + function($match) { + return $match[1].(intval($match[2])*2).'x'.(intval($match[3])*2).$match[4]; + }, + $html + ); + } + +}
\ No newline at end of file diff --git a/lib/pages.php b/lib/pages.php new file mode 100644 index 0000000..281ee52 --- /dev/null +++ b/lib/pages.php @@ -0,0 +1,32 @@ +<?php + +class pages { + + public static function add(array $data): ?int { + $db = getDb(); + $data['ts'] = time(); + $data['html'] = markup::markdownToHtml($data['md']); + if (!$db->insert('pages', $data)) + return null; + return $db->insertId(); + } + + public static function delete(Page $page): void { + getDb()->query("DELETE FROM pages WHERE short_name=?", $page->shortName); + } + + public static function getPageByName(string $short_name): ?Page { + $db = getDb(); + $q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name); + return $db->numRows($q) ? new Page($db->fetch($q)) : null; + } + + /** + * @return Page[] + */ + public static function getAll(): array { + $db = getDb(); + return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages"))); + } + +}
\ No newline at end of file diff --git a/lib/posts.php b/lib/posts.php new file mode 100644 index 0000000..bf8d149 --- /dev/null +++ b/lib/posts.php @@ -0,0 +1,188 @@ +<?php + +class posts { + + public static function getPostsCount(bool $include_hidden = false): int { + $db = getDb(); + $sql = "SELECT COUNT(*) FROM posts"; + if (!$include_hidden) { + $sql .= " WHERE visible=1"; + } + return (int)$db->result($db->query($sql)); + } + + public static function getPostsCountByTagId(int $tag_id, bool $include_hidden = false): int { + $db = getDb(); + if ($include_hidden) { + $sql = "SELECT COUNT(*) FROM posts_tags WHERE tag_id=?"; + } else { + $sql = "SELECT COUNT(*) FROM posts_tags + LEFT JOIN posts ON posts.id=posts_tags.post_id + WHERE posts_tags.tag_id=? AND posts.visible=1"; + } + return (int)$db->result($db->query($sql, $tag_id)); + } + + public static function getPosts(int $offset = 0, int $count = -1, bool $include_hidden = false): array { + $db = getDb(); + $sql = "SELECT * FROM posts"; + if (!$include_hidden) + $sql .= " WHERE visible=1"; + $sql .= " ORDER BY ts DESC"; + if ($offset != 0 && $count != -1) + $sql .= "LIMIT $offset, $count"; + $q = $db->query($sql); + return array_map('Post::create_instance', $db->fetchAll($q)); + } + + /** + * @return Post[] + */ + public static function getPostsByTagId(int $tag_id, bool $include_hidden = false): array { + $db = getDb(); + $sql = "SELECT posts.* FROM posts_tags + LEFT JOIN posts ON posts.id=posts_tags.post_id + WHERE posts_tags.tag_id=?"; + if (!$include_hidden) + $sql .= " AND posts.visible=1"; + $sql .= " ORDER BY posts.ts DESC"; + $q = $db->query($sql, $tag_id); + return array_map('Post::create_instance', $db->fetchAll($q)); + } + + public static function add(array $data = []): int|bool { + $db = getDb(); + + $html = \markup::markdownToHtml($data['md']); + $text = \markup::htmlToText($html); + + $data += [ + 'ts' => time(), + 'html' => $html, + 'text' => $text, + ]; + + if (!$db->insert('posts', $data)) + return false; + + $id = $db->insertId(); + + $post = posts::get($id); + $post->updateImagePreviews(); + + return $id; + } + + public static function delete(Post $post): void { + $tags = $post->getTags(); + + $db = getDb(); + $db->query("DELETE FROM posts WHERE id=?", $post->id); + $db->query("DELETE FROM posts_tags WHERE post_id=?", $post->id); + + foreach ($tags as $tag) + self::recountPostsWithTag($tag->id); + } + + public static function getTagIds(array $tags): array { + $found_tags = []; + $map = []; + + $db = getDb(); + $q = $db->query("SELECT id, tag FROM tags + WHERE tag IN ('".implode("','", array_map(function($tag) use ($db) { return $db->escape($tag); }, $tags))."')"); + while ($row = $db->fetch($q)) { + $found_tags[] = $row['tag']; + $map[$row['tag']] = (int)$row['id']; + } + + $notfound_tags = array_diff($tags, $found_tags); + if (!empty($notfound_tags)) { + foreach ($notfound_tags as $tag) { + $db->insert('tags', ['tag' => $tag]); + $map[$tag] = $db->insertId(); + } + } + + return $map; + } + + public static function get(int $id): ?Post { + $db = getDb(); + $q = $db->query("SELECT * FROM posts WHERE id=?", $id); + return $db->numRows($q) ? new Post($db->fetch($q)) : null; + } + + public static function getPostByName(string $short_name): ?Post { + $db = getDb(); + $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); + return $db->numRows($q) ? new Post($db->fetch($q)) : null; + } + + public static function getPostsById(array $ids, bool $flat = false): array { + if (empty($ids)) { + return []; + } + + $db = getDb(); + $posts = array_fill_keys($ids, null); + + $q = $db->query("SELECT * FROM posts WHERE id IN(".implode(',', $ids).")"); + + while ($row = $db->fetch($q)) { + $posts[(int)$row['id']] = new Post($row); + } + + if ($flat) { + $list = []; + foreach ($ids as $id) { + $list[] = $posts[$id]; + } + unset($posts); + return $list; + } + + return $posts; + } + + public static function getAllTags(bool $include_hidden = false): array { + $db = getDb(); + $field = $include_hidden ? 'posts_count' : 'visible_posts_count'; + $q = $db->query("SELECT * FROM tags WHERE $field > 0 ORDER BY $field DESC, tag"); + return array_map('Tag::create_instance', $db->fetchAll($q)); + } + + public static function getTag(string $tag): ?Tag { + $db = getDb(); + $q = $db->query("SELECT * FROM tags WHERE tag=?", $tag); + return $db->numRows($q) ? new Tag($db->fetch($q)) : null; + } + + /** + * @param int $tag_id + */ + public static function recountPostsWithTag($tag_id) { + $db = getDb(); + $count = $db->result($db->query("SELECT COUNT(*) FROM posts_tags WHERE tag_id=?", $tag_id)); + $vis_count = $db->result($db->query("SELECT COUNT(*) FROM posts_tags + LEFT JOIN posts ON posts.id=posts_tags.post_id + WHERE posts_tags.tag_id=? AND posts.visible=1", $tag_id)); + $db->query("UPDATE tags SET posts_count=?, visible_posts_count=? WHERE id=?", + $count, $vis_count, $tag_id); + } + + public static function splitStringToTags(string $tags): array { + $tags = trim($tags); + if ($tags == '') { + return []; + } + + $tags = preg_split('/,\s+/', $tags); + $tags = array_filter($tags, function($tag) { return trim($tag) != ''; }); + $tags = array_map('trim', $tags); + $tags = array_map('mb_strtolower', $tags); + + return $tags; + } + +} diff --git a/lib/uploads.php b/lib/uploads.php new file mode 100644 index 0000000..62be276 --- /dev/null +++ b/lib/uploads.php @@ -0,0 +1,145 @@ +<?php + +class uploads { + + protected static $allowedExtensions = [ + 'jpg', 'png', 'git', 'mp4', 'mp3', 'ogg', 'diff', 'txt', 'gz', 'tar', + 'icc', 'icm', 'patch', 'zip', 'brd', 'pdf', 'lua', 'xpi', 'rar', '7z', + 'tgz', 'bin', 'py', 'pac', + ]; + + public static function getCount(): int { + $db = getDb(); + return (int)$db->result($db->query("SELECT COUNT(*) FROM uploads")); + } + + public static function isExtensionAllowed(string $ext): bool { + return in_array($ext, self::$allowedExtensions); + } + + public static function add(string $tmp_name, string $name, string $note): ?int { + global $config; + + $name = sanitize_filename($name); + if (!$name) + $name = 'file'; + + $random_id = self::getNewRandomId(); + $size = filesize($tmp_name); + $is_image = detect_image_type($tmp_name) !== false; + $image_w = 0; + $image_h = 0; + if ($is_image) { + list($image_w, $image_h) = getimagesize($tmp_name); + } + + $db = getDb(); + if (!$db->insert('uploads', [ + 'random_id' => $random_id, + 'ts' => time(), + 'name' => $name, + 'size' => $size, + 'image' => (int)$is_image, + 'image_w' => $image_w, + 'image_h' => $image_h, + 'note' => $note, + 'downloads' => 0, + ])) { + return null; + } + + $id = $db->insertId(); + + $dir = $config['uploads_dir'].'/'.$random_id; + $path = $dir.'/'.$name; + + mkdir($dir); + chmod($dir, 0775); // g+w + + rename($tmp_name, $path); + chmod($path, 0664); // g+w + + return $id; + } + + public static function delete(int $id): bool { + $upload = self::get($id); + if (!$upload) + return false; + + $db = getDb(); + $db->query("DELETE FROM uploads WHERE id=?", $id); + + rrmdir($upload->getDirectory()); + return true; + } + + /** + * @return Upload[] + */ + public static function getAll(): array { + $db = getDb(); + $q = $db->query("SELECT * FROM uploads ORDER BY id DESC"); + return array_map('Upload::create_instance', $db->fetchAll($q)); + } + + public static function get(int $id): ?Upload { + $db = getDb(); + $q = $db->query("SELECT * FROM uploads WHERE id=?", $id); + if ($db->numRows($q)) { + return new Upload($db->fetch($q)); + } else { + return null; + } + } + + /** + * @param string[] $ids + * @param bool $flat + * @return Upload[] + */ + public static function getUploadsByRandomId(array $ids, bool $flat = false): array { + if (empty($ids)) { + return []; + } + + $db = getDb(); + $uploads = array_fill_keys($ids, null); + + $q = $db->query("SELECT * FROM uploads WHERE random_id IN('".implode('\',\'', array_map([$db, 'escape'], $ids))."')"); + + while ($row = $db->fetch($q)) { + $uploads[$row['random_id']] = new Upload($row); + } + + if ($flat) { + $list = []; + foreach ($ids as $id) { + $list[] = $uploads[$id]; + } + unset($uploads); + return $list; + } + + return $uploads; + } + + public static function getByRandomId(string $random_id): ?Upload { + $db = getDb(); + $q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id); + if ($db->numRows($q)) { + return new Upload($db->fetch($q)); + } else { + return null; + } + } + + protected static function getNewRandomId(): string { + $db = getDb(); + do { + $random_id = strgen(8); + } while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0); + return $random_id; + } + +} |