diff options
author | Freya Murphy <freya@freyacat.org> | 2024-05-24 09:05:42 -0400 |
---|---|---|
committer | Freya Murphy <freya@freyacat.org> | 2024-05-24 09:05:42 -0400 |
commit | c5f39ea2cd7cf02246705ea8872d3b350526165c (patch) | |
tree | 2694f9fdc5d83b529a01f2997c1d89c271c86592 /src/web | |
download | website-c5f39ea2cd7cf02246705ea8872d3b350526165c.tar.gz website-c5f39ea2cd7cf02246705ea8872d3b350526165c.tar.bz2 website-c5f39ea2cd7cf02246705ea8872d3b350526165c.zip |
initial
Diffstat (limited to 'src/web')
45 files changed, 4821 insertions, 0 deletions
diff --git a/src/web/_controller/_comments.php b/src/web/_controller/_comments.php new file mode 100644 index 0000000..4b87a94 --- /dev/null +++ b/src/web/_controller/_comments.php @@ -0,0 +1,87 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class _comments_controller extends Controller { + + private $comments_model; + + function __construct($load) { + parent::__construct($load); + $this->comments_model = $this->load->model('_comments'); + } + + + public function comments($page, $ref): void { + $data = $this->comments_model->get_comments($page); + $this->view('comments', array( + 'comments' => $data, + 'ref' => $ref, + 'page' => $page + )); + } + + public function post(): void { + $author = ''; $content = ''; $ref = ''; + if ( + !array_key_exists('author', $_GET) || + !array_key_exists('content', $_GET) || + !array_key_exists('ref', $_GET) || + !array_key_exists('page', $_GET) + ) { + $this->error(400); return; + } + + $author = trim($_GET['author']); + $content = trim($_GET['content']); + $page = $_GET['page']; + $ref = $_GET['ref']; + $url = NULL; + + $author_len = strlen($author); + $content_len = strlen($content); + + if ($author_len < 1 || $content_len < 1) { + $this->error(400); + return; + } + + if ($author_len > 30 || $content_len > 500) { + $this->error(413); + return; + } + + if (base64_encode(base64_decode($ref)) !== $ref) { + // invalid base64 + $this->error(400); + return; + } + + try { + $ref = base64_decode($ref); + $url = parse_url($ref); + if (!$url && array_key_exists('host', $url)) { + // dont allow redirects off this site + $this->error(400); + return; + } + } catch (Exception $e) { + $this->error(400); + return; + } + + $vulgar = 'false'; + if ( + $this->comments_model->is_vulgar($author) || + $this->comments_model->is_vulgar($content) + ) { + $vulgar = 'true'; + } + + $result = $this->comments_model + ->post_comment($author, $content, $page, $vulgar); + + if ($result) { + header('Location: ' . $this->main->get_url($ref) . '#comments'); + } else { + $this->error(500); + } + } +} diff --git a/src/web/_controller/_meta.php b/src/web/_controller/_meta.php new file mode 100644 index 0000000..801d254 --- /dev/null +++ b/src/web/_controller/_meta.php @@ -0,0 +1,76 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class _meta_controller extends Controller { + + function __construct($load) { + parent::__construct($load); + } + + public function robots() { + header("Content-Type: text/plain"); + $sitemap = $this->main->get_url_full('sitemap.xml'); + + echo "User-agent: *\n"; + echo "Disallow:\n"; + echo "Crawl-delay: 5\n"; + echo "Disallow: /_comments/\n"; + echo "Disallow: /pacbattle/\n"; + echo "Disallow: /bucket/\n"; + echo "Sitemap: {$sitemap}\n"; + } + + private function sitemap_page($url, $priority) { + echo "<url>\n"; + echo "<loc>{$this->main->get_url_full($url)}</loc>\n"; + echo "<priority>{$priority}</priority>\n"; + echo "</url>"; + } + + public function sitemap() { + header("Content-Type: application/xml"); + + echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; + echo "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"; + + $this->sitemap_page('home', 1); + $this->sitemap_page('projects', 0.8); + $this->sitemap_page('blog', 0.8); + + $this->load->app_lang('blog'); + $blog_modal = $this->load->model('blog'); + $blog = $blog_modal->get_data()['blog']; + + foreach ($blog as $name => $_) { + $this->sitemap_page("blog/post?name={$name}", 0.5); + } + + echo "</urlset>\n"; + } + + public function manifest() { + $json = array( + 'short_name' => lang('domain'), + 'name' => lang('domain'), + 'icons' => [ + array( + 'src' => $this->main->get_url('public/icons/logo512.png'), + 'type' => 'image/png', + 'sizes' => '512x512', + 'purpose' => 'any maskable' + ) + ], + 'id' => $this->main->get_url('home'), + 'start_url' => $this->main->get_url('home'), + 'background_color' => lang('theme_color'), + 'display' => 'standalone', + 'scope' => lang('base_path'), + 'theme_color' => lang('theme_color'), + 'shortcuts' => [], + 'description' => lang('default_short_desc'), + 'screenshots' => [] + ); + + header('Content-type: application/json'); + echo json_encode($json); + } + +} diff --git a/src/web/_controller/blog.php b/src/web/_controller/blog.php new file mode 100644 index 0000000..f13ffd1 --- /dev/null +++ b/src/web/_controller/blog.php @@ -0,0 +1,74 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Blog_controller extends Controller { + + public $comments_controller; + private $blog_model; + + function __construct($load) { + parent::__construct($load); + $this->blog_model = $this->load->model('blog'); + $this->comments_controller = $this->load->controller('_comments'); + } + + public function index(): void { + parent::index(); + $data = $this->blog_model->get_data(); + $this->view('header', $data); + $this->view('apps/blog', $data); + $this->view('footer', $data); + } + + private function protect($folder) { + if (!array_key_exists('name', $_GET)) { + $this->error(400); + } + + $basepath = $GLOBALS['assetroot'] . '/' . $folder . '/'; + $realBase = realpath($basepath); + + $userpath = $basepath . $_GET['name']; + $realUserPath = realpath($userpath); + + if ($realUserPath === false || strpos($realUserPath, $realBase) !== 0) { + $this->error(404); + } + } + + public function post(): void { + $this->protect('blog'); + parent::index(); + $data = $this->blog_model->get_post($_GET['name']); + if ($data === FALSE) { + $this->error(404); + } + $this->view('header', $data); + $this->view('apps/blog_post', $data); + $ref = 'blog/post?name=' . $_GET['name']; + $this->comments_controller->comments($data['post']['meta']['name'], $ref); + $this->view('footer', $data); + } + + public function writeup(): void { + $this->protect('writeup'); + parent::index(); + $data = $this->blog_model->get_writeup($_GET['name']); + if ($data === FALSE) { + $this->error(404); + } + $this->view('header', $data); + $this->view('apps/blog_writeup', $data); + $ref = 'blog/writeup?name=' . $_GET['name']; + $this->comments_controller->comments($data['post']['meta']['name'], $ref); + $this->view('footer', $data); + } + + public function rss() { + $data = $this->blog_model->get_data(); + header('Content-Type: application/xml'); + $this->view('apps/blog_rss', $data); + die(); + } + +} + +?> diff --git a/src/web/_controller/bucket.php b/src/web/_controller/bucket.php new file mode 100644 index 0000000..ed15ef8 --- /dev/null +++ b/src/web/_controller/bucket.php @@ -0,0 +1,22 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Bucket_controller extends Controller { + + private $bucket_model; + + function __construct($load) { + parent::__construct($load); + $this->bucket_model = $this->load->model('bucket'); + } + + public function index(): void { + parent::index(); + $data = $this->bucket_model->get_data(); + if ($data === NULL) { + $this->error(400); + return; + } + $this->view('apps/bucket', $data); + } +} + +?> diff --git a/src/web/_controller/error.php b/src/web/_controller/error.php new file mode 100644 index 0000000..d24308b --- /dev/null +++ b/src/web/_controller/error.php @@ -0,0 +1,21 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Error_controller extends Controller { + + private $error_model; + + function __construct($load) { + parent::__construct($load); + $this->error_model = $this->load->model('error'); + } + + public function index(): void { + parent::index(); + $data = $this->error_model->get_data(); + $this->view('header', $data); + $this->view('apps/error', $data); + $this->view('footer', $data); + } + +} + +?> diff --git a/src/web/_controller/home.php b/src/web/_controller/home.php new file mode 100644 index 0000000..12dff64 --- /dev/null +++ b/src/web/_controller/home.php @@ -0,0 +1,17 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Home_controller extends Controller { + function __construct($load) { + parent::__construct($load); + } + + public function index(): void { + parent::index(); + $data = $this->main->get_data(); + $this->view('header', $data); + $this->view('apps/home', $data); + $this->view('footer', $data); + } + +} + +?> diff --git a/src/web/_controller/projects.php b/src/web/_controller/projects.php new file mode 100644 index 0000000..9ee2136 --- /dev/null +++ b/src/web/_controller/projects.php @@ -0,0 +1,21 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Projects_controller extends Controller { + + private $projects_model; + + function __construct($load) { + parent::__construct($load); + $this->projects_model = $this->load->model('projects'); + } + + public function index(): void { + parent::index(); + $data = $this->projects_model->get_data(); + $this->view('header', $data); + $this->view('apps/projects', $data); + $this->view('footer', $data); + } + +} + +?> diff --git a/src/web/_model/_comments.php b/src/web/_model/_comments.php new file mode 100644 index 0000000..73c1fc7 --- /dev/null +++ b/src/web/_model/_comments.php @@ -0,0 +1,66 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class _comments_model extends Model { + + function __construct($load) { + parent::__construct($load); + } + + private function load_profanity() { + $path = $GLOBALS['assetroot'] . '/profanity.txt'; + $str = file_get_contents($path); + $lines = explode("\n", $str); + + $regex = '/('; + foreach ($lines as $idx => $line) { + if ($line == '') { + continue; + } + if ($idx != 0) { + $regex .= '|'; + } + $regex .= $line; + } + $regex .= ')/'; + + return $regex; + } + + public function is_vulgar($text) { + $profanity = $this->load_profanity(); + return preg_match($profanity, $text); + } + + public function get_comments($page) { + $ip = $this->main->info['ip']; + $query = $this->db + ->select('*') + ->from('admin.comment c') + ->where('c.page') + ->eq($page) + ->query('AND ( + (c.vulgar IS FALSE) OR + (c.vulgar IS TRUE and c.ip = ?) + )') + ->order_by('c.id', 'DESC'); + $result = $query->rows($ip); + return $result; + } + + public function ban_user() { + $ip = $this->main->info['ip']; + $this->db + ->insert_into('admin.banned', 'ip', 'reason') + ->values($ip, 'vulgar language') + ->execute(); + } + + public function post_comment($author, $content, $page, $vulgar) { + $ip = $this->main->info['ip']; + return $this->db + ->insert_into('admin.comment', + 'author', 'content', 'page', 'ip', 'vulgar') + ->values($author, $content, $page, $ip, $vulgar) + ->execute(); + } + +} diff --git a/src/web/_model/blog.php b/src/web/_model/blog.php new file mode 100644 index 0000000..42cee97 --- /dev/null +++ b/src/web/_model/blog.php @@ -0,0 +1,80 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Blog_model extends Model { + + private $markdown; + + function __construct($load) { + parent::__construct($load); + $this->markdown = new MarkdownParser(); + } + + private function load_blog(&$data) { + $blog = array(); + $dir = $GLOBALS['assetroot'] . '/blog'; + if ($handle = opendir($dir)) { + while (false !== ($entry = readdir($handle))) { + if (str_starts_with($entry, ".")) { + continue; + } + $path = $dir . '/' . $entry; + $md = $this->markdown->parse($path); + $blog[$entry] = $md; + } + } + krsort($blog); + $data['blog'] = $blog; + } + + public function get_data(): ?array { + $data = parent::get_data(); + $this->load_blog($data); + $data['title'] = lang('title'); + $data['desc'] = lang('blog_short_desc'); + return $data; + } + + private function load_post($name) { + $dir = $GLOBALS['assetroot'] . '/blog'; + $path = $dir . '/' . $name; + if(!file_exists($path)) { + return FALSE; + } + $md = $this->markdown->parse($path); + return $md; + } + + public function get_post($name) { + $data = parent::get_data(); + $post = $this->load_post($name); + if (!$post) { + return FALSE; + } + $data['title'] = $post['meta']['name']; + $data['desc'] = $post['meta']['desc']; + $data['post'] = $post; + return $data; + } + + private function load_writeup($name) { + $dir = $GLOBALS['assetroot'] . '/writeup'; + $path = $dir . '/' . $name; + if(!file_exists($path)) { + return FALSE; + } + $md = $this->markdown->parse($path); + return $md; + } + + public function get_writeup($name) { + $data = parent::get_data(); + $writeup = $this->load_writeup($name); + if (!$writeup) { + return FALSE; + } + $data['title'] = $writeup['meta']['name']; + $data['desc'] = $writeup['meta']['desc']; + $data['post'] = $writeup; + return $data; + } +} +?> diff --git a/src/web/_model/bucket.php b/src/web/_model/bucket.php new file mode 100644 index 0000000..f38bebe --- /dev/null +++ b/src/web/_model/bucket.php @@ -0,0 +1,26 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Bucket_model extends Model { + + function __construct($load) { + parent::__construct($load); + } + + public function get_data(): ?array { + $data = parent::get_data(); + + if (array_key_exists('name', $_GET)) { + $data['name'] = $_GET['name']; + } else { + return NULL; + } + + if (array_key_exists('lightmode', $_GET)) { + $data['lightmode'] = $_GET['lightmode']; + } else { + $data['lightmode'] = 'false'; + } + + return $data; + } +} +?> diff --git a/src/web/_model/error.php b/src/web/_model/error.php new file mode 100644 index 0000000..0a08fdd --- /dev/null +++ b/src/web/_model/error.php @@ -0,0 +1,31 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Error_model extends Model { + + function __construct($load) { + parent::__construct($load); + } + + private function get_msg(&$data) { + if (!array_key_exists('code', $_GET)) { + http_response_code(500); + $data['msg'] = ucfirst(lang('error')); + $data['title'] = '500'; + } else { + $code = $_GET['code']; + http_response_code($code); + $data['title'] = $code; + $msg = ucfirst(lang('error_' . $code, FALSE)); + if (!$msg) { + $msg = ucfirst(lang('error')); + } + $data['msg'] = $msg; + } + } + + public function get_data(): ?array { + $data = parent::get_data(); + $this->get_msg($data); + return $data; + } +} +?> diff --git a/src/web/_model/main.php b/src/web/_model/main.php new file mode 100644 index 0000000..6767010 --- /dev/null +++ b/src/web/_model/main.php @@ -0,0 +1,97 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Main_model extends Model { + + // stores the current request info + public $info; + + // the main loader + public $load; + + /** + * Loads the main model + * @param Loader $load - the main loader object + */ + function __construct($load) { + parent::__construct($load, TRUE); + $GLOBALS['main_model'] = $this; + } + + /** + * Gets the stamp for a asset path + * @param string $path + */ + private function asset_stamp($path): int { + $root = $GLOBALS['webroot']; + $path = $root . '/../public/' . $path; + return filemtime($path); + } + + /** + * Get the current IE version + * @returns the IE version if valid IE user agent, INT_MAX if not + */ + public function get_ie_version(): int { + if (preg_match('/MSIE\s(?P<v>\d+)/i', @$_SERVER['HTTP_USER_AGENT'], $B)) { + return $B['v']; + } else { + return PHP_INT_MAX; + } + } + + /** + * Gets the full url including the http scheme and host part + * Needed for IE 6 & 7 need. + */ + public function get_url_full($path): string { + $host = $_SERVER['HTTP_HOST']; + $base = lang('base_path'); + $url = "http://{$host}{$base}{$path}"; + return $url; + } + + /** + * Gets a full path url from a relative path + */ + public function get_url($path): string { + if ($this->get_ie_version() <= 7) { + return $this->get_url_full($path); + } + $base = lang('base_path'); + $url = "{$base}{$path}"; + return $url; + } + + /** + * Loads a css html link + * @param string $path - the path to the css file + */ + public function link_css($path): string { + $stamp = $this->asset_stamp($path); + $href = $this->get_url("public/{$path}?stamp={$stamp}"); + return '<link rel="stylesheet" href="'. $href .'">'; + } + + /** + * Loads a css html link + * @param string $path - the path to the css file + */ + public function embed_css($path): string { + $file = $GLOBALS['publicroot'] . '/' . $path; + if (file_exists($file)) { + $text = file_get_contents($file); + return "<style>{$text}</style>"; + } else { + return ""; + } + } + + /** + * Formats a ISO date + * @param $iso_date the ISO date + */ + public function format_date($iso_date): string { + return date("Y-m-d D H:m", strtotime($iso_date)); + } +} + +?> diff --git a/src/web/_model/projects.php b/src/web/_model/projects.php new file mode 100644 index 0000000..5373a78 --- /dev/null +++ b/src/web/_model/projects.php @@ -0,0 +1,36 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Projects_model extends Model { + + private $markdown; + + function __construct($load) { + parent::__construct($load); + $this->markdown = new MarkdownParser(); + } + + private function load_projects(&$data) { + $projects = array(); + $dir = $GLOBALS['assetroot'] . '/projects'; + if ($handle = opendir($dir)) { + while (false !== ($entry = readdir($handle))) { + if (str_starts_with($entry, ".")) { + continue; + } + $path = $dir . '/' . $entry; + $md = $this->markdown->parse($path); + $projects[$entry] = $md; + } + } + krsort($projects); + $data['projects'] = $projects; + } + + public function get_data(): ?array { + $data = parent::get_data(); + $this->load_projects($data); + $data['title'] = lang('title'); + $data['desc'] = lang('short_desc'); + return $data; + } +} +?> diff --git a/src/web/_views/apps/blog.php b/src/web/_views/apps/blog.php new file mode 100644 index 0000000..e8908e6 --- /dev/null +++ b/src/web/_views/apps/blog.php @@ -0,0 +1,13 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<?=aria_section('blog', lang('title'))?> + <p><?=lang('blog_desc')?></p> + <?php + foreach($blog as $name => $post) { + $meta = $post['meta']; + $link = $this->main->get_url('blog/post?name=' . $name); + echo '<a href="' . $link . '"><h3>' . $meta['name'] . '</h3></a>'; + echo '<span>' . $meta['desc'] . '</span><br>'; + echo '<span><time>' . $this->main->format_date($meta['date']) . '</time></span>'; + } + ?> +</div> diff --git a/src/web/_views/apps/blog_post.php b/src/web/_views/apps/blog_post.php new file mode 100644 index 0000000..d5ad255 --- /dev/null +++ b/src/web/_views/apps/blog_post.php @@ -0,0 +1,6 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<?=aria_section('post', $post['meta']['name'])?> + <span><?=ucfirst(lang('posted'))?>: <time><?=$this->main->format_date($post['meta']['date'])?></time></span> + <br> + <?=$post['content']?> +</div> diff --git a/src/web/_views/apps/blog_rss.php b/src/web/_views/apps/blog_rss.php new file mode 100644 index 0000000..1d0dcd4 --- /dev/null +++ b/src/web/_views/apps/blog_rss.php @@ -0,0 +1,20 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<rss version="2.0"> + <channel> + <title><?=lang('title')?></title> + <link><?=lang('root_url') . '/blog'?></link> + <description><?=lang('blog_short_desc')?></description> + <language><?=lang('lang_short')?></language> + <?php + foreach ($blog as $name => $post) { + echo '<item>'; + echo '<title>' . $post['meta']['name'] . '</title>'; + echo '<description>' . $post['meta']['desc'] . '</description>'; + echo '<pubDate>' . $post['meta']['date'] . '</pubDate>'; + echo '<link>' . lang('root_url') . '/blog/post?name=' . $name . '</link>'; + echo '<guid>' . lang('root_url') . '/blog/post?name=' . $name . '</guid>'; + echo '</item>'; + } + ?> + </channel> +</rss> diff --git a/src/web/_views/apps/blog_writeup.php b/src/web/_views/apps/blog_writeup.php new file mode 100644 index 0000000..b0f18e7 --- /dev/null +++ b/src/web/_views/apps/blog_writeup.php @@ -0,0 +1,5 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<?=aria_section('writeup', $post['meta']['name'])?> + <br> + <?=$post['content']?> +</div> diff --git a/src/web/_views/apps/bucket.php b/src/web/_views/apps/bucket.php new file mode 100644 index 0000000..58925f1 --- /dev/null +++ b/src/web/_views/apps/bucket.php @@ -0,0 +1,41 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> + <?php + $root='https://webring.bucketfish.me/redirect.html?to=%s&name=' . $name; + $this->view('head', $data); + if ($lightmode === 'true') { + echo $this->main->link_css('css/bucket_light.css'); + } else { + echo $this->main->link_css('css/bucket.css'); + } + ?> + </head> + <body> + <div id="webring"> + <center> + <span class="center"> + ๐ณ๏ธโ๐ + <a href="https://webring.bucketfish.me" class="header"> + <span class="e0">b</span><!-- + --><span class="e1">u</span><!-- + --><span class="e2">c</span><!-- + --><span class="e3">k</span><!-- + --><span class="e4">e</span><!-- + --><span class="e5">t</span><!-- + --> <!-- + --><span class="e6">w</span><!-- + --><span class="e7">e</span><!-- + --><span class="e8">b</span><!-- + --><span class="e9">r</span><!-- + --><span class="e10">i</span><!-- + --><span class="e11">n</span><!-- + --><span class="e12">g</span> + </a> + ๐ณ๏ธโ๐ + </span> + </center> + <span class="left">โฅผ <a href="<?=sprintf($root, 'prev')?>" class="prev">prev</a></span> + <span class="right"><a href="<?=sprintf($root, 'next')?>" class="next">next</a> โฅฝ</span> + </div> + </body> +</html> + diff --git a/src/web/_views/apps/error.php b/src/web/_views/apps/error.php new file mode 100644 index 0000000..efe7546 --- /dev/null +++ b/src/web/_views/apps/error.php @@ -0,0 +1,6 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<?=aria_section('error')?> + <h2><?=lang('haa_haa_hee_hee_hoo_hoo')?></h2> + <h1><?=$title?></h1> + <h2><?=ucfirst($msg)?></h2> +</div> diff --git a/src/web/_views/apps/home.php b/src/web/_views/apps/home.php new file mode 100644 index 0000000..8e59423 --- /dev/null +++ b/src/web/_views/apps/home.php @@ -0,0 +1,55 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<div class="col left"> + <?=aria_section('about', lang('about'))?> + <p><?=lang('section_about')?></p> + </div> + + <?=aria_section('whats_new', lang('whats_new'))?> + <p><?=lang('section_whats_new')?></p> + </div> +</div> + +<div class="col right"> + <?=aria_section('interests', lang('interests'))?> + <table> + <tbody> + <tr> + <th><?=lang('interests_general')?></th> + <td><?=lang('interests_general_value')?></td> + </tr> + <tr> + <th><?=lang('interests_music')?></th> + <td><?=lang('interests_music_value')?></td> + </tr> + <tr> + <th><?=lang('interests_comics')?></th> + <td><?=lang('interests_comics_value')?></td> + </tr> + </tbody> + </table> + </div> + + <?=aria_section('contact', lang('contact'))?> + <table> + <tbody> + <tr> + <th><?=lang('contact_email')?></th> + <td><a href="mailto:contact@freyacat.org">contact@freyacat.org</a></td> + </tr> + <tr> + <th><?=lang('contact_matrix')?></th> + <td><a href="https://matrix.to/#/@freya:freya.cat">@freya:freya.cat</a></td> + </tr> + <tr> + <th><?=lang('contact_xmpp')?></th> + <td><a href="xmpp:freya@freya.cat">freya@freya.cat</a></td> + </tr> + <tr> + <th><?=lang('contact_mastodon')?></th> + <td><a href="https://social.freya.cat/@freya">@freya@freya.cat</a></td> + </tr> + </tbody> + </table> + </div> +</div> + diff --git a/src/web/_views/apps/projects.php b/src/web/_views/apps/projects.php new file mode 100644 index 0000000..bac1004 --- /dev/null +++ b/src/web/_views/apps/projects.php @@ -0,0 +1,11 @@ +<?=aria_section('projects', lang('title'))?> + <?php + foreach($projects as $project) { + $meta = $project['meta']; + $content = $project['content']; + $link = lang('git_url') . '/' . $meta['repo']; + echo '<a href="' . $link . '"><h3>' . $meta['name'] . '</h3></a>'; + echo $content; + } + ?> +</div> diff --git a/src/web/_views/comments.php b/src/web/_views/comments.php new file mode 100644 index 0000000..d72afd6 --- /dev/null +++ b/src/web/_views/comments.php @@ -0,0 +1,51 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<?=aria_section('comments', lang('comments'))?> + <?php + foreach($comments as $comment) { + $date = date_create($comment['created']); + $date = date_format($date, "Y-m-d H:i"); + + echo '<div class="comment">'; + echo '<h3 class="header">' . esc($comment['author']) . '</h3>'; + echo '<span class="date">' . $date . '</span>'; + echo '<p class="content">' . esc($comment['content']) . '</p>'; + echo '</div>'; + } + if (!count($comments)) { + echo '<span>'. lang('no_comments') .'</span>'; + } + ?> + <div class="new"> + <h3><?=lang('new_comment_title')?></h3> + <form id="new_comment" method="get" action="<?=lang('base_path') . '_comments/post'?>"> + <input + type="text" + name="author" + id="author" + aria-label="<?=lang('new_comment_author_label')?>" + placeholder="<?=lang('new_comment_author_ph')?>"> + <input + type="text" + name="content" + id="content" + aria-label="<?=lang('new_comment_content_label')?>" + placeholder="<?=lang('new_comment_content_ph')?>"> + <input + type="hidden" + class="hidden" + name="ref" + value="<?=base64_encode($ref)?>"> + <input + type="hidden" + class="hidden" + name="page" + value="<?=$page?>"> + <input + type="submit" + role="button" + id="submit" + value="<?=lang('new_comment_submit_text')?>"> + </form> + </div> +</div> + diff --git a/src/web/_views/footer.php b/src/web/_views/footer.php new file mode 100644 index 0000000..1eac625 --- /dev/null +++ b/src/web/_views/footer.php @@ -0,0 +1,51 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<?php + $footer_text = lang('footer_text', NULL); + if ($footer_text) { + $footer_text = $footer_text[array_rand($footer_text)]; + } else { + $footer_text = ''; + } + + $legacy = $this->main->get_ie_version() <= 7; +?> + </div> + </div> + <div id="footer" role="contentinfo" aria-label="footer"> + <?=lang('license_pre')?> + <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a> + <br> + <?=lang('copyright')?> <?=lang('first_name')?> <?=lang('last_name')?> <?=date('Y')?> + <br> + <span class="footer-text"> + <?=$footer_text?> + </span> + <div class="buttons"> + <?=image('buttons/eyes', 'alt_button_eyes', animated: TRUE, width: '88', height: '30')?> + <?=image('buttons/vim', 'alt_button_vim', animated: TRUE, width: '88', height: '30')?> + <?=image('buttons/gnu-linux', 'alt_button_gnu_linux', width: '88', height: '30')?> + <a href="https://citrons.xyz/a/memetic-apioform-page.html"> + <?=image('buttons/apiopage', 'alt_button_apiopage', width: '81', height: '30')?> + </a> + </div> + <br> + <iframe + height="94" + class="john" + title="<?=lang('john_title')?>" + src="https://john.citrons.xyz/embed?ref=freya.cat" + ></iframe> + <iframe + height="40" + class="bucket" + title="<?=lang('bucket_title')?>" + src="<?=$this->main->get_url('bucket?name=freya')?>" + ></iframe> + </div> +<?php if($legacy): ?> + </center> +<?php else: ?> + </div> +<?php endif; ?> + </body> +</html> diff --git a/src/web/_views/head.php b/src/web/_views/head.php new file mode 100644 index 0000000..e30e05c --- /dev/null +++ b/src/web/_views/head.php @@ -0,0 +1,29 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<!DOCTYPE html> +<html lang="<?=lang('lang_short')?>"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="author" content="freya"> + <meta name="description" content="<?=$desc?>"> + <meta name="theme-color" content="<?=lang('theme_color')?>"> + <meta name="referrer" content="origin"> + <meta name="color-scheme" content="none"> + <meta property="og:description" content="<?=$desc?>"> + <meta property="og:title" content="<?=$title?>"> + <meta property="og:site_name" content="<?=lang('domain')?>"> + <meta property="og:image" content="<?=$this->main->get_url_full('public/icons/logo640.png')?>"> + <title><?=$title?></title> + <link rel="icon" type="image/png" sizes="16x16" href="/public/icons/logo16.png"> + <link rel="icon" type="image/png" sizes="32x32" href="/public/icons/logo32.png"> + <link rel="icon" type="image/png" sizes="64x64" href="/public/icons/logo64.png"> + <link rel="icon" type="image/png" sizes="320x320" href="/public/icons/logo320.png"> + <link rel="icon" type="image/png" sizes="512x512" href="/public/icons/logo512.png"> + <link rel="icon" type="image/png" sizes="640x640" href="/public/icons/logo640.png"> + <link rel="manifest" href="/manifest.json"> + <?php if($this->main->get_ie_version() <= 7) + echo $this->main->link_css('css/legacy.css'); + ?> + <?php foreach($css as $file) + echo $this->main->embed_css($file); + ?> diff --git a/src/web/_views/header.php b/src/web/_views/header.php new file mode 100644 index 0000000..b037038 --- /dev/null +++ b/src/web/_views/header.php @@ -0,0 +1,43 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ ?> +<?php + $this->view('head', $data); + echo $this->main->link_css('css/main.css'); + $legacy = $this->main->get_ie_version() <= 7; +?> +</head> +<body> +<?php if($legacy): ?> +<center> +<?php else: ?> +<div class="center"> +<?php endif; ?> + <div id="header" role="banner" aria-label="banner"> + <?=image('img/headerLogo', 'alt_website_logo', size: '200')?> + <div class="content"> + <h1 class="logo-text"> + <?=lang('first_name')?> + </h1> + <div role="navigation"> + <ul id="nav"> + <li><?=ilang('action_home', + href: $this->main->get_url('home'), + container: 'h2' + )?></li> + <li><?=ilang('action_projects', + href: $this->main->get_url('projects'), + container: 'h2' + )?></li> + <li><?=ilang('action_blog', + href: $this->main->get_url('blog'), + container: 'h2' + )?></li> + </ul> + </div> + </div> + </div> +<?php if($legacy): ?> + <div id="main" class="legacy" role="main"> +<?php else: ?> + <div id="main" role="main" aria-label="main"> +<?php endif; ?> + <div id="container"> diff --git a/src/web/config/routes.php b/src/web/config/routes.php new file mode 100644 index 0000000..cb78a72 --- /dev/null +++ b/src/web/config/routes.php @@ -0,0 +1,10 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +$routes = array(); +$routes[''] = 'home'; + +$routes['robots.txt'] = '_meta/robots'; +$routes['sitemap.xml'] = '_meta/sitemap'; +$routes['manifest.json'] = '_meta/manifest'; + +$serviceable = array('bucket'); diff --git a/src/web/config/style.php b/src/web/config/style.php new file mode 100644 index 0000000..6b29fab --- /dev/null +++ b/src/web/config/style.php @@ -0,0 +1,7 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +$style = array(); + +$style['home'] = 'css/home.css'; +$style['blog'] = 'css/blog.css'; +$style['error'] = 'css/error.css'; diff --git a/src/web/core/_controller.php b/src/web/core/_controller.php new file mode 100644 index 0000000..0dbb5b8 --- /dev/null +++ b/src/web/core/_controller.php @@ -0,0 +1,51 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +abstract class Controller { + + // the main model + public $main; + + // the loader + public $load; + + /** + * Creates a constructor + * @param Loader $load - the website loaded object + */ + function __construct($load) { + $this->load = $load; + $this->main = $this->load->model('main'); + + $this->load->lang(); + $info = $this->main->info; + $app = $info['app']; + if ($app) { + $this->load->app_lang($app); + } + } + + public function index() {} + + public function redirect($link) { + header('Location: '. $link, true, 301); + die(); + } + + protected function view($__name, $data = array()) { + $__root = $GLOBALS['webroot']; + $__path = $__root . '/_views/' . $__name . '.php'; + if (is_file($__path)) { + extract($data); + require($__path); + return; + } + } + + protected function error($code): void { + $_GET['code'] = $code; + $this->main->info['app'] = 'error'; + $error_controller = $this->load->controller('error'); + $error_controller->index(); + die(); + } + +} diff --git a/src/web/core/_model.php b/src/web/core/_model.php new file mode 100644 index 0000000..4c27b1b --- /dev/null +++ b/src/web/core/_model.php @@ -0,0 +1,50 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +abstract class Model { + // the main model + // shared by all controllers and models + public $main; + public $load; + + // the database + public $db; + + private $config; + + /** + * Creates a model + * @param Loader $load - the main loader object + */ + function __construct($load, $main = FALSE) { + $this->load = $load; + if ($main) { + $this->main = $this; + } else { + $this->main = $this->load->model('main'); + } + $this->db = $this->load->db(); + } + + /** + * @returns the base model data + */ + public function get_data(): ?array { + $data = array(); + + $info = $this->main->info; + $app = $info['app']; + + $data['title'] = lang('first_name'); + $data['desc'] = lang('default_short_desc'); + $data['css'] = array(); + + $style = $GLOBALS['style']; + if (isset($style[$app])) { + $css = $style[$app]; + if (!is_array($css)) + $css = array($css); + $data['css'] = $css; + } + + return $data; + } +} diff --git a/src/web/core/loader.php b/src/web/core/loader.php new file mode 100644 index 0000000..b0a1cbd --- /dev/null +++ b/src/web/core/loader.php @@ -0,0 +1,111 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Loader { + + // keep track of what has been loaded + private $loaded; + + // the database + private $db; + + function __construct() { + $this->loaded = array(); + } + + /** + * Loads a $type of object from a $dir with a given $name + * @param string $name - the name of the object to load + * @param string $dir - the directory theese objects are stored in + * @param string $type - the type of the object + */ + private function load_type($name, $dir, $type): object|NULL { + $path = $dir . '/' . $name . '.php'; + if (array_key_exists($path, $this->loaded)) { + return $this->loaded[$path]; + } + + if (!file_exists($path)) { + return NULL; + } + + $parts = explode('/', $name); + $part = end($parts); + $class = ucfirst($part) . '_' . $type; + require($path); + + $ref = NULL; + try { + $ref = new ReflectionClass($class); + } catch (Exception $_e) {} + + if ($ref === NULL) { + return NULL; + } + + $obj = $ref->newInstance($this); + $this->loaded[$path] = $obj; + + return $obj; + } + + /** + * Loads a model + * @param string $name - the name of the model to load + */ + public function model($name): object|NULL { + $root = $GLOBALS['webroot']; + $dir = $root . '/_model'; + return $this->load_type($name, $dir, 'model'); + } + + /** + * Loads a controller + * @param string $name - the name of the controller to load + */ + public function controller($name): Controller|NULL { + $root = $GLOBALS['webroot']; + $dir = $root . '/_controller'; + return $this->load_type($name, $dir, 'controller'); + } + + /** + * Loads the given common lang + */ + public function lang(): void { + $dir = $GLOBALS['webroot'] . '/lang/'; + $lang = $GLOBALS['lang']; + if ($handle = opendir($dir)) { + while (false !== ($entry = readdir($handle))) { + if ($entry === '.' || $entry === '..' || $entry === 'apps') { + continue; + } + $path = $dir . $entry; + require($path); + } + } + $GLOBALS['lang'] = $lang; + } + + /** + * Loads a given app specific lang + * @param string $name - the name of the app + */ + public function app_lang($name): void { + $dir = $GLOBALS['webroot'] . '/lang/apps/'; + $file = $dir . $name . '.php'; + if (file_exists($file)) { + $lang = $GLOBALS['lang']; + require($dir . $name . '.php'); + $GLOBALS['lang'] = $lang; + } + } + + public function db() { + if ($this->db) { + return $this->db; + } else { + $this->db = new DatabaseHelper(); + return $this->db; + } + } + +} diff --git a/src/web/core/router.php b/src/web/core/router.php new file mode 100644 index 0000000..c8fb142 --- /dev/null +++ b/src/web/core/router.php @@ -0,0 +1,248 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +class Router { + + // the loader + private $load; + + // the main model + private $main; + + // the database + private $db; + + private $db_ready; + + /** + * Creates a router + * @param Loader $load - the main laoder object + */ + function __construct($load) { + $this->load = $load; + $this->db = $load->db(); + $this->main = $this->load->model('main'); + $this->db_ready = file_exists('/status/ready'); + } + + /** + * @param string $path - the current request path + * Gets the current route + * @return array<string,mixed> + */ + private function get_req_route($path): array { + // trim the path + $path = trim($path); + // remove first '/' + $path = substr($path, 1); + // get path parts + $parts = explode('/', $path); + + $len = count($parts); + + // get route info + $route = array(); + // e.g. / + if ($path === '') { + $route = array( + 'app' => '', + 'slug' => 'index', + ); + // e.g. /home /login + } else if ($len === 1) { + $route = array( + 'app' => $parts[0], + 'slug' => 'index', + ); + // e.g. /home/posts + } else { + $route = array ( + 'app' => implode('/', array_slice($parts, 0, -1)), + 'slug' => end($parts) + ); + }; + + $routes = $GLOBALS['routes']; + if (array_key_exists($route['app'], $routes)) { + $parts = explode('/', $routes[$route['app']]); + if (count($parts) == 1) { + $route['app'] = $parts[0]; + } else { + $route['app'] = $parts[0]; + $route['slug'] = $parts[1]; + } + } + + return $route; + } + + /** + * Gets the users ip + */ + private function get_ip(): string { + $ip = ''; + if (!empty($_SERVER['HTTP_CLIENT_IP'])) { + $ip = $_SERVER['HTTP_CLIENT_IP']; + } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ip = $_SERVER['HTTP_X_FORWARDED_FOR']; + } else { + $ip = $_SERVER['REMOTE_ADDR']; + } + return $ip; + } + + /** + * Gets the curret request info + * @return array<string,mixed> + */ + private function get_req(): array|bool { + $method = $_SERVER['REQUEST_METHOD']; + + $uri_str = $_SERVER['REQUEST_URI']; + $uri = parse_url($uri_str); + if (!$uri) { + return FALSE; + } + + $path = ''; + if (array_key_exists('path', $uri)) { + $path = $uri['path']; + } + + return array_merge( + array( + 'uri' => $uri, + 'uri_str' => $uri_str, + 'method' => $method, + 'ip' => $this->get_ip() + ), + $this->get_req_route($path), + ); + } + + /** + * Handles a router error code + * @param int $code - the http error code + * @param bool $recursed + */ + private function handle_error($code, $recursed): void { + if ($recursed) { + die($code . ' (recursed)'); + } + $uri_str = $_SERVER['REQUEST_URI']; + $req = array(); + $req['slug'] = 'index'; + $req['app'] = 'error'; + $req['uri_str'] = $uri_str; + $this->main->info = $req; + $_GET['code'] = $code; + $this->handle_req($req, TRUE); + } + + private function load_htc($req, $recursed): void { + $parts = explode('/', $req['uri_str']); + $file = end($parts); + $path = $GLOBALS['publicroot'] . '/polyfills/' . $file; + + if (file_exists($path)) { + header('Content-type: text/x-component'); + include($path); + } else { + $this->handle_error(400, $recursed); + } + } + + /** + * @param array $req + * @param bool $recursed + */ + private function handle_req($req, $recursed = FALSE): void { + + if ($recursed === false) { + if ( + $this->db_ready === false && + in_array($req['app'], $GLOBALS['serviceable']) === false + ) { + $this->handle_error(503, $recursed); + return; + } + + if ($this->check_banned($req)) { + $this->handle_error(401, $recursed); + return; + } + } + + if (!$req) { + $this->handle_error(500, $recursed); + return; + } + + if (str_ends_with($req['uri_str'], '.htc')) { + $this->load_htc($req, $recursed); + return; + } + + $controller = $this->load->controller($req['app']); + + if ($controller === NULL) { + $this->handle_error(404, $recursed); + return; + } + + $ref = NULL; + try { + $ref = new ReflectionMethod($controller, $req['slug']); + } catch (Exception $_e) {} + + if ($ref === NULL || !$ref->isPublic()) { + $this->handle_error(404, $recursed); + return; + + } + + $ref->invoke($controller); + } + + private function log_request($req): void { + if ( + $req === FALSE || + $this->db_ready === FALSE || + in_array($req['app'], $GLOBALS['serviceable']) + ) { + return; + } + + $query = $this->db + ->insert_into('admin.request_log', + 'ip', 'method', 'uri') + ->values( + $req['ip'], $req['method'], $req['uri_str']); + + $query->execute(); + } + + private function check_banned($req) { + $ip = FALSE; + if ($req) { + $ip = $req['ip']; + } else { + $ip = $this->get_ip(); + } + $query = $this->db + ->select('TRUE') + ->from('admin.banned') + ->where('ip')->eq($ip); + + return !!($query->row()); + } + + /** + * Handels the incomming reuqest + */ + public function handle_request(): void { + $req = $this->get_req(); + $this->log_request($req); + $this->main->info = $req; + $this->handle_req($req); + } + +} diff --git a/src/web/helpers/aria.php b/src/web/helpers/aria.php new file mode 100644 index 0000000..8ebfcc5 --- /dev/null +++ b/src/web/helpers/aria.php @@ -0,0 +1,16 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +function aria_section($id, $title = NULL): string { + $out = ''; + if ($title) { + $idh = $id . '_heading'; + $out .= sprintf('<div id="%s" class="section" role="region" aria-labelledby="%s">', + $id, $idh); + $out .= sprintf('<h2 class="heading" id="%s">%s</h2>', + $idh, $title); + } else { + $out .= sprintf('<div id="%s" class="section" role="region">', + $id); + } + return $out; +} diff --git a/src/web/helpers/database.php b/src/web/helpers/database.php new file mode 100644 index 0000000..25cb5ba --- /dev/null +++ b/src/web/helpers/database.php @@ -0,0 +1,282 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +class DatabaseQuery { + + private $conn; + private $query; + + private $where; + private $set; + + private $param; + + function __construct($conn) { + $this->conn = $conn; + $this->query = ''; + + $this->set = FALSE; + $this->where = FALSE; + $this->param = array(); + } + + /// + /// ARBITRARY QUERY + /// + + public function query($query) { + $this->query .= $query; + return $this; + } + + /// + /// SELECT + /// + + public function select($select) { + $this->query .= "SELECT $select\n"; + return $this; + } + + public function from($from) { + $this->query .= "FROM $from\n"; + return $this; + } + + /// + /// INSERT + /// + + public function insert_into($insert, ...$columns) { + $this->query .= "INSERT INTO $insert\n ("; + foreach ($columns as $idx => $column) { + if ($idx !== 0) { + $this->query .= ","; + } + $this->query .= $column; + } + $this->query .= ")\n"; + return $this; + } + + public function values(...$values) { + $this->query .= "VALUES ("; + foreach ($values as $idx => $value) { + if ($idx !== 0) { + $this->query .= ","; + } + $this->query .= "?"; + array_push($this->param, $value); + } + $this->query .= ")\n"; + return $this; + } + + /// + /// WHERE + /// + + public function where($cond) { + if (!$this->where) { + $this->where = TRUE; + $this->query .= "WHERE "; + } else { + $this->query .= "AND "; + } + $this->query .= "$cond "; + return $this; + } + + public function where_in($column, $array) { + if (!$this->where) { + $this->where = TRUE; + $this->query .= "WHERE "; + } else { + $this->query .= "AND "; + } + if (empty($array)) { + $this->query .= "FALSE\n"; + return $this; + } + $in = $this->in($array); + $this->query .= "$column $in\n"; + return $this; + } + + private function in($array) { + $in = 'IN ('; + foreach ($array as $idx => $item) { + if ($idx != 0) { + $in .= ","; + } + $in .= "?"; + array_push($this->param, $item); + } + $in .= ")"; + return $in; + } + + /// + /// OPERATORS + /// + + public function like($item) { + $this->query .= "LIKE ?\n"; + array_push($this->param, $item); + return $this; + } + + public function eq($item) { + $this->query .= "= ?\n"; + array_push($this->param, $item); + return $this; + } + + public function ne($item) { + $this->query .= "<> ?\n"; + array_push($this->param, $item); + return $this; + } + + public function lt($item) { + $this->query .= "< ?\n"; + array_push($this->param, $item); + return $this; + } + + public function le($item) { + $this->query .= "<= ?\n"; + array_push($this->param, $item); + return $this; + } + + /// + /// JOINS + /// + + public function join($table, $on, $type = 'LEFT') { + $this->query .= "$type JOIN $table ON $on\n"; + return $this; + } + + /// + /// LIMIT, OFFSET, ORDER + /// + + public function limit($limit) { + $this->query .= "LIMIT ?\n"; + array_push($this->param, $limit); + return $this; + } + + public function offset($offset) { + $this->query .= "OFFSET ?\n"; + array_push($this->param, $offset); + return $this; + } + + public function order_by($column, $order = 'ASC') { + $this->query .= "ORDER BY " . $column . ' ' . $order . ' '; + return $this; + } + + /// + /// COLLECT + /// + + public function rows(...$params) { + $args = $this->param; + foreach ($params as $param) { + array_push($args, $param); + } + $stmt = $this->conn->prepare($this->query); + try { + $stmt->execute($args); + } catch (Exception $ex) { + echo $ex; + echo '<br> >> caused by <<<br>'; + echo str_replace("\n", "<br>", $this->query); + } + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function row(...$params) { + $args = $this->param; + foreach ($params as $param) { + array_push($args, $param); + } + $stmt = $this->conn->prepare($this->query); + $stmt->execute($args); + return $stmt->fetch(PDO::FETCH_ASSOC); + } + + public function execute(...$params) { + $args = $this->param; + foreach ($params as $param) { + array_push($args, $param); + } + $stmt = $this->conn->prepare($this->query); + try { + $stmt->execute($args); + return TRUE; + } catch (Exception $_e) { + echo $_e; + echo '<br> >> caused by <<<br>'; + echo str_replace("\n", "<br>", $this->query); + return FALSE; + } + } +} + +/** + * DatabaseHelper + * allows queries on the + * postgres database + */ +class DatabaseHelper { + + private $conn; + + function __construct() { + $this->conn = NULL; + } + + private function connect() { + if ($this->conn === NULL) { + $user = getenv("POSTGRES_USER"); + $pass = getenv("POSTGRES_PASSWORD"); + $db = getenv("POSTGRES_DB"); + $host = 'db'; + $port = '5432'; + + $conn_str = sprintf("pgsql:host=%s;port=%d;dbname=%s;user=%s;password=%s", + $host, + $port, + $db, + $user, + $pass + ); + $this->conn = new \PDO($conn_str); + $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + } + return $this->conn; + } + + public function select($select) { + $conn = $this->connect(); + $query = new DatabaseQuery($conn); + return $query->select($select); + } + + public function insert_into($insert, ...$columns) { + $conn = $this->connect(); + $query = new DatabaseQuery($conn); + return $query->insert_into($insert, ...$columns); + } + + public function query($query_str) { + $conn = $this->connect(); + $query = new DatabaseQuery($conn); + return $query->query($query_str); + } +} + diff --git a/src/web/helpers/image.php b/src/web/helpers/image.php new file mode 100644 index 0000000..c18154a --- /dev/null +++ b/src/web/helpers/image.php @@ -0,0 +1,94 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +function __get_mime($type) { + switch ($type) { + case 'mp4': + return 'video/mp4'; + case 'webm': + return 'video/webm'; + case 'gif': + return 'image/gif'; + case 'png': + return 'image/png'; + case 'jpg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + default: + return NULL; + } +} + +function __make_source( + $name, + $format, + $media +) { + if ($media) { + $media = "media=\"$media\""; + } else { + $media = ''; + } + $main = $GLOBALS['main_model']; + $path = $main->get_url('public/' . $name . '.' . $format); + $mime = __get_mime($format); + return sprintf('<source type="%s" srcset="%s" %s>', + $mime, $path, $media); +} + +function image( + $name, + $alt, + $formats = array('webp', 'png'), + $animated = FALSE, + $attrs = array(), + + $height = NULL, + $width = NULL, + $size = NULL, +) :string { + + if ($animated === TRUE) { + $animated = array('gif'); + } + + if (!$animated) { + $animated = array(); + } + + $out = "<picture>"; + + foreach ($formats as $format) { + $media = count($animated) ? '(prefers-reduced-motion: reduce)' : NULL; + $out .= __make_source($name, $format, $media); + } + + foreach ($animated as $format) { + $out .= __make_source($name, $format, NULL); + } + + $format = end($formats); + $main = $GLOBALS['main_model']; + $path = $main->get_url('public/' . $name . '.' . $format); + $out .= "<img src=\"$path\""; + if ($alt) { + $alt = lang($alt); + $attrs['alt'] = $alt; + } + if ($width) { + $attrs['width'] = $width; + } + if ($height) { + $attrs['height'] = $height; + } + if ($size) { + $attrs['width'] = $size; + $attrs['height'] = $size; + } + foreach ($attrs as $key => $value) { + $out .= " $key=\"$value\""; + } + $out .= '></picture>'; + + return $out; +} diff --git a/src/web/helpers/lang.php b/src/web/helpers/lang.php new file mode 100644 index 0000000..e8fa29e --- /dev/null +++ b/src/web/helpers/lang.php @@ -0,0 +1,79 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ +$lang = array(); + +function lang($key, $default = NULL, $sub = NULL) { + $lang = $GLOBALS['lang']; + if(array_key_exists($key, $lang)) { + if ($sub) { + return sprintf($lang[$key], ...$sub); + } else { + return $lang[$key]; + } + } else if ($default !== NULL) { + return $default; + } else { + trigger_error('Undefined lang string: ' . $key, E_USER_WARNING); + return $key; + } +} + +function ilang($key, + $class = NULL, + $id = NULL, + $href = NULL, + $click = NULL, + $attrs = array(), + $sub = NULL, + $button = FALSE, + $container = 'span' +) { + $text = ucfirst(lang($key . "_text", FALSE, sub: $sub)); + $tip = lang($key . "_tip", FALSE, sub: $sub); + $icon = lang($key . "_icon", FALSE); + $content = lang($key . "_content", FALSE); + + if ($click || $button) { + echo '<button '; + } else { + echo '<a '; + } + if ($tip) { + echo 'title="' . $tip . '" '; + echo 'aria-label="' . $tip . '" '; + } + if ($class) { + echo 'class="' . $class . '" '; + } + if ($id) { + echo 'id="' . $id . '" '; + } + if ($click) { + echo 'onclick="' . $click . '" '; + } + if ($href) { + echo 'href="' . $href . '" '; + } + foreach ($attrs as $key => $attr) { + echo $key . '="' . $attr . '" '; + } + echo '> '; + if ($icon) { + echo '<i class="' . $icon . '">'; + if ($content) { + echo $content; + } + echo '</i>'; + } + if ($text) { + echo '<' . $container; + if ($icon) { + echo ' class="ml-sm"'; + } + echo '>' . $text . '</' . $container . '>'; + } + if ($click || $button) { + echo '</button>'; + } else { + echo '</a>'; + } +} diff --git a/src/web/helpers/markdown.php b/src/web/helpers/markdown.php new file mode 100644 index 0000000..39b430c --- /dev/null +++ b/src/web/helpers/markdown.php @@ -0,0 +1,33 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +class MarkdownParser { + + private $parsedown; + + function __construct() { + $this->parsedown = new ParsedownExtra(); + } + + function parse($path) { + $content = file_get_contents($path); + $data = array( + 'meta' => array(), + 'content' => $content + ); + if (str_starts_with($content, '---')) { + $parts = explode('---', $content); + $data['content'] = trim(implode('---', array_slice($parts, 2))); + $meta = array_filter(explode("\n", $parts[1]), fn($x) => $x != ''); + foreach ($meta as $set) { + $parts = explode(": ", $set); + $key = trim($parts[0]); + $value = trim($parts[1]); + $data['meta'][$key] = $value; + } + + } + $data['content'] = $this->parsedown->text($data['content']); + return $data; + } + +} diff --git a/src/web/helpers/sanitize.php b/src/web/helpers/sanitize.php new file mode 100644 index 0000000..5d37852 --- /dev/null +++ b/src/web/helpers/sanitize.php @@ -0,0 +1,8 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +function esc($data) { + $data = str_replace('&', '&', $data); + $data = str_replace('<', '<', $data); + $data = str_replace('>', '>', $data); + return $data; +} diff --git a/src/web/index.php b/src/web/index.php new file mode 100644 index 0000000..e33e750 --- /dev/null +++ b/src/web/index.php @@ -0,0 +1,38 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +ini_set('html_errors', '1'); + +$webroot = dirname(__FILE__); +$assetroot = realpath(dirname(__FILE__) . '/../assets'); +$publicroot = realpath(dirname(__FILE__) . '/../public'); +$main_model = NULL; + +// loadd all third party +require($webroot . '/third_party/parsedown.php'); +require($webroot . '/third_party/parsedown_extra.php'); + +// load all the config files +require($webroot . '/config/routes.php'); +require($webroot . '/config/style.php'); + +// load all the helpers +require($webroot . '/helpers/lang.php'); +require($webroot . '/helpers/aria.php'); +require($webroot . '/helpers/image.php'); +require($webroot . '/helpers/markdown.php'); +require($webroot . '/helpers/database.php'); +require($webroot . '/helpers/sanitize.php'); + +// load all core files +require($webroot . '/core/_controller.php'); +require($webroot . '/core/_model.php'); +require($webroot . '/core/loader.php'); +require($webroot . '/core/router.php'); + +function __init() { + $load = new Loader(); + $router = new Router($load); + $router->handle_request(); +}; + +__init(); diff --git a/src/web/lang/apps/blog.php b/src/web/lang/apps/blog.php new file mode 100644 index 0000000..d860a8a --- /dev/null +++ b/src/web/lang/apps/blog.php @@ -0,0 +1,16 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +$lang['title'] = 'Blog'; +$lang['posted'] = 'posted'; + +$lang['blog_short_desc'] = 'I post things here sometimes'; +$lang['blog_desc'] = 'This is my blog! You can read the posts listed here, or you can find them on my <a href="blog/rss">RSS</a> feed.'; + +$lang['comments'] = 'Comments'; +$lang['no_comments'] = 'No comments'; +$lang['new_comment_title'] = 'New comment:'; +$lang['new_comment_author_ph'] = 'Identify yourself'; +$lang['new_comment_author_label'] = 'Name'; +$lang['new_comment_content_ph'] = 'Write the stuff'; +$lang['new_comment_content_label'] = 'Content'; +$lang['new_comment_submit_text'] = 'Post Comment'; diff --git a/src/web/lang/apps/error.php b/src/web/lang/apps/error.php new file mode 100644 index 0000000..79574b5 --- /dev/null +++ b/src/web/lang/apps/error.php @@ -0,0 +1,13 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +$lang['error_400'] = 'Bad request'; +$lang['error_401'] = 'Forbidden'; +$lang['error_404'] = 'Resource not found'; +$lang['error_413'] = 'Request too large'; +$lang['error_500'] = 'Whoops! Server error :('; +$lang['error_503'] = 'Service unavailable'; +$lang['error'] = 'An unknown error has occoured'; + +$lang['haa_haa_hee_hee_hoo_hoo'] = 'Haa Haa. Hee Hee. Hoo Hoo.'; + +?> diff --git a/src/web/lang/apps/home.php b/src/web/lang/apps/home.php new file mode 100644 index 0000000..b3e3a16 --- /dev/null +++ b/src/web/lang/apps/home.php @@ -0,0 +1,35 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +$lang['title'] = $lang['first_name']; + +# sections +$lang['about'] = 'About'; +$lang['section_about'] = ' +Hello! My name is Freya, and I am a person on the internet. +I like to make projects that involve many things, though some of my +favorites are operating systems, programming languages, and networking +infrastructure. +'; + +$lang['whats_new'] = 'Whats New?'; +$lang['section_whats_new'] = ' +Every so often I post ideas, thoughts, or +revelations that I have on my blog. +Feel free to check it out at any time. (โโฟโ) +'; + +# interests +$lang['interests'] = 'Interests'; +$lang['interests_general'] = 'General'; +$lang['interests_general_value'] = 'Computing, Anime, FNaF'; +$lang['interests_music'] = 'Music'; +$lang['interests_music_value'] = 'Billy Joel, Linkin Park, Vocaloid, Neil Cicierega'; +$lang['interests_comics'] = 'Comics'; +$lang['interests_comics_value'] = 'Homestuck'; + +# contact +$lang['contact'] = 'Contact Me'; +$lang['contact_email'] = 'Email'; +$lang['contact_matrix'] = 'Matrix'; +$lang['contact_xmpp'] = 'XMPP'; +$lang['contact_mastodon'] = 'Mastodon'; diff --git a/src/web/lang/apps/projects.php b/src/web/lang/apps/projects.php new file mode 100644 index 0000000..8302691 --- /dev/null +++ b/src/web/lang/apps/projects.php @@ -0,0 +1,4 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +$lang['title'] = 'Projects'; +$lang['short_desc'] = 'Things that I have made'; diff --git a/src/web/lang/common.php b/src/web/lang/common.php new file mode 100644 index 0000000..442e4c1 --- /dev/null +++ b/src/web/lang/common.php @@ -0,0 +1,60 @@ +<?php /* Copyright (c) 2024 Freya Murphy */ + +# things +$lang['lang_short'] = 'en'; +$lang['lang_full'] = 'en_US'; + +$lang['domain'] = 'freya.cat'; +$lang['base_path'] = '/'; +$lang['root_url'] = sprintf("https://%s%s", $lang['domain'], $lang['base_path']); +$lang['git_url'] = 'https://g.freya.cat/freya'; +$lang['theme_color'] = '#181818'; + +# names +$lang['first_name'] = 'Freya'; +$lang['last_name'] = 'Murphy'; + +$lang['default_short_desc'] = 'Hi I exist'; + +# common actions +$lang['action_home_text'] = 'Home'; +$lang['action_home_tip'] = 'View my home page.'; +$lang['action_projects_text'] = 'Projects'; +$lang['action_projects_tip'] = 'View my projects.'; +$lang['action_blog_text'] = 'Blog'; +$lang['action_blog_tip'] = 'View my blog'; + +# common alt text +$lang['alt_button_eyes'] = 'Best viewed with eyes'; +$lang['alt_button_vim'] = 'Edited with VIM'; +$lang['alt_button_gnu_linux'] = 'Made with GNU/Linux'; +$lang['alt_button_apiopage'] = 'Memetic apiopage'; +$lang['alt_website_logo'] = 'Website Logo'; + +# misc +$lang['john_title'] = 'Johnvertisement'; +$lang['bucket_title'] = 'Bucket Webring'; +$lang['license_pre'] = 'This site is licensed under the'; +$lang['copyright'] = 'Copyright (c)'; + +# footer_text +$lang ['footer_text'] = [ + "Always look on the bright side of life", + "๐ powder", + "Submit to john", + "Make sure to feed your computer three meals a day", + "curl https://f.freya.cat/rick/roll.sh | bash", + "medium rare chocolate", + "Incorporated by bees", + "[redacted]", + "Certified <a href=\"https://datatracker.ietf.org/doc/html/rfc9225\">RFC 9225</a> compliant.", + "JS free since always", + "Thank you for visiting the", + "footer text", + "If problems occur, stop doing the bad", + ":(){ :|: & };:", + "Approved by russian hackers", + "Shake well", + "Help im stuck inside footer text!", + "abort: no x11 display server found", +]; diff --git a/src/web/third_party/parsedown.php b/src/web/third_party/parsedown.php new file mode 100644 index 0000000..4d60658 --- /dev/null +++ b/src/web/third_party/parsedown.php @@ -0,0 +1,1995 @@ +<?php + +# +# +# Parsedown +# http://parsedown.org +# +# (c) Emanuil Rusev +# http://erusev.com +# +# For the full license information, view the LICENSE file that was distributed +# with this source code. +# +# + +class Parsedown +{ + # ~ + + const version = '1.8.0-beta-7'; + + # ~ + + function text($text) + { + $Elements = $this->textElements($text); + + # convert to markup + $markup = $this->elements($Elements); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + protected function textElements($text) + { + # make sure no definitions are set + $this->DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + # remove surrounding line breaks + $text = trim($text, "\n"); + + # split text into lines + $lines = explode("\n", $text); + + # iterate through lines to identify blocks + return $this->linesElements($lines); + } + + # + # Setters + # + + function setBreaksEnabled($breaksEnabled) + { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + function setMarkupEscaped($markupEscaped) + { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + function setUrlsLinked($urlsLinked) + { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + function setSafeMode($safeMode) + { + $this->safeMode = (bool) $safeMode; + + return $this; + } + + protected $safeMode; + + function setStrictMode($strictMode) + { + $this->strictMode = (bool) $strictMode; + + return $this; + } + + protected $strictMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'tel:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) + { + return $this->elements($this->linesElements($lines)); + } + + protected function linesElements(array $lines) + { + $Elements = array(); + $CurrentBlock = null; + + foreach ($lines as $line) + { + if (chop($line) === '') + { + if (isset($CurrentBlock)) + { + $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted']) + ? $CurrentBlock['interrupted'] + 1 : 1 + ); + } + + continue; + } + + while (($beforeTab = strstr($line, "\t", true)) !== false) + { + $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; + + $line = $beforeTab + . str_repeat(' ', $shortage) + . substr($line, strlen($beforeTab) + 1) + ; + } + + $indent = strspn($line, ' '); + + $text = $indent > 0 ? substr($line, $indent) : $line; + + # ~ + + $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); + + # ~ + + if (isset($CurrentBlock['continuable'])) + { + $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; + $Block = $this->$methodName($Line, $CurrentBlock); + + if (isset($Block)) + { + $CurrentBlock = $Block; + + continue; + } + else + { + if ($this->isBlockCompletable($CurrentBlock['type'])) + { + $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; + $CurrentBlock = $this->$methodName($CurrentBlock); + } + } + } + + # ~ + + $marker = $text[0]; + + # ~ + + $blockTypes = $this->unmarkedBlockTypes; + + if (isset($this->BlockTypes[$marker])) + { + foreach ($this->BlockTypes[$marker] as $blockType) + { + $blockTypes []= $blockType; + } + } + + # + # ~ + + foreach ($blockTypes as $blockType) + { + $Block = $this->{"block$blockType"}($Line, $CurrentBlock); + + if (isset($Block)) + { + $Block['type'] = $blockType; + + if ( ! isset($Block['identified'])) + { + if (isset($CurrentBlock)) + { + $Elements[] = $this->extractElement($CurrentBlock); + } + + $Block['identified'] = true; + } + + if ($this->isBlockContinuable($blockType)) + { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') + { + $Block = $this->paragraphContinue($Line, $CurrentBlock); + } + + if (isset($Block)) + { + $CurrentBlock = $Block; + } + else + { + if (isset($CurrentBlock)) + { + $Elements[] = $this->extractElement($CurrentBlock); + } + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) + { + $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; + $CurrentBlock = $this->$methodName($CurrentBlock); + } + + # ~ + + if (isset($CurrentBlock)) + { + $Elements[] = $this->extractElement($CurrentBlock); + } + + # ~ + + return $Elements; + } + + protected function extractElement(array $Component) + { + if ( ! isset($Component['element'])) + { + if (isset($Component['markup'])) + { + $Component['element'] = array('rawHtml' => $Component['markup']); + } + elseif (isset($Component['hidden'])) + { + $Component['element'] = array(); + } + } + + return $Component['element']; + } + + protected function isBlockContinuable($Type) + { + return method_exists($this, 'block' . $Type . 'Continue'); + } + + protected function isBlockCompletable($Type) + { + return method_exists($this, 'block' . $Type . 'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) + { + if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) + { + return; + } + + if ($Line['indent'] >= 4) + { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) + { + if ($Line['indent'] >= 4) + { + if (isset($Block['interrupted'])) + { + $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); + + unset($Block['interrupted']); + } + + $Block['element']['element']['text'] .= "\n"; + + $text = substr($Line['body'], 4); + + $Block['element']['element']['text'] .= $text; + + return $Block; + } + } + + protected function blockCodeComplete($Block) + { + return $Block; + } + + # + # Comment + + protected function blockComment($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (strpos($Line['text'], '<!--') === 0) + { + $Block = array( + 'element' => array( + 'rawHtml' => $Line['body'], + 'autobreak' => true, + ), + ); + + if (strpos($Line['text'], '-->') !== false) + { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + $Block['element']['rawHtml'] .= "\n" . $Line['body']; + + if (strpos($Line['text'], '-->') !== false) + { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) + { + $marker = $Line['text'][0]; + + $openerLength = strspn($Line['text'], $marker); + + if ($openerLength < 3) + { + return; + } + + $infostring = trim(substr($Line['text'], $openerLength), "\t "); + + if (strpos($infostring, '`') !== false) + { + return; + } + + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if ($infostring !== '') + { + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r")); + + $Element['attributes'] = array('class' => "language-$language"); + } + + $Block = array( + 'char' => $marker, + 'openerLength' => $openerLength, + 'element' => array( + 'name' => 'pre', + 'element' => $Element, + ), + ); + + return $Block; + } + + protected function blockFencedCodeContinue($Line, $Block) + { + if (isset($Block['complete'])) + { + return; + } + + if (isset($Block['interrupted'])) + { + $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); + + unset($Block['interrupted']); + } + + if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength'] + and chop(substr($Line['text'], $len), ' ') === '' + ) { + $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1); + + $Block['complete'] = true; + + return $Block; + } + + $Block['element']['element']['text'] .= "\n" . $Line['body']; + + return $Block; + } + + protected function blockFencedCodeComplete($Block) + { + return $Block; + } + + # + # Header + + protected function blockHeader($Line) + { + $level = strspn($Line['text'], '#'); + + if ($level > 6) + { + return; + } + + $text = trim($Line['text'], '#'); + + if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') + { + return; + } + + $text = trim($text, ' '); + + $Block = array( + 'element' => array( + 'name' => 'h' . $level, + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $text, + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + + # + # List + + protected function blockList($Line, array $CurrentBlock = null) + { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]'); + + if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) + { + $contentIndent = strlen($matches[2]); + + if ($contentIndent >= 5) + { + $contentIndent -= 1; + $matches[1] = substr($matches[1], 0, -$contentIndent); + $matches[3] = str_repeat(' ', $contentIndent) . $matches[3]; + } + elseif ($contentIndent === 0) + { + $matches[1] .= ' '; + } + + $markerWithoutWhitespace = strstr($matches[1], ' ', true); + + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'data' => array( + 'type' => $name, + 'marker' => $matches[1], + 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)), + ), + 'element' => array( + 'name' => $name, + 'elements' => array(), + ), + ); + $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/'); + + if ($name === 'ol') + { + $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0'; + + if ($listStart !== '1') + { + if ( + isset($CurrentBlock) + and $CurrentBlock['type'] === 'Paragraph' + and ! isset($CurrentBlock['interrupted']) + ) { + return; + } + + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => !empty($matches[3]) ? array($matches[3]) : array(), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] []= & $Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) + { + if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) + { + return null; + } + + $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker'])); + + if ($Line['indent'] < $requiredIndent + and ( + ( + $Block['data']['type'] === 'ol' + and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) + ) or ( + $Block['data']['type'] === 'ul' + and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) + ) + ) + ) { + if (isset($Block['interrupted'])) + { + $Block['li']['handler']['argument'] []= ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + unset($Block['li']); + + $text = isset($matches[1]) ? $matches[1] : ''; + + $Block['indent'] = $Line['indent']; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => array($text), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] []= & $Block['li']; + + return $Block; + } + elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) + { + return null; + } + + if ($Line['text'][0] === '[' and $this->blockReference($Line)) + { + return $Block; + } + + if ($Line['indent'] >= $requiredIndent) + { + if (isset($Block['interrupted'])) + { + $Block['li']['handler']['argument'] []= ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + $text = substr($Line['body'], $requiredIndent); + + $Block['li']['handler']['argument'] []= $text; + + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']); + + $Block['li']['handler']['argument'] []= $text; + + return $Block; + } + } + + protected function blockListComplete(array $Block) + { + if (isset($Block['loose'])) + { + foreach ($Block['element']['elements'] as &$li) + { + if (end($li['handler']['argument']) !== '') + { + $li['handler']['argument'] []= ''; + } + } + } + + return $Block; + } + + # + # Quote + + protected function blockQuote($Line) + { + if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) + { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => array( + 'function' => 'linesElements', + 'argument' => (array) $matches[1], + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) + { + return; + } + + if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) + { + $Block['element']['handler']['argument'] []= $matches[1]; + + return $Block; + } + + if ( ! isset($Block['interrupted'])) + { + $Block['element']['handler']['argument'] []= $Line['text']; + + return $Block; + } + } + + # + # Rule + + protected function blockRule($Line) + { + $marker = $Line['text'][0]; + + if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') + { + $Block = array( + 'element' => array( + 'name' => 'hr', + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, array $Block = null) + { + if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) + { + return; + } + + if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') + { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) + { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) + { + return; + } + + $Block = array( + 'name' => $matches[1], + 'element' => array( + 'rawHtml' => $Line['text'], + 'autobreak' => true, + ), + ); + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed']) or isset($Block['interrupted'])) + { + return; + } + + $Block['element']['rawHtml'] .= "\n" . $Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) + { + if (strpos($Line['text'], ']') !== false + and preg_match('/^\[(.+?)\]:[ ]*+<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches) + ) { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => isset($matches[3]) ? $matches[3] : null, + ); + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'element' => array(), + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, array $Block = null) + { + if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) + { + return; + } + + if ( + strpos($Block['element']['handler']['argument'], '|') === false + and strpos($Line['text'], '|') === false + and strpos($Line['text'], ':') === false + or strpos($Block['element']['handler']['argument'], "\n") !== false + ) { + return; + } + + if (chop($Line['text'], ' -:|') !== '') + { + return; + } + + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) + { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') + { + return; + } + + $alignment = null; + + if ($dividerCell[0] === ':') + { + $alignment = 'left'; + } + + if (substr($dividerCell, - 1) === ':') + { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments []= $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['handler']['argument']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + if (count($headerCells) !== count($alignments)) + { + return; + } + + foreach ($headerCells as $index => $headerCell) + { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $headerCell, + 'destination' => 'elements', + ) + ); + + if (isset($alignments[$index])) + { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => "text-align: $alignment;", + ); + } + + $HeaderElements []= $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'elements' => array(), + ), + ); + + $Block['element']['elements'] []= array( + 'name' => 'thead', + ); + + $Block['element']['elements'] []= array( + 'name' => 'tbody', + 'elements' => array(), + ); + + $Block['element']['elements'][0]['elements'] []= array( + 'name' => 'tr', + 'elements' => $HeaderElements, + ); + + return $Block; + } + + protected function blockTableContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) + { + return; + } + + if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) + { + $Elements = array(); + + $row = $Line['text']; + + $row = trim($row); + $row = trim($row, '|'); + + preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches); + + $cells = array_slice($matches[0], 0, count($Block['alignments'])); + + foreach ($cells as $index => $cell) + { + $cell = trim($cell); + + $Element = array( + 'name' => 'td', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $cell, + 'destination' => 'elements', + ) + ); + + if (isset($Block['alignments'][$index])) + { + $Element['attributes'] = array( + 'style' => 'text-align: ' . $Block['alignments'][$index] . ';', + ); + } + + $Elements []= $Element; + } + + $Element = array( + 'name' => 'tr', + 'elements' => $Elements, + ); + + $Block['element']['elements'][1]['elements'] []= $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) + { + return array( + 'type' => 'Paragraph', + 'element' => array( + 'name' => 'p', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $Line['text'], + 'destination' => 'elements', + ), + ), + ); + } + + protected function paragraphContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) + { + return; + } + + $Block['element']['handler']['argument'] .= "\n".$Line['text']; + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!*_&[:<`~\\'; + + # + # ~ + # + + public function line($text, $nonNestables = array()) + { + return $this->elements($this->lineElements($text, $nonNestables)); + } + + protected function lineElements($text, $nonNestables = array()) + { + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + $Elements = array(); + + $nonNestables = (empty($nonNestables) + ? array() + : array_combine($nonNestables, $nonNestables) + ); + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) + { + $marker = $excerpt[0]; + + $markerPosition = strlen($text) - strlen($excerpt); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + foreach ($this->InlineTypes[$marker] as $inlineType) + { + # check to see if the current inline type is nestable in the current context + + if (isset($nonNestables[$inlineType])) + { + continue; + } + + $Inline = $this->{"inline$inlineType"}($Excerpt); + + if ( ! isset($Inline)) + { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $Inline['position'] > $markerPosition) + { + continue; + } + + # sets a default inline position + + if ( ! isset($Inline['position'])) + { + $Inline['position'] = $markerPosition; + } + + # cause the new element to 'inherit' our non nestables + + + $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables']) + ? array_merge($Inline['element']['nonNestables'], $nonNestables) + : $nonNestables + ; + + # the text that comes before the inline + $unmarkedText = substr($text, 0, $Inline['position']); + + # compile the unmarked text + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + # compile the inline + $Elements[] = $this->extractElement($Inline); + + # remove the examined text + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + # the marker does not belong to an inline + + $unmarkedText = substr($text, 0, $markerPosition + 1); + + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + $text = substr($text, $markerPosition + 1); + } + + $InlineText = $this->inlineText($text); + $Elements[] = $InlineText['element']; + + foreach ($Elements as &$Element) + { + if ( ! isset($Element['autobreak'])) + { + $Element['autobreak'] = false; + } + } + + return $Elements; + } + + # + # ~ + # + + protected function inlineText($text) + { + $Inline = array( + 'extent' => strlen($text), + 'element' => array(), + ); + + $Inline['element']['elements'] = self::pregReplaceElements( + $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', + array( + array('name' => 'br'), + array('text' => "\n"), + ), + $text + ); + + return $Inline; + } + + protected function inlineCode($Excerpt) + { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(?<!['.$marker.'])\1(?!'.$marker.')/s', $Excerpt['text'], $matches)) + { + $text = $matches[2]; + $text = preg_replace('/[ ]*+\n/', ' ', $text); + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) + { + $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; + + $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' + . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*'; + + if (strpos($Excerpt['text'], '>') !== false + and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches) + ){ + $url = $matches[1]; + + if ( ! isset($matches[2])) + { + $url = "mailto:$url"; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'strong'; + } + elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) + { + $emphasis = 'em'; + } + else + { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) + { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) + { + return array( + 'element' => array('rawHtml' => $Excerpt['text'][1]), + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) + { + if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') + { + return; + } + + $Excerpt['text']= substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) + { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + 'src' => $Link['element']['attributes']['href'], + 'alt' => $Link['element']['handler']['argument'], + ), + 'autobreak' => true, + ), + ); + + $Inline['element']['attributes'] += $Link['element']['attributes']; + + unset($Inline['element']['attributes']['href']); + + return $Inline; + } + + protected function inlineLink($Excerpt) + { + $Element = array( + 'name' => 'a', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => null, + 'destination' => 'elements', + ), + 'nonNestables' => array('Url', 'Link'), + 'attributes' => array( + 'href' => null, + 'title' => null, + ), + ); + + $extent = 0; + + $remainder = $Excerpt['text']; + + if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) + { + $Element['handler']['argument'] = $matches[1]; + + $extent += strlen($matches[0]); + + $remainder = substr($remainder, $extent); + } + else + { + return; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) + { + $Element['attributes']['href'] = $matches[1]; + + if (isset($matches[2])) + { + $Element['attributes']['title'] = substr($matches[2], 1, - 1); + } + + $extent += strlen($matches[0]); + } + else + { + if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) + { + $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument']; + $definition = strtolower($definition); + + $extent += strlen($matches[0]); + } + else + { + $definition = strtolower($Element['handler']['argument']); + } + + if ( ! isset($this->DefinitionData['Reference'][$definition])) + { + return; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) + { + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) + { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) + { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?+[^-])*-->/s', $Excerpt['text'], $matches)) + { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) + { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) + { + if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false + and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches) + ) { + return array( + 'element' => array('rawHtml' => '&' . $matches[1] . ';'), + 'extent' => strlen($matches[0]), + ); + } + + return; + } + + protected function inlineStrikethrough($Excerpt) + { + if ( ! isset($Excerpt['text'][1])) + { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) + { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + } + + protected function inlineUrl($Excerpt) + { + if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') + { + return; + } + + if (strpos($Excerpt['context'], 'http') !== false + and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE) + ) { + $url = $matches[0][0]; + + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) + { + $url = $matches[1]; + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) + { + $Inline = $this->inlineText($text); + return $this->element($Inline['element']); + } + + # + # Handlers + # + + protected function handle(array $Element) + { + if (isset($Element['handler'])) + { + if (!isset($Element['nonNestables'])) + { + $Element['nonNestables'] = array(); + } + + if (is_string($Element['handler'])) + { + $function = $Element['handler']; + $argument = $Element['text']; + unset($Element['text']); + $destination = 'rawHtml'; + } + else + { + $function = $Element['handler']['function']; + $argument = $Element['handler']['argument']; + $destination = $Element['handler']['destination']; + } + + $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']); + + if ($destination === 'handler') + { + $Element = $this->handle($Element); + } + + unset($Element['handler']); + } + + return $Element; + } + + protected function handleElementRecursive(array $Element) + { + return $this->elementApplyRecursive(array($this, 'handle'), $Element); + } + + protected function handleElementsRecursive(array $Elements) + { + return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); + } + + protected function elementApplyRecursive($closure, array $Element) + { + $Element = call_user_func($closure, $Element); + + if (isset($Element['elements'])) + { + $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); + } + elseif (isset($Element['element'])) + { + $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); + } + + return $Element; + } + + protected function elementApplyRecursiveDepthFirst($closure, array $Element) + { + if (isset($Element['elements'])) + { + $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); + } + elseif (isset($Element['element'])) + { + $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); + } + + $Element = call_user_func($closure, $Element); + + return $Element; + } + + protected function elementsApplyRecursive($closure, array $Elements) + { + foreach ($Elements as &$Element) + { + $Element = $this->elementApplyRecursive($closure, $Element); + } + + return $Elements; + } + + protected function elementsApplyRecursiveDepthFirst($closure, array $Elements) + { + foreach ($Elements as &$Element) + { + $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); + } + + return $Elements; + } + + protected function element(array $Element) + { + if ($this->safeMode) + { + $Element = $this->sanitiseElement($Element); + } + + # identity map if element has no handler + $Element = $this->handle($Element); + + $hasName = isset($Element['name']); + + $markup = ''; + + if ($hasName) + { + $markup .= '<' . $Element['name']; + + if (isset($Element['attributes'])) + { + foreach ($Element['attributes'] as $name => $value) + { + if ($value === null) + { + continue; + } + + $markup .= " $name=\"".self::escape($value).'"'; + } + } + } + + $permitRawHtml = false; + + if (isset($Element['text'])) + { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) + { + $text = $Element['rawHtml']; + + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']); + + if ($hasContent) + { + $markup .= $hasName ? '>' : ''; + + if (isset($Element['elements'])) + { + $markup .= $this->elements($Element['elements']); + } + elseif (isset($Element['element'])) + { + $markup .= $this->element($Element['element']); + } + else + { + if (!$permitRawHtml) + { + $markup .= self::escape($text, true); + } + else + { + $markup .= $text; + } + } + + $markup .= $hasName ? '</' . $Element['name'] . '>' : ''; + } + elseif ($hasName) + { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) + { + $markup = ''; + + $autoBreak = true; + + foreach ($Elements as $Element) + { + if (empty($Element)) + { + continue; + } + + $autoBreakNext = (isset($Element['autobreak']) + ? $Element['autobreak'] : isset($Element['name']) + ); + // (autobreak === false) covers both sides of an element + $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext; + + $markup .= ($autoBreak ? "\n" : '') . $this->element($Element); + $autoBreak = $autoBreakNext; + } + + $markup .= $autoBreak ? "\n" : ''; + + return $markup; + } + + # ~ + + protected function li($lines) + { + $Elements = $this->linesElements($lines); + + if ( ! in_array('', $lines) + and isset($Elements[0]) and isset($Elements[0]['name']) + and $Elements[0]['name'] === 'p' + ) { + unset($Elements[0]['name']); + } + + return $Elements; + } + + # + # AST Convenience + # + + /** + * Replace occurrences $regexp with $Elements in $text. Return an array of + * elements representing the replacement. + */ + protected static function pregReplaceElements($regexp, $Elements, $text) + { + $newElements = array(); + + while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) + { + $offset = $matches[0][1]; + $before = substr($text, 0, $offset); + $after = substr($text, $offset + strlen($matches[0][0])); + + $newElements[] = array('text' => $before); + + foreach ($Elements as $Element) + { + $newElements[] = $Element; + } + + $text = $after; + } + + $newElements[] = array('text' => $text); + + return $newElements; + } + + # + # Deprecated Methods + # + + function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if ( ! isset($Element['name'])) + { + unset($Element['attributes']); + return $Element; + } + + if (isset($safeUrlNameToAtt[$Element['name']])) + { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if ( ! empty($Element['attributes'])) + { + foreach ($Element['attributes'] as $att => $val) + { + # filter out badly parsed attribute + if ( ! preg_match($goodAttribute, $att)) + { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) + { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) + { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) + { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) + { + return false; + } + else + { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + static function instance($name = 'default') + { + if (isset(self::$instances[$name])) + { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~' + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} + diff --git a/src/web/third_party/parsedown_extra.php b/src/web/third_party/parsedown_extra.php new file mode 100644 index 0000000..8cdb5d2 --- /dev/null +++ b/src/web/third_party/parsedown_extra.php @@ -0,0 +1,686 @@ +<?php + +# +# +# Parsedown Extra +# https://github.com/erusev/parsedown-extra +# +# (c) Emanuil Rusev +# http://erusev.com +# +# For the full license information, view the LICENSE file that was distributed +# with this source code. +# +# + +class ParsedownExtra extends Parsedown +{ + # ~ + + const version = '0.8.0'; + + # ~ + + function __construct() + { + if (version_compare(parent::version, '1.7.1') < 0) + { + throw new Exception('ParsedownExtra requires a later version of Parsedown'); + } + + $this->BlockTypes[':'] []= 'DefinitionList'; + $this->BlockTypes['*'] []= 'Abbreviation'; + + # identify footnote definitions before reference definitions + array_unshift($this->BlockTypes['['], 'Footnote'); + + # identify footnote markers before before links + array_unshift($this->InlineTypes['['], 'FootnoteMarker'); + } + + # + # ~ + + function text($text) + { + $Elements = $this->textElements($text); + + # convert to markup + $markup = $this->elements($Elements); + + # trim line breaks + $markup = trim($markup, "\n"); + + # merge consecutive dl elements + + $markup = preg_replace('/<\/dl>\s+<dl>\s+/', '', $markup); + + # add footnotes + + if (isset($this->DefinitionData['Footnote'])) + { + $Element = $this->buildFootnoteElement(); + + $markup .= "\n" . $this->element($Element); + } + + return $markup; + } + + # + # Blocks + # + + # + # Abbreviation + + protected function blockAbbreviation($Line) + { + if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches)) + { + $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2]; + + $Block = array( + 'hidden' => true, + ); + + return $Block; + } + } + + # + # Footnote + + protected function blockFootnote($Line) + { + if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches)) + { + $Block = array( + 'label' => $matches[1], + 'text' => $matches[2], + 'hidden' => true, + ); + + return $Block; + } + } + + protected function blockFootnoteContinue($Line, $Block) + { + if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text'])) + { + return; + } + + if (isset($Block['interrupted'])) + { + if ($Line['indent'] >= 4) + { + $Block['text'] .= "\n\n" . $Line['text']; + + return $Block; + } + } + else + { + $Block['text'] .= "\n" . $Line['text']; + + return $Block; + } + } + + protected function blockFootnoteComplete($Block) + { + $this->DefinitionData['Footnote'][$Block['label']] = array( + 'text' => $Block['text'], + 'count' => null, + 'number' => null, + ); + + return $Block; + } + + # + # Definition List + + protected function blockDefinitionList($Line, $Block) + { + if ( ! isset($Block) or $Block['type'] !== 'Paragraph') + { + return; + } + + $Element = array( + 'name' => 'dl', + 'elements' => array(), + ); + + $terms = explode("\n", $Block['element']['handler']['argument']); + + foreach ($terms as $term) + { + $Element['elements'] []= array( + 'name' => 'dt', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $term, + 'destination' => 'elements' + ), + ); + } + + $Block['element'] = $Element; + + $Block = $this->addDdElement($Line, $Block); + + return $Block; + } + + protected function blockDefinitionListContinue($Line, array $Block) + { + if ($Line['text'][0] === ':') + { + $Block = $this->addDdElement($Line, $Block); + + return $Block; + } + else + { + if (isset($Block['interrupted']) and $Line['indent'] === 0) + { + return; + } + + if (isset($Block['interrupted'])) + { + $Block['dd']['handler']['function'] = 'textElements'; + $Block['dd']['handler']['argument'] .= "\n\n"; + + $Block['dd']['handler']['destination'] = 'elements'; + + unset($Block['interrupted']); + } + + $text = substr($Line['body'], min($Line['indent'], 4)); + + $Block['dd']['handler']['argument'] .= "\n" . $text; + + return $Block; + } + } + + # + # Header + + protected function blockHeader($Line) + { + $Block = parent::blockHeader($Line); + + if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) + { + $attributeString = $matches[1][0]; + + $Block['element']['attributes'] = $this->parseAttributeData($attributeString); + + $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]); + } + + return $Block; + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) + { + return; + } + + if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) + { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) + { + return; + } + + $Block = array( + 'name' => $matches[1], + 'depth' => 0, + 'element' => array( + 'rawHtml' => $Line['text'], + 'autobreak' => true, + ), + ); + + $length = strlen($matches[0]); + $remainder = substr($Line['text'], $length); + + if (trim($remainder) === '') + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + $Block['closed'] = true; + $Block['void'] = true; + } + } + else + { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) + { + return; + } + if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) + { + $Block['closed'] = true; + } + } + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed'])) + { + return; + } + + if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open + { + $Block['depth'] ++; + } + + if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close + { + if ($Block['depth'] > 0) + { + $Block['depth'] --; + } + else + { + $Block['closed'] = true; + } + } + + if (isset($Block['interrupted'])) + { + $Block['element']['rawHtml'] .= "\n"; + unset($Block['interrupted']); + } + + $Block['element']['rawHtml'] .= "\n".$Line['body']; + + return $Block; + } + + protected function blockMarkupComplete($Block) + { + if ( ! isset($Block['void'])) + { + $Block['element']['rawHtml'] = $this->processTag($Block['element']['rawHtml']); + } + + return $Block; + } + + # + # Setext + + protected function blockSetextHeader($Line, array $Block = null) + { + $Block = parent::blockSetextHeader($Line, $Block); + + if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) + { + $attributeString = $matches[1][0]; + + $Block['element']['attributes'] = $this->parseAttributeData($attributeString); + + $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]); + } + + return $Block; + } + + # + # Inline Elements + # + + # + # Footnote Marker + + protected function inlineFootnoteMarker($Excerpt) + { + if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches)) + { + $name = $matches[1]; + + if ( ! isset($this->DefinitionData['Footnote'][$name])) + { + return; + } + + $this->DefinitionData['Footnote'][$name]['count'] ++; + + if ( ! isset($this->DefinitionData['Footnote'][$name]['number'])) + { + $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # ยป & + } + + $Element = array( + 'name' => 'sup', + 'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name), + 'element' => array( + 'name' => 'a', + 'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'), + 'text' => $this->DefinitionData['Footnote'][$name]['number'], + ), + ); + + return array( + 'extent' => strlen($matches[0]), + 'element' => $Element, + ); + } + } + + private $footnoteCount = 0; + + # + # Link + + protected function inlineLink($Excerpt) + { + $Link = parent::inlineLink($Excerpt); + + $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : ''; + + if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches)) + { + $Link['element']['attributes'] += $this->parseAttributeData($matches[1]); + + $Link['extent'] += strlen($matches[0]); + } + + return $Link; + } + + # + # ~ + # + + private $currentAbreviation; + private $currentMeaning; + + protected function insertAbreviation(array $Element) + { + if (isset($Element['text'])) + { + $Element['elements'] = self::pregReplaceElements( + '/\b'.preg_quote($this->currentAbreviation, '/').'\b/', + array( + array( + 'name' => 'abbr', + 'attributes' => array( + 'title' => $this->currentMeaning, + ), + 'text' => $this->currentAbreviation, + ) + ), + $Element['text'] + ); + + unset($Element['text']); + } + + return $Element; + } + + protected function inlineText($text) + { + $Inline = parent::inlineText($text); + + if (isset($this->DefinitionData['Abbreviation'])) + { + foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning) + { + $this->currentAbreviation = $abbreviation; + $this->currentMeaning = $meaning; + + $Inline['element'] = $this->elementApplyRecursiveDepthFirst( + array($this, 'insertAbreviation'), + $Inline['element'] + ); + } + } + + return $Inline; + } + + # + # Util Methods + # + + protected function addDdElement(array $Line, array $Block) + { + $text = substr($Line['text'], 1); + $text = trim($text); + + unset($Block['dd']); + + $Block['dd'] = array( + 'name' => 'dd', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $text, + 'destination' => 'elements' + ), + ); + + if (isset($Block['interrupted'])) + { + $Block['dd']['handler']['function'] = 'textElements'; + + unset($Block['interrupted']); + } + + $Block['element']['elements'] []= & $Block['dd']; + + return $Block; + } + + protected function buildFootnoteElement() + { + $Element = array( + 'name' => 'div', + 'attributes' => array('class' => 'footnotes'), + 'elements' => array( + array('name' => 'hr'), + array( + 'name' => 'ol', + 'elements' => array(), + ), + ), + ); + + uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes'); + + foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData) + { + if ( ! isset($DefinitionData['number'])) + { + continue; + } + + $text = $DefinitionData['text']; + + $textElements = parent::textElements($text); + + $numbers = range(1, $DefinitionData['count']); + + $backLinkElements = array(); + + foreach ($numbers as $number) + { + $backLinkElements[] = array('text' => ' '); + $backLinkElements[] = array( + 'name' => 'a', + 'attributes' => array( + 'href' => "#fnref$number:$definitionId", + 'rev' => 'footnote', + 'class' => 'footnote-backref', + ), + 'rawHtml' => '↩', + 'allowRawHtmlInSafeMode' => true, + 'autobreak' => false, + ); + } + + unset($backLinkElements[0]); + + $n = count($textElements) -1; + + if ($textElements[$n]['name'] === 'p') + { + $backLinkElements = array_merge( + array( + array( + 'rawHtml' => ' ', + 'allowRawHtmlInSafeMode' => true, + ), + ), + $backLinkElements + ); + + unset($textElements[$n]['name']); + + $textElements[$n] = array( + 'name' => 'p', + 'elements' => array_merge( + array($textElements[$n]), + $backLinkElements + ), + ); + } + else + { + $textElements[] = array( + 'name' => 'p', + 'elements' => $backLinkElements + ); + } + + $Element['elements'][1]['elements'] []= array( + 'name' => 'li', + 'attributes' => array('id' => 'fn:'.$definitionId), + 'elements' => array_merge( + $textElements + ), + ); + } + + return $Element; + } + + # ~ + + protected function parseAttributeData($attributeString) + { + $Data = array(); + + $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY); + + foreach ($attributes as $attribute) + { + if ($attribute[0] === '#') + { + $Data['id'] = substr($attribute, 1); + } + else # "." + { + $classes []= substr($attribute, 1); + } + } + + if (isset($classes)) + { + $Data['class'] = implode(' ', $classes); + } + + return $Data; + } + + # ~ + + protected function processTag($elementMarkup) # recursive + { + # http://stackoverflow.com/q/1148928/200145 + libxml_use_internal_errors(true); + + $DOMDocument = new DOMDocument; + + # http://stackoverflow.com/q/11309194/200145 + $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8'); + + # http://stackoverflow.com/q/4879946/200145 + $DOMDocument->loadHTML($elementMarkup); + $DOMDocument->removeChild($DOMDocument->doctype); + $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild); + + $elementText = ''; + + if ($DOMDocument->documentElement->getAttribute('markdown') === '1') + { + foreach ($DOMDocument->documentElement->childNodes as $Node) + { + $elementText .= $DOMDocument->saveHTML($Node); + } + + $DOMDocument->documentElement->removeAttribute('markdown'); + + $elementText = "\n".$this->text($elementText)."\n"; + } + else + { + foreach ($DOMDocument->documentElement->childNodes as $Node) + { + $nodeMarkup = $DOMDocument->saveHTML($Node); + + if ($Node instanceof DOMElement and ! in_array($Node->nodeName, $this->textLevelElements)) + { + $elementText .= $this->processTag($nodeMarkup); + } + else + { + $elementText .= $nodeMarkup; + } + } + } + + # because we don't want for markup to get encoded + $DOMDocument->documentElement->nodeValue = 'placeholder\x1A'; + + $markup = $DOMDocument->saveHTML($DOMDocument->documentElement); + $markup = str_replace('placeholder\x1A', $elementText, $markup); + + return $markup; + } + + # ~ + + protected function sortFootnotes($A, $B) # callback + { + return $A['number'] - $B['number']; + } + + # + # Fields + # + + protected $regexAttribute = '(?:[#.][-\w]+[ ]*)'; +} |